From 2ec674fbf35778006b957bda5bacec3d31280935 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 9 Oct 2021 13:41:48 +0200 Subject: [PATCH 001/592] Fixed node announce interval --- nomadnet/Node.py | 4 ++++ nomadnet/_version.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 1be51ab..ad46b76 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -31,6 +31,10 @@ class Node: if self.app.node_announce_at_start: self.announce() + job_thread = threading.Thread(target=self.__jobs) + job_thread.setDaemon(True) + job_thread.start() + def register_pages(self): self.servedpages = [] diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 48fef32..acf3be3 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.2" \ No newline at end of file +__version__ = "0.1.3" \ No newline at end of file From 71125252ee54c4358ffbf61c32f79360f4477662 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 9 Oct 2021 23:25:10 +0200 Subject: [PATCH 002/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5c967b5..1f1df4a 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.2.7', 'lxmf>=0.1.0', 'urwid>=2.1.2'], + install_requires=['rns>=0.2.8', 'lxmf>=0.1.1', 'urwid>=2.1.2'], python_requires='>=3.6', ) \ No newline at end of file From 33ccab09075dca1eb15fe2090ea82a4c6822ac0e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 10 Oct 2021 00:04:10 +0200 Subject: [PATCH 003/592] Added LXMF peers section to network view --- nomadnet/ui/textui/Network.py | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 2665c22..70fa48f 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1022,6 +1022,9 @@ class NetworkLeftPile(urwid.Pile): def keypress(self, size, key): if key == "ctrl l": self.parent.toggle_list() + elif key == "ctrl p": + self.parent.reinit_lxmf_peers() + self.parent.show_peers() elif key == "ctrl w": self.parent.browser.disconnect() elif key == "ctrl u": @@ -1043,6 +1046,7 @@ class NetworkDisplay(): self.browser.loopback = self.app.node.destination.hash self.known_nodes_display = KnownNodes(self.app) + self.lxmf_peers_display = LXMFPeers(self.app) self.network_stats_display = NetworkStats(self.app, self) self.announce_stream_display = AnnounceStream(self.app, self) self.local_peer_display = LocalPeer(self.app, self) @@ -1084,6 +1088,15 @@ class NetworkDisplay(): self.left_pile.contents[0] = (self.known_nodes_display, options) self.list_display = 1 + def show_peers(self): + options = self.left_pile.options(height_type="weight", height_amount=1) + self.left_pile.contents[0] = (self.lxmf_peers_display, options) + + if self.list_display != 0: + self.list_display = 0 + else: + self.list_display = 1 + def focus_lists(self): self.columns.focus_position = 0 @@ -1093,6 +1106,11 @@ class NetworkDisplay(): self.close_list_dialogs() self.announce_stream_display.rebuild_widget_list() + def reinit_lxmf_peers(self): + self.lxmf_peers_display = LXMFPeers(self.app) + self.lxmf_peers_display.delegate = self + self.close_list_dialogs() + def close_list_dialogs(self): if self.list_display == 0: options = self.left_pile.options(height_type="weight", height_amount=1) @@ -1121,6 +1139,95 @@ class NetworkDisplay(): self.known_nodes_display.rebuild_widget_list() +class LXMFPeers(urwid.WidgetWrap): + def __init__(self, app): + self.app = app + self.peer_list = app.message_router.peers + # self.peer_list = {} + + g = self.app.ui.glyphs + + self.widget_list = self.make_peer_widgets() + + self.ilb = IndicativeListBox( + self.widget_list, + on_selection_change=self.node_list_selection, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus" + ) + + if len(self.peer_list) > 0: + self.display_widget = self.ilb + widget_style = None + self.no_content = False + else: + self.no_content = True + widget_style = "inactive_text" + self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no LXMF nodes are peered\n\n"), align="center")]) + self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") + + urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="LXMF Peers"), widget_style)) + + def keypress(self, size, key): + if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + elif key == "ctrl x": + self.delete_selected_entry() + + return super(LXMFPeers, self).keypress(size, key) + + + def node_list_selection(self, arg1, arg2): + pass + + def delete_selected_entry(self): + destination_hash = self.ilb.get_selected_item().original_widget.destination_hash + self.app.message_router.unpeer(destination_hash) + self.delegate.reinit_lxmf_peers() + self.delegate.show_peers() + + + def rebuild_widget_list(self): + self.peer_list = self.app.message_router.peers + self.widget_list = self.make_peer_widgets() + self.ilb.set_body(self.widget_list) + if len(self.widget_list) > 0: + self.no_content = False + else: + self.no_content = True + self.delegate.reinit_lxmf_peers() + + def make_peer_widgets(self): + widget_list = [] + for peer_id in self.peer_list: + peer = self.peer_list[peer_id] + pe = LXMFPeerEntry(self.app, peer, self) + pe.destination_hash = peer.destination_hash + widget_list.append(pe) + + # TODO: Sort list + return widget_list + +class LXMFPeerEntry(urwid.WidgetWrap): + def __init__(self, app, peer, delegate): + destination_hash = peer.destination_hash + + self.app = app + g = self.app.ui.glyphs + + display_str = RNS.prettyhexrep(destination_hash) + + sym = g["sent"] + style = "list_unknown" + focus_style = "list_focus" + + widget = ListEntry(sym+" "+display_str+"\n Last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled") + # urwid.connect_signal(widget, "click", delegate.connect_node, node) + + self.display_widget = urwid.AttrMap(widget, style, focus_style) + self.display_widget.destination_hash = destination_hash + urwid.WidgetWrap.__init__(self, self.display_widget) + def pretty_date(time=False): """ From 313cc108de5b7c6805501c3f66ce80d8b043bc26 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 10 Oct 2021 00:09:43 +0200 Subject: [PATCH 004/592] Added selection check --- nomadnet/ui/textui/Network.py | 48 +++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 70fa48f..6348611 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -576,30 +576,32 @@ class KnownNodes(urwid.WidgetWrap): self.delegate.left_pile.contents[0] = (overlay, options) def delete_selected_entry(self): - source_hash = self.ilb.get_selected_item().original_widget.source_hash + si = self.ilb.get_selected_item() + if si != None: + source_hash = si.original_widget.source_hash - def dismiss_dialog(sender): - self.delegate.close_list_dialogs() + def dismiss_dialog(sender): + self.delegate.close_list_dialogs() - def confirmed(sender): - self.app.directory.forget(source_hash) - self.rebuild_widget_list() - self.delegate.close_list_dialogs() + def confirmed(sender): + self.app.directory.forget(source_hash) + self.rebuild_widget_list() + self.delegate.close_list_dialogs() - dialog = ListDialogLineBox( - urwid.Pile([ - urwid.Text("Delete Node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align="center"), - urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) - ]), title="?" - ) - dialog.delegate = self.delegate - bottom = self + dialog = ListDialogLineBox( + urwid.Pile([ + urwid.Text("Delete Node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align="center"), + urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) + ]), title="?" + ) + dialog.delegate = self.delegate + bottom = self - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) - options = self.delegate.left_pile.options("weight", 1) - self.delegate.left_pile.contents[0] = (overlay, options) + options = self.delegate.left_pile.options("weight", 1) + self.delegate.left_pile.contents[0] = (overlay, options) def rebuild_widget_list(self): @@ -1181,10 +1183,12 @@ class LXMFPeers(urwid.WidgetWrap): pass def delete_selected_entry(self): - destination_hash = self.ilb.get_selected_item().original_widget.destination_hash - self.app.message_router.unpeer(destination_hash) - self.delegate.reinit_lxmf_peers() - self.delegate.show_peers() + si = self.ilb.get_selected_item() + if si != None: + destination_hash = si.original_widget.destination_hash + self.app.message_router.unpeer(destination_hash) + self.delegate.reinit_lxmf_peers() + self.delegate.show_peers() def rebuild_widget_list(self): From 2ec95df3ec512458ae425c33635a399e7ba55d03 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 10 Oct 2021 13:50:57 +0200 Subject: [PATCH 005/592] Fixed announce interval interpreted as seconds instead of minutes. --- nomadnet/Node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index ad46b76..ab10139 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -138,7 +138,7 @@ class Node: while self.should_run_jobs: now = time.time() - if now > self.last_announce + self.announce_interval: + if now > self.last_announce + self.announce_interval*60: self.announce() time.sleep(self.job_interval) From 863a6cd2cdd4c1d21ad97545c3d1202af598559d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 11 Oct 2021 15:25:29 +0200 Subject: [PATCH 006/592] Update README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 9db207c..7a8eaa1 100755 --- a/README.md +++ b/README.md @@ -49,6 +49,13 @@ The first time the program is running, you will be presented with the guide sect To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. +## Support Nomad Network +You can help support the continued development of open, free and private communications systems by donating via one of the following channels: + +- Ethereum: 0x81F7B979fEa6134bA9FD5c701b3501A2e61E897a +- Bitcoin: 3CPmacGm34qYvR6XWLVEJmi2aNe3PZqUuq +- Ko-Fi: https://ko-fi.com/markqvist + ## Caveat Emptor Nomad Network is beta software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch. From 9f9f10d54e072cdc9d7397520c6def614d204c6a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 12 Oct 2021 20:46:05 +0200 Subject: [PATCH 007/592] Updated shortcuts bar --- nomadnet/ui/textui/Conversations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index d24220a..9cd9e6f 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -20,7 +20,7 @@ class ConversationDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-k] Clear Editor [C-w] Close [C-t] Editor Type [C-p] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-k] Clear [C-w] Close [C-t] Title [C-p] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") class ConversationsArea(urwid.LineBox): def keypress(self, size, key): From 19fb70fb3ff26be879e72a7ba4dbe24a52646f02 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 12 Oct 2021 21:08:31 +0200 Subject: [PATCH 008/592] Fixed sync progress bar display --- nomadnet/NomadNetworkApp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 01e3612..f27d083 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -218,6 +218,8 @@ class NomadNetworkApp: elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHED: return "Link established" elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_REQUEST_SENT: + return "Sync request sent" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RECEIVING: return "Receiving messages" elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: return "Messages received" From 14eb35f7ded9bd478e35c7507b44c1e4bb329d3a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 12 Oct 2021 21:12:32 +0200 Subject: [PATCH 009/592] Fixed sync progress bar display --- nomadnet/NomadNetworkApp.py | 2 ++ nomadnet/ui/textui/Conversations.py | 1 + 2 files changed, 3 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index f27d083..ba7465d 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -243,6 +243,8 @@ class NomadNetworkApp: return True elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_REQUEST_SENT: return True + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RECEIVING: + return True elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: return True elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 9cd9e6f..43eda8e 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -352,6 +352,7 @@ class ConversationsDisplay(): def cancel_sync(sender): self.app.cancel_lxmf_sync() + self.update_sync_dialog() cancel_button = urwid.Button("Close", on_press=dismiss_dialog) sync_progress = SyncProgressBar("progress_empty" , "progress_full", current=self.app.get_sync_progress(), done=1.0, satt=None) From 939bc37f8632fad13ad661c0be6325c4b64fc3d1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 15 Oct 2021 13:13:48 +0200 Subject: [PATCH 010/592] Mitigate KeyError in urwid --- nomadnet/ui/textui/Main.py | 7 ++++--- nomadnet/ui/textui/Network.py | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 5fb1228..deca22a 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -129,11 +129,12 @@ class MainDisplay(): def update_active_shortcuts(self): self.frame.contents["footer"] = (self.sub_displays.active().shortcuts().widget, None) - def request_redraw(self): - self.app.ui.loop.set_alarm_in(0.25, self.redraw_now) + def request_redraw(self, extra_delay=0.0): + self.app.ui.loop.set_alarm_in(0.25+extra_delay, self.redraw_now) def redraw_now(self, sender=None, data=None): - self.app.ui.loop.draw_screen() + self.app.ui.loop.screen.clear() + #self.app.ui.loop.draw_screen() def start(self): self.menu_display.start() diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 6348611..c47d5c1 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -107,6 +107,7 @@ class AnnounceInfo(urwid.WidgetWrap): self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) def connect(sender): + self.app.ui.main_display.request_redraw(extra_delay=0.75) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_announce_stream(None) @@ -433,6 +434,7 @@ class KnownNodeInfo(urwid.WidgetWrap): self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) def connect(sender): + self.app.ui.main_display.request_redraw(extra_delay=0.75) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_known_nodes(None) From 6d4ac49264efee40765fcc1053f3cdef80f40d07 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 15 Oct 2021 19:43:19 +0200 Subject: [PATCH 011/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index acf3be3..51e0a06 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.3" \ No newline at end of file +__version__ = "0.1.4" \ No newline at end of file From 6c16e810e587e9fe5df7a9d0cc48118f5bdf832a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 15 Oct 2021 19:47:16 +0200 Subject: [PATCH 012/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1f1df4a..c25c4a9 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.2.8', 'lxmf>=0.1.1', 'urwid>=2.1.2'], + install_requires=['rns>=0.2.9', 'lxmf>=0.1.2', 'urwid>=2.1.2'], python_requires='>=3.6', ) \ No newline at end of file From 8ff629a3e2105a2769abb9a64b05af623eb56b6a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 Oct 2021 19:07:12 +0200 Subject: [PATCH 013/592] Include local six --- nomadnet/vendor/configobj.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/vendor/configobj.py b/nomadnet/vendor/configobj.py index 58eb7d0..e0c01b6 100644 --- a/nomadnet/vendor/configobj.py +++ b/nomadnet/vendor/configobj.py @@ -19,7 +19,7 @@ import sys from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF16_BE, BOM_UTF16_LE -import six +import RNS.vendor.six as six __version__ = '5.0.6' # imported lazily to avoid startup performance hit if it isn't used From 686ca41b4e25a75475c7c27f69a85a37e5ccad17 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 4 Nov 2021 21:51:11 +0100 Subject: [PATCH 014/592] UI improvement in small terminals. Urwid bug mitigation. --- nomadnet/NomadNetworkApp.py | 15 +++++++++++++++ nomadnet/_version.py | 2 +- nomadnet/ui/textui/Conversations.py | 2 +- nomadnet/ui/textui/Guide.py | 2 ++ nomadnet/ui/textui/Main.py | 1 + nomadnet/ui/textui/Network.py | 27 +++++++++++++++++++++------ 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index ba7465d..42e1d91 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -1,6 +1,8 @@ import os +import sys import time import atexit +import traceback import RNS import LXMF @@ -25,6 +27,18 @@ class NomadNetworkApp: self.directory.save_to_disk() RNS.log("Nomad Network Client exiting now", RNS.LOG_VERBOSE) + def exception_handler(self, e_type, e_value, e_traceback): + RNS.log("An unhandled exception occurred, the details of which will be dumped below", RNS.LOG_ERROR) + RNS.log("Type : "+str(e_type), RNS.LOG_ERROR) + RNS.log("Value : "+str(e_value), RNS.LOG_ERROR) + t_string = "" + for line in traceback.format_tb(e_traceback): + t_string += line + RNS.log("Trace : \n"+t_string, RNS.LOG_ERROR) + + if issubclass(e_type, KeyboardInterrupt): + sys.__excepthook__(e_type, e_value, e_traceback) + def __init__(self, configdir = None, rnsconfigdir = None): self.version = __version__ self.enable_client = False @@ -194,6 +208,7 @@ class NomadNetworkApp: self.announce_now() atexit.register(self.exit_handler) + sys.excepthook = self.exception_handler nomadnet.ui.spawn(self.uimode) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 51e0a06..de49d1f 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.4" \ No newline at end of file +__version__ = "0.1.5" \ No newline at end of file diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 43eda8e..1c9f36f 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -98,7 +98,7 @@ class ConversationsDisplay(): highlight_offFocus="list_off_focus" ) - self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=("relative", 100))) + self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=("relative", 100)), title="Conversations") self.listbox.delegate = self def delete_selected_conversation(self): diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 9be9868..e6c9867 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -302,6 +302,8 @@ You're currently located in the guide section of the program. I'm sorry I had to To get the most out of Nomad Network, you will need a terminal that supports UTF-8 and at least 256 colors, ideally true-color. If your terminal supports true-color, you can go to the `![ Config ]`! menu item, launch the editor and change the configuration. +It is recommended to use a terminal size of at least 122x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped. + If you don't already have a Nerd Font installed (see https://www.nerdfonts.com/), I also highly recommend to do so, since it will greatly expand the amount of glyphs, icons and graphics that Nomad Network can use. Once you have your terminal set up with a Nerd Font, go to the `![ Config ]`! menu item and enable Nerd Fonts in the configuration instead of normal unicode glyphs. Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default UDP-based demo interface on your local ethernet network. This short guide won't go into any details on building networks, but you will find other entries in the guide that deal with network setup and configuration. diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index deca22a..68e7235 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -125,6 +125,7 @@ class MainDisplay(): def update_active_sub_display(self): self.frame.contents["body"] = (self.sub_displays.active().widget, None) self.update_active_shortcuts() + self.app.ui.main_display.request_redraw(extra_delay=0.0) def update_active_shortcuts(self): self.frame.contents["footer"] = (self.sub_displays.active().shortcuts().widget, None) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index c47d5c1..4e4ca4b 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -107,7 +107,7 @@ class AnnounceInfo(urwid.WidgetWrap): self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) def connect(sender): - self.app.ui.main_display.request_redraw(extra_delay=0.75) + self.app.ui.main_display.request_redraw(extra_delay=0.15) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_announce_stream(None) @@ -255,7 +255,7 @@ class AnnounceStream(urwid.WidgetWrap): self.widget_list = [] self.update_widget_list() - self.ilb = IndicativeListBox( + self.ilb = ExceptionHandlingListBox( self.widget_list, on_selection_change=self.list_selection, initialization_is_selection_change=False, @@ -434,7 +434,7 @@ class KnownNodeInfo(urwid.WidgetWrap): self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) def connect(sender): - self.app.ui.main_display.request_redraw(extra_delay=0.75) + self.app.ui.main_display.request_redraw(extra_delay=0.15) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_known_nodes(None) @@ -498,6 +498,21 @@ class KnownNodeInfo(urwid.WidgetWrap): urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Node Info")) +# Yes, this is weird. There is a bug in Urwid/ILB that causes +# an indexing exception when the list is very small vertically. +# This mitigates it. +class ExceptionHandlingListBox(IndicativeListBox): + def keypress(self, size, key): + try: + return super(ExceptionHandlingListBox, self).keypress(size, key) + + except Exception as e: + if key == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + elif key == "down": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.left_pile.set_focus(1) + + class KnownNodes(urwid.WidgetWrap): def __init__(self, app): self.app = app @@ -506,7 +521,7 @@ class KnownNodes(urwid.WidgetWrap): self.widget_list = self.make_node_widgets() - self.ilb = IndicativeListBox( + self.ilb = ExceptionHandlingListBox( self.widget_list, on_selection_change=self.node_list_selection, initialization_is_selection_change=False, @@ -684,7 +699,7 @@ class AnnounceTime(urwid.WidgetWrap): if self.app.peer_settings["last_announce"] != None: self.last_announce_string = pretty_date(int(self.app.peer_settings["last_announce"])) - self.display_widget.set_text("Last Announce : "+self.last_announce_string) + self.display_widget.set_text("Announced : "+self.last_announce_string) def update_time_callback(self, loop=None, user_data=None): self.update_time() @@ -1006,7 +1021,7 @@ class NetworkStats(urwid.WidgetWrap): def get_num_nodes(): return self.app.directory.number_of_known_nodes() - self.w_heard_peers = UpdatingText(self.app, "Heard Peers: ", get_num_peers, append_text=" (last 30m)") + self.w_heard_peers = UpdatingText(self.app, "Heard Peers: ", get_num_peers, append_text=" (30m)") self.w_known_nodes = UpdatingText(self.app, "Known Nodes: ", get_num_nodes) pile = urwid.Pile([ From f5b356d8b8002b4ebcf40bdfbd171f10b9c9802e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 4 Nov 2021 22:27:59 +0100 Subject: [PATCH 015/592] Updated guide --- nomadnet/ui/textui/Guide.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index e6c9867..f11677d 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -248,10 +248,20 @@ To learn how to host your own node, read the `*Hosting a Node`* section of this TOPIC_HOSTING = '''>Hosting a Node -To host a node on the network, you must enable it in the configuration file, by setting `*enable_node`* directive to `*yes`*. You should also configure the other node-related parameters such as the node name and announce interval settings. Once node hosting has been enabled in the configuration, Nomad Network will start hosting your node as soon as the program is lauched, and other peers on the network will be able to connect and interact with content on your node. +To host a node on the network, you must enable it in the configuration file, by setting `*enable_node`* directive to `*yes`*. You should also configure the other node-related parameters such as the node name and announce interval settings. Once node hosting has been enabled in the configuration, Nomad Network will start hosting your node as soon as the program is launched, and other peers on the network will be able to connect and interact with content on your node. By default, no content is defined, apart from a short placeholder home page. To learn how to add your own content, read on. +>>Distributed Message Store + +All nodes on the network automatically form a distributed message store that allows users to exchange messages, even when they are not available at the same time. + +When Nomad Network is configured to host a node, it also configures itself as an LXMF Propagation Node, and automatically discovers and peers with other propagation nodes on the network. This process is completely automatic and requires no configuration from the node operator. + +To view LXMF Propagation nodes that are currently peered with your node, go to the `![ Network ]`! part of the program and press `!Ctrl-P`!. + +The distributed message store is resilient to intermittency, and will remain functional as long as at least one node remains on the network. Nodes that were offline for a time will automatically be synced up to date when they regain connectivity. + >>Pages Nomad Network nodes can host pages similar to web pages, that other peers can read and interact with. Pages are written in a compact markup language called `*micron`*. To learn how to write formatted pages with micron, see the `*Markup`* section of this guide (which is, itself, written in micron). Pages can be linked arbitrarily with hyperlinks, that can also link to pages (or other resources) on other nodes. From fe0a3b6a3e3c39476e0b87bab10a609049a5d0ea Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 4 Nov 2021 22:28:35 +0100 Subject: [PATCH 016/592] Improved LXMF Peer display --- nomadnet/Directory.py | 7 +++++++ nomadnet/ui/textui/Network.py | 14 ++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index f37be18..c357b45 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -123,6 +123,13 @@ class Directory: else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" + def alleged_display_str(self, source_hash): + if source_hash in self.directory_entries: + return self.directory_entries[source_hash].display_name + else: + return None + + def trust_level(self, source_hash, announced_display_name=None): if source_hash in self.directory_entries: if announced_display_name == None: diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 4e4ca4b..615cdf6 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -941,7 +941,7 @@ class NodeInfo(urwid.WidgetWrap): self.t_active_links = NodeInfo.links_timer self.t_active_links.update_stat() - announce_button = urwid.Button("Announce Now", on_press=announce_query) + announce_button = urwid.Button("Announce", on_press=announce_query) connect_button = urwid.Button("Browse", on_press=connect_query) pile = urwid.Pile([ @@ -954,9 +954,9 @@ class NodeInfo(urwid.WidgetWrap): urwid.Columns([ ("weight", 0.3, urwid.Button("Back", on_press=show_peer_info)), ("weight", 0.1, urwid.Text("")), - ("weight", 0.3, connect_button), + ("weight", 0.4, connect_button), ("weight", 0.1, urwid.Text("")), - ("weight", 0.3, announce_button) + ("weight", 0.5, announce_button) ]) ]) else: @@ -1236,13 +1236,19 @@ class LXMFPeerEntry(urwid.WidgetWrap): self.app = app g = self.app.ui.glyphs + node_identity = RNS.Identity.recall(destination_hash) display_str = RNS.prettyhexrep(destination_hash) + if node_identity != None: + node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", node_identity) + display_name = self.app.directory.alleged_display_str(node_hash) + if display_name != None: + display_str += "\n "+str(display_name) sym = g["sent"] style = "list_unknown" focus_style = "list_focus" - widget = ListEntry(sym+" "+display_str+"\n Last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled") + widget = ListEntry(sym+" "+display_str+"\n Last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled messages") # urwid.connect_signal(widget, "click", delegate.connect_node, node) self.display_widget = urwid.AttrMap(widget, style, focus_style) From 1c1ddab28f79887e4693e29427c10ef2a254adc8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 5 Nov 2021 10:59:51 +0100 Subject: [PATCH 017/592] Urwid bug mitigation --- nomadnet/NomadNetworkApp.py | 24 +++++++++++++++++++++++- nomadnet/nomadnet.py | 1 + nomadnet/ui/textui/Main.py | 3 ++- nomadnet/ui/textui/Network.py | 6 ++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 42e1d91..c8630a7 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -1,8 +1,10 @@ import os +import io import sys import time import atexit import traceback +import contextlib import RNS import LXMF @@ -59,6 +61,7 @@ class NomadNetworkApp: self.configpath = self.configdir+"/config" self.logfilepath = self.configdir+"/logfile" + self.errorfilepath = self.configdir+"/errors" self.storagepath = self.configdir+"/storage" self.identitypath = self.configdir+"/storage/identity" self.cachepath = self.configdir+"/storage/cache" @@ -210,7 +213,26 @@ class NomadNetworkApp: atexit.register(self.exit_handler) sys.excepthook = self.exception_handler - nomadnet.ui.spawn(self.uimode) + # This stderr redirect is needed to stop urwid + # from spewing KeyErrors to the console and thus, + # messing up the UI. A pull request to fix the + # bug in urwid was submitted, but until it is + # merged, this hack will mitigate it. + strio = io.StringIO() + with contextlib.redirect_stderr(strio): + nomadnet.ui.spawn(self.uimode) + + if strio.tell() > 0: + try: + strio.seek(0) + err_file = open(self.errorfilepath, "w") + err_file.write(strio.read()) + err_file.close() + + except Exception as e: + RNS.log("Could not write stderr output to error log file at "+str(self.errorfilepath)+".", RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + def set_display_name(self, display_name): self.peer_settings["display_name"] = display_name diff --git a/nomadnet/nomadnet.py b/nomadnet/nomadnet.py index 2ee25de..5cc69a3 100644 --- a/nomadnet/nomadnet.py +++ b/nomadnet/nomadnet.py @@ -2,6 +2,7 @@ from ._version import __version__ +import io import argparse import nomadnet diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 68e7235..22445da 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -125,7 +125,8 @@ class MainDisplay(): def update_active_sub_display(self): self.frame.contents["body"] = (self.sub_displays.active().widget, None) self.update_active_shortcuts() - self.app.ui.main_display.request_redraw(extra_delay=0.0) + # TODO: Remove when new mitigation has been tested + # self.app.ui.main_display.request_redraw(extra_delay=0.0) def update_active_shortcuts(self): self.frame.contents["footer"] = (self.sub_displays.active().shortcuts().widget, None) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 615cdf6..6073e1f 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -107,7 +107,8 @@ class AnnounceInfo(urwid.WidgetWrap): self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) def connect(sender): - self.app.ui.main_display.request_redraw(extra_delay=0.15) + # TODO: Remove when new mitigation has been tested + # self.app.ui.main_display.request_redraw(extra_delay=0.75) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_announce_stream(None) @@ -434,7 +435,8 @@ class KnownNodeInfo(urwid.WidgetWrap): self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) def connect(sender): - self.app.ui.main_display.request_redraw(extra_delay=0.15) + # TODO: Remove when new mitigation has been tested + # self.app.ui.main_display.request_redraw(extra_delay=0.75) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_known_nodes(None) From b2d54cf40a6ae2e7d42173af2cac64814cc3cb5b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 1 Dec 2021 20:14:51 +0100 Subject: [PATCH 018/592] Added makefile --- Makefile | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0aac3f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +all: release + +clean: + @echo Cleaning... + -rm -r ./build + -rm -r ./dist + +remove_symlinks: + @echo Removing symlinks for build... + -rm ./LXMF + -rm ./RNS + +create_symlinks: + @echo Creating symlinks... + -ln -s ../Reticulum/RNS ./ + -ln -s ../LXMF/LXMF ./ + +build_wheel: + python3 setup.py sdist bdist_wheel + +release: remove_symlinks build_wheel create_symlinks + +upload: + @echo Uploading to PyPi... + twine upload dist/* From 9824a8f7ea0796216cd009abc97b3a5c441776fa Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 10 Dec 2021 20:07:47 +0100 Subject: [PATCH 019/592] Dependency updates --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c25c4a9..ad0c972 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.2.9', 'lxmf>=0.1.2', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.0', 'lxmf>=0.1.3', 'urwid>=2.1.2'], python_requires='>=3.6', ) \ No newline at end of file From b86c811bde94f367e1fe062519ee48d7e0e10b1c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 11 Dec 2021 19:20:34 +0100 Subject: [PATCH 020/592] Added delay to auto announces on start --- nomadnet/Node.py | 11 +++++++++-- nomadnet/NomadNetworkApp.py | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index ab10139..9a0ffd5 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -7,13 +7,14 @@ import RNS.vendor.umsgpack as msgpack class Node: JOB_INTERVAL = 5 + START_ANNOUNCE_DELAY = 6 def __init__(self, app): RNS.log("Nomad Network Node starting...", RNS.LOG_VERBOSE) self.app = app self.identity = self.app.identity self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, "nomadnetwork", "node") - self.last_announce = None + self.last_announce = time.time() self.announce_interval = self.app.node_announce_interval self.job_interval = Node.JOB_INTERVAL self.should_run_jobs = True @@ -29,7 +30,13 @@ class Node: RNS.log("Node \""+self.name+"\" ready for incoming connections on "+RNS.prettyhexrep(self.destination.hash), RNS.LOG_VERBOSE) if self.app.node_announce_at_start: - self.announce() + def delayed_announce(): + time.sleep(Node.START_ANNOUNCE_DELAY) + self.announce() + + da_thread = threading.Thread(target=delayed_announce) + da_thread.setDaemon(True) + da_thread.start() job_thread = threading.Thread(target=self.__jobs) job_thread.setDaemon(True) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index c8630a7..7753a45 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -3,6 +3,7 @@ import io import sys import time import atexit +import threading import traceback import contextlib @@ -23,6 +24,8 @@ class NomadNetworkApp: configdir = os.path.expanduser("~")+"/.nomadnetwork" + START_ANNOUNCE_DELAY = 3 + def exit_handler(self): RNS.log("Nomad Network Client exit handler executing...", RNS.LOG_VERBOSE) RNS.log("Saving directory...", RNS.LOG_VERBOSE) @@ -208,7 +211,13 @@ class NomadNetworkApp: self.autoselect_propagation_node() if self.peer_announce_at_start: - self.announce_now() + def delayed_announce(): + time.sleep(NomadNetworkApp.START_ANNOUNCE_DELAY) + self.announce_now() + + da_thread = threading.Thread(target=delayed_announce) + da_thread.setDaemon(True) + da_thread.start() atexit.register(self.exit_handler) sys.excepthook = self.exception_handler From d1633e07713499fe3a7cd6c4591b1368e9a618b7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 11 Dec 2021 20:48:41 +0100 Subject: [PATCH 021/592] Updated readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 7a8eaa1..b254b83 100755 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ The first time the program is running, you will be presented with the guide sect To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. +### Installing on Android +You can install Nomad Network on Android using Termux, but there's a few extra commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. + ## Support Nomad Network You can help support the continued development of open, free and private communications systems by donating via one of the following channels: From 58c118c7ef477f66e42a8cdb3ac35627660700f7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 11 Dec 2021 20:52:32 +0100 Subject: [PATCH 022/592] Updated readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b254b83..937e415 100755 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ The current version of the program should be considered a beta release. The prog ### Feature roadmap - Access control and authentication for nodes, pages and files - Network-wide propagated bulletins and discussion threads - - Geospatial information sharing and collaborative maps + - Collaborative maps + - Facilitation of trade and barter ## Dependencies: - Python 3 From cfb4cec041168eaa95f78691855f97796fe1030b Mon Sep 17 00:00:00 2001 From: markqvist Date: Tue, 1 Feb 2022 23:01:22 +0100 Subject: [PATCH 023/592] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 937e415..9d9be04 100755 --- a/README.md +++ b/README.md @@ -50,8 +50,10 @@ The first time the program is running, you will be presented with the guide sect To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. +**Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + ### Installing on Android -You can install Nomad Network on Android using Termux, but there's a few extra commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. +You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. ## Support Nomad Network You can help support the continued development of open, free and private communications systems by donating via one of the following channels: From 1cfa744fee33ca2fc9741841fadcecc539032c85 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Feb 2022 17:10:38 +0100 Subject: [PATCH 024/592] Added color-mapper for all terminal color modes --- nomadnet/ui/textui/MicronParser.py | 149 ++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index f9c5d59..bd52de1 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -185,14 +185,155 @@ def style_to_state(style, state): def make_style(state): def mono_color(fg, bg): return "default" + def low_color(color): - # TODO: Implement low-color mapper - return "default" + try: + result = "default" + if color == "default": + result = "default" + + elif len(color) == 6: + r = str(color[0]) + g = str(color[2]) + b = str(color[4]) + color = r+g+b + + if len(color) == 3: + t = 7 + + if color[0] == "g": + val = int(color[1:2]) + if val < 25: + result = "black" + elif val < 50: + result = "dark gray" + elif val < 75: + result = "light gray" + else: + result = "white" + else: + r = int(color[0], 16) + g = int(color[1], 16) + b = int(color[2], 16) + + if r == g == b: + val = int(color[0], 16)*6 + if val < 12: + result = "black" + elif val < 50: + result = "dark gray" + elif val < 80: + result = "light gray" + else: + result = "white" + + else: + if r == b: + if r > g: + if r > t: + result = "light magenta" + else: + result = "dark magenta" + else: + if g > t: + result = "light green" + else: + result = "dark green" + if b == g: + if b > r: + if b > t: + result = "light cyan" + else: + result = "dark cyan" + else: + if r > t: + result = "light red" + else: + result = "dark red" + if g == r: + if g > b: + if g > t: + result = "yellow" + else: + result = "brown" + else: + if b > t: + result = "light blue" + else: + result = "dark blue" + + if r > g and r > b: + if r > t: + result = "light red" + else: + result = "dark red" + if g > r and g > b: + if g > t: + result = "light green" + else: + result = "dark green" + if b > g and b > r: + if b > t: + result = "light blue" + else: + result = "dark blue" + + except Exception as e: + result = "default" + + return result + def high_color(color): + def parseval_hex(char): + return hex(max(0,min(int(char, 16),16)))[2:] + + def parseval_dec(char): + return str(max(0,min(int(char), 9))) + if color == "default": - return color + return "default" else: - return "#"+color + if len(color) == 6: + try: + v1 = parseval_hex(color[0]) + v2 = parseval_hex(color[1]) + v3 = parseval_hex(color[2]) + v4 = parseval_hex(color[3]) + v5 = parseval_hex(color[4]) + v6 = parseval_hex(color[5]) + color = "#"+v1+v2+v3+v4+v5+v6 + + except Exception as e: + return "default" + + return color + + elif len(color) == 3: + if color[0] == "g": + try: + v1 = parseval_dec(color[1]) + v2 = parseval_dec(color[2]) + + except Exception as e: + return "default" + + return "g"+v1+v2 + + else: + try: + v1 = parseval_hex(color[0]) + v2 = parseval_hex(color[1]) + v3 = parseval_hex(color[2]) + color = v1+v2+v3 + + except Exception as e: + return "default" + + r = color[0] + g = color[1] + b = color[2] + return "#"+r+r+g+g+b+b + bold = state["formatting"]["bold"] underline = state["formatting"]["underline"] From 6cfcec690edc04b83b52b43f3307175254f0dc62 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Feb 2022 18:39:21 +0100 Subject: [PATCH 025/592] Updated guide --- nomadnet/ui/textui/Guide.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index f11677d..2022a40 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -397,6 +397,9 @@ The following line should contain a green gradient bar: The following line should contain a blue gradient bar: `B001 `B002 `B003 `B004 `B005 `B006 `B007 `B008 `B009 `B00a `B00b `B00c `B00d `B00e `B00f`b +The following line should contain a grayscale gradient bar: +`Bg06 `Bg13 `Bg20 `Bg26 `Bg33 `Bg40 `Bg46 `Bg53 `Bg59 `Bg66 `Bg73 `Bg79 `Bg86 `Bg92 `Bg99`b + Unicode Glyphs : \u2713 \u2715 \u26a0 \u24c3 \u2193 Nerd Font Glyphs : \uf484 \uf9c4 \uf719 \uf502 \uf415 \uf023 \uf06e From 722382643a0bddbf9e450cdfa386cd9a05a1757f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Feb 2022 18:40:04 +0100 Subject: [PATCH 026/592] Improved Text UI themes --- nomadnet/ui/TextUI.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index a25805b..34d9340 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -22,9 +22,9 @@ THEMES = { # Style name # 16-color style # Monochrome style # 88, 256 and true-color style ("heading", "light gray,underline", "default", "underline", "g93,underline", "default"), ("menubar", "black", "light gray", "standout", "#111", "#bbb"), - ("scrollbar", "light gray", "default", "standout", "#444", "default"), + ("scrollbar", "light gray", "default", "default", "#444", "default"), ("shortcutbar", "black", "light gray", "standout", "#111", "#bbb"), - ("body_text", "white", "default", "default", "#ddd", "default"), + ("body_text", "light gray", "default", "default", "#ddd", "default"), ("error_text", "dark red", "default", "default", "dark red", "default"), ("warning_text", "yellow", "default", "default", "#ba4", "default"), ("inactive_text", "dark gray", "default", "default", "dark gray", "default"), @@ -45,7 +45,7 @@ THEMES = { ("list_normal", "dark gray", "default", "default", "#bbb", "default"), ("list_untrusted", "dark red", "default", "default", "#a22", "default"), ("list_focus_untrusted", "black", "light gray", "standout", "#810", "#aaa"), - ("topic_list_normal", "white", "default", "default", "#ddd", "default"), + ("topic_list_normal", "light gray", "default", "default", "#ddd", "default"), ("browser_controls", "light gray", "default", "default", "#bbb", "default"), ("progress_full", "black", "light gray", "standout", "#111", "#bbb"), ("progress_empty", "light gray", "default", "default", "#ddd", "default"), @@ -56,9 +56,9 @@ THEMES = { # Style name # 16-color style # Monochrome style # 88, 256 and true-color style ("heading", "dark gray,underline", "default", "underline", "g93,underline", "default"), ("menubar", "black", "dark gray", "standout", "#111", "#bbb"), - ("scrollbar", "dark gray", "default", "standout", "#444", "default"), + ("scrollbar", "dark gray", "default", "default", "#444", "default"), ("shortcutbar", "black", "dark gray", "standout", "#111", "#bbb"), - ("body_text", "black", "default", "default", "#222", "default"), + ("body_text", "dark gray", "default", "default", "#222", "default"), ("error_text", "dark red", "default", "default", "dark red", "default"), ("warning_text", "yellow", "default", "default", "#ba4", "default"), ("inactive_text", "light gray", "default", "default", "dark gray", "default"), @@ -78,7 +78,7 @@ THEMES = { ("list_normal", "dark gray", "default", "default", "#444", "default"), ("list_untrusted", "dark red", "default", "default", "#a22", "default"), ("list_focus_untrusted", "black", "dark gray", "standout", "#810", "#aaa"), - ("topic_list_normal", "black", "default", "default", "#222", "default"), + ("topic_list_normal", "dark gray", "default", "default", "#222", "default"), ("browser_controls", "dark gray", "default", "default", "#444", "default"), ("progress_full", "black", "dark gray", "standout", "#111", "#bbb"), ("progress_empty", "dark gray", "default", "default", "#ddd", "default"), From 2563daae9326574291458f8c50f766ce0f7bdfbd Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Feb 2022 19:58:17 +0100 Subject: [PATCH 027/592] Updated dependency versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ad0c972..cb233ce 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.0', 'lxmf>=0.1.3', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.3', 'lxmf>=0.1.3', 'urwid>=2.1.2'], python_requires='>=3.6', ) \ No newline at end of file From f7234eb13c6e89814c4cc3dee5436745da490bcb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Feb 2022 20:07:03 +0100 Subject: [PATCH 028/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index de49d1f..32efefd 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.5" \ No newline at end of file +__version__ = "0.1.6" \ No newline at end of file From 8efa406003c445ff324cd58061d8f109ad57f255 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 28 Mar 2022 15:32:49 +0200 Subject: [PATCH 029/592] Updated dependency versions --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index cb233ce..052877a 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.3', 'lxmf>=0.1.3', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.4', 'lxmf>=0.1.4', 'urwid>=2.1.2'], python_requires='>=3.6', -) \ No newline at end of file +) From 3f7631aab7cd365b7f5af022a660ae4c8891da84 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 28 Mar 2022 15:33:09 +0200 Subject: [PATCH 030/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 32efefd..f1380ee 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.6" \ No newline at end of file +__version__ = "0.1.7" From 598e826d85895159c1c01ae27f488412d3e3313a Mon Sep 17 00:00:00 2001 From: markqvist Date: Mon, 28 Mar 2022 22:56:20 +0200 Subject: [PATCH 031/592] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9d9be04..d5ba7a0 100755 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ To use Nomad Network on packet radio or LoRa, you will need to configure your Re ### Installing on Android You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. +For a native Android application with a graphical user interface, have a look at [Sideband](https://unsigned.io/sideband). + ## Support Nomad Network You can help support the continued development of open, free and private communications systems by donating via one of the following channels: From 1dffea0ac7c7bccd1f068e267df4a2dd7a6a221e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 1 Apr 2022 17:33:41 +0200 Subject: [PATCH 032/592] Updated readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d5ba7a0..e51cd9c 100755 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ The current version of the program should be considered a beta release. The prog - Collaborative maps - Facilitation of trade and barter +## Help & Discussion + +For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). + ## Dependencies: - Python 3 - RNS From adca0ad1b7390d671f03c463cf467b6b90a2bfbc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 1 Apr 2022 17:39:56 +0200 Subject: [PATCH 033/592] Updated readme --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e51cd9c..2df01cd 100755 --- a/README.md +++ b/README.md @@ -34,11 +34,6 @@ The current version of the program should be considered a beta release. The prog For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). -## Dependencies: - - Python 3 - - RNS - - LXMF - ## How do I get started? The easiest way to install Nomad Network is via pip: @@ -52,7 +47,9 @@ nomadnet The first time the program is running, you will be presented with the guide section, which contains all the information you need to start using Nomad Network. -To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. +To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). + +If you want to try Nomad Network without building your own physical network, you can try to connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. **Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. From 7f82017a2399e62f184dd292b26b1811caab16f0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 1 Apr 2022 17:41:29 +0200 Subject: [PATCH 034/592] Updated readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2df01cd..9052874 100755 --- a/README.md +++ b/README.md @@ -30,10 +30,6 @@ The current version of the program should be considered a beta release. The prog - Collaborative maps - Facilitation of trade and barter -## Help & Discussion - -For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). - ## How do I get started? The easiest way to install Nomad Network is via pip: @@ -58,6 +54,10 @@ You can install Nomad Network on Android using Termux, but there's a few more co For a native Android application with a graphical user interface, have a look at [Sideband](https://unsigned.io/sideband). +## Help & Discussion + +For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). + ## Support Nomad Network You can help support the continued development of open, free and private communications systems by donating via one of the following channels: From bc9fe84447403a4d508eea26ab15b833424c099e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 1 Apr 2022 17:42:31 +0200 Subject: [PATCH 035/592] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9052874..085f8ff 100755 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The first time the program is running, you will be presented with the guide sect To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). -If you want to try Nomad Network without building your own physical network, you can try to connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. +If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. **Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. From 880f00af51b4ec868186a712f7eed4ba87cd4057 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 Apr 2022 17:38:07 +0200 Subject: [PATCH 036/592] Implemented automatic LXMF sync --- nomadnet/NomadNetworkApp.py | 69 ++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 7753a45..484e916 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -28,6 +28,8 @@ class NomadNetworkApp: def exit_handler(self): RNS.log("Nomad Network Client exit handler executing...", RNS.LOG_VERBOSE) + self.should_run_jobs = False + RNS.log("Saving directory...", RNS.LOG_VERBOSE) self.directory.save_to_disk() RNS.log("Nomad Network Client exiting now", RNS.LOG_VERBOSE) @@ -80,10 +82,17 @@ class NomadNetworkApp: self.downloads_path = os.path.expanduser("~/Downloads") self.firstrun = False + self.should_run_jobs = True + self.job_interval = 5 + self.defer_jobs = 90 self.peer_announce_at_start = True self.try_propagation_on_fail = True + self.periodic_lxmf_sync = True + self.lxmf_sync_interval = 360*60 + self.lxmf_sync_limit = 8 + if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -159,6 +168,9 @@ class NomadNetworkApp: if not "propagation_node" in self.peer_settings: self.peer_settings["propagation_node"] = None + if not "last_lxmf_sync" in self.peer_settings: + self.peer_settings["last_lxmf_sync"] = 0 + except Exception as e: RNS.log("Could not load local peer settings from "+self.peersettingspath, RNS.LOG_ERROR) RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) @@ -171,7 +183,8 @@ class NomadNetworkApp: "announce_interval": None, "last_announce": None, "node_last_announce": None, - "propagation_node": None + "propagation_node": None, + "last_lxmf_sync": 0, } self.save_peer_settings() RNS.log("Created new peer settings file") @@ -222,6 +235,10 @@ class NomadNetworkApp: atexit.register(self.exit_handler) sys.excepthook = self.exception_handler + job_thread = threading.Thread(target=self.__jobs) + job_thread.setDaemon(True) + job_thread.start() + # This stderr redirect is needed to stop urwid # from spewing KeyErrors to the console and thus, # messing up the UI. A pull request to fix the @@ -243,6 +260,20 @@ class NomadNetworkApp: RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + def __jobs(self): + RNS.log("Deferring scheduled jobs for "+str(self.defer_jobs)+" seconds...", RNS.LOG_DEBUG) + time.sleep(self.defer_jobs) + + RNS.log("Starting job scheduler now", RNS.LOG_DEBUG) + while self.should_run_jobs: + now = time.time() + + if now > self.peer_settings["last_lxmf_sync"] + self.lxmf_sync_interval: + RNS.log("Initiating automatic LXMF sync", RNS.LOG_VERBOSE) + self.request_lxmf_sync(limit=self.lxmf_sync_limit) + + time.sleep(self.job_interval) + def set_display_name(self, display_name): self.peer_settings["display_name"] = display_name self.lxmf_destination.display_name = display_name @@ -303,6 +334,8 @@ class NomadNetworkApp: def request_lxmf_sync(self, limit = None): if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE or self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + self.peer_settings["last_lxmf_sync"] = time.time() + self.save_peer_settings() self.message_router.request_messages_from_propagation_node(self.identity, max_messages = limit) def cancel_lxmf_sync(self): @@ -447,6 +480,24 @@ class NomadNetworkApp: value = self.config["client"].as_bool(option) self.try_propagation_on_fail = value + if option == "periodic_lxmf_sync": + value = self.config["client"].as_bool(option) + self.periodic_lxmf_sync = value + + if option == "lxmf_sync_interval": + value = self.config["client"].as_int(option)*60 + + if value >= 60: + self.lxmf_sync_interval = value + + if option == "lxmf_sync_limit": + value = self.config["client"].as_int(option) + + if value > 0: + self.lxmf_sync_limit = value + else: + self.lxmf_sync_limit = None + if option == "user_interface": value = value.lower() if value == "none": @@ -597,6 +648,22 @@ announce_at_start = yes # possible. try_propagation_on_send_fail = yes +# Nomadnet will periodically sync messages from +# LXMF propagation nodes by default, if any are +# present. You can disable this if you want to +# only sync when manually initiated. +periodic_lxmf_sync = yes + +# The sync interval in minutes. This value is +# equal to 6 hours (360 minutes) by default. +lxmf_sync_interval = 360 + +# By default, automatic LXMF syncs will only +# download 8 messages at a time. You can change +# this number, or set the option to 0 to disable +# the limit, and download everything every time. +lxmf_sync_limit = 8 + [textui] # Amount of time to show intro screen From 47b65bc5d129dfff51be906224d1d0b68333aac4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 Apr 2022 17:43:50 +0200 Subject: [PATCH 037/592] Updated guide --- nomadnet/ui/textui/Guide.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 2022a40..2415dcc 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -234,6 +234,10 @@ For propagated delivery to work, one or more nodes must be available on the netw To select a node manually, go to the `![ Network ]`! part of the program, choose the desired node in the `*Known Nodes`* list, and select the `!< Info >`! button. In the `!Node Info`! dialog, you can specify the selected node as the default propagation node. +By default, Nomad Network will check in with propagation nodes, and download any available messages every 6 hours. You can change this interval, or disable automatic synchronisation completely, by editing the configuration file. + +You can always initiate a sync manually, by pressing `!Ctrl-R`! in the `![ Conversations ]`! part of the program, which will open the syncronisation window. + >>Node A Nomad Network `*node`* is an instance of the Nomad Network program that has been configured to host information for other peers and help propagate messages and information on the network. @@ -316,7 +320,7 @@ It is recommended to use a terminal size of at least 122x32. Nomad Network will If you don't already have a Nerd Font installed (see https://www.nerdfonts.com/), I also highly recommend to do so, since it will greatly expand the amount of glyphs, icons and graphics that Nomad Network can use. Once you have your terminal set up with a Nerd Font, go to the `![ Config ]`! menu item and enable Nerd Fonts in the configuration instead of normal unicode glyphs. -Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default UDP-based demo interface on your local ethernet network. This short guide won't go into any details on building networks, but you will find other entries in the guide that deal with network setup and configuration. +Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default one that Reticulum auto-generates on your local ethernet/WiFi network, or something much more complex. This short guide won't go into any details on building networks, but you will find other entries in the guide that deal with network setup and configuration. At least, if Nomad Network launches, it means that it is connected to a running Reticulum instance, that should in turn be connected to `*something`*, which should get you started. @@ -336,14 +340,14 @@ Nomad Network uses the Reticulum Network Stack for communication and encryption. Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. The number of distinct network topologies you can create with Reticulum is more or less endless, but common to them all is that you will need to define one or more interfaces for Reticulum to use. -If you have not changed the default Reticulum configuration, which should be located at `!~/.reticulum/config`!, you will have one interface active right now. With it, you should be able to communicate with any other peers and nodes that exist on your local ethernet or WiFi network, if your system is connected to one, and most probably nothing else outside of that. +If you have not changed the default Reticulum configuration, which should be located at `!~/.reticulum/config`!, you will have one interface active right now. With it, you should be able to communicate with any other peers and systems that exist on your local ethernet or WiFi network, if your computer is connected to one, and most probably nothing else outside of that. To learn how to configure your Reticulum setup to use LoRa radios, packet radio or other interfaces, or connect to other Reticulum networks via the Internet, the best places to start is to read the relevant parts of the Reticulum Manual, which can be found on GitHub: `c`_https://markqvist.github.io/Reticulum/manual/interfaces.html`_ `l -If you don't currently have access to the Internet, the default Reticulum configuration file is also full of examples of all the supported interface types, and it should be relatively simple to get a working setup going just from those examples. +If you don't currently have access to the Internet, you can generate a configuration file full of examples of all the supported interface types, by using the command `!rnsd --exampleconfig`!. Using those examples, it should be possible to get a working setup going. For future reference, you can download the Reticulum Manual in PDF format here: From 886c23c21f80a29541e02d43349de16eae363b41 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 Apr 2022 18:06:51 +0200 Subject: [PATCH 038/592] Fixed file downloading from cached pages --- nomadnet/ui/textui/Browser.py | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 7593b99..7cf12a5 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -371,6 +371,46 @@ class Browser: RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR) def download_file(self, destination_hash, path): + if self.link == None or self.link.destination.hash != self.destination_hash: + if not RNS.Transport.has_path(self.destination_hash): + self.status = Browser.NO_PATH + self.update_display() + + RNS.Transport.request_path(self.destination_hash) + self.status = Browser.PATH_REQUESTED + self.update_display() + + pr_time = time.time() + while not RNS.Transport.has_path(self.destination_hash): + now = time.time() + if now > pr_time+self.timeout: + self.request_timeout() + return + + time.sleep(0.25) + + self.status = Browser.ESTABLISHING_LINK + self.update_display() + + identity = RNS.Identity.recall(self.destination_hash) + destination = RNS.Destination( + identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + self.app_name, + self.aspects + ) + + self.link = RNS.Link(destination, established_callback = self.link_established, closed_callback = self.link_closed) + + while self.status == Browser.ESTABLISHING_LINK: + time.sleep(0.1) + + if self.status != Browser.LINK_ESTABLISHED: + return + + self.update_display() + if self.link != None and self.link.destination.hash == self.destination_hash: # Send the request self.status = Browser.REQUESTING From 3eaeb42bf6f66868a3a2829fdeb78b7ab0b17cd2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 Apr 2022 20:34:49 +0200 Subject: [PATCH 039/592] Added page authentication --- nomadnet/Directory.py | 24 ++++++++++-- nomadnet/Node.py | 72 ++++++++++++++++++++++++++++++----- nomadnet/ui/textui/Browser.py | 4 ++ nomadnet/ui/textui/Network.py | 13 +++++-- 4 files changed, 97 insertions(+), 16 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index c357b45..6191500 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -32,7 +32,7 @@ class Directory: packed_list = [] for source_hash in self.directory_entries: e = self.directory_entries[source_hash] - packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery)) + packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify)) directory = { "entry_list": packed_list, @@ -65,7 +65,12 @@ class Directory: else: preferred_delivery = None - entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery) + if len(e) > 5: + identify = e[5] + else: + identify = False + + entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify) self.directory_entries = entries @@ -183,6 +188,18 @@ class Directory: except Exception as e: return False + def should_identify_on_connect(self, source_hash): + if source_hash in self.directory_entries: + entry = self.directory_entries[source_hash] + return entry.identify + else: + return False + + def set_identify_on_connect(self, source_hash, state): + if source_hash in self.directory_entries: + entry = self.directory_entries[source_hash] + entry.identify = state + def known_nodes(self): node_list = [] for eh in self.directory_entries: @@ -214,7 +231,7 @@ class DirectoryEntry: DIRECT = 0x01 PROPAGATED = 0x02 - def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None): + def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False): if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: self.source_hash = source_hash self.display_name = display_name @@ -228,5 +245,6 @@ class DirectoryEntry: self.trust_level = trust_level self.hosts_node = hosts_node + self.identify = identify_on_connect else: raise TypeError("Attempt to add invalid source hash to directory") \ No newline at end of file diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 9a0ffd5..3c21a31 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -79,7 +79,8 @@ class Node: directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."] for file in files: - self.servedpages.append(base_path+"/"+file) + if not file.endswith(".allowed"): + self.servedpages.append(base_path+"/"+file) for directory in directories: self.scan_pages(base_path+"/"+directory) @@ -96,17 +97,63 @@ class Node: def serve_page(self, path, data, request_id, remote_identity, requested_at): RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) + file_path = path.replace("/page", self.app.pagespath, 1) - try: - RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) - if os.access(file_path, os.X_OK): - generated = subprocess.run([file_path], stdout=subprocess.PIPE) - return generated.stdout + + allowed_path = file_path+".allowed" + request_allowed = False + + if os.path.isfile(allowed_path): + allowed_list = [] + + try: + if os.access(allowed_path, os.X_OK): + allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE) + allowed_input = allowed_result.stdout + + else: + fh = open(allowed_path, "rb") + allowed_input = fh.read() + fh.close() + + allowed_hash_strs = allowed_input.splitlines() + + for hash_str in allowed_hash_strs: + if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: + try: + allowed_hash = bytes.fromhex(hash_str.decode("utf-8")) + allowed_list.append(allowed_hash) + + except Exception as e: + RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR) + + if remote_identity.hash in allowed_list: + request_allowed = True else: - fh = open(file_path, "rb") - response_data = fh.read() - fh.close() - return response_data + request_allowed = False + RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE) + + else: + request_allowed = True + + try: + if request_allowed: + RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) + if os.access(file_path, os.X_OK): + generated = subprocess.run([file_path], stdout=subprocess.PIPE) + return generated.stdout + else: + fh = open(file_path, "rb") + response_data = fh.read() + fh.close() + return response_data + else: + RNS.log("Request denied", RNS.LOG_VERBOSE) + return DEFAULT_NOTALLOWED.encode("utf-8") except Exception as e: RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_ERROR) @@ -160,4 +207,9 @@ DEFAULT_INDEX = '''>Default Home Page This node is serving pages, but the home page file (index.mu) was not found in the page storage directory. This is an auto-generated placeholder. If you are the node operator, you can define your own home page by creating a file named `*index.mu`* in the page storage directory. +''' + +DEFAULT_NOTALLOWED = '''>Request Not Allowed + +You are not authorised to carry out the request. ''' \ No newline at end of file diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 7cf12a5..59578e1 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -643,6 +643,10 @@ class Browser: def link_established(self, link): self.status = Browser.LINK_ESTABLISHED + if self.app.directory.should_identify_on_connect(self.destination_hash): + RNS.log("Link established, identifying to remote system...", RNS.LOG_VERBOSE) + self.link.identify(self.app.identity) + def link_closed(self, link): if self.status == Browser.DISCONECTED or self.status == Browser.DONE: diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 6073e1f..51f13f6 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -443,7 +443,11 @@ class KnownNodeInfo(urwid.WidgetWrap): def pn_change(sender, userdata): self.pn_changed = True + def ident_change(sender, userdata): + pass + propagation_node_checkbox = urwid.CheckBox("Use as default propagation node", state=(self.app.get_user_selected_propagation_node() == source_hash), on_state_change=pn_change) + connect_identify_checkbox = urwid.CheckBox("Identify when connecting", state=self.app.directory.should_identify_on_connect(source_hash), on_state_change=ident_change) def save_node(sender): if self.pn_changed: @@ -461,7 +465,7 @@ class KnownNodeInfo(urwid.WidgetWrap): display_str = e_name.get_edit_text() - node_entry = DirectoryEntry(source_hash, display_name=display_str, trust_level=trust_level, hosts_node=True) + node_entry = DirectoryEntry(source_hash, display_name=display_str, trust_level=trust_level, hosts_node=True, identify_on_connect=connect_identify_checkbox.get_state()) self.app.directory.remember(node_entry) self.app.ui.main_display.sub_displays.network_display.directory_change_callback() show_known_nodes(None) @@ -479,6 +483,7 @@ class KnownNodeInfo(urwid.WidgetWrap): # urwid.Text(["Trust : ", (style, trust_str)], align="left"), urwid.Divider(g["divider1"]), propagation_node_checkbox, + connect_identify_checkbox, urwid.Divider(g["divider1"]), r_untrusted, r_unknown, @@ -793,8 +798,9 @@ class LocalPeer(urwid.WidgetWrap): if display_name == None: display_name = "" - t_id = urwid.Text("Addr : "+RNS.hexrep(self.app.lxmf_destination.hash, delimit=False)) - e_name = urwid.Edit(caption="Name : ", edit_text=display_name) + t_id = urwid.Text("LXMF Addr : "+RNS.prettyhexrep(self.app.lxmf_destination.hash)) + i_id = urwid.Text("Identity : "+RNS.prettyhexrep(self.app.identity.hash)) + e_name = urwid.Edit(caption="Name : ", edit_text=display_name) def save_query(sender): def dismiss_dialog(sender): @@ -858,6 +864,7 @@ class LocalPeer(urwid.WidgetWrap): self.display_widget = urwid.Pile( [ t_id, + i_id, e_name, urwid.Divider(g["divider1"]), self.t_last_announce, From 8b848648475338ee8f2a3c78116552d142ca3f0e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 Apr 2022 21:02:28 +0200 Subject: [PATCH 040/592] Added user authentication info to guide --- nomadnet/ui/textui/Guide.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 2415dcc..bae43f4 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -276,6 +276,26 @@ You can control how long a peer will cache your pages by including the cache hea You can use a preprocessor such as PHP, bash, Python (or whatever you prefer) to generate dynamic pages. To do so, just set executable permissions on the relevant page file, and be sure to include the interpreter at the beginning of the file, for example `!#!/usr/bin/python3`!. +>>Authenticating Users + +Sometimes, you don't want everyone to be able to view certain pages or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. + +To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". + +For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this: + +`Faaa +`= +24c2dc2248953e0a3c21 +564a476410f4fca2ae93 +8bb864743f12f53395a1 +`= +`` + +You can also dynamically generate this list, by making the file executable, and writing a script (in whatever language you want), that prints the list to stdout. Every time someone tries to request the page, Nomad Network will check the allowed identities list, and only grant access to allowed users. + +By default, Nomad Network connects anonymously to all nodes. To be able to identify, and access restricted pages, you must allow identifying on a per-node basis. To allow identifying when connecting to a node, you must go to the `!Known Nodes`! list in the `![ Network ]`! part of the program, and enable the `!Identify When Connecting`! checkbox under `!Node Info`!. + >>Files Like pages, you can place files you want to make available in the `!~/.nomadnetwork/storage/files`! directory. To let a peer download a file, you should create a link to it in one of your pages. From 4ff99688de8e5d83f7ebb65563916f34591d3d2c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 Apr 2022 21:41:58 +0200 Subject: [PATCH 041/592] Added hops display to node info. Fixed node info display for unknown ops and addresses. --- nomadnet/Node.py | 2 +- nomadnet/ui/textui/Network.py | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 3c21a31..e1ea5df 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -131,7 +131,7 @@ class Node: except Exception as e: RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR) - if remote_identity.hash in allowed_list: + if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list: request_allowed = True else: request_allowed = False diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 51f13f6..f080f0b 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -493,11 +493,29 @@ class KnownNodeInfo(urwid.WidgetWrap): ] node_ident = RNS.Identity.recall(source_hash) - op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) - op_str = self.app.directory.simplest_display_str(op_hash) + if node_ident != None: + op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) + op_str = self.app.directory.simplest_display_str(op_hash) + else: + op_str = "Unknown" + operator_entry = urwid.Text("Operator : "+op_str, align="left") pile_widgets.insert(4, operator_entry) + hops = RNS.Transport.hops_to(source_hash) + if hops == 1: + str_s = "" + else: + str_s = "s" + + if hops != RNS.Transport.PATHFINDER_M: + hops_str = str(hops)+" hop"+str_s + else: + hops_str = "Unknown" + + operator_entry = urwid.Text("Distance : "+hops_str, align="left") + pile_widgets.insert(5, operator_entry) + pile = urwid.Pile(pile_widgets) self.display_widget = urwid.Filler(pile, valign="top", height="pack") From f7b6bd8d7151ec7f8288b47dbf4847d5b01c492d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 8 Apr 2022 10:29:52 +0200 Subject: [PATCH 042/592] Added configuration section to guide --- nomadnet/NomadNetworkApp.py | 3 + nomadnet/ui/textui/Extras.py | 2 +- nomadnet/ui/textui/Guide.py | 202 ++++++++++++++++++++++++++++++++++- 3 files changed, 205 insertions(+), 2 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 484e916..2a036c1 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -512,6 +512,9 @@ class NomadNetworkApp: else: self.config["textui"]["intro_time"] = self.config["textui"].as_int("intro_time") + if not "intro_text" in self.config["textui"]: + self.config["textui"]["intro_text"] = "Nomad Network" + if not "editor" in self.config["textui"]: self.config["textui"]["editor"] = "editor" diff --git a/nomadnet/ui/textui/Extras.py b/nomadnet/ui/textui/Extras.py index ce62430..f0314df 100644 --- a/nomadnet/ui/textui/Extras.py +++ b/nomadnet/ui/textui/Extras.py @@ -5,7 +5,7 @@ class IntroDisplay(): font = urwid.font.HalfBlock5x4Font() - big_text = urwid.BigText(("intro_title", "Nomad Network"), font) + big_text = urwid.BigText(("intro_title", self.app.config["textui"]["intro_text"]), font) big_text = urwid.Padding(big_text, align="center", width="clip") intro = urwid.Pile([ diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index bae43f4..e7a02e6 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -110,6 +110,7 @@ class TopicList(urwid.WidgetWrap): GuideEntry(self.app, self, guide_display, "Introduction"), GuideEntry(self.app, self, guide_display, "Concepts & Terminology"), GuideEntry(self.app, self, guide_display, "Hosting a Node"), + GuideEntry(self.app, self, guide_display, "Configuration Options"), GuideEntry(self.app, self, guide_display, "Markup"), self.first_run_entry, GuideEntry(self.app, self, guide_display, "Network Configuration"), @@ -354,6 +355,204 @@ Now go out there and explore. This is still early days. See what you can find an ''' +TOPIC_CONFIG = '''>Configuration Options + +To change the configuration of Nomad Network, you must edit the configuration file. By default, this is located at `!~/.nomadnetwork/config`! on your system. You can open it in any text-editor, and change the options. You can also use the editor built in to this program, under the `![ Config ]`! menu item. The default configuration file contains comments on all the different configuration options present, and explains their possible settings. + +For reference, all the configuration options are listed and explained here as well. The configuration is divided into different sections, each with their own options. + +>> Logging Section + +This section hold configuration directives related to logging output, and is delimited by the `![logging]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!loglevel = 4`! +>>>> +Sets the verbosity of the log output. Must be an integer from 0 through 7. +>>>>> +0: Log only critical information +1: Log errors and lower log levels +2: Log warnings and lower log levels +3: Log notices and lower log levels +4: Log info and lower (this is the default) +5: Verbose logging +6: Debug logging +7: Extreme logging +< + +>>> +`!destination = file`! +>>>> +Determines the output destination of logged information. Must be `!file`! or `!console`!. +< + +>>> +`!logfile = ~/.nomadnetwork/logfile`! +>>>> +Path to the log file. Must be a writable filesystem path. +< + +>> Client Section + +This section hold configuration directives related to the client behaviour and user interface of the program. It is delimited by the `![client]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!enable_client = yes`! +>>>> +Determines whether the client part of the program should be started on launch. Must be a boolean value. +< + +>>> +`!user_interface = text`! +>>>> +Selects which interface to use. Currently, only the `!text`! interface is available. +< + +>>> +`!downloads_path = ~/Downloads`! +>>>> +Sets the filesystem path to store downloaded files in. +< + +>>> +`!announce_at_start = yes`! +>>>> +Determines whether your LXMF address is automatically announced when the program starts. Must be a boolean value. +< + +>>> +`!try_propagation_on_send_fail = yes`! +>>>> +When this option is enabled, and sending a message directly to a peer fails, Nomad Network will instead deliver the message to the propagation network, for later retrieval by the recipient. +< + +>>> +`!periodic_lxmf_sync = yes`! +>>>> +Whether the program should periodically download messages from available propagation nodes in the background. +< + +>>> +`!lxmf_sync_interval = 360`! +>>>> +The number of minutes between each automatic sync. The default is equal to 6 hours. +< + +>>> +`!lxmf_sync_limit = 8`! +>>>> +On low-bandwidth networks, it can be useful to limit the amount of messages downloaded in each sync. The default is 8. Set to 0 to download all available messages every time a sync occurs. +< + +>> Text UI Section + +This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!intro_time = 1`! +>>>> +Number of seconds to display the intro screen. Set to 0 to disable the intro screen. +< + +>>> +`!intro_text = Nomad Network`! +>>>> +The text to display on the intro screen. +< + +>>> +`!editor = editor`! +>>>> +What editor program to use when launching a text editor from within the program. Defaults to the `!editor`! alias, which in turn will use the default editor of the operating system. +< + +>>> +`!glyphs = unicode`! +>>>> +Determines what set of glyphs the program uses for rendering the user interface. +>>>>> +The `!plain`! set only uses ASCII characters, and should work on all terminals, but is rather boring. +The `!unicode`! set uses more interesting glyphs and icons, and should work on most terminals. This is the default. +The `!nerdfont`! set allows using a much wider range of glyphs, icons and graphics, and should be enabled if you are using a Nerd Font in your terminal. +< + +>>> +`!mouse_enabled = yes`! +>>>> +Determines whether the program should react to mouse/touch input. Must be a boolean value. +< + +>>> +`!hide_guide = no`! +>>>> +This option allows hiding the `![ Guide ]`! section of the program. +< + +>>> +`!animation_interval = 1`! +>>>> +Sets the animation refresh rate for certain animations and graphics in the program. Must be an integer. +< + +>>> +`!colormode = 256`! +>>>> +Tells the program what color palette is supported by the terminal. Most terminals support `!256`! colors. If your terminal supports full-color / RGB-mode, set to `!24bit`!. Available options: +>>>>> +`!monochrome`! Single-color (black/white) palette, for monochrome displays +`!16`! Low-color mode for really old-school terminals +`!88`! Standard palletised color-mode for terminals +`!256`! Almost all modern terminals support this mode +`!24bit`! Most new terminals support this full-color mode +< + +>>> +`!theme = dark`! +>>>> +What color theme to use. Set it to match your terminal theme. Can be either `!dark`! or `!light`!. +< + +>> Node Section + +This section holds configuration directives related to the node hosting. It is delimited by the `![node]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!enable_node = yes`! +>>>> +Determines whether the node server should be started on launch. Must be a boolean value. +< + +>>> +`!node_name = DisplayName's Node`! +>>>> +Defines what the announced name of the node should be. +< + +>>> +`!announce_at_start = yes`! +>>>> +Determines whether your node is automatically announced on the network when the program starts. Must be a boolean value. +< + +>>> +`!announce_interval = 720`! +>>>> +Determines how often, in minutes, your node is announced on the network. Defaults to 12 hours. +< + +>>> +`!pages_path = ~/.nomadnetwork/storage/pages`! +>>>> +Determines where the node server will look for hosted pages. Must be a readable filesystem path. +< + +>>> +`!files_path = ~/.nomadnetwork/storage/files`! +>>>> +Determines where the node server will look for downloadable files. Must be a readable filesystem path. +< +''' + TOPIC_NETWORKS = '''>Network Configuration Nomad Network uses the Reticulum Network Stack for communication and encryption. This means that it will use any interfaces and communications channels already defined in your Reticulum configuration. @@ -733,9 +932,10 @@ TOPICS = { "Concepts & Terminology": TOPIC_CONCEPTS, "Conversations": TOPIC_CONVERSATIONS, "Hosting a Node": TOPIC_HOSTING, + "Configuration Options": TOPIC_CONFIG, "Markup": TOPIC_MARKUP, - "First Run": TOPIC_FIRST_RUN, "Display Test": TOPIC_DISPLAYTEST, "Network Configuration": TOPIC_NETWORKS, "Credits & Licenses": TOPIC_LICENSES, + "First Run": TOPIC_FIRST_RUN, } From 6d7e8c413e486efcb549bc61f22ca1350c1d2e94 Mon Sep 17 00:00:00 2001 From: markqvist Date: Fri, 8 Apr 2022 11:20:29 +0200 Subject: [PATCH 043/592] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 085f8ff..1675a4a 100755 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ Nomad Network does not need any connections to the public internet to work. In f The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the ideal time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion, report bugs and request features here on the GitHub project. ### Feature roadmap - - Access control and authentication for nodes, pages and files - Network-wide propagated bulletins and discussion threads - Collaborative maps - Facilitation of trade and barter From 34b377bafed94134fa700c1ed67eb8b40c5451bb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 20 Apr 2022 21:17:52 +0200 Subject: [PATCH 044/592] Fixed typo --- nomadnet/NomadNetworkApp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 2a036c1..069e1b6 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -367,7 +367,7 @@ class NomadNetworkApp: selected_node = node if selected_node == None: - RNS.log("Could not autoselect a prepagation node! LXMF propagation will not be available until a trusted node announces on the network.", RNS.LOG_WARNING) + RNS.log("Could not autoselect a propagation node! LXMF propagation will not be available until a trusted node announces on the network.", RNS.LOG_WARNING) else: node_identity = RNS.Identity.recall(selected_node.source_hash) if node_identity != None: From a917cbf01041f28236e9f1a49e1fb367832cfea6 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Tue, 26 Apr 2022 23:50:22 +0100 Subject: [PATCH 045/592] Automated docker build for nomadnet daemon Uses a Github Action (`.github/workflows/publish-container.yml`) to create a docker container image (from `Dockerfile`) that represents an extremely minimal python installation with a virtualenv holding all requirements necessary to execute `nomadnet`. New docker images are created on pushes to `master` or pushes to tags matching `*.*.*` (ie. version tags) and are retrievable with those tags. Examples: ```sh $ docker pull ghcr.io/markqvist/nomadnet:master # Print docker labels, to demonstrate the image has been retrieved $ docker inspect -f '{{json .Config.Labels}}' ghcr.io/markqvist/nomadnet:master | jq { "org.opencontainers.image.created": "2022-04-27T06:01:55.894Z", "org.opencontainers.image.description": "Communicate Freely", "org.opencontainers.image.licenses": "GPL-3.0", "org.opencontainers.image.revision": "59cffc4a9de0f276d2cc87537ff1316aed5f16dd", "org.opencontainers.image.source": "https://github.com/markqvist/NomadNet", "org.opencontainers.image.title": "NomadNet", "org.opencontainers.image.url": "https://github.com/markqvist/NomadNet", "org.opencontainers.image.version": "master" } # Run nomadnet interactively without installing it (with default config) $ docker run -it ghcr.io/markqvist/nomadnet:master # Run nomadnet as a daemon, using config stored on the host machine in specific directories $ docker run -d -v /local/path/nomadnetconfig/:/root/.nomadnetwork/ -v /local/path/reticulumconfig/:/root/.reticulum/:rw ghcr.io/markqvist/nomadnet:master ``` # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # Date: Tue Apr 26 23:50:22 2022 +0100 # # On branch dockerfile # Changes to be committed: # new file: .dockerignore # new file: .github/workflows/publish-container.yml # new file: Dockerfile # modified: README.md # # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # Date: Tue Apr 26 23:50:22 2022 +0100 # # On branch dockerfile # Changes to be committed: # new file: .dockerignore # new file: .github/workflows/publish-container.yml # new file: Dockerfile # modified: README.md # # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # Date: Tue Apr 26 23:50:22 2022 +0100 # # On branch dockerfile # Changes to be committed: # new file: .dockerignore # new file: .github/workflows/publish-container.yml # new file: Dockerfile # modified: README.md # --- .dockerignore | 1 + .github/workflows/publish-container.yml | 42 +++++++++++++++++++++++++ Dockerfile | 24 ++++++++++++++ README.md | 22 +++++++++++++ 4 files changed, 89 insertions(+) create mode 120000 .dockerignore create mode 100644 .github/workflows/publish-container.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 120000 index 0000000..3e4e48b --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml new file mode 100644 index 0000000..b013a5a --- /dev/null +++ b/.github/workflows/publish-container.yml @@ -0,0 +1,42 @@ +name: Create and publish a Docker image + +on: + push: + branches: ['master'] + tags: ['*.*.*'] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a16798 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-rc-alpine3.14 as build + +RUN apk add --no-cache build-base linux-headers libffi-dev cargo + +# Create a virtualenv that we'll copy to the published image +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip3 install setuptools-rust pyopenssl cryptography + +COPY . /app/ +RUN cd /app/ && python3 setup.py install + +# Use multi-stage build, as we don't need rust compilation on the final image +FROM python:3.11-rc-alpine3.14 + +LABEL org.opencontainers.image.documentation="https://github.com/jphastings/NomadNet#using-docker--running-a-daemon" + +ENV PATH="/opt/venv/bin:$PATH" +COPY --from=build /opt/venv /opt/venv + +VOLUME /root/.reticulum +VOLUME /root/.nomadnetwork + +ENTRYPOINT ["nomadnet"] diff --git a/README.md b/README.md index 1675a4a..f678269 100755 --- a/README.md +++ b/README.md @@ -53,6 +53,28 @@ You can install Nomad Network on Android using Termux, but there's a few more co For a native Android application with a graphical user interface, have a look at [Sideband](https://unsigned.io/sideband). +### Using docker / running a daemon + +Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the latest release) or the version number (eg `0.1.7`) for the specified version number (as tagged in this git repo). + + +```sh +$ docker pull ghcr.io/markqvist/nomadnet:master + +# Run nomadnet interactively without installing it (with default config) +$ docker run -it ghcr.io/markqvist/nomadnet:master + +# Run nomadnet as a daemon, using config stored on the host machine in specific +# directories, and allowing access to specific numbered ports openned by nomadnet +# on the host machine on the same port numbers. +$ docker run -d \ + -v /local/path/nomadnetconfig/:/root/.nomadnetwork/ \ + -v /local/path/reticulumconfig/:/root/.reticulum/ \ + -p 37428:37428 \ + -p 37429:37429 \ + ghcr.io/markqvist/nomadnet:master +``` + ## Help & Discussion For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). From c6e26e7f7fa3b9536ff9ef8c747f73aec5090900 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 13:11:04 +0200 Subject: [PATCH 046/592] Added daemon mode --- nomadnet/Conversation.py | 3 ++- nomadnet/Directory.py | 7 +++++-- nomadnet/NomadNetworkApp.py | 6 +++++- nomadnet/_version.py | 2 +- nomadnet/nomadnet.py | 7 ++++--- nomadnet/ui/MenuUI.py | 2 +- nomadnet/ui/NoneUI.py | 16 ++++++++++++++++ nomadnet/ui/__init__.py | 13 ++++++++++--- nomadnet/ui/textui/Network.py | 2 +- setup.py | 2 +- 10 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 nomadnet/ui/NoneUI.py diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 7cb2a8f..f97c7d0 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -66,7 +66,8 @@ class Conversation: except Exception as e: pass - Conversation.created_callback() + if Conversation.created_callback != None: + Conversation.created_callback() return ingested_path diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 6191500..0f10e24 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -86,7 +86,9 @@ class Directory: self.announce_stream.insert(0, (timestamp, source_hash, app_data, False)) while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: self.announce_stream.pop() - self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() def node_announce_received(self, source_hash, app_data, associated_peer): if app_data != None: @@ -101,7 +103,8 @@ class Directory: node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) self.remember(node_entry) - self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() def remove_announce_with_timestamp(self, timestamp): selected_announce = None diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 069e1b6..bf8809c 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -46,7 +46,7 @@ class NomadNetworkApp: if issubclass(e_type, KeyboardInterrupt): sys.__excepthook__(e_type, e_value, e_traceback) - def __init__(self, configdir = None, rnsconfigdir = None): + def __init__(self, configdir = None, rnsconfigdir = None, daemon = False): self.version = __version__ self.enable_client = False self.enable_node = False @@ -239,6 +239,10 @@ class NomadNetworkApp: job_thread.setDaemon(True) job_thread.start() + # Override UI choice from config on --daemon switch + if daemon: + self.uimode = nomadnet.ui.UI_NONE + # This stderr redirect is needed to stop urwid # from spewing KeyErrors to the console and thus, # messing up the UI. A pull request to fix the diff --git a/nomadnet/_version.py b/nomadnet/_version.py index f1380ee..9cb17e7 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.7" +__version__ = "0.1.8" diff --git a/nomadnet/nomadnet.py b/nomadnet/nomadnet.py index 5cc69a3..a487752 100644 --- a/nomadnet/nomadnet.py +++ b/nomadnet/nomadnet.py @@ -7,14 +7,15 @@ import argparse import nomadnet -def program_setup(configdir, rnsconfigdir): - app = nomadnet.NomadNetworkApp(configdir = configdir, rnsconfigdir = rnsconfigdir) +def program_setup(configdir, rnsconfigdir, daemon): + app = nomadnet.NomadNetworkApp(configdir = configdir, rnsconfigdir = rnsconfigdir, daemon = daemon) def main(): try: parser = argparse.ArgumentParser(description="Nomad Network Client") parser.add_argument("--config", action="store", default=None, help="path to alternative Nomad Network config directory", type=str) parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str) + parser.add_argument("-d", "--daemon", action="store_true", default=False, help="run Nomad Network in daemon mode") parser.add_argument("--version", action="version", version="Nomad Network Client {version}".format(version=__version__)) args = parser.parse_args() @@ -29,7 +30,7 @@ def main(): else: rnsconfigarg = None - program_setup(configarg, rnsconfigarg) + program_setup(configarg, rnsconfigarg, args.daemon) except KeyboardInterrupt: print("") diff --git a/nomadnet/ui/MenuUI.py b/nomadnet/ui/MenuUI.py index b5bc34b..0d7bad9 100644 --- a/nomadnet/ui/MenuUI.py +++ b/nomadnet/ui/MenuUI.py @@ -4,5 +4,5 @@ import nomadnet class MenuUI: def __init__(self): - RNS.log("Menu UI not implemented", RNS.LOG_ERROR) + RNS.log("Menu UI not implemented", RNS.LOG_ERROR, _override_destination=True) nomadnet.panic() \ No newline at end of file diff --git a/nomadnet/ui/NoneUI.py b/nomadnet/ui/NoneUI.py new file mode 100644 index 0000000..c3aee59 --- /dev/null +++ b/nomadnet/ui/NoneUI.py @@ -0,0 +1,16 @@ +import RNS +import nomadnet +import time + +from nomadnet import NomadNetworkApp + +class NoneUI: + + def __init__(self): + self.app = NomadNetworkApp.get_shared_instance() + self.app.ui = self + + RNS.log("Nomad Network started in daemon mode, all further messages are logged to "+str(self.app.logfilepath), RNS.LOG_INFO, _override_destination=True) + + while True: + time.sleep(1) \ No newline at end of file diff --git a/nomadnet/ui/__init__.py b/nomadnet/ui/__init__.py index 8fd7944..fbafc03 100644 --- a/nomadnet/ui/__init__.py +++ b/nomadnet/ui/__init__.py @@ -12,11 +12,15 @@ UI_MENU = 0x01 UI_TEXT = 0x02 UI_GRAPHICAL = 0x03 UI_WEB = 0x04 -UI_MODES = [UI_MENU, UI_TEXT, UI_GRAPHICAL, UI_WEB] +UI_MODES = [UI_NONE, UI_MENU, UI_TEXT, UI_GRAPHICAL, UI_WEB] def spawn(uimode): if uimode in UI_MODES: - RNS.log("Starting user interface...", RNS.LOG_INFO) + if uimode == UI_NONE: + RNS.log("Starting Nomad Network daemon...", RNS.LOG_INFO) + else: + RNS.log("Starting user interface...", RNS.LOG_INFO) + if uimode == UI_MENU: from .MenuUI import MenuUI return MenuUI() @@ -29,8 +33,11 @@ def spawn(uimode): elif uimode == UI_WEB: from .WebUI import WebUI return WebUI() + elif uimode == UI_NONE: + from .NoneUI import NoneUI + return NoneUI() else: return None else: - RNS.log("Invalid UI mode", RNS.LOG_ERROR) + RNS.log("Invalid UI mode", RNS.LOG_ERROR, _override_destination=True) nomadnet.panic() \ No newline at end of file diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index f080f0b..a6bb5c2 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -563,7 +563,7 @@ class KnownNodes(urwid.WidgetWrap): self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no nodes are known\n\n"), align="center")]) self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Known Nodes"), widget_style)) + urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style)) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): diff --git a/setup.py b/setup.py index 052877a..0509e47 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.4', 'lxmf>=0.1.4', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.6', 'lxmf>=0.1.6', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 73fb44dcc34fa3f36e456ba931bf6d94d6d4106c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 13:18:55 +0200 Subject: [PATCH 047/592] Updated readme and dockerfile --- Dockerfile | 4 ++-- README.md | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0a16798..1adf2af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN cd /app/ && python3 setup.py install # Use multi-stage build, as we don't need rust compilation on the final image FROM python:3.11-rc-alpine3.14 -LABEL org.opencontainers.image.documentation="https://github.com/jphastings/NomadNet#using-docker--running-a-daemon" +LABEL org.opencontainers.image.documentation="https://github.com/markqvist/NomadNet#using-docker--running-a-daemon" ENV PATH="/opt/venv/bin:$PATH" COPY --from=build /opt/venv /opt/venv @@ -21,4 +21,4 @@ COPY --from=build /opt/venv /opt/venv VOLUME /root/.reticulum VOLUME /root/.nomadnetwork -ENTRYPOINT ["nomadnet"] +ENTRYPOINT ["nomadnet --daemon"] \ No newline at end of file diff --git a/README.md b/README.md index f678269..dbe00c8 100755 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ pip3 install nomadnet # Run the client nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help ``` The first time the program is running, you will be presented with the guide section, which contains all the information you need to start using Nomad Network. @@ -48,12 +54,12 @@ If you want to try Nomad Network without building your own physical network, you **Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. -### Installing on Android +### Install on Android You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. For a native Android application with a graphical user interface, have a look at [Sideband](https://unsigned.io/sideband). -### Using docker / running a daemon +### Nomad Network Daemon in Docker Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the latest release) or the version number (eg `0.1.7`) for the specified version number (as tagged in this git repo). From bc7231def0f2b99ca44981fa77deb4a3c736087b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 13:19:35 +0200 Subject: [PATCH 048/592] Updated readme and dockerfile --- Dockerfile | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1adf2af..40bfe61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN cd /app/ && python3 setup.py install # Use multi-stage build, as we don't need rust compilation on the final image FROM python:3.11-rc-alpine3.14 -LABEL org.opencontainers.image.documentation="https://github.com/markqvist/NomadNet#using-docker--running-a-daemon" +LABEL org.opencontainers.image.documentation="https://github.com/markqvist/NomadNet#nomad-network-daemon-with-docker" ENV PATH="/opt/venv/bin:$PATH" COPY --from=build /opt/venv /opt/venv diff --git a/README.md b/README.md index dbe00c8..0be82ae 100755 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ You can install Nomad Network on Android using Termux, but there's a few more co For a native Android application with a graphical user interface, have a look at [Sideband](https://unsigned.io/sideband). -### Nomad Network Daemon in Docker +### Nomad Network Daemon with Docker Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the latest release) or the version number (eg `0.1.7`) for the specified version number (as tagged in this git repo). From 2b58a7840aac957f3ab003083113bd691a78ae8f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 14:15:29 +0200 Subject: [PATCH 049/592] Improved LXMF peer display --- nomadnet/ui/textui/Network.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index a6bb5c2..2c897d5 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -382,9 +382,9 @@ class KnownNodeInfo(urwid.WidgetWrap): addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" if node_ident != None: - lxmf_addr_str = RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident)) + lxmf_addr_str = "Runs LXMF Propagation Node "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident)) else: - lxmf_addr_str = "Unknown" + lxmf_addr_str = "No associated Propagation Node known" type_string = "Node " + g["node"] @@ -476,11 +476,10 @@ class KnownNodeInfo(urwid.WidgetWrap): pile_widgets = [ urwid.Text("Type : "+type_string, align="left"), - # urwid.Text("Name : "+display_str, align="left"), e_name, urwid.Text("Node Addr : "+addr_str, align="left"), - urwid.Text("LXMF Addr : "+lxmf_addr_str, align="left"), - # urwid.Text(["Trust : ", (style, trust_str)], align="left"), + urwid.Divider(g["divider1"]), + urwid.Text(lxmf_addr_str, align="left"), urwid.Divider(g["divider1"]), propagation_node_checkbox, connect_identify_checkbox, @@ -500,7 +499,7 @@ class KnownNodeInfo(urwid.WidgetWrap): op_str = "Unknown" operator_entry = urwid.Text("Operator : "+op_str, align="left") - pile_widgets.insert(4, operator_entry) + pile_widgets.insert(3, operator_entry) hops = RNS.Transport.hops_to(source_hash) if hops == 1: @@ -514,7 +513,7 @@ class KnownNodeInfo(urwid.WidgetWrap): hops_str = "Unknown" operator_entry = urwid.Text("Distance : "+hops_str, align="left") - pile_widgets.insert(5, operator_entry) + pile_widgets.insert(4, operator_entry) pile = urwid.Pile(pile_widgets) @@ -1212,7 +1211,7 @@ class LXMFPeers(urwid.WidgetWrap): self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no LXMF nodes are peered\n\n"), align="center")]) self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="LXMF Peers"), widget_style)) + urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="LXMF Propagation Peers"), widget_style)) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): @@ -1269,13 +1268,13 @@ class LXMFPeerEntry(urwid.WidgetWrap): node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", node_identity) display_name = self.app.directory.alleged_display_str(node_hash) if display_name != None: - display_str += "\n "+str(display_name) + display_str += " "+str(display_name) sym = g["sent"] style = "list_unknown" focus_style = "list_focus" - widget = ListEntry(sym+" "+display_str+"\n Last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled messages") + widget = ListEntry(sym+" "+display_str+"\n "+str(len(peer.unhandled_messages))+" unhandled messages - "+"Last heard "+pretty_date(int(peer.last_heard))) # urwid.connect_signal(widget, "click", delegate.connect_node, node) self.display_widget = urwid.AttrMap(widget, style, focus_style) From 1cdea6c1ab08d897a4c4bdfdbddd0781994da5fb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 14:33:37 +0200 Subject: [PATCH 050/592] Improved node info display --- nomadnet/ui/textui/Network.py | 50 ++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 2c897d5..40c1814 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -430,16 +430,45 @@ class KnownNodeInfo(urwid.WidgetWrap): e_name = urwid.Edit(caption="Name : ",edit_text=display_str) + node_ident = RNS.Identity.recall(source_hash) + op_hash = None + op_str = None + if node_ident != None: + op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) + op_str = self.app.directory.simplest_display_str(op_hash) + else: + op_str = "Unknown" + def show_known_nodes(sender): options = self.parent.left_pile.options(height_type="weight", height_amount=1) self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) def connect(sender): - # TODO: Remove when new mitigation has been tested - # self.app.ui.main_display.request_redraw(extra_delay=0.75) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_known_nodes(None) + def msg_op(sender): + show_known_nodes(None) + if node_ident != None: + try: + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + source_hash_text = RNS.hexrep(op_hash, delimit=False) + display_name = op_str + + if not source_hash_text in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(source_hash, display_name, trust_level) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list() + + self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text) + self.app.ui.main_display.show_conversations(None) + + except Exception as e: + RNS.log("Error while starting conversation from node info. The contained exception was: "+str(e), RNS.LOG_ERROR) + def pn_change(sender, userdata): self.pn_changed = True @@ -470,9 +499,13 @@ class KnownNodeInfo(urwid.WidgetWrap): self.app.ui.main_display.sub_displays.network_display.directory_change_callback() show_known_nodes(None) - type_button = ("weight", 0.45, urwid.Button("Connect", on_press=connect)) - save_button = ("weight", 0.45, urwid.Button("Save", on_press=save_node)) - button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_known_nodes)), ("weight", 0.1, urwid.Text("")), save_button, ("weight", 0.1, urwid.Text("")), type_button]) + back_button = ("weight", 0.2, urwid.Button("Back", on_press=show_known_nodes)) + connect_button = ("weight", 0.2, urwid.Button("Connect", on_press=connect)) + save_button = ("weight", 0.2, urwid.Button("Save", on_press=save_node)) + msg_button = ("weight", 0.2, urwid.Button("Msg Op", on_press=msg_op)) + bdiv = ("weight", 0.02, urwid.Text("")) + + button_columns = urwid.Columns([back_button, bdiv, connect_button, bdiv, msg_button, bdiv, save_button]) pile_widgets = [ urwid.Text("Type : "+type_string, align="left"), @@ -491,13 +524,6 @@ class KnownNodeInfo(urwid.WidgetWrap): button_columns ] - node_ident = RNS.Identity.recall(source_hash) - if node_ident != None: - op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) - op_str = self.app.directory.simplest_display_str(op_hash) - else: - op_str = "Unknown" - operator_entry = urwid.Text("Operator : "+op_str, align="left") pile_widgets.insert(3, operator_entry) From 2e085d2d8de6c0fa0607d34fdb8e000a05745b5a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 14:42:46 +0200 Subject: [PATCH 051/592] Improved announce info display --- nomadnet/ui/textui/Network.py | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 40c1814..32c90ce 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -118,6 +118,33 @@ class AnnounceInfo(urwid.WidgetWrap): self.app.ui.main_display.sub_displays.network_display.directory_change_callback() show_announce_stream(None) + if is_node: + node_ident = RNS.Identity.recall(source_hash) + op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) + op_str = self.app.directory.simplest_display_str(op_hash) + + def msg_op(sender): + show_announce_stream(None) + if is_node: + try: + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + source_hash_text = RNS.hexrep(op_hash, delimit=False) + display_name = op_str + + if not source_hash_text in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(source_hash, display_name, trust_level) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list() + + self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text) + self.app.ui.main_display.show_conversations(None) + + except Exception as e: + RNS.log("Error while starting conversation from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) + def converse(sender): show_announce_stream(None) try: @@ -141,13 +168,14 @@ class AnnounceInfo(urwid.WidgetWrap): if is_node: type_button = ("weight", 0.45, urwid.Button("Connect", on_press=connect)) + msg_button = ("weight", 0.45, urwid.Button("Msg Op", on_press=msg_op)) save_button = ("weight", 0.45, urwid.Button("Save", on_press=save_node)) else: type_button = ("weight", 0.45, urwid.Button("Converse", on_press=converse)) save_button = None if is_node: - button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_announce_stream)), ("weight", 0.1, urwid.Text("")), save_button, ("weight", 0.1, urwid.Text("")), type_button]) + button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_announce_stream)), ("weight", 0.1, urwid.Text("")), type_button, ("weight", 0.1, urwid.Text("")), msg_button, ("weight", 0.1, urwid.Text("")), save_button]) else: button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_announce_stream)), ("weight", 0.1, urwid.Text("")), type_button]) @@ -164,9 +192,6 @@ class AnnounceInfo(urwid.WidgetWrap): ] if is_node: - node_ident = RNS.Identity.recall(source_hash) - op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) - op_str = self.app.directory.simplest_display_str(op_hash) operator_entry = urwid.Text("Oprtr : "+op_str, align="left") pile_widgets.insert(4, operator_entry) From 3cd336ab909b0c91ed2c917d47a9d1084b67c935 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 16:12:00 +0200 Subject: [PATCH 052/592] Improved local node and peer info --- nomadnet/Node.py | 29 +++++- nomadnet/NomadNetworkApp.py | 9 ++ nomadnet/ui/textui/Network.py | 179 ++++++++++++++++++++++++++++++---- 3 files changed, 195 insertions(+), 22 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index e1ea5df..018d43b 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -24,6 +24,8 @@ class Node: self.register_pages() self.register_files() + self.destination.set_link_established_callback(self.peer_connected) + if self.name == None: self.name = self.app.peer_settings["display_name"]+"'s Node" @@ -97,6 +99,12 @@ class Node: def serve_page(self, path, data, request_id, remote_identity, requested_at): RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) + try: + self.app.peer_settings["served_page_requests"] += 1 + self.app.save_peer_settings() + + except Exception as e: + RNS.log("Could not increase served page request count", RNS.LOG_ERROR) file_path = path.replace("/page", self.app.pagespath, 1) @@ -163,6 +171,13 @@ class Node: # TODO: Improve file handling, this will be slow for large files def serve_file(self, path, data, request_id, remote_identity, requested_at): RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) + try: + self.app.peer_settings["served_file_requests"] += 1 + self.app.save_peer_settings() + + except Exception as e: + RNS.log("Could not increase served file request count", RNS.LOG_ERROR) + file_path = path.replace("/file", self.app.filespath, 1) file_name = path.replace("/file/", "", 1) try: @@ -197,10 +212,20 @@ class Node: time.sleep(self.job_interval) - def peer_connected(link): - RNS.log("Peer connected to "+str(self.destination), RNS.LOG_INFO) + def peer_connected(self, link): + RNS.log("Peer connected to "+str(self.destination), RNS.LOG_VERBOSE) + try: + self.app.peer_settings["node_connects"] += 1 + self.app.save_peer_settings() + + except Exception as e: + RNS.log("Could not increase node connection count", RNS.LOG_ERROR) + link.set_link_closed_callback(self.peer_disconnected) + def peer_disconnected(self, link): + RNS.log("Peer disconnected from "+str(self.destination), RNS.LOG_VERBOSE) + pass DEFAULT_INDEX = '''>Default Home Page diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index bf8809c..a441703 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -171,6 +171,15 @@ class NomadNetworkApp: if not "last_lxmf_sync" in self.peer_settings: self.peer_settings["last_lxmf_sync"] = 0 + if not "node_connects" in self.peer_settings: + self.peer_settings["node_connects"] = 0 + + if not "served_page_requests" in self.peer_settings: + self.peer_settings["served_page_requests"] = 0 + + if not "served_file_requests" in self.peer_settings: + self.peer_settings["served_file_requests"] = 0 + except Exception as e: RNS.log("Could not load local peer settings from "+self.peersettingspath, RNS.LOG_ERROR) RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 32c90ce..acfd4cf 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -407,7 +407,7 @@ class KnownNodeInfo(urwid.WidgetWrap): addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" if node_ident != None: - lxmf_addr_str = "Runs LXMF Propagation Node "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident)) + lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident)) else: lxmf_addr_str = "No associated Propagation Node known" @@ -537,7 +537,7 @@ class KnownNodeInfo(urwid.WidgetWrap): e_name, urwid.Text("Node Addr : "+addr_str, align="left"), urwid.Divider(g["divider1"]), - urwid.Text(lxmf_addr_str, align="left"), + urwid.Text(lxmf_addr_str, align="center"), urwid.Divider(g["divider1"]), propagation_node_checkbox, connect_identify_checkbox, @@ -806,7 +806,7 @@ class NodeAnnounceTime(urwid.WidgetWrap): if self.app.peer_settings["node_last_announce"] != None: self.last_announce_string = pretty_date(int(self.app.peer_settings["node_last_announce"])) - self.display_widget.set_text("Last Announce : "+self.last_announce_string) + self.display_widget.set_text("Last Announce : "+self.last_announce_string) def update_time_callback(self, loop=None, user_data=None): self.update_time() @@ -837,7 +837,103 @@ class NodeActiveConnections(urwid.WidgetWrap): if self.app.node != None: self.stat_string = str(len(self.app.node.destination.links)) - self.display_widget.set_text("Connected Peers : "+self.stat_string) + self.display_widget.set_text("Connections Now : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False + + +class NodeTotalConnections(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + urwid.WidgetWrap.__init__(self, self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None: + self.stat_string = str(self.app.peer_settings["node_connects"]) + + self.display_widget.set_text("Total Connections : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False + + +class NodeTotalPages(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + urwid.WidgetWrap.__init__(self, self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None: + self.stat_string = str(self.app.peer_settings["served_page_requests"]) + + self.display_widget.set_text("Served Pages : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False + + +class NodeTotalFiles(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + urwid.WidgetWrap.__init__(self, self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None: + self.stat_string = str(self.app.peer_settings["served_file_requests"]) + + self.display_widget.set_text("Served Files : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -873,7 +969,7 @@ class LocalPeer(urwid.WidgetWrap): def save_query(sender): def dismiss_dialog(sender): self.dialog_open = False - self.parent.left_pile.contents[2] = (LocalPeer(self.app, self.parent), options) + self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) self.app.set_display_name(e_name.get_edit_text()) @@ -890,19 +986,19 @@ class LocalPeer(urwid.WidgetWrap): overlay = dialog options = self.parent.left_pile.options(height_type="pack", height_amount=None) self.dialog_open = True - self.parent.left_pile.contents[2] = (overlay, options) + self.parent.left_pile.contents[1] = (overlay, options) def announce_query(sender): def dismiss_dialog(sender): self.dialog_open = False options = self.parent.left_pile.options(height_type="pack", height_amount=None) - self.parent.left_pile.contents[2] = (LocalPeer(self.app, self.parent), options) + self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) self.app.announce_now() dialog = DialogLineBox( urwid.Pile([ - urwid.Text("\n\n\nAnnounce Sent\n\n", align="center"), + urwid.Text("\n\n\nAnnounce Sent\n\n\n", align="center"), urwid.Button("OK", on_press=dismiss_dialog) ]), title=g["info"] ) @@ -914,11 +1010,11 @@ class LocalPeer(urwid.WidgetWrap): self.dialog_open = True options = self.parent.left_pile.options(height_type="pack", height_amount=None) - self.parent.left_pile.contents[2] = (overlay, options) + self.parent.left_pile.contents[1] = (overlay, options) def node_info_query(sender): options = self.parent.left_pile.options(height_type="pack", height_amount=None) - self.parent.left_pile.contents[2] = (self.parent.node_info_display, options) + self.parent.left_pile.contents[1] = (self.parent.node_info_display, options) if LocalPeer.announce_timer == None: self.t_last_announce = AnnounceTime(self.app) @@ -951,6 +1047,9 @@ class LocalPeer(urwid.WidgetWrap): class NodeInfo(urwid.WidgetWrap): announce_timer = None links_timer = None + conns_timer = None + pages_timer = None + files_timer = None def __init__(self, app, parent): self.app = app @@ -963,7 +1062,7 @@ class NodeInfo(urwid.WidgetWrap): def show_peer_info(sender): options = self.parent.left_pile.options(height_type="pack", height_amount=None) - self.parent.left_pile.contents[2] = (LocalPeer(self.app, self.parent), options) + self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) if self.app.enable_node: if self.app.node != None: @@ -977,11 +1076,17 @@ class NodeInfo(urwid.WidgetWrap): t_id = urwid.Text("Addr : "+RNS.hexrep(self.app.node.destination.hash, delimit=False)) e_name = urwid.Text("Name : "+display_name) + def stats_query(sender): + self.app.peer_settings["node_connects"] = 0 + self.app.peer_settings["served_page_requests"] = 0 + self.app.peer_settings["served_file_requests"] = 0 + self.app.save_peer_settings() + def announce_query(sender): def dismiss_dialog(sender): self.dialog_open = False options = self.parent.left_pile.options(height_type="pack", height_amount=None) - self.parent.left_pile.contents[2] = (NodeInfo(self.app, self.parent), options) + self.parent.left_pile.contents[1] = (NodeInfo(self.app, self.parent), options) self.app.node.announce() @@ -999,7 +1104,7 @@ class NodeInfo(urwid.WidgetWrap): self.dialog_open = True options = self.parent.left_pile.options(height_type="pack", height_amount=None) - self.parent.left_pile.contents[2] = (overlay, options) + self.parent.left_pile.contents[1] = (overlay, options) def connect_query(sender): self.parent.browser.retrieve_url(RNS.hexrep(self.app.node.destination.hash, delimit=False)) @@ -1018,22 +1123,54 @@ class NodeInfo(urwid.WidgetWrap): self.t_active_links = NodeInfo.links_timer self.t_active_links.update_stat() + if NodeInfo.conns_timer == None: + self.t_total_connections = NodeTotalConnections(self.app) + NodeInfo.conns_timer = self.t_total_connections + else: + self.t_total_connections = NodeInfo.conns_timer + self.t_total_connections.update_stat() + + if NodeInfo.pages_timer == None: + self.t_total_pages = NodeTotalPages(self.app) + NodeInfo.pages_timer = self.t_total_pages + else: + self.t_total_pages = NodeInfo.pages_timer + self.t_total_pages.update_stat() + + if NodeInfo.files_timer == None: + self.t_total_files = NodeTotalFiles(self.app) + NodeInfo.files_timer = self.t_total_files + else: + self.t_total_files = NodeInfo.files_timer + self.t_total_files.update_stat() + + lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", self.app.node.destination.identity)) + e_lxmf = urwid.Text(lxmf_addr_str, align="center") + announce_button = urwid.Button("Announce", on_press=announce_query) connect_button = urwid.Button("Browse", on_press=connect_query) + reset_button = urwid.Button("Rst Stats", on_press=stats_query) pile = urwid.Pile([ t_id, e_name, urwid.Divider(g["divider1"]), + e_lxmf, + urwid.Divider(g["divider1"]), self.t_last_announce, self.t_active_links, + self.t_total_connections, + self.t_total_pages, + self.t_total_files, urwid.Divider(g["divider1"]), urwid.Columns([ - ("weight", 0.3, urwid.Button("Back", on_press=show_peer_info)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.4, connect_button), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.5, announce_button) + ("weight", 5, urwid.Button("Back", on_press=show_peer_info)), + ("weight", 0.5, urwid.Text("")), + ("weight", 6, connect_button), + ("weight", 0.5, urwid.Text("")), + ("weight", 8, reset_button), + ("weight", 0.5, urwid.Text("")), + ("weight", 7, announce_button), ]) ]) else: @@ -1051,7 +1188,9 @@ class NodeInfo(urwid.WidgetWrap): if self.app.node != None: self.t_last_announce.start() self.t_active_links.start() - + self.t_total_connections.start() + self.t_total_pages.start() + self.t_total_files.start() class UpdatingText(urwid.WidgetWrap): @@ -1153,7 +1292,7 @@ class NetworkDisplay(): self.list_display = 1 self.left_pile = NetworkLeftPile([ ("weight", 1, self.known_nodes_display), - ("pack", self.network_stats_display), + # ("pack", self.network_stats_display), ("pack", self.local_peer_display), ]) From 13484a665a8519607d2b1b6427f58f4515a7b90d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 18:37:29 +0200 Subject: [PATCH 053/592] Improved link rendering and target handling in text blocks and with alignment. Fixes #4. --- nomadnet/ui/textui/MicronParser.py | 32 ++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index bd52de1..7a8ff7e 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -528,7 +528,6 @@ class LinkableText(urwid.Text): if self.delegate != None: self.delegate.handle_link(link_target) - def find_next_part_pos(self, pos, part_positions): for position in part_positions: if position > pos: @@ -596,7 +595,12 @@ class LinkableText(urwid.Text): return key elif key == "right": + old = self._cursor_position self._cursor_position = self.find_next_part_pos(self._cursor_position, part_positions) + if self._cursor_position == old: + self._cursor_position = 0 + return "down" + self._invalidate() elif key == "left": @@ -641,9 +645,33 @@ class LinkableText(urwid.Text): if button != 1 or not is_mouse_press(event): return False else: - pos = (y * size[0]) + x + (maxcol,) = size + translation = self.get_line_translation(maxcol) + line_offset = 0 + + if self.align == "center": + line_offset = translation[y][1][1]-translation[y][0][0] + if x < translation[y][0][0]: + x = translation[y][0][0] + + if x > translation[y][1][0]+translation[y][0][0]: + x = translation[y][1][0]+translation[y][0][0] + + elif self.align == "right": + line_offset = translation[y][1][1]-translation[y][0][0] + if x < translation[y][0][0]: + x = translation[y][0][0] + + else: + line_offset = translation[y][0][1] + if x > translation[y][0][0]: + x = translation[y][0][0] + + pos = line_offset+x + self._cursor_position = pos item = self.find_item_at_pos(self._cursor_position) + if item != None: if isinstance(item, LinkSpec): self.handle_link(item.link_target) From f92858866a1a33352633da867bd00a67e0b60172 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 19:21:14 +0200 Subject: [PATCH 054/592] Added destination ignore functionality --- nomadnet/Conversation.py | 31 ++++++++++++++++++------------- nomadnet/Directory.py | 13 +++++++++---- nomadnet/NomadNetworkApp.py | 25 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index f97c7d0..6d66219 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -14,20 +14,25 @@ class Conversation: @staticmethod def received_announce(destination_hash, announced_identity, app_data): app = nomadnet.NomadNetworkApp.get_shared_instance() - destination_hash_text = RNS.hexrep(destination_hash, delimit=False) - # Check if the announced destination is in - # our list of conversations - if destination_hash_text in [e[0] for e in Conversation.conversation_list(app)]: - if app.directory.find(destination_hash): - if Conversation.created_callback != None: - Conversation.created_callback() - else: - if Conversation.created_callback != None: - Conversation.created_callback() - # Add the announce to the directory announce - # stream logger - app.directory.lxmf_announce_received(destination_hash, app_data) + if not destination_hash in app.ignored_list: + destination_hash_text = RNS.hexrep(destination_hash, delimit=False) + # Check if the announced destination is in + # our list of conversations + if destination_hash_text in [e[0] for e in Conversation.conversation_list(app)]: + if app.directory.find(destination_hash): + if Conversation.created_callback != None: + Conversation.created_callback() + else: + if Conversation.created_callback != None: + Conversation.created_callback() + + # Add the announce to the directory announce + # stream logger + app.directory.lxmf_announce_received(destination_hash, app_data) + + else: + RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) @staticmethod def query_for_peer(source_hash): diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 0f10e24..9d3029b 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -12,12 +12,17 @@ class Directory: @staticmethod def received_announce(destination_hash, announced_identity, app_data): app = nomadnet.NomadNetworkApp.get_shared_instance() - destination_hash_text = RNS.hexrep(destination_hash, delimit=False) - associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) + if not destination_hash in app.ignored_list: + destination_hash_text = RNS.hexrep(destination_hash, delimit=False) - app.directory.node_announce_received(destination_hash, app_data, associated_peer) - app.autoselect_propagation_node() + associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) + + app.directory.node_announce_received(destination_hash, app_data, associated_peer) + app.autoselect_propagation_node() + + else: + RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) def __init__(self, app): diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index a441703..62b4865 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -65,6 +65,7 @@ class NomadNetworkApp: self.rns = RNS.Reticulum(configdir = rnsconfigdir) self.configpath = self.configdir+"/config" + self.ignoredpath = self.configdir+"/ignored" self.logfilepath = self.configdir+"/logfile" self.errorfilepath = self.configdir+"/errors" self.storagepath = self.configdir+"/storage" @@ -202,12 +203,36 @@ class NomadNetworkApp: RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) nomadnet.panic() + self.ignored_list = [] + if os.path.isfile(self.ignoredpath): + try: + fh = open(self.ignoredpath, "rb") + ignored_input = fh.read() + fh.close() + + ignored_hash_strs = ignored_input.splitlines() + + for hash_str in ignored_hash_strs: + if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: + try: + ignored_hash = bytes.fromhex(hash_str.decode("utf-8")) + self.ignored_list.append(ignored_hash) + + except Exception as e: + RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while fetching loading list of ignored destinations: "+str(e), RNS.LOG_ERROR) self.directory = nomadnet.Directory(self) self.message_router = LXMF.LXMRouter(identity = self.identity, storagepath = self.storagepath, autopeer = True) self.message_router.register_delivery_callback(self.lxmf_delivery) + for destination_hash in self.ignored_list: + self.message_router.ignore_destination(destination_hash) + self.lxmf_destination = self.message_router.register_delivery_identity(self.identity, display_name=self.peer_settings["display_name"]) self.lxmf_destination.set_default_app_data(self.get_display_name_bytes) From 3ec82b2775c3a1baad703b94ea1eab7b7318c302 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 19:42:01 +0200 Subject: [PATCH 055/592] Updated guide --- nomadnet/ui/textui/Guide.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index e7a02e6..558aa0c 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -551,6 +551,10 @@ Determines where the node server will look for hosted pages. Must be a readable >>>> Determines where the node server will look for downloadable files. Must be a readable filesystem path. < + +>Ignoring Destinations + +If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 20-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. ''' TOPIC_NETWORKS = '''>Network Configuration From a09d79f27c8608d0fb2f6c168262aa30f83b7e02 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 22:34:35 +0200 Subject: [PATCH 056/592] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0be82ae..fac9ed5 100755 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The current version of the program should be considered a beta release. The prog ### Feature roadmap - Network-wide propagated bulletins and discussion threads - - Collaborative maps + - Collaborative maps and geospatial information sharing - Facilitation of trade and barter ## How do I get started? From d8e84409c7223171f957c33617178154f0f2bb44 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 22:43:08 +0200 Subject: [PATCH 057/592] Fixed dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 40bfe61..649ec68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ COPY --from=build /opt/venv /opt/venv VOLUME /root/.reticulum VOLUME /root/.nomadnetwork -ENTRYPOINT ["nomadnet --daemon"] \ No newline at end of file +ENTRYPOINT ["nomadnet"] \ No newline at end of file From b312bd97e02c07a4b05df9a8f8c7917030325146 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 May 2022 22:44:38 +0200 Subject: [PATCH 058/592] Run nomadnet as daemon in docker --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 649ec68..d1c016d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ COPY --from=build /opt/venv /opt/venv VOLUME /root/.reticulum VOLUME /root/.nomadnetwork -ENTRYPOINT ["nomadnet"] \ No newline at end of file +ENTRYPOINT ["nomadnet", "--daemon"] \ No newline at end of file From 3fc321e80473f4e05d1156878b658a36e7eff01b Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Wed, 18 May 2022 08:56:45 +0100 Subject: [PATCH 059/592] Move --daemon to CMD from ENTRYPOINT I've moved the `--daemon` command from `ENTRYPOINT` to `CMD` to allow users of the dockerfile some flexibility The executable and/or arguments in `CMD` can be overridden easily by users of the container, but `ENTRYPOINT` is intended as a "this is always at the start of the command". ([reference](https://docs.docker.com/engine/reference/builder/#cmd)) For example, with the change in this commit: - `docker run ghcr.io/markqvist/nomadnet:master --help` will output the same as `nomadnet --help` - `docker run ghcr.io/markqvist/nomadnet:master` will run `nomadnet --daemon` - `docker run ghcr.io/markqvist/nomadnet:master ""` will run `nomadnet` (if we wanted this to be a little more self-explaining, there could be a `--ui` flag which runs the UI and is default-on, so `docker run ghcr.io/markqvist/nomadnet:master --ui` would work) --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d1c016d..ea3e1b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,5 @@ COPY --from=build /opt/venv /opt/venv VOLUME /root/.reticulum VOLUME /root/.nomadnetwork -ENTRYPOINT ["nomadnet", "--daemon"] \ No newline at end of file +ENTRYPOINT ["nomadnet"] +CMD ["--daemon"] From d65d03fe8987cc70433fd05762db339ea929a5ab Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 18 May 2022 14:00:13 +0200 Subject: [PATCH 060/592] Added argument for text ui --- nomadnet/nomadnet.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nomadnet/nomadnet.py b/nomadnet/nomadnet.py index a487752..c48151d 100644 --- a/nomadnet/nomadnet.py +++ b/nomadnet/nomadnet.py @@ -16,6 +16,7 @@ def main(): parser.add_argument("--config", action="store", default=None, help="path to alternative Nomad Network config directory", type=str) parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str) parser.add_argument("-d", "--daemon", action="store_true", default=False, help="run Nomad Network in daemon mode") + parser.add_argument("-t", "--textui", action="store_true", default=False, help="run Nomad Network in text-UI mode") parser.add_argument("--version", action="version", version="Nomad Network Client {version}".format(version=__version__)) args = parser.parse_args() @@ -30,7 +31,15 @@ def main(): else: rnsconfigarg = None - program_setup(configarg, rnsconfigarg, args.daemon) + if args.daemon: + daemon = True + else: + daemon = False + + if args.textui: + daemon = False + + program_setup(configarg, rnsconfigarg, daemon) except KeyboardInterrupt: print("") From a6eac0d7ced8b26c855dfd9bb0e88e17ac19a4f7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 18 May 2022 16:18:13 +0200 Subject: [PATCH 061/592] Configure python for unbuffered stdout in Docker images --- Dockerfile | 1 + README.md | 29 ++++++++++++++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index ea3e1b3..0944827 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ FROM python:3.11-rc-alpine3.14 LABEL org.opencontainers.image.documentation="https://github.com/markqvist/NomadNet#nomad-network-daemon-with-docker" ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED="yes" COPY --from=build /opt/venv /opt/venv VOLUME /root/.reticulum diff --git a/README.md b/README.md index fac9ed5..474ec42 100755 --- a/README.md +++ b/README.md @@ -59,25 +59,32 @@ You can install Nomad Network on Android using Termux, but there's a few more co For a native Android application with a graphical user interface, have a look at [Sideband](https://unsigned.io/sideband). -### Nomad Network Daemon with Docker - -Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the latest release) or the version number (eg `0.1.7`) for the specified version number (as tagged in this git repo). +### Docker Images +Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the very latest commit) or the version number (eg `0.1.8`) for a specific release. ```sh $ docker pull ghcr.io/markqvist/nomadnet:master # Run nomadnet interactively without installing it (with default config) -$ docker run -it ghcr.io/markqvist/nomadnet:master +$ docker run -it ghcr.io/markqvist/nomadnet:master --textui -# Run nomadnet as a daemon, using config stored on the host machine in specific -# directories, and allowing access to specific numbered ports openned by nomadnet -# on the host machine on the same port numbers. +# Run nomadnet as a daemon, using config stored on the host machine in specified +# directories, and connect the containers network to the host network (which will +# allow the default AutoInterface to automatically peer with other discovered +# Reticulum instances). $ docker run -d \ - -v /local/path/nomadnetconfig/:/root/.nomadnetwork/ \ - -v /local/path/reticulumconfig/:/root/.reticulum/ \ - -p 37428:37428 \ - -p 37429:37429 \ + -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ + -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ + --network host + ghcr.io/markqvist/nomadnet:master + +# You can also keep the network of the container isolated from the host, but you +# will need to manually configure one or more Reticulum interfaces to reach other +# nodes in a network, by editing the Reticulum configuration file. +$ docker run -d \ + -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ + -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ ghcr.io/markqvist/nomadnet:master ``` From 78147121181385afe198b6b5e6aa68ac41e71be6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 18 May 2022 16:33:28 +0200 Subject: [PATCH 062/592] Added log to console option for daemon mode --- nomadnet/NomadNetworkApp.py | 9 +++++++-- nomadnet/nomadnet.py | 17 +++++++++++++---- nomadnet/ui/NoneUI.py | 5 ++++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 62b4865..69af68e 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -46,7 +46,7 @@ class NomadNetworkApp: if issubclass(e_type, KeyboardInterrupt): sys.__excepthook__(e_type, e_value, e_traceback) - def __init__(self, configdir = None, rnsconfigdir = None, daemon = False): + def __init__(self, configdir = None, rnsconfigdir = None, daemon = False, force_console = False): self.version = __version__ self.enable_client = False self.enable_node = False @@ -59,6 +59,11 @@ class NomadNetworkApp: else: self.configdir = configdir + if force_console: + self.force_console_log = True + else: + self.force_console_log = False + if NomadNetworkApp._shared_instance == None: NomadNetworkApp._shared_instance = self @@ -490,7 +495,7 @@ class NomadNetworkApp: if RNS.loglevel > 7: RNS.loglevel = 7 if option == "destination": - if value.lower() == "file": + if value.lower() == "file" and not self.force_console_log: RNS.logdest = RNS.LOG_FILE if "logfile" in self.config["logging"]: self.logfilepath = self.config["logging"]["logfile"] diff --git a/nomadnet/nomadnet.py b/nomadnet/nomadnet.py index c48151d..cb8591f 100644 --- a/nomadnet/nomadnet.py +++ b/nomadnet/nomadnet.py @@ -7,16 +7,22 @@ import argparse import nomadnet -def program_setup(configdir, rnsconfigdir, daemon): - app = nomadnet.NomadNetworkApp(configdir = configdir, rnsconfigdir = rnsconfigdir, daemon = daemon) +def program_setup(configdir, rnsconfigdir, daemon, console): + app = nomadnet.NomadNetworkApp( + configdir = configdir, + rnsconfigdir = rnsconfigdir, + daemon = daemon, + force_console = console, + ) def main(): try: parser = argparse.ArgumentParser(description="Nomad Network Client") parser.add_argument("--config", action="store", default=None, help="path to alternative Nomad Network config directory", type=str) parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str) - parser.add_argument("-d", "--daemon", action="store_true", default=False, help="run Nomad Network in daemon mode") parser.add_argument("-t", "--textui", action="store_true", default=False, help="run Nomad Network in text-UI mode") + parser.add_argument("-d", "--daemon", action="store_true", default=False, help="run Nomad Network in daemon mode") + parser.add_argument("-c", "--console", action="store_true", default=False, help="in daemon mode, log to console instead of file") parser.add_argument("--version", action="version", version="Nomad Network Client {version}".format(version=__version__)) args = parser.parse_args() @@ -31,15 +37,18 @@ def main(): else: rnsconfigarg = None + console = False if args.daemon: daemon = True + if args.console: + console = True else: daemon = False if args.textui: daemon = False - program_setup(configarg, rnsconfigarg, daemon) + program_setup(configarg, rnsconfigarg, daemon, console) except KeyboardInterrupt: print("") diff --git a/nomadnet/ui/NoneUI.py b/nomadnet/ui/NoneUI.py index c3aee59..40a58cc 100644 --- a/nomadnet/ui/NoneUI.py +++ b/nomadnet/ui/NoneUI.py @@ -10,7 +10,10 @@ class NoneUI: self.app = NomadNetworkApp.get_shared_instance() self.app.ui = self - RNS.log("Nomad Network started in daemon mode, all further messages are logged to "+str(self.app.logfilepath), RNS.LOG_INFO, _override_destination=True) + if not self.app.force_console_log: + RNS.log("Nomad Network started in daemon mode, all further messages are logged to "+str(self.app.logfilepath), RNS.LOG_INFO, _override_destination=True) + else: + RNS.log("Nomad Network daemon started", RNS.LOG_INFO) while True: time.sleep(1) \ No newline at end of file From fa2382e34a24b8de61162c70ad8559857292c014 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 18 May 2022 16:53:17 +0200 Subject: [PATCH 063/592] Updated readme --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 474ec42..8271d94 100755 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Nomad Network is automatically published as a docker image on Github Packages. I ```sh $ docker pull ghcr.io/markqvist/nomadnet:master -# Run nomadnet interactively without installing it (with default config) +# Run nomadnet interactively in a container $ docker run -it ghcr.io/markqvist/nomadnet:master --textui # Run nomadnet as a daemon, using config stored on the host machine in specified @@ -86,6 +86,9 @@ $ docker run -d \ -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ ghcr.io/markqvist/nomadnet:master + +# Send daemon log output to console instead of file +$ docker run -i ghcr.io/markqvist/nomadnet:master --daemon --console ``` ## Help & Discussion From 2354ba3412694108c7d68c2004e119ec20c95149 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 11 Jun 2022 15:03:43 +0200 Subject: [PATCH 064/592] Fixed missing dictionary initialisations --- nomadnet/NomadNetworkApp.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 69af68e..c04e8c7 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -200,6 +200,9 @@ class NomadNetworkApp: "node_last_announce": None, "propagation_node": None, "last_lxmf_sync": 0, + "node_connects": 0, + "served_page_requests": 0, + "served_file_requests": 0 } self.save_peer_settings() RNS.log("Created new peer settings file") From 7a625d677dca0e3e3568751253a9ef8bd3602dba Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 17 Jun 2022 13:43:38 +0200 Subject: [PATCH 065/592] Added handling for authenticated propagation nodes --- nomadnet/NomadNetworkApp.py | 9 ++++++++- nomadnet/ui/textui/Conversations.py | 6 +++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index c04e8c7..c076a05 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -255,6 +255,9 @@ class NomadNetworkApp: if self.enable_node: self.message_router.enable_propagation() + + # TODO: Set LXMF storage limits + RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) self.node = nomadnet.Node(self) else: @@ -352,6 +355,10 @@ class NomadNetworkApp: return "Done, no new messages" else: return "Downloaded "+str(new_msgs)+" new messages" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_IDENTITY_RCVD: + return "Node did not receive identification" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_ACCESS: + return "Node did not allow request" else: return "Unknown" @@ -379,7 +386,7 @@ class NomadNetworkApp: return self.message_router.propagation_transfer_progress def request_lxmf_sync(self, limit = None): - if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE or self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE or self.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: self.peer_settings["last_lxmf_sync"] = time.time() self.save_peer_settings() self.message_router.request_messages_from_propagation_node(self.identity, max_messages = limit) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 1c9f36f..90b7034 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -334,7 +334,7 @@ class ConversationsDisplay(): self.dialog_open = False self.sync_dialog = None self.update_conversation_list() - if self.app.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + if self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: self.app.cancel_lxmf_sync() max_messages_group = [] @@ -360,7 +360,7 @@ class ConversationsDisplay(): real_sync_button = urwid.Button("Sync Now", on_press=sync_now) hidden_sync_button = urwid.Button("Cancel Sync", on_press=cancel_sync) - if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: sync_button = real_sync_button else: sync_button = hidden_sync_button @@ -423,7 +423,7 @@ class ConversationsDisplay(): if self.dialog_open and self.sync_dialog != None: self.sync_dialog.sync_progress.set_completion(self.app.get_sync_progress()) - if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: self.sync_dialog.bc.contents[0] = (self.sync_dialog.real_sync_button, self.sync_dialog.bc.options("weight", 0.45)) else: self.sync_dialog.bc.contents[0] = (self.sync_dialog.hidden_sync_button, self.sync_dialog.bc.options("weight", 0.45)) From 2b3943eeba0f445fa334ddf662406e8da3fa90e4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 17 Jun 2022 14:36:18 +0200 Subject: [PATCH 066/592] Update version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 9cb17e7..c11f861 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.8" +__version__ = "0.1.9" diff --git a/setup.py b/setup.py index 0509e47..6d4bfdc 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.6', 'lxmf>=0.1.6', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.8', 'lxmf>=0.1.7', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 2767e35a9fe5353728e9b49f36b3c78e578fcea7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 17 Jun 2022 15:19:32 +0200 Subject: [PATCH 067/592] Added storage limit and priorities to configuration --- nomadnet/NomadNetworkApp.py | 52 +++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index c076a05..57b09bf 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -254,9 +254,17 @@ class NomadNetworkApp: RNS.log("LXMF Router ready to receive on: "+RNS.prettyhexrep(self.lxmf_destination.hash)) if self.enable_node: - self.message_router.enable_propagation() + self.message_router.set_message_storage_limit(gigabytes=self.message_storage_limit) + for dest_str in self.prioritised_lxmf_destinations: + try: + dest_hash = bytes.fromhex(dest_str) + if len(dest_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + self.message_router.prioritise(dest_hash) - # TODO: Set LXMF storage limits + except Exception as e: + RNS.log("Cannot prioritise "+str(dest_str)+", it is not a valid destination hash", RNS.LOG_ERROR) + + self.message_router.enable_propagation() RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) self.node = nomadnet.Node(self) @@ -656,6 +664,19 @@ class NomadNetworkApp: if "files_path" in self.config["node"]: self.filespath = self.config["node"]["files_path"] + if "prioritise_destinations" in self.config["node"]: + self.prioritised_lxmf_destinations = self.config["node"].as_list("prioritise_destinations") + else: + self.prioritised_lxmf_destinations = [] + + if not "message_storage_limit" in self.config["node"]: + self.message_storage_limit = 2 + else: + value = self.config["node"].as_int("message_storage_limit") + if value < 0.064: + value = 0.064 + self.message_storage_limit = value + @staticmethod def get_shared_instance(): @@ -766,28 +787,43 @@ editor = editor # If you don't want the Guide section to # show up in the menu, you can disable it. - hide_guide = no [node] # Whether to enable node hosting - enable_node = no # The node name will be visible to other # peers on the network, and included in # announces. - node_name = None # Automatic announce interval in minutes. # 6 hours by default. - announce_interval = 360 -# Whether to announce when the node starts - +# Whether to announce when the node starts. announce_at_start = Yes +# The maximum amount of storage to use for +# the LXMF Propagation Node message store, +# specified in gigabytes. When this limit +# is reached, LXMF will periodically remove +# messages in its message store. By default, +# LXMF prioritises keeping messages that are +# new and small. Large and old messages will +# be removed first. This setting is optional +# and defaults to 2 gigabytes. +# message_storage_limit = 2 + +# You can tell the LXMF message router to +# prioritise storage for one or more +# destinations. If the message store reaches +# the specified limit, LXMF will prioritise +# keeping messages for destinations specified +# with this option. This setting is optional, +# and generally you do not need to use it. +# prioritise_destinations = 10bc7624c27032a18639, ba780a6dff4cc1391db8 + '''.splitlines() \ No newline at end of file From 0a0de599ddc5f65fb002ed0898787de77ea5235b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 17 Jun 2022 15:27:31 +0200 Subject: [PATCH 068/592] Updated guide --- nomadnet/NomadNetworkApp.py | 2 +- nomadnet/ui/textui/Guide.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 57b09bf..5b24995 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -672,7 +672,7 @@ class NomadNetworkApp: if not "message_storage_limit" in self.config["node"]: self.message_storage_limit = 2 else: - value = self.config["node"].as_int("message_storage_limit") + value = self.config["node"].as_float("message_storage_limit") if value < 0.064: value = 0.064 self.message_storage_limit = value diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 558aa0c..1bb002f 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -514,12 +514,12 @@ What color theme to use. Set it to match your terminal theme. Can be either `!da >> Node Section -This section holds configuration directives related to the node hosting. It is delimited by the `![node]`! header in the configuration file. Available directives, along with their default values, are as follows: +This section holds configuration directives related to the node hosting. It is delimited by the `![node]`! header in the configuration file. Available directives, along with example values, are as follows: >>> -`!enable_node = yes`! +`!enable_node = no`! >>>> -Determines whether the node server should be started on launch. Must be a boolean value. +Determines whether the node server should be started on launch. Must be a boolean value, and is turned off by default. < >>> @@ -552,6 +552,18 @@ Determines where the node server will look for hosted pages. Must be a readable Determines where the node server will look for downloadable files. Must be a readable filesystem path. < +>>> +`!message_storage_limit = 2`! +>>>> +Configures the maximum amount of storage, in gigabytes, the LXMF Propagation Node will use to store messages. +< + +>>> +`!prioritise_destinations = 10bc7624c27032a18639, ba780a6dff4cc1391db8`! +>>>> +Configures the LXMF Propagation Node to prioritise storing messages for certain destinations. If the message store reaches the specified limit, LXMF will prioritise keeping messages for destinations specified with this option. This setting is optional, and generally you do not need to use it. +< + >Ignoring Destinations If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 20-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. From 8ef9e55a2215fddd57b4d0994c23cd463d05482d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 22 Jun 2022 17:41:11 +0200 Subject: [PATCH 069/592] Updated readme --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8271d94..a91f9cf 100755 --- a/README.md +++ b/README.md @@ -98,8 +98,20 @@ For help requests, discussion, sharing ideas or anything else related to Nomad N ## Support Nomad Network You can help support the continued development of open, free and private communications systems by donating via one of the following channels: -- Ethereum: 0x81F7B979fEa6134bA9FD5c701b3501A2e61E897a -- Bitcoin: 3CPmacGm34qYvR6XWLVEJmi2aNe3PZqUuq +- Monero: + ``` + 84FpY1QbxHcgdseePYNmhTHcrgMX4nFf + BYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD1 + 9b3B8NiLCGVxzKV17UMmmeEsCrPyA5w + ``` +- Ethereum + ``` + 0x81F7B979fEa6134bA9FD5c701b3501A2e61E897a + ``` +- Bitcoin + ``` + 3CPmacGm34qYvR6XWLVEJmi2aNe3PZqUuq + ``` - Ko-Fi: https://ko-fi.com/markqvist ## Caveat Emptor From 681efb422de78f9e2005f98141a78c8ceb146b43 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 30 Jun 2022 13:51:46 +0200 Subject: [PATCH 070/592] Prepared for 128-bit destinations --- nomadnet/ui/textui/Browser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 59578e1..8e40928 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -279,7 +279,7 @@ class Browser: components = url.split(":") if len(components) == 1: - if len(components[0]) == 20: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: try: destination_hash = bytes.fromhex(components[0]) except Exception as e: @@ -288,7 +288,7 @@ class Browser: else: raise ValueError("Malformed URL") elif len(components) == 2: - if len(components[0]) == 20: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: try: destination_hash = bytes.fromhex(components[0]) except Exception as e: From 1b1558c769c199fe31da84c9e3d65c7dff9bfb25 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 2 Jul 2022 08:56:11 +0200 Subject: [PATCH 071/592] Updated guide --- nomadnet/ui/textui/Guide.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 1bb002f..793b00a 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -202,9 +202,9 @@ The following section will briefly introduce various concepts and terms used in A `*peer`* refers to another Nomad Network client, which will generally be operated by another person. But since Nomad Network is a general LXMF client, it could also be any other LXMF client, program, automated system or machine that can communicate over LXMF. -All peers (and nodes) are identified by their `*address`* (which is, technically speaking, a Reticulum destination hash). An address consist of 20 hexadecimal characters, and looks like this: +All peers (and nodes) are identified by their `*address`* (which is, technically speaking, a Reticulum destination hash). An address consist of 32 hexadecimal characters (16 bytes), and looks like this: -`c +`c `l Anyone can choose whatever display name they want, but addresses are always unique, and generated from the unique cryptographic keys of the peer. This is an important point to understand. Since there is not anyone controlling naming or address spaces in Nomad Network, you can easily come across another user with the same display name as you. @@ -535,9 +535,9 @@ Determines whether your node is automatically announced on the network when the < >>> -`!announce_interval = 720`! +`!announce_interval = 360`! >>>> -Determines how often, in minutes, your node is announced on the network. Defaults to 12 hours. +Determines how often, in minutes, your node is announced on the network. Defaults to 6 hours. < >>> @@ -559,14 +559,14 @@ Configures the maximum amount of storage, in gigabytes, the LXMF Propagation Nod < >>> -`!prioritise_destinations = 10bc7624c27032a18639, ba780a6dff4cc1391db8`! +`!prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf`! >>>> Configures the LXMF Propagation Node to prioritise storing messages for certain destinations. If the message store reaches the specified limit, LXMF will prioritise keeping messages for destinations specified with this option. This setting is optional, and generally you do not need to use it. < >Ignoring Destinations -If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 20-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. +If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 32-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. ''' TOPIC_NETWORKS = '''>Network Configuration From 27dc1b5fb5b83e359b00e196049e05c294e5e56f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 12:48:24 +0200 Subject: [PATCH 072/592] Set and restore IXON tty settings --- nomadnet/NomadNetworkApp.py | 9 ++++++++ nomadnet/ui/TextUI.py | 20 ++++++++++++++++- nomadnet/ui/textui/Extras.py | 42 ------------------------------------ 3 files changed, 28 insertions(+), 43 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 5b24995..65a5e4f 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -32,6 +32,15 @@ class NomadNetworkApp: RNS.log("Saving directory...", RNS.LOG_VERBOSE) self.directory.save_to_disk() + + if hasattr(self.ui, "restore_ixon"): + if self.ui.restore_ixon: + try: + os.system("stty ixon") + + except Exception as e: + RNS.log("Could not restore flow control sequences. The contained exception was: "+str(e), RNS.LOG_WARNING) + RNS.log("Nomad Network Client exiting now", RNS.LOG_VERBOSE) def exception_handler(self, e_type, e_value, e_traceback): diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 34d9340..7ed1201 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -127,6 +127,21 @@ GLYPHS = { class TextUI: def __init__(self): + self.restore_ixon = False + + try: + rval = os.system("stty -a | grep \"\\-ixon\"") + if rval == 0: + pass + + else: + os.system("stty -ixon") + self.restore_ixon = True + + except Exception as e: + RNS.log("Could not configure terminal flow control sequences, some keybindings may not work.", RNS.LOG_WARNING) + RNS.log("The contained exception was: "+str(e)) + self.app = NomadNetworkApp.get_shared_instance() self.app.ui = self self.loop = None @@ -164,7 +179,7 @@ class TextUI: else: initial_widget = self.main_display.widget - self.loop = urwid.MainLoop(initial_widget, screen=self.screen, handle_mouse=mouse_enabled) + self.loop = urwid.MainLoop(initial_widget, unhandled_input=self.unhandled_input, screen=self.screen, handle_mouse=mouse_enabled) if intro_timeout > 0: self.loop.set_alarm_in(intro_timeout, self.display_main) @@ -194,5 +209,8 @@ class TextUI: self.screen.set_terminal_properties(colormode) self.screen.reset_default_terminal_palette() + def unhandled_input(self, key): + pass + def display_main(self, loop, user_data): self.loop.widget = self.main_display.widget diff --git a/nomadnet/ui/textui/Extras.py b/nomadnet/ui/textui/Extras.py index f0314df..75327d4 100644 --- a/nomadnet/ui/textui/Extras.py +++ b/nomadnet/ui/textui/Extras.py @@ -17,45 +17,3 @@ class IntroDisplay(): self.widget = urwid.Filler(intro) - -class DemoDisplay(): - def __init__(self, ui, app): - import urwid - - def color_mono(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_MONO) - - def color_16(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_16) - - def color_88(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_88) - - def color_8bit(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_256) - - def color_true(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_TRUE) - - # pile = urwid.Pile([ - # urwid.Text(("heading", "This is a heading")), - # urwid.Text(("body_text", "Hello World \U0001F332")), - # urwid.Button(("buttons", "Monochrome"), color_mono), - # urwid.Button(("buttons", "16 color"), color_16), - # urwid.Button(("buttons", "88 color"), color_88), - # urwid.Button(("buttons", "256 color"), color_8bit), - # urwid.Button(("buttons", "True color"), color_true), - # ]) - - gf = urwid.GridFlow([ - urwid.Text(("heading", "This is a heading")), - urwid.Text(("body_text", "Hello World \U0001F332")), - urwid.Button(("buttons", "Monochrome"), color_mono), - urwid.Button(("buttons", "16 color"), color_16), - urwid.Button(("buttons", "88 color"), color_88), - urwid.Button(("buttons", "256 color"), color_8bit), - urwid.Button(("buttons", "True color"), color_true), - ], cell_width=20, h_sep=0, v_sep=0, align="left") - - self.widget = urwid.Filler(urwid.Padding((urwid.Text("Test"),urwid.Text("Test 2"))), 'top') - #self.widget = urwid.Filler(pile, 'top') \ No newline at end of file From f75b4d0b5717f9a29db67b17441978c0ef5b705b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 14:34:34 +0200 Subject: [PATCH 073/592] Improved dialog navigation --- nomadnet/ui/TextUI.py | 3 ++- nomadnet/ui/textui/Network.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 7ed1201..4a8abf9 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -210,7 +210,8 @@ class TextUI: self.screen.reset_default_terminal_palette() def unhandled_input(self, key): - pass + if key == "ctrl q": + raise urwid.ExitMainLoop def display_main(self, loop, user_data): self.loop.widget = self.main_display.widget diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index acfd4cf..8bfe457 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -51,6 +51,13 @@ class ListEntry(urwid.Text): class AnnounceInfo(urwid.WidgetWrap): + def keypress(self, size, key): + if key == "esc": + options = self.parent.left_pile.options(height_type="weight", height_amount=1) + self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) + else: + return super(AnnounceInfo, self).keypress(size, key) + def __init__(self, announce, parent, app): self.app = nomadnet.NomadNetworkApp.get_shared_instance() self.parent = self.app.ui.main_display.sub_displays.network_display @@ -387,6 +394,13 @@ class ListDialogLineBox(urwid.LineBox): return super(ListDialogLineBox, self).keypress(size, key) class KnownNodeInfo(urwid.WidgetWrap): + def keypress(self, size, key): + if key == "esc": + options = self.parent.left_pile.options(height_type="weight", height_amount=1) + self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) + else: + return super(KnownNodeInfo, self).keypress(size, key) + def __init__(self, node_hash): self.app = nomadnet.NomadNetworkApp.get_shared_instance() self.parent = self.app.ui.main_display.sub_displays.network_display @@ -567,6 +581,10 @@ class KnownNodeInfo(urwid.WidgetWrap): pile_widgets.insert(4, operator_entry) pile = urwid.Pile(pile_widgets) + + pile.focus_position = len(pile.contents)-1 + button_columns.focus_position = 0 + self.display_widget = urwid.Filler(pile, valign="top", height="pack") @@ -1257,6 +1275,8 @@ class NetworkLeftPile(urwid.Pile): def keypress(self, size, key): if key == "ctrl l": self.parent.toggle_list() + elif key == "ctrl e": + self.parent.selected_node_info() elif key == "ctrl p": self.parent.reinit_lxmf_peers() self.parent.show_peers() @@ -1332,6 +1352,18 @@ class NetworkDisplay(): else: self.list_display = 1 + def selected_node_info(self): + if self.list_display == 1: + parent = self.app.ui.main_display.sub_displays.network_display + selected_node_entry = parent.known_nodes_display.ilb.get_selected_item() + if selected_node_entry != None: + selected_node_hash = selected_node_entry._get_base_widget().display_widget.source_hash + + if selected_node_hash != None: + info_widget = KnownNodeInfo(selected_node_hash) + options = parent.left_pile.options(height_type="weight", height_amount=1) + parent.left_pile.contents[0] = (info_widget, options) + def focus_lists(self): self.columns.focus_position = 0 From 4e50e0f0764be27ba63c530200967cdd43fbdffa Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 16:30:40 +0200 Subject: [PATCH 074/592] Added keyboard shortcuts to guide --- nomadnet/ui/textui/Guide.py | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 793b00a..4f00b36 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -111,6 +111,7 @@ class TopicList(urwid.WidgetWrap): GuideEntry(self.app, self, guide_display, "Concepts & Terminology"), GuideEntry(self.app, self, guide_display, "Hosting a Node"), GuideEntry(self.app, self, guide_display, "Configuration Options"), + GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"), GuideEntry(self.app, self, guide_display, "Markup"), self.first_run_entry, GuideEntry(self.app, self, guide_display, "Network Configuration"), @@ -194,6 +195,47 @@ The current version of the program should be considered a beta release. The prog ''' +TOPIC_SHORTCUTS = '''>Keyboard Shortcuts + +The different sections of the program has a number of keyboard shortcuts mapped, that makes operating and navigating the program easier. The following lists details all mapped shortcuts. + +>>`!Conversations Window`! +>>>Conversation List + - Ctrl-N Start a new conversation + - Ctrl-E Display and edit selected peer info + - Ctrl-X Delete conversation + - Ctrl-R Open LXMF syncronisation dialog + +>>>Conversation Display + - Ctrl-D Send message + - Ctrl-K Clear input fields + - Ctrl-T Toggle message title field + - Ctrl-O Toggle sort mode + - Ctrl-P Purge failed messages + - Ctrl-X Clear conversation history + - Ctrl-W Close conversation + +>>`!Network Window`! +>>>Announce Stream + - Ctrl-L Switch to Known Nodes list + - Ctrl-X Delete selected announce + - Ctrl-P Display peered LXMF Propagation Nodes + +>>>Known Nodes + - Ctrl-L Switch to Announce Stream + - Ctrl-X Delete selected node entry + - Ctrl-P Display peered LXMF Propagation Nodes + +>>>Browser + - Ctrl-D Back + - Ctrl-F Forward + - Ctrl-R Reload page + - Ctrl-U Open URL entry dialog + - Ctrl-S Save connected node + - Ctrl-L Toggle fullscreen browser window + - Ctrl-W Disconnect from node +''' + TOPIC_CONCEPTS = '''>Concepts and Terminology The following section will briefly introduce various concepts and terms used in the program. @@ -949,6 +991,7 @@ TOPICS = { "Conversations": TOPIC_CONVERSATIONS, "Hosting a Node": TOPIC_HOSTING, "Configuration Options": TOPIC_CONFIG, + "Keyboard Shortcuts": TOPIC_SHORTCUTS, "Markup": TOPIC_MARKUP, "Display Test": TOPIC_DISPLAYTEST, "Network Configuration": TOPIC_NETWORKS, From 87c810fdd0c592aba2a2d0e0e955bbb718eb9b44 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 16:31:01 +0200 Subject: [PATCH 075/592] Added save connected node functionality --- nomadnet/Directory.py | 13 ++++++++++-- nomadnet/ui/textui/Browser.py | 37 +++++++++++++++++++++++++++++++++++ nomadnet/ui/textui/Network.py | 3 +++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 9d3029b..740fd4f 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -60,6 +60,9 @@ class Directory: entries = {} for e in unpacked_list: + if e[1] == None: + e[1] = RNS.prettyhexrep(e[0]) + if len(e) > 3: hosts_node = e[3] else: @@ -132,7 +135,11 @@ class Directory: return "<"+RNS.hexrep(source_hash, delimit=False)+">" else: if source_hash in self.directory_entries: - return self.directory_entries[source_hash].display_name + dn = self.directory_entries[source_hash].display_name + if dn == None: + return RNS.prettyhexrep(source_hash) + else: + return dn else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" @@ -242,10 +249,12 @@ class DirectoryEntry: def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False): if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: self.source_hash = source_hash - self.display_name = display_name + if display_name == None: display_name = source_hash + self.display_name = display_name + if preferred_delivery == None: self.preferred_delivery = DirectoryEntry.DIRECT else: diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 8e40928..b7dc0f6 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -6,6 +6,7 @@ import nomadnet import subprocess import threading from .MicronParser import markup_to_attrmaps +from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.Scrollable import * class BrowserFrame(urwid.Frame): @@ -20,6 +21,8 @@ class BrowserFrame(urwid.Frame): self.delegate.reload() elif key == "ctrl u": self.delegate.url_dialog() + elif key == "ctrl s": + self.delegate.save_node_dialog() elif self.get_focus() == "body": return super(BrowserFrame, self).keypress(size, key) # if key == "up" and self.delegate.messagelist.top_is_visible: @@ -501,6 +504,40 @@ class Browser: self.delegate.columns.contents[1] = (overlay, options) self.delegate.columns.focus_position = 1 + def save_node_dialog(self): + def dismiss_dialog(sender): + self.close_dialogs() + + display_name = RNS.Identity.recall_app_data(self.destination_hash) + disp_str = "" + if display_name != None: + display_name = display_name.decode("utf-8") + disp_str = " \""+display_name+"\"" + + def confirmed(sender): + node_entry = DirectoryEntry(self.destination_hash, display_name=display_name, hosts_node=True) + self.app.directory.remember(node_entry) + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + self.close_dialogs() + + dialog = UrlDialogLineBox( + urwid.Pile([ + urwid.Text("Save connected node"+disp_str+" "+RNS.prettyhexrep(self.destination_hash)+" to Known Nodes?\n"), + urwid.Columns([("weight", 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Save", on_press=confirmed))]) + ]), title="Save Node" + ) + + dialog.confirmed = confirmed + dialog.delegate = self + bottom = self.display_widget + + overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 50), valign="middle", height="pack", left=2, right=2) + + options = self.delegate.columns.options("weight", self.delegate.right_area_width) + self.delegate.columns.contents[1] = (overlay, options) + self.delegate.columns.focus_position = 1 + def load_page(self): cached = self.get_cached(self.current_url()) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 8bfe457..475767d 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1284,6 +1284,9 @@ class NetworkLeftPile(urwid.Pile): self.parent.browser.disconnect() elif key == "ctrl u": self.parent.browser.url_dialog() + elif key == "ctrl s": + self.parent.browser.save_node_dialog() + else: return super(NetworkLeftPile, self).keypress(size, key) From 1002a89ad5eba4d68ae1db842d188051f55552aa Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 16:38:43 +0200 Subject: [PATCH 076/592] Improved sync dialog text --- nomadnet/ui/textui/Conversations.py | 3 ++- nomadnet/ui/textui/Guide.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 90b7034..21660b3 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -398,7 +398,8 @@ class ConversationsDisplay(): dialog = DialogLineBox( urwid.Pile([ urwid.Text(""), - urwid.Text("No trusted nodes found, cannot sync", align="center"), + urwid.Text("No trusted nodes found, cannot sync!\n", align="center"), + urwid.Text("To syncronise messages from the network, one or more nodes must be marked as trusted in the Known Nodes list. Nomad Network will then automatically sync from the nearest trusted node.", align="left"), urwid.Text(""), button_columns ]), title="Message Sync" diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 4f00b36..7beaae5 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -277,7 +277,7 @@ For propagated delivery to work, one or more nodes must be available on the netw To select a node manually, go to the `![ Network ]`! part of the program, choose the desired node in the `*Known Nodes`* list, and select the `!< Info >`! button. In the `!Node Info`! dialog, you can specify the selected node as the default propagation node. -By default, Nomad Network will check in with propagation nodes, and download any available messages every 6 hours. You can change this interval, or disable automatic synchronisation completely, by editing the configuration file. +By default, Nomad Network will check in with propagation nodes, and download any available messages every 6 hours. You can change this interval, or disable automatic syncronisation completely, by editing the configuration file. You can always initiate a sync manually, by pressing `!Ctrl-R`! in the `![ Conversations ]`! part of the program, which will open the syncronisation window. @@ -287,7 +287,7 @@ A Nomad Network `*node`* is an instance of the Nomad Network program that has be Nodes can host pages (similar to webpages) written in a markup-language called `*micron`*, as well as make files and other resources available for download for peers on the network. Nodes also form a distributed message store for offline users, and allows messages to be exchanged between peers that are not online at the same time. -If no nodes exist on a network, all peers will still be able to communicate directly peer-to-peer, but both endpoints of a conversation will need to be available at the same time to converse. When nodes exist on the network, messages will be held and syncronised between nodes for deferred delivery if the destination peer is unavailable. Nodes will automatically discover and peer with each other, and handle synchronisation of message stores. +If no nodes exist on a network, all peers will still be able to communicate directly peer-to-peer, but both endpoints of a conversation will need to be available at the same time to converse. When nodes exist on the network, messages will be held and syncronised between nodes for deferred delivery if the destination peer is unavailable. Nodes will automatically discover and peer with each other, and handle syncronisation of message stores. To learn how to host your own node, read the `*Hosting a Node`* section of this guide. From f699c61a644532a2ab39bd343d91a1c9f5bd1ab5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 16:48:40 +0200 Subject: [PATCH 077/592] Improved missing keys description --- nomadnet/ui/textui/Conversations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 21660b3..9b5200f 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -226,7 +226,7 @@ class ConversationsDisplay(): (urwid.Button("OK", on_press=dismiss_dialog), options) ] query_button = urwid.Button("Query network for keys", on_press=query_action, user_data=source_hash_text) - known_section = urwid.Pile([urwid.Divider(g["divider1"]), urwid.Text(g["info"]+"\n", align="center"), urwid.Text("The identity of this peer is not known, and you cannot currently communicate.\n", align="center"), query_button, urwid.Divider(g["divider1"])]) + known_section = urwid.Pile([urwid.Divider(g["divider1"]), urwid.Text(g["info"]+"\n", align="center"), urwid.Text("The identity of this peer is not known, and you cannot currently send messages to it. You can query the network to obtain the identity.\n", align="center"), query_button, urwid.Divider(g["divider1"])]) dialog_pile = urwid.Pile([ selected_id_widget, @@ -754,7 +754,7 @@ class ConversationWidget(urwid.WidgetWrap): if allowed: self.frame.contents["footer"] = (self.minimal_editor, None) else: - warning = urwid.AttrMap(urwid.Padding(urwid.Text(g["info"]+" You cannot currently communicate with this peer, since it's identity keys are not known", align="center")), "msg_header_caution") + warning = urwid.AttrMap(urwid.Padding(urwid.Text("\n"+g["info"]+"\n\nYou cannot currently message this peer, since it's identity keys are not known.\n\nWait for an announce to arrive from the peer, or query the network for it.\n\nTo query the network, select this conversation in the conversation list, press Ctrl-E, and use the query button.\n", align="center")), "msg_header_caution") self.frame.contents["footer"] = (warning, None) def toggle_focus_area(self): From 3c7b8511d796fab8f137e33cd8751f3458126a41 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 17:07:54 +0200 Subject: [PATCH 078/592] Updated frame sizing --- nomadnet/ui/textui/Conversations.py | 25 +++++++++++++++++-------- nomadnet/ui/textui/Network.py | 9 ++++++--- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 9b5200f..5c0632d 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -48,6 +48,7 @@ class DialogLineBox(urwid.LineBox): class ConversationsDisplay(): list_width = 0.33 + given_list_width = 52 cached_conversation_widgets = {} def __init__(self, app): @@ -64,8 +65,10 @@ class ConversationsDisplay(): self.columns_widget = urwid.Columns( [ - ("weight", ConversationsDisplay.list_width, self.listbox), - ("weight", 1-ConversationsDisplay.list_width, self.make_conversation_widget(None)) + # ("weight", ConversationsDisplay.list_width, self.listbox), + # ("weight", 1-ConversationsDisplay.list_width, self.make_conversation_widget(None)) + (ConversationsDisplay.given_list_width, self.listbox), + ("weight", 1, self.make_conversation_widget(None)) ], dividechars=0, focus_column=0, box_columns=[0] ) @@ -126,7 +129,8 @@ class ConversationsDisplay(): overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def edit_selected_in_directory(self): @@ -249,7 +253,8 @@ class ConversationsDisplay(): overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def new_conversation(self): @@ -318,7 +323,8 @@ class ConversationsDisplay(): overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def delete_conversation(self, source_hash): @@ -417,7 +423,8 @@ class ConversationsDisplay(): overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def update_sync_dialog(self, loop = None, sender = None): @@ -438,7 +445,8 @@ class ConversationsDisplay(): def update_conversation_list(self): ilb_position = self.ilb.get_selected_position() self.update_listbox() - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) if not (self.dialog_open and self.sync_dialog != None): self.columns_widget.contents[0] = (self.listbox, options) else: @@ -469,7 +477,8 @@ class ConversationsDisplay(): self.app.mark_conversation_read(self.currently_displayed_conversation) self.currently_displayed_conversation = source_hash - options = self.widget.options("weight", 1-ConversationsDisplay.list_width) + # options = self.widget.options("weight", 1-ConversationsDisplay.list_width) + options = self.widget.options("weight", 1) self.widget.contents[1] = (self.make_conversation_widget(source_hash), options) if source_hash == None: self.widget.set_focus_column(0) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 475767d..d36534a 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1293,6 +1293,7 @@ class NetworkLeftPile(urwid.Pile): class NetworkDisplay(): list_width = 0.33 + given_list_width = 52 def __init__(self, app): self.app = app @@ -1327,8 +1328,10 @@ class NetworkDisplay(): self.columns = urwid.Columns( [ - ("weight", NetworkDisplay.list_width, self.left_area), - ("weight", self.right_area_width, self.right_area) + # ("weight", NetworkDisplay.list_width, self.left_area), + # ("weight", self.right_area_width, self.right_area) + (NetworkDisplay.given_list_width, self.left_area), + ("weight", 1, self.right_area) ], dividechars=0, focus_column=0 ) @@ -1499,7 +1502,7 @@ class LXMFPeerEntry(urwid.WidgetWrap): style = "list_unknown" focus_style = "list_focus" - widget = ListEntry(sym+" "+display_str+"\n "+str(len(peer.unhandled_messages))+" unhandled messages - "+"Last heard "+pretty_date(int(peer.last_heard))) + widget = ListEntry(sym+" "+display_str+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs - "+"Last heard "+pretty_date(int(peer.last_heard))) # urwid.connect_signal(widget, "click", delegate.connect_node, node) self.display_widget = urwid.AttrMap(widget, style, focus_style) From 726ee8873eb106b3140a3c4f16b850b9bcd5dcfe Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 17:34:12 +0200 Subject: [PATCH 079/592] Added fullscreen mode to browser and conversations --- nomadnet/ui/textui/Browser.py | 2 ++ nomadnet/ui/textui/Conversations.py | 13 +++++++++++++ nomadnet/ui/textui/Guide.py | 3 ++- nomadnet/ui/textui/Network.py | 12 ++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index b7dc0f6..47abd05 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -23,6 +23,8 @@ class BrowserFrame(urwid.Frame): self.delegate.url_dialog() elif key == "ctrl s": self.delegate.save_node_dialog() + elif key == "ctrl g": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen() elif self.get_focus() == "body": return super(BrowserFrame, self).keypress(size, key) # if key == "up" and self.delegate.messagelist.top_is_visible: diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 5c0632d..5b296b9 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -32,6 +32,8 @@ class ConversationsArea(urwid.LineBox): self.delegate.new_conversation() elif key == "ctrl r": self.delegate.sync_conversations() + elif key == "ctrl g": + self.delegate.toggle_fullscreen() elif key == "tab": self.delegate.app.ui.main_display.frame.set_focus("header") elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): @@ -332,6 +334,15 @@ class ConversationsDisplay(): conversation = ConversationsDisplay.cached_conversation_widgets[source_hash] self.close_conversation(conversation) + def toggle_fullscreen(self): + if ConversationsDisplay.given_list_width != 0: + self.saved_list_width = ConversationsDisplay.given_list_width + ConversationsDisplay.given_list_width = 0 + else: + ConversationsDisplay.given_list_width = self.saved_list_width + + self.update_conversation_list() + def sync_conversations(self): g = self.app.ui.glyphs self.dialog_open = True @@ -790,6 +801,8 @@ class ConversationWidget(urwid.WidgetWrap): self.toggle_editor() elif key == "ctrl x": self.clear_history_dialog() + elif key == "ctrl g": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.conversations_display.toggle_fullscreen() elif key == "ctrl o": self.sort_by_timestamp ^= True self.conversation_changed(None) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 7beaae5..793dab9 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -213,6 +213,7 @@ The different sections of the program has a number of keyboard shortcuts mapped, - Ctrl-O Toggle sort mode - Ctrl-P Purge failed messages - Ctrl-X Clear conversation history + - Ctrl-G Toggle fullscreen conversation - Ctrl-W Close conversation >>`!Network Window`! @@ -232,7 +233,7 @@ The different sections of the program has a number of keyboard shortcuts mapped, - Ctrl-R Reload page - Ctrl-U Open URL entry dialog - Ctrl-S Save connected node - - Ctrl-L Toggle fullscreen browser window + - Ctrl-G Toggle fullscreen browser window - Ctrl-W Disconnect from node ''' diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index d36534a..658feba 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1275,6 +1275,8 @@ class NetworkLeftPile(urwid.Pile): def keypress(self, size, key): if key == "ctrl l": self.parent.toggle_list() + elif key == "ctrl g": + self.parent.toggle_fullscreen() elif key == "ctrl e": self.parent.selected_node_info() elif key == "ctrl p": @@ -1349,6 +1351,16 @@ class NetworkDisplay(): self.left_pile.contents[0] = (self.known_nodes_display, options) self.list_display = 1 + def toggle_fullscreen(self): + if NetworkDisplay.given_list_width != 0: + self.saved_list_width = NetworkDisplay.given_list_width + NetworkDisplay.given_list_width = 0 + else: + NetworkDisplay.given_list_width = self.saved_list_width + + options = self.widget.options("given", NetworkDisplay.given_list_width) + self.widget.contents[0] = (self.left_area, options) + def show_peers(self): options = self.left_pile.options(height_type="weight", height_amount=1) self.left_pile.contents[0] = (self.lxmf_peers_display, options) From d1c66aa7cd860181cec410e92d0570f25c31c51a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 17:37:13 +0200 Subject: [PATCH 080/592] Fixed missing error handling in URL dialog --- nomadnet/ui/textui/Browser.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 47abd05..fbe4826 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -486,7 +486,12 @@ class Browser: self.close_dialogs() def confirmed(sender): - self.retrieve_url(e_url.get_edit_text()) + try: + self.retrieve_url(e_url.get_edit_text()) + except Exception as e: + self.browser_footer = urwid.Text("Could not open link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + self.close_dialogs() dialog = UrlDialogLineBox( From d8a35be14d6286d452b249e4730c1f2358e2fd9a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 17:41:28 +0200 Subject: [PATCH 081/592] Updated guide --- nomadnet/ui/textui/Guide.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 793dab9..0e4cdc6 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -749,7 +749,7 @@ While micron can output formatted text to even the most basic terminal, there's Formatting such as `_underline`_, `!bold`! or `*italics`* will be displayed if your terminal supports it. -If you are having trouble getting micron output to display correctly, try using `*gnome-terminal`*, which should work with all formatting options out of the box. Most other terminals will work fine as well, but you might have to change some settings to get certain formatting to display correctly. +If you are having trouble getting micron output to display correctly, try using `*gnome-terminal`* or `*alacritty`*, which should work with all formatting options out of the box. Most other terminals will work fine as well, but you might have to change some settings to get certain formatting to display correctly. >>>Encoding From 247db32033d6de6059e1593d05d5dcb6841c887b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 19:25:17 +0200 Subject: [PATCH 082/592] Added LXMF link support --- nomadnet/Conversation.py | 9 ++-- nomadnet/Directory.py | 7 ++- nomadnet/ui/textui/Browser.py | 83 +++++++++++++++++++++++++++++++---- nomadnet/ui/textui/Guide.py | 12 ++--- nomadnet/ui/textui/Network.py | 3 ++ 5 files changed, 91 insertions(+), 23 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 6d66219..94f2697 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -239,10 +239,11 @@ class Conversation: def __str__(self): string = self.source_hash - if self.source_identity: - if self.source_identity.app_data: - # TODO: Sanitise for viewing - string += " | "+self.source.source_identity.app_data.decode("utf-8") + # TODO: Remove this + # if self.source_identity: + # if self.source_identity.app_data: + # # TODO: Sanitise for viewing, or just clean this + # string += " | "+self.source_identity.app_data.decode("utf-8") return string diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 740fd4f..a2d9e79 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -60,8 +60,6 @@ class Directory: entries = {} for e in unpacked_list: - if e[1] == None: - e[1] = RNS.prettyhexrep(e[0]) if len(e) > 3: hosts_node = e[3] @@ -250,8 +248,9 @@ class DirectoryEntry: if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: self.source_hash = source_hash - if display_name == None: - display_name = source_hash + # TODO: Clean + # if display_name == None: + # display_name = source_hash self.display_name = display_name diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index fbe4826..bdecffe 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -133,17 +133,82 @@ class Browser: self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to: "+str(link_target))]), "browser_controls") self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) - def handle_link(self, link_target): - if self.status >= Browser.DISCONECTED: - RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG) - try: - self.retrieve_url(link_target) - except Exception as e: - self.browser_footer = urwid.Text("Could not open link: "+str(e)) - self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + def expand_shorthands(self, destination_type): + if destination_type == "nnn": + return "nomadnetwork.node" + elif destination_type == "lxmf": + return "lxmf.delivery" else: - RNS.log("Browser aleady hadling link, cannot handle link to: "+str(link_target), RNS.LOG_DEBUG) + return destination_type + def handle_link(self, link_target): + components = link_target.split("@") + destination_type = None + + if len(components) == 2: + destination_type = self.expand_shorthands(components[0]) + link_target = components[1] + else: + destination_type = "nomadnetwork.node" + link_target = components[0] + + if destination_type == "nomadnetwork.node": + if self.status >= Browser.DISCONECTED: + RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG) + try: + self.retrieve_url(link_target) + except Exception as e: + self.browser_footer = urwid.Text("Could not open link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + else: + RNS.log("Browser already handling link, cannot handle link to: "+str(link_target), RNS.LOG_DEBUG) + + elif destination_type == "lxmf.delivery": + RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG) + self.handle_lxmf_link(link_target) + + else: + RNS.log("No known handler for destination type "+str(destination_type), RNS.LOG_DEBUG) + self.browser_footer = urwid.Text("Could not open link: "+"No known handler for destination type "+str(destination_type)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + def handle_lxmf_link(self, link_target): + try: + if not type(link_target) is str: + raise ValueError("Invalid data type for LXMF link") + + if len(link_target) != (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + raise ValueError("Invalid length for LXMF link") + + try: + bytes.fromhex(link_target) + + except Exception as e: + raise ValueError("Could not decode destination hash from LXMF link") + + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + source_hash_text = link_target + display_name_data = RNS.Identity.recall_app_data(bytes.fromhex(source_hash_text)) + + display_name = None + if display_name_data != None: + display_name = display_name_data.decode("utf-8") + + if not source_hash_text in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(bytes.fromhex(source_hash_text), display_name=display_name) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list() + + self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text) + self.app.ui.main_display.show_conversations(None) + + except Exception as e: + RNS.log("Error while starting conversation from link. The contained exception was: "+str(e), RNS.LOG_ERROR) + self.browser_footer = urwid.Text("Could not open LXMF link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) def micron_released_focus(self): diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 0e4cdc6..fe6324b 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -929,11 +929,11 @@ Here's a few examples: `Faaa `= -Here is a link without any label: `[1385edace36466a6b3dd:/page/index.mu] +Here is a link without any label: `[72914442a3689add83a09a767963f57c:/page/index.mu] -This is a `[labeled link`1385edace36466a6b3dd:/page/index.mu] to the same page, but it's hard to see if you don't know it +This is a `[labeled link`72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it -Here is `F00a`_`[a more visible link`1385edace36466a6b3dd:/page/index.mu]`_`f +Here is `F00a`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f `= `` @@ -941,11 +941,11 @@ The above markup produces the following output: `Faaa`B333 -Here is a link without any label: `[1385edace36466a6b3dd:/page/index.mu] +Here is a link without any label: `[72914442a3689add83a09a767963f57c:/page/index.mu] -This is a `[labeled link`1385edace36466a6b3dd:/page/index.mu] to the same page, but it's hard to see if you don't know it +This is a `[labeled link`72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it -Here is `F00f`_`[a more visible link`1385edace36466a6b3dd:/page/index.mu]`_`f +Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f `` diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 658feba..9ea6a5c 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -420,6 +420,9 @@ class KnownNodeInfo(urwid.WidgetWrap): addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" + if display_str == None: + display_str = addr_str + if node_ident != None: lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident)) else: From 850f56cac33beff5da39765867242fdd28d47407 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 20:05:17 +0200 Subject: [PATCH 083/592] Change storage limit specification to megabytes. Added storage stat to node info. --- nomadnet/NomadNetworkApp.py | 14 +++---- nomadnet/ui/textui/Conversations.py | 2 +- nomadnet/ui/textui/Guide.py | 4 +- nomadnet/ui/textui/Network.py | 63 ++++++++++++++++++++++++++--- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 65a5e4f..e837fc0 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -263,7 +263,7 @@ class NomadNetworkApp: RNS.log("LXMF Router ready to receive on: "+RNS.prettyhexrep(self.lxmf_destination.hash)) if self.enable_node: - self.message_router.set_message_storage_limit(gigabytes=self.message_storage_limit) + self.message_router.set_message_storage_limit(megabytes=self.message_storage_limit) for dest_str in self.prioritised_lxmf_destinations: try: dest_hash = bytes.fromhex(dest_str) @@ -679,11 +679,11 @@ class NomadNetworkApp: self.prioritised_lxmf_destinations = [] if not "message_storage_limit" in self.config["node"]: - self.message_storage_limit = 2 + self.message_storage_limit = 2000 else: value = self.config["node"].as_float("message_storage_limit") - if value < 0.064: - value = 0.064 + if value < 0.005: + value = 0.005 self.message_storage_limit = value @@ -817,14 +817,14 @@ announce_at_start = Yes # The maximum amount of storage to use for # the LXMF Propagation Node message store, -# specified in gigabytes. When this limit +# specified in megabytes. When this limit # is reached, LXMF will periodically remove # messages in its message store. By default, # LXMF prioritises keeping messages that are # new and small. Large and old messages will # be removed first. This setting is optional # and defaults to 2 gigabytes. -# message_storage_limit = 2 +# message_storage_limit = 2000 # You can tell the LXMF message router to # prioritise storage for one or more @@ -833,6 +833,6 @@ announce_at_start = Yes # keeping messages for destinations specified # with this option. This setting is optional, # and generally you do not need to use it. -# prioritise_destinations = 10bc7624c27032a18639, ba780a6dff4cc1391db8 +# prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf '''.splitlines() \ No newline at end of file diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 5b296b9..6d2895b 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -14,7 +14,7 @@ class ConversationListDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[Enter] Open [C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-g] Fullscreen"), "shortcutbar") class ConversationDisplayShortcuts(): def __init__(self, app): diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index fe6324b..0e7bdd2 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -596,9 +596,9 @@ Determines where the node server will look for downloadable files. Must be a rea < >>> -`!message_storage_limit = 2`! +`!message_storage_limit = 2000`! >>>> -Configures the maximum amount of storage, in gigabytes, the LXMF Propagation Node will use to store messages. +Configures the maximum amount of storage, in megabytes, that the LXMF Propagation Node will use to store messages. < >>> diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 9ea6a5c..e261583 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -13,7 +13,7 @@ class NetworkDisplayShortcuts(): self.app = app g = app.ui.glyphs - self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-g] Fullscreen"), "shortcutbar") # "[C-"+g["arrow_u"]+g["arrow_d"]+"] Navigate Lists" @@ -827,7 +827,7 @@ class NodeAnnounceTime(urwid.WidgetWrap): if self.app.peer_settings["node_last_announce"] != None: self.last_announce_string = pretty_date(int(self.app.peer_settings["node_last_announce"])) - self.display_widget.set_text("Last Announce : "+self.last_announce_string) + self.display_widget.set_text("Last Announce : "+self.last_announce_string) def update_time_callback(self, loop=None, user_data=None): self.update_time() @@ -858,7 +858,7 @@ class NodeActiveConnections(urwid.WidgetWrap): if self.app.node != None: self.stat_string = str(len(self.app.node.destination.links)) - self.display_widget.set_text("Connections Now : "+self.stat_string) + self.display_widget.set_text("Conneced Now : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -874,6 +874,48 @@ class NodeActiveConnections(urwid.WidgetWrap): def stop(self): self.started = False +class NodeStorageStats(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + urwid.WidgetWrap.__init__(self, self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None: + + limit = self.app.message_router.message_storage_limit + used = self.app.message_router.message_storage_size() + + if limit != None: + pct = round((used/limit)*100, 1) + pct_str = str(pct)+"%, " + limit_str = " of "+RNS.prettysize(limit) + else: + limit_str = "" + pct_str = "" + + self.stat_string = pct_str+RNS.prettysize(used)+limit_str + + self.display_widget.set_text("LXMF Storage : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False class NodeTotalConnections(urwid.WidgetWrap): def __init__(self, app): @@ -890,7 +932,7 @@ class NodeTotalConnections(urwid.WidgetWrap): if self.app.node != None: self.stat_string = str(self.app.peer_settings["node_connects"]) - self.display_widget.set_text("Total Connections : "+self.stat_string) + self.display_widget.set_text("Total Connects : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -922,7 +964,7 @@ class NodeTotalPages(urwid.WidgetWrap): if self.app.node != None: self.stat_string = str(self.app.peer_settings["served_page_requests"]) - self.display_widget.set_text("Served Pages : "+self.stat_string) + self.display_widget.set_text("Served Pages : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -954,7 +996,7 @@ class NodeTotalFiles(urwid.WidgetWrap): if self.app.node != None: self.stat_string = str(self.app.peer_settings["served_file_requests"]) - self.display_widget.set_text("Served Files : "+self.stat_string) + self.display_widget.set_text("Served Files : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -1071,6 +1113,7 @@ class NodeInfo(urwid.WidgetWrap): conns_timer = None pages_timer = None files_timer = None + storage_timer = None def __init__(self, app, parent): self.app = app @@ -1144,6 +1187,13 @@ class NodeInfo(urwid.WidgetWrap): self.t_active_links = NodeInfo.links_timer self.t_active_links.update_stat() + if NodeInfo.storage_timer == None: + self.t_storage_stats = NodeStorageStats(self.app) + NodeInfo.storage_timer = self.t_storage_stats + else: + self.t_active_links = NodeInfo.links_timer + self.t_active_links.update_stat() + if NodeInfo.conns_timer == None: self.t_total_connections = NodeTotalConnections(self.app) NodeInfo.conns_timer = self.t_total_connections @@ -1179,6 +1229,7 @@ class NodeInfo(urwid.WidgetWrap): e_lxmf, urwid.Divider(g["divider1"]), self.t_last_announce, + self.t_storage_stats, self.t_active_links, self.t_total_connections, self.t_total_pages, From 6da537176bfed80d782bc18f5bdc06fc5ab2ea36 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 23:03:50 +0200 Subject: [PATCH 084/592] Added printing support --- nomadnet/NomadNetworkApp.py | 173 +++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index e837fc0..4d66b07 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -2,9 +2,11 @@ import os import io import sys import time +import shlex import atexit import threading import traceback +import subprocess import contextlib import RNS @@ -12,6 +14,7 @@ import LXMF import nomadnet from nomadnet.Directory import DirectoryEntry +from datetime import datetime import RNS.vendor.umsgpack as msgpack @@ -480,6 +483,69 @@ class NomadNetworkApp: nomadnet.Conversation.ingest(message, self) + if self.should_print(message): + self.print_message(message) + + def should_print(self, message): + if self.print_messages: + if self.print_all_messages: + return True + + else: + source_hash_text = RNS.hexrep(message.source_hash, delimit=False) + + if self.print_trusted_messages: + trust_level = self.directory.trust_level(message.source_hash) + if trust_level == DirectoryEntry.TRUSTED: + return True + + if type(self.allowed_message_print_destinations) is list: + if source_hash_text in self.allowed_message_print_destinations: + return True + + return False + + def print_message(self, message, received = None): + try: + template = self.printing_template_msg + + if received == None: + received = time.time() + + g = self.ui.glyphs + + m_rtime = datetime.fromtimestamp(message.timestamp) + stime = m_rtime.strftime(self.time_format) + + message_time = datetime.fromtimestamp(received) + rtime = message_time.strftime(self.time_format) + + display_name = self.directory.simplest_display_str(message.source_hash) + title = message.title_as_string() + if title == "": + title = "None" + + output = template.format( + origin=display_name, + stime=stime, + rtime=rtime, + mtitle=title, + mbody=message.content_as_string(), + ) + + filename = "/tmp/"+RNS.hexrep(RNS.Identity.full_hash(output.encode("utf-8")), delimit=False) + with open(filename, "wb") as f: + f.write(output.encode("utf-8")) + f.close() + + print_command = "lp -d thermal -o cpi=16 -o lpi=8 "+filename + return_code = subprocess.call(shlex.split(print_command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + os.unlink(filename) + + except Exception as e: + RNS.log("Error while printing incoming LXMF message. The contained exception was: "+str(e)) + def conversations(self): return nomadnet.Conversation.conversation_list(self) @@ -686,6 +752,55 @@ class NomadNetworkApp: value = 0.005 self.message_storage_limit = value + self.print_command = "lp" + self.print_messages = False + self.print_all_messages = False + self.print_trusted_messages = False + if "printing" in self.config: + if not "print_messages" in self.config["printing"]: + self.print_messages = False + else: + self.print_messages = self.config["printing"].as_bool("print_messages") + + if "print_command" in self.config["printing"]: + self.print_command = self.config["printing"]["print_command"] + + if self.print_messages: + if not "print_from" in self.config["printing"]: + self.allowed_message_print_destinations = None + else: + if type(self.config["printing"]["print_from"]) == str: + self.allowed_message_print_destinations = [] + if self.config["printing"]["print_from"].lower() == "everywhere": + self.print_all_messages = True + + if self.config["printing"]["print_from"].lower() == "trusted": + + self.print_all_messages = False + self.print_trusted_messages = True + + if len(self.config["printing"]["print_from"]) == (RNS.Identity.TRUNCATED_HASHLENGTH//8)*2: + self.allowed_message_print_destinations.append(self.config["printing"]["print_from"]) + + if type(self.config["printing"]["print_from"]) == list: + self.allowed_message_print_destinations = self.config["printing"].as_list("print_from") + for allowed_entry in self.allowed_message_print_destinations: + if allowed_entry.lower() == "trusted": + self.print_trusted_messages = True + + + if not "message_template" in self.config["printing"]: + self.printing_template_msg = __printing_template_msg__ + else: + mt_path = os.path.expanduser(self.config["printing"]["message_template"]) + if os.path.isfile(mt_path): + template_file = open(mt_path, "rb") + self.printing_template_msg = template_file.read().decode("utf-8") + else: + template_file = open(mt_path, "wb") + template_file.write(__printing_template_msg__.encode("utf-8")) + self.printing_template_msg = __printing_template_msg__ + @staticmethod def get_shared_instance(): @@ -835,4 +950,60 @@ announce_at_start = Yes # and generally you do not need to use it. # prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf -'''.splitlines() \ No newline at end of file +[printing] + +# You can configure Nomad Network to print +# various kinds of information and messages. + +# Printing messages is disabled by default +print_messages = No + +# You can configure a custom template for +# message printing. If you uncomment this +# option, set a path to the template and +# restart Nomad Network, a default template +# will be created that you can edit. +# message_template = ~/.nomadnetwork/print_template_msg.txt + +# You can configure Nomad Network to only +# print messages from trusted destinations. +# print_from = trusted + +# Or specify the source LXMF addresses that +# will automatically have messages printed +# on arrival. +# print_from = 76fe5751a56067d1e84eef3e88eab85b, 0e70b5848eb57c13154154feaeeb89b7 + +# Or allow printing from anywhere, if you +# are feeling brave and adventurous. +# print_from = everywhere + +# You can configure the printing command. +# This will use the default CUPS printer on +# your system. +print_command = lp + +# You can specify what printer to use +# print_command = lp -d PRINTER_NAME + +# Or specify more advanced options. This +# example works well for small thermal- +# roll printers. +# print_command = lp -d PRINTER_NAME -o cpi=16 -o lpi=8 + +# This one is more suitable for full-sheet +# printers. +# print_command = lp -d PRINTER_NAME -o page-left=36 -o page-top=36 -o page-right=36 -o page-bottom=36 + +'''.splitlines() + +__printing_template_msg__ = """ +--------------------------- +From: {origin} +Sent: {stime} +Rcvd: {rtime} +Title: {mtitle} + +{mbody} +--------------------------- +""" From a83db96b225a1ff2d3e57f6a61805c2b9b21f39d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Jul 2022 23:17:29 +0200 Subject: [PATCH 085/592] Added printing to guide --- nomadnet/ui/textui/Guide.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 0e7bdd2..65a92ed 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -607,6 +607,34 @@ Configures the maximum amount of storage, in megabytes, that the LXMF Propagatio Configures the LXMF Propagation Node to prioritise storing messages for certain destinations. If the message store reaches the specified limit, LXMF will prioritise keeping messages for destinations specified with this option. This setting is optional, and generally you do not need to use it. < +>> Printing Section + +This section holds configuration directives related to printing. It is delimited by the `![printing]`! header in the configuration file. Available directives, along with example values, are as follows: + +>>> +`!print_messages = no`! +>>>> +Determines whether messages should be printed upon arrival. Must be a boolean value, and is turned off by default. +< + +>>> +`!message_template = ~/.nomadnetwork/print_template_msg.txt`! +>>>> +Determines where the template for printed messages is found. Must be a filesystem path. If you set this path to a non-existing file, an example will be generated in the specified location. +< + +>>> +`!print_from = 76fe5751a56067d1e84eef3e88eab85b, trusted`! +>>>> +Determines from which destinations messages are printed. Can be a list of destinations hashes, the keyword "trusted", or "everywhere". +< + +>>> +`!print_command = lp -d PRINTER_NAME -o cpi=16 -o lpi=8`! +>>>> +Specifies the command that Nomad Network uses to print the message. Defaults to "lp". The above example works well for small thermal-roll printers. +< + >Ignoring Destinations If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 32-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. From d35e23c74fb47ab918980773d83b2ff7259e41e2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 5 Jul 2022 00:08:21 +0200 Subject: [PATCH 086/592] Updated version and dependencies --- README.md | 2 +- nomadnet/_version.py | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a91f9cf..072a4c6 100755 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ For a native Android application with a graphical user interface, have a look at ### Docker Images -Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the very latest commit) or the version number (eg `0.1.8`) for a specific release. +Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the very latest commit) or the version number (eg `0.2.0`) for a specific release. ```sh $ docker pull ghcr.io/markqvist/nomadnet:master diff --git a/nomadnet/_version.py b/nomadnet/_version.py index c11f861..d3ec452 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.9" +__version__ = "0.2.0" diff --git a/setup.py b/setup.py index 6d4bfdc..71128e0 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.8', 'lxmf>=0.1.7', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.9', 'lxmf>=0.1.7', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 5c3d47fbd2f469b32777d2cffa93cb68942955f1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 5 Jul 2022 01:22:55 +0200 Subject: [PATCH 087/592] Fixed invalid reference --- nomadnet/ui/textui/Network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index e261583..4233974 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1191,8 +1191,8 @@ class NodeInfo(urwid.WidgetWrap): self.t_storage_stats = NodeStorageStats(self.app) NodeInfo.storage_timer = self.t_storage_stats else: - self.t_active_links = NodeInfo.links_timer - self.t_active_links.update_stat() + self.t_storage_stats = NodeInfo.links_timer + self.t_storage_stats.update_stat() if NodeInfo.conns_timer == None: self.t_total_connections = NodeTotalConnections(self.app) From 0156bad7fa6326875aad0d080599bde6d7bdc12d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 5 Jul 2022 01:25:24 +0200 Subject: [PATCH 088/592] Fixed invalid reference --- nomadnet/ui/textui/Network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 4233974..6039c48 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1191,7 +1191,7 @@ class NodeInfo(urwid.WidgetWrap): self.t_storage_stats = NodeStorageStats(self.app) NodeInfo.storage_timer = self.t_storage_stats else: - self.t_storage_stats = NodeInfo.links_timer + self.t_storage_stats = NodeInfo.storage_timer self.t_storage_stats.update_stat() if NodeInfo.conns_timer == None: From b73549179e93198afa55211e680c6adc709245a6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 8 Jul 2022 17:02:07 +0200 Subject: [PATCH 089/592] Updated sync progress bar --- README.md | 4 +--- nomadnet/NomadNetworkApp.py | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 072a4c6..d49e9cd 100755 --- a/README.md +++ b/README.md @@ -100,9 +100,7 @@ You can help support the continued development of open, free and private communi - Monero: ``` - 84FpY1QbxHcgdseePYNmhTHcrgMX4nFf - BYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD1 - 9b3B8NiLCGVxzKV17UMmmeEsCrPyA5w + 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w ``` - Ethereum ``` diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 4d66b07..12323d1 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -386,13 +386,13 @@ class NomadNetworkApp: if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE: return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_PATH_REQUESTED: - return True + return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHING: - return True + return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHED: - return True + return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_REQUEST_SENT: - return True + return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RECEIVING: return True elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: From be061b1d3ab370f9cc0fb1a6659c05a98b4361f1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 9 Jul 2022 16:14:26 +0200 Subject: [PATCH 090/592] Updated version and dependency versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index d3ec452..3ced358 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/setup.py b/setup.py index 71128e0..80b8314 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.9', 'lxmf>=0.1.7', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.11', 'lxmf>=0.1.7', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 7923cae62d2efd6cd059cbf09ce21e8f3e519272 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 9 Jul 2022 16:37:41 +0200 Subject: [PATCH 091/592] Updated readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d49e9cd..69c151c 100755 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Nomad Network is build on [LXMF](https://github.com/markqvist/LXMF) and [Reticul Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. +If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android and macOS. + ## Notable Features - Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports. - Zero-configuration, minimal-infrastructure mesh communication From ea9460cb8f6eaf3b077ca95c9e69994b17ca4f1a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 13 Sep 2022 21:08:34 +0200 Subject: [PATCH 092/592] Updated readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 69c151c..e5c3ee2 100755 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ nomadnet --daemon nomadnet --help ``` +On Arch Linux it is also possible to install using the `nomadnet` package from the [AUR](https://aur.archlinux.org/packages/nomadnet). + The first time the program is running, you will be presented with the guide section, which contains all the information you need to start using Nomad Network. To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). From fe108a45fa1d844dba1f5feb66403da4bd909a14 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 13 Sep 2022 21:34:12 +0200 Subject: [PATCH 093/592] Added testnet node info --- README.md | 7 ++++++- nomadnet/ui/textui/Guide.py | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e5c3ee2..51ea867 100755 --- a/README.md +++ b/README.md @@ -54,7 +54,12 @@ The first time the program is running, you will be presented with the guide sect To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). -If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. +If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: + + - `464ddc8cd323648ef919a638923b4916` Dublin Hub Testnet Node + - `5cda089fc42675bdd904c6d06db87d49` Frankfurt Hub Testnet Node + +To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. **Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 65a92ed..66deee7 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -675,7 +675,14 @@ The Testnet also runs the latest version of Reticulum, often even a short while outgoing = True target_host = frankfurt.rns.unsigned.io target_port = 4965 +< +If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: + + - Dublin Hub Testnet Node : `!`[464ddc8cd323648ef919a638923b4916]`! + - Frankfurt Hub Testnet Node : `!`[5cda089fc42675bdd904c6d06db87d49]`! + +To browse pages on a node that is not currently known, open the URL dialog in the `![ Network ]`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!< Go >`! or press enter. Nomadnet will attempt to discover and connect to the requested node. ''' TOPIC_DISPLAYTEST = '''>Markup & Color Display Test From 30eaadead23574a37de072bbdbc445ae6f9f92f8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 13 Sep 2022 21:35:02 +0200 Subject: [PATCH 094/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 3ced358..b5fdc75 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.1" +__version__ = "0.2.2" From 69c498432d602c4e654df355fe626c678a32b1cf Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 13 Sep 2022 21:37:59 +0200 Subject: [PATCH 095/592] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51ea867..1bcb0fe 100755 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ nomadnet --help On Arch Linux it is also possible to install using the `nomadnet` package from the [AUR](https://aur.archlinux.org/packages/nomadnet). -The first time the program is running, you will be presented with the guide section, which contains all the information you need to start using Nomad Network. +The first time the program is running, you will be presented with the **Guide section**, which contains all the information you need to start using Nomad Network. To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). From 4b76387f2c3f4698032f1d3cab84227cc4664d54 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 13 Sep 2022 21:47:41 +0200 Subject: [PATCH 096/592] Request announce for new conversations with unknown destinations --- nomadnet/ui/textui/Conversations.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 6d2895b..951b452 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -294,6 +294,10 @@ class ConversationsDisplay(): self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + + if not RNS.Transport.has_path(source_hash): + RNS.Transport.request_path(source_hash) + self.update_conversation_list() self.display_conversation(source_hash_text) From 6df7feffe68a785e48b40a5b729669ae8119ec1a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 13 Sep 2022 22:42:01 +0200 Subject: [PATCH 097/592] Updated guide --- nomadnet/ui/textui/Guide.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 66deee7..812d1ca 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -682,7 +682,7 @@ If you connect to the testnet, you can leave nomadnet running for a while and wa - Dublin Hub Testnet Node : `!`[464ddc8cd323648ef919a638923b4916]`! - Frankfurt Hub Testnet Node : `!`[5cda089fc42675bdd904c6d06db87d49]`! -To browse pages on a node that is not currently known, open the URL dialog in the `![ Network ]`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!< Go >`! or press enter. Nomadnet will attempt to discover and connect to the requested node. +To browse pages on a node that is not currently known, open the URL dialog in the `![ Network ]`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!< Go >`! or press enter. Nomadnet will attempt to discover and connect to the requested node. You can save the currently connected node by pressing `!Ctrl+S`!. ''' TOPIC_DISPLAYTEST = '''>Markup & Color Display Test From ebfa6484a28626e0345705463a6a130d2d6f6a59 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 13 Sep 2022 23:13:36 +0200 Subject: [PATCH 098/592] Persist directory to disk for every user modification --- nomadnet/Conversation.py | 3 +++ nomadnet/Directory.py | 3 +++ nomadnet/NomadNetworkApp.py | 1 - nomadnet/ui/textui/Conversations.py | 3 --- nomadnet/ui/textui/Network.py | 5 +++++ 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 94f2697..d36f2eb 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -142,6 +142,9 @@ class Conversation: self.__changed_callback = None + if not RNS.Transport.has_path(bytes.fromhex(source_hash)): + RNS.Transport.request_path(bytes.fromhex(source_hash)) + self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) if self.source_identity: diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index a2d9e79..875f621 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -47,6 +47,7 @@ class Directory: file = open(self.app.directorypath, "wb") file.write(msgpack.packb(directory)) file.close() + except Exception as e: RNS.log("Could not write directory to disk. Then contained exception was: "+str(e), RNS.LOG_ERROR) @@ -179,6 +180,8 @@ class Directory: node_entry = self.directory_entries[associated_node] node_entry.trust_level = entry.trust_level + self.save_to_disk() + def forget(self, source_hash): if source_hash in self.directory_entries: self.directory_entries.pop(source_hash) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 12323d1..07919e2 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -30,7 +30,6 @@ class NomadNetworkApp: START_ANNOUNCE_DELAY = 3 def exit_handler(self): - RNS.log("Nomad Network Client exit handler executing...", RNS.LOG_VERBOSE) self.should_run_jobs = False RNS.log("Saving directory...", RNS.LOG_VERBOSE) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 951b452..12645e5 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -294,9 +294,6 @@ class ConversationsDisplay(): self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) - - if not RNS.Transport.has_path(source_hash): - RNS.Transport.request_path(source_hash) self.update_conversation_list() diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 6039c48..9a72d13 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -127,6 +127,9 @@ class AnnounceInfo(urwid.WidgetWrap): if is_node: node_ident = RNS.Identity.recall(source_hash) + if not node_ident: + raise KeyError("Could not recall identity for selected node") + op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) op_str = self.app.directory.simplest_display_str(op_hash) @@ -607,6 +610,8 @@ class ExceptionHandlingListBox(IndicativeListBox): nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") elif key == "down": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.left_pile.set_focus(1) + else: + RNS.log("An error occurred while processing an interface event. The contained exception was: "+str(e), RNS.LOG_ERROR) class KnownNodes(urwid.WidgetWrap): From 47d3581b5f9c8a83946c11904683febc8187b7b6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 14 Sep 2022 00:04:29 +0200 Subject: [PATCH 099/592] Updated readme --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 1bcb0fe..0ab5dda 100755 --- a/README.md +++ b/README.md @@ -48,8 +48,6 @@ nomadnet --daemon nomadnet --help ``` -On Arch Linux it is also possible to install using the `nomadnet` package from the [AUR](https://aur.archlinux.org/packages/nomadnet). - The first time the program is running, you will be presented with the **Guide section**, which contains all the information you need to start using Nomad Network. To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). From 07f6bb836755edc09c7215de12ef310454169fe8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 14 Sep 2022 16:28:39 +0200 Subject: [PATCH 100/592] Place configuration in .config dir by default. Closes #12. --- nomadnet/NomadNetworkApp.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 07919e2..3616555 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -25,7 +25,11 @@ class NomadNetworkApp: time_format = "%Y-%m-%d %H:%M:%S" _shared_instance = None - configdir = os.path.expanduser("~")+"/.nomadnetwork" + userdir = os.path.expanduser("~") + if os.path.isdir(userdir+"/.nomadnetwork") and os.path.isfile(userdir+"/.nomadnetwork/config"): + configdir = userdir+"/.nomadnetwork" + else: + configdir = userdir+"/.config/nomadnetwork" START_ANNOUNCE_DELAY = 3 From 04c91de485595a8798fe9cf3ca58d173c30ff316 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 14 Sep 2022 16:29:13 +0200 Subject: [PATCH 101/592] Dependency update --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 80b8314..5672516 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.11', 'lxmf>=0.1.7', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.12', 'lxmf>=0.1.7', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 7cf6f74d0c4fb5f7004fb1d4c7486475b0b4002d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 14 Sep 2022 18:36:52 +0200 Subject: [PATCH 102/592] Updated config paths --- nomadnet/NomadNetworkApp.py | 2 +- nomadnet/ui/textui/Guide.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 3616555..9c62590 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -966,7 +966,7 @@ print_messages = No # option, set a path to the template and # restart Nomad Network, a default template # will be created that you can edit. -# message_template = ~/.nomadnetwork/print_template_msg.txt +# message_template = ~/.config/nomadnetwork/print_template_msg.txt # You can configure Nomad Network to only # print messages from trusted destinations. diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 812d1ca..8b6c952 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -314,7 +314,7 @@ The distributed message store is resilient to intermittency, and will remain fun Nomad Network nodes can host pages similar to web pages, that other peers can read and interact with. Pages are written in a compact markup language called `*micron`*. To learn how to write formatted pages with micron, see the `*Markup`* section of this guide (which is, itself, written in micron). Pages can be linked arbitrarily with hyperlinks, that can also link to pages (or other resources) on other nodes. -To add pages to your node, place micron files in the `*pages`* directory of your Nomad Network programs `*storage`* directory. By default, the path to this will be `!~/.nomadnetwork/storage/pages`!. You should probably create the file `!index.mu`! first, as this is the page that will get served by default to a connecting peer. +To add pages to your node, place micron files in the `*pages`* directory of your Nomad Network programs `*storage`* directory. By default, the path to this will be `!~/.config/nomadnetwork/storage/pages`!. You should probably create the file `!index.mu`! first, as this is the page that will get served by default to a connecting peer. You can control how long a peer will cache your pages by including the cache header in a page. To do so, the first line of your page must start with `!#!c=X`!, where `!X`! is the cache time in seconds. To tell the peer to always load the page from your node, and never cache it, set the cache time to zero. You should only do this if there is a real need, for example if your page displays dynamic content that `*must`* be updated at every page view. The default caching time is 12 hours. In most cases, you should not need to include the cache control header in your pages. @@ -342,7 +342,7 @@ By default, Nomad Network connects anonymously to all nodes. To be able to ident >>Files -Like pages, you can place files you want to make available in the `!~/.nomadnetwork/storage/files`! directory. To let a peer download a file, you should create a link to it in one of your pages. +Like pages, you can place files you want to make available in the `!~/.config/nomadnetwork/storage/files`! directory. To let a peer download a file, you should create a link to it in one of your pages. >>Links and URLs @@ -400,7 +400,7 @@ Now go out there and explore. This is still early days. See what you can find an TOPIC_CONFIG = '''>Configuration Options -To change the configuration of Nomad Network, you must edit the configuration file. By default, this is located at `!~/.nomadnetwork/config`! on your system. You can open it in any text-editor, and change the options. You can also use the editor built in to this program, under the `![ Config ]`! menu item. The default configuration file contains comments on all the different configuration options present, and explains their possible settings. +To change the configuration of Nomad Network, you must edit the configuration file. By default, this is located at `!~/.config/nomadnetwork/config`! on your system. You can open it in any text-editor, and change the options. You can also use the editor built in to this program, under the `![ Config ]`! menu item. The default configuration file contains comments on all the different configuration options present, and explains their possible settings. For reference, all the configuration options are listed and explained here as well. The configuration is divided into different sections, each with their own options. @@ -430,7 +430,7 @@ Determines the output destination of logged information. Must be `!file`! or `!c < >>> -`!logfile = ~/.nomadnetwork/logfile`! +`!logfile = ~/.config/nomadnetwork/logfile`! >>>> Path to the log file. Must be a writable filesystem path. < @@ -584,13 +584,13 @@ Determines how often, in minutes, your node is announced on the network. Default < >>> -`!pages_path = ~/.nomadnetwork/storage/pages`! +`!pages_path = ~/.config/nomadnetwork/storage/pages`! >>>> Determines where the node server will look for hosted pages. Must be a readable filesystem path. < >>> -`!files_path = ~/.nomadnetwork/storage/files`! +`!files_path = ~/.config/nomadnetwork/storage/files`! >>>> Determines where the node server will look for downloadable files. Must be a readable filesystem path. < @@ -618,7 +618,7 @@ Determines whether messages should be printed upon arrival. Must be a boolean va < >>> -`!message_template = ~/.nomadnetwork/print_template_msg.txt`! +`!message_template = ~/.config/nomadnetwork/print_template_msg.txt`! >>>> Determines where the template for printed messages is found. Must be a filesystem path. If you set this path to a non-existing file, an example will be generated in the specified location. < @@ -637,7 +637,7 @@ Specifies the command that Nomad Network uses to print the message. Defaults to >Ignoring Destinations -If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 32-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. +If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.config/nomadnetwork/ignored`! file. To ignore nodes or peers, add one 32-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. ''' TOPIC_NETWORKS = '''>Network Configuration From 4a935cb5007ce341f8dfb5f363a2634a0c56f369 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 16 Sep 2022 23:47:31 +0200 Subject: [PATCH 103/592] Updated announce stream formatting --- nomadnet/ui/textui/Network.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 9a72d13..b5107ae 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -218,6 +218,7 @@ class AnnounceStreamEntry(urwid.WidgetWrap): date_time_format = "%Y-%m-%d" time_time_format = "%H:%M:%S" short_time_format = "%Y-%m-%d %H:%M" + date_only_format = "%Y-%m-%d" timestamp = announce[0] source_hash = announce[1] @@ -232,7 +233,7 @@ class AnnounceStreamEntry(urwid.WidgetWrap): if dt.strftime(date_time_format) == dtn.strftime(date_time_format): ts_string = dt.strftime(time_time_format) else: - ts_string = dt.strftime(short_time_format) + ts_string = dt.strftime(date_only_format) trust_level = self.app.directory.trust_level(source_hash) display_str = self.app.directory.simplest_display_str(source_hash) From 4da00e57f332c9e4d9609d4016d461b968ef72b0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 28 Sep 2022 16:00:09 +0200 Subject: [PATCH 104/592] Better terminal palette handling. Fixes #8. --- nomadnet/NomadNetworkApp.py | 8 ++++++++ nomadnet/ui/TextUI.py | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 9c62590..c845c43 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -47,6 +47,14 @@ class NomadNetworkApp: except Exception as e: RNS.log("Could not restore flow control sequences. The contained exception was: "+str(e), RNS.LOG_WARNING) + if hasattr(self.ui, "restore_palette"): + if self.ui.restore_palette: + try: + self.ui.screen.write("\x1b]104\x07") + + except Exception as e: + RNS.log("Could not restore terminal color palette. The contained exception was: "+str(e), RNS.LOG_WARNING) + RNS.log("Nomad Network Client exiting now", RNS.LOG_VERBOSE) def exception_handler(self, e_type, e_value, e_traceback): diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 4a8abf9..ae3e17d 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -207,7 +207,10 @@ class TextUI: def set_colormode(self, colormode): self.colormode = colormode self.screen.set_terminal_properties(colormode) - self.screen.reset_default_terminal_palette() + + if self.colormode < 256: + self.screen.reset_default_terminal_palette() + self.restore_palette = True def unhandled_input(self, key): if key == "ctrl q": From 4fa3dbbcd518ff7284a7dc5f23a24506c52234db Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 30 Sep 2022 00:49:20 +0200 Subject: [PATCH 105/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5672516..46bf51d 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.12', 'lxmf>=0.1.7', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.12', 'lxmf>=0.1.8', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 4757550626a077a0ed5e50acc25747b52d6b3d6d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 30 Sep 2022 20:44:26 +0200 Subject: [PATCH 106/592] Updated default config dir paths --- nomadnet/NomadNetworkApp.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index c845c43..d791bcf 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -26,10 +26,12 @@ class NomadNetworkApp: _shared_instance = None userdir = os.path.expanduser("~") - if os.path.isdir(userdir+"/.nomadnetwork") and os.path.isfile(userdir+"/.nomadnetwork/config"): - configdir = userdir+"/.nomadnetwork" - else: + if os.path.isdir("/etc/nomadnetwork") and os.path.isfile("/etc/nomadnetwork/config"): + configdir = "/etc/nomadnetwork" + elif os.path.isdir(userdir+"/.config/nomadnetwork") and os.path.isfile(userdir+"/.config/nomadnetwork/config"): configdir = userdir+"/.config/nomadnetwork" + else: + configdir = userdir+"/.nomadnetwork" START_ANNOUNCE_DELAY = 3 From c999014876cf1ef24bfd4bbd3aec5ca6beaa6d28 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 30 Sep 2022 20:59:02 +0200 Subject: [PATCH 107/592] Updated guide and config paths --- nomadnet/NomadNetworkApp.py | 2 +- nomadnet/ui/textui/Guide.py | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index d791bcf..bc972c0 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -976,7 +976,7 @@ print_messages = No # option, set a path to the template and # restart Nomad Network, a default template # will be created that you can edit. -# message_template = ~/.config/nomadnetwork/print_template_msg.txt +# message_template = ~/.nomadnetwork/print_template_msg.txt # You can configure Nomad Network to only # print messages from trusted destinations. diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 8b6c952..1452bec 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -314,7 +314,7 @@ The distributed message store is resilient to intermittency, and will remain fun Nomad Network nodes can host pages similar to web pages, that other peers can read and interact with. Pages are written in a compact markup language called `*micron`*. To learn how to write formatted pages with micron, see the `*Markup`* section of this guide (which is, itself, written in micron). Pages can be linked arbitrarily with hyperlinks, that can also link to pages (or other resources) on other nodes. -To add pages to your node, place micron files in the `*pages`* directory of your Nomad Network programs `*storage`* directory. By default, the path to this will be `!~/.config/nomadnetwork/storage/pages`!. You should probably create the file `!index.mu`! first, as this is the page that will get served by default to a connecting peer. +To add pages to your node, place micron files in the `*pages`* directory of your Nomad Network programs `*storage`* directory. By default, the path to this will be `!~/.nomadnetwork/storage/pages`!. You should probably create the file `!index.mu`! first, as this is the page that will get served by default to a connecting peer. You can control how long a peer will cache your pages by including the cache header in a page. To do so, the first line of your page must start with `!#!c=X`!, where `!X`! is the cache time in seconds. To tell the peer to always load the page from your node, and never cache it, set the cache time to zero. You should only do this if there is a real need, for example if your page displays dynamic content that `*must`* be updated at every page view. The default caching time is 12 hours. In most cases, you should not need to include the cache control header in your pages. @@ -342,7 +342,7 @@ By default, Nomad Network connects anonymously to all nodes. To be able to ident >>Files -Like pages, you can place files you want to make available in the `!~/.config/nomadnetwork/storage/files`! directory. To let a peer download a file, you should create a link to it in one of your pages. +Like pages, you can place files you want to make available in the `!~/.nomadnetwork/storage/files`! directory. To let a peer download a file, you should create a link to it in one of your pages. >>Links and URLs @@ -400,7 +400,15 @@ Now go out there and explore. This is still early days. See what you can find an TOPIC_CONFIG = '''>Configuration Options -To change the configuration of Nomad Network, you must edit the configuration file. By default, this is located at `!~/.config/nomadnetwork/config`! on your system. You can open it in any text-editor, and change the options. You can also use the editor built in to this program, under the `![ Config ]`! menu item. The default configuration file contains comments on all the different configuration options present, and explains their possible settings. +To change the configuration of Nomad Network, you must edit the configuration file. If you did not manually specify a config path when you started the program, Nomad Net will look for a configuration in the folllowing directories: + + `!/etc/nomadnetwork`! + `!~/.config/nomadnetwork`! + `!~/.nomadnetwork`! + +If no existing configuration file is found, one will be created at `!~/.nomadnetwork/config`! by default. The default configuration file contains comments on all the different configuration options present, and explains their possible settings. + +You can open the configuration file in any text-editor, and change the options. You can also use the editor built in to this program, under the `![ Config ]`! menu item. If the built-in editor does not gain focus, and your navigation keys are not working, try hitting enter or space, which should focus the editor and let you navigate the text. For reference, all the configuration options are listed and explained here as well. The configuration is divided into different sections, each with their own options. @@ -430,7 +438,7 @@ Determines the output destination of logged information. Must be `!file`! or `!c < >>> -`!logfile = ~/.config/nomadnetwork/logfile`! +`!logfile = ~/.nomadnetwork/logfile`! >>>> Path to the log file. Must be a writable filesystem path. < @@ -584,13 +592,13 @@ Determines how often, in minutes, your node is announced on the network. Default < >>> -`!pages_path = ~/.config/nomadnetwork/storage/pages`! +`!pages_path = ~/.nomadnetwork/storage/pages`! >>>> Determines where the node server will look for hosted pages. Must be a readable filesystem path. < >>> -`!files_path = ~/.config/nomadnetwork/storage/files`! +`!files_path = ~/.nomadnetwork/storage/files`! >>>> Determines where the node server will look for downloadable files. Must be a readable filesystem path. < @@ -618,7 +626,7 @@ Determines whether messages should be printed upon arrival. Must be a boolean va < >>> -`!message_template = ~/.config/nomadnetwork/print_template_msg.txt`! +`!message_template = ~/.nomadnetwork/print_template_msg.txt`! >>>> Determines where the template for printed messages is found. Must be a filesystem path. If you set this path to a non-existing file, an example will be generated in the specified location. < @@ -637,7 +645,7 @@ Specifies the command that Nomad Network uses to print the message. Defaults to >Ignoring Destinations -If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.config/nomadnetwork/ignored`! file. To ignore nodes or peers, add one 32-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. +If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 32-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. ''' TOPIC_NETWORKS = '''>Network Configuration From 6ff01c3e678219dcd37f989c2f0b3ba57f1eb29a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 30 Sep 2022 22:58:18 +0200 Subject: [PATCH 108/592] Cleanup --- nomadnet/ui/textui/Main.py | 2 -- nomadnet/ui/textui/Network.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 22445da..deca22a 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -125,8 +125,6 @@ class MainDisplay(): def update_active_sub_display(self): self.frame.contents["body"] = (self.sub_displays.active().widget, None) self.update_active_shortcuts() - # TODO: Remove when new mitigation has been tested - # self.app.ui.main_display.request_redraw(extra_delay=0.0) def update_active_shortcuts(self): self.frame.contents["footer"] = (self.sub_displays.active().shortcuts().widget, None) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index b5107ae..525fc0b 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -114,8 +114,6 @@ class AnnounceInfo(urwid.WidgetWrap): self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) def connect(sender): - # TODO: Remove when new mitigation has been tested - # self.app.ui.main_display.request_redraw(extra_delay=0.75) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_announce_stream(None) From 583b4307fca55065e4962c91301e9635f0176682 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 4 Oct 2022 09:30:29 +0200 Subject: [PATCH 109/592] Bumped version and RNS version --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index b5fdc75..d31c31e 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.2" +__version__ = "0.2.3" diff --git a/setup.py b/setup.py index 46bf51d..77f72ef 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.12', 'lxmf>=0.1.8', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.13', 'lxmf>=0.1.9', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 094a604dd633a735effcbf9eb8d82eb424970cd4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 6 Oct 2022 23:40:10 +0200 Subject: [PATCH 110/592] Updated version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index d31c31e..788da1f 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.3" +__version__ = "0.2.4" diff --git a/setup.py b/setup.py index 77f72ef..6d461de 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.13', 'lxmf>=0.1.9', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.14', 'lxmf>=0.2.0', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 10501f67124d0035d4c6845c8ce1a4b399c4e295 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 6 Oct 2022 23:47:43 +0200 Subject: [PATCH 111/592] Updated readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ab5dda..53fcda7 100755 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ nomadnet --daemon nomadnet --help ``` +**Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + The first time the program is running, you will be presented with the **Guide section**, which contains all the information you need to start using Nomad Network. To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). @@ -59,8 +61,6 @@ If you want to try Nomad Network without building your own physical network, you To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. -**Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. - ### Install on Android You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. From cb672565e42a10615a10cdea4ed70394866f0374 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 7 Oct 2022 01:45:43 +0200 Subject: [PATCH 112/592] Updated readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 53fcda7..b2139e5 100755 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ To use Nomad Network on packet radio or LoRa, you will need to configure your Re If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: - - `464ddc8cd323648ef919a638923b4916` Dublin Hub Testnet Node - - `5cda089fc42675bdd904c6d06db87d49` Frankfurt Hub Testnet Node + - `abb3ebcd03cb2388a838e70c001291f9` Dublin Hub Testnet Node + - `ea6a715f814bdc37e56f80c34da6ad51` Frankfurt Hub Testnet Node To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. From 29269de3ab07464c614b8103cbebbb2c44e44e1e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 7 Oct 2022 01:47:29 +0200 Subject: [PATCH 113/592] Updated node addresses in guide --- nomadnet/ui/textui/Guide.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 1452bec..ddaab45 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -687,8 +687,8 @@ The Testnet also runs the latest version of Reticulum, often even a short while If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: - - Dublin Hub Testnet Node : `!`[464ddc8cd323648ef919a638923b4916]`! - - Frankfurt Hub Testnet Node : `!`[5cda089fc42675bdd904c6d06db87d49]`! + - Dublin Hub Testnet Node : `!`[abb3ebcd03cb2388a838e70c001291f9]`! + - Frankfurt Hub Testnet Node : `!`[ea6a715f814bdc37e56f80c34da6ad51]`! To browse pages on a node that is not currently known, open the URL dialog in the `![ Network ]`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!< Go >`! or press enter. Nomadnet will attempt to discover and connect to the requested node. You can save the currently connected node by pressing `!Ctrl+S`!. ''' From 0cbdb2c395795df9870e931ce359197b3f47afcd Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 20 Oct 2022 14:59:51 +0200 Subject: [PATCH 114/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6d461de..5695a79 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.14', 'lxmf>=0.2.0', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.15', 'lxmf>=0.2.0', 'urwid>=2.1.2'], python_requires='>=3.6', ) From d5e3809ba54c71ee77d2c3b61b8b7b74c9f52e72 Mon Sep 17 00:00:00 2001 From: markqvist Date: Thu, 20 Oct 2022 18:07:48 +0200 Subject: [PATCH 115/592] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b2139e5..32f3cee 100755 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ To browse pages on a node that is not currently known, open the URL dialog in th ### Install on Android You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. -For a native Android application with a graphical user interface, have a look at [Sideband](https://unsigned.io/sideband). +For a native Android application with a graphical user interface, have a look at [Sideband](https://github.com/markqvist/Sideband). ### Docker Images From a7ffd2101b943ab989699734fb164f19b9e4e70c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 20 Oct 2022 21:01:31 +0200 Subject: [PATCH 116/592] Updated version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 788da1f..fe404ae 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.4" +__version__ = "0.2.5" diff --git a/setup.py b/setup.py index 5695a79..9532e24 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.15', 'lxmf>=0.2.0', 'urwid>=2.1.2'], + install_requires=['rns>=0.3.16', 'lxmf>=0.2.1', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 72cfab3bd29bd8a4fe63d83542b6e62138058a9f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 20 Oct 2022 21:12:16 +0200 Subject: [PATCH 117/592] Fixed missing escape parsing for backslashes --- nomadnet/ui/textui/MicronParser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 7a8ff7e..6948c00 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -480,7 +480,11 @@ def make_output(state, line, url_delegate): elif mode == "text": if c == "\\": - escape = True + if escape: + part += c + escape = False + else: + escape = True elif c == "`": if escape: part += c From fe257a63c0465e47b9ef75948c58f77dfab0c793 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 22 Oct 2022 22:32:49 +0200 Subject: [PATCH 118/592] Fixed typo --- nomadnet/NomadNetworkApp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index bc972c0..76695be 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -256,7 +256,7 @@ class NomadNetworkApp: RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) except Exception as e: - RNS.log("Error while fetching loading list of ignored destinations: "+str(e), RNS.LOG_ERROR) + RNS.log("Error while loading list of ignored destinations: "+str(e), RNS.LOG_ERROR) self.directory = nomadnet.Directory(self) From 4fcf37ac86d6b8f998e16254dd2f74931903bc21 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 25 Oct 2022 12:52:02 +0200 Subject: [PATCH 119/592] Implemented support for standalone LXMF propagation nodes --- nomadnet/Directory.py | 58 +++++++++++++++++---- nomadnet/NomadNetworkApp.py | 20 +++----- nomadnet/_version.py | 2 +- nomadnet/ui/textui/Conversations.py | 9 +++- nomadnet/ui/textui/Network.py | 80 ++++++++++++++++++++--------- 5 files changed, 121 insertions(+), 48 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 875f621..64eaf07 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -5,6 +5,29 @@ import time import nomadnet import RNS.vendor.umsgpack as msgpack +class PNAnnounceHandler: + def __init__(self, owner): + self.aspect_filter = "lxmf.propagation" + self.owner = owner + + def received_announce(self, destination_hash, announced_identity, app_data): + try: + if type(app_data) == bytes: + data = msgpack.unpackb(app_data) + + if data[0] == True: + RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash)) + + associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) + associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity) + + self.owner.app.directory.pn_announce_received(destination_hash, app_data, associated_peer, associated_node) + self.owner.app.autoselect_propagation_node() + + except Exception as e: + RNS.log("Error while evaluating propagation node announce, ignoring announce.", RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + class Directory: ANNOUNCE_STREAM_MAXLENGTH = 64 @@ -14,8 +37,6 @@ class Directory: app = nomadnet.NomadNetworkApp.get_shared_instance() if not destination_hash in app.ignored_list: - destination_hash_text = RNS.hexrep(destination_hash, delimit=False) - associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) app.directory.node_announce_received(destination_hash, app_data, associated_peer) @@ -31,6 +52,9 @@ class Directory: self.app = app self.load_from_disk() + self.pn_announce_handler = PNAnnounceHandler(self) + RNS.Transport.register_announce_handler(self.pn_announce_handler) + def save_to_disk(self): try: @@ -90,7 +114,7 @@ class Directory: def lxmf_announce_received(self, source_hash, app_data): if app_data != None: timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, False)) + self.announce_stream.insert(0, (timestamp, source_hash, app_data, "peer")) while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: self.announce_stream.pop() @@ -100,7 +124,7 @@ class Directory: def node_announce_received(self, source_hash, app_data, associated_peer): if app_data != None: timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, True)) + self.announce_stream.insert(0, (timestamp, source_hash, app_data, "node")) while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: self.announce_stream.pop() @@ -113,6 +137,27 @@ class Directory: if hasattr(self.app.ui, "main_display"): self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + def pn_announce_received(self, source_hash, app_data, associated_peer, associated_node): + found_node = None + for sh in self.directory_entries: + if sh == associated_node: + found_node = True + break + + for e in self.announce_stream: + if e[1] == associated_node: + found_node = True + break + + if not found_node: + timestamp = time.time() + self.announce_stream.insert(0, (timestamp, source_hash, app_data, "pn")) + while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self.announce_stream.pop() + + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + def remove_announce_with_timestamp(self, timestamp): selected_announce = None for announce in self.announce_stream: @@ -250,11 +295,6 @@ class DirectoryEntry: def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False): if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: self.source_hash = source_hash - - # TODO: Clean - # if display_name == None: - # display_name = source_hash - self.display_name = display_name if preferred_delivery == None: diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 76695be..d0ac8a5 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -436,8 +436,9 @@ class NomadNetworkApp: def autoselect_propagation_node(self): selected_node = None - if "propagation_node" in self.peer_settings and self.directory.find(self.peer_settings["propagation_node"]): - selected_node = self.directory.find(self.peer_settings["propagation_node"]) + if "propagation_node" in self.peer_settings: + selected_node = self.peer_settings["propagation_node"] + else: nodes = self.directory.known_nodes() trusted_nodes = [] @@ -450,19 +451,14 @@ class NomadNetworkApp: if hops < best_hops: best_hops = hops - selected_node = node + selected_node = node.source_hash if selected_node == None: - RNS.log("Could not autoselect a propagation node! LXMF propagation will not be available until a trusted node announces on the network.", RNS.LOG_WARNING) + RNS.log("Could not autoselect a propagation node! LXMF propagation will not be available until a trusted node announces on the network, or a propagation node is manually selected.", RNS.LOG_WARNING) else: - node_identity = RNS.Identity.recall(selected_node.source_hash) - if node_identity != None: - propagation_hash = RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_identity) - RNS.log("Selecting "+selected_node.display_name+" "+RNS.prettyhexrep(propagation_hash)+" as default LXMF propagation node", RNS.LOG_INFO) - self.message_router.set_outbound_propagation_node(propagation_hash) - else: - RNS.log("Could not recall identity for autoselected LXMF propagation node "+RNS.prettyhexrep(selected_node.source_hash), RNS.LOG_WARNING) - RNS.log("LXMF propagation will not be available until a trusted node announces on the network.", RNS.LOG_WARNING) + pn_name_str = "" + RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_INFO) + self.message_router.set_outbound_propagation_node(selected_node) def get_user_selected_propagation_node(self): if "propagation_node" in self.peer_settings: diff --git a/nomadnet/_version.py b/nomadnet/_version.py index fe404ae..01ef120 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.5" +__version__ = "0.2.6" diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 12645e5..cac9b89 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -398,10 +398,15 @@ class ConversationsDisplay(): if pn_ident != None: node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) pn_entry = self.app.directory.find(node_hash) + pn_display_str = " " + if pn_entry != None: + pn_display_str += " "+str(pn_entry.display_name) + else: + pn_display_str += " "+RNS.prettyhexrep(pn_hash) dialog = DialogLineBox( urwid.Pile([ - urwid.Text(""+g["node"]+" "+str(pn_entry.display_name), align="center"), + urwid.Text(""+g["node"]+pn_display_str, align="center"), urwid.Divider(g["divider1"]), sync_progress, urwid.Divider(g["divider1"]), @@ -417,7 +422,7 @@ class ConversationsDisplay(): urwid.Pile([ urwid.Text(""), urwid.Text("No trusted nodes found, cannot sync!\n", align="center"), - urwid.Text("To syncronise messages from the network, one or more nodes must be marked as trusted in the Known Nodes list. Nomad Network will then automatically sync from the nearest trusted node.", align="left"), + urwid.Text("To syncronise messages from the network, one or more nodes must be marked as trusted in the Known Nodes list, or a node must manually be selected as the default propagation node. Nomad Network will then automatically sync from the nearest trusted node, or the manually selected one.", align="left"), urwid.Text(""), button_columns ]), title="Message Sync" diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 525fc0b..ebc9e2a 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -71,11 +71,17 @@ class AnnounceInfo(urwid.WidgetWrap): trust_str = "" display_str = self.app.directory.simplest_display_str(source_hash) addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" - is_node = announce[3] + info_type = announce[3] - if is_node: - type_string = "Node " + g["node"] - else: + is_node = False + is_pn = False + if info_type == "node" or info_type == True: + type_string = "Nomad Network Node " + g["node"] + is_node = True + elif info_type == "pn": + type_string = "LXMF Propagation Node " + g["sent"] + is_pn = True + elif info_type == "peer" or info_type == False: type_string = "Peer " + g["peer"] try: @@ -174,10 +180,20 @@ class AnnounceInfo(urwid.WidgetWrap): except Exception as e: RNS.log("Error while starting conversation from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) + def use_pn(sender): + show_announce_stream(None) + try: + self.app.set_user_selected_propagation_node(source_hash) + except Exception as e: + RNS.log("Error while setting active propagation node from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) + if is_node: type_button = ("weight", 0.45, urwid.Button("Connect", on_press=connect)) msg_button = ("weight", 0.45, urwid.Button("Msg Op", on_press=msg_op)) save_button = ("weight", 0.45, urwid.Button("Save", on_press=save_node)) + elif is_pn: + type_button = ("weight", 0.45, urwid.Button("Use as default", on_press=use_pn)) + save_button = None else: type_button = ("weight", 0.45, urwid.Button("Converse", on_press=converse)) save_button = None @@ -187,21 +203,33 @@ class AnnounceInfo(urwid.WidgetWrap): else: button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_announce_stream)), ("weight", 0.1, urwid.Text("")), type_button]) - pile_widgets = [ - urwid.Text("Time : "+ts_string, align="left"), - urwid.Text("Addr : "+addr_str, align="left"), - urwid.Text("Type : "+type_string, align="left"), - urwid.Text("Name : "+display_str, align="left"), - urwid.Text(["Trust : ", (style, trust_str)], align="left"), - urwid.Divider(g["divider1"]), - urwid.Text(["Announce Data: \n", (data_style, data_str)], align="left"), - urwid.Divider(g["divider1"]), - button_columns - ] + pile_widgets = [] - if is_node: - operator_entry = urwid.Text("Oprtr : "+op_str, align="left") - pile_widgets.insert(4, operator_entry) + if is_pn: + pile_widgets = [ + urwid.Text("Time : "+ts_string, align="left"), + urwid.Text("Addr : "+addr_str, align="left"), + urwid.Text("Type : "+type_string, align="left"), + urwid.Divider(g["divider1"]), + button_columns + ] + + else: + pile_widgets = [ + urwid.Text("Time : "+ts_string, align="left"), + urwid.Text("Addr : "+addr_str, align="left"), + urwid.Text("Type : "+type_string, align="left"), + urwid.Text("Name : "+display_str, align="left"), + urwid.Text(["Trust : ", (style, trust_str)], align="left"), + urwid.Divider(g["divider1"]), + urwid.Text(["Announce Data: \n", (data_style, data_str)], align="left"), + urwid.Divider(g["divider1"]), + button_columns + ] + + if is_node: + operator_entry = urwid.Text("Oprtr : "+op_str, align="left") + pile_widgets.insert(4, operator_entry) pile = urwid.Pile(pile_widgets) @@ -220,7 +248,7 @@ class AnnounceStreamEntry(urwid.WidgetWrap): timestamp = announce[0] source_hash = announce[1] - is_node = announce[3] + announce_type = announce[3] self.app = app self.timestamp = timestamp time_format = app.time_format @@ -257,10 +285,12 @@ class AnnounceStreamEntry(urwid.WidgetWrap): style = "list_untrusted" focus_style = "list_focus_untrusted" - if is_node: + if announce_type == "node" or announce_type == True: type_symbol = g["node"] - else: + elif announce_type == "peer" or announce_type == False: type_symbol = g["peer"] + elif announce_type == "pn": + type_symbol = g["sent"] widget = ListEntry(ts_string+" "+type_symbol+" "+display_str) urwid.connect_signal(widget, "click", self.display_announce, announce) @@ -425,13 +455,15 @@ class KnownNodeInfo(urwid.WidgetWrap): if display_str == None: display_str = addr_str + pn_hash = RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident) + if node_ident != None: - lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident)) + lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(pn_hash) else: lxmf_addr_str = "No associated Propagation Node known" - type_string = "Node " + g["node"] + type_string = "Nomad Network Node " + g["node"] if trust_level == DirectoryEntry.UNTRUSTED: trust_str = "Untrusted" @@ -525,7 +557,7 @@ class KnownNodeInfo(urwid.WidgetWrap): def save_node(sender): if self.pn_changed: if propagation_node_checkbox.get_state(): - self.app.set_user_selected_propagation_node(source_hash) + self.app.set_user_selected_propagation_node(pn_hash) else: self.app.set_user_selected_propagation_node(None) From fe0437a2fdda51db6072a3990b54b619a69089bc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 3 Nov 2022 12:22:29 +0100 Subject: [PATCH 120/592] Updated guide --- nomadnet/ui/textui/Guide.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index ddaab45..ae0ffb4 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -272,7 +272,7 @@ In the `![ Conversations ]`! part of the program you can view and interact with By default, Nomad Network will attempt to deliver messages to a peer directly. This happens by first establishing an encrypted link directly to the peer, and then delivering the message over it. -If the desired peer is not available because it has disconnected from the network, this method will obviously fail. In this case, Nomad Network will attempt to deliver the message to a node, which will store and forward it over the network, for later retrieval by the destination peer. The message is encrypted with an ephemeral key before being transmitted to the network, and is only readable by the intended recipient. +If the desired peer is not available because it has disconnected from the network, this method will obviously fail. In this case, Nomad Network will attempt to deliver the message to a node, which will store and forward it over the network, for later retrieval by the destination peer. The message is encrypted before being transmitted to the network, and is only readable by the intended recipient. For propagated delivery to work, one or more nodes must be available on the network. If one or more trusted nodes are available, Nomad Network will automatically select the most suitable node to send the message via, but you can also manually specify what node to use. From 70d0b0a32ab276fe7bff9995ca5bb1e84f7c13db Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 3 Nov 2022 12:23:00 +0100 Subject: [PATCH 121/592] Updated dependencies and version --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 01ef120..6cd38b7 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.6" +__version__ = "0.2.7" diff --git a/setup.py b/setup.py index 9532e24..e27bca0 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.16', 'lxmf>=0.2.1', 'urwid>=2.1.2'], + install_requires=['rns>=0.4.0', 'lxmf>=0.2.3', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 6a8a146d6aa15876fafcab11626e30eb25fb8891 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 3 Nov 2022 12:23:44 +0100 Subject: [PATCH 122/592] Updated dependencies and version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 6cd38b7..01ef120 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.7" +__version__ = "0.2.6" From 08a9225cc9fcaa1c08322fb65349117ef2c7854f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 3 Nov 2022 23:20:29 +0100 Subject: [PATCH 123/592] Updated dependencies and version --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 01ef120..6cd38b7 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.6" +__version__ = "0.2.7" diff --git a/setup.py b/setup.py index e27bca0..ff802b6 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.4.0', 'lxmf>=0.2.3', 'urwid>=2.1.2'], + install_requires=['rns>=0.4.1', 'lxmf>=0.2.4', 'urwid>=2.1.2'], python_requires='>=3.6', ) From 15a4ec2af9cffec1f5f3129f8f42c7b719874cac Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 17 Nov 2022 13:35:05 +0100 Subject: [PATCH 124/592] Added roadmap --- README.md | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 32f3cee..76c6a08 100755 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Nomad Network - Communicate Freely -========== +# Nomad Network - Communicate Freely Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. @@ -26,11 +25,6 @@ If you'd rather want to use an LXMF client with a graphical user interface, you ## Current Status The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the ideal time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion, report bugs and request features here on the GitHub project. -### Feature roadmap - - Network-wide propagated bulletins and discussion threads - - Collaborative maps and geospatial information sharing - - Facilitation of trade and barter - ## How do I get started? The easiest way to install Nomad Network is via pip: @@ -119,6 +113,29 @@ You can help support the continued development of open, free and private communi ``` - Ko-Fi: https://ko-fi.com/markqvist +## Development Roadmap + +- New major features + - Network-wide propagated bulletins and discussion threads + - Collaborative maps and geospatial information sharing + - Facilitation of trade and barter +- Minor improvements and fixes + - Fix internal editor failing on some OSes with no "editor" alias + - Possibly add a required-width header + - Improve browser handling of remote link close + - Better navigation handling when requests fail (also because of closed links) + - Ctrl-M shorcut for jumping to menu + - Retry failed messages mechanism + - Re-arrange buttons to be more consistent + - Link status (RSSI and SNR) in conversation or conv list + - Share node with other users / send node info to user + - Input field for pages + - Post mechanism + - Term compatibility notice in readme + - Selected icon in conversation list + - Possibly a Search Local Nodes function + - Possibly add via entry in node info box, next to distance + ## Caveat Emptor Nomad Network is beta software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch. From 730c17c981a8bf620590a3ba76372f7513e6a253 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 19 Nov 2022 20:04:01 +0100 Subject: [PATCH 125/592] Implemented paper message handling --- nomadnet/Conversation.py | 29 ++++++ nomadnet/NomadNetworkApp.py | 35 ++++++- nomadnet/ui/TextUI.py | 4 +- nomadnet/ui/textui/Conversations.py | 145 +++++++++++++++++++++++++++- 4 files changed, 207 insertions(+), 6 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index d36f2eb..35b8cf5 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -227,6 +227,35 @@ class Conversation: RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) return False + def paper_output(self, content="", title=""): + if self.send_destination: + try: + dest = self.send_destination + source = self.app.lxmf_destination + desired_method = LXMF.LXMessage.PAPER + + lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method) + qr_code = lxm.as_qr() + qr_tmp_path = self.app.tmpfilespath+"/"+str(RNS.hexrep(lxm.hash, delimit=False)) + qr_code.save(qr_tmp_path) + + print_result = self.app.print_file(qr_tmp_path) + os.unlink(qr_tmp_path) + + if print_result: + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + + return print_result + + except Exception as e: + RNS.log("An error occurred while generating paper message, the contained exception was: "+str(e), RNS.LOG_ERROR) + return False + + else: + RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) + return False + def message_notification(self, message): if message.state == LXMF.LXMessage.FAILED and hasattr(message, "try_propagation_on_fail") and message.try_propagation_on_fail: RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index d0ac8a5..2dea9ad 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -105,6 +105,7 @@ class NomadNetworkApp: self.conversationpath = self.configdir+"/storage/conversations" self.directorypath = self.configdir+"/storage/directory" self.peersettingspath = self.configdir+"/storage/peersettings" + self.tmpfilespath = self.configdir+"/storage/tmp" self.pagespath = self.configdir+"/storage/pages" self.filespath = self.configdir+"/storage/files" @@ -145,6 +146,11 @@ class NomadNetworkApp: if not os.path.isdir(self.cachepath): os.makedirs(self.cachepath) + if not os.path.isdir(self.tmpfilespath): + os.makedirs(self.tmpfilespath) + else: + self.clear_tmp_dir() + if os.path.isfile(self.configpath): try: self.config = ConfigObj(self.configpath) @@ -514,6 +520,26 @@ class NomadNetworkApp: return False + def print_file(self, filename): + print_command = self.print_command+" "+filename + + try: + return_code = subprocess.call(shlex.split(print_command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + except Exception as e: + RNS.log("An error occurred while executing print command: "+str(print_command), RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + return False + + if return_code == 0: + RNS.log("Successfully printed "+str(filename)+" using print command: "+print_command, RNS.LOG_DEBUG) + return True + + else: + RNS.log("Printing "+str(filename)+" failed using print command: "+print_command, RNS.LOG_DEBUG) + return False + + def print_message(self, message, received = None): try: template = self.printing_template_msg @@ -547,8 +573,7 @@ class NomadNetworkApp: f.write(output.encode("utf-8")) f.close() - print_command = "lp -d thermal -o cpi=16 -o lpi=8 "+filename - return_code = subprocess.call(shlex.split(print_command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.print_file(filename) os.unlink(filename) @@ -576,6 +601,12 @@ class NomadNetworkApp: if os.path.isfile(self.conversationpath + "/" + source_hash + "/unread"): os.unlink(self.conversationpath + "/" + source_hash + "/unread") + def clear_tmp_dir(self): + if os.path.isdir(self.tmpfilespath): + for file in os.listdir(self.tmpfilespath): + fpath = self.tmpfilespath+"/"+file + os.unlink(fpath) + def createDefaultConfig(self): self.config = ConfigObj(__default_nomadnet_config__) self.config.filename = self.configpath diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index ae3e17d..6389aac 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -121,7 +121,9 @@ GLYPHS = { ("decoration_menu", " +", " +", " \uf93a"), ("unread_menu", " !", " \u2709", urm_char), ("globe", "", "", "\uf484"), - ("sent", "/\\", "\u2191", "\ufbf4") + ("sent", "/\\", "\u2191", "\ufbf4"), + ("papermsg", "P", "\u25a4", "\uf719"), + ("qrcode", "QR", "\u25a4", "\uf029"), } class TextUI: diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index cac9b89..8de7065 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -14,13 +14,13 @@ class ConversationListDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-g] Fullscreen"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URL [C-g] Fullscreen"), "shortcutbar") class ConversationDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-k] Clear [C-w] Close [C-t] Title [C-p] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-k] Clear [C-w] Close [C-u] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") class ConversationsArea(urwid.LineBox): def keypress(self, size, key): @@ -30,6 +30,8 @@ class ConversationsArea(urwid.LineBox): self.delegate.delete_selected_conversation() elif key == "ctrl n": self.delegate.new_conversation() + elif key == "ctrl u": + self.delegate.ingest_lxm_url() elif key == "ctrl r": self.delegate.sync_conversations() elif key == "ctrl g": @@ -330,6 +332,110 @@ class ConversationsDisplay(): options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) + def ingest_lxm_url(self): + self.dialog_open = True + lxm_url = "" + e_url = urwid.Edit(caption="URL : ",edit_text=lxm_url) + + def dismiss_dialog(sender): + self.update_conversation_list() + self.dialog_open = False + + def confirmed(sender): + try: + local_delivery_signal = "local_delivery_occurred" + duplicate_signal = "duplicate_lxm" + lxm_url = e_url.get_edit_text() + + ingest_result = self.app.message_router.ingest_lxm_url( + lxm_url, + signal_local_delivery=local_delivery_signal, + signal_duplicate=duplicate_signal + ) + + if ingest_result == False: + raise ValueError("The URL contained no decodable messages") + + elif ingest_result == local_delivery_signal: + rdialog_pile = urwid.Pile([ + urwid.Text("Message was decoded, decrypted successfully, and added to your conversation list."), + urwid.Text(""), + urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URL") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay(rdialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + elif ingest_result == duplicate_signal: + rdialog_pile = urwid.Pile([ + urwid.Text("The decoded message has already been processed by the LXMF Router, and will not be ingested again."), + urwid.Text(""), + urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URL") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay(rdialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + else: + if self.app.enable_node: + propagation_text = "The decoded message was not addressed to this LXMF address, but has been added to the propagation node queues, and will be distributed on the propagation network." + else: + propagation_text = "The decoded message was not addressed to this LXMF address, and has been discarded." + + rdialog_pile = urwid.Pile([ + urwid.Text(propagation_text), + urwid.Text(""), + urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URL") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay(rdialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + except Exception as e: + RNS.log("Could not ingest LXM URL. The contained exception was: "+str(e), RNS.LOG_VERBOSE) + if not dialog_pile.error_display: + dialog_pile.error_display = True + options = dialog_pile.options(height_type="pack") + dialog_pile.contents.append((urwid.Text(""), options)) + dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URL data. Check your input."), align="center"), options)) + + dialog_pile = urwid.Pile([ + e_url, + urwid.Text(""), + urwid.Columns([("weight", 0.45, urwid.Button("Ingest", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))]) + ]) + dialog_pile.error_display = False + + dialog = DialogLineBox(dialog_pile, title="Ingest message URL") + dialog.delegate = self + bottom = self.listbox + + overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + + options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + def delete_conversation(self, source_hash): if source_hash in ConversationsDisplay.cached_conversation_widgets: conversation = ConversationsDisplay.cached_conversation_widgets[source_hash] @@ -636,6 +742,8 @@ class MessageEdit(urwid.Edit): def keypress(self, size, key): if key == "ctrl d": self.delegate.send_message() + elif key == "ctrl p": + self.delegate.paper_message() elif key == "ctrl k": self.delegate.clear_editor() elif key == "up": @@ -800,7 +908,7 @@ class ConversationWidget(urwid.WidgetWrap): self.toggle_focus_area() elif key == "ctrl w": self.close() - elif key == "ctrl p": + elif key == "ctrl u": self.conversation.purge_failed() self.conversation_changed(None) elif key == "ctrl t": @@ -860,6 +968,34 @@ class ConversationWidget(urwid.WidgetWrap): else: pass + def paper_message(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + if self.conversation.paper_output(content, title): + self.clear_editor() + else: + self.paper_message_failed() + + def paper_message_failed(self): + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("Could not output paper message,\ncheck your settings. See the log\nfile for any error messages.\n", align="center"), + urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + ]), title="!" + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align="center", width=34, valign="middle", height="pack", left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.set_focus("body") + def close(self): self.delegate.close_conversation(self) @@ -890,6 +1026,9 @@ class LXMessageWidget(urwid.WidgetWrap): elif message.lxm.method == LXMF.LXMessage.PROPAGATED and message.lxm.state == LXMF.LXMessage.SENT: header_style = "msg_header_propagated" title_string = g["sent"]+" "+title_string + elif message.lxm.method == LXMF.LXMessage.PAPER and message.lxm.state == LXMF.LXMessage.PAPER: + header_style = "msg_header_propagated" + title_string = g["papermsg"]+" "+title_string elif message.lxm.state == LXMF.LXMessage.SENT: header_style = "msg_header_sent" title_string = g["sent"]+" "+title_string From b08ae0cf0273eb2ddc8cdcd98fd67013be935497 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 19 Nov 2022 20:05:31 +0100 Subject: [PATCH 126/592] Updated dependencies --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ff802b6..ddedba4 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.4.1', 'lxmf>=0.2.4', 'urwid>=2.1.2'], - python_requires='>=3.6', + install_requires=["rns>=0.4.1", "lxmf>=0.2.5", "urwid>=2.1.2", "qrcode"], + python_requires=">=3.6", ) From 9244f00e21c4071a7e20fc8fcf7b754778f09403 Mon Sep 17 00:00:00 2001 From: khimaros Date: Mon, 21 Nov 2022 11:10:13 -0800 Subject: [PATCH 127/592] spelling fix --- nomadnet/ui/textui/Network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index ebc9e2a..81f725f 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -894,7 +894,7 @@ class NodeActiveConnections(urwid.WidgetWrap): if self.app.node != None: self.stat_string = str(len(self.app.node.destination.links)) - self.display_widget.set_text("Conneced Now : "+self.stat_string) + self.display_widget.set_text("Connected Now : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() From dfd87a2119437cceed9efdd7ff18681a457d7c33 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 22 Nov 2022 19:55:17 +0100 Subject: [PATCH 128/592] Updated paper message UI labels --- nomadnet/ui/textui/Conversations.py | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 8de7065..9aa1978 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -14,7 +14,7 @@ class ConversationListDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URL [C-g] Fullscreen"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-g] Fullscreen"), "shortcutbar") class ConversationDisplayShortcuts(): def __init__(self, app): @@ -31,7 +31,7 @@ class ConversationsArea(urwid.LineBox): elif key == "ctrl n": self.delegate.new_conversation() elif key == "ctrl u": - self.delegate.ingest_lxm_url() + self.delegate.ingest_lxm_uri() elif key == "ctrl r": self.delegate.sync_conversations() elif key == "ctrl g": @@ -332,10 +332,10 @@ class ConversationsDisplay(): options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) - def ingest_lxm_url(self): + def ingest_lxm_uri(self): self.dialog_open = True - lxm_url = "" - e_url = urwid.Edit(caption="URL : ",edit_text=lxm_url) + lxm_uri = "" + e_uri = urwid.Edit(caption="URI : ",edit_text=lxm_uri) def dismiss_dialog(sender): self.update_conversation_list() @@ -345,16 +345,16 @@ class ConversationsDisplay(): try: local_delivery_signal = "local_delivery_occurred" duplicate_signal = "duplicate_lxm" - lxm_url = e_url.get_edit_text() + lxm_uri = e_uri.get_edit_text() - ingest_result = self.app.message_router.ingest_lxm_url( - lxm_url, + ingest_result = self.app.message_router.ingest_lxm_uri( + lxm_uri, signal_local_delivery=local_delivery_signal, signal_duplicate=duplicate_signal ) if ingest_result == False: - raise ValueError("The URL contained no decodable messages") + raise ValueError("The URI contained no decodable messages") elif ingest_result == local_delivery_signal: rdialog_pile = urwid.Pile([ @@ -364,7 +364,7 @@ class ConversationsDisplay(): ]) rdialog_pile.error_display = False - rdialog = DialogLineBox(rdialog_pile, title="Ingest message URL") + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") rdialog.delegate = self bottom = self.listbox @@ -381,7 +381,7 @@ class ConversationsDisplay(): ]) rdialog_pile.error_display = False - rdialog = DialogLineBox(rdialog_pile, title="Ingest message URL") + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") rdialog.delegate = self bottom = self.listbox @@ -403,7 +403,7 @@ class ConversationsDisplay(): ]) rdialog_pile.error_display = False - rdialog = DialogLineBox(rdialog_pile, title="Ingest message URL") + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") rdialog.delegate = self bottom = self.listbox @@ -413,21 +413,21 @@ class ConversationsDisplay(): self.columns_widget.contents[0] = (roverlay, options) except Exception as e: - RNS.log("Could not ingest LXM URL. The contained exception was: "+str(e), RNS.LOG_VERBOSE) + RNS.log("Could not ingest LXM URI. The contained exception was: "+str(e), RNS.LOG_VERBOSE) if not dialog_pile.error_display: dialog_pile.error_display = True options = dialog_pile.options(height_type="pack") dialog_pile.contents.append((urwid.Text(""), options)) - dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URL data. Check your input."), align="center"), options)) + dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URI data. Check your input."), align="center"), options)) dialog_pile = urwid.Pile([ - e_url, + e_uri, urwid.Text(""), urwid.Columns([("weight", 0.45, urwid.Button("Ingest", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))]) ]) dialog_pile.error_display = False - dialog = DialogLineBox(dialog_pile, title="Ingest message URL") + dialog = DialogLineBox(dialog_pile, title="Ingest message URI") dialog.delegate = self bottom = self.listbox From 22f18324fcdd9ca71d7ed3eef01d87c5aa5e056f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 22 Nov 2022 19:56:25 +0100 Subject: [PATCH 129/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 6cd38b7..c49a95c 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.7" +__version__ = "0.2.8" From 73fffe519a7c725606abd0334d7ad6094898db2b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 22 Nov 2022 20:03:21 +0100 Subject: [PATCH 130/592] Updated readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 76c6a08..e8f8b25 100755 --- a/README.md +++ b/README.md @@ -120,15 +120,15 @@ You can help support the continued development of open, free and private communi - Collaborative maps and geospatial information sharing - Facilitation of trade and barter - Minor improvements and fixes + - Link status (RSSI and SNR) in conversation or conv list + - Ctrl-M shorcut for jumping to menu + - Share node with other users / send node info to user - Fix internal editor failing on some OSes with no "editor" alias - Possibly add a required-width header - Improve browser handling of remote link close - Better navigation handling when requests fail (also because of closed links) - - Ctrl-M shorcut for jumping to menu - Retry failed messages mechanism - Re-arrange buttons to be more consistent - - Link status (RSSI and SNR) in conversation or conv list - - Share node with other users / send node info to user - Input field for pages - Post mechanism - Term compatibility notice in readme From 29feb1cab1e513185501163a4951fe26f43b3031 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 24 Nov 2022 19:17:46 +0100 Subject: [PATCH 131/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ddedba4..a2a44a8 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.1", "lxmf>=0.2.5", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.4.2", "lxmf>=0.2.6", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From 52dfebcfa2ea9a49b519a8d581ffbbde39a84220 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 29 Nov 2022 15:54:45 +0100 Subject: [PATCH 132/592] Updated guide text --- nomadnet/ui/textui/Guide.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index ae0ffb4..8b178c9 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -670,19 +670,19 @@ For future reference, you can download the Reticulum Manual in PDF format here: It might be nice to keep that handy when you are not connected to the Internet, as it is full of information and examples that are also very relevant to Nomad Network. ->The Unsigned.io Testnet +>The Reticulum Testnet If you have Internet access, and just want to get started experimenting, you are welcome to join the Unsigned.io RNS Testnet. The testnet is just that, an informal network for testing and experimenting. It will be up most of the time, and anyone can join, but it also means that there's no guarantees for service availability. The Testnet also runs the latest version of Reticulum, often even a short while before it is publicly released, which means strange behaviour might occur. If none of that scares you, add the following interface to your Reticulum configuration file to join: >> -[[RNS Testnet Frankfurt]] +[[RNS Testnet Zurich]] type = TCPClientInterface interface_enabled = yes outgoing = True - target_host = frankfurt.rns.unsigned.io - target_port = 4965 + target_host = zurich.connect.reticulum.network + target_port = 4242 < If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: From 0ebdb9c8265e72447d0c6777e5f78c12f931c913 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 20 Dec 2022 00:38:46 +0100 Subject: [PATCH 133/592] Added automatic deregistering from peered LXMF propagation nodes when disabling node hosting --- nomadnet/NomadNetworkApp.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 2dea9ad..1211e32 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -98,6 +98,7 @@ class NomadNetworkApp: self.ignoredpath = self.configdir+"/ignored" self.logfilepath = self.configdir+"/logfile" self.errorfilepath = self.configdir+"/errors" + self.pnannouncedpath = self.configdir+"/pnannounced" self.storagepath = self.configdir+"/storage" self.identitypath = self.configdir+"/storage/identity" self.cachepath = self.configdir+"/storage/cache" @@ -296,11 +297,24 @@ class NomadNetworkApp: RNS.log("Cannot prioritise "+str(dest_str)+", it is not a valid destination hash", RNS.LOG_ERROR) self.message_router.enable_propagation() + try: + with open(self.pnannouncedpath, "wb") as pnf: + pnf.write(msgpack.packb(time.time())) + pnf.close() + except Exception as e: + RNS.log("An error ocurred while writing Propagation Node announce timestamp. The contained exception was: "+str(e), RNS.LOG_ERROR) RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) self.node = nomadnet.Node(self) else: self.node = None + if os.path.isfile(self.pnannouncedpath): + try: + RNS.log("Sending indication to peered LXMF Propagation Node that this node is no longer participating", RNS.LOG_DEBUG) + self.message_router.disable_propagation() + os.unlink(self.pnannouncedpath) + except Exception as e: + RNS.log("An error ocurred while indicating that this LXMF Propagation Node is no longer participating. The contained exception was: "+str(e), RNS.LOG_ERROR) RNS.Transport.register_announce_handler(nomadnet.Conversation) RNS.Transport.register_announce_handler(nomadnet.Directory) From 0a5663b985d1ec990c2965d036c1bacad9ee4de1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 20 Dec 2022 21:27:26 +0100 Subject: [PATCH 134/592] Updated RNS dependency version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a2a44a8..2389030 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.2", "lxmf>=0.2.6", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.4.3", "lxmf>=0.2.6", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From 1913dff3fde4ba2a8e0a68006c6e25ee4c4a6315 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 20 Dec 2022 21:27:45 +0100 Subject: [PATCH 135/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index c49a95c..75cf783 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.8" +__version__ = "0.2.9" From 5bcb4a3cec17bf2a35426c5e6e1a9c6ffd7fb672 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 21 Dec 2022 00:04:25 +0100 Subject: [PATCH 136/592] Updated LXMF dependency version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2389030..476ad03 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.3", "lxmf>=0.2.6", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.4.3", "lxmf>=0.2.7", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From bd631f6f5fd9c94afd3a9616201126177bc095f8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 21 Dec 2022 00:04:45 +0100 Subject: [PATCH 137/592] Updated LXMF propagation node list display --- nomadnet/ui/textui/Network.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 81f725f..9d51928 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1604,7 +1604,14 @@ class LXMFPeerEntry(urwid.WidgetWrap): style = "list_unknown" focus_style = "list_focus" - widget = ListEntry(sym+" "+display_str+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs - "+"Last heard "+pretty_date(int(peer.last_heard))) + alive_string = "Unknown" + if hasattr(peer, "alive"): + if peer.alive: + alive_string = "Available" + else: + alive_string = "Unresponsive" + + widget = ListEntry(sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs") # urwid.connect_signal(widget, "click", delegate.connect_node, node) self.display_widget = urwid.AttrMap(widget, style, focus_style) From 84ea13acbdd48f09abb83e37a3eb2fe25bb0fede Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 21 Dec 2022 00:08:24 +0100 Subject: [PATCH 138/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 75cf783..493f741 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.2.9" +__version__ = "0.3.0" From ed3596b89a1780118568c0492808bbcdc6d71f98 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 23 Dec 2022 23:54:23 +0100 Subject: [PATCH 139/592] Updated dependencies and version --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 493f741..260c070 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/setup.py b/setup.py index 476ad03..0cd3426 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.3", "lxmf>=0.2.7", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.4.6", "lxmf>=0.2.8", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From 9e9dd2a481c7b9e8da7253a2e103e13ff1bbc5d1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 14 Jan 2023 21:15:21 +0100 Subject: [PATCH 140/592] Updated dependencies and version --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 260c070..f9aa3e1 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/setup.py b/setup.py index 0cd3426..880b2a1 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.6", "lxmf>=0.2.8", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.4.7", "lxmf>=0.2.9", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From fea602bbcb1ff1e35d9d167f70015c946f8f509b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 1 Feb 2023 11:52:56 +0100 Subject: [PATCH 141/592] Dialog on missing announce key --- nomadnet/ui/textui/Network.py | 36 +++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 9d51928..5225faa 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -239,7 +239,7 @@ class AnnounceInfo(urwid.WidgetWrap): class AnnounceStreamEntry(urwid.WidgetWrap): - def __init__(self, app, announce): + def __init__(self, app, announce, delegate): full_time_format = "%Y-%m-%d %H:%M:%S" date_time_format = "%Y-%m-%d" time_time_format = "%H:%M:%S" @@ -250,6 +250,7 @@ class AnnounceStreamEntry(urwid.WidgetWrap): source_hash = announce[1] announce_type = announce[3] self.app = app + self.delegate = delegate self.timestamp = timestamp time_format = app.time_format dt = datetime.fromtimestamp(self.timestamp) @@ -299,10 +300,37 @@ class AnnounceStreamEntry(urwid.WidgetWrap): urwid.WidgetWrap.__init__(self, self.display_widget) def display_announce(self, event, announce): + try: parent = self.app.ui.main_display.sub_displays.network_display info_widget = AnnounceInfo(announce, parent, self.app) options = parent.left_pile.options(height_type="weight", height_amount=1) parent.left_pile.contents[0] = (info_widget, options) + + except KeyError as e: + def dismiss_dialog(sender): + self.delegate.parent.close_list_dialogs() + + def confirmed(sender): + self.delegate.parent.close_list_dialogs() + + dialog = ListDialogLineBox( + urwid.Pile([ + urwid.Text("The keys for the announced destination could not be recalled. You can wait for an announce to arrive, or request the keys from the network.\n", align="center"), + urwid.Columns([ + ("weight", 0.45, urwid.Button("Request keys", on_press=confirmed)), + ("weight", 0.1, urwid.Text("")), + ("weight", 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), + ]) + ]), + title="Keys Unknown" + ) + dialog.delegate = self.delegate.parent + bottom = self.delegate + + overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + + options = self.delegate.parent.left_pile.options("weight", 1) + self.delegate.parent.left_pile.contents[0] = (overlay, options) def timestamp(self): return self.timestamp @@ -358,7 +386,7 @@ class AnnounceStream(urwid.WidgetWrap): new_entries.insert(0, e) for e in new_entries: - nw = AnnounceStreamEntry(self.app, e) + nw = AnnounceStreamEntry(self.app, e, self) nw.timestamp = e[0] self.widget_list.insert(0, nw) @@ -1498,10 +1526,6 @@ class NetworkDisplay(): self.local_peer_display.start() self.node_info_display.start() self.network_stats_display.start() - # There seems to be an intermittent memory leak somewhere - # in the periodic updating here. The periodic updater should - # not be needed anymore, so dis - #self.announce_stream_display.start() def shortcuts(self): return self.shortcuts_display From 7cb53fe170299cdd5345617f548b489f0f9c0496 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 1 Feb 2023 12:23:45 +0100 Subject: [PATCH 142/592] Request keys option for unknown announce edge case --- nomadnet/ui/textui/Network.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 5225faa..7a8081b 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -311,19 +311,28 @@ class AnnounceStreamEntry(urwid.WidgetWrap): self.delegate.parent.close_list_dialogs() def confirmed(sender): - self.delegate.parent.close_list_dialogs() + def close_req(sender): + self.delegate.parent.close_list_dialogs() + + dialog_pile.contents[0] = (urwid.Text("\nKeys requested from network\n", align="center"), options) + RNS.Transport.request_path(announce[1]) + + confirmed_button = urwid.Button("Request keys", on_press=confirmed) + + dialog_pile = urwid.Pile([ + urwid.Text("The keys for the announced destination could not be recalled. You can wait for an announce to arrive, or request the keys from the network.\n", align="center"), + urwid.Columns([ + ("weight", 0.45, confirmed_button), + ("weight", 0.1, urwid.Text("")), + ("weight", 0.45, urwid.Button("Close", on_press=dismiss_dialog)), + ]) + ]) dialog = ListDialogLineBox( - urwid.Pile([ - urwid.Text("The keys for the announced destination could not be recalled. You can wait for an announce to arrive, or request the keys from the network.\n", align="center"), - urwid.Columns([ - ("weight", 0.45, urwid.Button("Request keys", on_press=confirmed)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), - ]) - ]), + dialog_pile, title="Keys Unknown" ) + confirmed_button.dialog_pile = dialog_pile dialog.delegate = self.delegate.parent bottom = self.delegate From c1cbc564595d1b4d1181bfa205f856809259ab58 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 1 Feb 2023 14:23:04 +0100 Subject: [PATCH 143/592] Kill log viewer process before exiting urwid main loop --- nomadnet/ui/textui/Main.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index deca22a..45584d5 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -140,6 +140,15 @@ class MainDisplay(): self.menu_display.start() def quit(self, sender=None): + logterm_pid = None + if True or RNS.vendor.platformutils.is_android(): + if self.sub_displays.log_display != None and self.sub_displays.log_display.log_term != None: + if self.sub_displays.log_display.log_term.log_term != None: + logterm_pid = self.sub_displays.log_display.log_term.log_term.pid + if logterm_pid != None: + import os, signal + os.kill(logterm_pid, signal.SIGKILL) + raise urwid.ExitMainLoop From a8d337a94ef393a34d6bf2cabe62824423bdeea4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 1 Feb 2023 15:39:45 +0100 Subject: [PATCH 144/592] Fixed propagation node autoselection not working when None value was set in settings dict --- nomadnet/NomadNetworkApp.py | 11 ++++------- nomadnet/ui/textui/Network.py | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 1211e32..701869b 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -456,9 +456,8 @@ class NomadNetworkApp: def autoselect_propagation_node(self): selected_node = None - if "propagation_node" in self.peer_settings: + if "propagation_node" in self.peer_settings and self.peer_settings["propagation_node"] != None: selected_node = self.peer_settings["propagation_node"] - else: nodes = self.directory.known_nodes() trusted_nodes = [] @@ -706,7 +705,7 @@ class NomadNetworkApp: self.config["textui"]["intro_text"] = "Nomad Network" if not "editor" in self.config["textui"]: - self.config["textui"]["editor"] = "editor" + self.config["textui"]["editor"] = "nano" if not "glyphs" in self.config["textui"]: self.config["textui"]["glyphs"] = "unicode" @@ -958,10 +957,8 @@ glyphs = unicode # application. On by default. mouse_enabled = True -# What editor to use for editing text. By -# default the operating systems "editor" -# alias will be used. -editor = editor +# What editor to use for editing text. +editor = nano # If you don't want the Guide section to # show up in the menu, you can disable it. diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 7a8081b..633fb2d 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -704,7 +704,7 @@ class KnownNodes(urwid.WidgetWrap): else: self.no_content = True widget_style = "inactive_text" - self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no nodes are known\n\n"), align="center")]) + self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no nodes are saved\n\nCtrl+L to view the announce stream\n\n"), align="center")]) self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style)) From dc64ab26d42f0f9c79006f0ac403555b390747f6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 11:49:36 +0100 Subject: [PATCH 145/592] Fixed browser cursor position on scrolling --- nomadnet/ui/textui/Browser.py | 36 +++++++++++++++++++++++----- nomadnet/vendor/Scrollable.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index bdecffe..0fbd57c 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -26,13 +26,32 @@ class BrowserFrame(urwid.Frame): elif key == "ctrl g": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen() elif self.get_focus() == "body": + if key == "down" or key == "up": + try: + if hasattr(self.delegate, "page_pile") and self.delegate.page_pile: + def df(loop, user_data): + st = None + nf = self.delegate.page_pile.get_focus() + if hasattr(nf, "key_timeout"): + st = nf + elif hasattr(nf, "original_widget"): + no = nf.original_widget + if hasattr(no, "original_widget"): + st = no.original_widget + else: + if hasattr(no, "key_timeout"): + st = no + + if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): + st.keypress(None, None) + + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.25, df) + + except Exception as e: + RNS.log("Error while setting up cursor timeout. The contained exception was: "+str(e), RNS.LOG_ERROR) + return super(BrowserFrame, self).keypress(size, key) - # if key == "up" and self.delegate.messagelist.top_is_visible: - # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") - # elif key == "down" and self.delegate.messagelist.bottom_is_visible: - # self.set_focus("footer") - # else: - # return super(ConversationFrame, self).keypress(size, key) + else: return super(BrowserFrame, self).keypress(size, key) @@ -80,6 +99,7 @@ class Browser: self.link_target = None self.frame = None self.attr_maps = [] + self.page_pile = None self.build_display() self.history = [] @@ -219,6 +239,7 @@ class Browser: self.browser_header = urwid.Text("") self.browser_footer = urwid.Text("") + self.page_pile = None self.browser_body = urwid.Filler(urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align="center"), "middle") self.frame = BrowserFrame(self.browser_body, header=self.browser_header, footer=self.browser_footer) @@ -266,6 +287,7 @@ class Browser: def update_display(self): if self.status == Browser.DISCONECTED: self.display_widget.set_attr_map({None: "inactive_text"}) + self.page_pile = None self.browser_body = urwid.Filler(urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align="center"), "middle") self.browser_footer = urwid.Text("") self.browser_header = urwid.Text("") @@ -314,6 +336,8 @@ class Browser: def update_page_display(self): pile = urwid.Pile(self.attr_maps) + pile.automove_cursor_on_scroll = True + self.page_pile = pile self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") def identify(self): diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index ee28a46..d4eb078 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -107,6 +107,51 @@ class Scrollable(urwid.WidgetDecoration): if canv_full.cursor is not None: # Full canvas contains the cursor, but scrolled out of view self._forward_keypress = False + + # Reset cursor position on page/up down scrolling + try: + if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll: + pwi = 0 + ch = 0 + last_hidden = False + first_visible = False + for w,o in ow.contents: + wcanv = w.render((maxcol,)) + wh = wcanv.rows() + if wh: + ch += wh + + if not last_hidden and ch >= self._trim_top: + last_hidden = True + + elif last_hidden: + if not first_visible: + first_visible = True + + if w.selectable(): + ow.focus_item = pwi + + st = None + nf = ow.get_focus() + if hasattr(nf, "key_timeout"): + st = nf + elif hasattr(nf, "original_widget"): + no = nf.original_widget + if hasattr(no, "original_widget"): + st = no.original_widget + else: + if hasattr(no, "key_timeout"): + st = no + + if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): + st.keypress(None, None) + + break + + pwi += 1 + except Exception as e: + pass + else: # Original widget does not have a cursor, but may be selectable From 78106b3f8cfe2f48673e7ae4afa14d9d578ee1a7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 11:50:13 +0100 Subject: [PATCH 146/592] Fixed invalid indent --- nomadnet/vendor/Scrollable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index d4eb078..eb0d2fa 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -149,8 +149,8 @@ class Scrollable(urwid.WidgetDecoration): break pwi += 1 - except Exception as e: - pass + except Exception as e: + pass else: # Original widget does not have a cursor, but may be selectable From 71ff0f9bcdf157029fe4e572870ea90056b7f5ff Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 12:03:46 +0100 Subject: [PATCH 147/592] Added feedback on opening link --- nomadnet/ui/textui/Browser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 0fbd57c..8b75fbd 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -51,7 +51,7 @@ class BrowserFrame(urwid.Frame): RNS.log("Error while setting up cursor timeout. The contained exception was: "+str(e), RNS.LOG_ERROR) return super(BrowserFrame, self).keypress(size, key) - + else: return super(BrowserFrame, self).keypress(size, key) @@ -175,6 +175,7 @@ class Browser: if destination_type == "nomadnetwork.node": if self.status >= Browser.DISCONECTED: RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG) + self.browser_footer = urwid.Text("Opening link to: "+str(link_target)) try: self.retrieve_url(link_target) except Exception as e: From e3074830d64dd27d82db1e3279fd3c01d141c15e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 12:04:34 +0100 Subject: [PATCH 148/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 880b2a1..fadae5d 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.7", "lxmf>=0.2.9", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.4.8", "lxmf>=0.3.0", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From 365a027f9b04b8fdba30f036aee6663c1587cdef Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 12:05:06 +0100 Subject: [PATCH 149/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index f9aa3e1..e19434e 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.2" +__version__ = "0.3.3" From 16ca15c82cd2df0f758e14ba9fb93b7b40d3efa9 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 13:16:00 +0100 Subject: [PATCH 150/592] Updated guide --- nomadnet/ui/textui/Guide.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 8b178c9..c30bfee 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -312,14 +312,22 @@ The distributed message store is resilient to intermittency, and will remain fun >>Pages -Nomad Network nodes can host pages similar to web pages, that other peers can read and interact with. Pages are written in a compact markup language called `*micron`*. To learn how to write formatted pages with micron, see the `*Markup`* section of this guide (which is, itself, written in micron). Pages can be linked arbitrarily with hyperlinks, that can also link to pages (or other resources) on other nodes. +Nomad Network nodes can host pages similar to web pages, that other peers can read and interact with. Pages are written in a compact markup language called `*micron`*. To learn how to write formatted pages with micron, see the `*Markup`* section of this guide (which is, itself, written in micron). Pages can be linked together with hyperlinks, that can also link to pages (or other resources) on other nodes. To add pages to your node, place micron files in the `*pages`* directory of your Nomad Network programs `*storage`* directory. By default, the path to this will be `!~/.nomadnetwork/storage/pages`!. You should probably create the file `!index.mu`! first, as this is the page that will get served by default to a connecting peer. You can control how long a peer will cache your pages by including the cache header in a page. To do so, the first line of your page must start with `!#!c=X`!, where `!X`! is the cache time in seconds. To tell the peer to always load the page from your node, and never cache it, set the cache time to zero. You should only do this if there is a real need, for example if your page displays dynamic content that `*must`* be updated at every page view. The default caching time is 12 hours. In most cases, you should not need to include the cache control header in your pages. +>> Dynamic Pages + You can use a preprocessor such as PHP, bash, Python (or whatever you prefer) to generate dynamic pages. To do so, just set executable permissions on the relevant page file, and be sure to include the interpreter at the beginning of the file, for example `!#!/usr/bin/python3`!. +In the `!examples`! directory, you can find various small examples for the use of this feature. The currently included examples are: + + - A messageboard that receives messages over LXMF, contributed by trippcheng + +By default, you can find the examples in `!~/.nomadnetwork/examples`!. If you build something neat, that you feel would fit here, you are more than welcome to contribute it. + >>Authenticating Users Sometimes, you don't want everyone to be able to view certain pages or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. @@ -330,9 +338,9 @@ For each user allowed to access the page, add a line to this file, containing th `Faaa `= -24c2dc2248953e0a3c21 -564a476410f4fca2ae93 -8bb864743f12f53395a1 +d454bcdac0e64fb68ba8e267543ae110 +2b9ff3fb5902c9ca5ff97bdfb239ef50 +7106d5abbc7208bfb171f2dd84b36490 `= `` @@ -348,7 +356,7 @@ Like pages, you can place files you want to make available in the `!~/.nomadnetw Links to pages and resources in Nomad Network use a simple URL format. Here is an example: -`!1385edace36466a6b3dd:/page/index.mu`! +`!18176ffddcc8cce1ddf8e3f72068f4a6:/page/index.mu`! The first part is the 10 byte destination address of the node (represented as readable hexadecimal), followed by the `!:`! character. Everything after the `!:`! represents the request path. From 8a1cb25403e21ec288972e69dd33b2195df60761 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 13:16:27 +0100 Subject: [PATCH 151/592] Ensure PN autoselection on node save if trusted --- nomadnet/ui/textui/Network.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 633fb2d..b794cd2 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -610,6 +610,10 @@ class KnownNodeInfo(urwid.WidgetWrap): node_entry = DirectoryEntry(source_hash, display_name=display_str, trust_level=trust_level, hosts_node=True, identify_on_connect=connect_identify_checkbox.get_state()) self.app.directory.remember(node_entry) self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + if trust_level == DirectoryEntry.TRUSTED: + self.app.autoselect_propagation_node() + show_known_nodes(None) back_button = ("weight", 0.2, urwid.Button("Back", on_press=show_known_nodes)) From f37ce1482242f785104462bfaff128304264f798 Mon Sep 17 00:00:00 2001 From: chengtripp <124098378+chengtripp@users.noreply.github.com> Date: Sat, 4 Feb 2023 14:16:06 +0000 Subject: [PATCH 152/592] Create messageboard.py --- nomadnet/apps/messageboard/messageboard.py | 187 +++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 nomadnet/apps/messageboard/messageboard.py diff --git a/nomadnet/apps/messageboard/messageboard.py b/nomadnet/apps/messageboard/messageboard.py new file mode 100644 index 0000000..8a71cd1 --- /dev/null +++ b/nomadnet/apps/messageboard/messageboard.py @@ -0,0 +1,187 @@ +# Simple message board that can be hosted on a NomadNet node, messages can be posted by 'conversing' with a unique peer, all messages are then forwarded to the message board. +# https://github.com/chengtripp/lxmf_messageboard + +import RNS +import LXMF +import os, time +from queue import Queue +import RNS.vendor.umsgpack as msgpack + +display_name = "NomadNet Message Board" +max_messages = 20 + +def setup_lxmf(): + if os.path.isfile(identitypath): + identity = RNS.Identity.from_file(identitypath) + RNS.log('Loaded identity from file', RNS.LOG_INFO) + else: + RNS.log('No Primary Identity file found, creating new...', RNS.LOG_INFO) + identity = RNS.Identity() + identity.to_file(identitypath) + + return identity + +def lxmf_delivery(message): + # Do something here with a received message + RNS.log("A message was received: "+str(message.content.decode('utf-8'))) + + message_content = message.content.decode('utf-8') + source_hash_text = RNS.hexrep(message.source_hash, delimit=False) + + #Create username (just first 5 char of your addr) + username = source_hash_text[0:5] + + RNS.log('Username: {}'.format(username), RNS.LOG_INFO) + + time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) + new_message = '{} {}: {}\n'.format(time_string, username, message_content) + + # Push message to board + # First read message board (if it exists + if os.path.isfile(boardpath): + f = open(boardpath, "rb") + message_board = msgpack.unpack(f) + f.close() + else: + message_board = [] + + #Check we aren't doubling up (this can sometimes happen if there is an error initially and it then gets fixed) + if new_message not in message_board: + # Append our new message to the list + message_board.append(new_message) + + # Prune the message board if needed + while len(message_board) > max_messages: + RNS.log('Pruning Message Board') + message_board.pop(0) + + # Now open the board and write the updated list + f = open(boardpath, "wb") + msgpack.pack(message_board, f) + f.close() + + # Send reply + message_reply = '{}_{}_Your message has been added to the messageboard'.format(source_hash_text, time.time()) + q.put(message_reply) + +def announce_now(lxmf_destination): + lxmf_destination.announce() + +def send_message(destination_hash, message_content): + try: + # Make a binary destination hash from a hexadecimal string + destination_hash = bytes.fromhex(destination_hash) + + except Exception as e: + RNS.log("Invalid destination hash", RNS.LOG_ERROR) + return + + # Check that size is correct + if not len(destination_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + RNS.log("Invalid destination hash length", RNS.LOG_ERROR) + + else: + # Length of address was correct, let's try to recall the + # corresponding Identity + destination_identity = RNS.Identity.recall(destination_hash) + + if destination_identity == None: + # No path/identity known, we'll have to abort or request one + RNS.log("Could not recall an Identity for the requested address. You have probably never received an announce from it. Try requesting a path from the network first. In fact, let's do this now :)", RNS.LOG_ERROR) + RNS.Transport.request_path(destination_hash) + RNS.log("OK, a path was requested. If the network knows a path, you will receive an announce with the Identity data shortly.", RNS.LOG_INFO) + + else: + # We know the identity for the destination hash, let's + # reconstruct a destination object. + lxmf_destination = RNS.Destination(destination_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + + # Create a new message object + lxm = LXMF.LXMessage(lxmf_destination, local_lxmf_destination, message_content, title="Reply", desired_method=LXMF.LXMessage.DIRECT) + + # You can optionally tell LXMF to try to send the message + # as a propagated message if a direct link fails + lxm.try_propagation_on_fail = True + + # Send it + message_router.handle_outbound(lxm) + +def announce_check(): + if os.path.isfile(announcepath): + f = open(announcepath, "r") + announce = int(f.readline()) + f.close() + else: + RNS.log('failed to open announcepath', RNS.LOG_DEBUG) + announce = 1 + + if announce > int(time.time()): + RNS.log('Recent announcement', RNS.LOG_DEBUG) + else: + f = open(announcepath, "w") + next_announce = int(time.time()) + 1800 + f.write(str(next_announce)) + f.close() + announce_now(local_lxmf_destination) + RNS.log('Announcement sent, expr set 1800 seconds', RNS.LOG_INFO) + +#Setup Paths and Config Files +userdir = os.path.expanduser("~") + +if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"): + configdir = "/etc/nomadmb" +elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"): + configdir = userdir+"/.config/nomadmb" +else: + configdir = userdir+"/.nomadmb" + +storagepath = configdir+"/storage" +if not os.path.isdir(storagepath): + os.makedirs(storagepath) + +identitypath = configdir+"/storage/identity" +announcepath = configdir+"/storage/announce" +boardpath = configdir+"/storage/board" + +# Message Queue +q = Queue(maxsize = 5) + +# Start Reticulum and print out all the debug messages +reticulum = RNS.Reticulum(loglevel=RNS.LOG_VERBOSE) + +# Create a Identity. +current_identity = setup_lxmf() + +# Init the LXMF router +message_router = LXMF.LXMRouter(identity = current_identity, storagepath = configdir) + +# Register a delivery destination (for yourself) +# In this example we use the same Identity as we used +# to instantiate the LXMF router. It could be a different one, +# but it can also just be the same, depending on what you want. +local_lxmf_destination = message_router.register_delivery_identity(current_identity, display_name=display_name) + +# Set a callback for when a message is received +message_router.register_delivery_callback(lxmf_delivery) + +# Announce node properties + +RNS.log('LXMF Router ready to receive on: {}'.format(RNS.prettyhexrep(local_lxmf_destination.hash)), RNS.LOG_INFO) +announce_check() + +while True: + + # Work through internal message queue + for i in list(q.queue): + message_id = q.get() + split_message = message_id.split('_') + destination_hash = split_message[0] + message = split_message[2] + RNS.log('{} {}'.format(destination_hash, message), RNS.LOG_INFO) + send_message(destination_hash, message) + + # Check whether we need to make another announcement + announce_check() + + #Sleep + time.sleep(10) From 570b99d814b2a618de74e3310173063eb01320d3 Mon Sep 17 00:00:00 2001 From: chengtripp <124098378+chengtripp@users.noreply.github.com> Date: Sat, 4 Feb 2023 14:17:38 +0000 Subject: [PATCH 153/592] Create messageboard.mu --- nomadnet/apps/messageboard/messageboard.mu | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 nomadnet/apps/messageboard/messageboard.mu diff --git a/nomadnet/apps/messageboard/messageboard.mu b/nomadnet/apps/messageboard/messageboard.mu new file mode 100644 index 0000000..24993d0 --- /dev/null +++ b/nomadnet/apps/messageboard/messageboard.mu @@ -0,0 +1,41 @@ +#!/bin/python3 +import time +import os +import RNS.vendor.umsgpack as msgpack + +message_board_peer = 'please_replace' +userdir = os.path.expanduser("~") + +if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"): + configdir = "/etc/nomadmb" +elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"): + configdir = userdir+"/.config/nomadmb" +else: + configdir = userdir+"/.nomadmb" + +storagepath = configdir+"/storage" +if not os.path.isdir(storagepath): + os.makedirs(storagepath) + +boardpath = configdir+"/storage/board" + +print('`!`F222`Bddd`cNomadNet Message Board') + +print('-') +print('`a`b`f') +print("") +print("To add a message to the board just converse with the NomadNet Message Board at `[lxmf@{}]".format(message_board_peer)) +time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) +print("Last Updated: {}".format(time_string)) +print("") +print('>Messages') +print(" Date Time Username Message") +f = open(boardpath, "rb") +board_contents = msgpack.unpack(f) +board_contents.reverse() + +for content in board_contents: + print("`a{}".format(content.rstrip())) + print("") + +f.close() From 02a15f1503c1e9191c5d61f38674b09dcae2c4ec Mon Sep 17 00:00:00 2001 From: chengtripp <124098378+chengtripp@users.noreply.github.com> Date: Sat, 4 Feb 2023 14:20:42 +0000 Subject: [PATCH 154/592] Create README.md --- nomadnet/apps/messageboard/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 nomadnet/apps/messageboard/README.md diff --git a/nomadnet/apps/messageboard/README.md b/nomadnet/apps/messageboard/README.md new file mode 100644 index 0000000..53c637b --- /dev/null +++ b/nomadnet/apps/messageboard/README.md @@ -0,0 +1,18 @@ +# lxmf_messageboard +Simple message board that can be hosted on a NomadNet node, messages can be posted by 'conversing' with a unique peer, all messages are then forwarded to the message board. + +## How Do I Use It? +A user can submit messages to the message board by initiating a chat with the message board peer, they are assigned a username (based on the first 5 characters of their address) and their messages are added directly to the message board. The message board can be viewed on a page hosted by a NomadNet node. + +An example message board can be found on the reticulum testnet hosted on the SolarExpress Node `` and the message board peer `` + +## How Does It Work? +The message board page itself is hosted on a NomadNet node, you can place the message_board.mu into the pages directory. You can then run the message_board.py script which provides the peer that the users can send messages to. The two parts are joined together using umsgpack and a flat file system similar to NomadNet and Reticulum and runs in the background. + +## How Do I Set It Up? +* Turn on node hosting in NomadNet +* Put the `message_board.mu` file into `pages` directory in the config file for `NomadNet`. Edit the file to customise from the default page. +* Run the `message_board.py` script (`python3 message_board.py` either in a `screen` or as a system service), this script uses `NomadNet` and `RNS` libraries and has no additional libraries that need to be installed. Take a note of the message boards address, it is printed on starting the board, you can then place this address in `message_board.mu` file to make it easier for users to interact the board. + +## Credits +* The send and receive functions in message_board.py are based on examples posted on the Reticulum Matrix channel by [Mark](https://github.com/markqvist) From a4f5b2870729eae6533edc4177b2d03ab4b14d14 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 16:42:23 +0100 Subject: [PATCH 155/592] Renamed directory --- nomadnet/{apps => examples}/messageboard/README.md | 2 +- nomadnet/{apps => examples}/messageboard/messageboard.mu | 0 nomadnet/{apps => examples}/messageboard/messageboard.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename nomadnet/{apps => examples}/messageboard/README.md (91%) rename nomadnet/{apps => examples}/messageboard/messageboard.mu (100%) rename nomadnet/{apps => examples}/messageboard/messageboard.py (100%) diff --git a/nomadnet/apps/messageboard/README.md b/nomadnet/examples/messageboard/README.md similarity index 91% rename from nomadnet/apps/messageboard/README.md rename to nomadnet/examples/messageboard/README.md index 53c637b..16e35e0 100644 --- a/nomadnet/apps/messageboard/README.md +++ b/nomadnet/examples/messageboard/README.md @@ -15,4 +15,4 @@ The message board page itself is hosted on a NomadNet node, you can place the me * Run the `message_board.py` script (`python3 message_board.py` either in a `screen` or as a system service), this script uses `NomadNet` and `RNS` libraries and has no additional libraries that need to be installed. Take a note of the message boards address, it is printed on starting the board, you can then place this address in `message_board.mu` file to make it easier for users to interact the board. ## Credits -* The send and receive functions in message_board.py are based on examples posted on the Reticulum Matrix channel by [Mark](https://github.com/markqvist) +* This example application was written and contributed by @chengtripp \ No newline at end of file diff --git a/nomadnet/apps/messageboard/messageboard.mu b/nomadnet/examples/messageboard/messageboard.mu similarity index 100% rename from nomadnet/apps/messageboard/messageboard.mu rename to nomadnet/examples/messageboard/messageboard.mu diff --git a/nomadnet/apps/messageboard/messageboard.py b/nomadnet/examples/messageboard/messageboard.py similarity index 100% rename from nomadnet/apps/messageboard/messageboard.py rename to nomadnet/examples/messageboard/messageboard.py From 681295a818db4cecfc457357e1ddb93d57494722 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 16:43:50 +0100 Subject: [PATCH 156/592] Create examples directory on init --- nomadnet/NomadNetworkApp.py | 4 ++++ nomadnet/vendor/quotes.py | 1 + setup.py | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 701869b..2efa8c1 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -111,6 +111,7 @@ class NomadNetworkApp: self.pagespath = self.configdir+"/storage/pages" self.filespath = self.configdir+"/storage/files" self.cachepath = self.configdir+"/storage/cache" + self.examplespath = self.configdir+"/examples" self.downloads_path = os.path.expanduser("~/Downloads") @@ -169,6 +170,9 @@ class NomadNetworkApp: else: RNS.log("Could not load config file, creating default configuration file...") self.createDefaultConfig() + if not os.path.isdir(self.examplespath): + os.makedirs(self.examplespath) + self.firstrun = True if os.path.isfile(self.identitypath): diff --git a/nomadnet/vendor/quotes.py b/nomadnet/vendor/quotes.py index ee142f7..4c87571 100644 --- a/nomadnet/vendor/quotes.py +++ b/nomadnet/vendor/quotes.py @@ -3,4 +3,5 @@ quotes = [ ("That's enough entropy for you my friend", "Unknown"), ("Any time two people connect online, it's financed by a third person who believes they can manipulate the first two", "Jaron Lanier"), ("The landscape of the future is set, but how one will march across it is not determined", "Terence McKenna") + ("Freedom originates in the division of power, despotism in its concentration.", "John Acton") ] \ No newline at end of file diff --git a/setup.py b/setup.py index fadae5d..b3fadd6 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,12 @@ exec(open("nomadnet/_version.py", "r").read()) with open("README.md", "r") as fh: long_description = fh.read() +package_data = { +"": [ + "examples/messageboard/*", + ] +} + setuptools.setup( name="nomadnet", version=__version__, @@ -15,6 +21,7 @@ setuptools.setup( long_description_content_type="text/markdown", url="https://github.com/markqvist/nomadnet", packages=setuptools.find_packages(), + package_data=package_data, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", From 6aca6d671a1bdf45651c6fbf99d79cd828d255d9 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 17:04:16 +0100 Subject: [PATCH 157/592] Add node examples on first run --- nomadnet/NomadNetworkApp.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 2efa8c1..db94ce9 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -168,11 +168,19 @@ class NomadNetworkApp: RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR) nomadnet.panic() else: + if not os.path.isdir(self.examplespath): + try: + os.makedirs(self.examplespath) + from distutils.dir_util import copy_tree + examplespath = os.path.join(os.path.dirname(__file__), "examples") + copy_tree(examplespath, self.examplespath) + + except Exception as e: + RNS.log("Could not copy examples into the "+self.examplespath+" directory.", RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + RNS.log("Could not load config file, creating default configuration file...") self.createDefaultConfig() - if not os.path.isdir(self.examplespath): - os.makedirs(self.examplespath) - self.firstrun = True if os.path.isfile(self.identitypath): From f4e3f1cb46738c47e936d39820abc8c4fa69dd3c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 4 Feb 2023 19:06:16 +0100 Subject: [PATCH 158/592] Excluded pycache from example inclusions --- nomadnet/NomadNetworkApp.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index db94ce9..cd11bfe 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -170,10 +170,9 @@ class NomadNetworkApp: else: if not os.path.isdir(self.examplespath): try: - os.makedirs(self.examplespath) - from distutils.dir_util import copy_tree + import shutil examplespath = os.path.join(os.path.dirname(__file__), "examples") - copy_tree(examplespath, self.examplespath) + shutil.copytree(examplespath, self.examplespath, ignore=shutil.ignore_patterns("__pycache__")) except Exception as e: RNS.log("Could not copy examples into the "+self.examplespath+" directory.", RNS.LOG_ERROR) From 2d6cd8e33fc9f25a997424cfdf1427bad4d577a4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 9 Feb 2023 12:40:35 +0100 Subject: [PATCH 159/592] Catch unhandled Ctrl-E before passing to terminal --- nomadnet/ui/TextUI.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 6389aac..3f38744 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -217,6 +217,8 @@ class TextUI: def unhandled_input(self, key): if key == "ctrl q": raise urwid.ExitMainLoop + elif key == "ctrl e": + pass def display_main(self, loop, user_data): self.loop.widget = self.main_display.widget From 4ec1a4979f1ffd95d436ee201b8ccf75d6fb854a Mon Sep 17 00:00:00 2001 From: Dionysis Grigoropoulos Date: Thu, 9 Feb 2023 13:45:37 +0200 Subject: [PATCH 160/592] Fix bug where Nomadnet crashes on empty lists --- nomadnet/ui/textui/Conversations.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 9aa1978..3929bd1 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -110,7 +110,10 @@ class ConversationsDisplay(): def delete_selected_conversation(self): self.dialog_open = True - source_hash = self.ilb.get_selected_item().source_hash + item = self.ilb.get_selected_item() + if item == None: + return + source_hash = item.source_hash def dismiss_dialog(sender): self.update_conversation_list() @@ -140,7 +143,10 @@ class ConversationsDisplay(): def edit_selected_in_directory(self): g = self.app.ui.glyphs self.dialog_open = True - source_hash_text = self.ilb.get_selected_item().source_hash + item = self.ilb.get_selected_item() + if item == None: + return + source_hash_text = item.source_hash display_name = self.ilb.get_selected_item().display_name if display_name == None: display_name = "" From 1d3538a604963e142ad3cab6bf221934b9f6bd16 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 9 Feb 2023 21:18:14 +0100 Subject: [PATCH 161/592] Field parsing --- nomadnet/ui/textui/MicronParser.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 6948c00..db0e8ff 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -1,5 +1,6 @@ import nomadnet import urwid +import random import time from urwid.util import is_mouse_press from urwid.text_layout import calc_coords @@ -423,6 +424,32 @@ def make_output(state, line, url_delegate): elif c == "a": state["align"] = state["default_align"] + elif c == "<": + # TODO: remove + import RNS + field_name = None + field_name_end = line[i:].find("`") + if field_name_end == -1: + pass + else: + field_name = line[i+1:i+field_name_end] + def sr(): + return "@{"+str(random.randint(1000,9999))+"}" + rsg = sr() + while rsg in line[i+field_name_end:]: + rsg = sr() + lr = line[i+field_name_end:].replace("\\>", rsg) + endpos = lr.find(">") + + if endpos == -1: + pass + + else: + field_data = lr[1:endpos].replace(rsg, "\\>") + skip = len(field_data)+len(field_name)+2 + field_data = field_data.replace("\\>", ">") + RNS.log("Field "+str(field_name)+": "+str(field_data)) + elif c == "[": endpos = line[i:].find("]") if endpos == -1: From eb6dd1729b84380d443b7fecdbe4d5076f4fa0d8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 14 Feb 2023 15:26:25 +0100 Subject: [PATCH 162/592] Input field rendering --- nomadnet/ui/textui/MicronParser.py | 66 ++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index db0e8ff..69586ae 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -6,6 +6,9 @@ from urwid.util import is_mouse_press from urwid.text_layout import calc_coords import re +# TODO: remove +import RNS + DEFAULT_FG_DARK = "ddd" DEFAULT_FG_LIGHT = "222" DEFAULT_BG = "default" @@ -63,13 +66,14 @@ def markup_to_attrmaps(markup, url_delegate = None): for line in lines: if len(line) > 0: - display_widget = parse_line(line, state, url_delegate) + display_widgets = parse_line(line, state, url_delegate) else: - display_widget = urwid.Text("") + display_widgets = [urwid.Text("")] - if display_widget != None: - attrmap = urwid.AttrMap(display_widget, make_style(state)) - attrmaps.append(attrmap) + if display_widgets != None and len(display_widgets) != 0: + for display_widget in display_widgets: + attrmap = urwid.AttrMap(display_widget, make_style(state)) + attrmaps.append(attrmap) return attrmaps @@ -125,7 +129,7 @@ def parse_line(line, state, url_delegate): heading_style = first_style output.insert(0, " "*left_indent(state)) - return urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style) + return [urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)] else: return None else: @@ -138,22 +142,54 @@ def parse_line(line, state, url_delegate): else: divider_char = "\u2500" if state["depth"] == 0: - return urwid.Divider(divider_char) + return [urwid.Divider(divider_char)] else: - return urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state)) + return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))] output = make_output(state, line, url_delegate) if output != None: - if url_delegate != None: - text_widget = LinkableText(output, align=state["align"], delegate=url_delegate) + text_only = True + for o in output: + if not isinstance(o, tuple): + text_only = False + break + + if not text_only: + widgets = [] + for o in output: + if isinstance(o, tuple): + if url_delegate != None: + tw = LinkableText(o, align=state["align"], delegate=url_delegate) + else: + tw = urwid.Text(o, align=state["align"]) + + widgets.append(("pack", tw)) + else: + if o["type"] == "field": + fw = 32 + fd = o["data"] + fn = o["name"] + fs = o["style"] + f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=True) + f.field_name = fn + fa = urwid.AttrMap(f, fs) + widgets.append((fw, fa)) + + columns_widget = urwid.Columns(widgets, dividechars=0) + text_widget = columns_widget + # text_widget = urwid.Text("<"+output+">", align=state["align"]) + else: - text_widget = urwid.Text(output, align=state["align"]) + if url_delegate != None: + text_widget = LinkableText(output, align=state["align"], delegate=url_delegate) + else: + text_widget = urwid.Text(output, align=state["align"]) if state["depth"] == 0: - return text_widget + return [text_widget] else: - return urwid.Padding(text_widget, left=left_indent(state), right=right_indent(state)) + return [urwid.Padding(text_widget, left=left_indent(state), right=right_indent(state))] else: return None else: @@ -425,8 +461,6 @@ def make_output(state, line, url_delegate): state["align"] = state["default_align"] elif c == "<": - # TODO: remove - import RNS field_name = None field_name_end = line[i:].find("`") if field_name_end == -1: @@ -448,7 +482,7 @@ def make_output(state, line, url_delegate): field_data = lr[1:endpos].replace(rsg, "\\>") skip = len(field_data)+len(field_name)+2 field_data = field_data.replace("\\>", ">") - RNS.log("Field "+str(field_name)+": "+str(field_data)) + output.append({"type":"field", "name": field_name, "data": field_data, "style": make_style(state)}) elif c == "[": endpos = line[i:].find("]") From 1378a3f6ddd611ff116899d29a3dd3f134674360 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 14 Feb 2023 16:07:30 +0100 Subject: [PATCH 163/592] Field width and mask control --- nomadnet/ui/textui/MicronParser.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 69586ae..fc1a6ec 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -167,11 +167,12 @@ def parse_line(line, state, url_delegate): widgets.append(("pack", tw)) else: if o["type"] == "field": - fw = 32 + fw = o["width"] fd = o["data"] fn = o["name"] fs = o["style"] - f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=True) + fmask = "*" if o["masked"] else None + f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) f.field_name = fn fa = urwid.AttrMap(f, fs) widgets.append((fw, fa)) @@ -467,6 +468,20 @@ def make_output(state, line, url_delegate): pass else: field_name = line[i+1:i+field_name_end] + field_name_skip = len(field_name) + field_masked = False + field_width = 24 + + if "|" in field_name: + f_components = field_name.split("|") + field_flags = f_components[0] + field_name = f_components[1] + if "!" in field_flags: + field_flags = field_flags.replace("!", "") + field_masked = True + if len(field_flags) > 0: + field_width = min(int(field_flags), 256) + def sr(): return "@{"+str(random.randint(1000,9999))+"}" rsg = sr() @@ -480,9 +495,16 @@ def make_output(state, line, url_delegate): else: field_data = lr[1:endpos].replace(rsg, "\\>") - skip = len(field_data)+len(field_name)+2 + skip = len(field_data)+field_name_skip+2 field_data = field_data.replace("\\>", ">") - output.append({"type":"field", "name": field_name, "data": field_data, "style": make_style(state)}) + output.append({ + "type":"field", + "name": field_name, + "width": field_width, + "masked": field_masked, + "data": field_data, + "style": make_style(state) + }) elif c == "[": endpos = line[i:].find("]") From d30f405fd5b94295a6e93445ea7d108364638787 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 14 Feb 2023 19:46:21 +0100 Subject: [PATCH 164/592] Added input field data handling --- nomadnet/Node.py | 15 +++- nomadnet/ui/textui/Browser.py | 48 +++++++++++-- nomadnet/ui/textui/MicronParser.py | 109 +++++++++++++++++------------ 3 files changed, 119 insertions(+), 53 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 018d43b..13e1f41 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -97,7 +97,7 @@ class Node: for directory in directories: self.scan_files(base_path+"/"+directory) - def serve_page(self, path, data, request_id, remote_identity, requested_at): + def serve_page(self, path, data, request_id, link_id, remote_identity, requested_at): RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) try: self.app.peer_settings["served_page_requests"] += 1 @@ -152,7 +152,18 @@ class Node: if request_allowed: RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) if os.access(file_path, os.X_OK): - generated = subprocess.run([file_path], stdout=subprocess.PIPE) + env_map = {} + if link_id != None: + env_map["link_id"] = RNS.hexrep(link_id, delimit=False) + if remote_identity != None: + env_map["remote_identity"] = RNS.hexrep(remote_identity.hash, delimit=False) + + if data != None and isinstance(data, dict): + for e in data: + if isinstance(e, str) and e.startswith("field_"): + env_map[e] = data[e] + + generated = subprocess.run([file_path], stdout=subprocess.PIPE, env=env_map) return generated.stdout else: fh = open(file_path, "rb") diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 8b75fbd..6c3aa51 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -81,6 +81,7 @@ class Browser: self.aspects = aspects self.destination_hash = destination_hash self.path = path + self.field_data = None self.timeout = Browser.DEFAULT_TIMEOUT self.last_keypress = None @@ -161,7 +162,31 @@ class Browser: else: return destination_type - def handle_link(self, link_target): + def handle_link(self, link_target, link_fields = None): + field_data = None + if link_fields != None: + field_data = {} + def recurse_down(w): + target = None + if isinstance(w, list): + for t in w: + recurse_down(t) + elif isinstance(w, tuple): + for t in w: + recurse_down(t) + elif hasattr(w, "contents"): + recurse_down(w.contents) + elif hasattr(w, "original_widget"): + recurse_down(w.original_widget) + elif hasattr(w, "_original_widget"): + recurse_down(w._original_widget) + else: + if hasattr(w, "field_name") and (link_fields == "all" or w.field_name in link_fields): + field_data["field_"+w.field_name] = w.get_edit_text() + + recurse_down(self.attr_maps) + RNS.log("Found field data: "+str(field_data)) + components = link_target.split("@") destination_type = None @@ -177,7 +202,7 @@ class Browser: RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG) self.browser_footer = urwid.Text("Opening link to: "+str(link_target)) try: - self.retrieve_url(link_target) + self.retrieve_url(link_target, field_data) except Exception as e: self.browser_footer = urwid.Text("Could not open link: "+str(e)) self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) @@ -365,7 +390,7 @@ class Browser: self.update_display() - def retrieve_url(self, url): + def retrieve_url(self, url, field_data = None): self.previous_destination_hash = self.destination_hash self.previous_path = self.path @@ -418,6 +443,7 @@ class Browser: else: self.set_destination_hash(destination_hash) self.set_path(path) + self.set_field_data(field_data) self.load_page() def set_destination_hash(self, destination_hash): @@ -431,6 +457,8 @@ class Browser: def set_path(self, path): self.path = path + def set_field_data(self, field_data): + self.field_data = field_data def set_timeout(self, timeout): self.timeout = timeout @@ -637,7 +665,11 @@ class Browser: def load_page(self): - cached = self.get_cached(self.current_url()) + if self.field_data == None: + cached = self.get_cached(self.current_url()) + else: + cached = None + if cached: self.status = Browser.DONE self.page_data = cached @@ -671,7 +703,11 @@ class Browser: page_data = b"The requested local page did not exist in the file system" if os.path.isfile(page_path): if os.access(page_path, os.X_OK): - generated = subprocess.run([page_path], stdout=subprocess.PIPE) + if self.field_data != None: + env_map = self.field_data + else: + env_map = None + generated = subprocess.run([page_path], stdout=subprocess.PIPE, env=env_map) page_data = generated.stdout else: file = open(page_path, "rb") @@ -758,7 +794,7 @@ class Browser: self.update_display() receipt = self.link.request( self.path, - data = None, + data = self.field_data, response_callback = self.response_received, failed_callback = self.request_failed, progress_callback = self.response_progressed diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index fc1a6ec..917808c 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -462,49 +462,52 @@ def make_output(state, line, url_delegate): state["align"] = state["default_align"] elif c == "<": - field_name = None - field_name_end = line[i:].find("`") - if field_name_end == -1: - pass - else: - field_name = line[i+1:i+field_name_end] - field_name_skip = len(field_name) - field_masked = False - field_width = 24 - - if "|" in field_name: - f_components = field_name.split("|") - field_flags = f_components[0] - field_name = f_components[1] - if "!" in field_flags: - field_flags = field_flags.replace("!", "") - field_masked = True - if len(field_flags) > 0: - field_width = min(int(field_flags), 256) - - def sr(): - return "@{"+str(random.randint(1000,9999))+"}" - rsg = sr() - while rsg in line[i+field_name_end:]: - rsg = sr() - lr = line[i+field_name_end:].replace("\\>", rsg) - endpos = lr.find(">") - - if endpos == -1: + try: + field_name = None + field_name_end = line[i:].find("`") + if field_name_end == -1: pass - else: - field_data = lr[1:endpos].replace(rsg, "\\>") - skip = len(field_data)+field_name_skip+2 - field_data = field_data.replace("\\>", ">") - output.append({ - "type":"field", - "name": field_name, - "width": field_width, - "masked": field_masked, - "data": field_data, - "style": make_style(state) - }) + field_name = line[i+1:i+field_name_end] + field_name_skip = len(field_name) + field_masked = False + field_width = 24 + + if "|" in field_name: + f_components = field_name.split("|") + field_flags = f_components[0] + field_name = f_components[1] + if "!" in field_flags: + field_flags = field_flags.replace("!", "") + field_masked = True + if len(field_flags) > 0: + field_width = min(int(field_flags), 256) + + def sr(): + return "@{"+str(random.randint(1000,9999))+"}" + rsg = sr() + while rsg in line[i+field_name_end:]: + rsg = sr() + lr = line[i+field_name_end:].replace("\\>", rsg) + endpos = lr.find(">") + + if endpos == -1: + pass + + else: + field_data = lr[1:endpos].replace(rsg, "\\>") + skip = len(field_data)+field_name_skip+2 + field_data = field_data.replace("\\>", ">") + output.append({ + "type":"field", + "name": field_name, + "width": field_width, + "masked": field_masked, + "data": field_data, + "style": make_style(state) + }) + except Exception as e: + pass elif c == "[": endpos = line[i:].find("]") @@ -517,13 +520,20 @@ def make_output(state, line, url_delegate): link_components = link_data.split("`") if len(link_components) == 1: link_label = "" + link_fields = "" link_url = link_data elif len(link_components) == 2: link_label = link_components[0] link_url = link_components[1] + link_fields = "" + elif len(link_components) == 3: + link_label = link_components[0] + link_url = link_components[1] + link_fields = link_components[2] else: link_url = "" link_label = "" + link_fields = "" if len(link_url) != 0: if link_label == "": @@ -551,6 +561,14 @@ def make_output(state, line, url_delegate): if url_delegate != None: linkspec = LinkSpec(link_url, orig_spec) + if link_fields != "": + if link_fields == "*": + linkspec.link_fields = "all" + else: + lf = link_fields.split("|") + if len(lf) > 0: + linkspec.link_fields = lf + output.append((linkspec, link_label)) else: output.append(make_part(state, link_label)) @@ -593,6 +611,7 @@ def make_output(state, line, url_delegate): class LinkSpec(urwid.AttrSpec): def __init__(self, link_target, orig_spec): self.link_target = link_target + self.link_fields = None urwid.AttrSpec.__init__(self, orig_spec.foreground, orig_spec.background) @@ -611,9 +630,9 @@ class LinkableText(urwid.Text): if self.delegate != None: self.delegate.last_keypress = 0 - def handle_link(self, link_target): + def handle_link(self, link_target, link_fields): if self.delegate != None: - self.delegate.handle_link(link_target) + self.delegate.handle_link(link_target, link_fields) def find_next_part_pos(self, pos, part_positions): for position in part_positions: @@ -671,7 +690,7 @@ class LinkableText(urwid.Text): item = self.find_item_at_pos(self._cursor_position) if item != None: if isinstance(item, LinkSpec): - self.handle_link(item.link_target) + self.handle_link(item.link_target, item.link_fields) elif key == "up": self._cursor_position = 0 @@ -761,7 +780,7 @@ class LinkableText(urwid.Text): if item != None: if isinstance(item, LinkSpec): - self.handle_link(item.link_target) + self.handle_link(item.link_target, item.link_fields) self._invalidate() self._emit("change") From c134ee180a1e7c9056fc5fb809f0fa722b1ecdd3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 14 Feb 2023 19:46:39 +0100 Subject: [PATCH 165/592] Added input fields example --- nomadnet/examples/various/input_fields.py | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 nomadnet/examples/various/input_fields.py diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py new file mode 100644 index 0000000..2400b79 --- /dev/null +++ b/nomadnet/examples/various/input_fields.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import os +env_string = "" +for e in os.environ: + env_string += "{}={}\n".format(e, os.environ[e]) + +template = """>Fields and Submitting Data + +Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. This page contains a few examples. + +>> Read Environment Variables + +{@ENV} +>>Examples of Fields and Submissions + +The following section contains a simple set of fields, and a few different links that submit the field data in different ways. + +-= + +An input field : `B444``b + +An masked field : `B444``b + +An small field : `B444`<8|small`test>`b, and some more text. + +Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b + +The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. + +You can `!`[submit`:/page/input_fields.mu`one|password|small]`! other fields, or just `!`[a single one`:/page/input_fields.mu`username]`! + +Or simply `!`[submit them all`:/page/input_fields.mu`*]`!. + +-= + +""" +print(template.replace("{@ENV}", env_string)) \ No newline at end of file From 1a84e0c019efda22c4da4cfeaf895e51192da451 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 15 Feb 2023 10:09:25 +0100 Subject: [PATCH 166/592] Added link variables --- nomadnet/Node.py | 2 +- nomadnet/ui/textui/Browser.py | 48 +++++++++++++++++++----------- nomadnet/ui/textui/MicronParser.py | 12 ++------ 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 13e1f41..525cf4c 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -160,7 +160,7 @@ class Node: if data != None and isinstance(data, dict): for e in data: - if isinstance(e, str) and e.startswith("field_"): + if isinstance(e, str) and (e.startswith("field_") or e.startswith("var_")): env_map[e] = data[e] generated = subprocess.run([file_path], stdout=subprocess.PIPE, env=env_map) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 6c3aa51..372f190 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -81,7 +81,7 @@ class Browser: self.aspects = aspects self.destination_hash = destination_hash self.path = path - self.field_data = None + self.request_data = None self.timeout = Browser.DEFAULT_TIMEOUT self.last_keypress = None @@ -162,10 +162,21 @@ class Browser: else: return destination_type - def handle_link(self, link_target, link_fields = None): - field_data = None - if link_fields != None: - field_data = {} + def handle_link(self, link_target, link_data = None): + request_data = None + if link_data != None: + link_fields = [] + request_data = {} + all_fields = True if "*" in link_data else False + + for e in link_data: + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + else: + link_fields.append(e) + def recurse_down(w): target = None if isinstance(w, list): @@ -181,11 +192,11 @@ class Browser: elif hasattr(w, "_original_widget"): recurse_down(w._original_widget) else: - if hasattr(w, "field_name") and (link_fields == "all" or w.field_name in link_fields): - field_data["field_"+w.field_name] = w.get_edit_text() + if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields): + request_data["field_"+w.field_name] = w.get_edit_text() recurse_down(self.attr_maps) - RNS.log("Found field data: "+str(field_data)) + RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) components = link_target.split("@") destination_type = None @@ -202,7 +213,7 @@ class Browser: RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG) self.browser_footer = urwid.Text("Opening link to: "+str(link_target)) try: - self.retrieve_url(link_target, field_data) + self.retrieve_url(link_target, request_data) except Exception as e: self.browser_footer = urwid.Text("Could not open link: "+str(e)) self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) @@ -376,6 +387,7 @@ class Browser: if self.link != None: self.link.teardown() + self.request_data = None self.attr_maps = [] self.status = Browser.DISCONECTED self.response_progress = 0 @@ -390,7 +402,7 @@ class Browser: self.update_display() - def retrieve_url(self, url, field_data = None): + def retrieve_url(self, url, request_data = None): self.previous_destination_hash = self.destination_hash self.previous_path = self.path @@ -443,7 +455,7 @@ class Browser: else: self.set_destination_hash(destination_hash) self.set_path(path) - self.set_field_data(field_data) + self.set_request_data(request_data) self.load_page() def set_destination_hash(self, destination_hash): @@ -457,8 +469,8 @@ class Browser: def set_path(self, path): self.path = path - def set_field_data(self, field_data): - self.field_data = field_data + def set_request_data(self, request_data): + self.request_data = request_data def set_timeout(self, timeout): self.timeout = timeout @@ -665,7 +677,7 @@ class Browser: def load_page(self): - if self.field_data == None: + if self.request_data == None: cached = self.get_cached(self.current_url()) else: cached = None @@ -703,10 +715,10 @@ class Browser: page_data = b"The requested local page did not exist in the file system" if os.path.isfile(page_path): if os.access(page_path, os.X_OK): - if self.field_data != None: - env_map = self.field_data + if self.request_data != None: + env_map = self.request_data else: - env_map = None + env_map = {} generated = subprocess.run([page_path], stdout=subprocess.PIPE, env=env_map) page_data = generated.stdout else: @@ -794,7 +806,7 @@ class Browser: self.update_display() receipt = self.link.request( self.path, - data = self.field_data, + data = self.request_data, response_callback = self.response_received, failed_callback = self.request_failed, progress_callback = self.response_progressed diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 917808c..f6a82e8 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -6,9 +6,6 @@ from urwid.util import is_mouse_press from urwid.text_layout import calc_coords import re -# TODO: remove -import RNS - DEFAULT_FG_DARK = "ddd" DEFAULT_FG_LIGHT = "222" DEFAULT_BG = "default" @@ -562,12 +559,9 @@ def make_output(state, line, url_delegate): if url_delegate != None: linkspec = LinkSpec(link_url, orig_spec) if link_fields != "": - if link_fields == "*": - linkspec.link_fields = "all" - else: - lf = link_fields.split("|") - if len(lf) > 0: - linkspec.link_fields = lf + lf = link_fields.split("|") + if len(lf) > 0: + linkspec.link_fields = lf output.append((linkspec, link_label)) else: From e266719e2c1cdb93ed29b57ad3b9588c087f2478 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 15 Feb 2023 11:53:34 +0100 Subject: [PATCH 167/592] Keyboard navigation for fields --- nomadnet/examples/various/input_fields.py | 6 ++++++ nomadnet/ui/textui/MicronParser.py | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py index 2400b79..43d7e65 100644 --- a/nomadnet/examples/various/input_fields.py +++ b/nomadnet/examples/various/input_fields.py @@ -31,6 +31,12 @@ You can `!`[submit`:/page/input_fields.mu`one|password|small]`! other fields, or Or simply `!`[submit them all`:/page/input_fields.mu`*]`!. +Submission links can also `!`[include pre-configured variables`:/page/input_fields.mu`username|two|entitiy_id=4611|action=view]`!. + +Or take all fields and `!`[pre-configured variables`:/page/input_fields.mu`*|entitiy_id=4611|action=view]`!. + +Or only `!`[pre-configured variables`:/page/input_fields.mu`entitiy_id=4688|task=something]`! + -= """ diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index f6a82e8..f549509 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -158,6 +158,7 @@ def parse_line(line, state, url_delegate): if isinstance(o, tuple): if url_delegate != None: tw = LinkableText(o, align=state["align"], delegate=url_delegate) + tw.in_columns = True else: tw = urwid.Text(o, align=state["align"]) @@ -621,6 +622,7 @@ class LinkableText(urwid.Text): self.delegate = delegate self._cursor_position = 0 self.key_timeout = 3 + self.in_columns = False if self.delegate != None: self.delegate.last_keypress = 0 @@ -697,16 +699,23 @@ class LinkableText(urwid.Text): elif key == "right": old = self._cursor_position self._cursor_position = self.find_next_part_pos(self._cursor_position, part_positions) + if self._cursor_position == old: - self._cursor_position = 0 - return "down" + if self.in_columns: + return "right" + else: + self._cursor_position = 0 + return "down" self._invalidate() elif key == "left": if self._cursor_position > 0: - self._cursor_position = self.find_prev_part_pos(self._cursor_position, part_positions) - self._invalidate() + if self.in_columns: + return "left" + else: + self._cursor_position = self.find_prev_part_pos(self._cursor_position, part_positions) + self._invalidate() else: if self.delegate != None: From 1f768978557209f2dab0e9eddce30988b4489971 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 15 Feb 2023 12:26:37 +0100 Subject: [PATCH 168/592] Added fields and request links to guide --- nomadnet/ui/textui/Guide.py | 93 ++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index c30bfee..3039a3e 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -322,9 +322,12 @@ You can control how long a peer will cache your pages by including the cache hea You can use a preprocessor such as PHP, bash, Python (or whatever you prefer) to generate dynamic pages. To do so, just set executable permissions on the relevant page file, and be sure to include the interpreter at the beginning of the file, for example `!#!/usr/bin/python3`!. +Data from fields and link variables will be passed to these scipts or programs as environment variables, and can simply be read by any method for acessing such. + In the `!examples`! directory, you can find various small examples for the use of this feature. The currently included examples are: - A messageboard that receives messages over LXMF, contributed by trippcheng + - A simple demonstration on how to create fields and read entered data in node-side scripts By default, you can find the examples in `!~/.nomadnetwork/examples`!. If you build something neat, that you feel would fit here, you are more than welcome to contribute it. @@ -1002,6 +1005,94 @@ Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/inde When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL. +>Fields & Requests + +Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. + +>>Request Links + +Links can contain request variables and a list of fields to submit to the node-side application. You can include all fields on the page, only specific ones, and any number of request variables. To simply submit all fields on a page to a specified node-side page, create a link like this: + +`Faaa +`= +`[Submit Fields`:/page/fields.mu`*] +`= +`` + +Note the `!*`! following the extra `!\``! at the end of the path. This `!*`! denotes `*all fields`*. You can also specify a list of fields to include: + +`Faaa +`= +`[Submit Fields`:/page/fields.mu`username|auth_token] +`= +`` + +If you want to include pre-set variables, you can do it like this: + +`Faaa +`= +`[Query the System`:/page/fields.mu`username|auth_token|action=view|amount=64] +`= +`` + +>> Fields + +Here's an example of creating a field. We'll create a field named `!user_input`! and fill it with the text `!Pre-defined data`!. Note that we are using background color tags to make the field more visible to the user: + +`Faaa +`= +A simple input field: `B444``b +`= +`` + +You must always set a field `*name`*, but you can of course omit the pre-defined value of the field: + +`Faaa +`= +An empty input field: `B444``b +`= +`` + +You can set the size of the field like this: + +`Faaa +`= +A sized input field: `B444`<16|with_size`>`b +`= +`` + +It is possible to mask fields, for example for use with passwords and similar: + +`Faaa +`= +A masked input field: `B444``b +`= +`` + +And you can of course control all parameters at the same time: + +`Faaa +`= +Full control: `B444``b +`= +`` + +Collecting the above markup produces the following output: + +`Faaa`B333 + +A simple input field: `B444``B333 + +An empty input field: `B444``B333 + +A sized input field: `B444`<16|with_size`>`B333 + +A masked input field: `B444``B333 + +Full control: `B444``B333 + +`` + >Comments You can insert comments that will not be displayed in the output by starting a line with the # character. @@ -1034,7 +1125,7 @@ To display literal content, for example source-code, or blocks of text that shou `= ''' TOPIC_MARKUP += TOPIC_MARKUP.replace("`=", "\\`=") + "[ micron source for document goes here, we don't want infinite recursion now, do we? ]\n\\`=" -TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, you should be well equipped to write documents and pages using micron. Thank you for staying with me.\n\n`c\U0001F332\n" +TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, you should be well equipped to write documents, pages and applications using micron and Nomad Network. Thank you for staying with me.\n\n`c\U0001F332\n" TOPICS = { From 1b561a88ba01e7e4db9e8a1ac832299a492dbdb0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 15 Feb 2023 12:28:16 +0100 Subject: [PATCH 169/592] Updated version --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index e19434e..334b899 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.3" +__version__ = "0.3.4" diff --git a/setup.py b/setup.py index b3fadd6..9a029e6 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.8", "lxmf>=0.3.0", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.4.9", "lxmf>=0.3.0", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From 10d6d737b9dab01930721beffb47ff7405f43039 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 17 Feb 2023 19:37:10 +0100 Subject: [PATCH 170/592] Added LER metric to propagation node list --- nomadnet/ui/textui/Network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index b794cd2..d06fcf4 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1648,7 +1648,7 @@ class LXMFPeerEntry(urwid.WidgetWrap): else: alive_string = "Unresponsive" - widget = ListEntry(sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs") + widget = ListEntry(sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs, "+RNS.prettysize(peer.link_establishment_rate/8, "b")+"/s LER") # urwid.connect_signal(widget, "click", delegate.connect_node, node) self.display_widget = urwid.AttrMap(widget, style, focus_style) From 2fe2f6bb49b2a465521c5999dce929b8f71afb46 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 17 Feb 2023 22:31:21 +0100 Subject: [PATCH 171/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9a029e6..a3256ee 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.9", "lxmf>=0.3.0", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.4.9", "lxmf>=0.3.1", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From 8109bce5a3c136f715d296de584bcacd0435c93a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 17 Feb 2023 23:04:44 +0100 Subject: [PATCH 172/592] Fixed missing path in env --- nomadnet/Node.py | 4 +++- nomadnet/ui/textui/Browser.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 525cf4c..1ad254b 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -153,6 +153,8 @@ class Node: RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) if os.access(file_path, os.X_OK): env_map = {} + if "PATH" in os.environ: + env_map["PATH"] = os.environ["PATH"] if link_id != None: env_map["link_id"] = RNS.hexrep(link_id, delimit=False) if remote_identity != None: @@ -248,4 +250,4 @@ If you are the node operator, you can define your own home page by creating a fi DEFAULT_NOTALLOWED = '''>Request Not Allowed You are not authorised to carry out the request. -''' \ No newline at end of file +''' diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 372f190..7907e65 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -719,6 +719,10 @@ class Browser: env_map = self.request_data else: env_map = {} + + if "PATH" in os.environ: + env_map["PATH"] = os.environ["PATH"] + generated = subprocess.run([page_path], stdout=subprocess.PIPE, env=env_map) page_data = generated.stdout else: @@ -1108,4 +1112,4 @@ class UrlEdit(urwid.Edit): if key == "enter": self.confirmed(self) else: - return super(UrlEdit, self).keypress(size, key) \ No newline at end of file + return super(UrlEdit, self).keypress(size, key) From bf7004fd0f59bdbaa5d95f90ca12bff1b00f8185 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 17 Feb 2023 23:09:13 +0100 Subject: [PATCH 173/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 334b899..a8d4557 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.4" +__version__ = "0.3.5" From 02f9a5a7603e66776398b8d0e706fcd97bbdbf07 Mon Sep 17 00:00:00 2001 From: Swissbandit <109535859+Swissbandit@users.noreply.github.com> Date: Wed, 29 Mar 2023 16:44:37 +0200 Subject: [PATCH 174/592] Update Guide.py fixed typo redundant .mu --- nomadnet/ui/textui/Guide.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 3039a3e..c8c2cfc 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -335,7 +335,7 @@ By default, you can find the examples in `!~/.nomadnetwork/examples`!. If you bu Sometimes, you don't want everyone to be able to view certain pages or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. -To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". +To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.allowed". For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this: From b6e6c4bd3d4a6f156393e376000bd1fb949c7967 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 13 Jun 2023 19:57:49 +0200 Subject: [PATCH 175/592] Updated version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index a8d4557..d7b30e1 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.5" +__version__ = "0.3.6" diff --git a/setup.py b/setup.py index a3256ee..2992e60 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.4.9", "lxmf>=0.3.1", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.5.5", "lxmf>=0.3.2", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From 3d0043499cc9a70821957c5cc8a520aa7892282b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 13 Aug 2023 20:37:44 +0200 Subject: [PATCH 176/592] Added error handling to lxmf.delivery announce handler. Fixes #30. Hopefully. --- nomadnet/Directory.py | 21 +++++++++++++-------- setup.py | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 64eaf07..2cba01b 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -34,16 +34,21 @@ class Directory: aspect_filter = "nomadnetwork.node" @staticmethod def received_announce(destination_hash, announced_identity, app_data): - app = nomadnet.NomadNetworkApp.get_shared_instance() + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() - if not destination_hash in app.ignored_list: - associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) + if not destination_hash in app.ignored_list: + associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) - app.directory.node_announce_received(destination_hash, app_data, associated_peer) - app.autoselect_propagation_node() - - else: - RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + app.directory.node_announce_received(destination_hash, app_data, associated_peer) + app.autoselect_propagation_node() + + else: + RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while evaluating LXMF destination announce, ignoring announce.", RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) def __init__(self, app): diff --git a/setup.py b/setup.py index 2992e60..b5b5fad 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.5.5", "lxmf>=0.3.2", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.5.7", "lxmf>=0.3.2", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From f0a4efa28b986a5f4fcf0bd307342ebbf76e5ca5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 13 Aug 2023 20:54:29 +0200 Subject: [PATCH 177/592] Added alternative save node hotkey. Added save hotkey to hotkeys list. --- nomadnet/ui/textui/Browser.py | 2 ++ nomadnet/ui/textui/Network.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 7907e65..4cd6ce0 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -23,6 +23,8 @@ class BrowserFrame(urwid.Frame): self.delegate.url_dialog() elif key == "ctrl s": self.delegate.save_node_dialog() + elif key == "ctrl b": + self.delegate.save_node_dialog() elif key == "ctrl g": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen() elif self.get_focus() == "body": diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index d06fcf4..16ff98c 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -13,7 +13,7 @@ class NetworkDisplayShortcuts(): self.app = app g = app.ui.glyphs - self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-g] Fullscreen"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-g] Fullscreen [C-s / C-b] Save"), "shortcutbar") # "[C-"+g["arrow_u"]+g["arrow_d"]+"] Navigate Lists" From 919a146da1982bcb5e4cebc83d8bda03c96f3eea Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 13 Aug 2023 20:55:35 +0200 Subject: [PATCH 178/592] Added alternative save node hotkey. Added save hotkey to hotkeys list. Closes #31. --- nomadnet/ui/textui/Network.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 16ff98c..7de57eb 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -13,9 +13,7 @@ class NetworkDisplayShortcuts(): self.app = app g = app.ui.glyphs - self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-g] Fullscreen [C-s / C-b] Save"), "shortcutbar") - # "[C-"+g["arrow_u"]+g["arrow_d"]+"] Navigate Lists" - + self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-g] Fullscreen [C-s / C-b] Save Node"), "shortcutbar") class DialogLineBox(urwid.LineBox): def keypress(self, size, key): From 22a7acf25981198ace40379ee71a0a9ee4a354b5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 13 Aug 2023 21:54:35 +0200 Subject: [PATCH 179/592] Added compact announce stream option --- nomadnet/Directory.py | 39 +++++++++++++++++++++++++++++++++++++ nomadnet/NomadNetworkApp.py | 11 +++++++++++ nomadnet/ui/textui/Guide.py | 6 ++++++ 3 files changed, 56 insertions(+) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 2cba01b..aee392c 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -118,6 +118,19 @@ class Directory: def lxmf_announce_received(self, source_hash, app_data): if app_data != None: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self.announce_stream: + if announce[1] == source_hash: + remove_announces.append(announce) + + for a in remove_announces: + self.announce_stream.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + timestamp = time.time() self.announce_stream.insert(0, (timestamp, source_hash, app_data, "peer")) while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: @@ -128,6 +141,19 @@ class Directory: def node_announce_received(self, source_hash, app_data, associated_peer): if app_data != None: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self.announce_stream: + if announce[1] == source_hash: + remove_announces.append(announce) + + for a in remove_announces: + self.announce_stream.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + timestamp = time.time() self.announce_stream.insert(0, (timestamp, source_hash, app_data, "node")) while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: @@ -155,6 +181,19 @@ class Directory: break if not found_node: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self.announce_stream: + if announce[1] == source_hash: + remove_announces.append(announce) + + for a in remove_announces: + self.announce_stream.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + timestamp = time.time() self.announce_stream.insert(0, (timestamp, source_hash, app_data, "pn")) while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index cd11bfe..f17c31c 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -126,6 +126,7 @@ class NomadNetworkApp: self.periodic_lxmf_sync = True self.lxmf_sync_interval = 360*60 self.lxmf_sync_limit = 8 + self.compact_stream = False if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -698,6 +699,10 @@ class NomadNetworkApp: else: self.lxmf_sync_limit = None + if option == "compact_announce_stream": + value = self.config["client"].as_bool(option) + self.compact_stream = value + if option == "user_interface": value = value.lower() if value == "none": @@ -929,6 +934,12 @@ lxmf_sync_interval = 360 # the limit, and download everything every time. lxmf_sync_limit = 8 +# The announce stream will only show one entry +# per destination or node by default. You can +# change this to show as many announces as have +# been received, for every destination. +compact_announce_stream = yes + [textui] # Amount of time to show intro screen diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index c8c2cfc..954cbd2 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -506,6 +506,12 @@ The number of minutes between each automatic sync. The default is equal to 6 hou On low-bandwidth networks, it can be useful to limit the amount of messages downloaded in each sync. The default is 8. Set to 0 to download all available messages every time a sync occurs. < +>>> +`!compact_announce_stream = yes`! +>>>> +With this option enabled, Nomad Network will only display one entry in the announce stream per destination. Older announces are culled when a new one arrives. +< + >> Text UI Section This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows: From 34b3987ded73f18ab2cd0aab2f687d6107ce2a4a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 14 Aug 2023 18:02:39 +0200 Subject: [PATCH 180/592] Added error handling to micron parser mouse event translator. Fixes #32. --- nomadnet/ui/textui/MicronParser.py | 68 ++++++++++++++++-------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index f549509..3885a15 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -751,41 +751,45 @@ class LinkableText(urwid.Text): return x, y def mouse_event(self, size, event, button, x, y, focus): - if button != 1 or not is_mouse_press(event): - return False - else: - (maxcol,) = size - translation = self.get_line_translation(maxcol) - line_offset = 0 - - if self.align == "center": - line_offset = translation[y][1][1]-translation[y][0][0] - if x < translation[y][0][0]: - x = translation[y][0][0] - - if x > translation[y][1][0]+translation[y][0][0]: - x = translation[y][1][0]+translation[y][0][0] - - elif self.align == "right": - line_offset = translation[y][1][1]-translation[y][0][0] - if x < translation[y][0][0]: - x = translation[y][0][0] - + try: + if button != 1 or not is_mouse_press(event): + return False else: - line_offset = translation[y][0][1] - if x > translation[y][0][0]: - x = translation[y][0][0] + (maxcol,) = size + translation = self.get_line_translation(maxcol) + line_offset = 0 - pos = line_offset+x + if self.align == "center": + line_offset = translation[y][1][1]-translation[y][0][0] + if x < translation[y][0][0]: + x = translation[y][0][0] - self._cursor_position = pos - item = self.find_item_at_pos(self._cursor_position) + if x > translation[y][1][0]+translation[y][0][0]: + x = translation[y][1][0]+translation[y][0][0] - if item != None: - if isinstance(item, LinkSpec): - self.handle_link(item.link_target, item.link_fields) + elif self.align == "right": + line_offset = translation[y][1][1]-translation[y][0][0] + if x < translation[y][0][0]: + x = translation[y][0][0] - self._invalidate() - self._emit("change") + else: + line_offset = translation[y][0][1] + if x > translation[y][0][0]: + x = translation[y][0][0] + + pos = line_offset+x + + self._cursor_position = pos + item = self.find_item_at_pos(self._cursor_position) + + if item != None: + if isinstance(item, LinkSpec): + self.handle_link(item.link_target, item.link_fields) + + self._invalidate() + self._emit("change") + + return True - return True \ No newline at end of file + except Exception as e: + return False \ No newline at end of file From 81f65e345330ab3809bbd0f2a7e8acb721f18793 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 15 Aug 2023 11:13:31 +0200 Subject: [PATCH 181/592] Updated install section --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e8f8b25..170e859 100755 --- a/README.md +++ b/README.md @@ -30,7 +30,29 @@ The easiest way to install Nomad Network is via pip: ```bash # Install Nomad Network and dependencies -pip3 install nomadnet +pip install nomadnet + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +``` + +If you are using an operating system that blocks normal user package installation via `pip`, you can use the `pipx` tool to install Nomad Network instead: + +```bash +# Install Nomad Network +pipx install nomadnet + +# Optionally install Reticulum utilities +pipx install rns + +# Optionally install standalone LXMF utilities +pipx install lxmf # Run the client nomadnet From f36018632f35a75cb2bfd7830c890f35c330bef9 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 15 Aug 2023 11:25:46 +0200 Subject: [PATCH 182/592] Updated install section --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 170e859..165090d 100755 --- a/README.md +++ b/README.md @@ -42,7 +42,14 @@ nomadnet --daemon nomadnet --help ``` -If you are using an operating system that blocks normal user package installation via `pip`, you can use the `pipx` tool to install Nomad Network instead: +If you are using an operating system that blocks normal user package installation via `pip`, you can return `pip` to normal behaviour by adding editing the `~/.config/pip/pip.conf` file, and adding the following directive in the `[global]` section: + +```text +[global] +break-system-packages = true +``` + +Alternatively, you can use the `pipx` tool to install Nomad Network in an isolated environment: ```bash # Install Nomad Network From 062e31964ae5bca40658f382f409e343dc31136b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 15 Aug 2023 11:26:48 +0200 Subject: [PATCH 183/592] Updated install section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 165090d..a0861fa 100755 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ nomadnet --daemon nomadnet --help ``` -If you are using an operating system that blocks normal user package installation via `pip`, you can return `pip` to normal behaviour by adding editing the `~/.config/pip/pip.conf` file, and adding the following directive in the `[global]` section: +If you are using an operating system that blocks normal user package installation via `pip`, you can return `pip` to normal behaviour by editing the `~/.config/pip/pip.conf` file, and adding the following directive in the `[global]` section: ```text [global] From a5623978a26fd06298fd53b3a0eb10fa614dd49d Mon Sep 17 00:00:00 2001 From: Petr Blaha Date: Thu, 7 Sep 2023 10:19:38 +0200 Subject: [PATCH 184/592] dozkerize build --- Dockerfile.build | 32 ++++++++++++++++++++++++++++++++ Dockerfile.howto | 6 ++++++ requirements.txt | 6 ++++++ 3 files changed, 44 insertions(+) create mode 100644 Dockerfile.build create mode 100644 Dockerfile.howto create mode 100644 requirements.txt diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..3ffba8e --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,32 @@ +FROM python:alpine +LABEL authors="Petr Blaha petr.blaha@cleverdata.cz" +USER root +RUN apk update +RUN apk add build-base libffi-dev cargo pkgconfig linux-headers py3-virtualenv + +RUN addgroup -S myuser && adduser -S -G myuser myuser +USER myuser +WORKDIR /home/myuser + +RUN pip install --upgrade pip +RUN pip install setuptools-rust pyopenssl cryptography + + +ENV PATH="/home/myuser/.local/bin:${PATH}" + +################### BEGIN NomadNet ########################################### + +COPY --chown=myuser:myuser requirements.txt requirements.txt + +RUN pip install --user -r requirements.txt + + +COPY --chown=myuser:myuser . . + +#Python create virtual environment +RUN virtualenv /home/myuser/NomadNet/venv +RUN source /home/myuser/NomadNet/venv/bin/activate + +RUN make all + +################### END NomadNet ########################################### diff --git a/Dockerfile.howto b/Dockerfile.howto new file mode 100644 index 0000000..cfd4ee8 --- /dev/null +++ b/Dockerfile.howto @@ -0,0 +1,6 @@ +# Run docker command one by one(all four), it will build NomadNet artifact and copy to dist directory. +# No need to build locally and install dependencies +docker build -t nomadnetdockerimage -f Dockerfile.build . +docker run -d -it --name nomadnetdockercontainer nomadnetdockerimage /bin/sh +docker cp nomadnetdockercontainer:/home/myuser/dist . +docker rm -f nomadnetdockercontainer \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..884128f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +compiler==0.2.0 +configobj==5.0.8 +lxmf==0.3.2 +rns==0.5.7 +setuptools==68.0.0 +urwid==2.1.2 From 8d9f4956dba456763838d00282e9e0674f9f1492 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 19 Sep 2023 11:56:04 +0200 Subject: [PATCH 185/592] Improved request failure handling --- nomadnet/ui/textui/Browser.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 4cd6ce0..d5f3763 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -756,7 +756,7 @@ class Browser: def __load(self): # If an established link exists, but it doesn't match the target # destination, we close and clear it. - if self.link != None and self.link.destination.hash != self.destination_hash: + if self.link != None and (self.link.destination.hash != self.destination_hash or self.link.status != RNS.Link.ACTIVE): self.link.teardown() self.link = None @@ -1005,6 +1005,11 @@ class Browser: self.response_transfer_size = None self.update_display() + if self.link != None: + try: + self.link.teardown() + except Exception as e: + pass else: self.status = Browser.REQUEST_FAILED self.response_progress = 0 @@ -1012,6 +1017,11 @@ class Browser: self.response_transfer_size = None self.update_display() + if self.link != None: + try: + self.link.teardown() + except Exception as e: + pass def request_timeout(self, request_receipt=None): @@ -1021,6 +1031,11 @@ class Browser: self.response_transfer_size = None self.update_display() + if self.link != None: + try: + self.link.teardown() + except Exception as e: + pass def response_progressed(self, request_receipt): From 92c3c55e03b56464c233e794736c4c6d77431117 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 19 Sep 2023 11:56:24 +0200 Subject: [PATCH 186/592] Updated version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index d7b30e1..8879c6c 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.6" +__version__ = "0.3.7" diff --git a/setup.py b/setup.py index b5b5fad..6d011fa 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.5.7", "lxmf>=0.3.2", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.5.9", "lxmf>=0.3.3", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From da348c3b42d69281e7344f707d944cd9f1317891 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 19 Sep 2023 14:46:52 +0200 Subject: [PATCH 187/592] Improved propagation peer list display --- nomadnet/ui/TextUI.py | 4 ++++ nomadnet/ui/textui/Network.py | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 3f38744..9ecdddc 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -45,6 +45,8 @@ THEMES = { ("list_normal", "dark gray", "default", "default", "#bbb", "default"), ("list_untrusted", "dark red", "default", "default", "#a22", "default"), ("list_focus_untrusted", "black", "light gray", "standout", "#810", "#aaa"), + ("list_unresponsive", "yellow", "default", "default", "#b92", "default"), + ("list_focus_unresponsive", "black", "light gray", "standout", "#530", "#aaa"), ("topic_list_normal", "light gray", "default", "default", "#ddd", "default"), ("browser_controls", "light gray", "default", "default", "#bbb", "default"), ("progress_full", "black", "light gray", "standout", "#111", "#bbb"), @@ -78,6 +80,8 @@ THEMES = { ("list_normal", "dark gray", "default", "default", "#444", "default"), ("list_untrusted", "dark red", "default", "default", "#a22", "default"), ("list_focus_untrusted", "black", "dark gray", "standout", "#810", "#aaa"), + ("list_unresponsive", "yellow", "default", "default", "#b92", "default"), + ("list_focus_unresponsive", "black", "light gray", "standout", "#530", "#aaa"), ("topic_list_normal", "dark gray", "default", "default", "#222", "default"), ("browser_controls", "dark gray", "default", "default", "#444", "default"), ("progress_full", "black", "dark gray", "standout", "#111", "#bbb"), diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 7de57eb..cbbf880 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1576,7 +1576,11 @@ class LXMFPeers(urwid.WidgetWrap): self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no LXMF nodes are peered\n\n"), align="center")]) self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="LXMF Propagation Peers"), widget_style)) + if hasattr(self, "peer_list") and self.peer_list: + pl = len(self.peer_list) + else: + pl = 0 + urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title=f"LXMF Propagation Peers ({pl})"), widget_style)) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): @@ -1611,13 +1615,13 @@ class LXMFPeers(urwid.WidgetWrap): def make_peer_widgets(self): widget_list = [] - for peer_id in self.peer_list: + sorted_peers = sorted(self.peer_list, key=lambda pid: self.peer_list[pid].link_establishment_rate, reverse=True) + for peer_id in sorted_peers: peer = self.peer_list[peer_id] pe = LXMFPeerEntry(self.app, peer, self) pe.destination_hash = peer.destination_hash widget_list.append(pe) - # TODO: Sort list return widget_list class LXMFPeerEntry(urwid.WidgetWrap): @@ -1633,7 +1637,7 @@ class LXMFPeerEntry(urwid.WidgetWrap): node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", node_identity) display_name = self.app.directory.alleged_display_str(node_hash) if display_name != None: - display_str += " "+str(display_name) + display_str = str(display_name)+"\n "+display_str sym = g["sent"] style = "list_unknown" @@ -1643,8 +1647,12 @@ class LXMFPeerEntry(urwid.WidgetWrap): if hasattr(peer, "alive"): if peer.alive: alive_string = "Available" + style = "list_normal" + focus_style = "list_focus" else: alive_string = "Unresponsive" + style = "list_unresponsive" + focus_style = "list_focus_unresponsive" widget = ListEntry(sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs, "+RNS.prettysize(peer.link_establishment_rate/8, "b")+"/s LER") # urwid.connect_signal(widget, "click", delegate.connect_node, node) From c52efbfb753cf5ad0b8af8808343c96608f94846 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 Sep 2023 22:58:00 +0200 Subject: [PATCH 188/592] Temporarily fixed urwid at 2.1.2 due to breaking changes in 2.2.0 --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 8879c6c..4ad67eb 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.7" +__version__ = "0.3.8" diff --git a/setup.py b/setup.py index 6d011fa..0563419 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.5.9", "lxmf>=0.3.3", "urwid>=2.1.2", "qrcode"], + install_requires=["rns>=0.5.9", "lxmf>=0.3.3", "urwid==2.1.2", "qrcode"], python_requires=">=3.6", ) From c670aa82ad7d4e25dd76e2c1b18dfccdc21b9d2d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 Sep 2023 12:03:00 +0200 Subject: [PATCH 189/592] Updated RNS dependency --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0563419..dc0e489 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.5.9", "lxmf>=0.3.3", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.6.0", "lxmf>=0.3.3", "urwid==2.1.2", "qrcode"], python_requires=">=3.6", ) From c411ce703f42c6d42bbb63deae8f0b0cb6333a49 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 3 Oct 2023 11:53:52 +0200 Subject: [PATCH 190/592] Fixed incorrect checkbox display in UI --- nomadnet/ui/textui/Network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index cbbf880..bbc82a4 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -586,7 +586,7 @@ class KnownNodeInfo(urwid.WidgetWrap): def ident_change(sender, userdata): pass - propagation_node_checkbox = urwid.CheckBox("Use as default propagation node", state=(self.app.get_user_selected_propagation_node() == source_hash), on_state_change=pn_change) + propagation_node_checkbox = urwid.CheckBox("Use as default propagation node", state=(self.app.get_user_selected_propagation_node() == pn_hash), on_state_change=pn_change) connect_identify_checkbox = urwid.CheckBox("Identify when connecting", state=self.app.directory.should_identify_on_connect(source_hash), on_state_change=ident_change) def save_node(sender): From 72a5ed9c8ef680571275b7ebfd4c5f85b08aae03 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 3 Oct 2023 11:54:09 +0200 Subject: [PATCH 191/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 4ad67eb..771bc6e 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.8" +__version__ = "0.3.9" From 2aa5c6f66500175a084ab631a30dcff6464edf0b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 7 Oct 2023 16:44:09 +0200 Subject: [PATCH 192/592] Updated version and dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index dc0e489..85b096c 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.6.0", "lxmf>=0.3.3", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.6.2", "lxmf>=0.3.4", "urwid==2.1.2", "qrcode"], python_requires=">=3.6", ) From cfc0f4f9c180fb7059492d69a731e5f7601bb983 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 15 Oct 2023 20:35:08 +0200 Subject: [PATCH 193/592] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a0861fa..ccc3989 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Off-grid, resilient mesh communication with strong encryption, forward secrecy a ![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) -Nomad Network Allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. +Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. Nomad Network is build on [LXMF](https://github.com/markqvist/LXMF) and [Reticulum](https://github.com/markqvist/Reticulum), which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. From 0cd1c9cf37fd2a0633a157f39598a22dd7ccbbc8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 15 Oct 2023 20:35:21 +0200 Subject: [PATCH 194/592] Added paper message output options --- nomadnet/Conversation.py | 45 ++++++++++++---- nomadnet/ui/textui/Conversations.py | 83 ++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 35b8cf5..086da2e 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -227,7 +227,7 @@ class Conversation: RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) return False - def paper_output(self, content="", title=""): + def paper_output(self, content="", title="", mode="print_qr"): if self.send_destination: try: dest = self.send_destination @@ -235,18 +235,45 @@ class Conversation: desired_method = LXMF.LXMessage.PAPER lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method) - qr_code = lxm.as_qr() - qr_tmp_path = self.app.tmpfilespath+"/"+str(RNS.hexrep(lxm.hash, delimit=False)) - qr_code.save(qr_tmp_path) - print_result = self.app.print_file(qr_tmp_path) - os.unlink(qr_tmp_path) - - if print_result: + if mode == "print_qr": + qr_code = lxm.as_qr() + qr_tmp_path = self.app.tmpfilespath+"/"+str(RNS.hexrep(lxm.hash, delimit=False)) + qr_code.save(qr_tmp_path) + + print_result = self.app.print_file(qr_tmp_path) + os.unlink(qr_tmp_path) + + if print_result: + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + + return print_result + + elif mode == "save_qr": + qr_code = lxm.as_qr() + qr_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".png") + qr_code.save(qr_save_path) message_path = Conversation.ingest(lxm, self.app, originator=True) self.messages.append(ConversationMessage(message_path)) + return qr_save_path - return print_result + elif mode == "save_uri": + lxm_uri = lxm.as_uri() + uri_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".txt") + with open(uri_save_path, "wb") as f: + f.write(lxm_uri.encode("utf-8")) + + # TODO: Remove after LXMF 0.3.5 #### + lxm.determine_transport_encryption() + #################################### + + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + return uri_save_path + + elif mode == "return_uri": + return lxm.as_uri() except Exception as e: RNS.log("An error occurred while generating paper message, the contained exception was: "+str(e), RNS.LOG_ERROR) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 3929bd1..ea52568 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -974,7 +974,27 @@ class ConversationWidget(urwid.WidgetWrap): else: pass - def paper_message(self): + def paper_message_saved(self, path): + g = self.app.ui.glyphs + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("The paper message was saved to:\n\n"+str(path)+"\n", align="center"), + urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + ]), title=g["page"] + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align="center", width=60, valign="middle", height="pack", left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.set_focus("body") + + def print_paper_message_qr(self): content = self.content_editor.get_edit_text() title = self.title_editor.get_edit_text() if not content == "": @@ -983,6 +1003,67 @@ class ConversationWidget(urwid.WidgetWrap): else: self.paper_message_failed() + def save_paper_message_qr(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + output_result = self.conversation.paper_output(content, title, mode="save_qr") + if output_result != False: + self.clear_editor() + self.paper_message_saved(output_result) + else: + self.paper_message_failed() + + def save_paper_message_uri(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + output_result = self.conversation.paper_output(content, title, mode="save_uri") + if output_result != False: + self.clear_editor() + self.paper_message_saved(output_result) + else: + self.paper_message_failed() + + def paper_message(self): + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + def print_qr(sender): + dismiss_dialog(self) + self.print_paper_message_qr() + + def save_qr(sender): + dismiss_dialog(self) + self.save_paper_message_qr() + + def save_uri(sender): + dismiss_dialog(self) + self.save_paper_message_uri() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("Select the desired paper message output method.\nSaved files will be written to:\n\n"+str(self.app.downloads_path)+"\n", align="center"), + urwid.Columns([ + ("weight", 0.5, urwid.Button("Print QR", on_press=print_qr)), + ("weight", 0.1, urwid.Text("")), + ("weight", 0.5, urwid.Button("Save QR", on_press=save_qr)), + ("weight", 0.1, urwid.Text("")), + ("weight", 0.5, urwid.Button("Save URI", on_press=save_uri)), + ("weight", 0.1, urwid.Text("")), + ("weight", 0.5, urwid.Button("Cancel", on_press=dismiss_dialog)) + ]) + ]), title="Create Paper Message" + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align="center", width=60, valign="middle", height="pack", left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.set_focus("body") + def paper_message_failed(self): def dismiss_dialog(sender): self.dialog_open = False From 5c1a2c348509d9988f84369e13bb2e27200999b7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 15 Oct 2023 21:00:42 +0200 Subject: [PATCH 195/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 771bc6e..6a9beea 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.3.9" +__version__ = "0.4.0" From a921adbdbe66bc8247c38d54af5cee108a713a6e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 15 Oct 2023 21:00:47 +0200 Subject: [PATCH 196/592] Cleanup --- nomadnet/Conversation.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 086da2e..4f78ad9 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -263,10 +263,6 @@ class Conversation: uri_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".txt") with open(uri_save_path, "wb") as f: f.write(lxm_uri.encode("utf-8")) - - # TODO: Remove after LXMF 0.3.5 #### - lxm.determine_transport_encryption() - #################################### message_path = Conversation.ingest(lxm, self.app, originator=True) self.messages.append(ConversationMessage(message_path)) From 37ad5cc5d3c7a053a253df0d9186f834b0538add Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 15 Oct 2023 21:01:39 +0200 Subject: [PATCH 197/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 85b096c..ca8b4eb 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.6.2", "lxmf>=0.3.4", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.6.2", "lxmf>=0.3.5", "urwid==2.1.2", "qrcode"], python_requires=">=3.6", ) From 2322a254a85b150d720aeb4c61ea993f0bee743f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 15 Oct 2023 22:53:56 +0200 Subject: [PATCH 198/592] LXM URI file output --- nomadnet/Conversation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 4f78ad9..253a50a 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -259,7 +259,7 @@ class Conversation: return qr_save_path elif mode == "save_uri": - lxm_uri = lxm.as_uri() + lxm_uri = lxm.as_uri()+"\n" uri_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".txt") with open(uri_save_path, "wb") as f: f.write(lxm_uri.encode("utf-8")) From 0fb9e180ba18de111699d9a439e59df39dfc97bd Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 16 Oct 2023 00:16:04 +0200 Subject: [PATCH 199/592] Added node list sorting --- nomadnet/Directory.py | 29 ++++++++++++++++++++++++----- nomadnet/ui/textui/Conversations.py | 2 +- nomadnet/ui/textui/Network.py | 18 +++++++++++++++++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index aee392c..dc1ed68 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -66,7 +66,7 @@ class Directory: packed_list = [] for source_hash in self.directory_entries: e = self.directory_entries[source_hash] - packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify)) + packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank)) directory = { "entry_list": packed_list, @@ -106,13 +106,17 @@ class Directory: else: identify = False - entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify) + if len(e) > 6: + sort_rank = e[6] + else: + sort_rank = None + + entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank) self.directory_entries = entries self.announce_stream = unpacked_directory["announce_stream"] - except Exception as e: RNS.log("Could not load directory from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -220,7 +224,14 @@ class Directory: def simplest_display_str(self, source_hash): trust_level = self.trust_level(source_hash) if trust_level == DirectoryEntry.WARNING or trust_level == DirectoryEntry.UNTRUSTED: - return "<"+RNS.hexrep(source_hash, delimit=False)+">" + if source_hash in self.directory_entries: + dn = self.directory_entries[source_hash].display_name + if dn == None: + return RNS.prettyhexrep(source_hash) + else: + return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">" + else: + return "<"+RNS.hexrep(source_hash, delimit=False)+">" else: if source_hash in self.directory_entries: dn = self.directory_entries[source_hash].display_name @@ -253,6 +264,12 @@ class Directory: else: return DirectoryEntry.UNKNOWN + def sort_rank(self, source_hash): + if source_hash in self.directory_entries: + return self.directory_entries[source_hash].sort_rank + else: + return None + def preferred_delivery(self, source_hash): if source_hash in self.directory_entries: return self.directory_entries[source_hash].preferred_delivery @@ -312,6 +329,7 @@ class Directory: if e.hosts_node: node_list.append(e) + node_list.sort(key = lambda e: (e.sort_rank if e.sort_rank != None else 2^32, DirectoryEntry.TRUSTED-e.trust_level, e.display_name)) return node_list def number_of_known_nodes(self): @@ -336,10 +354,11 @@ class DirectoryEntry: DIRECT = 0x01 PROPAGATED = 0x02 - def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False): + def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None): if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: self.source_hash = source_hash self.display_name = display_name + self.sort_rank = sort_rank if preferred_delivery == None: self.preferred_delivery = DirectoryEntry.DIRECT diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index ea52568..e942fae 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -984,7 +984,7 @@ class ConversationWidget(urwid.WidgetWrap): urwid.Pile([ urwid.Text("The paper message was saved to:\n\n"+str(path)+"\n", align="center"), urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) - ]), title=g["page"] + ]), title=g["papermsg"].replace(" ", "") ) dialog.delegate = self bottom = self.messagelist diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index bbc82a4..6d54762 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -480,6 +480,12 @@ class KnownNodeInfo(urwid.WidgetWrap): trust_level = self.app.directory.trust_level(source_hash) trust_str = "" node_entry = self.app.directory.find(source_hash) + sort_str = self.app.directory.sort_rank(source_hash) + if sort_str == None: + sort_str = "None" + else: + sort_str = str(sort_str) + if node_entry == None: display_str = self.app.directory.simplest_display_str(source_hash) else: @@ -540,6 +546,7 @@ class KnownNodeInfo(urwid.WidgetWrap): r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) e_name = urwid.Edit(caption="Name : ",edit_text=display_str) + e_sort = urwid.Edit(caption="Sort Rank : ",edit_text=sort_str) node_ident = RNS.Identity.recall(source_hash) op_hash = None @@ -604,8 +611,16 @@ class KnownNodeInfo(urwid.WidgetWrap): trust_level = DirectoryEntry.TRUSTED display_str = e_name.get_edit_text() + sort_rank = e_sort.get_edit_text() + try: + if int(sort_rank) >= 0: + sort_rank = int(sort_rank) + else: + sort_rank = None + except: + sort_rank = None - node_entry = DirectoryEntry(source_hash, display_name=display_str, trust_level=trust_level, hosts_node=True, identify_on_connect=connect_identify_checkbox.get_state()) + node_entry = DirectoryEntry(source_hash, display_name=display_str, trust_level=trust_level, hosts_node=True, identify_on_connect=connect_identify_checkbox.get_state(), sort_rank=sort_rank) self.app.directory.remember(node_entry) self.app.ui.main_display.sub_displays.network_display.directory_change_callback() @@ -626,6 +641,7 @@ class KnownNodeInfo(urwid.WidgetWrap): urwid.Text("Type : "+type_string, align="left"), e_name, urwid.Text("Node Addr : "+addr_str, align="left"), + e_sort, urwid.Divider(g["divider1"]), urwid.Text(lxmf_addr_str, align="center"), urwid.Divider(g["divider1"]), From 8d72a83411a7a3890c905ce919dd267d61471d82 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 16 Oct 2023 00:51:23 +0200 Subject: [PATCH 200/592] Added option to disable propagation node for pageserving-only nodes --- nomadnet/NomadNetworkApp.py | 42 ++++++++++++++++++++++++++++------- nomadnet/ui/textui/Guide.py | 6 +++++ nomadnet/ui/textui/Network.py | 33 ++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index f17c31c..c966665 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -122,6 +122,7 @@ class NomadNetworkApp: self.peer_announce_at_start = True self.try_propagation_on_fail = True + self.disable_propagation = False self.periodic_lxmf_sync = True self.lxmf_sync_interval = 360*60 @@ -308,15 +309,27 @@ class NomadNetworkApp: except Exception as e: RNS.log("Cannot prioritise "+str(dest_str)+", it is not a valid destination hash", RNS.LOG_ERROR) - self.message_router.enable_propagation() - try: - with open(self.pnannouncedpath, "wb") as pnf: - pnf.write(msgpack.packb(time.time())) - pnf.close() - except Exception as e: - RNS.log("An error ocurred while writing Propagation Node announce timestamp. The contained exception was: "+str(e), RNS.LOG_ERROR) + if self.disable_propagation: + if os.path.isfile(self.pnannouncedpath): + try: + RNS.log("Sending indication to peered LXMF Propagation Node that this node is no longer participating", RNS.LOG_DEBUG) + self.message_router.disable_propagation() + os.unlink(self.pnannouncedpath) + except Exception as e: + RNS.log("An error ocurred while indicating that this LXMF Propagation Node is no longer participating. The contained exception was: "+str(e), RNS.LOG_ERROR) + else: + self.message_router.enable_propagation() + try: + with open(self.pnannouncedpath, "wb") as pnf: + pnf.write(msgpack.packb(time.time())) + pnf.close() - RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) + except Exception as e: + RNS.log("An error ocurred while writing Propagation Node announce timestamp. The contained exception was: "+str(e), RNS.LOG_ERROR) + + if not self.disable_propagation: + RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) + self.node = nomadnet.Node(self) else: self.node = None @@ -788,6 +801,11 @@ class NomadNetworkApp: else: self.node_name = self.config["node"]["node_name"] + if not "disable_propagation" in self.config["node"]: + self.disable_propagation = False + else: + self.disable_propagation = self.config["node"].as_bool("disable_propagation") + if not "announce_at_start" in self.config["node"]: self.node_announce_at_start = False else: @@ -1003,6 +1021,14 @@ announce_interval = 360 # Whether to announce when the node starts. announce_at_start = Yes +# By default, when Nomad Network is hosting a +# node, it will also act as an LXMF propagation +# node. If there is already a large amount of +# propagation nodes on the network, or you +# simply want to run a pageserving-only node, +# you can disable running a propagation node. +# disable_propagation = False + # The maximum amount of storage to use for # the LXMF Propagation Node message store, # specified in megabytes. When this limit diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 954cbd2..67aa856 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -620,6 +620,12 @@ Determines where the node server will look for hosted pages. Must be a readable Determines where the node server will look for downloadable files. Must be a readable filesystem path. < +>>> +`!disable_propagation = no`! +>>>> +By default, when Nomad Network is hosting a node, it will also run an LXMF propagation node. If there is already a large amount of propagation nodes on the network, or you simply want to run a pageserving-only node, you can disable running a propagation node. +< + >>> `!message_storage_limit = 2000`! >>>> diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 6d54762..56b7d3b 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -977,12 +977,12 @@ class NodeStorageStats(urwid.WidgetWrap): def update_stat(self): self.stat_string = "None" - if self.app.node != None: + if self.app.node != None and not self.app.disable_propagation: limit = self.app.message_router.message_storage_limit used = self.app.message_router.message_storage_size() - if limit != None: + if limit != None and used != None: pct = round((used/limit)*100, 1) pct_str = str(pct)+"%, " limit_str = " of "+RNS.prettysize(limit) @@ -1313,12 +1313,35 @@ class NodeInfo(urwid.WidgetWrap): connect_button = urwid.Button("Browse", on_press=connect_query) reset_button = urwid.Button("Rst Stats", on_press=stats_query) - pile = urwid.Pile([ + if not self.app.disable_propagation: + pile = urwid.Pile([ + t_id, + e_name, + urwid.Divider(g["divider1"]), + e_lxmf, + urwid.Divider(g["divider1"]), + self.t_last_announce, + self.t_storage_stats, + self.t_active_links, + self.t_total_connections, + self.t_total_pages, + self.t_total_files, + urwid.Divider(g["divider1"]), + urwid.Columns([ + ("weight", 5, urwid.Button("Back", on_press=show_peer_info)), + ("weight", 0.5, urwid.Text("")), + ("weight", 6, connect_button), + ("weight", 0.5, urwid.Text("")), + ("weight", 8, reset_button), + ("weight", 0.5, urwid.Text("")), + ("weight", 7, announce_button), + ]) + ]) + else: + pile = urwid.Pile([ t_id, e_name, urwid.Divider(g["divider1"]), - e_lxmf, - urwid.Divider(g["divider1"]), self.t_last_announce, self.t_storage_stats, self.t_active_links, From a3bf538afe7f1c414d028e177659199a767f050c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 16 Oct 2023 01:46:50 +0200 Subject: [PATCH 201/592] Added propagation node list sorting and manual delivery sync initiation --- nomadnet/Directory.py | 6 ++++ nomadnet/ui/textui/Guide.py | 29 +++++++++++------- nomadnet/ui/textui/Network.py | 57 ++++++++++++++++++++++++++++++----- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index dc1ed68..1b9e876 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -264,6 +264,12 @@ class Directory: else: return DirectoryEntry.UNKNOWN + def pn_trust_level(self, source_hash): + recalled_identity = RNS.Identity.recall(source_hash) + if recalled_identity != None: + associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", recalled_identity) + return self.trust_level(associated_node) + def sort_rank(self, source_hash): if source_hash in self.directory_entries: return self.directory_entries[source_hash].sort_rank diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 67aa856..1b64a4e 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -217,6 +217,15 @@ The different sections of the program has a number of keyboard shortcuts mapped, - Ctrl-W Close conversation >>`!Network Window`! +>>>Browser + - Ctrl-D Back + - Ctrl-F Forward + - Ctrl-R Reload page + - Ctrl-U Open URL entry dialog + - Ctrl-S Save connected node + - Ctrl-G Toggle fullscreen browser window + - Ctrl-W Disconnect from node + >>>Announce Stream - Ctrl-L Switch to Known Nodes list - Ctrl-X Delete selected announce @@ -227,14 +236,10 @@ The different sections of the program has a number of keyboard shortcuts mapped, - Ctrl-X Delete selected node entry - Ctrl-P Display peered LXMF Propagation Nodes ->>>Browser - - Ctrl-D Back - - Ctrl-F Forward - - Ctrl-R Reload page - - Ctrl-U Open URL entry dialog - - Ctrl-S Save connected node - - Ctrl-G Toggle fullscreen browser window - - Ctrl-W Disconnect from node +>>>Peered LXMF Propagation Nodes + - Ctrl-L Switch to Announce Stream or Known Nodes + - Ctrl-X Break peering with selected node entry + - Ctrl-R Request immediate delivery sync of unhandled LXMs ''' TOPIC_CONCEPTS = '''>Concepts and Terminology @@ -302,11 +307,13 @@ By default, no content is defined, apart from a short placeholder home page. To >>Distributed Message Store -All nodes on the network automatically form a distributed message store that allows users to exchange messages, even when they are not available at the same time. +All nodes on the network will automatically participate in a distributed message store that allows users to exchange messages, even when they are not connected to the network at the same time. -When Nomad Network is configured to host a node, it also configures itself as an LXMF Propagation Node, and automatically discovers and peers with other propagation nodes on the network. This process is completely automatic and requires no configuration from the node operator. +When Nomad Network is configured to host a node, by default it also configures itself as an LXMF Propagation Node, and automatically discovers and peers with other propagation nodes on the network. This process is completely automatic and requires no configuration from the node operator. -To view LXMF Propagation nodes that are currently peered with your node, go to the `![ Network ]`! part of the program and press `!Ctrl-P`!. +If there is already an abundance of Propagation Nodes on the network, or the operator simply wishes to host a pageserving-only node, Propagation Node hosting can be disabled in the configuration file. + +To view LXMF Propagation nodes that are currently peered with your node, go to the `![ Network ]`! part of the program and press `!Ctrl-P`!. In the list of peered Propagation Nodes, it is possible to break peering with a node by pressing `!Ctrl-X`!. It is also possible to request an immediate delivery sync of all unhandled messages for a node, by pressing `!Ctrl-R`!. The distributed message store is resilient to intermittency, and will remain functional as long as at least one node remains on the network. Nodes that were offline for a time will automatically be synced up to date when they regain connectivity. diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 56b7d3b..19ef8af 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -2,6 +2,7 @@ import RNS import urwid import nomadnet import time +import threading from datetime import datetime from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY @@ -1626,6 +1627,8 @@ class LXMFPeers(urwid.WidgetWrap): nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") elif key == "ctrl x": self.delete_selected_entry() + elif key == "ctrl r": + self.sync_selected_entry() return super(LXMFPeers, self).keypress(size, key) @@ -1641,6 +1644,43 @@ class LXMFPeers(urwid.WidgetWrap): self.delegate.reinit_lxmf_peers() self.delegate.show_peers() + def sync_selected_entry(self): + sync_grace = 10 + si = self.ilb.get_selected_item() + if si != None: + destination_hash = si.original_widget.destination_hash + if destination_hash in self.app.message_router.peers: + peer = self.app.message_router.peers[destination_hash] + if time.time() > peer.last_sync_attempt+sync_grace: + peer.next_sync_attempt = time.time()-1 + + def job(): + peer.sync() + threading.Thread(target=job, daemon=True).start() + + time.sleep(0.25) + + def dismiss_dialog(sender): + self.close_list_dialogs() + + dialog = ListDialogLineBox( + urwid.Pile([ + urwid.Text("A delivery sync of all unhandled LXMs was manually requested for the selected node\n", align="center"), + urwid.Columns([("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("OK", on_press=dismiss_dialog))]) + ]), title="!" + ) + dialog.delegate = self.delegate + bottom = self + + overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + + options = self.delegate.left_pile.options("weight", 1) + self.delegate.left_pile.contents[0] = (overlay, options) + + + def close_list_dialogs(self): + self.delegate.reinit_lxmf_peers() + self.delegate.show_peers() def rebuild_widget_list(self): self.peer_list = self.app.message_router.peers @@ -1654,17 +1694,18 @@ class LXMFPeers(urwid.WidgetWrap): def make_peer_widgets(self): widget_list = [] - sorted_peers = sorted(self.peer_list, key=lambda pid: self.peer_list[pid].link_establishment_rate, reverse=True) + sorted_peers = sorted(self.peer_list, key=lambda pid: (self.app.directory.pn_trust_level(pid), self.peer_list[pid].link_establishment_rate), reverse=True) for peer_id in sorted_peers: peer = self.peer_list[peer_id] - pe = LXMFPeerEntry(self.app, peer, self) + trust_level = self.app.directory.pn_trust_level(peer_id) + pe = LXMFPeerEntry(self.app, peer, self, trust_level) pe.destination_hash = peer.destination_hash widget_list.append(pe) return widget_list class LXMFPeerEntry(urwid.WidgetWrap): - def __init__(self, app, peer, delegate): + def __init__(self, app, peer, delegate, trust_level): destination_hash = peer.destination_hash self.app = app @@ -1686,16 +1727,18 @@ class LXMFPeerEntry(urwid.WidgetWrap): if hasattr(peer, "alive"): if peer.alive: alive_string = "Available" - style = "list_normal" - focus_style = "list_focus" + if trust_level == DirectoryEntry.TRUSTED: + style = "list_trusted" + focus_style = "list_focus_trusted" + else: + style = "list_normal" + focus_style = "list_focus" else: alive_string = "Unresponsive" style = "list_unresponsive" focus_style = "list_focus_unresponsive" widget = ListEntry(sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs, "+RNS.prettysize(peer.link_establishment_rate/8, "b")+"/s LER") - # urwid.connect_signal(widget, "click", delegate.connect_node, node) - self.display_widget = urwid.AttrMap(widget, style, focus_style) self.display_widget.destination_hash = destination_hash urwid.WidgetWrap.__init__(self, self.display_widget) From 5a7c84d2a4c360ef869f452e8a11ce4607fb84b5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 16 Oct 2023 01:50:56 +0200 Subject: [PATCH 202/592] Updated LXMF version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ca8b4eb..15b31a2 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.6.2", "lxmf>=0.3.5", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.6.2", "lxmf>=0.3.6", "urwid==2.1.2", "qrcode"], python_requires=">=3.6", ) From efd1d083994233f67059c48aab00213167a34546 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 27 Oct 2023 20:41:18 +0200 Subject: [PATCH 203/592] Improved LXMF sync feedback --- nomadnet/NomadNetworkApp.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index c966665..a78eed0 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -427,16 +427,24 @@ class NomadNetworkApp: return "Receiving messages" elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: return "Messages received" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_PATH: + return "No path to node" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_FAILED: + return "Link establisment failed" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_TRANSFER_FAILED: + return "Sync request failed" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_IDENTITY_RCVD: + return "Remote got no identity" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_ACCESS: + return "Node rejected request" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_FAILED: + return "Sync failed" elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: new_msgs = self.message_router.propagation_transfer_last_result if new_msgs == 0: return "Done, no new messages" else: return "Downloaded "+str(new_msgs)+" new messages" - elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_IDENTITY_RCVD: - return "Node did not receive identification" - elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_ACCESS: - return "Node did not allow request" else: return "Unknown" From df9fccf19980514308ab47f04d9dcdc25cfb8eb8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 27 Oct 2023 20:42:03 +0200 Subject: [PATCH 204/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 6a9beea..3d26edf 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.4.1" From 082026ca1b5cb858fcf510b1d485eb318a673b68 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 27 Oct 2023 20:43:31 +0200 Subject: [PATCH 205/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 15b31a2..1a11b3c 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.6.2", "lxmf>=0.3.6", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.6.3", "lxmf>=0.3.7", "urwid==2.1.2", "qrcode"], python_requires=">=3.6", ) From 82bb479957bf31f7ad5852b1baaa6008593fdade Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 2 Nov 2023 12:25:36 +0100 Subject: [PATCH 206/592] Adjusted timeouts --- nomadnet/Conversation.py | 2 +- nomadnet/ui/textui/Browser.py | 4 ++-- nomadnet/ui/textui/Conversations.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 253a50a..a79d60b 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -142,7 +142,7 @@ class Conversation: self.__changed_callback = None - if not RNS.Transport.has_path(bytes.fromhex(source_hash)): + if not RNS.Identity.recall(bytes.fromhex(self.source_hash)): RNS.Transport.request_path(bytes.fromhex(source_hash)) self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index d5f3763..54afaa7 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -517,7 +517,7 @@ class Browser: self.status = Browser.PATH_REQUESTED self.update_display() - pr_time = time.time() + pr_time = time.time()+RNS.Transport.first_hop_timeout(self.destination_hash) while not RNS.Transport.has_path(self.destination_hash): now = time.time() if now > pr_time+self.timeout: @@ -770,7 +770,7 @@ class Browser: self.status = Browser.PATH_REQUESTED self.update_display() - pr_time = time.time() + pr_time = time.time()+RNS.Transport.first_hop_timeout(self.destination_hash) while not RNS.Transport.has_path(self.destination_hash): now = time.time() if now > pr_time+self.timeout: diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index e942fae..5417fe2 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -894,7 +894,7 @@ class ConversationWidget(urwid.WidgetWrap): if allowed: self.frame.contents["footer"] = (self.minimal_editor, None) else: - warning = urwid.AttrMap(urwid.Padding(urwid.Text("\n"+g["info"]+"\n\nYou cannot currently message this peer, since it's identity keys are not known.\n\nWait for an announce to arrive from the peer, or query the network for it.\n\nTo query the network, select this conversation in the conversation list, press Ctrl-E, and use the query button.\n", align="center")), "msg_header_caution") + warning = urwid.AttrMap(urwid.Padding(urwid.Text("\n"+g["info"]+"\n\nYou cannot currently message this peer, since it's identity keys are not known. The keys have been requested from the network and should arrive shortly, if available. Close this conversation and reopen it to try again.\n\nWait for this announce to arrive from, or query the network manually.\n\nTo query the network, select this conversation in the conversation list, press Ctrl-E, and use the query button.\n", align="center")), "msg_header_caution") self.frame.contents["footer"] = (warning, None) def toggle_focus_area(self): From 89dd17ece56bd7afe5d3fcda621190de5a44b5ec Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 2 Nov 2023 12:48:40 +0100 Subject: [PATCH 207/592] Updated version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 3d26edf..df12433 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.1" +__version__ = "0.4.2" diff --git a/setup.py b/setup.py index 1a11b3c..abe8212 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.6.3", "lxmf>=0.3.7", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.6.4", "lxmf>=0.3.8", "urwid==2.1.2", "qrcode"], python_requires=">=3.6", ) From 276a5f56e1b1f8db92757180eb27204a7ed463ec Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 2 Nov 2023 18:30:23 +0100 Subject: [PATCH 208/592] Changed text --- nomadnet/ui/textui/Conversations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 5417fe2..9bb8e02 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -894,7 +894,7 @@ class ConversationWidget(urwid.WidgetWrap): if allowed: self.frame.contents["footer"] = (self.minimal_editor, None) else: - warning = urwid.AttrMap(urwid.Padding(urwid.Text("\n"+g["info"]+"\n\nYou cannot currently message this peer, since it's identity keys are not known. The keys have been requested from the network and should arrive shortly, if available. Close this conversation and reopen it to try again.\n\nWait for this announce to arrive from, or query the network manually.\n\nTo query the network, select this conversation in the conversation list, press Ctrl-E, and use the query button.\n", align="center")), "msg_header_caution") + warning = urwid.AttrMap(urwid.Padding(urwid.Text("\n"+g["info"]+"\n\nYou cannot currently message this peer, since it's identity keys are not known. The keys have been requested from the network and should arrive shortly, if available. Close this conversation and reopen it to try again.\n\nTo query the network manually, select this conversation in the conversation list, press Ctrl-E, and use the query button.\n", align="center")), "msg_header_caution") self.frame.contents["footer"] = (warning, None) def toggle_focus_area(self): From 19d1d8504f5c2546fb62f9d6cacb99b6e949203a Mon Sep 17 00:00:00 2001 From: faragher Date: Sun, 26 Nov 2023 15:51:47 -0600 Subject: [PATCH 209/592] Possible fix for Directory error --- nomadnet/Directory.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 1b9e876..fd8cacb 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -90,7 +90,10 @@ class Directory: entries = {} for e in unpacked_list: - + + if e[1] == None: + e[1] = "Undefined" + if len(e) > 3: hosts_node = e[3] else: @@ -375,4 +378,4 @@ class DirectoryEntry: self.hosts_node = hosts_node self.identify = identify_on_connect else: - raise TypeError("Attempt to add invalid source hash to directory") \ No newline at end of file + raise TypeError("Attempt to add invalid source hash to directory") From e022d469f83b3074e9b4d8dd7acedf1081696718 Mon Sep 17 00:00:00 2001 From: faragher Date: Wed, 29 Nov 2023 03:23:35 -0600 Subject: [PATCH 210/592] Made save paths relative --- nomadnet/ui/textui/Browser.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 54afaa7..fcb7492 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -976,7 +976,10 @@ class Browser: try: file_name = request_receipt.response[0] file_data = request_receipt.response[1] - file_destination = self.app.downloads_path+"/"+file_name + file_destination_name = file_name.split("/") + file_destination_name = file_destination_name[len(file_destination_name)-1] + file_destination = self.app.downloads_path+"/"+file_destination_name + counter = 0 while os.path.isfile(file_destination): From 2449b39f778a600ac5da058f4592b89e93dbd30b Mon Sep 17 00:00:00 2001 From: faragher Date: Wed, 29 Nov 2023 05:43:49 -0600 Subject: [PATCH 211/592] Added file and path scans to jobs --- nomadnet/Node.py | 12 ++++++++++++ nomadnet/NomadNetworkApp.py | 30 +++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 1ad254b..836833b 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -15,7 +15,11 @@ class Node: self.identity = self.app.identity self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, "nomadnetwork", "node") self.last_announce = time.time() + self.last_file_refresh = time.time() + self.last_page_refresh = time.time() self.announce_interval = self.app.node_announce_interval + self.page_refresh_interval = self.app.page_refresh_interval + self.file_refresh_interval = self.app.file_refresh_interval self.job_interval = Node.JOB_INTERVAL self.should_run_jobs = True self.app_data = None @@ -222,6 +226,14 @@ class Node: if now > self.last_announce + self.announce_interval*60: self.announce() + + if self.page_refresh_interval > 0: + if now > self.last_page_refresh + self.page_refresh_interval*60: + self.register_pages() + + if self.file_refresh_interval > 0: + if now > self.last_file_refresh + self.file_refresh_interval*60: + self.register_files() time.sleep(self.job_interval) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index a78eed0..5777bbf 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -115,10 +115,12 @@ class NomadNetworkApp: self.downloads_path = os.path.expanduser("~/Downloads") - self.firstrun = False - self.should_run_jobs = True - self.job_interval = 5 - self.defer_jobs = 90 + self.firstrun = False + self.should_run_jobs = True + self.job_interval = 5 + self.defer_jobs = 90 + self.page_refresh_interval = 0 + self.file_refresh_interval = 0 self.peer_announce_at_start = True self.try_propagation_on_fail = True @@ -827,12 +829,30 @@ class NomadNetworkApp: if value < 1: value = 1 self.node_announce_interval = value - + if "pages_path" in self.config["node"]: self.pagespath = self.config["node"]["pages_path"] + + if not "page_refresh_interval" in self.config["node"]: + self.page_refresh_interval = 0 + else: + value = self.config["node"].as_int("page_refresh_interval") + if value < 0: + value = 0 + self.page_refresh_interval = value + if "files_path" in self.config["node"]: self.filespath = self.config["node"]["files_path"] + + if not "file_refresh_interval" in self.config["node"]: + self.file_refresh_interval = 0 + else: + value = self.config["node"].as_int("file_refresh_interval") + if value < 0: + value = 0 + self.file_refresh_interval = value + if "prioritise_destinations" in self.config["node"]: self.prioritised_lxmf_destinations = self.config["node"].as_list("prioritise_destinations") From bb98a512f30f03ad74af5b21d9d4d807dad86dce Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 3 Jan 2024 13:05:38 +0100 Subject: [PATCH 212/592] Added page and file refresh intervals to guide --- nomadnet/ui/textui/Guide.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 1b64a4e..e9d9d27 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -621,12 +621,24 @@ Determines how often, in minutes, your node is announced on the network. Default Determines where the node server will look for hosted pages. Must be a readable filesystem path. < +>>> +`!page_refresh_interval = 0`! +>>>> +Determines the interval in seconds for rescanning the hosted pages path. By default, this option is disabled, and the pages path will only be scanned on startup. +< + >>> `!files_path = ~/.nomadnetwork/storage/files`! >>>> Determines where the node server will look for downloadable files. Must be a readable filesystem path. < +>>> +`!file_refresh_interval = 0`! +>>>> +Determines the interval in seconds for rescanning the hosted files path. By default, this option is disabled, and the files path will only be scanned on startup. +< + >>> `!disable_propagation = no`! >>>> From 5736012f2c10026bd7032b1d3aa4feb0c78feac8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 3 Jan 2024 13:07:20 +0100 Subject: [PATCH 213/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index df12433..f6b7e26 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.2" +__version__ = "0.4.3" From 1f7302903ab2fc1840c13ac360eb71ec9a86cd29 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 8 Jan 2024 00:10:55 +0100 Subject: [PATCH 214/592] Fixed missing timestamp update on page/file periodic scan --- nomadnet/Node.py | 8 +++++++- nomadnet/NomadNetworkApp.py | 16 ++++++++++++++++ nomadnet/ui/textui/Guide.py | 4 ++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 836833b..70b455c 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -50,6 +50,8 @@ class Node: def register_pages(self): + # TODO: Deregister previously registered pages + # that no longer exist. self.servedpages = [] self.scan_pages(self.app.pagespath) @@ -69,6 +71,8 @@ class Node: ) def register_files(self): + # TODO: Deregister previously registered files + # that no longer exist. self.servedfiles = [] self.scan_files(self.app.filespath) @@ -230,10 +234,12 @@ class Node: if self.page_refresh_interval > 0: if now > self.last_page_refresh + self.page_refresh_interval*60: self.register_pages() - + self.last_page_refresh = time.time() + if self.file_refresh_interval > 0: if now > self.last_file_refresh + self.file_refresh_interval*60: self.register_files() + self.last_file_refresh = time.time() time.sleep(self.job_interval) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 5777bbf..bfe71f1 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -1077,6 +1077,22 @@ announce_at_start = Yes # and generally you do not need to use it. # prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf +# Automatic rescan interval of the pages directory in minutes. +# Default: int = 0 (no rescan) +page_refresh_interval = 0 + +# You can specify the interval in minutes for +# rescanning the hosted pages path. By default, +# this option is disabled, and the pages path +# will only be scanned on startup. +# page_refresh_interval = 0 + +# You can specify the interval in minutes for +# rescanning the hosted files path. By default, +# this option is disabled, and the files path +# will only be scanned on startup. +# file_refresh_interval = 0 + [printing] # You can configure Nomad Network to print diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index e9d9d27..e0935c7 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -624,7 +624,7 @@ Determines where the node server will look for hosted pages. Must be a readable >>> `!page_refresh_interval = 0`! >>>> -Determines the interval in seconds for rescanning the hosted pages path. By default, this option is disabled, and the pages path will only be scanned on startup. +Determines the interval in minutes for rescanning the hosted pages path. By default, this option is disabled, and the pages path will only be scanned on startup. < >>> @@ -636,7 +636,7 @@ Determines where the node server will look for downloadable files. Must be a rea >>> `!file_refresh_interval = 0`! >>>> -Determines the interval in seconds for rescanning the hosted files path. By default, this option is disabled, and the files path will only be scanned on startup. +Determines the interval in minutes for rescanning the hosted files path. By default, this option is disabled, and the files path will only be scanned on startup. < >>> From 407cc8fb5f99e4e06c7ead97f2b180196c8b201c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 8 Jan 2024 00:12:38 +0100 Subject: [PATCH 215/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index f6b7e26..cd1ee63 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.3" +__version__ = "0.4.4" From 691f4df098b43fb5fdb985e06ec5cbb8053fe5d3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 14 Jan 2024 01:08:10 +0100 Subject: [PATCH 216/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index cd1ee63..98a433b 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.4" +__version__ = "0.4.5" diff --git a/setup.py b/setup.py index abe8212..e627c09 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.6.4", "lxmf>=0.3.8", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.7.0", "lxmf>=0.3.8", "urwid==2.1.2", "qrcode"], python_requires=">=3.6", ) From 4eef326d6b70c7abeada6375cb6e64e88640c4d0 Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Mon, 15 Jan 2024 10:39:35 +0100 Subject: [PATCH 217/592] Allow newer versions of urwid Last urwid versions are backward-compatible with 2.1.2 (but may produce "DeprecationWarning" on legacy logic) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e627c09..7807c58 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.0", "lxmf>=0.3.8", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.7.0", "lxmf>=0.3.8", "urwid>=2.1.2", "qrcode"], python_requires=">=3.6", ) From 910e527cc7eb59dc8fe3d2a6f5fb10dcf7e30d92 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 15 Jan 2024 19:30:26 +0100 Subject: [PATCH 218/592] Terminate log tail when view is inactive --- nomadnet/ui/textui/Log.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index b38e4b3..853acf7 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -12,10 +12,21 @@ class LogDisplay(): def __init__(self, app): import urwid self.app = app - self.log_term = LogTerminal(self.app) + self.log_term = None self.shortcuts_display = LogDisplayShortcuts(self.app) - self.widget = urwid.LineBox(self.log_term) + self.widget = None + + def show(self): + if self.log_term == None: + self.log_term = LogTerminal(self.app) + self.widget = urwid.LineBox(self.log_term) + + def kill(self): + if self.log_term != None: + self.log_term.terminate() + self.log_term = None + self.widget = None def shortcuts(self): return self.shortcuts_display @@ -26,10 +37,14 @@ class LogTerminal(urwid.WidgetWrap): self.log_term = urwid.Terminal( ("tail", "-fn50", self.app.logfilepath), encoding='utf-8', - escape_sequence="up" + escape_sequence="up", + main_loop=self.app.ui.loop, ) urwid.WidgetWrap.__init__(self, self.log_term) + def terminate(self): + self.log_term.terminate() + def keypress(self, size, key): if key == "up": From 0ed4e09b826f01e1f8a708986c15ae02ae615942 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 15 Jan 2024 19:40:01 +0100 Subject: [PATCH 219/592] Updated nomadnet to use Urwid 2.4.2 --- nomadnet/ui/textui/Guide.py | 7 +++---- nomadnet/ui/textui/Main.py | 3 +++ nomadnet/vendor/Scrollable.py | 6 +++--- setup.py | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index e0935c7..95cf5bf 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -163,9 +163,8 @@ class GuideDisplay(): entry.display_topic(entry.display_topic, entry.topic_name) def set_content_widgets(self, new_content): - options = self.columns.options(width_type="weight", width_amount=1-GuideDisplay.list_width) + options = self.columns.options(width_type="weight", width_amount=1-GuideDisplay.list_width, box_widget=True) pile = urwid.Pile(new_content) - #content = urwid.LineBox(urwid.Filler(pile, "top")) content = urwid.LineBox(urwid.AttrMap(ScrollBar(Scrollable(pile), thumb_char="\u2503", trough_char=" "), "scrollbar")) self.columns.contents[1] = (content, options) @@ -398,7 +397,7 @@ You're currently located in the guide section of the program. I'm sorry I had to To get the most out of Nomad Network, you will need a terminal that supports UTF-8 and at least 256 colors, ideally true-color. If your terminal supports true-color, you can go to the `![ Config ]`! menu item, launch the editor and change the configuration. -It is recommended to use a terminal size of at least 122x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped. +It is recommended to use a terminal size of at least 135x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped. If you don't already have a Nerd Font installed (see https://www.nerdfonts.com/), I also highly recommend to do so, since it will greatly expand the amount of glyphs, icons and graphics that Nomad Network can use. Once you have your terminal set up with a Nerd Font, go to the `![ Config ]`! menu item and enable Nerd Fonts in the configuration instead of normal unicode glyphs. @@ -1156,7 +1155,7 @@ To display literal content, for example source-code, or blocks of text that shou `= ''' TOPIC_MARKUP += TOPIC_MARKUP.replace("`=", "\\`=") + "[ micron source for document goes here, we don't want infinite recursion now, do we? ]\n\\`=" -TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, you should be well equipped to write documents, pages and applications using micron and Nomad Network. Thank you for staying with me.\n\n`c\U0001F332\n" +TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, you should be well equipped to write documents, pages and applications using micron and Nomad Network. Thank you for staying with me.\n" TOPICS = { diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 45584d5..ee03ea5 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -116,6 +116,7 @@ class MainDisplay(): def show_log(self, user_data): self.sub_displays.active_display = self.sub_displays.log_display + self.sub_displays.log_display.show() self.update_active_sub_display() def show_guide(self, user_data): @@ -125,6 +126,8 @@ class MainDisplay(): def update_active_sub_display(self): self.frame.contents["body"] = (self.sub_displays.active().widget, None) self.update_active_shortcuts() + if self.sub_displays.active_display != self.sub_displays.log_display: + self.sub_displays.log_display.kill() def update_active_shortcuts(self): self.frame.contents["footer"] = (self.sub_displays.active().shortcuts().widget, None) diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index eb0d2fa..eedb1ab 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -268,10 +268,10 @@ class Scrollable(urwid.WidgetDecoration): def _get_original_widget_size(self, size): ow = self._original_widget sizing = ow.sizing() - if FIXED in sizing: - return () - elif FLOW in sizing: + if FLOW in sizing: return (size[0],) + elif FIXED in sizing: + return () def get_scrollpos(self, size=None, focus=False): """Current scrolling position diff --git a/setup.py b/setup.py index e627c09..b3614a0 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.0", "lxmf>=0.3.8", "urwid==2.1.2", "qrcode"], + install_requires=["rns>=0.7.0", "lxmf>=0.3.8", "urwid>=2.4.2", "qrcode"], python_requires=">=3.6", ) From dcd5f7df9559b0da7353c3de18b747e6f81c7de3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 15 Jan 2024 20:26:10 +0100 Subject: [PATCH 220/592] Updated readme --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ccc3989..00e0ae6 100755 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ If you'd rather want to use an LXMF client with a graphical user interface, you - Page caching in browser ## Current Status -The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the ideal time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion, report bugs and request features here on the GitHub project. +The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. ## How do I get started? The easiest way to install Nomad Network is via pip: @@ -121,6 +121,22 @@ $ docker run -d \ $ docker run -i ghcr.io/markqvist/nomadnet:master --daemon --console ``` +## Tools & Extensions + +Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist: + +- [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum)) +- [NomadForecast](https://github.com/faragher/NomadForecast) +- [micron-blog](https://github.com/randogoth/micron-blog) +- [md2mu](https://github.com/randogoth/md2mu) +- [Any2MicronConverter](https://github.com/SebastianObi/Any2MicronConverter) +- [Some nomadnet page examples](https://github.com/SebastianObi/NomadNet-Pages) +- [More nomadnet page examples](https://github.com/epenguins/NomadNet_pages) +- [LXMF-Bot](https://github.com/randogoth/lxmf-bot) +- [LXMF Messageboard](https://github.com/chengtripp/lxmf_messageboard) +- [LXMEvent](https://github.com/faragher/LXMEvent) +- [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools) + ## Help & Discussion For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). From 9943cad15fa0095c59606f78ffded95d4d0401ed Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 17 Jan 2024 23:08:52 +0100 Subject: [PATCH 221/592] Updated readme --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 00e0ae6..fa3f3bb 100755 --- a/README.md +++ b/README.md @@ -22,9 +22,6 @@ If you'd rather want to use an LXMF client with a graphical user interface, you - An easy to use and bandwidth efficient markup language for writing pages - Page caching in browser -## Current Status -The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. - ## How do I get started? The easiest way to install Nomad Network is via pip: From 24d850107daccaea66b5da3cbeddd42ca906ce8c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 17 Jan 2024 23:15:00 +0100 Subject: [PATCH 222/592] Updated LXMF dependency --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b3614a0..e57c528 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.0", "lxmf>=0.3.8", "urwid>=2.4.2", "qrcode"], + install_requires=["rns>=0.7.0", "lxmf>=0.3.9", "urwid>=2.4.2", "qrcode"], python_requires=">=3.6", ) From d856f3fd28163a7aa0a7e1faf0425abe76260e0b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 17 Jan 2024 23:31:16 +0100 Subject: [PATCH 223/592] Pin Urwid to 2.4.2 due to rendering bug in 2.4.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e57c528..5e16086 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.0", "lxmf>=0.3.9", "urwid>=2.4.2", "qrcode"], + install_requires=["rns>=0.7.0", "lxmf>=0.3.9", "urwid==2.4.2", "qrcode"], python_requires=">=3.6", ) From ed6ba633174c212df96b9de3104be62f5849e41f Mon Sep 17 00:00:00 2001 From: Aleksei Stepanov Date: Thu, 18 Jan 2024 10:22:38 +0100 Subject: [PATCH 224/592] Ban `urwid==2.4.3` as containing UI regression. Fix urwid entities init: use correct `super()`, hack `__super` is officially deprecated. Method .method(self, ...) is only for fallback in corner cases (normally not recommended to use). --- nomadnet/ui/textui/Config.py | 4 +-- nomadnet/ui/textui/Conversations.py | 6 ++--- nomadnet/ui/textui/Guide.py | 4 +-- nomadnet/ui/textui/Log.py | 2 +- nomadnet/ui/textui/MicronParser.py | 6 ++--- nomadnet/ui/textui/Network.py | 38 ++++++++++++++--------------- setup.py | 2 +- 7 files changed, 31 insertions(+), 31 deletions(-) diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py index 6b7039f..f94037c 100644 --- a/nomadnet/ui/textui/Config.py +++ b/nomadnet/ui/textui/Config.py @@ -13,7 +13,7 @@ class ConfigFiller(urwid.WidgetWrap): def __init__(self, widget, app): self.app = app self.filler = urwid.Filler(widget, "top") - urwid.WidgetWrap.__init__(self, self.filler) + super().__init__(self.filler) def keypress(self, size, key): @@ -71,7 +71,7 @@ class EditorTerminal(urwid.WidgetWrap): urwid.connect_signal(self.term, 'closed', quit_term) - urwid.WidgetWrap.__init__(self, self.term) + super().__init__(self.term) def keypress(self, size, key): diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 9bb8e02..3d1d857 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -788,7 +788,7 @@ class ConversationWidget(urwid.WidgetWrap): if source_hash == None: self.frame = None display_widget = urwid.LineBox(urwid.Filler(urwid.Text("\n No conversation selected"), "top")) - urwid.WidgetWrap.__init__(self, display_widget) + super().__init__(display_widget) else: if source_hash in ConversationsDisplay.cached_conversation_widgets: return ConversationsDisplay.cached_conversation_widgets[source_hash] @@ -852,7 +852,7 @@ class ConversationWidget(urwid.WidgetWrap): self.frame ) - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def clear_history_dialog(self): def dismiss_dialog(sender): @@ -1141,7 +1141,7 @@ class LXMessageWidget(urwid.WidgetWrap): urwid.Text("") ]) - urwid.WidgetWrap.__init__(self, display_widget) + super().__init__(display_widget) class SyncProgressBar(urwid.ProgressBar): def get_text(self): diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 95cf5bf..ccd2b52 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -75,7 +75,7 @@ class GuideEntry(urwid.WidgetWrap): style = "topic_list_normal" focus_style = "list_focus" self.display_widget = urwid.AttrMap(widget, style, focus_style) - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def display_topic(self, event, topic): markup = TOPICS[topic] @@ -125,7 +125,7 @@ class TopicList(urwid.WidgetWrap): highlight_offFocus="list_off_focus" ) - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.ilb, title="Topics")) + super().__init__(urwid.LineBox(self.ilb, title="Topics")) def keypress(self, size, key): diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index 853acf7..28cc22e 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -40,7 +40,7 @@ class LogTerminal(urwid.WidgetWrap): escape_sequence="up", main_loop=self.app.ui.loop, ) - urwid.WidgetWrap.__init__(self, self.log_term) + super().__init__(self.log_term) def terminate(self): self.log_term.terminate() diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 3885a15..335d2c0 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -608,7 +608,7 @@ class LinkSpec(urwid.AttrSpec): self.link_target = link_target self.link_fields = None - urwid.AttrSpec.__init__(self, orig_spec.foreground, orig_spec.background) + super().__init__(orig_spec.foreground, orig_spec.background) class LinkableText(urwid.Text): @@ -618,7 +618,7 @@ class LinkableText(urwid.Text): signals = ["click", "change"] def __init__(self, text, align=None, cursor_position=0, delegate=None): - self.__super.__init__(text, align=align) + super().__init__(text, align=align) self.delegate = delegate self._cursor_position = 0 self.key_timeout = 3 @@ -729,7 +729,7 @@ class LinkableText(urwid.Text): def render(self, size, focus=False): now = time.time() - c = self.__super.render(size, focus) + c = super().render(size, focus) if focus and (self.delegate == None or now < self.delegate.last_keypress+self.key_timeout): c = urwid.CompositeCanvas(c) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 19ef8af..f0c40a3 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -234,7 +234,7 @@ class AnnounceInfo(urwid.WidgetWrap): self.display_widget = urwid.Filler(pile, valign="top", height="pack") - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Announce Info")) + super().__init__(urwid.LineBox(self.display_widget, title="Announce Info")) class AnnounceStreamEntry(urwid.WidgetWrap): @@ -296,7 +296,7 @@ class AnnounceStreamEntry(urwid.WidgetWrap): urwid.connect_signal(widget, "click", self.display_announce, announce) self.display_widget = urwid.AttrMap(widget, style, focus_style) - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def display_announce(self, event, announce): try: @@ -365,7 +365,7 @@ class AnnounceStream(urwid.WidgetWrap): ) self.display_widget = self.ilb - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Announce Stream")) + super().__init__(urwid.LineBox(self.display_widget, title="Announce Stream")) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): @@ -681,7 +681,7 @@ class KnownNodeInfo(urwid.WidgetWrap): self.display_widget = urwid.Filler(pile, valign="top", height="pack") - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Node Info")) + super().__init__(urwid.LineBox(self.display_widget, title="Node Info")) # Yes, this is weird. There is a bug in Urwid/ILB that causes @@ -726,7 +726,7 @@ class KnownNodes(urwid.WidgetWrap): self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no nodes are saved\n\nCtrl+L to view the announce stream\n\n"), align="center")]) self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style)) + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style)) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): @@ -869,7 +869,7 @@ class NodeEntry(urwid.WidgetWrap): self.display_widget = urwid.AttrMap(widget, style, focus_style) self.display_widget.source_hash = source_hash - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) class AnnounceTime(urwid.WidgetWrap): @@ -880,7 +880,7 @@ class AnnounceTime(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_time() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_time(self): self.last_announce_string = "Never" @@ -912,7 +912,7 @@ class NodeAnnounceTime(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_time() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_time(self): self.last_announce_string = "Never" @@ -943,7 +943,7 @@ class NodeActiveConnections(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" @@ -974,7 +974,7 @@ class NodeStorageStats(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" @@ -1017,7 +1017,7 @@ class NodeTotalConnections(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" @@ -1049,7 +1049,7 @@ class NodeTotalPages(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" @@ -1081,7 +1081,7 @@ class NodeTotalFiles(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" @@ -1193,7 +1193,7 @@ class LocalPeer(urwid.WidgetWrap): ] ) - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Local Peer Info")) + super().__init__(urwid.LineBox(self.display_widget, title="Local Peer Info")) def start(self): self.t_last_announce.start() @@ -1369,7 +1369,7 @@ class NodeInfo(urwid.WidgetWrap): self.display_widget = pile - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Local Node Info"), widget_style)) + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title="Local Node Info"), widget_style)) def start(self): if self.app.node != None: @@ -1392,7 +1392,7 @@ class UpdatingText(urwid.WidgetWrap): self.append_text = append_text self.update() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update(self): self.value = self.value_method() @@ -1434,7 +1434,7 @@ class NetworkStats(urwid.WidgetWrap): self.display_widget = urwid.LineBox(pile, title="Network Stats") - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def start(self): self.w_heard_peers.start() @@ -1620,7 +1620,7 @@ class LXMFPeers(urwid.WidgetWrap): pl = len(self.peer_list) else: pl = 0 - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title=f"LXMF Propagation Peers ({pl})"), widget_style)) + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title=f"LXMF Propagation Peers ({pl})"), widget_style)) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): @@ -1741,7 +1741,7 @@ class LXMFPeerEntry(urwid.WidgetWrap): widget = ListEntry(sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs, "+RNS.prettysize(peer.link_establishment_rate/8, "b")+"/s LER") self.display_widget = urwid.AttrMap(widget, style, focus_style) self.display_widget.destination_hash = destination_hash - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def pretty_date(time=False): diff --git a/setup.py b/setup.py index 5e16086..2bc72d0 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.0", "lxmf>=0.3.9", "urwid==2.4.2", "qrcode"], + install_requires=["rns>=0.7.0", "lxmf>=0.3.9", "urwid>=2.4.2,!=2.4.3", "qrcode"], python_requires=">=3.6", ) From 77eca5d23ef522a18508e6f06e68cafd98cdd9f7 Mon Sep 17 00:00:00 2001 From: Aleksei Stepanov Date: Thu, 18 Jan 2024 11:58:22 +0100 Subject: [PATCH 225/592] Use urwid constants for widgets instead of string equivalents * Reduce `DeprecationWarning` amount: `get_focus` -> `focus_position` for `Frame` and `focus` for `Pile` `set_focus` -> use property `focus_position` --- nomadnet/ui/textui/Browser.py | 73 ++++-- nomadnet/ui/textui/Config.py | 20 +- nomadnet/ui/textui/Conversations.py | 358 +++++++++++++++++++++------- nomadnet/ui/textui/Directory.py | 2 +- nomadnet/ui/textui/Extras.py | 6 +- nomadnet/ui/textui/Guide.py | 14 +- nomadnet/ui/textui/Log.py | 3 +- nomadnet/ui/textui/Main.py | 5 +- nomadnet/ui/textui/Map.py | 2 +- nomadnet/ui/textui/MicronParser.py | 3 +- nomadnet/ui/textui/Network.py | 283 +++++++++++++--------- 11 files changed, 524 insertions(+), 245 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index fcb7492..7ce5d99 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -27,13 +27,13 @@ class BrowserFrame(urwid.Frame): self.delegate.save_node_dialog() elif key == "ctrl g": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen() - elif self.get_focus() == "body": + elif self.focus_position == "body": if key == "down" or key == "up": try: if hasattr(self.delegate, "page_pile") and self.delegate.page_pile: def df(loop, user_data): st = None - nf = self.delegate.page_pile.get_focus() + nf = self.delegate.page_pile.focus if hasattr(nf, "key_timeout"): st = nf elif hasattr(nf, "original_widget"): @@ -279,7 +279,10 @@ class Browser: self.browser_footer = urwid.Text("") self.page_pile = None - self.browser_body = urwid.Filler(urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align="center"), "middle") + self.browser_body = urwid.Filler( + urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), + urwid.MIDDLE, + ) self.frame = BrowserFrame(self.browser_body, header=self.browser_header, footer=self.browser_footer) self.frame.delegate = self @@ -306,28 +309,29 @@ class Browser: self.update_display() columns = urwid.Columns([ - ("weight", 0.5, urwid.Text(" ")), + (urwid.WEIGHT, 0.5, urwid.Text(" ")), (8, urwid.Button("Back", on_press=back_action)), - ("weight", 0.5, urwid.Text(" ")) + (urwid.WEIGHT, 0.5, urwid.Text(" ")), ]) if len(self.attr_maps) > 0: pile = urwid.Pile([ - urwid.Text("!\n\n"+self.status_text()+"\n", align="center"), - columns + urwid.Text("!\n\n"+self.status_text()+"\n", align=urwid.CENTER), + columns ]) else: - pile = urwid.Pile([ - urwid.Text("!\n\n"+self.status_text(), align="center") - ]) + pile = urwid.Pile([urwid.Text("!\n\n"+self.status_text(), align=urwid.CENTER)]) - return urwid.Filler(pile, "middle") + return urwid.Filler(pile, urwid.MIDDLE) def update_display(self): if self.status == Browser.DISCONECTED: self.display_widget.set_attr_map({None: "inactive_text"}) self.page_pile = None - self.browser_body = urwid.Filler(urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align="center"), "middle") + self.browser_body = urwid.Filler( + urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), + urwid.MIDDLE, + ) self.browser_footer = urwid.Text("") self.browser_header = urwid.Text("") self.linebox.set_title("Remote Node") @@ -354,7 +358,10 @@ class Browser: elif self.status <= Browser.REQUEST_SENT: if len(self.attr_maps) == 0: - self.browser_body = urwid.Filler(urwid.Text("Retrieving\n["+self.current_url()+"]", align="center"), "middle") + self.browser_body = urwid.Filler( + urwid.Text("Retrieving\n["+self.current_url()+"]", align=urwid.CENTER), + urwid.MIDDLE, + ) self.browser_footer = self.make_status_widget() @@ -608,7 +615,7 @@ class Browser: self.load_page() def close_dialogs(self): - options = self.delegate.columns.options("weight", self.delegate.right_area_width) + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) self.delegate.columns.contents[1] = (self.display_widget, options) def url_dialog(self): @@ -629,7 +636,11 @@ class Browser: dialog = UrlDialogLineBox( urwid.Pile([ e_url, - urwid.Columns([("weight", 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Go", on_press=confirmed))]) + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Go", on_press=confirmed)), + ]) ]), title="Enter URL" ) e_url.confirmed = confirmed @@ -637,9 +648,18 @@ class Browser: dialog.delegate = self bottom = self.display_widget - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 65), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=(urwid.RELATIVE, 65), + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.delegate.columns.options("weight", self.delegate.right_area_width) + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) self.delegate.columns.contents[1] = (overlay, options) self.delegate.columns.focus_position = 1 @@ -663,7 +683,11 @@ class Browser: dialog = UrlDialogLineBox( urwid.Pile([ urwid.Text("Save connected node"+disp_str+" "+RNS.prettyhexrep(self.destination_hash)+" to Known Nodes?\n"), - urwid.Columns([("weight", 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Save", on_press=confirmed))]) + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + ]) ]), title="Save Node" ) @@ -671,9 +695,18 @@ class Browser: dialog.delegate = self bottom = self.display_widget - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 50), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=(urwid.RELATIVE, 50), + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.delegate.columns.options("weight", self.delegate.right_area_width) + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) self.delegate.columns.contents[1] = (overlay, options) self.delegate.columns.focus_position = 1 diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py index 6b7039f..618e8c6 100644 --- a/nomadnet/ui/textui/Config.py +++ b/nomadnet/ui/textui/Config.py @@ -12,13 +12,13 @@ class ConfigDisplayShortcuts(): class ConfigFiller(urwid.WidgetWrap): def __init__(self, widget, app): self.app = app - self.filler = urwid.Filler(widget, "top") + self.filler = urwid.Filler(widget, urwid.TOP) urwid.WidgetWrap.__init__(self, self.filler) def keypress(self, size, key): if key == "up": - self.app.ui.main_display.frame.set_focus("header") + self.app.ui.main_display.frame.focus_position = "header" return super(ConfigFiller, self).keypress(size, key) @@ -31,12 +31,20 @@ class ConfigDisplay(): self.editor_term = EditorTerminal(self.app, self) self.widget = urwid.LineBox(self.editor_term) self.app.ui.main_display.update_active_sub_display() - self.app.ui.main_display.frame.set_focus("body") + self.app.ui.main_display.frame.focus_position = "body" self.editor_term.term.change_focus(True) pile = urwid.Pile([ - urwid.Text(("body_text", "\nTo change the configuration, edit the config file located at:\n\n"+self.app.configpath+"\n\nRestart Nomad Network for changes to take effect\n"), align="center"), - urwid.Padding(urwid.Button("Open Editor", on_press=open_editor), width=15, align="center"), + urwid.Text( + ( + "body_text", + "\nTo change the configuration, edit the config file located at:\n\n" + +self.app.configpath + +"\n\nRestart Nomad Network for changes to take effect\n", + ), + align=urwid.CENTER, + ), + urwid.Padding(urwid.Button("Open Editor", on_press=open_editor), width=15, align=urwid.CENTER), ]) self.config_explainer = ConfigFiller(pile, self.app) @@ -77,5 +85,5 @@ class EditorTerminal(urwid.WidgetWrap): def keypress(self, size, key): # TODO: Decide whether there should be a way to get out while editing #if key == "up": - # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" return super(EditorTerminal, self).keypress(size, key) \ No newline at end of file diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 9bb8e02..f5dab60 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -37,9 +37,9 @@ class ConversationsArea(urwid.LineBox): elif key == "ctrl g": self.delegate.toggle_fullscreen() elif key == "tab": - self.delegate.app.ui.main_display.frame.set_focus("header") + self.delegate.app.ui.main_display.frame.focus_position = "header" elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): - self.delegate.app.ui.main_display.frame.set_focus("header") + self.delegate.app.ui.main_display.frame.focus_position = "header" else: return super(ConversationsArea, self).keypress(size, key) @@ -69,10 +69,10 @@ class ConversationsDisplay(): self.columns_widget = urwid.Columns( [ - # ("weight", ConversationsDisplay.list_width, self.listbox), - # ("weight", 1-ConversationsDisplay.list_width, self.make_conversation_widget(None)) + # (urwid.WEIGHT, ConversationsDisplay.list_width, self.listbox), + # (urwid.WEIGHT, 1-ConversationsDisplay.list_width, self.make_conversation_widget(None)) (ConversationsDisplay.given_list_width, self.listbox), - ("weight", 1, self.make_conversation_widget(None)) + (urwid.WEIGHT, 1, self.make_conversation_widget(None)) ], dividechars=0, focus_column=0, box_columns=[0] ) @@ -105,7 +105,7 @@ class ConversationsDisplay(): highlight_offFocus="list_off_focus" ) - self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=("relative", 100)), title="Conversations") + self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Conversations") self.listbox.delegate = self def delete_selected_conversation(self): @@ -127,17 +127,33 @@ class ConversationsDisplay(): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n", align="center"), - urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) + urwid.Text( + "Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) ]), title="?" ) dialog.delegate = self bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def edit_selected_in_directory(self): @@ -223,9 +239,12 @@ class ConversationsDisplay(): RNS.log("Could not save directory entry. The contained exception was: "+str(e), RNS.LOG_VERBOSE) if not dialog_pile.error_display: dialog_pile.error_display = True - options = dialog_pile.options(height_type="pack") + options = dialog_pile.options(height_type=urwid.PACK) dialog_pile.contents.append((urwid.Text(""), options)) - dialog_pile.contents.append((urwid.Text(("error_text", "Could not save entry. Check your input."), align="center"), options)) + dialog_pile.contents.append(( + urwid.Text(("error_text", "Could not save entry. Check your input."), align=urwid.CENTER), + options,) + ) source_is_known = self.app.directory.is_known(bytes.fromhex(source_hash_text)) if source_is_known: @@ -234,13 +253,23 @@ class ConversationsDisplay(): def query_action(sender, user_data): self.close_conversation_by_hash(user_data) nomadnet.Conversation.query_for_peer(user_data) - options = dialog_pile.options(height_type="pack") + options = dialog_pile.options(height_type=urwid.PACK) dialog_pile.contents = [ (urwid.Text("Query sent"), options), (urwid.Button("OK", on_press=dismiss_dialog), options) ] query_button = urwid.Button("Query network for keys", on_press=query_action, user_data=source_hash_text) - known_section = urwid.Pile([urwid.Divider(g["divider1"]), urwid.Text(g["info"]+"\n", align="center"), urwid.Text("The identity of this peer is not known, and you cannot currently send messages to it. You can query the network to obtain the identity.\n", align="center"), query_button, urwid.Divider(g["divider1"])]) + known_section = urwid.Pile([ + urwid.Divider(g["divider1"]), + urwid.Text(g["info"]+"\n", align=urwid.CENTER), + urwid.Text( + "The identity of this peer is not known, and you cannot currently send messages to it. " + "You can query the network to obtain the identity.\n", + align=urwid.CENTER, + ), + query_button, + urwid.Divider(g["divider1"]), + ]) dialog_pile = urwid.Pile([ selected_id_widget, @@ -253,7 +282,11 @@ class ConversationsDisplay(): r_direct, r_propagated, known_section, - urwid.Columns([("weight", 0.45, urwid.Button("Save", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))]) + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) ]) dialog_pile.error_display = False @@ -261,10 +294,19 @@ class ConversationsDisplay(): dialog.delegate = self bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def new_conversation(self): @@ -312,9 +354,15 @@ class ConversationsDisplay(): RNS.log("Could not start conversation. The contained exception was: "+str(e), RNS.LOG_VERBOSE) if not dialog_pile.error_display: dialog_pile.error_display = True - options = dialog_pile.options(height_type="pack") + options = dialog_pile.options(height_type=urwid.PACK) dialog_pile.contents.append((urwid.Text(""), options)) - dialog_pile.contents.append((urwid.Text(("error_text", "Could not start conversation. Check your input."), align="center"), options)) + dialog_pile.contents.append(( + urwid.Text( + ("error_text", "Could not start conversation. Check your input."), + align=urwid.CENTER, + ), + options, + )) dialog_pile = urwid.Pile([ e_id, @@ -324,7 +372,11 @@ class ConversationsDisplay(): r_unknown, r_trusted, urwid.Text(""), - urwid.Columns([("weight", 0.45, urwid.Button("Create", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))]) + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) ]) dialog_pile.error_display = False @@ -332,10 +384,19 @@ class ConversationsDisplay(): dialog.delegate = self bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def ingest_lxm_uri(self): @@ -366,7 +427,10 @@ class ConversationsDisplay(): rdialog_pile = urwid.Pile([ urwid.Text("Message was decoded, decrypted successfully, and added to your conversation list."), urwid.Text(""), - urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) ]) rdialog_pile.error_display = False @@ -374,16 +438,28 @@ class ConversationsDisplay(): rdialog.delegate = self bottom = self.listbox - roverlay = urwid.Overlay(rdialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (roverlay, options) elif ingest_result == duplicate_signal: rdialog_pile = urwid.Pile([ urwid.Text("The decoded message has already been processed by the LXMF Router, and will not be ingested again."), urwid.Text(""), - urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) ]) rdialog_pile.error_display = False @@ -391,9 +467,18 @@ class ConversationsDisplay(): rdialog.delegate = self bottom = self.listbox - roverlay = urwid.Overlay(rdialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (roverlay, options) else: @@ -405,7 +490,10 @@ class ConversationsDisplay(): rdialog_pile = urwid.Pile([ urwid.Text(propagation_text), urwid.Text(""), - urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) ]) rdialog_pile.error_display = False @@ -413,23 +501,36 @@ class ConversationsDisplay(): rdialog.delegate = self bottom = self.listbox - roverlay = urwid.Overlay(rdialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (roverlay, options) except Exception as e: RNS.log("Could not ingest LXM URI. The contained exception was: "+str(e), RNS.LOG_VERBOSE) if not dialog_pile.error_display: dialog_pile.error_display = True - options = dialog_pile.options(height_type="pack") + options = dialog_pile.options(height_type=urwid.PACK) dialog_pile.contents.append((urwid.Text(""), options)) - dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URI data. Check your input."), align="center"), options)) + dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URI data. Check your input."), align=urwid.CENTER), options)) dialog_pile = urwid.Pile([ e_uri, urwid.Text(""), - urwid.Columns([("weight", 0.45, urwid.Button("Ingest", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))]) + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Ingest", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) ]) dialog_pile.error_display = False @@ -437,9 +538,18 @@ class ConversationsDisplay(): dialog.delegate = self bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def delete_conversation(self, source_hash): @@ -471,7 +581,7 @@ class ConversationsDisplay(): r_mall = urwid.RadioButton(max_messages_group, "Download all", state=True) r_mlim = urwid.RadioButton(max_messages_group, "Limit to", state=False) ie_lim = urwid.IntEdit("", 5) - rbs = urwid.GridFlow([r_mlim, ie_lim], 12, 1, 0, align="left") + rbs = urwid.GridFlow([r_mlim, ie_lim], 12, 1, 0, align=urwid.LEFT) def sync_now(sender): limit = None @@ -495,7 +605,11 @@ class ConversationsDisplay(): else: sync_button = hidden_sync_button - button_columns = urwid.Columns([("weight", 0.45, sync_button), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, cancel_button)]) + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, sync_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, cancel_button), + ]) real_sync_button.bc = button_columns pn_ident = None @@ -518,7 +632,7 @@ class ConversationsDisplay(): dialog = DialogLineBox( urwid.Pile([ - urwid.Text(""+g["node"]+pn_display_str, align="center"), + urwid.Text(""+g["node"]+pn_display_str, align=urwid.CENTER), urwid.Divider(g["divider1"]), sync_progress, urwid.Divider(g["divider1"]), @@ -529,12 +643,23 @@ class ConversationsDisplay(): ]), title="Message Sync" ) else: - button_columns = urwid.Columns([("weight", 0.45, urwid.Text("" )), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, cancel_button)]) + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Text("" )), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, cancel_button), + ]) dialog = DialogLineBox( urwid.Pile([ urwid.Text(""), - urwid.Text("No trusted nodes found, cannot sync!\n", align="center"), - urwid.Text("To syncronise messages from the network, one or more nodes must be marked as trusted in the Known Nodes list, or a node must manually be selected as the default propagation node. Nomad Network will then automatically sync from the nearest trusted node, or the manually selected one.", align="left"), + urwid.Text("No trusted nodes found, cannot sync!\n", align=urwid.CENTER), + urwid.Text( + "To synchronise messages from the network, " + "one or more nodes must be marked as trusted in the Known Nodes list, " + "or a node must manually be selected as the default propagation node. " + "Nomad Network will then automatically sync from the nearest trusted node, " + "or the manually selected one.", + align=urwid.LEFT, + ), urwid.Text(""), button_columns ]), title="Message Sync" @@ -550,10 +675,19 @@ class ConversationsDisplay(): self.sync_dialog = dialog bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def update_sync_dialog(self, loop = None, sender = None): @@ -561,9 +695,9 @@ class ConversationsDisplay(): self.sync_dialog.sync_progress.set_completion(self.app.get_sync_progress()) if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: - self.sync_dialog.bc.contents[0] = (self.sync_dialog.real_sync_button, self.sync_dialog.bc.options("weight", 0.45)) + self.sync_dialog.bc.contents[0] = (self.sync_dialog.real_sync_button, self.sync_dialog.bc.options(urwid.WEIGHT, 0.45)) else: - self.sync_dialog.bc.contents[0] = (self.sync_dialog.hidden_sync_button, self.sync_dialog.bc.options("weight", 0.45)) + self.sync_dialog.bc.contents[0] = (self.sync_dialog.hidden_sync_button, self.sync_dialog.bc.options(urwid.WEIGHT, 0.45)) self.app.ui.loop.set_alarm_in(0.2, self.update_sync_dialog) @@ -574,13 +708,22 @@ class ConversationsDisplay(): def update_conversation_list(self): ilb_position = self.ilb.get_selected_position() self.update_listbox() - # options = self.columns_widget.options("weight", ConversationsDisplay.list_width) - options = self.columns_widget.options("given", ConversationsDisplay.given_list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) if not (self.dialog_open and self.sync_dialog != None): self.columns_widget.contents[0] = (self.listbox, options) else: bottom = self.listbox - overlay = urwid.Overlay(self.sync_dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + self.sync_dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) self.columns_widget.contents[0] = (overlay, options) if ilb_position != None: @@ -606,17 +749,17 @@ class ConversationsDisplay(): self.app.mark_conversation_read(self.currently_displayed_conversation) self.currently_displayed_conversation = source_hash - # options = self.widget.options("weight", 1-ConversationsDisplay.list_width) - options = self.widget.options("weight", 1) + # options = self.widget.options(urwid.WEIGHT, 1-ConversationsDisplay.list_width) + options = self.widget.options(urwid.WEIGHT, 1) self.widget.contents[1] = (self.make_conversation_widget(source_hash), options) if source_hash == None: - self.widget.set_focus_column(0) + self.widget.focus_position = 0 else: if self.app.conversation_is_unread(source_hash): self.app.mark_conversation_read(source_hash) self.update_conversation_list() - self.widget.set_focus_column(1) + self.widget.focus_position = 1 conversation_position = None index = 0 for widget in self.list_widgets: @@ -756,9 +899,9 @@ class MessageEdit(urwid.Edit): y = self.get_cursor_coords(size)[1] if y == 0: if self.delegate.full_editor_active and self.name == "title_editor": - self.delegate.frame.set_focus("body") + self.delegate.frame.focus_position = "body" elif not self.delegate.full_editor_active and self.name == "content_editor": - self.delegate.frame.set_focus("body") + self.delegate.frame.focus_position = "body" else: return super(MessageEdit, self).keypress(size, key) else: @@ -769,11 +912,11 @@ class MessageEdit(urwid.Edit): class ConversationFrame(urwid.Frame): def keypress(self, size, key): - if self.get_focus() == "body": + if self.focus_position == "body": if key == "up" and self.delegate.messagelist.top_is_visible: - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "down" and self.delegate.messagelist.bottom_is_visible: - self.set_focus("footer") + self.focus_position = "footer" else: return super(ConversationFrame, self).keypress(size, key) elif key == "ctrl k": @@ -815,7 +958,11 @@ class ConversationWidget(urwid.WidgetWrap): header = None if self.conversation.trust_level == DirectoryEntry.UNTRUSTED: - header = urwid.AttrMap(urwid.Padding(urwid.Text(g["warning"]+" Warning: Conversation with untrusted peer "+g["warning"], align="center")), "msg_warning_untrusted") + header = urwid.AttrMap( + urwid.Padding( + urwid.Text(g["warning"]+" Warning: Conversation with untrusted peer "+g["warning"], align=urwid.CENTER)), + "msg_warning_untrusted", + ) self.minimal_editor = urwid.AttrMap(msg_editor, "msg_editor") self.minimal_editor.name = "minimal_editor" @@ -867,17 +1014,30 @@ class ConversationWidget(urwid.WidgetWrap): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("Clear conversation history\n", align="center"), - urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) + urwid.Text("Clear conversation history\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) ]), title="?" ) dialog.delegate = self bottom = self.messagelist - overlay = urwid.Overlay(dialog, bottom, align="center", width=34, valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=34, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) self.frame.contents["body"] = (overlay, self.frame.options()) - self.frame.set_focus("body") + self.frame.focus_position = "body" def toggle_editor(self): if self.full_editor_active: @@ -894,7 +1054,17 @@ class ConversationWidget(urwid.WidgetWrap): if allowed: self.frame.contents["footer"] = (self.minimal_editor, None) else: - warning = urwid.AttrMap(urwid.Padding(urwid.Text("\n"+g["info"]+"\n\nYou cannot currently message this peer, since it's identity keys are not known. The keys have been requested from the network and should arrive shortly, if available. Close this conversation and reopen it to try again.\n\nTo query the network manually, select this conversation in the conversation list, press Ctrl-E, and use the query button.\n", align="center")), "msg_header_caution") + warning = urwid.AttrMap( + urwid.Padding(urwid.Text( + "\n"+g["info"]+"\n\nYou cannot currently message this peer, since it's identity keys are not known. " + "The keys have been requested from the network and should arrive shortly, if available. " + "Close this conversation and reopen it to try again.\n\n" + "To query the network manually, select this conversation in the conversation list, " + "press Ctrl-E, and use the query button.\n", + align=urwid.CENTER, + )), + "msg_header_caution", + ) self.frame.contents["footer"] = (warning, None) def toggle_focus_area(self): @@ -905,9 +1075,9 @@ class ConversationWidget(urwid.WidgetWrap): pass if name == "messagelist": - self.frame.set_focus("footer") + self.frame.focus_position = "footer" elif name == "minimal_editor" or name == "full_editor": - self.frame.set_focus("body") + self.frame.focus_position = "body" def keypress(self, size, key): if key == "tab": @@ -982,17 +1152,20 @@ class ConversationWidget(urwid.WidgetWrap): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("The paper message was saved to:\n\n"+str(path)+"\n", align="center"), - urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + urwid.Text("The paper message was saved to:\n\n"+str(path)+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) ]), title=g["papermsg"].replace(" ", "") ) dialog.delegate = self bottom = self.messagelist - overlay = urwid.Overlay(dialog, bottom, align="center", width=60, valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) self.frame.contents["body"] = (overlay, self.frame.options()) - self.frame.set_focus("body") + self.frame.focus_position = "body" def print_paper_message_qr(self): content = self.content_editor.get_edit_text() @@ -1044,25 +1217,28 @@ class ConversationWidget(urwid.WidgetWrap): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("Select the desired paper message output method.\nSaved files will be written to:\n\n"+str(self.app.downloads_path)+"\n", align="center"), + urwid.Text( + "Select the desired paper message output method.\nSaved files will be written to:\n\n"+str(self.app.downloads_path)+"\n", + align=urwid.CENTER, + ), urwid.Columns([ - ("weight", 0.5, urwid.Button("Print QR", on_press=print_qr)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.5, urwid.Button("Save QR", on_press=save_qr)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.5, urwid.Button("Save URI", on_press=save_uri)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.5, urwid.Button("Cancel", on_press=dismiss_dialog)) + (urwid.WEIGHT, 0.5, urwid.Button("Print QR", on_press=print_qr)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Save QR", on_press=save_qr)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Save URI", on_press=save_uri)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Cancel", on_press=dismiss_dialog)) ]) ]), title="Create Paper Message" ) dialog.delegate = self bottom = self.messagelist - overlay = urwid.Overlay(dialog, bottom, align="center", width=60, valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) self.frame.contents["body"] = (overlay, self.frame.options()) - self.frame.set_focus("body") + self.frame.focus_position = "body" def paper_message_failed(self): def dismiss_dialog(sender): @@ -1071,17 +1247,23 @@ class ConversationWidget(urwid.WidgetWrap): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("Could not output paper message,\ncheck your settings. See the log\nfile for any error messages.\n", align="center"), - urwid.Columns([("weight", 0.6, urwid.Text("")), ("weight", 0.4, urwid.Button("OK", on_press=dismiss_dialog))]) + urwid.Text( + "Could not output paper message,\ncheck your settings. See the log\nfile for any error messages.\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) ]), title="!" ) dialog.delegate = self bottom = self.messagelist - overlay = urwid.Overlay(dialog, bottom, align="center", width=34, valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=34, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) self.frame.contents["body"] = (overlay, self.frame.options()) - self.frame.set_focus("body") + self.frame.focus_position = "body" def close(self): self.delegate.close_conversation(self) diff --git a/nomadnet/ui/textui/Directory.py b/nomadnet/ui/textui/Directory.py index aeec463..3ff090b 100644 --- a/nomadnet/ui/textui/Directory.py +++ b/nomadnet/ui/textui/Directory.py @@ -15,7 +15,7 @@ class DirectoryDisplay(): ]) self.shortcuts_display = DirectoryDisplayShortcuts(self.app) - self.widget = urwid.Filler(pile, 'top') + self.widget = urwid.Filler(pile, urwid.TOP) def shortcuts(self): return self.shortcuts_display \ No newline at end of file diff --git a/nomadnet/ui/textui/Extras.py b/nomadnet/ui/textui/Extras.py index 75327d4..54b4fec 100644 --- a/nomadnet/ui/textui/Extras.py +++ b/nomadnet/ui/textui/Extras.py @@ -6,13 +6,13 @@ class IntroDisplay(): font = urwid.font.HalfBlock5x4Font() big_text = urwid.BigText(("intro_title", self.app.config["textui"]["intro_text"]), font) - big_text = urwid.Padding(big_text, align="center", width="clip") + big_text = urwid.Padding(big_text, align=urwid.CENTER, width=urwid.CLIP) intro = urwid.Pile([ big_text, - urwid.Text(("Version %s" % (str(self.app.version))), align="center"), + urwid.Text(("Version %s" % (str(self.app.version))), align=urwid.CENTER), urwid.Divider(), - urwid.Text(("-= Starting =- "), align="center"), + urwid.Text(("-= Starting =- "), align=urwid.CENTER), ]) self.widget = urwid.Filler(intro) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 95cf5bf..a9d247e 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -10,7 +10,7 @@ class GuideDisplayShortcuts(): self.app = app g = app.ui.glyphs - self.widget = urwid.AttrMap(urwid.Padding(urwid.Text(""), align="left"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Padding(urwid.Text(""), align=urwid.LEFT), "shortcutbar") class ListEntry(urwid.Text): _selectable = True @@ -130,7 +130,7 @@ class TopicList(urwid.WidgetWrap): def keypress(self, size, key): if key == "up" and (self.ilb.first_item_is_selected()): - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" return super(TopicList, self).keypress(size, key) @@ -141,16 +141,16 @@ class GuideDisplay(): self.app = app g = self.app.ui.glyphs - topic_text = urwid.Text("\n No topic selected", align="left") + topic_text = urwid.Text("\n No topic selected", align=urwid.LEFT) self.left_area = TopicList(self.app, self) - self.right_area = urwid.LineBox(urwid.Filler(topic_text, "top")) + self.right_area = urwid.LineBox(urwid.Filler(topic_text, urwid.TOP)) self.columns = urwid.Columns( [ - ("weight", GuideDisplay.list_width, self.left_area), - ("weight", 1-GuideDisplay.list_width, self.right_area) + (urwid.WEIGHT, GuideDisplay.list_width, self.left_area), + (urwid.WEIGHT, 1-GuideDisplay.list_width, self.right_area) ], dividechars=0, focus_column=0 ) @@ -163,7 +163,7 @@ class GuideDisplay(): entry.display_topic(entry.display_topic, entry.topic_name) def set_content_widgets(self, new_content): - options = self.columns.options(width_type="weight", width_amount=1-GuideDisplay.list_width, box_widget=True) + options = self.columns.options(width_type=urwid.WEIGHT, width_amount=1-GuideDisplay.list_width, box_widget=True) pile = urwid.Pile(new_content) content = urwid.LineBox(urwid.AttrMap(ScrollBar(Scrollable(pile), thumb_char="\u2503", trough_char=" "), "scrollbar")) diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index 853acf7..624912d 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -10,7 +10,6 @@ class LogDisplayShortcuts(): class LogDisplay(): def __init__(self, app): - import urwid self.app = app self.log_term = None @@ -48,6 +47,6 @@ class LogTerminal(urwid.WidgetWrap): def keypress(self, size, key): if key == "up": - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" return super(LogTerminal, self).keypress(size, key) \ No newline at end of file diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index ee03ea5..303024a 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -1,5 +1,4 @@ import RNS -import time from .Network import * from .Conversations import * @@ -158,7 +157,7 @@ class MainDisplay(): class MenuColumns(urwid.Columns): def keypress(self, size, key): if key == "tab" or key == "down": - self.handler.frame.set_focus("body") + self.handler.frame.focus_position = "body" return super(MenuColumns, self).keypress(size, key) @@ -172,7 +171,7 @@ class MenuDisplay(): self.menu_indicator = urwid.Text("") - menu_text = ("pack", self.menu_indicator) + menu_text = (urwid.PACK, self.menu_indicator) button_network = (11, MenuButton("Network", on_press=handler.show_network)) button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) diff --git a/nomadnet/ui/textui/Map.py b/nomadnet/ui/textui/Map.py index 3d4a03d..b2ef4dd 100644 --- a/nomadnet/ui/textui/Map.py +++ b/nomadnet/ui/textui/Map.py @@ -15,7 +15,7 @@ class MapDisplay(): ]) self.shortcuts_display = MapDisplayShortcuts(self.app) - self.widget = urwid.Filler(pile, 'top') + self.widget = urwid.Filler(pile, urwid.TOP) def shortcuts(self): return self.shortcuts_display \ No newline at end of file diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 3885a15..ee1cebe 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -4,7 +4,6 @@ import random import time from urwid.util import is_mouse_press from urwid.text_layout import calc_coords -import re DEFAULT_FG_DARK = "ddd" DEFAULT_FG_LIGHT = "222" @@ -162,7 +161,7 @@ def parse_line(line, state, url_delegate): else: tw = urwid.Text(o, align=state["align"]) - widgets.append(("pack", tw)) + widgets.append((urwid.PACK, tw)) else: if o["type"] == "field": fw = o["width"] diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 19ef8af..ea0bc98 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -52,7 +52,7 @@ class ListEntry(urwid.Text): class AnnounceInfo(urwid.WidgetWrap): def keypress(self, size, key): if key == "esc": - options = self.parent.left_pile.options(height_type="weight", height_amount=1) + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) else: return super(AnnounceInfo, self).keypress(size, key) @@ -115,7 +115,7 @@ class AnnounceInfo(urwid.WidgetWrap): style = "list_untrusted" def show_announce_stream(sender): - options = self.parent.left_pile.options(height_type="weight", height_amount=1) + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) def connect(sender): @@ -187,52 +187,64 @@ class AnnounceInfo(urwid.WidgetWrap): RNS.log("Error while setting active propagation node from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) if is_node: - type_button = ("weight", 0.45, urwid.Button("Connect", on_press=connect)) - msg_button = ("weight", 0.45, urwid.Button("Msg Op", on_press=msg_op)) - save_button = ("weight", 0.45, urwid.Button("Save", on_press=save_node)) + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Connect", on_press=connect)) + msg_button = (urwid.WEIGHT, 0.45, urwid.Button("Msg Op", on_press=msg_op)) + save_button = (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_node)) elif is_pn: - type_button = ("weight", 0.45, urwid.Button("Use as default", on_press=use_pn)) + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Use as default", on_press=use_pn)) save_button = None else: - type_button = ("weight", 0.45, urwid.Button("Converse", on_press=converse)) + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Converse", on_press=converse)) save_button = None if is_node: - button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_announce_stream)), ("weight", 0.1, urwid.Text("")), type_button, ("weight", 0.1, urwid.Text("")), msg_button, ("weight", 0.1, urwid.Text("")), save_button]) + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=show_announce_stream)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + type_button, + (urwid.WEIGHT, 0.1, urwid.Text("")), + msg_button, + (urwid.WEIGHT, 0.1, urwid.Text("")), + save_button, + ]) else: - button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_announce_stream)), ("weight", 0.1, urwid.Text("")), type_button]) + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=show_announce_stream)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + type_button, + ]) pile_widgets = [] if is_pn: pile_widgets = [ - urwid.Text("Time : "+ts_string, align="left"), - urwid.Text("Addr : "+addr_str, align="left"), - urwid.Text("Type : "+type_string, align="left"), + urwid.Text("Time : "+ts_string, align=urwid.LEFT), + urwid.Text("Addr : "+addr_str, align=urwid.LEFT), + urwid.Text("Type : "+type_string, align=urwid.LEFT), urwid.Divider(g["divider1"]), button_columns ] else: pile_widgets = [ - urwid.Text("Time : "+ts_string, align="left"), - urwid.Text("Addr : "+addr_str, align="left"), - urwid.Text("Type : "+type_string, align="left"), - urwid.Text("Name : "+display_str, align="left"), - urwid.Text(["Trust : ", (style, trust_str)], align="left"), + urwid.Text("Time : "+ts_string, align=urwid.LEFT), + urwid.Text("Addr : "+addr_str, align=urwid.LEFT), + urwid.Text("Type : "+type_string, align=urwid.LEFT), + urwid.Text("Name : "+display_str, align=urwid.LEFT), + urwid.Text(["Trust : ", (style, trust_str)], align=urwid.LEFT), urwid.Divider(g["divider1"]), - urwid.Text(["Announce Data: \n", (data_style, data_str)], align="left"), + urwid.Text(["Announce Data: \n", (data_style, data_str)], align=urwid.LEFT), urwid.Divider(g["divider1"]), button_columns ] if is_node: - operator_entry = urwid.Text("Oprtr : "+op_str, align="left") + operator_entry = urwid.Text("Oprtr : "+op_str, align=urwid.LEFT) pile_widgets.insert(4, operator_entry) pile = urwid.Pile(pile_widgets) - self.display_widget = urwid.Filler(pile, valign="top", height="pack") + self.display_widget = urwid.Filler(pile, valign=urwid.TOP, height=urwid.PACK) urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Announce Info")) @@ -302,7 +314,7 @@ class AnnounceStreamEntry(urwid.WidgetWrap): try: parent = self.app.ui.main_display.sub_displays.network_display info_widget = AnnounceInfo(announce, parent, self.app) - options = parent.left_pile.options(height_type="weight", height_amount=1) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) parent.left_pile.contents[0] = (info_widget, options) except KeyError as e: @@ -313,17 +325,21 @@ class AnnounceStreamEntry(urwid.WidgetWrap): def close_req(sender): self.delegate.parent.close_list_dialogs() - dialog_pile.contents[0] = (urwid.Text("\nKeys requested from network\n", align="center"), options) + dialog_pile.contents[0] = (urwid.Text("\nKeys requested from network\n", align=urwid.CENTER), options) RNS.Transport.request_path(announce[1]) confirmed_button = urwid.Button("Request keys", on_press=confirmed) dialog_pile = urwid.Pile([ - urwid.Text("The keys for the announced destination could not be recalled. You can wait for an announce to arrive, or request the keys from the network.\n", align="center"), + urwid.Text( + "The keys for the announced destination could not be recalled. " + "You can wait for an announce to arrive, or request the keys from the network.\n", + align=urwid.CENTER, + ), urwid.Columns([ - ("weight", 0.45, confirmed_button), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.45, urwid.Button("Close", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.45, confirmed_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Close", on_press=dismiss_dialog)), ]) ]) @@ -335,9 +351,18 @@ class AnnounceStreamEntry(urwid.WidgetWrap): dialog.delegate = self.delegate.parent bottom = self.delegate - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.delegate.parent.left_pile.options("weight", 1) + options = self.delegate.parent.left_pile.options(urwid.WEIGHT, 1) self.delegate.parent.left_pile.contents[0] = (overlay, options) def timestamp(self): @@ -369,7 +394,7 @@ class AnnounceStream(urwid.WidgetWrap): def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() @@ -464,7 +489,7 @@ class ListDialogLineBox(urwid.LineBox): class KnownNodeInfo(urwid.WidgetWrap): def keypress(self, size, key): if key == "esc": - options = self.parent.left_pile.options(height_type="weight", height_amount=1) + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) else: return super(KnownNodeInfo, self).keypress(size, key) @@ -559,7 +584,7 @@ class KnownNodeInfo(urwid.WidgetWrap): op_str = "Unknown" def show_known_nodes(sender): - options = self.parent.left_pile.options(height_type="weight", height_amount=1) + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) def connect(sender): @@ -630,21 +655,21 @@ class KnownNodeInfo(urwid.WidgetWrap): show_known_nodes(None) - back_button = ("weight", 0.2, urwid.Button("Back", on_press=show_known_nodes)) - connect_button = ("weight", 0.2, urwid.Button("Connect", on_press=connect)) - save_button = ("weight", 0.2, urwid.Button("Save", on_press=save_node)) - msg_button = ("weight", 0.2, urwid.Button("Msg Op", on_press=msg_op)) - bdiv = ("weight", 0.02, urwid.Text("")) + back_button = (urwid.WEIGHT, 0.2, urwid.Button("Back", on_press=show_known_nodes)) + connect_button = (urwid.WEIGHT, 0.2, urwid.Button("Connect", on_press=connect)) + save_button = (urwid.WEIGHT, 0.2, urwid.Button("Save", on_press=save_node)) + msg_button = (urwid.WEIGHT, 0.2, urwid.Button("Msg Op", on_press=msg_op)) + bdiv = (urwid.WEIGHT, 0.02, urwid.Text("")) button_columns = urwid.Columns([back_button, bdiv, connect_button, bdiv, msg_button, bdiv, save_button]) pile_widgets = [ - urwid.Text("Type : "+type_string, align="left"), + urwid.Text("Type : "+type_string, align=urwid.LEFT), e_name, - urwid.Text("Node Addr : "+addr_str, align="left"), + urwid.Text("Node Addr : "+addr_str, align=urwid.LEFT), e_sort, urwid.Divider(g["divider1"]), - urwid.Text(lxmf_addr_str, align="center"), + urwid.Text(lxmf_addr_str, align=urwid.CENTER), urwid.Divider(g["divider1"]), propagation_node_checkbox, connect_identify_checkbox, @@ -656,7 +681,7 @@ class KnownNodeInfo(urwid.WidgetWrap): button_columns ] - operator_entry = urwid.Text("Operator : "+op_str, align="left") + operator_entry = urwid.Text("Operator : "+op_str, align=urwid.LEFT) pile_widgets.insert(3, operator_entry) hops = RNS.Transport.hops_to(source_hash) @@ -670,7 +695,7 @@ class KnownNodeInfo(urwid.WidgetWrap): else: hops_str = "Unknown" - operator_entry = urwid.Text("Distance : "+hops_str, align="left") + operator_entry = urwid.Text("Distance : "+hops_str, align=urwid.LEFT) pile_widgets.insert(4, operator_entry) pile = urwid.Pile(pile_widgets) @@ -679,7 +704,7 @@ class KnownNodeInfo(urwid.WidgetWrap): button_columns.focus_position = 0 - self.display_widget = urwid.Filler(pile, valign="top", height="pack") + self.display_widget = urwid.Filler(pile, valign=urwid.TOP, height=urwid.PACK) urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Node Info")) @@ -694,9 +719,9 @@ class ExceptionHandlingListBox(IndicativeListBox): except Exception as e: if key == "up": - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "down": - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.left_pile.set_focus(1) + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.left_pile.focus_position = 1 else: RNS.log("An error occurred while processing an interface event. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -723,14 +748,23 @@ class KnownNodes(urwid.WidgetWrap): else: self.no_content = True widget_style = "inactive_text" - self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no nodes are saved\n\nCtrl+L to view the announce stream\n\n"), align="center")]) - self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") + self.pile = urwid.Pile([ + urwid.Text(("warning_text", g["info"]+"\n"), align=urwid.CENTER), + SelectText( + ( + "warning_text", + "Currently, no nodes are saved\n\nCtrl+L to view the announce stream\n\n", + ), + align=urwid.CENTER, + ), + ]) + self.display_widget = urwid.Filler(self.pile, valign=urwid.TOP, height=urwid.PACK) urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style)) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() @@ -757,27 +791,27 @@ class KnownNodes(urwid.WidgetWrap): def show_info(sender): info_widget = KnownNodeInfo(source_hash) - options = parent.left_pile.options(height_type="weight", height_amount=1) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) parent.left_pile.contents[0] = (info_widget, options) dialog = ListDialogLineBox( urwid.Pile([ - urwid.Text("Connect to node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align="center"), + urwid.Text("Connect to node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align=urwid.CENTER), urwid.Columns([ - ("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.45, urwid.Button("Info", on_press=show_info))]) + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Info", on_press=show_info))]) ]), title="?" ) dialog.delegate = self.delegate bottom = self - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) - options = self.delegate.left_pile.options("weight", 1) + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) self.delegate.left_pile.contents[0] = (overlay, options) def delete_selected_entry(self): @@ -796,16 +830,29 @@ class KnownNodes(urwid.WidgetWrap): dialog = ListDialogLineBox( urwid.Pile([ - urwid.Text("Delete Node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align="center"), - urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) + urwid.Text("Delete Node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) ]), title="?" ) dialog.delegate = self.delegate bottom = self - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.delegate.left_pile.options("weight", 1) + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) self.delegate.left_pile.contents[0] = (overlay, options) @@ -1130,45 +1177,45 @@ class LocalPeer(urwid.WidgetWrap): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("\n\n\nSaved\n\n", align="center"), + urwid.Text("\n\n\nSaved\n\n", align=urwid.CENTER), urwid.Button("OK", on_press=dismiss_dialog) ]), title=g["info"] ) dialog.delegate = self bottom = self - #overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=4, right=4) + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) overlay = dialog - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.dialog_open = True self.parent.left_pile.contents[1] = (overlay, options) def announce_query(sender): def dismiss_dialog(sender): self.dialog_open = False - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) self.app.announce_now() dialog = DialogLineBox( urwid.Pile([ - urwid.Text("\n\n\nAnnounce Sent\n\n\n", align="center"), + urwid.Text("\n\n\nAnnounce Sent\n\n\n", align=urwid.CENTER), urwid.Button("OK", on_press=dismiss_dialog) ]), title=g["info"] ) dialog.delegate = self bottom = self - #overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=4, right=4) + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) overlay = dialog self.dialog_open = True - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (overlay, options) def node_info_query(sender): - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (self.parent.node_info_display, options) if LocalPeer.announce_timer == None: @@ -1189,7 +1236,11 @@ class LocalPeer(urwid.WidgetWrap): self.t_last_announce, announce_button, urwid.Divider(g["divider1"]), - urwid.Columns([("weight", 0.45, urwid.Button("Save", on_press=save_query)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Node Info", on_press=node_info_query))]) + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), + ]) ] ) @@ -1217,7 +1268,7 @@ class NodeInfo(urwid.WidgetWrap): widget_style = "" def show_peer_info(sender): - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) if self.app.enable_node: @@ -1241,25 +1292,25 @@ class NodeInfo(urwid.WidgetWrap): def announce_query(sender): def dismiss_dialog(sender): self.dialog_open = False - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (NodeInfo(self.app, self.parent), options) self.app.node.announce() dialog = DialogLineBox( urwid.Pile([ - urwid.Text("\n\n\nAnnounce Sent\n\n", align="center"), + urwid.Text("\n\n\nAnnounce Sent\n\n", align=urwid.CENTER), urwid.Button("OK", on_press=dismiss_dialog) ]), title=g["info"] ) dialog.delegate = self bottom = self - #overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=4, right=4) + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) overlay = dialog self.dialog_open = True - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (overlay, options) def connect_query(sender): @@ -1308,7 +1359,7 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_files.update_stat() lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", self.app.node.destination.identity)) - e_lxmf = urwid.Text(lxmf_addr_str, align="center") + e_lxmf = urwid.Text(lxmf_addr_str, align=urwid.CENTER) announce_button = urwid.Button("Announce", on_press=announce_query) connect_button = urwid.Button("Browse", on_press=connect_query) @@ -1329,13 +1380,13 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_files, urwid.Divider(g["divider1"]), urwid.Columns([ - ("weight", 5, urwid.Button("Back", on_press=show_peer_info)), - ("weight", 0.5, urwid.Text("")), - ("weight", 6, connect_button), - ("weight", 0.5, urwid.Text("")), - ("weight", 8, reset_button), - ("weight", 0.5, urwid.Text("")), - ("weight", 7, announce_button), + (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 6, connect_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 8, reset_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 7, announce_button), ]) ]) else: @@ -1351,20 +1402,20 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_files, urwid.Divider(g["divider1"]), urwid.Columns([ - ("weight", 5, urwid.Button("Back", on_press=show_peer_info)), - ("weight", 0.5, urwid.Text("")), - ("weight", 6, connect_button), - ("weight", 0.5, urwid.Text("")), - ("weight", 8, reset_button), - ("weight", 0.5, urwid.Text("")), - ("weight", 7, announce_button), + (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 6, connect_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 8, reset_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 7, announce_button), ]) ]) else: pile = urwid.Pile([ - urwid.Text("\n"+g["info"], align="center"), - urwid.Text("\nThis instance is not hosting a node\n\n", align="center"), - urwid.Padding(urwid.Button("Back", on_press=show_peer_info), "center", "pack") + urwid.Text("\n"+g["info"], align=urwid.CENTER), + urwid.Text("\nThis instance is not hosting a node\n\n", align=urwid.CENTER), + urwid.Padding(urwid.Button("Back", on_press=show_peer_info), urwid.CENTER, urwid.PACK) ]) self.display_widget = pile @@ -1486,9 +1537,9 @@ class NetworkDisplay(): self.list_display = 1 self.left_pile = NetworkLeftPile([ - ("weight", 1, self.known_nodes_display), - # ("pack", self.network_stats_display), - ("pack", self.local_peer_display), + (urwid.WEIGHT, 1, self.known_nodes_display), + # (urwid.PACK, self.network_stats_display), + (urwid.PACK, self.local_peer_display), ]) self.left_pile.parent = self @@ -1499,10 +1550,10 @@ class NetworkDisplay(): self.columns = urwid.Columns( [ - # ("weight", NetworkDisplay.list_width, self.left_area), - # ("weight", self.right_area_width, self.right_area) + # (urwid.WEIGHT, NetworkDisplay.list_width, self.left_area), + # (urwid.WEIGHT, self.right_area_width, self.right_area) (NetworkDisplay.given_list_width, self.left_area), - ("weight", 1, self.right_area) + (urwid.WEIGHT, 1, self.right_area) ], dividechars=0, focus_column=0 ) @@ -1512,11 +1563,11 @@ class NetworkDisplay(): def toggle_list(self): if self.list_display != 0: - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.announce_stream_display, options) self.list_display = 0 else: - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.known_nodes_display, options) self.list_display = 1 @@ -1531,7 +1582,7 @@ class NetworkDisplay(): self.widget.contents[0] = (self.left_area, options) def show_peers(self): - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.lxmf_peers_display, options) if self.list_display != 0: @@ -1548,7 +1599,7 @@ class NetworkDisplay(): if selected_node_hash != None: info_widget = KnownNodeInfo(selected_node_hash) - options = parent.left_pile.options(height_type="weight", height_amount=1) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) parent.left_pile.contents[0] = (info_widget, options) def focus_lists(self): @@ -1567,10 +1618,10 @@ class NetworkDisplay(): def close_list_dialogs(self): if self.list_display == 0: - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.announce_stream_display, options) else: - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.known_nodes_display, options) def start(self): @@ -1613,8 +1664,11 @@ class LXMFPeers(urwid.WidgetWrap): else: self.no_content = True widget_style = "inactive_text" - self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no LXMF nodes are peered\n\n"), align="center")]) - self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") + self.pile = urwid.Pile([ + urwid.Text(("warning_text", g["info"]+"\n"), align=urwid.CENTER), + SelectText(("warning_text", "Currently, no LXMF nodes are peered\n\n"), align=urwid.CENTER), + ]) + self.display_widget = urwid.Filler(self.pile, valign=urwid.TOP, height=urwid.PACK) if hasattr(self, "peer_list") and self.peer_list: pl = len(self.peer_list) @@ -1624,7 +1678,7 @@ class LXMFPeers(urwid.WidgetWrap): def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() elif key == "ctrl r": @@ -1665,16 +1719,21 @@ class LXMFPeers(urwid.WidgetWrap): dialog = ListDialogLineBox( urwid.Pile([ - urwid.Text("A delivery sync of all unhandled LXMs was manually requested for the selected node\n", align="center"), - urwid.Columns([("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("OK", on_press=dismiss_dialog))]) - ]), title="!" + urwid.Text("A delivery sync of all unhandled LXMs was manually requested for the selected node\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), + title="!", + ) dialog.delegate = self.delegate bottom = self - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) - options = self.delegate.left_pile.options("weight", 1) + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) self.delegate.left_pile.contents[0] = (overlay, options) From 6a460d9600e8935bc0fb2b4bb0d81f4a42e90c0d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 18 Jan 2024 15:57:49 +0100 Subject: [PATCH 226/592] Fixed ILB scrolling --- .../additional_urwid_widgets/widgets/indicative_listbox.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py index d4f18dc..9797319 100644 --- a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py @@ -269,11 +269,13 @@ class IndicativeListBox(urwid.WidgetWrap): # mousewheel up elif button == 4.0: - was_handeled = self._pass_key_to_contained_listbox(modified_size, "page up") + # was_handeled = self._pass_key_to_contained_listbox(modified_size, "page up") + was_handeled = self._pass_key_to_contained_listbox(modified_size, "up") # mousewheel down elif button == 5.0: - was_handeled = self._pass_key_to_contained_listbox(modified_size, "page down") + # was_handeled = self._pass_key_to_contained_listbox(modified_size, "page down") + was_handeled = self._pass_key_to_contained_listbox(modified_size, "down") focus_position_after_input = self.get_selected_position() From ca730ded3bcabde8d546577f3fb3310aea928947 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 18 Jan 2024 16:00:28 +0100 Subject: [PATCH 227/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 98a433b..3dd3d2d 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.5" +__version__ = "0.4.6" From 8b69947098dc10fc978ab7207d5ca46b2fe3d907 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 18 Jan 2024 16:05:45 +0100 Subject: [PATCH 228/592] Updated readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fa3f3bb..92269b3 100755 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Nomad Network is a very flexible and extensible platform, and a variety of commu - [LXMF-Bot](https://github.com/randogoth/lxmf-bot) - [LXMF Messageboard](https://github.com/chengtripp/lxmf_messageboard) - [LXMEvent](https://github.com/faragher/LXMEvent) +- [POPR](https://github.com/faragher/POPR) - [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools) ## Help & Discussion From c004adfb8c8bb3b55c07a5be69bde083e85f16b0 Mon Sep 17 00:00:00 2001 From: Aleksei Stepanov Date: Tue, 23 Jan 2024 08:45:58 +0100 Subject: [PATCH 229/592] Fix docker image build * Use non-rc python * Use `pip` for package install `python setup.py --install` is deprecated by setuptools --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0944827..e9920e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-rc-alpine3.14 as build +FROM python:3.12-alpine as build RUN apk add --no-cache build-base linux-headers libffi-dev cargo @@ -8,10 +8,10 @@ ENV PATH="/opt/venv/bin:$PATH" RUN pip3 install setuptools-rust pyopenssl cryptography COPY . /app/ -RUN cd /app/ && python3 setup.py install +RUN cd /app/ && pip3 install . # Use multi-stage build, as we don't need rust compilation on the final image -FROM python:3.11-rc-alpine3.14 +FROM python:3.12-alpine LABEL org.opencontainers.image.documentation="https://github.com/markqvist/NomadNet#nomad-network-daemon-with-docker" From 04376a9f96d0eca8a50ed8c560f1f94da04bf58c Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Thu, 25 Jan 2024 10:14:12 -0500 Subject: [PATCH 230/592] add message notification bell for textui --- nomadnet/NomadNetworkApp.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index bfe71f1..89a142d 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -125,6 +125,7 @@ class NomadNetworkApp: self.peer_announce_at_start = True self.try_propagation_on_fail = True self.disable_propagation = False + self.notify_on_new_message = True self.periodic_lxmf_sync = True self.lxmf_sync_interval = 360*60 @@ -546,6 +547,9 @@ class NomadNetworkApp: nomadnet.Conversation.ingest(message, self) + if self.notify_on_new_message: + self.notify_message_recieved() + if self.should_print(message): self.print_message(message) @@ -649,6 +653,11 @@ class NomadNetworkApp: if os.path.isfile(self.conversationpath + "/" + source_hash + "/unread"): os.unlink(self.conversationpath + "/" + source_hash + "/unread") + def notify_message_recieved(self): + if self.uimode == nomadnet.ui.UI_TEXT: + sys.stdout.write("\a") + sys.stdout.flush() + def clear_tmp_dir(self): if os.path.isdir(self.tmpfilespath): for file in os.listdir(self.tmpfilespath): @@ -726,6 +735,10 @@ class NomadNetworkApp: value = self.config["client"].as_bool(option) self.compact_stream = value + if option == "notify_on_new_message": + value = self.config["client"].as_bool(option) + self.notify_on_new_message = value + if option == "user_interface": value = value.lower() if value == "none": @@ -953,6 +966,7 @@ destination = file enable_client = yes user_interface = text downloads_path = ~/Downloads +notify_on_new_message = yes # By default, the peer is announced at startup # to let other peers reach it immediately. From 641f326be784ac546203da02ae9b396f6f8b5c2f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 16 Feb 2024 17:48:34 +0100 Subject: [PATCH 231/592] Updated readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 92269b3..2352951 100755 --- a/README.md +++ b/README.md @@ -143,16 +143,16 @@ For help requests, discussion, sharing ideas or anything else related to Nomad N You can help support the continued development of open, free and private communications systems by donating via one of the following channels: - Monero: - ``` + ``` 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w ``` - Ethereum ``` - 0x81F7B979fEa6134bA9FD5c701b3501A2e61E897a + 0xFDabC71AC4c0C78C95aDDDe3B4FA19d6273c5E73 ``` - Bitcoin ``` - 3CPmacGm34qYvR6XWLVEJmi2aNe3PZqUuq + 35G9uWVzrpJJibzUwpNUQGQNFzLirhrYAH ``` - Ko-Fi: https://ko-fi.com/markqvist From ff45c597f8499b6e6eecf0b5a2f7ff7b7c88f207 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 16 Feb 2024 17:54:07 +0100 Subject: [PATCH 232/592] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2352951..e487f56 100755 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ For help requests, discussion, sharing ideas or anything else related to Nomad N You can help support the continued development of open, free and private communications systems by donating via one of the following channels: - Monero: - ``` + ``` 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w ``` - Ethereum From e695cce3ba012de6b524bf804ddf343212238ea1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 29 Feb 2024 18:07:34 +0100 Subject: [PATCH 233/592] Added issue templates --- .github/ISSUE_TEMPLATE/config.yml | 11 ++++++++ .github/ISSUE_TEMPLATE/🐛-bug-report.md | 35 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/🐛-bug-report.md diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..caf3250 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ✨ Feature Request or Idea + url: https://github.com/markqvist/Reticulum/discussions/new?category=ideas + about: Propose and discuss features and ideas + - name: 💬 Questions, Help & Discussion + about: Ask anything, or get help + url: https://github.com/markqvist/Reticulum/discussions/new/choose + - name: 📖 Read the Reticulum Manual + url: https://markqvist.github.io/Reticulum/manual/ + about: The complete documentation for Reticulum diff --git a/.github/ISSUE_TEMPLATE/🐛-bug-report.md b/.github/ISSUE_TEMPLATE/🐛-bug-report.md new file mode 100644 index 0000000..77ad6c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/🐛-bug-report.md @@ -0,0 +1,35 @@ +--- +name: "\U0001F41B Bug Report" +about: Report a reproducible bug +title: '' +labels: '' +assignees: '' + +--- + +**Read the Contribution Guidelines** +Before creating a bug report on this issue tracker, you **must** read the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md). Issues that do not follow the contribution guidelines **will be deleted without comment**. + +- The issue tracker is used by developers of this project. **Do not use it to ask general questions, or for support requests**. +- Ideas and feature requests can be made on the [Discussions](https://github.com/markqvist/Reticulum/discussions). **Only** feature requests accepted by maintainers and developers are tracked and included on the issue tracker. **Do not post feature requests here**. +- After reading the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md), delete this section from your bug report. + +**Describe the Bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Describe in detail how to reproduce the bug. + +**Expected Behavior** +A clear and concise description of what you expected to happen. + +**Logs & Screenshots** +Please include any relevant log output. If applicable, also add screenshots to help explain your problem. + +**System Information** +- OS and version +- Python version +- Program version + +**Additional context** +Add any other context about the problem here. From 6026f42f34179380895380e6e555405c23647696 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 29 Feb 2024 18:42:47 +0100 Subject: [PATCH 234/592] Check browser destination before displaying save dialog. Fixes #35. --- nomadnet/ui/textui/Browser.py | 82 ++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 7ce5d99..c8dd6e0 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -664,52 +664,56 @@ class Browser: self.delegate.columns.focus_position = 1 def save_node_dialog(self): - def dismiss_dialog(sender): - self.close_dialogs() + if self.destination_hash != None: + try: + def dismiss_dialog(sender): + self.close_dialogs() - display_name = RNS.Identity.recall_app_data(self.destination_hash) - disp_str = "" - if display_name != None: - display_name = display_name.decode("utf-8") - disp_str = " \""+display_name+"\"" - - def confirmed(sender): - node_entry = DirectoryEntry(self.destination_hash, display_name=display_name, hosts_node=True) - self.app.directory.remember(node_entry) - self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + display_name = RNS.Identity.recall_app_data(self.destination_hash) + disp_str = "" + if display_name != None: + display_name = display_name.decode("utf-8") + disp_str = " \""+display_name+"\"" + + def confirmed(sender): + node_entry = DirectoryEntry(self.destination_hash, display_name=display_name, hosts_node=True) + self.app.directory.remember(node_entry) + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() - self.close_dialogs() + self.close_dialogs() - dialog = UrlDialogLineBox( - urwid.Pile([ - urwid.Text("Save connected node"+disp_str+" "+RNS.prettyhexrep(self.destination_hash)+" to Known Nodes?\n"), - urwid.Columns([ - (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), - (urwid.WEIGHT, 0.1, urwid.Text("")), - (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), - ]) - ]), title="Save Node" - ) + dialog = UrlDialogLineBox( + urwid.Pile([ + urwid.Text("Save connected node"+disp_str+" "+RNS.prettyhexrep(self.destination_hash)+" to Known Nodes?\n"), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + ]) + ]), title="Save Node" + ) - dialog.confirmed = confirmed - dialog.delegate = self - bottom = self.display_widget + dialog.confirmed = confirmed + dialog.delegate = self + bottom = self.display_widget - overlay = urwid.Overlay( - dialog, - bottom, - align=urwid.CENTER, - width=(urwid.RELATIVE, 50), - valign=urwid.MIDDLE, - height=urwid.PACK, - left=2, - right=2, - ) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=(urwid.RELATIVE, 50), + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) - self.delegate.columns.contents[1] = (overlay, options) - self.delegate.columns.focus_position = 1 + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) + self.delegate.columns.contents[1] = (overlay, options) + self.delegate.columns.focus_position = 1 + except Exception as e: + pass def load_page(self): if self.request_data == None: From bcca6be48767a20bbdf862a55ce181b84bf24e21 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 1 Mar 2024 17:55:13 +0100 Subject: [PATCH 235/592] Updated guide text --- nomadnet/ui/textui/Guide.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 3913c55..a7b8a77 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -300,7 +300,7 @@ To learn how to host your own node, read the `*Hosting a Node`* section of this TOPIC_HOSTING = '''>Hosting a Node -To host a node on the network, you must enable it in the configuration file, by setting `*enable_node`* directive to `*yes`*. You should also configure the other node-related parameters such as the node name and announce interval settings. Once node hosting has been enabled in the configuration, Nomad Network will start hosting your node as soon as the program is launched, and other peers on the network will be able to connect and interact with content on your node. +To host a node on the network, you must enable it in the configuration file, by setting the `*enable_node`* directive to `*yes`*. You should also configure the other node-related parameters such as the node name and announce interval settings. Once node hosting has been enabled in the configuration, Nomad Network will start hosting your node as soon as the program is launched, and other peers on the network will be able to connect and interact with content on your node. By default, no content is defined, apart from a short placeholder home page. To learn how to add your own content, read on. @@ -310,9 +310,12 @@ All nodes on the network will automatically participate in a distributed message When Nomad Network is configured to host a node, by default it also configures itself as an LXMF Propagation Node, and automatically discovers and peers with other propagation nodes on the network. This process is completely automatic and requires no configuration from the node operator. -If there is already an abundance of Propagation Nodes on the network, or the operator simply wishes to host a pageserving-only node, Propagation Node hosting can be disabled in the configuration file. +`!However`!, if there is already an abundance of Propagation Nodes on the network, or the operator simply wishes to host a pageserving-only node, Propagation Node hosting can be disabled in the configuration file. -To view LXMF Propagation nodes that are currently peered with your node, go to the `![ Network ]`! part of the program and press `!Ctrl-P`!. In the list of peered Propagation Nodes, it is possible to break peering with a node by pressing `!Ctrl-X`!. It is also possible to request an immediate delivery sync of all unhandled messages for a node, by pressing `!Ctrl-R`!. +To view LXMF Propagation nodes that are currently peered with your node, go to the `![ Network ]`! part of the program and press `!Ctrl-P`!. In the list of peered Propagation Nodes, it is possible to: + + - Immediately break peering with a node by pressing `!Ctrl-X`! + - Request an immediate delivery sync of all unhandled messages for a node, by pressing `!Ctrl-R`! The distributed message store is resilient to intermittency, and will remain functional as long as at least one node remains on the network. Nodes that were offline for a time will automatically be synced up to date when they regain connectivity. @@ -326,9 +329,9 @@ You can control how long a peer will cache your pages by including the cache hea >> Dynamic Pages -You can use a preprocessor such as PHP, bash, Python (or whatever you prefer) to generate dynamic pages. To do so, just set executable permissions on the relevant page file, and be sure to include the interpreter at the beginning of the file, for example `!#!/usr/bin/python3`!. +You can use a preprocessor such as PHP, bash, Python (or whatever you prefer) to generate dynamic pages and fully interactive applications running over Nomad Network. To do so, just set executable permissions on the relevant page file, and be sure to include the interpreter at the beginning of the file, for example `!#!/usr/bin/python3`!. -Data from fields and link variables will be passed to these scipts or programs as environment variables, and can simply be read by any method for acessing such. +Data from fields and link variables will be passed to these scipts or programs as environment variables, and can simply be read by any method for accessing such. In the `!examples`! directory, you can find various small examples for the use of this feature. The currently included examples are: From 026ff7b5c7c69574ddd4b7a56cf2805e74a041d5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 1 Mar 2024 17:55:19 +0100 Subject: [PATCH 236/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 3dd3d2d..a34b2f6 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.6" +__version__ = "0.4.7" diff --git a/setup.py b/setup.py index 2bc72d0..8e5442e 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.0", "lxmf>=0.3.9", "urwid>=2.4.2,!=2.4.3", "qrcode"], + install_requires=["rns>=0.7.2", "lxmf>=0.4.0", "urwid>=2.4.2,!=2.4.3", "qrcode"], python_requires=">=3.6", ) From d9bba6fd6999dc400627db8617ffd24c1657c0fb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 2 Mar 2024 00:11:17 +0100 Subject: [PATCH 237/592] Added LXMF transfer size limit options --- nomadnet/NomadNetworkApp.py | 46 +++++++++++++++++++++++++++++++++++-- nomadnet/ui/textui/Guide.py | 12 ++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 89a142d..c14ed82 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -119,7 +119,7 @@ class NomadNetworkApp: self.should_run_jobs = True self.job_interval = 5 self.defer_jobs = 90 - self.page_refresh_interval = 0 + self.page_refresh_interval = 0 self.file_refresh_interval = 0 self.peer_announce_at_start = True @@ -127,6 +127,9 @@ class NomadNetworkApp: self.disable_propagation = False self.notify_on_new_message = True + self.lxmf_max_propagation_size = None + self.lxmf_max_incoming_size = None + self.periodic_lxmf_sync = True self.lxmf_sync_interval = 360*60 self.lxmf_sync_limit = 8 @@ -283,7 +286,11 @@ class NomadNetworkApp: self.directory = nomadnet.Directory(self) - self.message_router = LXMF.LXMRouter(identity = self.identity, storagepath = self.storagepath, autopeer = True) + self.message_router = LXMF.LXMRouter( + identity = self.identity, storagepath = self.storagepath, autopeer = True, + propagation_limit = self.lxmf_max_propagation_size, delivery_limit = self.lxmf_max_incoming_size, + ) + self.message_router.register_delivery_callback(self.lxmf_delivery) for destination_hash in self.ignored_list: @@ -731,6 +738,14 @@ class NomadNetworkApp: else: self.lxmf_sync_limit = None + if option == "max_accepted_size": + value = self.config["client"].as_float(option) + + if value > 0: + self.lxmf_max_incoming_size = value + else: + self.lxmf_max_incoming_size = 500 + if option == "compact_announce_stream": value = self.config["client"].as_bool(option) self.compact_stream = value @@ -829,6 +844,14 @@ class NomadNetworkApp: else: self.disable_propagation = self.config["node"].as_bool("disable_propagation") + if not "max_transfer_size" in self.config["node"]: + self.lxmf_max_propagation_size = 256 + else: + value = self.config["node"].as_float("max_transfer_size") + if value < 1: + value = 1 + self.lxmf_max_propagation_size = value + if not "announce_at_start" in self.config["node"]: self.node_announce_at_start = False else: @@ -994,6 +1017,13 @@ lxmf_sync_interval = 360 # the limit, and download everything every time. lxmf_sync_limit = 8 +# The maximum accepted unpacked size for mes- +# sages received directly from other peers, +# specified in kilobytes. Messages larger than +# this will be rejected before the transfer +# begins. +max_accepted_size = 500 + # The announce stream will only show one entry # per destination or node by default. You can # change this to show as many announces as have @@ -1082,6 +1112,18 @@ announce_at_start = Yes # and defaults to 2 gigabytes. # message_storage_limit = 2000 +# The maximum accepted transfer size per in- +# coming propagation transfer, in kilobytes. +# This also sets the upper limit for the size +# of single messages accepted onto this node. +# +# If a node wants to propagate a larger number +# of messages to this node, than what can fit +# within this limit, it will prioritise sending +# the smallest, newest messages first, and try +# with any remaining messages at a later point. +max_transfer_size = 256 + # You can tell the LXMF message router to # prioritise storage for one or more # destinations. If the message store reaches diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index a7b8a77..6b3720c 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -515,6 +515,12 @@ The number of minutes between each automatic sync. The default is equal to 6 hou On low-bandwidth networks, it can be useful to limit the amount of messages downloaded in each sync. The default is 8. Set to 0 to download all available messages every time a sync occurs. < +>>> +`!max_accepted_size = 500`! +>>>> +The maximum accepted unpacked size for messages received directly from other peers, specified in kilobytes. Messages larger than this will be rejected before the transfer begins. +< + >>> `!compact_announce_stream = yes`! >>>> @@ -653,6 +659,12 @@ By default, when Nomad Network is hosting a node, it will also run an LXMF propa Configures the maximum amount of storage, in megabytes, that the LXMF Propagation Node will use to store messages. < +>>> +`!max_transfer_size = 256`! +>>>> +The maximum accepted transfer size per incoming propagation transfer, in kilobytes. This also sets the upper limit for the size of single messages accepted onto this propagation node. If a node wants to propagate a larger number of messages to this node, than what can fit within this limit, it will prioritise sending the smallest, newest messages first, and try with any remaining messages at a later point. +< + >>> `!prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf`! >>>> From f1989cfc6e52508fac0604e1e47d611ae06809dc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 2 Mar 2024 09:11:09 +0100 Subject: [PATCH 238/592] Fixed inadverdent trust level warning --- nomadnet/Directory.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index fd8cacb..b4a993e 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -257,11 +257,12 @@ class Directory: if announced_display_name == None: return self.directory_entries[source_hash].trust_level else: - for entry in self.directory_entries: - e = self.directory_entries[entry] - if e.display_name == announced_display_name: - if e.source_hash != source_hash: - return DirectoryEntry.WARNING + if not self.directory_entries[source_hash].trust_level == DirectoryEntry.TRUSTED: + for entry in self.directory_entries: + e = self.directory_entries[entry] + if e.display_name == announced_display_name: + if e.source_hash != source_hash: + return DirectoryEntry.WARNING return self.directory_entries[source_hash].trust_level else: From add8b295ec58cfa520a63faeb8b019e80147fb63 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Mar 2024 00:31:25 +0100 Subject: [PATCH 239/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index a34b2f6..a3a9bd5 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.7" +__version__ = "0.4.8" From 6a4f2026249b22a00f6ff98c12d06fd5b160a90d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 Mar 2024 00:31:38 +0100 Subject: [PATCH 240/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8e5442e..0459349 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.2", "lxmf>=0.4.0", "urwid>=2.4.2,!=2.4.3", "qrcode"], + install_requires=["rns>=0.7.2", "lxmf>=0.4.1", "urwid>=2.4.2,!=2.4.3", "qrcode"], python_requires=">=3.6", ) From c2fb2ca9f82ce8333116e449b7b480812ee1273f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 5 May 2024 20:13:00 +0200 Subject: [PATCH 241/592] Updated version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index a3a9bd5..574c066 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.8" +__version__ = "0.4.9" diff --git a/setup.py b/setup.py index 0459349..16ec390 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.2", "lxmf>=0.4.1", "urwid>=2.4.2,!=2.4.3", "qrcode"], + install_requires=["rns>=0.7.4", "lxmf>=0.4.3", "urwid>=2.4.2,!=2.4.3", "qrcode"], python_requires=">=3.6", ) From 51f0048e7c151a99e132d4d3334dae05bb6ada6a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 18 May 2024 14:56:22 +0200 Subject: [PATCH 242/592] Updated nerd font glyphs. Fixes #55. --- nomadnet/ui/TextUI.py | 14 +++++++------- nomadnet/ui/textui/Guide.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 9ecdddc..3e4b3cf 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -97,10 +97,10 @@ GLYPHSETS = { } if platform.system() == "Darwin": - urm_char = " \uf0e0 " + urm_char = " \uf0e0" ur_char = "\uf0e0 " else: - urm_char = " \uf003 " + urm_char = " \uf003" ur_char = "\uf003 " GLYPHS = { @@ -115,17 +115,17 @@ GLYPHS = { ("arrow_u", "/\\", "\u2191", "\u2191"), ("arrow_d", "\\/", "\u2193", "\u2193"), ("warning", "!", "\u26a0", "\uf12a"), - ("info", "i", "\u2139", "\ufb4d"), + ("info", "i", "\u2139", "\U000f064e"), ("unread", "[!]", "\u2709", ur_char), ("divider1", "-", "\u2504", "\u2504"), ("peer", "[P]", "\u24c5 ", "\uf415"), - ("node", "[N]", "\u24c3 ", "\uf502"), + ("node", "[N]", "\u24c3 ", "\U000f0002"), ("page", "", "\u25a4 ", "\uf719 "), - ("speed", "", "\u25F7 ", "\uf9c4"), - ("decoration_menu", " +", " +", " \uf93a"), + ("speed", "", "\u25F7 ", "\U000f04c5 "), + ("decoration_menu", " +", " +", " \U000f043b"), ("unread_menu", " !", " \u2709", urm_char), ("globe", "", "", "\uf484"), - ("sent", "/\\", "\u2191", "\ufbf4"), + ("sent", "/\\", "\u2191", "\U000f0cd8"), ("papermsg", "P", "\u25a4", "\uf719"), ("qrcode", "QR", "\u25a4", "\uf029"), } diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 6b3720c..a7a6ee2 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -783,7 +783,7 @@ The following line should contain a grayscale gradient bar: Unicode Glyphs : \u2713 \u2715 \u26a0 \u24c3 \u2193 -Nerd Font Glyphs : \uf484 \uf9c4 \uf719 \uf502 \uf415 \uf023 \uf06e +Nerd Font Glyphs : \uf484 \U000f04c5 \U000f0219 \U000f0002 \uf415 \uf023 \uf06e ''' From b14d42a17c911b3f1f43f4ec1afcafe6e6046bf5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 18 May 2024 15:15:00 +0200 Subject: [PATCH 243/592] Updated versions --- requirements.txt | 6 ------ setup.py | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 884128f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -compiler==0.2.0 -configobj==5.0.8 -lxmf==0.3.2 -rns==0.5.7 -setuptools==68.0.0 -urwid==2.1.2 diff --git a/setup.py b/setup.py index 16ec390..36362d9 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.4", "lxmf>=0.4.3", "urwid>=2.4.2,!=2.4.3", "qrcode"], + install_requires=["rns>=0.7.5", "lxmf>=0.4.3", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From 7bf577a8c5f77b725901ff424359dd5d4110385b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 25 May 2024 22:54:23 +0200 Subject: [PATCH 244/592] Updated guide --- nomadnet/ui/textui/Guide.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index a7a6ee2..7a3b5c8 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -485,6 +485,12 @@ Selects which interface to use. Currently, only the `!text`! interface is availa Sets the filesystem path to store downloaded files in. < +>>> +`!notify_on_new_message = yes`! +>>>> +Sets whether to output a notification character (bell or flash) to the terminal when a new message is received. +< + >>> `!announce_at_start = yes`! >>>> @@ -1064,7 +1070,7 @@ Links can contain request variables and a list of fields to submit to the node-s `= `` -Note the `!*`! following the extra `!\``! at the end of the path. This `!*`! denotes `*all fields`*. You can also specify a list of fields to include: +Note the `!*`! following the extra `!\\``! at the end of the path. This `!*`! denotes `*all fields`*. You can also specify a list of fields to include: `Faaa `= From 8ad19cf048a9c63d84a5586d05a83c3cf6130260 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 29 May 2024 00:38:40 +0200 Subject: [PATCH 245/592] Updated readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index e487f56..313c0af 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Nomad Network allows you to build private and resilient communications platforms Nomad Network is build on [LXMF](https://github.com/markqvist/LXMF) and [Reticulum](https://github.com/markqvist/Reticulum), which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. -Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. +Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over *extremely* low-bandwidth medium, and has been succesfully used over 300bps radio links. If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android and macOS. @@ -161,7 +161,6 @@ You can help support the continued development of open, free and private communi - New major features - Network-wide propagated bulletins and discussion threads - Collaborative maps and geospatial information sharing - - Facilitation of trade and barter - Minor improvements and fixes - Link status (RSSI and SNR) in conversation or conv list - Ctrl-M shorcut for jumping to menu From 648242b99fdf836648a7aed21ce3f261c3ba3e4e Mon Sep 17 00:00:00 2001 From: donuts-are-good <96031819+donuts-are-good@users.noreply.github.com> Date: Wed, 3 Jul 2024 11:47:55 -0500 Subject: [PATCH 246/592] Fix: Grammar, it's -> its --- nomadnet/ui/textui/Conversations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 3b7c538..9a5405d 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1056,7 +1056,7 @@ class ConversationWidget(urwid.WidgetWrap): else: warning = urwid.AttrMap( urwid.Padding(urwid.Text( - "\n"+g["info"]+"\n\nYou cannot currently message this peer, since it's identity keys are not known. " + "\n"+g["info"]+"\n\nYou cannot currently message this peer, since its identity keys are not known. " "The keys have been requested from the network and should arrive shortly, if available. " "Close this conversation and reopen it to try again.\n\n" "To query the network manually, select this conversation in the conversation list, " From 4a1832ae3423f1e7c88be39831db81cab19bdcfe Mon Sep 17 00:00:00 2001 From: edd <> Date: Sat, 17 Aug 2024 01:41:51 +0300 Subject: [PATCH 247/592] Add Windows log static tail --- nomadnet/ui/textui/Log.py | 96 +++++++++++++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 9 deletions(-) diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index c09855d..9156e4e 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -1,6 +1,11 @@ +import os +import sys +import itertools +import mmap import urwid import nomadnet + class LogDisplayShortcuts(): def __init__(self, app): import urwid @@ -8,28 +13,31 @@ class LogDisplayShortcuts(): self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") + class LogDisplay(): def __init__(self, app): self.app = app - self.log_term = None self.shortcuts_display = LogDisplayShortcuts(self.app) self.widget = None + @property + def log_term(self): + return self.widget + def show(self): - if self.log_term == None: - self.log_term = LogTerminal(self.app) - self.widget = urwid.LineBox(self.log_term) + if self.widget is None: + self.widget = log_widget(self.app) def kill(self): - if self.log_term != None: - self.log_term.terminate() - self.log_term = None + if self.widget is not None: + self.widget.terminate() self.widget = None def shortcuts(self): return self.shortcuts_display + class LogTerminal(urwid.WidgetWrap): def __init__(self, app): self.app = app @@ -39,7 +47,8 @@ class LogTerminal(urwid.WidgetWrap): escape_sequence="up", main_loop=self.app.ui.loop, ) - super().__init__(self.log_term) + self.widget = urwid.LineBox(self.log_term) + super().__init__(self.widget) def terminate(self): self.log_term.terminate() @@ -49,4 +58,73 @@ class LogTerminal(urwid.WidgetWrap): if key == "up": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" - return super(LogTerminal, self).keypress(size, key) \ No newline at end of file + return super(LogTerminal, self).keypress(size, key) + + +class LogTail(urwid.WidgetWrap): + def __init__(self, app): + self.app = app + self.log_tail = urwid.Text(tail(self.app.logfilepath, 50)) + self.log = urwid.Scrollable(self.log_tail) + self.log.set_scrollpos(-1) + self.log_scrollbar = urwid.ScrollBar(self.log) + # We have this here because ui.textui.Main depends on this field to kill it + self.log_term = None + + super().__init__(self.log_scrollbar) + + def terminate(self): + pass + + +def log_widget(app, platform=sys.platform): + if platform == "linux": + return LogTerminal(app) + elif platform == "win32": + return LogTail(app) + else: + raise ValueError(f"Unknown platform {platform}. Expected linux/win32") + + +# https://stackoverflow.com/a/34029605/3713120 +def _tail(f_name, n, offset=0): + def skip_back_lines(mm: mmap.mmap, numlines: int, startidx: int) -> int: + '''Factored out to simplify handling of n and offset''' + for _ in itertools.repeat(None, numlines): + startidx = mm.rfind(b'\n', 0, startidx) + if startidx < 0: + break + return startidx + + # Open file in binary mode + with open(f_name, 'rb') as binf, mmap.mmap(binf.fileno(), 0, access=mmap.ACCESS_READ) as mm: + # len(mm) - 1 handles files ending w/newline by getting the prior line + startofline = skip_back_lines(mm, offset, len(mm) - 1) + if startofline < 0: + return [] # Offset lines consumed whole file, nothing to return + # If using a generator function (yield-ing, see below), + # this should be a plain return, no empty list + + endoflines = startofline + 1 # Slice end to omit offset lines + + # Find start of lines to capture (add 1 to move from newline to beginning of following line) + startofline = skip_back_lines(mm, n, startofline) + 1 + + # Passing True to splitlines makes it return the list of lines without + # removing the trailing newline (if any), so list mimics f.readlines() + # return mm[startofline:endoflines].splitlines(True) + # If Windows style \r\n newlines need to be normalized to \n + return mm[startofline:endoflines].replace(os.linesep.encode(sys.getdefaultencoding()), b'\n').splitlines(True) + + +def tail(f_name, n): + """ + Return the last n lines of a given file name, f_name. + Akin to `tail - ` + """ + def decode(b): + return b.decode(encoding) + + encoding = sys.getdefaultencoding() + lines = map(decode, _tail(f_name=f_name, n=n)) + return ''.join(lines) From ecb6ca6553be9c7365cc872871a67f2c84761a2f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 17 Aug 2024 21:24:11 +0200 Subject: [PATCH 248/592] Cleanup --- nomadnet/ui/textui/Log.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index 9156e4e..bea98ef 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -78,13 +78,10 @@ class LogTail(urwid.WidgetWrap): def log_widget(app, platform=sys.platform): - if platform == "linux": - return LogTerminal(app) - elif platform == "win32": + if platform == "win32": return LogTail(app) else: - raise ValueError(f"Unknown platform {platform}. Expected linux/win32") - + return LogTerminal(app) # https://stackoverflow.com/a/34029605/3713120 def _tail(f_name, n, offset=0): From 77c9e6c9eb118c44f3f88c4b3d1001a2bafcea42 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 29 Aug 2024 15:35:46 +0200 Subject: [PATCH 249/592] Updated version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 574c066..3d18726 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.4.9" +__version__ = "0.5.0" diff --git a/setup.py b/setup.py index 36362d9..5b5560a 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.5", "lxmf>=0.4.3", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.7.6", "lxmf>=0.4.5", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From 76cb1f73f58b19102a9161f80386456d87edbd43 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 8 Sep 2024 01:23:09 +0200 Subject: [PATCH 250/592] Ratchet, stamp and ticket compatibility --- nomadnet/Conversation.py | 32 ++++++++++++++++++++++++-------- setup.py | 2 +- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index a79d60b..8e2e523 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -27,6 +27,13 @@ class Conversation: if Conversation.created_callback != None: Conversation.created_callback() + # This reformats the new v0.5.0 announce data back to the expected format + # for nomadnets storage and other handling functions. + dn = LXMF.display_name_from_app_data(app_data) + app_data = b"" + if dn != None: + app_data = dn.encode("utf-8") + # Add the announce to the directory announce # stream logger app.directory.lxmf_announce_received(destination_hash, app_data) @@ -281,13 +288,17 @@ class Conversation: def message_notification(self, message): if message.state == LXMF.LXMessage.FAILED and hasattr(message, "try_propagation_on_fail") and message.try_propagation_on_fail: - RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) - message.try_propagation_on_fail = None - message.delivery_attempts = 0 - del message.next_delivery_attempt - message.packed = None - message.desired_method = LXMF.LXMessage.PROPAGATED - self.app.message_router.handle_outbound(message) + if hasattr(message, "stamp_generation_failed") and message.stamp_generation_failed == True: + RNS.log(f"Could not send {message} due to a stamp generation failure", RNS.LOG_ERROR) + else: + RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) + message.try_propagation_on_fail = None + message.delivery_attempts = 0 + if hasattr(message, "next_delivery_attempt"): + del message.next_delivery_attempt + message.packed = None + message.desired_method = LXMF.LXMessage.PROPAGATED + self.app.message_router.handle_outbound(message) else: message_path = Conversation.ingest(message, self.app, originator=True) @@ -318,12 +329,17 @@ class ConversationMessage: self.timestamp = self.lxm.timestamp self.sort_timestamp = os.path.getmtime(self.file_path) - if self.lxm.state > LXMF.LXMessage.DRAFT and self.lxm.state < LXMF.LXMessage.SENT: + if self.lxm.state > LXMF.LXMessage.GENERATING and self.lxm.state < LXMF.LXMessage.SENT: found = False + for pending in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_outbound: if pending.hash == self.lxm.hash: found = True + for pending_id in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_deferred_stamps: + if pending_id == self.lxm.hash: + found = True + if not found: self.lxm.state = LXMF.LXMessage.FAILED diff --git a/setup.py b/setup.py index 5b5560a..1316a8d 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.6", "lxmf>=0.4.5", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.7.7", "lxmf>=0.5.0", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From 0135de3e0ebc9a387cb532c04815ded7a77fadea Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 9 Sep 2024 16:25:41 +0200 Subject: [PATCH 251/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 3d18726..dd9b22c 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.5.1" From 1bbfacee9435cb8f2eda0317c49a12d02b2b2e16 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 11 Sep 2024 00:20:35 +0200 Subject: [PATCH 252/592] Add stamp configuration options --- nomadnet/Conversation.py | 6 ++++- nomadnet/NomadNetworkApp.py | 49 ++++++++++++++++++++++++++++++++++--- nomadnet/ui/textui/Guide.py | 21 ++++++++++++---- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 8e2e523..057e040 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -217,7 +217,11 @@ class Conversation: if self.app.message_router.get_outbound_propagation_node() != None: desired_method = LXMF.LXMessage.PROPAGATED - lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method) + dest_is_trusted = False + if self.app.directory.trust_level(dest.hash) == DirectoryEntry.TRUSTED: + dest_is_trusted = True + + lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method, include_ticket=dest_is_trusted) lxm.register_delivery_callback(self.message_notification) lxm.register_failed_callback(self.message_notification) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index c14ed82..84fdcd5 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -134,6 +134,10 @@ class NomadNetworkApp: self.lxmf_sync_interval = 360*60 self.lxmf_sync_limit = 8 self.compact_stream = False + + self.required_stamp_cost = None + self.accept_invalid_stamps = False + if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -296,8 +300,9 @@ class NomadNetworkApp: for destination_hash in self.ignored_list: self.message_router.ignore_destination(destination_hash) - self.lxmf_destination = self.message_router.register_delivery_identity(self.identity, display_name=self.peer_settings["display_name"]) - self.lxmf_destination.set_default_app_data(self.get_display_name_bytes) + self.lxmf_destination = self.message_router.register_delivery_identity(self.identity, display_name=self.peer_settings["display_name"], stamp_cost=self.required_stamp_cost) + if not self.accept_invalid_stamps: + self.message_router.enforce_stamps() RNS.Identity.remember( packet_hash=None, @@ -492,7 +497,9 @@ class NomadNetworkApp: self.message_router.cancel_propagation_node_requests() def announce_now(self): - self.lxmf_destination.announce() + self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost) + self.lxmf_destination.display_name = self.peer_settings["display_name"] + self.message_router.announce(self.lxmf_destination.hash) self.peer_settings["last_announce"] = time.time() self.save_peer_settings() @@ -738,6 +745,24 @@ class NomadNetworkApp: else: self.lxmf_sync_limit = None + if option == "required_stamp_cost": + value = self.config["node"]["node_name"] + if value.lower() == "none": + self.required_stamp_cost = None + else: + value = self.config["client"].as_int(option) + + if value > 0: + if value > 255: + value = 255 + self.required_stamp_cost = value + else: + self.required_stamp_cost = None + + if option == "accept_invalid_stamps": + value = self.config["client"].as_bool(option) + self.accept_invalid_stamps = value + if option == "max_accepted_size": value = self.config["client"].as_float(option) @@ -1017,6 +1042,24 @@ lxmf_sync_interval = 360 # the limit, and download everything every time. lxmf_sync_limit = 8 +# You can specify a required stamp cost for +# inbound messages to be accepted. Specifying +# a stamp cost will require untrusted senders +# that message you to include a cryptographic +# stamp in their messages. Performing this +# operation takes the sender an amount of time +# proportional to the stamp cost. As a rough +# estimate, a stamp cost of 8 will take less +# than a second to compute, and a stamp cost +# of 20 could take several minutes, even on +# a fast computer. +required_stamp_cost = None + +# You can signal stamp requirements to senders, +# but still accept messages with invalid stamps +# by setting this option to True. +accept_invalid_stamps = False + # The maximum accepted unpacked size for mes- # sages received directly from other peers, # specified in kilobytes. Messages larger than diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 7a3b5c8..69305cf 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -521,6 +521,18 @@ The number of minutes between each automatic sync. The default is equal to 6 hou On low-bandwidth networks, it can be useful to limit the amount of messages downloaded in each sync. The default is 8. Set to 0 to download all available messages every time a sync occurs. < +>>> +`!required_stamp_cost = None`! +>>>> +You can specify a required stamp cost for inbound messages to be accepted. Specifying a stamp cost will require untrusted senders that message you to include a cryptographic stamp in their messages. Performing this operation takes the sender an amount of time proportional to the stamp cost. As a rough estimate, a stamp cost of 8 will take less than a second to compute, and a stamp cost of 20 could take several minutes, even on a fast computer. +< + +>>> +`!accept_invalid_stamps = False`! +>>>> +You can signal stamp requirements to senders, but still accept messages with invalid stamps by setting this option to True. +< + >>> `!max_accepted_size = 500`! >>>> @@ -739,12 +751,11 @@ If you have Internet access, and just want to get started experimenting, you are The Testnet also runs the latest version of Reticulum, often even a short while before it is publicly released, which means strange behaviour might occur. If none of that scares you, add the following interface to your Reticulum configuration file to join: >> -[[RNS Testnet Zurich]] +[[RNS Testnet Dublin]] type = TCPClientInterface - interface_enabled = yes - outgoing = True - target_host = zurich.connect.reticulum.network - target_port = 4242 + enabled = yes + target_host = dublin.connect.reticulum.network + target_port = 4965 < If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: From 53922757823548bcb89a2e9cf77f184c13aadece Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 11 Sep 2024 11:49:36 +0200 Subject: [PATCH 253/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1316a8d..ca07184 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.7", "lxmf>=0.5.0", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.7.7", "lxmf>=0.5.1", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From e755641dbe9d54cc2e36986f635d13886d0bae80 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 11 Sep 2024 11:49:50 +0200 Subject: [PATCH 254/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index dd9b22c..7225152 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.5.1" +__version__ = "0.5.2" From 03a02a9ebc4fb1a2cc985bb48a7fb99b6d1bf5f5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 11 Sep 2024 13:02:05 +0200 Subject: [PATCH 255/592] Fixed incorrect display name loading in conversation list --- nomadnet/Conversation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 057e040..7487c87 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -102,7 +102,7 @@ class Conversation: unread = True if display_name == None and app_data: - display_name = app_data.decode("utf-8") + display_name = LXMF.display_name_from_app_data(app_data) if display_name == None: sort_name = "" From 112a45f270b51d14ded7572bb8996e7ef9f8ce28 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 Sep 2024 14:50:32 +0200 Subject: [PATCH 256/592] Fixed invalid dict key. Fixes #59. --- nomadnet/NomadNetworkApp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 84fdcd5..b9ec687 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -746,7 +746,7 @@ class NomadNetworkApp: self.lxmf_sync_limit = None if option == "required_stamp_cost": - value = self.config["node"]["node_name"] + value = self.config["client"][option] if value.lower() == "none": self.required_stamp_cost = None else: From 0df8b56d58360fe8946eaec15e87059758ffc006 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 17 Sep 2024 14:53:15 +0200 Subject: [PATCH 257/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 7225152..43a1e95 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.5.2" +__version__ = "0.5.3" diff --git a/setup.py b/setup.py index ca07184..c14730e 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.7", "lxmf>=0.5.1", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.7.8", "lxmf>=0.5.2", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From c61da069f2d19dbf8315d5858eeac97efefd6add Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Oct 2024 11:14:17 +0200 Subject: [PATCH 258/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c14730e..b7fd8da 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.7.8", "lxmf>=0.5.2", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.8.2", "lxmf>=0.5.5", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From 0e79c3299ca503fb2f2888b85f681b4e257389f8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Oct 2024 11:25:08 +0200 Subject: [PATCH 259/592] Added opportunistic delivery if destination ratchets are available --- nomadnet/Conversation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 7487c87..9b3f804 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -216,6 +216,10 @@ class Conversation: if self.app.directory.preferred_delivery(dest.hash) == DirectoryEntry.PROPAGATED: if self.app.message_router.get_outbound_propagation_node() != None: desired_method = LXMF.LXMessage.PROPAGATED + else: + if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None: + RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG) + desired_method = LXMF.LXMessage.OPPORTUNISTIC dest_is_trusted = False if self.app.directory.trust_level(dest.hash) == DirectoryEntry.TRUSTED: From 289136a63226a8af0f59df9322ffb58cf497183e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Oct 2024 11:26:05 +0200 Subject: [PATCH 260/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 43a1e95..6b27eee 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.5.3" +__version__ = "0.5.4" From dc43bc6a22b5be668a3d75be89c5bae0725b1af5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 15 Nov 2024 16:29:46 +0100 Subject: [PATCH 261/592] Fix invalid LXMF link handling in browser --- nomadnet/ui/textui/Browser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index c8dd6e0..58ae008 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1,4 +1,5 @@ import RNS +import LXMF import os import time import urwid @@ -252,7 +253,7 @@ class Browser: display_name = None if display_name_data != None: - display_name = display_name_data.decode("utf-8") + display_name = LXMF.display_name_from_app_data(display_name_data) if not source_hash_text in [c[0] for c in existing_conversations]: entry = DirectoryEntry(bytes.fromhex(source_hash_text), display_name=display_name) From a5aa2097bd7370c3c16bb1fb670b195f42c9b25e Mon Sep 17 00:00:00 2001 From: probability Date: Mon, 25 Nov 2024 18:15:42 -0500 Subject: [PATCH 262/592] Add Checkbox and Radio Button fields to Micron format --- nomadnet/examples/various/input_fields.py | 21 +++- nomadnet/ui/textui/Browser.py | 58 +++++++--- nomadnet/ui/textui/Guide.py | 37 ++++++ nomadnet/ui/textui/MicronParser.py | 131 ++++++++++++++++------ 4 files changed, 192 insertions(+), 55 deletions(-) diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py index 43d7e65..663dc93 100644 --- a/nomadnet/examples/various/input_fields.py +++ b/nomadnet/examples/various/input_fields.py @@ -17,6 +17,8 @@ The following section contains a simple set of fields, and a few different links -= + +>>>Text Fields An input field : `B444``b An masked field : `B444``b @@ -27,7 +29,24 @@ Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. -You can `!`[submit`:/page/input_fields.mu`one|password|small]`! other fields, or just `!`[a single one`:/page/input_fields.mu`username]`! +>> Checkbox Fields + +`B444``b Sign me up + +>> Radio group + +Select your favorite color: + +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue + + +>>> Submitting data + +You can `!`[submit`:/page/input_fields.mu`one|password|small|color]`! other fields, or just `!`[a single one`:/page/input_fields.mu`username]`! Or simply `!`[submit them all`:/page/input_fields.mu`*]`!. diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 58ae008..5e412a4 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -180,23 +180,47 @@ class Browser: else: link_fields.append(e) - def recurse_down(w): - target = None - if isinstance(w, list): - for t in w: - recurse_down(t) - elif isinstance(w, tuple): - for t in w: - recurse_down(t) - elif hasattr(w, "contents"): - recurse_down(w.contents) - elif hasattr(w, "original_widget"): - recurse_down(w.original_widget) - elif hasattr(w, "_original_widget"): - recurse_down(w._original_widget) - else: - if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields): - request_data["field_"+w.field_name] = w.get_edit_text() + def recurse_down(w): + if isinstance(w, list): + for t in w: + recurse_down(t) + elif isinstance(w, tuple): + for t in w: + recurse_down(t) + elif hasattr(w, "contents"): + recurse_down(w.contents) + elif hasattr(w, "original_widget"): + recurse_down(w.original_widget) + elif hasattr(w, "_original_widget"): + recurse_down(w._original_widget) + else: + if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields): + field_key = "field_" + w.field_name + if isinstance(w, urwid.Edit): + request_data[field_key] = w.edit_text + elif isinstance(w, urwid.RadioButton): + if w.state: + user_data = getattr(w, "field_value", None) + if user_data is not None: + request_data[field_key] = user_data + elif isinstance(w, urwid.CheckBox): + user_data = getattr(w, "field_value", "1") + if w.state: + existing_value = request_data.get(field_key, '') + if existing_value: + # Concatenate the new value with the existing one + request_data[field_key] = existing_value + ',' + user_data + else: + # Initialize the field with the current value + request_data[field_key] = user_data + else: + pass # do nothing if checkbox is not check + + + + + + recurse_down(self.attr_maps) RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 69305cf..48dfb56 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1152,6 +1152,43 @@ A sized input field: `B444`<16|with_size`>`B333 A masked input field: `B444``B333 Full control: `B444``B333 +`b +>>> Checkboxes + +In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options. + +`Faaa +`= +``b Label Text` +`= +When the checkbox is checked, it's field will be set to the provided value. If there are multiple checkboxes that share the same field name, the checked values will be concatenated when they are sent to the node by a comma. +`` + +`B444``b Sign me up` + +>>> Radio groups + +Radio groups are another input that lets the user chose from a set of options. Unlike checkboxes, radio buttons with the same field name are mutually exclusive. + +Example: + +`= +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue +`= + +will render: + +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue + +In this example, when the data is submitted, `B444` field_color`b will be set to whichever value from the list was selected. `` diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 27e420b..7668ecf 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -160,7 +160,6 @@ def parse_line(line, state, url_delegate): tw.in_columns = True else: tw = urwid.Text(o, align=state["align"]) - widgets.append((urwid.PACK, tw)) else: if o["type"] == "field": @@ -169,10 +168,37 @@ def parse_line(line, state, url_delegate): fn = o["name"] fs = o["style"] fmask = "*" if o["masked"] else None - f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) + f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=False, mask=fmask) f.field_name = fn fa = urwid.AttrMap(f, fs) widgets.append((fw, fa)) + elif o["type"] == "checkbox": + fn = o["name"] + fv = o["value"] + flabel = o["label"] + fs = o["style"] + f = urwid.CheckBox(flabel, state=False) + f.field_name = fn + f.field_value = fv + fa = urwid.AttrMap(f, fs) + widgets.append((urwid.PACK, fa)) + elif o["type"] == "radio": + fn = o["name"] + fv = o["value"] + flabel = o["label"] + fs = o["style"] + if not "radio_groups" in state: + state["radio_groups"] = {} + if not fn in state["radio_groups"]: + state["radio_groups"][fn] = [] + group = state["radio_groups"][fn] + f = urwid.RadioButton(group, flabel, state=False, user_data=fv) + f.field_name = fn + f.field_value = fv + fa = urwid.AttrMap(f, fs) + widgets.append((urwid.PACK, fa)) + + columns_widget = urwid.Columns(widgets, dividechars=0) text_widget = columns_widget @@ -458,54 +484,85 @@ def make_output(state, line, url_delegate): elif c == "a": state["align"] = state["default_align"] - elif c == "<": + elif c == '<': + if len(part) > 0: + output.append(make_part(state, part)) + part = "" try: - field_name = None - field_name_end = line[i:].find("`") - if field_name_end == -1: - pass + field_start = i + 1 # position after '<' + backtick_pos = line.find('`', field_start) + if backtick_pos == -1: + pass # No '`', invalid field else: - field_name = line[i+1:i+field_name_end] - field_name_skip = len(field_name) + field_content = line[field_start:backtick_pos] field_masked = False field_width = 24 + field_type = "field" + field_name = field_content + field_value = "" + field_data = "" - if "|" in field_name: - f_components = field_name.split("|") + # check if field_content contains '|' + if '|' in field_content: + f_components = field_content.split('|') field_flags = f_components[0] field_name = f_components[1] - if "!" in field_flags: + + # handle field type indicators + if '^' in field_flags: + field_type = "radio" + field_flags = field_flags.replace("^", "") + elif '?' in field_flags: + field_type = "checkbox" + field_flags = field_flags.replace("?", "") + elif '!' in field_flags: field_flags = field_flags.replace("!", "") field_masked = True - if len(field_flags) > 0: - field_width = min(int(field_flags), 256) - def sr(): - return "@{"+str(random.randint(1000,9999))+"}" - rsg = sr() - while rsg in line[i+field_name_end:]: - rsg = sr() - lr = line[i+field_name_end:].replace("\\>", rsg) - endpos = lr.find(">") - - if endpos == -1: - pass - + # Handle field width + if len(field_flags) > 0: + try: + field_width = min(int(field_flags), 256) + except ValueError: + pass # Ignore invalid width + + if len(f_components) > 2: + field_value = f_components[2] else: - field_data = lr[1:endpos].replace(rsg, "\\>") - skip = len(field_data)+field_name_skip+2 - field_data = field_data.replace("\\>", ">") - output.append({ - "type":"field", - "name": field_name, - "width": field_width, - "masked": field_masked, - "data": field_data, - "style": make_style(state) - }) + field_name = field_content + + # Find the closing '>' character + field_end = line.find('>', backtick_pos) + if field_end == -1: + pass # No closing '>', invalid field + else: + field_data = line[backtick_pos+1:field_end] + + # Now, we have all field data + if field_type in ["checkbox", "radio"]: + # for checkboxes and radios, field_data is the label + output.append({ + "type": field_type, + "name": field_name, + "value": field_value if field_value else field_data, + "label": field_data, + "style": make_style(state) + }) + else: + # For text fields field_data is the initial text + output.append({ + "type": "field", + "name": field_name, + "width": field_width, + "masked": field_masked, + "data": field_data, + "style": make_style(state) + }) + skip = field_end - i except Exception as e: pass - + + elif c == "[": endpos = line[i:].find("]") if endpos == -1: From 912c510ab2df91a436b2c60887900e0fcd060cc2 Mon Sep 17 00:00:00 2001 From: zenith Date: Tue, 26 Nov 2024 19:58:53 -0500 Subject: [PATCH 263/592] Add Checkbox and Radio Button fields to Micron format --- nomadnet/examples/various/input_fields.py | 2 +- nomadnet/ui/textui/Guide.py | 4 ++++ nomadnet/ui/textui/MicronParser.py | 26 +++++++++++++++++++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py index 663dc93..5d1eccc 100644 --- a/nomadnet/examples/various/input_fields.py +++ b/nomadnet/examples/various/input_fields.py @@ -31,7 +31,7 @@ The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. >> Checkbox Fields -`B444``b Sign me up +`B444``b Sign me up >> Radio group diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 48dfb56..ba6c4d5 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1166,6 +1166,10 @@ When the checkbox is checked, it's field will be set to the provided value. If t `B444``b Sign me up` +You can also pre-check both checkboxes and radio groups by appending a |* after the field value. + +`B444``b Pre-checked checkbox` + >>> Radio groups Radio groups are another input that lets the user chose from a set of options. Unlike checkboxes, radio buttons with the same field name are mutually exclusive. diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 7668ecf..05ded7a 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -177,7 +177,8 @@ def parse_line(line, state, url_delegate): fv = o["value"] flabel = o["label"] fs = o["style"] - f = urwid.CheckBox(flabel, state=False) + fprechecked = o.get("prechecked", False) + f = urwid.CheckBox(flabel, state=fprechecked) f.field_name = fn f.field_value = fv fa = urwid.AttrMap(f, fs) @@ -187,12 +188,13 @@ def parse_line(line, state, url_delegate): fv = o["value"] flabel = o["label"] fs = o["style"] - if not "radio_groups" in state: + fprechecked = o.get("prechecked", False) + if "radio_groups" not in state: state["radio_groups"] = {} - if not fn in state["radio_groups"]: + if fn not in state["radio_groups"]: state["radio_groups"][fn] = [] group = state["radio_groups"][fn] - f = urwid.RadioButton(group, flabel, state=False, user_data=fv) + f = urwid.RadioButton(group, flabel, state=fprechecked, user_data=fv) f.field_name = fn f.field_value = fv fa = urwid.AttrMap(f, fs) @@ -200,6 +202,7 @@ def parse_line(line, state, url_delegate): + columns_widget = urwid.Columns(widgets, dividechars=0) text_widget = columns_widget # text_widget = urwid.Text("<"+output+">", align=state["align"]) @@ -501,6 +504,7 @@ def make_output(state, line, url_delegate): field_name = field_content field_value = "" field_data = "" + field_prechecked = False # check if field_content contains '|' if '|' in field_content: @@ -526,10 +530,23 @@ def make_output(state, line, url_delegate): except ValueError: pass # Ignore invalid width + # Check for value and pre-checked flag if len(f_components) > 2: field_value = f_components[2] + else: + field_value = "" + if len(f_components) > 3: + if f_components[3] == '*': + field_prechecked = True + else: + # No '|', so field_name is field_content field_name = field_content + field_type = "field" + field_masked = False + field_width = 24 + field_value = "" + field_prechecked = False # Find the closing '>' character field_end = line.find('>', backtick_pos) @@ -546,6 +563,7 @@ def make_output(state, line, url_delegate): "name": field_name, "value": field_value if field_value else field_data, "label": field_data, + "prechecked": field_prechecked, "style": make_style(state) }) else: From 11fd305959fc8c416e28df9d393fab77c2184c06 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 9 Dec 2024 22:11:49 +0100 Subject: [PATCH 264/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 6b27eee..86716a7 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.5.4" +__version__ = "0.5.5" diff --git a/setup.py b/setup.py index b7fd8da..70d8e63 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.8.2", "lxmf>=0.5.5", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.8.7", "lxmf>=0.5.9", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From bec76124282f20931704c7d8ebb7e111b2fdfa92 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 9 Dec 2024 22:49:55 +0100 Subject: [PATCH 265/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 70d8e63..1ec2fbc 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.8.7", "lxmf>=0.5.9", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.8.7", "lxmf>=0.5.8", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From 7b38d4f80eb26b0527c7687d0da13a4765eae516 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 12 Dec 2024 08:53:20 +0100 Subject: [PATCH 266/592] Fix multiline prop --- nomadnet/ui/textui/MicronParser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 05ded7a..bbda2c8 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -168,7 +168,7 @@ def parse_line(line, state, url_delegate): fn = o["name"] fs = o["style"] fmask = "*" if o["masked"] else None - f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=False, mask=fmask) + f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) f.field_name = fn fa = urwid.AttrMap(f, fs) widgets.append((fw, fa)) From ccc41a5789ed62c000730f908759669f2cb63cad Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 12 Dec 2024 08:54:05 +0100 Subject: [PATCH 267/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 86716a7..a779a44 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.5.5" +__version__ = "0.5.6" From d8cfc69ac63faf462f95411efd07d8351b2061a5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 12 Dec 2024 08:54:59 +0100 Subject: [PATCH 268/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1ec2fbc..e1fe428 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.8.7", "lxmf>=0.5.8", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.8.8", "lxmf>=0.5.8", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From 00855c32a7cf7e6694dea92ede3f47f211fb7af9 Mon Sep 17 00:00:00 2001 From: Steve Miller <43918257+kc1awv@users.noreply.github.com> Date: Tue, 17 Dec 2024 09:07:14 -0500 Subject: [PATCH 269/592] Update TextUI.py Replace some missing or deprecated NF icons with new / updated icons. --- nomadnet/ui/TextUI.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 3e4b3cf..1fa7add 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -105,27 +105,27 @@ else: GLYPHS = { # Glyph name # Plain # Unicode # Nerd Font - ("check", "=", "\u2713", "\u2713"), - ("cross", "X", "\u2715", "\u2715"), + ("check", "=", "\u2713", "\uf00c"), + ("cross", "X", "\u2715", "\uf467"), ("unknown", "?", "?", "?"), ("encrypted", "", "\u26BF", "\uf023"), ("plaintext", "!", "!", "\uf06e "), - ("arrow_r", "->", "\u2192", "\u2192"), - ("arrow_l", "<-", "\u2190", "\u2190"), - ("arrow_u", "/\\", "\u2191", "\u2191"), - ("arrow_d", "\\/", "\u2193", "\u2193"), + ("arrow_r", "->", "\u2192", "\uf105"), + ("arrow_l", "<-", "\u2190", "\uf104"), + ("arrow_u", "/\\", "\u2191", "\uf106"), + ("arrow_d", "\\/", "\u2193", "\uf107"), ("warning", "!", "\u26a0", "\uf12a"), - ("info", "i", "\u2139", "\U000f064e"), + ("info", "i", "\u2139", "\uf064e"), ("unread", "[!]", "\u2709", ur_char), - ("divider1", "-", "\u2504", "\u2504"), + ("divider1", "-", "\u2504", "\uf01d8"), ("peer", "[P]", "\u24c5 ", "\uf415"), - ("node", "[N]", "\u24c3 ", "\U000f0002"), + ("node", "[N]", "\u24c3 ", "\uf0002"), ("page", "", "\u25a4 ", "\uf719 "), - ("speed", "", "\u25F7 ", "\U000f04c5 "), - ("decoration_menu", " +", " +", " \U000f043b"), + ("speed", "", "\u25F7 ", "\uf04c5 "), + ("decoration_menu", " +", " +", " \uf043b"), ("unread_menu", " !", " \u2709", urm_char), ("globe", "", "", "\uf484"), - ("sent", "/\\", "\u2191", "\U000f0cd8"), + ("sent", "/\\", "\u2191", "\uf0cd8"), ("papermsg", "P", "\u25a4", "\uf719"), ("qrcode", "QR", "\u25a4", "\uf029"), } From 7d18a103cfc69a6b392696e65b1b546e9907fbd9 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Fri, 20 Dec 2024 23:03:31 +1300 Subject: [PATCH 270/592] don't try to execute nomadnet pages when running on windows --- nomadnet/Node.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 70b455c..37eb691 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -1,4 +1,6 @@ import os +import sys + import RNS import time import threading @@ -159,7 +161,8 @@ class Node: try: if request_allowed: RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) - if os.access(file_path, os.X_OK): + is_windows = sys.platform == "win32" + if is_windows is False and os.access(file_path, os.X_OK): env_map = {} if "PATH" in os.environ: env_map["PATH"] = os.environ["PATH"] From c95fd83400b8558eee26dd8f8aac324e266773ca Mon Sep 17 00:00:00 2001 From: Aleksei Stepanov Date: Wed, 1 Jan 2025 13:37:16 +0100 Subject: [PATCH 271/592] Handle urwid API deprecations * `_get_base_widget` method is going to be removed --- nomadnet/ui/textui/Network.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 5ae371f..207ed9b 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1594,10 +1594,10 @@ class NetworkDisplay(): if self.list_display == 1: parent = self.app.ui.main_display.sub_displays.network_display selected_node_entry = parent.known_nodes_display.ilb.get_selected_item() - if selected_node_entry != None: - selected_node_hash = selected_node_entry._get_base_widget().display_widget.source_hash + if selected_node_entry is not None: + selected_node_hash = selected_node_entry.base_widget.display_widget.source_hash - if selected_node_hash != None: + if selected_node_hash is not None: info_widget = KnownNodeInfo(selected_node_hash) options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) parent.left_pile.contents[0] = (info_widget, options) From bd909c9f5840dbb86d43db0784a91e2ac2049eb7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 13 Jan 2025 14:40:31 +0100 Subject: [PATCH 272/592] Updated version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e1fe428..e285a5a 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.8.8", "lxmf>=0.5.8", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.9.0", "lxmf>=0.5.9", "urwid>=2.4.4", "qrcode"], python_requires=">=3.6", ) From 46eee79da23a1f51a5ed59b7623fb56d23f3cb71 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 13 Jan 2025 14:40:45 +0100 Subject: [PATCH 273/592] Added sync transfer rate to PN list display --- nomadnet/ui/textui/Network.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 5ae371f..466204d 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1797,7 +1797,15 @@ class LXMFPeerEntry(urwid.WidgetWrap): style = "list_unresponsive" focus_style = "list_focus_unresponsive" - widget = ListEntry(sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard))+"\n "+str(len(peer.unhandled_messages))+" unhandled LXMs, "+RNS.prettysize(peer.link_establishment_rate/8, "b")+"/s LER") + if peer.propagation_transfer_limit: + txfer_limit = RNS.prettysize(peer.propagation_transfer_limit*1000) + else: + txfer_limit = "No" + peer_info_str = sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard)) + peer_info_str += "\n "+str(len(peer.unhandled_messages))+f" unhandled LXMs, {txfer_limit} sync limit\n" + peer_info_str += f" {RNS.prettyspeed(peer.sync_transfer_rate)} STR, " + peer_info_str += f"{RNS.prettyspeed(peer.link_establishment_rate)} LER\n" + widget = ListEntry(peer_info_str) self.display_widget = urwid.AttrMap(widget, style, focus_style) self.display_widget.destination_hash = destination_hash super().__init__(self.display_widget) From eb3ff558c00200dd6901c4f2574f457fc248950e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 13 Jan 2025 15:09:39 +0100 Subject: [PATCH 274/592] Use built-in platform check --- nomadnet/Node.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 37eb691..5c2a3e0 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -161,8 +161,7 @@ class Node: try: if request_allowed: RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) - is_windows = sys.platform == "win32" - if is_windows is False and os.access(file_path, os.X_OK): + if not RNS.vendor.platformutils.is_windows() and os.access(file_path, os.X_OK): env_map = {} if "PATH" in os.environ: env_map["PATH"] = os.environ["PATH"] From 01a5c210169c096f2a7acb68a4963662d1f15fff Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 13 Jan 2025 15:17:03 +0100 Subject: [PATCH 275/592] Revert NF glyphs --- nomadnet/ui/TextUI.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 1fa7add..3e4b3cf 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -105,27 +105,27 @@ else: GLYPHS = { # Glyph name # Plain # Unicode # Nerd Font - ("check", "=", "\u2713", "\uf00c"), - ("cross", "X", "\u2715", "\uf467"), + ("check", "=", "\u2713", "\u2713"), + ("cross", "X", "\u2715", "\u2715"), ("unknown", "?", "?", "?"), ("encrypted", "", "\u26BF", "\uf023"), ("plaintext", "!", "!", "\uf06e "), - ("arrow_r", "->", "\u2192", "\uf105"), - ("arrow_l", "<-", "\u2190", "\uf104"), - ("arrow_u", "/\\", "\u2191", "\uf106"), - ("arrow_d", "\\/", "\u2193", "\uf107"), + ("arrow_r", "->", "\u2192", "\u2192"), + ("arrow_l", "<-", "\u2190", "\u2190"), + ("arrow_u", "/\\", "\u2191", "\u2191"), + ("arrow_d", "\\/", "\u2193", "\u2193"), ("warning", "!", "\u26a0", "\uf12a"), - ("info", "i", "\u2139", "\uf064e"), + ("info", "i", "\u2139", "\U000f064e"), ("unread", "[!]", "\u2709", ur_char), - ("divider1", "-", "\u2504", "\uf01d8"), + ("divider1", "-", "\u2504", "\u2504"), ("peer", "[P]", "\u24c5 ", "\uf415"), - ("node", "[N]", "\u24c3 ", "\uf0002"), + ("node", "[N]", "\u24c3 ", "\U000f0002"), ("page", "", "\u25a4 ", "\uf719 "), - ("speed", "", "\u25F7 ", "\uf04c5 "), - ("decoration_menu", " +", " +", " \uf043b"), + ("speed", "", "\u25F7 ", "\U000f04c5 "), + ("decoration_menu", " +", " +", " \U000f043b"), ("unread_menu", " !", " \u2709", urm_char), ("globe", "", "", "\uf484"), - ("sent", "/\\", "\u2191", "\uf0cd8"), + ("sent", "/\\", "\u2191", "\U000f0cd8"), ("papermsg", "P", "\u25a4", "\uf719"), ("qrcode", "QR", "\u25a4", "\uf029"), } From 74e7e4e806ba246149e5c374b720eb1dd23209a0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 13 Jan 2025 15:22:13 +0100 Subject: [PATCH 276/592] Updated default config --- nomadnet/NomadNetworkApp.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index b9ec687..d36d555 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -1136,13 +1136,15 @@ announce_interval = 360 # Whether to announce when the node starts. announce_at_start = Yes -# By default, when Nomad Network is hosting a -# node, it will also act as an LXMF propagation +# When Nomad Network is hosting a page-serving +# node, it can also act as an LXMF propagation # node. If there is already a large amount of # propagation nodes on the network, or you # simply want to run a pageserving-only node, # you can disable running a propagation node. -# disable_propagation = False +# Due to lots of propagation nodes being +# available, this is currently the default. +disable_propagation = Yes # The maximum amount of storage to use for # the LXMF Propagation Node message store, @@ -1176,10 +1178,6 @@ max_transfer_size = 256 # and generally you do not need to use it. # prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf -# Automatic rescan interval of the pages directory in minutes. -# Default: int = 0 (no rescan) -page_refresh_interval = 0 - # You can specify the interval in minutes for # rescanning the hosted pages path. By default, # this option is disabled, and the pages path From 4fb97eecc5dab067d272a9a1b97f9ece02f4183f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 13 Jan 2025 15:24:23 +0100 Subject: [PATCH 277/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e285a5a..a2f54a9 100644 --- a/setup.py +++ b/setup.py @@ -31,5 +31,5 @@ setuptools.setup( 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, install_requires=["rns>=0.9.0", "lxmf>=0.5.9", "urwid>=2.4.4", "qrcode"], - python_requires=">=3.6", + python_requires=">=3.7", ) From c48a8d2a09e618b9fac08ab0c387e426a3efd95d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 13 Jan 2025 15:25:12 +0100 Subject: [PATCH 278/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index a779a44..1cc82e6 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.5.6" +__version__ = "0.5.7" From 7cd025e236b06b6e3a0e654d4c08e7b97559eac3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 18 Jan 2025 17:20:29 +0100 Subject: [PATCH 279/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a2f54a9..e595b83 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.0", "lxmf>=0.5.9", "urwid>=2.4.4", "qrcode"], + install_requires=["rns>=0.9.0", "lxmf>=0.5.9", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From d13c8af88ee2dc10b633a2d736d04e3633cdd61a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 19 Jan 2025 21:47:22 +0100 Subject: [PATCH 280/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e595b83..a3e210c 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.0", "lxmf>=0.5.9", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.1", "lxmf>=0.6.0", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From d5cf34f9d9760271c77fbe6d923920bba40d6e15 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 21 Jan 2025 17:01:06 +0100 Subject: [PATCH 281/592] Get PN message counts faster --- nomadnet/ui/textui/Network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 9c16309..111beba 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1802,7 +1802,7 @@ class LXMFPeerEntry(urwid.WidgetWrap): else: txfer_limit = "No" peer_info_str = sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard)) - peer_info_str += "\n "+str(len(peer.unhandled_messages))+f" unhandled LXMs, {txfer_limit} sync limit\n" + peer_info_str += "\n "+str(peer.unhandled_message_count)+f" unhandled LXMs, {txfer_limit} sync limit\n" peer_info_str += f" {RNS.prettyspeed(peer.sync_transfer_rate)} STR, " peer_info_str += f"{RNS.prettyspeed(peer.link_establishment_rate)} LER\n" widget = ListEntry(peer_info_str) From 11fda5101730e35bf11b4a2b00274dff0412b5e7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 22 Jan 2025 12:03:49 +0100 Subject: [PATCH 282/592] Updated guide --- nomadnet/ui/textui/Guide.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index ba6c4d5..6308edc 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -666,9 +666,9 @@ Determines the interval in minutes for rescanning the hosted files path. By defa < >>> -`!disable_propagation = no`! +`!disable_propagation = yes`! >>>> -By default, when Nomad Network is hosting a node, it will also run an LXMF propagation node. If there is already a large amount of propagation nodes on the network, or you simply want to run a pageserving-only node, you can disable running a propagation node. +When Nomad Network is hosting a node, it can also run an LXMF propagation node. If there is already a large amount of propagation nodes on the network, or you simply want to run a pageserving-only node, you can disable running a propagation node. < >>> @@ -689,6 +689,18 @@ The maximum accepted transfer size per incoming propagation transfer, in kilobyt Configures the LXMF Propagation Node to prioritise storing messages for certain destinations. If the message store reaches the specified limit, LXMF will prioritise keeping messages for destinations specified with this option. This setting is optional, and generally you do not need to use it. < +>>> +`!max_peers = 25`! +>>>> +Configures the maximum number of other nodes the LXMF Propagation Node will automatically peer with. The default is 50, but can be lowered or increased according to available resources. +< + +>>> +`!static_peers = e17f833c4ddf8890dd3a79a6fea8161d, 5a2d0029b6e5ec87020abaea0d746da4`! +>>>> +Configures the LXMF Propagation Node to always maintain propagation node peering with the specified list of destination hashes. +< + >> Printing Section This section holds configuration directives related to printing. It is delimited by the `![printing]`! header in the configuration file. Available directives, along with example values, are as follows: From 9ee052a12208a0ab85757c74a3cbf6c77567b0cc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 22 Jan 2025 12:04:34 +0100 Subject: [PATCH 283/592] Sort PNs according to STR --- nomadnet/ui/textui/Network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 111beba..1a151a7 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1753,7 +1753,7 @@ class LXMFPeers(urwid.WidgetWrap): def make_peer_widgets(self): widget_list = [] - sorted_peers = sorted(self.peer_list, key=lambda pid: (self.app.directory.pn_trust_level(pid), self.peer_list[pid].link_establishment_rate), reverse=True) + sorted_peers = sorted(self.peer_list, key=lambda pid: (self.app.directory.pn_trust_level(pid), self.peer_list[pid].sync_transfer_rate), reverse=True) for peer_id in sorted_peers: peer = self.peer_list[peer_id] trust_level = self.app.directory.pn_trust_level(peer_id) From e332be62118210805c04abaed540d1b8ef516d16 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 22 Jan 2025 12:05:08 +0100 Subject: [PATCH 284/592] Added ability configure max_peers and static_peers --- nomadnet/NomadNetworkApp.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index d36d555..6fb6cbb 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -290,9 +290,20 @@ class NomadNetworkApp: self.directory = nomadnet.Directory(self) + static_peers = [] + for static_peer in self.static_peers: + try: + dh = bytes.fromhex(static_peer) + if len(dh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + raise ValueError("Invalid destination length") + static_peers.append(dh) + except Exception as e: + RNS.log(f"Could not decode static peer destination hash {static_peer}: {e}", RNS.LOG_ERROR) + self.message_router = LXMF.LXMRouter( identity = self.identity, storagepath = self.storagepath, autopeer = True, propagation_limit = self.lxmf_max_propagation_size, delivery_limit = self.lxmf_max_incoming_size, + max_peers = self.max_peers, static_peers = static_peers, ) self.message_router.register_delivery_callback(self.lxmf_delivery) @@ -920,6 +931,19 @@ class NomadNetworkApp: else: self.prioritised_lxmf_destinations = [] + if "static_peers" in self.config["node"]: + self.static_peers = self.config["node"].as_list("static_peers") + else: + self.static_peers = [] + + if not "max_peers" in self.config["node"]: + self.max_peers = None + else: + value = self.config["node"].as_int("max_peers") + if value < 0: + value = 0 + self.max_peers = value + if not "message_storage_limit" in self.config["node"]: self.message_storage_limit = 2000 else: @@ -1178,6 +1202,17 @@ max_transfer_size = 256 # and generally you do not need to use it. # prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf +# You can configure the maximum number of other +# propagation nodes that this node will peer +# with automatically. The default is 50. +# max_peers = 25 + +# You can configure a list of static propagation +# node peers, that this node will always be +# peered with, by specifying a list of +# destination hashes. +# static_peers = e17f833c4ddf8890dd3a79a6fea8161d, 5a2d0029b6e5ec87020abaea0d746da4 + # You can specify the interval in minutes for # rescanning the hosted pages path. By default, # this option is disabled, and the pages path From f000073594556731f87071e3ec0829c44c7a3def Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 22 Jan 2025 12:06:05 +0100 Subject: [PATCH 285/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 1cc82e6..906d362 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.5.7" +__version__ = "0.6.0" diff --git a/setup.py b/setup.py index a3e210c..431f029 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.1", "lxmf>=0.6.0", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.1", "lxmf>=0.6.1", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 5f31aeb3c1b2b9583f8a921dc8986ad37fae57c8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 27 Jan 2025 10:26:39 +0100 Subject: [PATCH 286/592] Fixed missing none check --- nomadnet/Directory.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index b4a993e..90dd714 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -143,8 +143,9 @@ class Directory: while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: self.announce_stream.pop() - if hasattr(self.app.ui, "main_display"): - self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + if hasattr(self.app, "ui") and self.app.ui != None: + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() def node_announce_received(self, source_hash, app_data, associated_peer): if app_data != None: From 704a4ea828272f8231673add1c7846764b2c9707 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 27 Jan 2025 10:26:55 +0100 Subject: [PATCH 287/592] Updated issue template --- .github/ISSUE_TEMPLATE/🐛-bug-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/🐛-bug-report.md b/.github/ISSUE_TEMPLATE/🐛-bug-report.md index 77ad6c2..65b492e 100644 --- a/.github/ISSUE_TEMPLATE/🐛-bug-report.md +++ b/.github/ISSUE_TEMPLATE/🐛-bug-report.md @@ -12,7 +12,7 @@ Before creating a bug report on this issue tracker, you **must** read the [Contr - The issue tracker is used by developers of this project. **Do not use it to ask general questions, or for support requests**. - Ideas and feature requests can be made on the [Discussions](https://github.com/markqvist/Reticulum/discussions). **Only** feature requests accepted by maintainers and developers are tracked and included on the issue tracker. **Do not post feature requests here**. -- After reading the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md), delete this section from your bug report. +- After reading the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md), **delete this section only** (*"Read the Contribution Guidelines"*) from your bug report, **and fill in all the other sections**. **Describe the Bug** A clear and concise description of what the bug is. From 9ef34fc7740b87628197da5e1478a42b7867a94e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 28 Jan 2025 22:45:32 +0100 Subject: [PATCH 288/592] Maintain PN list position --- nomadnet/ui/textui/Network.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 1a151a7..7cf4058 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1612,9 +1612,15 @@ class NetworkDisplay(): self.announce_stream_display.rebuild_widget_list() def reinit_lxmf_peers(self): + if self.lxmf_peers_display: + si = self.lxmf_peers_display.ilb.get_selected_position() + else: + si = None self.lxmf_peers_display = LXMFPeers(self.app) self.lxmf_peers_display.delegate = self self.close_list_dialogs() + if si != None: + self.lxmf_peers_display.ilb.select_item(si) def close_list_dialogs(self): if self.list_display == 0: From eeb15dcb434fa065a3e3d8302e1367806ffdb309 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 28 Jan 2025 23:04:22 +0100 Subject: [PATCH 289/592] Use lock on announce stream updates --- nomadnet/Directory.py | 155 ++++++++++++++++++++++-------------------- 1 file changed, 80 insertions(+), 75 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 90dd714..53c8572 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -3,6 +3,7 @@ import RNS import LXMF import time import nomadnet +import threading import RNS.vendor.umsgpack as msgpack class PNAnnounceHandler: @@ -55,6 +56,7 @@ class Directory: self.directory_entries = {} self.announce_stream = [] self.app = app + self.announce_lock = threading.Lock() self.load_from_disk() self.pn_announce_handler = PNAnnounceHandler(self) @@ -124,91 +126,94 @@ class Directory: RNS.log("Could not load directory from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) def lxmf_announce_received(self, source_hash, app_data): - if app_data != None: - if self.app.compact_stream: - try: - remove_announces = [] - for announce in self.announce_stream: - if announce[1] == source_hash: - remove_announces.append(announce) + with self.announce_lock: + if app_data != None: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self.announce_stream: + if announce[1] == source_hash: + remove_announces.append(announce) - for a in remove_announces: - self.announce_stream.remove(a) + for a in remove_announces: + self.announce_stream.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self.announce_stream.insert(0, (timestamp, source_hash, app_data, "peer")) + while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self.announce_stream.pop() + + if hasattr(self.app, "ui") and self.app.ui != None: + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + def node_announce_received(self, source_hash, app_data, associated_peer): + with self.announce_lock: + if app_data != None: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self.announce_stream: + if announce[1] == source_hash: + remove_announces.append(announce) + + for a in remove_announces: + self.announce_stream.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self.announce_stream.insert(0, (timestamp, source_hash, app_data, "node")) + while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self.announce_stream.pop() + + if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: + existing_entry = self.find(source_hash) + if not existing_entry: + node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) + self.remember(node_entry) - except Exception as e: - RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) - - timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, "peer")) - while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: - self.announce_stream.pop() - - if hasattr(self.app, "ui") and self.app.ui != None: if hasattr(self.app.ui, "main_display"): self.app.ui.main_display.sub_displays.network_display.directory_change_callback() - def node_announce_received(self, source_hash, app_data, associated_peer): - if app_data != None: - if self.app.compact_stream: - try: - remove_announces = [] - for announce in self.announce_stream: - if announce[1] == source_hash: - remove_announces.append(announce) - - for a in remove_announces: - self.announce_stream.remove(a) - - except Exception as e: - RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) - - timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, "node")) - while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: - self.announce_stream.pop() - - if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: - existing_entry = self.find(source_hash) - if not existing_entry: - node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) - self.remember(node_entry) - - if hasattr(self.app.ui, "main_display"): - self.app.ui.main_display.sub_displays.network_display.directory_change_callback() - def pn_announce_received(self, source_hash, app_data, associated_peer, associated_node): - found_node = None - for sh in self.directory_entries: - if sh == associated_node: - found_node = True - break + with self.announce_lock: + found_node = None + for sh in self.directory_entries: + if sh == associated_node: + found_node = True + break - for e in self.announce_stream: - if e[1] == associated_node: - found_node = True - break + for e in self.announce_stream: + if e[1] == associated_node: + found_node = True + break - if not found_node: - if self.app.compact_stream: - try: - remove_announces = [] - for announce in self.announce_stream: - if announce[1] == source_hash: - remove_announces.append(announce) + if not found_node: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self.announce_stream: + if announce[1] == source_hash: + remove_announces.append(announce) - for a in remove_announces: - self.announce_stream.remove(a) + for a in remove_announces: + self.announce_stream.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self.announce_stream.insert(0, (timestamp, source_hash, app_data, "pn")) + while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self.announce_stream.pop() - except Exception as e: - RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) - - timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, "pn")) - while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: - self.announce_stream.pop() - - if hasattr(self.app.ui, "main_display"): - self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() def remove_announce_with_timestamp(self, timestamp): selected_announce = None From 46f3a4127c85a2434d51b8c491150447adeffa81 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 29 Jan 2025 01:24:08 +0100 Subject: [PATCH 290/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 431f029..430d07c 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.1", "lxmf>=0.6.1", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.1", "lxmf>=0.6.2", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From a26edd21db8d193fd470057db0a41336f76dbf7b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 29 Jan 2025 01:24:29 +0100 Subject: [PATCH 291/592] Added acceptance rate to PN peer stats --- nomadnet/ui/textui/Network.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 7cf4058..42b2c0a 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1807,10 +1807,11 @@ class LXMFPeerEntry(urwid.WidgetWrap): txfer_limit = RNS.prettysize(peer.propagation_transfer_limit*1000) else: txfer_limit = "No" + ar = round(peer.acceptance_rate*100, 2) peer_info_str = sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard)) peer_info_str += "\n "+str(peer.unhandled_message_count)+f" unhandled LXMs, {txfer_limit} sync limit\n" peer_info_str += f" {RNS.prettyspeed(peer.sync_transfer_rate)} STR, " - peer_info_str += f"{RNS.prettyspeed(peer.link_establishment_rate)} LER\n" + peer_info_str += f"{RNS.prettyspeed(peer.link_establishment_rate)} LER, {ar}% AR\n" widget = ListEntry(peer_info_str) self.display_widget = urwid.AttrMap(widget, style, focus_style) self.display_widget.destination_hash = destination_hash From 9f00ffbae680234392bd68d3f49e474194a20f95 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 29 Jan 2025 01:25:33 +0100 Subject: [PATCH 292/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 906d362..43c4ab0 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.6.0" +__version__ = "0.6.1" From c3c8f991311dee3cf4c3488fb56352523600bd15 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 29 Jan 2025 01:41:25 +0100 Subject: [PATCH 293/592] Updated logging --- nomadnet/NomadNetworkApp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 6fb6cbb..37c3f1e 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -537,7 +537,7 @@ class NomadNetworkApp: RNS.log("Could not autoselect a propagation node! LXMF propagation will not be available until a trusted node announces on the network, or a propagation node is manually selected.", RNS.LOG_WARNING) else: pn_name_str = "" - RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_INFO) + RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_DEBUG) self.message_router.set_outbound_propagation_node(selected_node) def get_user_selected_propagation_node(self): From 7ced4c36596980c0752d3708bdf2a1589ee8c671 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 29 Jan 2025 14:45:19 +0100 Subject: [PATCH 294/592] Fixed missing attribute check --- nomadnet/Directory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 53c8572..335db63 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -212,7 +212,7 @@ class Directory: while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: self.announce_stream.pop() - if hasattr(self.app.ui, "main_display"): + if hasattr(self.app, "ui") and hasattr(self.app.ui, "main_display"): self.app.ui.main_display.sub_displays.network_display.directory_change_callback() def remove_announce_with_timestamp(self, timestamp): From d9886980fa84a368c3645713555aed0226c34eac Mon Sep 17 00:00:00 2001 From: byte-diver Date: Fri, 7 Feb 2025 00:10:44 +0100 Subject: [PATCH 295/592] fix docker, add tabs --- Dockerfile.build | 4 -- nomadnet/ui/textui/Network.py | 119 ++++++++++++++++++++++++---------- 2 files changed, 84 insertions(+), 39 deletions(-) diff --git a/Dockerfile.build b/Dockerfile.build index 3ffba8e..e28e9bd 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -16,10 +16,6 @@ ENV PATH="/home/myuser/.local/bin:${PATH}" ################### BEGIN NomadNet ########################################### -COPY --chown=myuser:myuser requirements.txt requirements.txt - -RUN pip install --user -r requirements.txt - COPY --chown=myuser:myuser . . diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 42b2c0a..d6c6014 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -141,7 +141,7 @@ class AnnounceInfo(urwid.WidgetWrap): if is_node: try: existing_conversations = nomadnet.Conversation.conversation_list(self.app) - + source_hash_text = RNS.hexrep(op_hash, delimit=False) display_name = op_str @@ -162,7 +162,7 @@ class AnnounceInfo(urwid.WidgetWrap): show_announce_stream(None) try: existing_conversations = nomadnet.Conversation.conversation_list(self.app) - + source_hash_text = RNS.hexrep(source_hash, delimit=False) display_name = data_str @@ -316,7 +316,7 @@ class AnnounceStreamEntry(urwid.WidgetWrap): info_widget = AnnounceInfo(announce, parent, self.app) options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) parent.left_pile.contents[0] = (info_widget, options) - + except KeyError as e: def dismiss_dialog(sender): self.delegate.parent.close_list_dialogs() @@ -368,6 +368,10 @@ class AnnounceStreamEntry(urwid.WidgetWrap): def timestamp(self): return self.timestamp +class TabButton(urwid.Button): + button_left = urwid.Text("[") + button_right = urwid.Text("]") + class AnnounceStream(urwid.WidgetWrap): def __init__(self, app, parent): self.app = app @@ -376,11 +380,24 @@ class AnnounceStream(urwid.WidgetWrap): self.timeout = self.app.config["textui"]["animation_interval"]*2 self.ilb = None self.no_content = True - + self.current_tab = "nodes" + self.added_entries = [] self.widget_list = [] self.update_widget_list() + # Create tab buttons + self.tab_nodes = urwid.AttrMap(TabButton("Nodes", on_press=self.show_nodes_tab), "tab_active") + self.tab_peers = urwid.AttrMap(TabButton("Peers", on_press=self.show_peers_tab), "tab_inactive") + self.tab_pn = urwid.AttrMap(TabButton("LXMF Propagation Nodes", on_press=self.show_pn_tab), "tab_inactive") + + # Create tab bar with proportional widths + self.tab_bar = urwid.Columns([ + ('weight', 1, self.tab_nodes), + ('weight', 1, self.tab_peers), + ('weight', 3, self.tab_pn), + ], dividechars=1) # Add 1 character spacing between tabs + self.ilb = ExceptionHandlingListBox( self.widget_list, on_selection_change=self.list_selection, @@ -389,7 +406,13 @@ class AnnounceStream(urwid.WidgetWrap): #highlight_offFocus="list_off_focus" ) - self.display_widget = self.ilb + # Combine tab bar and list box + self.pile = urwid.Pile([ + ('pack', self.tab_bar), + ('weight', 1, self.ilb), + ]) + + self.display_widget = self.pile super().__init__(urwid.LineBox(self.display_widget, title="Announce Stream")) def keypress(self, size, key): @@ -397,7 +420,7 @@ class AnnounceStream(urwid.WidgetWrap): nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() - + return super(AnnounceStream, self).keypress(size, key) def delete_selected_entry(self): @@ -412,28 +435,54 @@ class AnnounceStream(urwid.WidgetWrap): self.update_widget_list() def update_widget_list(self): + self.widget_list = [] new_entries = [] - for e in self.app.directory.announce_stream: - if not e[0] in self.added_entries: - self.added_entries.insert(0, e[0]) - new_entries.insert(0, e) - for e in new_entries: + for e in self.app.directory.announce_stream: + announce_type = e[3] + + # Filter based on current tab + if self.current_tab == "nodes" and (announce_type == "node" or announce_type == True): + new_entries.append(e) + elif self.current_tab == "peers" and (announce_type == "peer" or announce_type == False): + new_entries.append(e) + elif self.current_tab == "pn" and announce_type == "pn": + new_entries.append(e) + + for e in reversed(new_entries): nw = AnnounceStreamEntry(self.app, e, self) nw.timestamp = e[0] - self.widget_list.insert(0, nw) + self.widget_list.append(nw) if len(new_entries) > 0: self.no_content = False - if self.ilb != None: - self.ilb.set_body(self.widget_list) else: - if len(self.widget_list) == 0: - self.no_content = True - - if self.ilb != None: - self.ilb.set_body(self.widget_list) + self.no_content = True + self.widget_list = [urwid.Text(f"No {self.current_tab} announces", align='center')] + if self.ilb: + self.ilb.set_body(self.widget_list) + + def show_nodes_tab(self, button): + self.current_tab = "nodes" + self.tab_nodes.set_attr_map({None: "tab_active"}) + self.tab_peers.set_attr_map({None: "tab_inactive"}) + self.tab_pn.set_attr_map({None: "tab_inactive"}) + self.update_widget_list() + + def show_peers_tab(self, button): + self.current_tab = "peers" + self.tab_nodes.set_attr_map({None: "tab_inactive"}) + self.tab_peers.set_attr_map({None: "tab_active"}) + self.tab_pn.set_attr_map({None: "tab_inactive"}) + self.update_widget_list() + + def show_pn_tab(self, button): + self.current_tab = "pn" + self.tab_nodes.set_attr_map({None: "tab_inactive"}) + self.tab_peers.set_attr_map({None: "tab_inactive"}) + self.tab_pn.set_attr_map({None: "tab_active"}) + self.update_widget_list() def list_selection(self, arg1, arg2): pass @@ -596,7 +645,7 @@ class KnownNodeInfo(urwid.WidgetWrap): if node_ident != None: try: existing_conversations = nomadnet.Conversation.conversation_list(self.app) - + source_hash_text = RNS.hexrep(op_hash, delimit=False) display_name = op_str @@ -632,7 +681,7 @@ class KnownNodeInfo(urwid.WidgetWrap): trust_level = DirectoryEntry.UNTRUSTED if r_unknown.get_state() == True: trust_level = DirectoryEntry.UNKNOWN - + if r_trusted.get_state() == True: trust_level = DirectoryEntry.TRUSTED @@ -699,7 +748,7 @@ class KnownNodeInfo(urwid.WidgetWrap): pile_widgets.insert(4, operator_entry) pile = urwid.Pile(pile_widgets) - + pile.focus_position = len(pile.contents)-1 button_columns.focus_position = 0 @@ -733,7 +782,7 @@ class KnownNodes(urwid.WidgetWrap): g = self.app.ui.glyphs self.widget_list = self.make_node_widgets() - + self.ilb = ExceptionHandlingListBox( self.widget_list, on_selection_change=self.node_list_selection, @@ -767,7 +816,7 @@ class KnownNodes(urwid.WidgetWrap): nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() - + return super(KnownNodes, self).keypress(size, key) @@ -910,7 +959,7 @@ class NodeEntry(urwid.WidgetWrap): focus_style = "list_focus_untrusted" type_symbol = g["node"] - + widget = ListEntry(type_symbol+" "+display_str) urwid.connect_signal(widget, "click", delegate.connect_node, node) @@ -1209,7 +1258,7 @@ class LocalPeer(urwid.WidgetWrap): #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) overlay = dialog - + self.dialog_open = True options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (overlay, options) @@ -1226,7 +1275,7 @@ class LocalPeer(urwid.WidgetWrap): self.t_last_announce.update_time() announce_button = urwid.Button("Announce Now", on_press=announce_query) - + self.display_widget = urwid.Pile( [ t_id, @@ -1270,13 +1319,13 @@ class NodeInfo(urwid.WidgetWrap): def show_peer_info(sender): options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) - + if self.app.enable_node: if self.app.node != None: display_name = self.app.node.name else: display_name = None - + if display_name == None: display_name = "" @@ -1308,7 +1357,7 @@ class NodeInfo(urwid.WidgetWrap): #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) overlay = dialog - + self.dialog_open = True options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (overlay, options) @@ -1596,12 +1645,12 @@ class NetworkDisplay(): selected_node_entry = parent.known_nodes_display.ilb.get_selected_item() if selected_node_entry is not None: selected_node_hash = selected_node_entry.base_widget.display_widget.source_hash - + if selected_node_hash is not None: info_widget = KnownNodeInfo(selected_node_hash) options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) parent.left_pile.contents[0] = (info_widget, options) - + def focus_lists(self): self.columns.focus_position = 0 @@ -1689,7 +1738,7 @@ class LXMFPeers(urwid.WidgetWrap): self.delete_selected_entry() elif key == "ctrl r": self.sync_selected_entry() - + return super(LXMFPeers, self).keypress(size, key) @@ -1713,7 +1762,7 @@ class LXMFPeers(urwid.WidgetWrap): peer = self.app.message_router.peers[destination_hash] if time.time() > peer.last_sync_attempt+sync_grace: peer.next_sync_attempt = time.time()-1 - + def job(): peer.sync() threading.Thread(target=job, daemon=True).start() @@ -1778,7 +1827,7 @@ class LXMFPeerEntry(urwid.WidgetWrap): node_identity = RNS.Identity.recall(destination_hash) display_str = RNS.prettyhexrep(destination_hash) - if node_identity != None: + if node_identity != None: node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", node_identity) display_name = self.app.directory.alleged_display_str(node_hash) if display_name != None: From c198d516da1c30b458816cadcfecf8e816aa2c5d Mon Sep 17 00:00:00 2001 From: byte-diver Date: Fri, 7 Feb 2025 00:23:43 +0100 Subject: [PATCH 296/592] cleaning and sort --- nomadnet/ui/textui/Network.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index d6c6014..bc87f5a 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -387,9 +387,9 @@ class AnnounceStream(urwid.WidgetWrap): self.update_widget_list() # Create tab buttons - self.tab_nodes = urwid.AttrMap(TabButton("Nodes", on_press=self.show_nodes_tab), "tab_active") - self.tab_peers = urwid.AttrMap(TabButton("Peers", on_press=self.show_peers_tab), "tab_inactive") - self.tab_pn = urwid.AttrMap(TabButton("LXMF Propagation Nodes", on_press=self.show_pn_tab), "tab_inactive") + self.tab_nodes = TabButton("Nodes", on_press=self.show_nodes_tab) + self.tab_peers = TabButton("Peers", on_press=self.show_peers_tab) + self.tab_pn = TabButton("LXMF Propagation Nodes", on_press=self.show_pn_tab) # Create tab bar with proportional widths self.tab_bar = urwid.Columns([ @@ -449,7 +449,7 @@ class AnnounceStream(urwid.WidgetWrap): elif self.current_tab == "pn" and announce_type == "pn": new_entries.append(e) - for e in reversed(new_entries): + for e in new_entries: nw = AnnounceStreamEntry(self.app, e, self) nw.timestamp = e[0] self.widget_list.append(nw) @@ -465,23 +465,14 @@ class AnnounceStream(urwid.WidgetWrap): def show_nodes_tab(self, button): self.current_tab = "nodes" - self.tab_nodes.set_attr_map({None: "tab_active"}) - self.tab_peers.set_attr_map({None: "tab_inactive"}) - self.tab_pn.set_attr_map({None: "tab_inactive"}) self.update_widget_list() def show_peers_tab(self, button): self.current_tab = "peers" - self.tab_nodes.set_attr_map({None: "tab_inactive"}) - self.tab_peers.set_attr_map({None: "tab_active"}) - self.tab_pn.set_attr_map({None: "tab_inactive"}) self.update_widget_list() def show_pn_tab(self, button): self.current_tab = "pn" - self.tab_nodes.set_attr_map({None: "tab_inactive"}) - self.tab_peers.set_attr_map({None: "tab_inactive"}) - self.tab_pn.set_attr_map({None: "tab_active"}) self.update_widget_list() def list_selection(self, arg1, arg2): From f8775adbabdbd4d9140eac8696a2d2360565986e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 18 Feb 2025 20:27:33 +0100 Subject: [PATCH 297/592] Increased announce stream length --- nomadnet/Directory.py | 2 +- nomadnet/ui/textui/Network.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 335db63..ee0ef09 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -30,7 +30,7 @@ class PNAnnounceHandler: RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) class Directory: - ANNOUNCE_STREAM_MAXLENGTH = 64 + ANNOUNCE_STREAM_MAXLENGTH = 256 aspect_filter = "nomadnetwork.node" @staticmethod diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index bc87f5a..63f00ec 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -389,7 +389,7 @@ class AnnounceStream(urwid.WidgetWrap): # Create tab buttons self.tab_nodes = TabButton("Nodes", on_press=self.show_nodes_tab) self.tab_peers = TabButton("Peers", on_press=self.show_peers_tab) - self.tab_pn = TabButton("LXMF Propagation Nodes", on_press=self.show_pn_tab) + self.tab_pn = TabButton("Propagation Nodes", on_press=self.show_pn_tab) # Create tab bar with proportional widths self.tab_bar = urwid.Columns([ From 03d1b22b8f537667e45e0380b45210e16651fd5b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 18 Feb 2025 21:20:50 +0100 Subject: [PATCH 298/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 430d07c..a17efae 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.1", "lxmf>=0.6.2", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.2", "lxmf>=0.6.2", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From c280e36a84c5cb3ac6bb60e285b9c53676533a1a Mon Sep 17 00:00:00 2001 From: RFNexus Date: Sun, 9 Mar 2025 15:20:36 -0400 Subject: [PATCH 299/592] Interface Management (1/2) --- nomadnet/ui/TextUI.py | 22 +- nomadnet/ui/textui/Guide.py | 6 + nomadnet/ui/textui/Interfaces.py | 2865 +++++++++++++++++ nomadnet/ui/textui/Main.py | 30 +- nomadnet/vendor/AsciiChart.py | 87 + .../additional_urwid_widgets/FormWidgets.py | 283 ++ 6 files changed, 3280 insertions(+), 13 deletions(-) create mode 100644 nomadnet/ui/textui/Interfaces.py create mode 100644 nomadnet/vendor/AsciiChart.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/FormWidgets.py diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 3e4b3cf..11dd2c7 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -51,7 +51,15 @@ THEMES = { ("browser_controls", "light gray", "default", "default", "#bbb", "default"), ("progress_full", "black", "light gray", "standout", "#111", "#bbb"), ("progress_empty", "light gray", "default", "default", "#ddd", "default"), - ], + ("interface_title", "", "", "default", "", ""), + ("interface_title_selected", "bold", "", "bold", "", ""), + ("connected_status", "dark green", "default", "default", "dark green", "default"), + ("disconnected_status", "dark red", "default", "default", "dark red", "default"), + ("placeholder", "dark gray", "default", "default", "dark gray", "default"), + ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"), + ("error", "light red,blink", "default", "blink", "#f44,blink", "default"), + + ], }, THEME_LIGHT: { "urwid_theme": [ @@ -69,6 +77,7 @@ THEMES = { ("msg_header_ok", "black", "dark green", "standout", "#111", "#6b2"), ("msg_header_caution", "black", "yellow", "standout", "#111", "#fd3"), ("msg_header_sent", "black", "dark gray", "standout", "#111", "#ddd"), + ("msg_header_propagated", "black", "light blue", "standout", "#111", "#28b"), ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), @@ -86,6 +95,13 @@ THEMES = { ("browser_controls", "dark gray", "default", "default", "#444", "default"), ("progress_full", "black", "dark gray", "standout", "#111", "#bbb"), ("progress_empty", "dark gray", "default", "default", "#ddd", "default"), + ("interface_title", "dark gray", "default", "default", "#444", "default"), + ("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"), + ("connected_status", "dark green", "default", "default", "#4a0", "default"), + ("disconnected_status", "dark red", "default", "default", "#a22", "default"), + ("placeholder", "light gray", "default", "default", "#999", "default"), + ("placeholder_text", "light gray", "default", "default", "#999", "default"), + ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), ], } } @@ -128,6 +144,8 @@ GLYPHS = { ("sent", "/\\", "\u2191", "\U000f0cd8"), ("papermsg", "P", "\u25a4", "\uf719"), ("qrcode", "QR", "\u25a4", "\uf029"), + ("selected", "[*] ", "\u25CF", "\u25CF"), + ("unselected", "[ ] ", "\u25CB", "\u25CB"), } class TextUI: @@ -163,7 +181,7 @@ class TextUI: if self.app.config["textui"]["glyphs"] == "plain": glyphset = "plain" - elif self.app.config["textui"]["glyphs"] == "unicoode": + elif self.app.config["textui"]["glyphs"] == "unicode": glyphset = "unicode" elif self.app.config["textui"]["glyphs"] == "nerdfont": glyphset = "nerdfont" diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 6308edc..94f83d0 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -109,6 +109,7 @@ class TopicList(urwid.WidgetWrap): self.topic_list = [ GuideEntry(self.app, self, guide_display, "Introduction"), GuideEntry(self.app, self, guide_display, "Concepts & Terminology"), + GuideEntry(self.app, self, guide_display, "Interfaces"), GuideEntry(self.app, self, guide_display, "Hosting a Node"), GuideEntry(self.app, self, guide_display, "Configuration Options"), GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"), @@ -386,6 +387,10 @@ Links can be inserted into micron documents. See the `*Markup`* section of this ''' +TOPIC_INTERFACES = ''' +>TODO +''' + TOPIC_CONVERSATIONS = '''>Conversations Conversations in Nomad Network @@ -1247,6 +1252,7 @@ TOPICS = { "Introduction": TOPIC_INTRODUCTION, "Concepts & Terminology": TOPIC_CONCEPTS, "Conversations": TOPIC_CONVERSATIONS, + "Interfaces": TOPIC_INTERFACES, "Hosting a Node": TOPIC_HOSTING, "Configuration Options": TOPIC_CONFIG, "Keyboard Shortcuts": TOPIC_SHORTCUTS, diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py new file mode 100644 index 0000000..281e505 --- /dev/null +++ b/nomadnet/ui/textui/Interfaces.py @@ -0,0 +1,2865 @@ +import RNS +import time +from math import log10, pow + +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * +from nomadnet.vendor.AsciiChart import AsciiChart + +### GYLPHS ### +INTERFACE_GLYPHS = { + # Glyph name # Plain # Unicode # Nerd Font + ("NetworkInterfaceType", "(IP)", "\U0001f5a7", "\U000f0200"), + ("SerialInterfaceType", "(<->)", "\u2194", "\U000f065c"), + ("RNodeInterfaceType", "(R)" , "\u16b1", "\U000f043a"), + ("OtherInterfaceType", "(#)" , "\U0001f67e", "\ued95"), +} + +### HELPER ### +def _get_interface_icon(glyphset, iface_type): + glyphset_index = 1 # Default to unicode + if glyphset == "plain": + glyphset_index = 0 # plain + elif glyphset == "nerdfont": + glyphset_index = 2 # nerdfont + + type_to_glyph_tuple = { + "AutoInterface": "NetworkInterfaceType", + "TCPClientInterface": "NetworkInterfaceType", + "TCPServerInterface": "NetworkInterfaceType", + "UDPInterface": "NetworkInterfaceType", + "I2PInterface": "NetworkInterfaceType", + + "RNodeInterface": "RNodeInterfaceType", + "RNodeMultiInterface": "RNodeInterfaceType", + + "SerialInterface": "SerialInterfaceType", + "KISSInterface": "SerialInterfaceType", + "AX25KISSInterface": "SerialInterfaceType", + + "PipeInterface": "OtherInterfaceType" + } + + glyph_tuple_name = type_to_glyph_tuple.get(iface_type, "OtherInterfaceType") + + for glyph_tuple in INTERFACE_GLYPHS: + if glyph_tuple[0] == glyph_tuple_name: + return glyph_tuple[glyphset_index + 1] + + # Fallback + return "(#)" if glyphset == "plain" else "🙾" if glyphset == "unicode" else " " + +def format_bytes(bytes_value): + units = ['bytes', 'KB/s', 'MB/s', 'GB/s', 'TB/s'] + size = float(bytes_value) + unit_index = 0 + + while size >= 1024.0 and unit_index < len(units) - 1: + size /= 1024.0 + unit_index += 1 + + if unit_index == 0: + return f"{int(size)} {units[unit_index]}" + else: + return f"{size:.1f} {units[unit_index]}" + +### PORT FUNCTIONS ### +PYSERIAL_AVAILABLE = False # If NomadNet is installed on environments with rnspure instead of rns, pyserial won't be available +try: + import serial.tools.list_ports + PYSERIAL_AVAILABLE = True +except ImportError: + class DummyPort: + def __init__(self, device, description=None, manufacturer=None, hwid=None): + self.device = device + self.description = description or device + self.manufacturer = manufacturer + self.hwid = hwid + self.vid = None + self.pid = None + +def get_port_info(): + if not PYSERIAL_AVAILABLE: + return [] + + try: + ports = serial.tools.list_ports.comports() + port_info = [] + + # Ports are sorted into categories for dropdown, priority ports appear first + priority_ports = [] # USB, ACM, bluetooth, etc + standard_ports = [] # COM, tty/s ports + + for port in ports: + desc = f"{port.device}" + if port.description and port.description != port.device: + desc += f" ({port.description})" + if port.manufacturer: + desc += f" - {port.manufacturer}" + + is_standard = ( + port.device.startswith("COM") or # windows + "/dev/ttyS" in port.device or # Linux + "Serial" in port.description + ) + + port_data = { + 'device': port.device, + 'description': desc, + 'hwid': port.hwid, + 'vid': port.vid, + 'pid': port.pid, + 'is_standard': is_standard + } + + if is_standard: + standard_ports.append(port_data) + else: + priority_ports.append(port_data) + + priority_ports.sort(key=lambda x: x['device']) + standard_ports.sort(key=lambda x: x['device']) + + return priority_ports + standard_ports + except Exception as e: + RNS.log(f"error accessing serial ports: {str(e)}", RNS.LOG_ERROR) + return [] + +def get_port_field(): + if not PYSERIAL_AVAILABLE: + return { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "/dev/ttyUSB0 or COM port (pyserial not installed)", + "validation": ["required"], + "transform": lambda x: x.strip() + } + + port_info = get_port_info() + + if len(port_info) > 1: + options = [p['description'] for p in port_info] + device_map = {p['description']: p['device'] for p in port_info} + + return { + "config_key": "port", + "type": "dropdown", + "label": "Port: ", + "options": options, + "default": options[0] if options else "", + "validation": ["required"], + "transform": lambda x: device_map[x] + } + else: + # single or no ports - use text field + default = port_info[0]['device'] if port_info else "" + placeholder = "/dev/ttyXXX (or COM port on Windows)" + + return { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": default, + "placeholder": placeholder, + "validation": ["required"], + "transform": lambda x: x.strip() + } + +### RNODE #### +def calculate_rnode_parameters(bandwidth, spreading_factor, coding_rate, noise_floor=6, antenna_gain=0, + transmit_power=17): + crn = { + 5: 1, + 6: 2, + 7: 3, + 8: 4, + } + coding_rate_n = crn.get(coding_rate, 1) + + sfn = { + 5: -2.5, + 6: -5, + 7: -7.5, + 8: -10, + 9: -12.5, + 10: -15, + 11: -17.5, + 12: -20 + } + + data_rate = spreading_factor * ( + (4 / (4 + coding_rate_n)) / (pow(2, spreading_factor) / (bandwidth / 1000))) * 1000 + + sensitivity = -174 + 10 * log10(bandwidth) + noise_floor + (sfn.get(spreading_factor, 0)) + + if bandwidth == 203125 or bandwidth == 406250 or bandwidth > 500000: + sensitivity = -165.6 + 10 * log10(bandwidth) + noise_floor + (sfn.get(spreading_factor, 0)) + + link_budget = (transmit_power - sensitivity) + antenna_gain + + if data_rate < 1000: + data_rate_str = f"{data_rate:.0f} bps" + else: + data_rate_str = f"{(data_rate / 1000):.2f} kbps" + + return { + "data_rate": data_rate_str, + "link_budget": f"{link_budget:.1f} dB", + "sensitivity": f"{sensitivity:.1f} dBm", + "raw_data_rate": data_rate, + "raw_link_budget": link_budget, + "raw_sensitivity": sensitivity + } + +class RNodeCalculator(urwid.WidgetWrap): + def __init__(self, parent_view): + self.parent_view = parent_view + self.update_alarm = None + + self.data_rate_widget = urwid.Text("Data Rate: Calculating...") + self.link_budget_widget = urwid.Text("Link Budget: Calculating...") + self.sensitivity_widget = urwid.Text("Sensitivity: Calculating...") + + self.noise_floor_edit = urwid.Edit("", "0") + self.antenna_gain_edit = urwid.Edit("", "0") + + layout = urwid.Pile([ + urwid.Divider("-"), + + urwid.Columns([ + (28, urwid.Text(("key", "Enter Noise Floor (dB): "), align="right")), + self.noise_floor_edit + ]), + + urwid.Columns([ + (28, urwid.Text(("key", "Enter Antenna Gain (dBi): "), align="right")), + self.antenna_gain_edit + ]), + + urwid.Divider(), + + urwid.Text(("connected_status", "On-Air Calculations:"), align="left"), + self.data_rate_widget, + self.link_budget_widget, + self.sensitivity_widget, + urwid.Divider(), + + urwid.Text([ + "These calculations will update as you change RNode parameters" + ]) + ]) + + super().__init__(layout) + + self.connect_all_field_signals() + + self.update_calculation() + + def connect_all_field_signals(self): + urwid.connect_signal(self.noise_floor_edit, 'change', self._queue_update) + urwid.connect_signal(self.antenna_gain_edit, 'change', self._queue_update) + rnode_fields = ['bandwidth', 'spreadingfactor', 'codingrate', 'txpower'] + + for field_name in rnode_fields: + if field_name in self.parent_view.fields: + + field_widget = self.parent_view.fields[field_name]['widget'] + + if hasattr(field_widget, 'edit_text'): + urwid.connect_signal(field_widget, 'change', self._queue_update) + elif hasattr(field_widget, '_emit') and 'change' in getattr(field_widget, 'signals', []): + urwid.connect_signal(field_widget, 'change', self._queue_update) + + def _queue_update(self, widget, new_text): + if self.update_alarm is not None: + try: + self.parent_view.parent.app.ui.loop.remove_alarm(self.update_alarm) + except: + pass + + self.update_alarm = self.parent_view.parent.app.ui.loop.set_alarm_in( + 0.3, self._delayed_update) + + def _delayed_update(self, loop, user_data): + self.update_alarm = None + self.update_calculation() + + def update_calculation(self): + try: + + try: + bandwidth_widget = self.parent_view.fields.get('bandwidth', {}).get('widget') + bandwidth = int(bandwidth_widget.get_value()) if bandwidth_widget else 125000 + except (ValueError, AttributeError): + bandwidth = 125000 + + try: + sf_widget = self.parent_view.fields.get('spreadingfactor', {}).get('widget') + spreading_factor = int(sf_widget.get_value()) if sf_widget else 7 + except (ValueError, AttributeError): + spreading_factor = 7 + + try: + cr_widget = self.parent_view.fields.get('codingrate', {}).get('widget') + coding_rate = int(cr_widget.get_value()) if cr_widget else 5 + if isinstance(coding_rate, str) and ":" in coding_rate: + coding_rate = int(coding_rate.split(":")[1]) + except (ValueError, AttributeError): + coding_rate = 5 + + try: + txpower_widget = self.parent_view.fields.get('txpower', {}).get('widget') + if hasattr(txpower_widget, 'edit_text'): + txpower_text = txpower_widget.edit_text.strip() + txpower = int(txpower_text) if txpower_text else 17 + else: + txpower = int(txpower_widget.get_value()) if txpower_widget else 17 + except (ValueError, AttributeError): + txpower = 17 + + try: + noise_floor_text = self.noise_floor_edit.edit_text.strip() + noise_floor = int(noise_floor_text) if noise_floor_text else 0 + except (ValueError, AttributeError): + noise_floor = 0 + + try: + antenna_gain_text = self.antenna_gain_edit.edit_text.strip() + antenna_gain = int(antenna_gain_text) if antenna_gain_text else 0 + except (ValueError, AttributeError): + antenna_gain = 0 + + result = calculate_rnode_parameters( + bandwidth=bandwidth, + spreading_factor=spreading_factor, + coding_rate=coding_rate, + noise_floor=noise_floor, + antenna_gain=antenna_gain, + transmit_power=txpower + ) + + self.data_rate_widget.set_text(f"Data Rate: {result['data_rate']}") + self.link_budget_widget.set_text(f"Link Budget: {result['link_budget']}") + self.sensitivity_widget.set_text(f"Sensitivity: {result['sensitivity']}") + + except (ValueError, KeyError, TypeError) as e: + self.data_rate_widget.set_text(f"Data Rate: Waiting for parameters...") + self.link_budget_widget.set_text(f"Link Budget: Waiting for valid parameters...") + self.sensitivity_widget.set_text(f"Sensitivity: Waiting for parameters...") + +### INTERFACE FIELDS ### +COMMON_INTERFACE_OPTIONS = [ + { + "config_key": "network_name", + "type": "edit", + "label": "Virtual Network Name: ", + "placeholder": "Optional virtual network name", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "passphrase", + "type": "edit", + "label": "IFAC Passphrase: ", + "placeholder": "IFAC authentication passphrase", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "ifac_size", + "type": "edit", + "label": "IFAC Size: ", + "placeholder": "8 - 512", + "default": "", + "validation": ['number'], + "transform": lambda x: x.strip() + }, + { + "config_key": "bitrate", + "type": "edit", + "label": "Inferred Bitrate: ", + "placeholder": "Automatically determined", + "default": "", + "validation": ['number'], + "transform": lambda x: x.strip() + }, +] + +INTERFACE_FIELDS = { + "AutoInterface": [ + { + + }, + { + "additional_options": [ + { + "config_key": "devices", + "type": "multilist", + "label": "Devices: ", + "validation": [], + "transform": lambda x: ",".join(x) + }, + { + "config_key": "ignored_devices", + "type": "multilist", + "label": "Ignored Devices: ", + "validation": [], + "transform": lambda x: ",".join(x) + }, + { + "config_key": "group_id", + "type": "edit", + "label": "Group ID: ", + "default": "", + "placeholder": "e.g., my_custom_network", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "discovery_scope", + "type": "dropdown", + "label": "Discovery Scope: ", + "options": ["None", "link", "admin", "site", "organisation", "global"], + "default": "None", + "validation": [], + "transform": lambda x: "" if x == "None" else x.strip() + } + ] + }, + ], + "I2PInterface": [ + { + "config_key": "peers", + "type": "multilist", + "label": "Peers: ", + "placeholder": "", + "validation": ["required"], + "transform": lambda x: ",".join(x) + } + ], + "TCPServerInterface": [ + { + "config_key": "listen_ip", + "type": "edit", + "label": "Listen IP: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "listen_port", + "type": "edit", + "label": "Listen Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else 4242 + }, + { + "additional_options": [ + { + "config_key": "prefer_ipv6", + "type": "checkbox", + "label": "Prefer IPv6?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "i2p_tunneled", + "type": "checkbox", + "label": "I2P Tunneled?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "placeholder": "A specific network device to listen on - e.g. eth0", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else 4242 + }, + ] + } + ], + "TCPClientInterface": [ + { + "config_key": "target_host", + "type": "edit", + "label": "Target Host: ", + "default": "", + "placeholder": "e.g., 127.0.0.1", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "target_port", + "type": "edit", + "label": "Target Port: ", + "default": "", + "placeholder": "e.g., 8080", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else 4242 + }, + { + "additional_options": [ + { + "config_key": "i2p_tunneled", + "type": "checkbox", + "label": "I2P Tunneled?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "kiss_framing", + "type": "checkbox", + "label": "KISS Framing?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + } + ] + } + ], + "UDPInterface": [ + { + "config_key": "listen_ip", + "type": "edit", + "label": "Listen IP: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "listen_port", + "type": "edit", + "label": "Listen Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else 4242 + }, + { + "config_key": "forward_ip", + "type": "edit", + "label": "Forward IP: ", + "default": "", + "placeholder": "e.g., 255.255.255.255", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "forward_port", + "type": "edit", + "label": "Forward Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else 4242 + }, + { + "additional_options": [ + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "placeholder": "A specific network device to listen on - e.g. eth0", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else 4242 + }, + ] + } + ], + "RNodeInterface": [ + get_port_field(), + { + "config_key": "frequency", + "type": "edit", + "label": "Frequency (MHz): ", + "default": "", + "placeholder": "868.5", + "validation": ["required", "float"], + "transform": lambda x: int(float(x.strip()) * 1000000) if x.strip() else 868500000 + }, + { + "config_key": "txpower", + "type": "edit", + "label": "Transmit Power (dBm): ", + "default": "", + "placeholder": "17", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else 17 + }, + { + "config_key": "bandwidth", + "type": "dropdown", + "label": "Bandwidth (Hz): ", + "options": ["7800", "10400", "15600", "20800", "31250", "41700", "62500", "125000", "250000", + "500000", "1625000"], + "default": "7800", + "validation": ["required"], + "transform": lambda x: int(x) + }, + { + "config_key": "spreadingfactor", + "type": "dropdown", + "label": "Spreading Factor: ", + "options": ["7", "8", "9", "10", "11", "12"], + "default": "7", + "validation": ["required"], + "transform": lambda x: int(x) + }, + { + "config_key": "codingrate", + "type": "dropdown", + "label": "Coding Rate: ", + "options": ["4:5", "4:6", "4:7", "4:8"], + "default": "4:5", + "validation": ["required"], + "transform": lambda x: int(x.split(":")[1]) + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "airtime_limit_long", + "type": "edit", + "label": "Airtime Limit Long (Seconds): ", + "placeholder": "e.g. 1.5", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "airtime_limit_short", + "type": "edit", + "label": "Airtime Limit Short (Seconds): ", + "placeholder": "e.g. 33", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + ] + } + ], + "SerialInterface": [ + get_port_field(), + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + ], + "PipeInterface": [ + { + "config_key": "command", + "type": "edit", + "label": "Command: ", + "default": "", + "placeholder": "e.g. netcat -l 5757", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "respawn_delay", + "type": "edit", + "label": "Respawn Delay (seconds): ", + "default": "", + "placeholder": "e.g. 5", + "validation": ["number"], + "transform": lambda x: x.strip() + }, + ], + "KISSInterface": [ + get_port_field(), + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "preamble", + "type": "edit", + "label": "Preamble (miliseconds): ", + "default": "", + "placeholder": "e.g. 150", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "txtail", + "type": "edit", + "label": "TX Tail (miliseconds): ", + "default": "", + "placeholder": "e.g. 10", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "slottime", + "type": "edit", + "label": "slottime (miliseconds): ", + "default": "", + "placeholder": "e.g. 20", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "persistence", + "type": "edit", + "label": "Persistence (miliseconds): ", + "default": "", + "placeholder": "e.g. 200", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "ID Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "flow_control", + "type": "checkbox", + "label": "Flow Control ", + "validation": [], + "transform": lambda x: "" if x == "" else bool(x) + }, + ] + } + ], + "AX25KISSInterface": [ + get_port_field(), + { + "config_key": "callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. NO1CLL", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "ssid", + "type": "edit", + "label": "SSID: ", + "default": "", + "placeholder": "e.g. 0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "preamble", + "type": "edit", + "label": "Preamble (miliseconds): ", + "default": "", + "placeholder": "e.g. 150", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "txtail", + "type": "edit", + "label": "TX Tail (miliseconds): ", + "default": "", + "placeholder": "e.g. 10", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "slottime", + "type": "edit", + "label": "Slottime (miliseconds): ", + "default": "", + "placeholder": "e.g. 20", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "persistence", + "type": "edit", + "label": "Persistence (miliseconds): ", + "default": "", + "placeholder": "e.g. 200", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "additional_options": [ + { + "config_key": "flow_control", + "type": "checkbox", + "label": "Flow Control ", + "validation": [], + "transform": lambda x: "" if x == "" else bool(x) + }, + ] + } + ], + "default": [ + { + + }, + ] +} + +### INTERFACE WIDGETS #### +class SelectableInterfaceItem(urwid.WidgetWrap): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None): + self.parent = parent + self._selectable = True + self.icon = icon + self.name = name + self.is_connected = is_connected + self.is_enabled = is_enabled + self.iface_options = iface_options + + + if is_enabled: + enabled_txt = ("connected_status", "Enabled") + else: + enabled_txt = ("disconnected_status", "Disabled") + + if is_connected: + connected_txt = ("connected_status", "Connected") + else: + connected_txt = ("disconnected_status", "Disconnected") + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text(("interface_title", f"{icon} {name}")) + + title_content = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + + self.tx_widget = urwid.Text(("value", format_bytes(tx))) + self.rx_widget = urwid.Text(("value", format_bytes(rx))) + + self.status_widget = urwid.Text(enabled_txt) + self.connection_widget = urwid.Text(connected_txt) + + rows = [ + urwid.Columns([ + (10, urwid.Text(("key", "Status: "))), + (10, self.status_widget), + (3, urwid.Text(" | ")), + self.connection_widget, + ]), + + urwid.Columns([ + (10, urwid.Text(("key", "Type:"))), + urwid.Text(("value", iface_type)), + ]), + + urwid.Divider("-"), + + urwid.Columns([ + (10, urwid.Text(("key", "TX:"))), + (15, self.tx_widget), + (10, urwid.Text(("key", "RX:"))), + self.rx_widget, + ]), + ] + + pile_contents = [title_content] + rows + + pile = urwid.Pile(pile_contents) + + padded_body = urwid.Padding(pile, left=2, right=2) + + box = urwid.LineBox( + padded_body, + title=None, + #todo + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + super().__init__(box) + + def update_status_display(self): + if self.is_enabled: + self.status_widget.set_text(("connected_status", "Enabled")) + else: + self.status_widget.set_text(("disconnected_status", "Disabled")) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + + if focus: + self.title_widget.set_text( + ("interface_title_selected", f"{self.icon} {self.name}")) + else: + self.title_widget.set_text(("interface_title", f"{self.icon} {self.name}")) + + return super().render(size, focus=focus) + + def keypress(self, size, key): + if key == "enter": + self.parent.switch_to_show_interface(self.name) + return None + return key + + def update_stats(self, tx, rx): + self.tx_widget.set_text(("value", format_bytes(tx))) + self.rx_widget.set_text(("value", format_bytes(rx))) + +class InterfaceOptionItem(urwid.WidgetWrap): + def __init__(self, parent_display, label, value): + self.parent_display = parent_display + self.label = label + self.value = value + self._selectable = True + + text_widget = urwid.Text(label, align="left") + super().__init__(urwid.AttrMap(text_widget, "list_normal", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == "enter": + self.parent_display.dismiss_dialog() + self.parent_display.switch_to_add_interface(self.value) + return None + return super().keypress(size, key) + +class InterfaceBandwidthChart: + + def __init__(self, history_length=60, glyphset="unicode"): + self.history_length = history_length + self.glyphset = glyphset + self.rx_rates = [0] * history_length + self.tx_rates = [0] * history_length + + self.prev_rx = None + self.prev_tx = None + self.prev_time = None + + self.max_rx_rate = 1 + self.max_tx_rate = 1 + + self.first_update = True + self.initialization_complete = False + self.stabilization_updates = 2 + self.update_count = 0 + + self.peak_rx_for_display = 0 + self.peak_tx_for_display = 0 + + def update(self, rx_bytes, tx_bytes): + current_time = time.time() + + if self.prev_rx is None or self.first_update: + self.prev_rx = rx_bytes + self.prev_tx = tx_bytes + self.prev_time = current_time + self.first_update = False + return + + time_delta = max(0.1, current_time - self.prev_time) + + rx_delta = max(0, rx_bytes - self.prev_rx) / time_delta + tx_delta = max(0, tx_bytes - self.prev_tx) / time_delta + + self.prev_rx = rx_bytes + self.prev_tx = tx_bytes + self.prev_time = current_time + + self.update_count += 1 + + self.rx_rates.pop(0) + self.tx_rates.pop(0) + self.rx_rates.append(rx_delta) + self.tx_rates.append(tx_delta) + + if self.update_count >= self.stabilization_updates: + self.initialization_complete = True + + self.peak_rx_for_display = max(self.peak_rx_for_display, rx_delta) + self.peak_tx_for_display = max(self.peak_tx_for_display, tx_delta) + + + current_rx_max = max(self.rx_rates) + current_tx_max = max(self.tx_rates) + + self.max_rx_rate = max(1, current_rx_max) + self.max_tx_rate = max(1, current_tx_max) + + def get_charts(self, height=8): + chart = AsciiChart(glyphset=self.glyphset) + + rx_data = self.rx_rates.copy() + tx_data = self.tx_rates.copy() + + peak_rx = self.peak_rx_for_display if self.initialization_complete else 0 + peak_tx = self.peak_tx_for_display if self.initialization_complete else 0 + + peak_rx_str = format_bytes(peak_rx) + peak_tx_str = format_bytes(peak_tx) + + rx_chart = chart.plot( + [rx_data], + { + 'height': height, + 'format': '{:8.1f}', + 'min': 0, + 'max': self.max_rx_rate * 1.1, + } + ) + + tx_chart = chart.plot( + [tx_data], + { + 'height': height, + 'format': '{:8.1f}', + 'min': 0, + 'max': self.max_tx_rate * 1.1, + } + ) + + return rx_chart, tx_chart, peak_rx_str, peak_tx_str + + +class ResponsiveChartContainer(urwid.WidgetWrap): + + def __init__(self, rx_box, tx_box, min_cols_for_horizontal=100): + self.rx_box = rx_box + self.tx_box = tx_box + self.min_cols_for_horizontal = min_cols_for_horizontal + + self.horizontal_layout = urwid.Columns([ + (urwid.WEIGHT, 1, self.rx_box), + (urwid.WEIGHT, 1, self.tx_box) + ]) + + self.vertical_layout = urwid.Pile([ + self.rx_box, + self.tx_box + ]) + + self.layout = urwid.WidgetPlaceholder(self.horizontal_layout) + + super().__init__(self.layout) + + def render(self, size, focus=False): + maxcol = size[0] if len(size) > 0 else 0 + + if maxcol >= self.min_cols_for_horizontal and self.layout.original_widget is not self.horizontal_layout: + self.layout.original_widget = self.horizontal_layout + elif maxcol < self.min_cols_for_horizontal and self.layout.original_widget is not self.vertical_layout: + self.layout.original_widget = self.vertical_layout + + return super().render(size, focus) + +### URWID FILLER ### +class InterfaceFiller(urwid.WidgetWrap): + def __init__(self, widget, app): + self.app = app + self.filler = urwid.Filler(widget, urwid.TOP) + super().__init__(self.filler) + + def keypress(self, size, key): + if key == "ctrl a": + # add interface + self.app.ui.main_display.sub_displays.interface_display.add_interface() + return + elif key == "ctrl x": + # remove Interface + self.app.ui.main_display.sub_displays.interface_display.remove_selected_interface() + return + elif key == "ctrl e": + # edit interface + self.app.ui.main_display.sub_displays.interface_display.edit_selected_interface() + return None + elif key == "tab": + # navigation + self.app.ui.main_display.frame.focus_position = "header" + return + + return super().keypress(size, key) + +### VIEWS ### +class AddInterfaceView(urwid.WidgetWrap): + def __init__(self, parent, iface_type): + self.parent = parent + self.iface_type = iface_type + self.fields = {} + self.port_pile = None + self.additional_fields = {} + self.additional_pile_contents = [] + self.common_fields = {} + + self.parent.shortcuts_display.set_add_interface_shortcuts() + + name_field = FormEdit( + config_key="name", + placeholder="Enter interface name", + validation_types=["required"] + ) + self.fields['name'] = { + 'label': "Name: ", + 'widget': name_field + } + + config = INTERFACE_FIELDS.get(iface_type, INTERFACE_FIELDS["default"]) + iface_fields = [field for field in config if "config_key" in field] + + for field in iface_fields: + self._initialize_field(field) + + self._initialize_additional_fields(config) + + self._initialize_common_fields() + + pile_items = self._build_form_layout(iface_fields) + + form_pile = urwid.Pile(pile_items) + form_filler = urwid.Filler(form_pile, valign="top") + #todo + form_box = urwid.LineBox( + form_filler, + title="Add Interface", + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + background = urwid.SolidFill(" ") + self.overlay = urwid.Overlay( + top_w=form_box, + bottom_w=background, + align='center', + width=('relative', 85), + valign='middle', + height=('relative', 85), + + ) + super().__init__(self.overlay) + + def _initialize_field(self, field): + if field["type"] == "dropdown": + widget = FormDropdown( + config_key=field["config_key"], + label=field["label"], + options=field["options"], + default=field.get("default"), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "checkbox": + widget = FormCheckbox( + config_key=field["config_key"], + label=field["label"], + state=field.get("default", False), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "multilist": + widget = FormMultiList( + config_key=field["config_key"], + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + else: + widget = FormEdit( + config_key=field["config_key"], + caption="", + edit_text=field.get("default", ""), + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + + self.fields[field["config_key"]] = { + 'label': field["label"], + 'widget': widget + } + + def _initialize_additional_fields(self, config): + for field in config: + if isinstance(field, dict) and "additional_options" in field: + for option in field["additional_options"]: + if option["type"] == "checkbox": + widget = FormCheckbox( + config_key=option["config_key"], + label=option["label"], + state=option.get("default", False), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "dropdown": + widget = FormDropdown( + config_key=option["config_key"], + label=option["label"], + options=option["options"], + default=option.get("default"), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "multilist": + widget = FormMultiList( + config_key=option["config_key"], + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + else: + widget = FormEdit( + config_key=option["config_key"], + caption="", + edit_text=str(option.get("default", "")), + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + + self.additional_fields[option["config_key"]] = { + 'label': option["label"], + 'widget': widget, + 'type': option["type"] + } + + def _initialize_common_fields(self): + + if self.parent.app.rns.transport_enabled(): + # Transport mode options + COMMON_INTERFACE_OPTIONS.extend([ + { + "config_key": "outgoing", + "type": "checkbox", + "label": "Allow outgoing traffic", + "default": True, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "mode", + "type": "dropdown", + "label": "Interface Mode: ", + "options": ["full", "gateway", "access_point", "roaming", "boundary"], + "default": "full", + "validation": [], + "transform": lambda x: x + }, + { + "config_key": "announce_cap", + "type": "edit", + "label": "Announce Cap: ", + "placeholder": "Default: 2.0", + "default": "", + "validation": ["float"], + "transform": lambda x: float(x) if x.strip() else 2.0 + } + ]) + + for option in COMMON_INTERFACE_OPTIONS: + if option["type"] == "checkbox": + widget = FormCheckbox( + config_key=option["config_key"], + label=option["label"], + state=option.get("default", False), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "dropdown": + widget = FormDropdown( + config_key=option["config_key"], + label=option["label"], + options=option["options"], + default=option.get("default"), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + else: + widget = FormEdit( + config_key=option["config_key"], + caption="", + edit_text=str(option.get("default", "")), + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + + + + self.common_fields[option["config_key"]] = { + 'label': option["label"], + 'widget': widget, + 'type': option["type"] + } + + def _on_rnode_field_change(self, widget, new_value): + if hasattr(self, 'rnode_calculator') and self.calculator_visible: + self.rnode_calculator.update_calculation() + + def _build_form_layout(self, iface_fields): + pile_items = [] + pile_items.append(urwid.Text(("form_title", f"Add new {_get_interface_icon(self.parent.glyphset, self.iface_type)} {self.iface_type}"), align="center")) + pile_items.append(urwid.Divider("─")) + + for key in ["name"] + [f["config_key"] for f in iface_fields]: + field = self.fields[key] + widget = field["widget"] + + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget, + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + if self.iface_type in ["RNodeInterface", "SerialInterface", "AX25KISSInterface", "KISSInterface"] and key == "port": + refresh_btn = urwid.Button("Refresh Ports", on_press=self.refresh_ports) + refresh_btn = urwid.AttrMap(refresh_btn, "button_normal", focus_map="button_focus") + refresh_row = urwid.Padding(refresh_btn, left=26, width=20) + field_pile.contents.append((refresh_row, field_pile.options())) + self.port_pile = field_pile + + pile_items.append(field_pile) + + self.more_options_visible = False + self.more_options_button = urwid.Button("Show more options", on_press=self.toggle_more_options) + self.more_options_button = urwid.AttrMap(self.more_options_button, "button_normal", focus_map="button_focus") + self.more_options_widget = urwid.Pile([]) + + self.ifac_options_visible = False + self.ifac_options_button = urwid.Button("Show IFAC options", on_press=self.toggle_ifac_options) + self.ifac_options_button = urwid.AttrMap(self.ifac_options_button, "button_normal", focus_map="button_focus") + self.ifac_options_widget = urwid.Pile([]) + + if self.iface_type == "RNodeInterface": + self.calculator_button = urwid.Button("Show On-Air Calculations", on_press=self.toggle_calculator) + self.calculator_button = urwid.AttrMap(self.calculator_button, "button_normal", focus_map="button_focus") + + save_btn = urwid.Button("Save", on_press=self.on_save) + back_btn = urwid.Button("Cancel", on_press=self.on_back) + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, save_btn), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, back_btn), + ]) + + pile_items.extend([ + urwid.Divider(), + self.more_options_button, + self.more_options_widget, + ]) + if self.iface_type == "RNodeInterface": + self.rnode_calculator = RNodeCalculator(self) + self.calculator_visible = False + self.calculator_widget = urwid.Pile([]) + pile_items.extend([ + self.calculator_button, + self.calculator_widget, + ]) + pile_items.extend([ + urwid.Divider("─"), + button_row, + ]) + + return pile_items + + def toggle_more_options(self, button): + if self.more_options_visible: + self.more_options_widget.contents = [] + button.base_widget.set_label("Show more options") + self.more_options_visible = False + else: + pile_contents = [] + + if self.additional_fields: + for key, field in self.additional_fields.items(): + widget = field['widget'] + + if field['type'] == "checkbox": + centered_widget = urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', widget), + ('weight', 1, urwid.Text("")) + ]) + field_pile = urwid.Pile([ + centered_widget, + urwid.Padding(widget.error_widget, left=24) + ]) + else: + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + pile_contents.append(field_pile) + + if self.additional_fields and self.common_fields: + pile_contents.append(urwid.Divider("─")) + + if self.common_fields: + # pile_contents.append(urwid.Text(("interface_title", "Common "), align="center")) + for key, field in self.common_fields.items(): + widget = field['widget'] + + if field['type'] == "checkbox": + centered_widget = urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', widget), + ('weight', 1, urwid.Text("")) + ]) + field_pile = urwid.Pile([ + centered_widget, + urwid.Padding(widget.error_widget, left=24) + ]) + else: + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + pile_contents.append(field_pile) + + if pile_contents: + self.more_options_widget.contents = [(w, self.more_options_widget.options()) for w in pile_contents] + else: + self.more_options_widget.contents = [( + urwid.Text("No additional options available", align="center"), + self.more_options_widget.options() + )] + + button.base_widget.set_label("Hide more options") + self.more_options_visible = True + + def toggle_ifac_options(self, button): + if self.ifac_options_visible: + self.ifac_options_widget.contents = [] + button.base_widget.set_label("Show IFAC options") + self.ifac_options_visible = False + else: + dummy = urwid.Text("IFAC (Interface Access Codes)", align="left") + self.ifac_options_widget.contents = [(dummy, self.more_options_widget.options())] + button.base_widget.set_label("Hide IFAC options") + self.ifac_options_visible = True + + def toggle_calculator(self, button): + if self.calculator_visible: + self.calculator_widget.contents = [] + button.base_widget.set_label("Show On-Air Calculations") + self.calculator_visible = False + else: + calculator_contents = [self.rnode_calculator] + + self.calculator_widget.contents = [(w, self.calculator_widget.options()) for w in calculator_contents] + + self.rnode_calculator.update_calculation() + + button.base_widget.set_label("Hide On-Air Calculations") + self.calculator_visible = True + + def refresh_ports(self, button): + if self.port_pile is not None: + # Get fresh port config + port_field = get_port_field() + + if port_field["type"] == "dropdown": + widget = FormDropdown( + config_key=port_field["config_key"], + label=port_field["label"], + options=port_field["options"], + default=port_field.get("default"), + validation_types=port_field.get("validation", []), + transform=port_field.get("transform") + ) + else: + widget = FormEdit( + config_key=port_field["config_key"], + caption="", + edit_text=port_field.get("default", ""), + placeholder=port_field.get("placeholder", ""), + validation_types=port_field.get("validation", []), + transform=port_field.get("transform") + ) + + self.fields["port"] = { + 'label': port_field["label"], + 'widget': widget + } + + columns = urwid.Columns([ + (26, urwid.Text(("key", port_field["label"]), align="right")), + widget + ]) + self.port_pile.contents[0] = (columns, self.port_pile.options()) + + self.port_pile.contents[1] = (urwid.Padding(widget.error_widget, left=24), self.port_pile.options()) + + def validate_all(self): + all_valid = True + + # validate main fields + for field in self.fields.values(): + if not field["widget"].validate(): + all_valid = False + + # validate additional iface fields + for field in self.additional_fields.values(): + if not field["widget"].validate(): + all_valid = False + + # validate common fields + for field in self.common_fields.values(): + if not field["widget"].validate(): + all_valid = False + + return all_valid + + def on_save(self, button): + all_valid = self.validate_all() + + name = self.fields['name']["widget"].get_value() or "Untitled interface" + + existing_interfaces = self.parent.app.rns.config['interfaces'] + if name in existing_interfaces: + self.fields['name']["widget"].error = f"Interface name '{name}' already exists" + self.fields['name']["widget"].error_widget.set_text(("error", self.fields['name']["widget"].error)) + all_valid = False + + if not all_valid: + return + + interface_config = { + "type": self.iface_type, + "interface_enabled": True + } + + for field_key, field in self.fields.items(): + if field_key != "name": + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + interface_config[widget.config_key] = value + + for field_key, field in self.additional_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + interface_config[widget.config_key] = value + + for field_key, field in self.common_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + interface_config[widget.config_key] = value + + # Add interface to RNS config + try: + interfaces = self.parent.app.rns.config['interfaces'] + + interfaces[name] = interface_config + + self.parent.app.rns.config.write() + print(self.parent.glyphset) + new_item = SelectableInterfaceItem( + parent=self.parent, + name=name, + is_connected=False, # will always be false until restart + is_enabled=True, + iface_type=self.iface_type, + tx=0, + rx=0, + icon=_get_interface_icon(self.parent.glyphset, self.iface_type), + iface_options=interface_config + ) + + self.parent.interface_items.append(new_item) + self.parent._rebuild_list() + + self.show_message(f"Interface {name} added. Restart NomadNet to start using this interface") + + except Exception as e: + print(f"Error saving interface: {str(e)}") + self.show_message(f"Error: {str(e)}", title="Error") + + def on_back(self, button): + self.parent.switch_to_list() + + def show_message(self, message, title="Notice"): + + def dismiss_dialog(button): + self.parent.switch_to_list() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title=title + ) + + overlay = urwid.Overlay( + dialog, + self.parent.interfaces_display, + align='center', + width=100, + valign='middle', + height=8, + min_width=1, + min_height=1 + ) + + self.parent.widget = overlay + self.parent.app.ui.main_display.update_active_sub_display() + +class EditInterfaceView(AddInterfaceView): + def __init__(self, parent, iface_name): + self.parent = parent + self.iface_name = iface_name + + self.interface_config = parent.app.rns.config['interfaces'][iface_name] + self.iface_type = self.interface_config.get("type", "Unknown") + + super().__init__(parent, self.iface_type) + + self.overlay.top_w.title_widget.set_text(f"Edit Interface: {iface_name}") + self._populate_form_fields() + + def _populate_form_fields(self): + + self.fields['name']['widget'].edit_text = self.iface_name + + for key, field in self.fields.items(): + if key != 'name' and key in self.interface_config: + widget = field['widget'] + value = self.interface_config[key] + + if key == 'frequency': + # convert Hz back to MHz + value = float(value) / 1000000 + # decimal format + value = f"{value:.6f}".rstrip('0').rstrip('.') if '.' in f"{value:.6f}" else f"{value}" + + if hasattr(widget, 'edit_text'): + # For text input fields, set edit_text + widget.edit_text = str(value) + elif hasattr(widget, 'set_state'): + # For checkboxes + widget.set_state(bool(value)) + elif isinstance(widget, FormDropdown): + # For dropdowns - update selected and update display + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + else: + for opt in widget.options: + try: + if widget.transform(opt) == value: + widget.selected = opt + widget.main_button.base_widget.set_text(opt) + break + except: + pass + elif isinstance(widget, FormMultiList): + if isinstance(value, str): + items = [item.strip() for item in value.split(',') if item.strip()] + self._populate_multilist(widget, items) + elif isinstance(value, list): + self._populate_multilist(widget, value) + + for key, field in self.additional_fields.items(): + if key in self.interface_config: + widget = field['widget'] + value = self.interface_config[key] + + if hasattr(widget, 'edit_text'): + widget.edit_text = str(value) + elif hasattr(widget, 'set_state'): + # RNS.log(f"KEY: {key} VAL: {value}") + checkbox_state = value if isinstance(value, bool) else value.strip().lower() not in ('false', 'off', 'no', '0') + widget.set_state(checkbox_state) + elif isinstance(widget, FormDropdown): + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + else: + # Try to match after transform + for opt in widget.options: + try: + if widget.transform(opt) == value: + widget.selected = opt + widget.main_button.base_widget.set_text(opt) + break + except: + pass + elif isinstance(widget, FormMultiList): + if isinstance(value, str): + items = [item.strip() for item in value.split(',') if item.strip()] + self._populate_multilist(widget, items) + elif isinstance(value, list): + self._populate_multilist(widget, value) + + for key, field in self.common_fields.items(): + if key in self.interface_config: + widget = field['widget'] + value = self.interface_config[key] + + if hasattr(widget, 'edit_text'): + widget.edit_text = str(value) + elif hasattr(widget, 'set_state'): + widget.set_state(bool(value)) + elif isinstance(widget, FormDropdown): + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + else: + # Try to match after transform + for opt in widget.options: + try: + if widget.transform(opt) == value: + widget.selected = opt + widget.main_button.base_widget.set_text(opt) + break + except: + pass + elif isinstance(widget, FormMultiList): + if isinstance(value, str): + items = [item.strip() for item in value.split(',') if item.strip()] + self._populate_multilist(widget, items) + elif isinstance(value, list): + self._populate_multilist(widget, value) + + def _populate_multilist(self, widget, items): + while len(widget.entries) > 1: + widget.remove_entry(None, widget.entries[-1]) + + if items: + first_entry = widget.entries[0] + first_edit = first_entry.contents[0][0] + if len(items) > 0: + first_edit.edit_text = items[0] + + for i in range(1, len(items)): + widget.add_entry(None) + entry = widget.entries[i] + edit_widget = entry.contents[0][0] + edit_widget.edit_text = items[i] + + def on_save(self, button): + if not self.validate_all(): + return + + new_name = self.fields['name']["widget"].get_value() or self.iface_name + + updated_config = { + "type": self.iface_type, + "interface_enabled": True + } + + for field_key, field in self.fields.items(): + if field_key != "name": + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + for field_key, field in self.additional_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + for field_key, field in self.common_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + try: + interfaces = self.parent.app.rns.config['interfaces'] + + if new_name != self.iface_name: + del interfaces[self.iface_name] + interfaces[new_name] = updated_config + + for i, item in enumerate(self.parent.interface_items): + if item.name == self.iface_name: + self.parent.interface_items[i].name = new_name + break + else: + interfaces[self.iface_name] = updated_config + + self.parent.app.rns.config.write() + + for item in self.parent.interface_items: + if item.name == new_name: + item.iface_type = self.iface_type + break + + self.parent._rebuild_list() + + self.show_message(f"Interface {new_name} updated. Restart NomadNet for these changes to take effect") + + except Exception as e: + print(f"Error saving interface: {str(e)}") + self.show_message(f"Error updating interface: {str(e)}", title="Error") + + +class ShowInterface(urwid.WidgetWrap): + def __init__(self, parent, iface_name): + self.parent = parent + self.iface_name = iface_name + self.started = False + self.g = self.parent.app.ui.glyphs + + # get config + self.interface_config = self.parent.app.rns.config['interfaces'][iface_name] + iface_type = self.interface_config.get("type", "Unknown") + + self.parent.shortcuts_display.set_show_interface_shortcuts() + + self.config_rows = [] + + # get interface stats + interface_stats = self.parent.app.rns.get_interface_stats() + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + self.stats = stats_lookup.get(iface_name, {}) + + self.tx = self.stats.get("txb", 0) + self.rx = self.stats.get("rxb", 0) + self.is_connected = self.stats.get("status", False) + self.is_enabled = (str(self.interface_config.get("enabled")).lower() != 'false' and + str(self.interface_config.get("interface_enabled")).lower() != 'false') + + header_content = [ + urwid.Text(("interface_title", f"Interface: {iface_name}"), align="center"), + urwid.Divider("=") + ] + header = urwid.Pile(header_content) + + self.edit_button = urwid.Button("Edit", on_press=self.on_edit) + self.back_button = urwid.Button("Back", on_press=self.on_back) + + self.toggle_button = urwid.Button( + "Disable" if self.is_enabled else "Enable", + on_press=self.on_toggle_enabled + ) + + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, self.back_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.toggle_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.edit_button), + ]) + + footer_content = [ + urwid.Divider("="), + button_row + ] + footer = urwid.Pile(footer_content) + + # status widgets + self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status", + "Enabled" if self.is_enabled else "Disabled")) + + self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status", + self.parent.g['selected'] if self.is_enabled else self.parent.g[ + 'unselected'])) + + self.connection_text = urwid.Text(("connected_status" if self.is_connected else "disconnected_status", + "Connected" if self.is_connected else "Disconnected")) + + self.info_rows = [ + urwid.Columns([ + (10, urwid.Text(("key", "Type:"))), + urwid.Text(("value", f"{_get_interface_icon(self.parent.glyphset, iface_type)} {iface_type}")), + ]), + urwid.Columns([ + (10, urwid.Text(("key", "Status:"))), + (4, self.status_indicator), + (8, self.status_text), + (3, urwid.Text(" | ")), + self.connection_text, + ]), + urwid.Divider("-") + ] + + self.tx_text = urwid.Text(("value", format_bytes(self.tx))) + self.rx_text = urwid.Text(("value", format_bytes(self.rx))) + + self.stat_row = urwid.Columns([ + (10, urwid.Text(("key", "TX:"))), + (15, self.tx_text), + (10, urwid.Text(("key", "RX:"))), + self.rx_text, + ]) + + self.info_rows.append(self.stat_row) + self.info_rows.append(urwid.Divider("-")) + + self.bandwidth_chart = InterfaceBandwidthChart(history_length=60, glyphset=self.parent.glyphset) + self.bandwidth_chart.update(self.rx, self.tx) + + self.rx_chart_text = urwid.Text("Loading RX data...", align='left') + self.tx_chart_text = urwid.Text("Loading TX data...", align='left') + self.rx_peak_text = urwid.Text("Peak: 0 B/s", align='right') + self.tx_peak_text = urwid.Text("Peak: 0 B/s", align='right') + + self.rx_box = urwid.LineBox( + urwid.Pile([ + urwid.AttrMap(self.rx_chart_text, "rx"), + self.rx_peak_text + ]), + title="RX Traffic (60s)" + ) + + self.tx_box = urwid.LineBox( + urwid.Pile([ + urwid.AttrMap(self.tx_chart_text, "tx"), + self.tx_peak_text + ]), + title="TX Traffic (60s)" + ) + + self.horizontal_charts = urwid.Columns([ + (urwid.WEIGHT, 1, self.rx_box), + (urwid.WEIGHT, 1, self.tx_box) + ]) + + self.vertical_charts = urwid.Pile([ + self.rx_box, + self.tx_box + ]) + + self.disconnected_message = urwid.Filler( + urwid.Text(("disconnected_status", + "Charts not available - Interface is not connected"), + align="center"), + valign="top" + ) + self.disconnected_box = urwid.LineBox(self.disconnected_message, title="Bandwidth Charts") + + self.charts_widget = self.vertical_charts + self.is_horizontal = False + + screen = urwid.raw_display.Screen() + screen_cols, _ = screen.get_cols_rows() + if screen_cols >= 100: + self.charts_widget = self.horizontal_charts + self.is_horizontal = True + + if not self.is_connected: + self.charts_widget = self.disconnected_box + + connection_params = [] + radio_params = [] + network_params = [] + ifac_params = [] + other_params = [] + + # Sort parameters into groups + for key, value in self.interface_config.items(): + # skip empty + if value is None or value == "": + continue + + # Skip these keys as their shown elsewhere + if key in ["type", "interface_enabled", "enabled", 'selected_interface_mode', 'name']: + continue + + # Connection parameters + elif key in ["port", "listen_ip", "listen_port", "target_host", "target_port", "device"]: + connection_params.append((key, value)) + + # Radio parameters + elif key in ["frequency", "bandwidth", "spreadingfactor", "codingrate", "txpower"]: + radio_params.append((key, value)) + + # Network parameters + elif key in ["network_name", "bitrate", "peers", "group_id", "multicast_address_type", + "discovery_scope", "announce_cap", "mode"]: + network_params.append((key, value)) + + # IFAC parameters + elif key in ["passphrase", "ifac_size", "ifac_netname", "ifac_netkey"]: + ifac_params.append((key, value)) + + else: + other_params.append((key, value)) + + def create_param_row(key, value): + if isinstance(value, bool): + value_str = "Yes" if value else "No" + elif key == "frequency": + int_value = int(value) + value_str = f"{int_value / 1000000:.3f} MHz" + elif key == "bandwidth": + int_value = int(value) + value_str = f"{int_value / 1000:.1f} kHz" + else: + value_str = str(value) + # format display keys: "listen_port" => Listen Port + display_key = key.replace('_', ' ').title() + + return urwid.Columns([ + (18, urwid.Text(("key", f"{display_key}:"))), + urwid.Text(("value", value_str)), + ]) + + if connection_params: + connection_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Connection Parameters"), align="left")) + for key, value in connection_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if radio_params: + radio_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Radio Parameters"), align="left")) + for key, value in radio_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if network_params: + network_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Network Parameters"), align="left")) + for key, value in network_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if ifac_params: + ifac_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "IFAC Parameters"), align="left")) + for key, value in ifac_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if other_params: + other_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Additional Parameters"), align="left")) + for key, value in other_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if not self.config_rows: + self.config_rows.append(urwid.Text("No additional parameters", align="center")) + + body_content = [] + body_content.extend(self.info_rows) + body_content.append( + self.charts_widget) + body_content.append(urwid.Divider("-")) + body_content.extend(self.config_rows) + + body_pile = urwid.Pile(body_content) + body_padding = urwid.Padding(body_pile, left=2, right=2) + + body = urwid.ListBox(urwid.SimpleListWalker([body_padding])) + + self.frame = urwid.Frame( + body=body, + header=header, + footer=footer + ) + + self.content_box = urwid.LineBox(self.frame) + + super().__init__(self.content_box) + + def update_status_display(self): + if self.is_enabled: + self.status_indicator.set_text(("connected_status", self.parent.g['selected'])) + self.status_text.set_text(("connected_status", "Enabled")) + else: + self.status_indicator.set_text(("disconnected_status", self.parent.g['unselected'])) + self.status_text.set_text(("disconnected_status", "Disabled")) + + def update_connection_display(self, is_connected): + old_connection_state = self.is_connected + self.is_connected = is_connected + + self.connection_text.set_text(("connected_status" if self.is_connected else "disconnected_status", + "Connected" if self.is_connected else "Disconnected")) + + if old_connection_state != self.is_connected: + body_pile = self.frame.body.body[0].original_widget + + chart_index = None + for i, (widget, options) in enumerate(body_pile.contents): + if (widget == self.horizontal_charts or + widget == self.vertical_charts or + widget == self.disconnected_box): + chart_index = i + break + + if chart_index is not None: + if self.is_connected: + new_widget = self.horizontal_charts if self.is_horizontal else self.vertical_charts + if not self.started: + self.start() + else: + new_widget = self.disconnected_box + self.started = False + + body_pile.contents[chart_index] = (new_widget, body_pile.options()) + + def on_toggle_enabled(self, button): + action = "disable" if self.is_enabled else "enable" + + def on_confirm_yes(confirm_button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + self.is_enabled = not self.is_enabled + + self.toggle_button.set_label("Disable" if self.is_enabled else "Enable") + + if "interface_enabled" in self.interface_config: + self.interface_config["interface_enabled"] = self.is_enabled + else: + self.interface_config["enabled"] = self.is_enabled + + try: + interfaces = self.parent.app.rns.config['interfaces'] + + interfaces[self.iface_name] = self.interface_config + + self.parent.app.rns.config.write() + + self.update_status_display() + + for item in self.parent.interface_items: + if item.name == self.iface_name: + item.is_enabled = self.is_enabled + item.update_status_display() + + if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None: + self.parent.app.ui.loop.draw_screen() + + self.show_restart_required_message() + + except Exception as e: + self.show_error_message(f"Error updating interface: {str(e)}") + + def on_confirm_no(confirm_button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + confirm_text = urwid.Text(( + "interface_title", + f"Are you sure you want to {action} the {self.iface_name} interface?" + ), align="center") + + yes_button = urwid.Button("Yes", on_press=on_confirm_yes) + no_button = urwid.Button("No", on_press=on_confirm_no) + + buttons_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, yes_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, no_button), + ]) + + pile = urwid.Pile([ + confirm_text, + urwid.Divider(), + buttons_row + ]) + + dialog = DialogLineBox(pile, title="Confirm") + + overlay = urwid.Overlay( + dialog, + self.parent.app.ui.main_display.frame.body, + align='center', + width=50, + valign='middle', + height=7 + ) + + self.parent.app.ui.main_display.frame.body = overlay + + def show_restart_required_message(self): + + def dismiss_dialog(button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text( + f"Interface {self.iface_name} has been " + + ("enabled" if self.is_enabled else "disabled") + + ".\nRestart required for changes to take effect.", + align="center" + ), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title="Notice" + ) + + overlay = urwid.Overlay( + dialog, + self.parent.app.ui.main_display.frame.body, + align='center', + width=50, + valign='middle', + height=8 + ) + + self.parent.app.ui.main_display.frame.body = overlay + + def show_error_message(self, message): + + def dismiss_dialog(button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title="Error" + ) + + overlay = urwid.Overlay( + dialog, + self.parent.app.ui.main_display.frame.body, + align='center', + width=50, + valign='middle', + height=8 + ) + + self.parent.app.ui.main_display.frame.body = overlay + + def keypress(self, size, key): + if key == 'tab': + if self.frame.focus_position == 'body': + self.frame.focus_position = 'footer' + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + footer_pile.focus_position = 1 # button row + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + button_row.focus_position = 0 + return None + # If we're currently in footer change the focus + elif self.frame.focus_position == 'footer': + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + # If on first button (Back), move to Toggle button + if button_row.focus_position == 0: + button_row.focus_position = 2 # skip spacer + return None + # If on toggle button, move to Edit button + elif button_row.focus_position == 2: + button_row.focus_position = 4 + return None + # if on edit button wrap back to toggle button + elif button_row.focus_position == 4: + button_row.focus_position = 0 + return None + elif key == 'shift tab': + if self.frame.focus_position == 'footer': + self.frame.focus_position = 'body' + return None + elif self.frame.focus_position == 'footer': + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + if button_row.focus_position == 4: # edit button + button_row.focus_position = 2 # toggle button + return None + elif button_row.focus_position == 2: + button_row.focus_position = 0 # back button + return None + elif button_row.focus_position == 0: # back button + self.frame.focus_position = 'body' + return None + elif key == "h" and self.is_connected: # horizontal layout + if not self.is_horizontal: + self.switch_to_horizontal() + return None + elif key == "v" and self.is_connected: # vertical layout + if self.is_horizontal: + self.switch_to_vertical() + return None + + return super().keypress(size, key) + + def switch_to_horizontal(self): + if not self.is_connected: + return + + self.is_horizontal = True + + body_pile = self.frame.body.body[0].original_widget + for i, (widget, options) in enumerate(body_pile.contents): + if widget == self.vertical_charts: + body_pile.contents[i] = (self.horizontal_charts, options) + self.charts_widget = self.horizontal_charts + break + + def switch_to_vertical(self): + if not self.is_connected: + return + + self.is_horizontal = False + body_pile = self.frame.body.body[0].original_widget + for i, (widget, options) in enumerate(body_pile.contents): + if widget == self.horizontal_charts: + body_pile.contents[i] = (self.vertical_charts, options) + self.charts_widget = self.vertical_charts + break + + def start(self): + if not self.started and self.is_connected: + self.started = True + self.parent.app.ui.loop.set_alarm_in(1, self.update_bandwidth_charts) + + def update_bandwidth_charts(self, loop, user_data): + if not self.started: + return + + interface_stats = self.parent.app.rns.get_interface_stats() + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + stats = stats_lookup.get(self.iface_name, {}) + + tx = stats.get("txb", self.tx) + rx = stats.get("rxb", self.rx) + + new_connection_status = stats.get("status", False) + if new_connection_status != self.is_connected: + self.update_connection_display(new_connection_status) + + if not self.is_connected: + return + + self.tx_text.set_text(("value", format_bytes(tx))) + self.rx_text.set_text(("value", format_bytes(rx))) + + self.bandwidth_chart.update(rx, tx) + + rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) + + self.rx_chart_text.set_text(rx_chart) + self.tx_chart_text.set_text(tx_chart) + self.rx_peak_text.set_text(f"Peak: {peak_rx}") + self.tx_peak_text.set_text(f"Peak: {peak_tx}") + + self.tx = tx + self.rx = rx + + if self.started: + loop.set_alarm_in(1, self.update_bandwidth_charts) + + def on_back(self, button): + self.started = False + self.parent.switch_to_list() + + def on_edit(self, button): + self.started = False + self.parent.switch_to_edit_interface(self.iface_name) + +### MAIN DISPLAY ### +class InterfaceDisplay: + def __init__(self, app): + self.app = app + self.started = False + self.interface_items = [] + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.terminal_cols, self.terminal_rows = urwid.raw_display.Screen().get_cols_rows() + self.iface_row_offset = 4 + self.list_rows = self.terminal_rows - self.iface_row_offset + + interfaces = app.rns.config['interfaces'] + processed_interfaces = {} + + for interface_name, interface in interfaces.items(): + interface_data = interface.copy() + + # handle sub-interfaces for RNodeMultiInterface + if interface_data.get("type") == "RNodeMultiInterface": + sub_interfaces = [] + for sub_name, sub_config in interface_data.items(): + if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", + "configured_bitrate"}: + if isinstance(sub_config, dict): + sub_config["name"] = sub_name + sub_interfaces.append(sub_config) + + # add sub-interfaces to the main interface data + interface_data["sub_interfaces"] = sub_interfaces + + for sub in sub_interfaces: + del interface_data[sub["name"]] + + processed_interfaces[interface_name] = interface_data + + interface_stats = app.rns.get_interface_stats() + stats_lookup = {interface['short_name']: interface for interface in interface_stats['interfaces']} + # print(stats_lookup) + for interface_name, interface_data in processed_interfaces.items(): + # configobj false values + is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + iface_type = interface_data.get("type", "Unknown") + icon = _get_interface_icon(self.glyphset, iface_type) + + stats_for_interface = stats_lookup.get(interface_name) + + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + is_connected = stats_for_interface["status"] + else: + tx = 0 + rx = 0 + is_connected = False + + item = SelectableInterfaceItem( + parent=self, + name=interface_data.get("name", interface_name), + is_connected=is_connected, + is_enabled=is_enabled, + iface_type=iface_type, + tx=tx, + rx=rx, + icon=icon + ) + + self.interface_items.append(item) + + interface_header = urwid.Text(("interface_title", "Interfaces"), align="center") + if len(self.interface_items) == 0: + interface_header = urwid.Text( + ("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center") + + + list_contents = [ + interface_header, + urwid.Divider(), + ] + self.interface_items + + self.list_walker = urwid.SimpleFocusListWalker(list_contents) + self.list_box = urwid.ListBox(self.list_walker) + + self.box_adapter = urwid.BoxAdapter(self.list_box, self.list_rows) + + + pile = urwid.Pile([self.box_adapter]) + self.interfaces_display = InterfaceFiller(pile, self.app) + self.shortcuts_display = InterfaceDisplayShortcuts(self.app) + self.widget = self.interfaces_display + + def start(self): + # started from Main.py + self.started = True + self.app.ui.loop.set_alarm_in(1, self.poll_stats) + self.app.ui.loop.set_alarm_in(5, self.check_terminal_size) + + def switch_to_edit_interface(self, iface_name): + self.edit_interface_view = EditInterfaceView(self, iface_name) + self.widget = self.edit_interface_view + self.app.ui.main_display.update_active_sub_display() + + def edit_selected_interface(self): + focus_widget, focus_position = self.box_adapter._original_widget.body.get_focus() + + if not isinstance(focus_widget, SelectableInterfaceItem): + return + + selected_item = focus_widget + interface_name = selected_item.name + + self.switch_to_edit_interface(interface_name) + + def check_terminal_size(self, loop, user_data): + new_cols, new_rows = loop.screen.get_cols_rows() + + if new_rows != self.terminal_rows or new_cols != self.terminal_cols: + self.terminal_cols, self.terminal_rows = new_cols, new_rows + self.list_rows = self.terminal_rows - self.iface_row_offset + + self.box_adapter.height = self.list_rows + + loop.draw_screen() + + if self.started: + loop.set_alarm_in(5, self.check_terminal_size) + + def poll_stats(self, loop, user_data): + interface_stats = self.app.rns.get_interface_stats() + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + for item in self.interface_items: + # use interface name as the key + stats_for_interface = stats_lookup.get(item.name) + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + item.update_stats(tx, rx) + + loop.set_alarm_in(1, self.poll_stats) + + + def shortcuts(self): + return self.shortcuts_display + + def switch_to_show_interface(self, iface_name): + show_interface = ShowInterface(self, iface_name) + self.widget = show_interface + self.app.ui.main_display.update_active_sub_display() + + show_interface.start() + + def switch_to_list(self): + self.shortcuts_display.reset_shortcuts() + self.widget = self.interfaces_display + self._rebuild_list() + self.app.ui.main_display.update_active_sub_display() + + def add_interface(self): + dialog_widgets = [] + + def add_heading(txt): + dialog_widgets.append(urwid.Text(("interface_title", txt), align="left")) + + def add_option(label, value): + item = InterfaceOptionItem(self, label, value) + dialog_widgets.append(item) + + # Get the icons based on plain, unicode, nerdfont glyphset + network_icon = _get_interface_icon(self.glyphset, "AutoInterface") + rnode_icon = _get_interface_icon(self.glyphset, "RNodeInterface") + serial_icon = _get_interface_icon(self.glyphset, "SerialInterface") + other_icon = _get_interface_icon(self.glyphset, "PipeInterface") + + add_heading(f"{network_icon} IP Networks") + add_option("Auto Interface", "AutoInterface") + add_option("TCP Client Interface", "TCPClientInterface") + add_option("TCP Server Interface", "TCPServerInterface") + add_option("UDP Interface", "UDPInterface") + add_option("I2P Interface", "I2PInterface") + + add_heading(f"{rnode_icon} RNodes") + add_option("RNode Interface", "RNodeInterface") + # add_option("RNode Multi Interface", "RNodeMultiInterface") TODO + + add_heading(f"{serial_icon} Hardware") + add_option("Serial Interface", "SerialInterface") + add_option("KISS Interface", "KISSInterface") + add_option("AX.25 KISS Interface", "AX25KISSInterface") + + add_heading(f"{other_icon} Other") + add_option("Pipe Interface", "PipeInterface") + # add_option("Custom Interface", "CustomInterface") TODO + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)) + dialog = DialogLineBox(listbox, parent=self, title="Select Interface Type") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 50), + valign='middle', + height=('relative', 50), + min_width=20, + min_height=15, + left=2, + right=2 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def switch_to_add_interface(self, iface_type): + self.add_interface_view = AddInterfaceView(self, iface_type) + self.widget = self.add_interface_view + self.app.ui.main_display.update_active_sub_display() + + def remove_selected_interface(self): + focus_widget, focus_position = self.box_adapter._original_widget.body.get_focus() + if not isinstance(focus_widget, SelectableInterfaceItem): + return + + selected_item = focus_widget + interface_name = selected_item.name + + def on_confirm_yes(button): + try: + if interface_name in self.app.rns.config['interfaces']: + del self.app.rns.config['interfaces'][interface_name] + self.app.rns.config.write() + + if selected_item in self.interface_items: + self.interface_items.remove(selected_item) + + self._rebuild_list() + self.dismiss_dialog() + + except Exception as e: + print(e) + + def on_confirm_no(button): + self.dismiss_dialog() + + confirm_text = urwid.Text(("interface_title", f"Remove interface {interface_name}?"), align="center") + yes_button = urwid.Button("Yes", on_press=on_confirm_yes) + no_button = urwid.Button("No", on_press=on_confirm_no) + + buttons_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, yes_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, no_button), + ]) + + pile = urwid.Pile([ + confirm_text, + buttons_row + ]) + + dialog = DialogLineBox(pile, parent=self, title="?") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 35), + valign='middle', + height=(5), + min_width=5, + left=2, + right=2 + ) + dialog.original_widget.focus_position = 1 # columns row + buttons_row = dialog.original_widget.contents[1][0] + buttons_row.focus_position = 2 # second button "No" + + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def dismiss_dialog(self): + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + + def _rebuild_list(self): + interface_header = urwid.Text(("interface_title", f"Interfaces ({len(self.interface_items)})"), align="center") + if len(self.interface_items) == 0: + interface_header = urwid.Text(("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center") + + new_list = [ + interface_header, + urwid.Divider(), + ] + self.interface_items + # RNS.log(f"items: {self.interface_items}") + + walker = urwid.SimpleFocusListWalker(new_list) + self.box_adapter._original_widget.body = walker + self.box_adapter._original_widget.focus_position = len(new_list) - 1 + +### SHORTCUTS ### +class InterfaceDisplayShortcuts: + def __init__(self, app): + self.app = app + self.default_shortcuts = "[C-a] Add Interface [C-e] Edit Interface [C-x] Remove Interface [Enter] Show Interface" + self.current_shortcuts = self.default_shortcuts + self.widget = urwid.AttrMap( + urwid.Text(self.current_shortcuts), + "shortcutbar" + ) + + def update_shortcuts(self, new_shortcuts): + self.current_shortcuts = new_shortcuts + self.widget.original_widget.set_text(new_shortcuts) + + def reset_shortcuts(self): + self.update_shortcuts(self.default_shortcuts) + + def set_show_interface_shortcuts(self): + show_shortcuts = "[Back] Return to List [Tab] Navigate [Shift-tab] Change Focus [h] Horizontal Charts [v] Vertical Charts [Up/Down] Scroll" + self.update_shortcuts(show_shortcuts) + + def set_add_interface_shortcuts(self): + add_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option" + self.update_shortcuts(add_shortcuts) + + def set_edit_interface_shortcuts(self): + edit_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option" + self.update_shortcuts(edit_shortcuts) \ No newline at end of file diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 303024a..34d88c3 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -4,6 +4,7 @@ from .Network import * from .Conversations import * from .Directory import * from .Config import * +from .Interfaces import * from .Map import * from .Log import * from .Guide import * @@ -16,6 +17,7 @@ class SubDisplays(): self.conversations_display = ConversationsDisplay(self.app) self.directory_display = DirectoryDisplay(self.app) self.config_display = ConfigDisplay(self.app) + self.interface_display = InterfaceDisplay(self.app) self.map_display = MapDisplay(self.app) self.log_display = LogDisplay(self.app) self.guide_display = GuideDisplay(self.app) @@ -113,6 +115,11 @@ class MainDisplay(): self.sub_displays.active_display = self.sub_displays.config_display self.update_active_sub_display() + def show_interfaces(self, user_data): + self.sub_displays.active_display = self.sub_displays.interface_display + self.update_active_sub_display() + self.sub_displays.interface_display.start() + def show_log(self, user_data): self.sub_displays.active_display = self.sub_displays.log_display self.sub_displays.log_display.show() @@ -171,21 +178,22 @@ class MenuDisplay(): self.menu_indicator = urwid.Text("") - menu_text = (urwid.PACK, self.menu_indicator) - button_network = (11, MenuButton("Network", on_press=handler.show_network)) - button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) - button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) - button_map = (7, MenuButton("Map", on_press=handler.show_map)) - button_log = (7, MenuButton("Log", on_press=handler.show_log)) - button_config = (10, MenuButton("Config", on_press=handler.show_config)) - button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) - button_quit = (8, MenuButton("Quit", on_press=handler.quit)) + menu_text = (urwid.PACK, self.menu_indicator) + button_network = (11, MenuButton("Network", on_press=handler.show_network)) + button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) + button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) + button_map = (7, MenuButton("Map", on_press=handler.show_map)) + button_log = (7, MenuButton("Log", on_press=handler.show_log)) + button_config = (10, MenuButton("Config", on_press=handler.show_config)) + button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) + button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) + button_quit = (8, MenuButton("Quit", on_press=handler.quit)) # buttons = [menu_text, button_conversations, button_node, button_directory, button_map] if self.app.config["textui"]["hide_guide"]: - buttons = [menu_text, button_conversations, button_network, button_log, button_config, button_quit] + buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_quit] else: - buttons = [menu_text, button_conversations, button_network, button_log, button_config, button_guide, button_quit] + buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_guide, button_quit] columns = MenuColumns(buttons, dividechars=1) columns.handler = handler diff --git a/nomadnet/vendor/AsciiChart.py b/nomadnet/vendor/AsciiChart.py new file mode 100644 index 0000000..4689590 --- /dev/null +++ b/nomadnet/vendor/AsciiChart.py @@ -0,0 +1,87 @@ +from __future__ import division +from math import ceil, floor, isnan +# Derived from asciichartpy | https://github.com/kroitor/asciichart/blob/master/asciichartpy/__init__.py +class AsciiChart: + def __init__(self, glyphset="unicode"): + self.symbols = ['┼', '┤', '╶', '╴', '─', '╰', '╭', '╮', '╯', '│'] + if glyphset == "plain": + self.symbols = ['+', '|', '-', '-', '-', '\'', ',', '.', '`', '|'] + def plot(self, series, cfg=None): + if len(series) == 0: + return '' + if not isinstance(series[0], list): + if all(isnan(n) for n in series): + return '' + else: + series = [series] + cfg = cfg or {} + minimum = cfg.get('min', min(filter(lambda n: not isnan(n), [j for i in series for j in i]))) + maximum = cfg.get('max', max(filter(lambda n: not isnan(n), [j for i in series for j in i]))) + symbols = cfg.get('symbols', self.symbols) + if minimum > maximum: + raise ValueError('The min value cannot exceed the max value.') + interval = maximum - minimum + offset = cfg.get('offset', 3) + height = cfg.get('height', interval) + ratio = height / interval if interval > 0 else 1 + + min2 = int(floor(minimum * ratio)) + max2 = int(ceil(maximum * ratio)) + + def clamp(n): + return min(max(n, minimum), maximum) + + def scaled(y): + return int(round(clamp(y) * ratio) - min2) + + rows = max2 - min2 + + width = 0 + for i in range(0, len(series)): + width = max(width, len(series[i])) + width += offset + + placeholder = cfg.get('format', '{:8.2f} ') + + result = [[' '] * width for i in range(rows + 1)] + + for y in range(min2, max2 + 1): + label = placeholder.format(maximum - ((y - min2) * interval / (rows if rows else 1))) + result[y - min2][max(offset - len(label), 0)] = label + result[y - min2][offset - 1] = symbols[0] if y == 0 else symbols[1] + + d0 = series[0][0] + if not isnan(d0): + result[rows - scaled(d0)][offset - 1] = symbols[0] + + for i in range(0, len(series)): + for x in range(0, len(series[i]) - 1): + d0 = series[i][x + 0] + d1 = series[i][x + 1] + + if isnan(d0) and isnan(d1): + continue + + if isnan(d0) and not isnan(d1): + result[rows - scaled(d1)][x + offset] = symbols[2] + continue + + if not isnan(d0) and isnan(d1): + result[rows - scaled(d0)][x + offset] = symbols[3] + continue + + y0 = scaled(d0) + y1 = scaled(d1) + if y0 == y1: + result[rows - y0][x + offset] = symbols[4] + continue + + result[rows - y1][x + offset] = symbols[5] if y0 > y1 else symbols[6] + result[rows - y0][x + offset] = symbols[7] if y0 > y1 else symbols[8] + + start = min(y0, y1) + 1 + end = max(y0, y1) + for y in range(start, end): + result[rows - y][x + offset] = symbols[9] + + return '\n'.join([''.join(row).rstrip() for row in result]) \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py new file mode 100644 index 0000000..4e4809a --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -0,0 +1,283 @@ +import urwid + +class DialogLineBox(urwid.LineBox): + def __init__(self, body, parent=None, title="?"): + super().__init__(body, title=title) + self.parent = parent + + def keypress(self, size, key): + if key == "esc": + if self.parent and hasattr(self.parent, "dismiss_dialog"): + self.parent.dismiss_dialog() + return None + return super().keypress(size, key) + +class Placeholder(urwid.Edit): + def __init__(self, caption="", edit_text="", placeholder="", **kwargs): + super().__init__(caption, edit_text, **kwargs) + self.placeholder = placeholder + + def render(self, size, focus=False): + if not self.edit_text and not focus: + placeholder_widget = urwid.Text(("placeholder", self.placeholder)) + return placeholder_widget.render(size, focus) + else: + return super().render(size, focus) + +class Dropdown(urwid.WidgetWrap): + signals = ['change'] # emit for urwid.connect_signal fn + + def __init__(self, label, options, default=None): + self.label = label + self.options = options + self.selected = default if default is not None else options[0] + + self.main_text = f"{self.selected}" + self.main_button = urwid.SelectableIcon(self.main_text, 0) + self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus") + + self.option_widgets = [] + for opt in options: + icon = urwid.SelectableIcon(opt, 0) + icon = urwid.AttrMap(icon, "list_normal", "list_focus") + self.option_widgets.append(icon) + + self.options_walker = urwid.SimpleFocusListWalker(self.option_widgets) + self.options_listbox = urwid.ListBox(self.options_walker) + self.dropdown_box = None # will be created on open_dropdown + + self.pile = urwid.Pile([self.main_button]) + self.dropdown_visible = False + + super().__init__(self.pile) + + def open_dropdown(self): + if not self.dropdown_visible: + height = len(self.options) + self.dropdown_box = urwid.BoxAdapter(self.options_listbox, height) + self.pile.contents.append((self.dropdown_box, self.pile.options())) + self.dropdown_visible = True + self.pile.focus_position = 1 + self.options_walker.set_focus(0) + + def close_dropdown(self): + if self.dropdown_visible: + self.pile.contents.pop() # remove the dropdown_box + self.dropdown_visible = False + self.pile.focus_position = 0 + self.dropdown_box = None + + def keypress(self, size, key): + if not self.dropdown_visible: + if key == "enter": + self.open_dropdown() + return None + return self.main_button.keypress(size, key) + else: + if key == "enter": + focus_result = self.options_walker.get_focus() + if focus_result is not None: + focus_widget = focus_result[0] + new_val = focus_widget.base_widget.text + old_val = self.selected + self.selected = new_val + self.main_button.base_widget.set_text(f"{self.selected}") + + if old_val != new_val: + self._emit('change', new_val) + + self.close_dropdown() + return None + return self.dropdown_box.keypress(size, key) + + def get_value(self): + return self.selected + +class ValidationError(urwid.Text): + def __init__(self, message=""): + super().__init__(("error", message)) + +class FormField: + def __init__(self, config_key, transform=None): + self.config_key = config_key + self.transform = transform or (lambda x: x) + +class FormEdit(Placeholder, FormField): + def __init__(self, config_key, caption="", edit_text="", placeholder="", validation_types=None, transform=None, **kwargs): + Placeholder.__init__(self, caption, edit_text, placeholder, **kwargs) + FormField.__init__(self, config_key, transform) + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + def get_value(self): + return self.transform(self.edit_text.strip()) + + def validate(self): + value = self.edit_text.strip() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + elif validation == "number": + if value and not value.replace('-', '').replace('.', '').isdigit(): + self.error = "This field must be a number" + break + elif validation == "float": + try: + if value: + float(value) + except ValueError: + self.error = "This field must be decimal number" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + +class FormCheckbox(urwid.CheckBox, FormField): + def __init__(self, config_key, label="", state=False, validation_types=None, transform=None, **kwargs): + urwid.CheckBox.__init__(self, label, state, **kwargs) + FormField.__init__(self, config_key, transform) + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + def get_value(self): + return self.transform(self.get_state()) + + def validate(self): + + value = self.get_state() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + +class FormDropdown(Dropdown, FormField): + signals = ['change'] + + def __init__(self, config_key, label, options, default=None, validation_types=None, transform=None): + self.options = [str(opt) for opt in options] + + if default is not None: + default_str = str(default) + if default_str in self.options: + default = default_str + elif transform: + try: + default_transformed = transform(default_str) + for opt in self.options: + if transform(opt) == default_transformed: + default = opt + break + except: + default = self.options[0] + else: + default = self.options[0] + else: + default = self.options[0] + + Dropdown.__init__(self, label, self.options, default) + FormField.__init__(self, config_key, transform) + + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + if hasattr(self, 'main_button'): + self.main_button.base_widget.set_text(str(default)) + + def get_value(self): + return self.transform(self.selected) + + def validate(self): + value = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + def open_dropdown(self): + if not self.dropdown_visible: + super().open_dropdown() + try: + current_index = self.options.index(self.selected) + self.options_walker.set_focus(current_index) + except ValueError: + pass + +class FormMultiList(urwid.Pile, FormField): + def __init__(self, config_key, placeholder="", validation_types=None, transform=None, **kwargs): + self.entries = [] + self.error_widget = urwid.Text("") + self.error = None + self.placeholder = placeholder + self.validation_types = validation_types or [] + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add Another", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [first_entry, add_button_padded] + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self): + edit = urwid.Edit("", "") + entry_row = urwid.Columns([ + ('weight', 1, edit), + (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), + ]) + return entry_row + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return self.entries + [urwid.Padding(self.add_button, left=2, right=2)] + + def get_value(self): + values = [] + for entry in self.entries: + edit_widget = entry.contents[0][0] + value = edit_widget.edit_text.strip() + if value: + values.append(value) + return self.transform(values) + + def validate(self): + values = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "At least one entry is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None \ No newline at end of file From e4f57d62605c3aa41c6fc5be58b2976fff4f4cce Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 31 Mar 2025 15:03:44 +0200 Subject: [PATCH 300/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a17efae..3a5416e 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.2", "lxmf>=0.6.2", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.3", "lxmf>=0.6.2", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From e79689010ed3753d85d31c8542db4bc3ff0b98c1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 8 Apr 2025 02:03:34 +0200 Subject: [PATCH 301/592] Fixed directory sorting bug --- nomadnet/Directory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index ee0ef09..ac03155 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -345,7 +345,7 @@ class Directory: if e.hosts_node: node_list.append(e) - node_list.sort(key = lambda e: (e.sort_rank if e.sort_rank != None else 2^32, DirectoryEntry.TRUSTED-e.trust_level, e.display_name)) + node_list.sort(key = lambda e: (e.sort_rank if e.sort_rank != None else 2^32, DirectoryEntry.TRUSTED-e.trust_level, e.display_name if e.display_name != None else "_")) return node_list def number_of_known_nodes(self): From 42fdc30cf8f2d2c3d844e8136cc8c2eea0ed37f4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 8 Apr 2025 02:05:02 +0200 Subject: [PATCH 302/592] Cleanup configobj --- nomadnet/NomadNetworkApp.py | 2 +- nomadnet/vendor/configobj.py | 2483 ---------------------------------- 2 files changed, 1 insertion(+), 2484 deletions(-) delete mode 100644 nomadnet/vendor/configobj.py diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 37c3f1e..81dc68f 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -19,7 +19,7 @@ from datetime import datetime import RNS.vendor.umsgpack as msgpack from ._version import __version__ -from .vendor.configobj import ConfigObj +from RNS.vendor.configobj import ConfigObj class NomadNetworkApp: time_format = "%Y-%m-%d %H:%M:%S" diff --git a/nomadnet/vendor/configobj.py b/nomadnet/vendor/configobj.py deleted file mode 100644 index e0c01b6..0000000 --- a/nomadnet/vendor/configobj.py +++ /dev/null @@ -1,2483 +0,0 @@ -# configobj.py -# A config file reader/writer that supports nested sections in config files. -# Copyright (C) 2005-2014: -# (name) : (email) -# Michael Foord: fuzzyman AT voidspace DOT org DOT uk -# Nicola Larosa: nico AT tekNico DOT net -# Rob Dennis: rdennis AT gmail DOT com -# Eli Courtwright: eli AT courtwright DOT org - -# This software is licensed under the terms of the BSD license. -# http://opensource.org/licenses/BSD-3-Clause - -# ConfigObj 5 - main repository for documentation and issue tracking: -# https://github.com/DiffSK/configobj - -import os -import re -import sys - -from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF16_BE, BOM_UTF16_LE - -import RNS.vendor.six as six -__version__ = '5.0.6' - -# imported lazily to avoid startup performance hit if it isn't used -compiler = None - -# A dictionary mapping BOM to -# the encoding to decode with, and what to set the -# encoding attribute to. -BOMS = { - BOM_UTF8: ('utf_8', None), - BOM_UTF16_BE: ('utf16_be', 'utf_16'), - BOM_UTF16_LE: ('utf16_le', 'utf_16'), - BOM_UTF16: ('utf_16', 'utf_16'), - } -# All legal variants of the BOM codecs. -# TODO: the list of aliases is not meant to be exhaustive, is there a -# better way ? -BOM_LIST = { - 'utf_16': 'utf_16', - 'u16': 'utf_16', - 'utf16': 'utf_16', - 'utf-16': 'utf_16', - 'utf16_be': 'utf16_be', - 'utf_16_be': 'utf16_be', - 'utf-16be': 'utf16_be', - 'utf16_le': 'utf16_le', - 'utf_16_le': 'utf16_le', - 'utf-16le': 'utf16_le', - 'utf_8': 'utf_8', - 'u8': 'utf_8', - 'utf': 'utf_8', - 'utf8': 'utf_8', - 'utf-8': 'utf_8', - } - -# Map of encodings to the BOM to write. -BOM_SET = { - 'utf_8': BOM_UTF8, - 'utf_16': BOM_UTF16, - 'utf16_be': BOM_UTF16_BE, - 'utf16_le': BOM_UTF16_LE, - None: BOM_UTF8 - } - - -def match_utf8(encoding): - return BOM_LIST.get(encoding.lower()) == 'utf_8' - - -# Quote strings used for writing values -squot = "'%s'" -dquot = '"%s"' -noquot = "%s" -wspace_plus = ' \r\n\v\t\'"' -tsquot = '"""%s"""' -tdquot = "'''%s'''" - -# Sentinel for use in getattr calls to replace hasattr -MISSING = object() - -__all__ = ( - 'DEFAULT_INDENT_TYPE', - 'DEFAULT_INTERPOLATION', - 'ConfigObjError', - 'NestingError', - 'ParseError', - 'DuplicateError', - 'ConfigspecError', - 'ConfigObj', - 'SimpleVal', - 'InterpolationError', - 'InterpolationLoopError', - 'MissingInterpolationOption', - 'RepeatSectionError', - 'ReloadError', - 'UnreprError', - 'UnknownType', - 'flatten_errors', - 'get_extra_values' -) - -DEFAULT_INTERPOLATION = 'configparser' -DEFAULT_INDENT_TYPE = ' ' -MAX_INTERPOL_DEPTH = 10 - -OPTION_DEFAULTS = { - 'interpolation': True, - 'raise_errors': False, - 'list_values': True, - 'create_empty': False, - 'file_error': False, - 'configspec': None, - 'stringify': True, - # option may be set to one of ('', ' ', '\t') - 'indent_type': None, - 'encoding': None, - 'default_encoding': None, - 'unrepr': False, - 'write_empty_values': False, -} - -# this could be replaced if six is used for compatibility, or there are no -# more assertions about items being a string - - -def getObj(s): - global compiler - if compiler is None: - import compiler - s = "a=" + s - p = compiler.parse(s) - return p.getChildren()[1].getChildren()[0].getChildren()[1] - - -class UnknownType(Exception): - pass - - -class Builder(object): - - def build(self, o): - if m is None: - raise UnknownType(o.__class__.__name__) - return m(o) - - def build_List(self, o): - return list(map(self.build, o.getChildren())) - - def build_Const(self, o): - return o.value - - def build_Dict(self, o): - d = {} - i = iter(map(self.build, o.getChildren())) - for el in i: - d[el] = next(i) - return d - - def build_Tuple(self, o): - return tuple(self.build_List(o)) - - def build_Name(self, o): - if o.name == 'None': - return None - if o.name == 'True': - return True - if o.name == 'False': - return False - - # An undefined Name - raise UnknownType('Undefined Name') - - def build_Add(self, o): - real, imag = list(map(self.build_Const, o.getChildren())) - try: - real = float(real) - except TypeError: - raise UnknownType('Add') - if not isinstance(imag, complex) or imag.real != 0.0: - raise UnknownType('Add') - return real+imag - - def build_Getattr(self, o): - parent = self.build(o.expr) - return getattr(parent, o.attrname) - - def build_UnarySub(self, o): - return -self.build_Const(o.getChildren()[0]) - - def build_UnaryAdd(self, o): - return self.build_Const(o.getChildren()[0]) - - -_builder = Builder() - - -def unrepr(s): - if not s: - return s - - # this is supposed to be safe - import ast - return ast.literal_eval(s) - - -class ConfigObjError(SyntaxError): - """ - This is the base class for all errors that ConfigObj raises. - It is a subclass of SyntaxError. - """ - def __init__(self, message='', line_number=None, line=''): - self.line = line - self.line_number = line_number - SyntaxError.__init__(self, message) - - -class NestingError(ConfigObjError): - """ - This error indicates a level of nesting that doesn't match. - """ - - -class ParseError(ConfigObjError): - """ - This error indicates that a line is badly written. - It is neither a valid ``key = value`` line, - nor a valid section marker line. - """ - - -class ReloadError(IOError): - """ - A 'reload' operation failed. - This exception is a subclass of ``IOError``. - """ - def __init__(self): - IOError.__init__(self, 'reload failed, filename is not set.') - - -class DuplicateError(ConfigObjError): - """ - The keyword or section specified already exists. - """ - - -class ConfigspecError(ConfigObjError): - """ - An error occured whilst parsing a configspec. - """ - - -class InterpolationError(ConfigObjError): - """Base class for the two interpolation errors.""" - - -class InterpolationLoopError(InterpolationError): - """Maximum interpolation depth exceeded in string interpolation.""" - - def __init__(self, option): - InterpolationError.__init__( - self, - 'interpolation loop detected in value "%s".' % option) - - -class RepeatSectionError(ConfigObjError): - """ - This error indicates additional sections in a section with a - ``__many__`` (repeated) section. - """ - - -class MissingInterpolationOption(InterpolationError): - """A value specified for interpolation was missing.""" - def __init__(self, option): - msg = 'missing option "%s" in interpolation.' % option - InterpolationError.__init__(self, msg) - - -class UnreprError(ConfigObjError): - """An error parsing in unrepr mode.""" - - - -class InterpolationEngine(object): - """ - A helper class to help perform string interpolation. - - This class is an abstract base class; its descendants perform - the actual work. - """ - - # compiled regexp to use in self.interpolate() - _KEYCRE = re.compile(r"%\(([^)]*)\)s") - _cookie = '%' - - def __init__(self, section): - # the Section instance that "owns" this engine - self.section = section - - - def interpolate(self, key, value): - # short-cut - if not self._cookie in value: - return value - - def recursive_interpolate(key, value, section, backtrail): - """The function that does the actual work. - - ``value``: the string we're trying to interpolate. - ``section``: the section in which that string was found - ``backtrail``: a dict to keep track of where we've been, - to detect and prevent infinite recursion loops - - This is similar to a depth-first-search algorithm. - """ - # Have we been here already? - if (key, section.name) in backtrail: - # Yes - infinite loop detected - raise InterpolationLoopError(key) - # Place a marker on our backtrail so we won't come back here again - backtrail[(key, section.name)] = 1 - - # Now start the actual work - match = self._KEYCRE.search(value) - while match: - # The actual parsing of the match is implementation-dependent, - # so delegate to our helper function - k, v, s = self._parse_match(match) - if k is None: - # That's the signal that no further interpolation is needed - replacement = v - else: - # Further interpolation may be needed to obtain final value - replacement = recursive_interpolate(k, v, s, backtrail) - # Replace the matched string with its final value - start, end = match.span() - value = ''.join((value[:start], replacement, value[end:])) - new_search_start = start + len(replacement) - # Pick up the next interpolation key, if any, for next time - # through the while loop - match = self._KEYCRE.search(value, new_search_start) - - # Now safe to come back here again; remove marker from backtrail - del backtrail[(key, section.name)] - - return value - - # Back in interpolate(), all we have to do is kick off the recursive - # function with appropriate starting values - value = recursive_interpolate(key, value, self.section, {}) - return value - - - def _fetch(self, key): - """Helper function to fetch values from owning section. - - Returns a 2-tuple: the value, and the section where it was found. - """ - # switch off interpolation before we try and fetch anything ! - save_interp = self.section.main.interpolation - self.section.main.interpolation = False - - # Start at section that "owns" this InterpolationEngine - current_section = self.section - while True: - # try the current section first - val = current_section.get(key) - if val is not None and not isinstance(val, Section): - break - # try "DEFAULT" next - val = current_section.get('DEFAULT', {}).get(key) - if val is not None and not isinstance(val, Section): - break - # move up to parent and try again - # top-level's parent is itself - if current_section.parent is current_section: - # reached top level, time to give up - break - current_section = current_section.parent - - # restore interpolation to previous value before returning - self.section.main.interpolation = save_interp - if val is None: - raise MissingInterpolationOption(key) - return val, current_section - - - def _parse_match(self, match): - """Implementation-dependent helper function. - - Will be passed a match object corresponding to the interpolation - key we just found (e.g., "%(foo)s" or "$foo"). Should look up that - key in the appropriate config file section (using the ``_fetch()`` - helper function) and return a 3-tuple: (key, value, section) - - ``key`` is the name of the key we're looking for - ``value`` is the value found for that key - ``section`` is a reference to the section where it was found - - ``key`` and ``section`` should be None if no further - interpolation should be performed on the resulting value - (e.g., if we interpolated "$$" and returned "$"). - """ - raise NotImplementedError() - - - -class ConfigParserInterpolation(InterpolationEngine): - """Behaves like ConfigParser.""" - _cookie = '%' - _KEYCRE = re.compile(r"%\(([^)]*)\)s") - - def _parse_match(self, match): - key = match.group(1) - value, section = self._fetch(key) - return key, value, section - - - -class TemplateInterpolation(InterpolationEngine): - """Behaves like string.Template.""" - _cookie = '$' - _delimiter = '$' - _KEYCRE = re.compile(r""" - \$(?: - (?P\$) | # Two $ signs - (?P[_a-z][_a-z0-9]*) | # $name format - {(?P[^}]*)} # ${name} format - ) - """, re.IGNORECASE | re.VERBOSE) - - def _parse_match(self, match): - # Valid name (in or out of braces): fetch value from section - key = match.group('named') or match.group('braced') - if key is not None: - value, section = self._fetch(key) - return key, value, section - # Escaped delimiter (e.g., $$): return single delimiter - if match.group('escaped') is not None: - # Return None for key and section to indicate it's time to stop - return None, self._delimiter, None - # Anything else: ignore completely, just return it unchanged - return None, match.group(), None - - -interpolation_engines = { - 'configparser': ConfigParserInterpolation, - 'template': TemplateInterpolation, -} - - -def __newobj__(cls, *args): - # Hack for pickle - return cls.__new__(cls, *args) - -class Section(dict): - """ - A dictionary-like object that represents a section in a config file. - - It does string interpolation if the 'interpolation' attribute - of the 'main' object is set to True. - - Interpolation is tried first from this object, then from the 'DEFAULT' - section of this object, next from the parent and its 'DEFAULT' section, - and so on until the main object is reached. - - A Section will behave like an ordered dictionary - following the - order of the ``scalars`` and ``sections`` attributes. - You can use this to change the order of members. - - Iteration follows the order: scalars, then sections. - """ - - - def __setstate__(self, state): - dict.update(self, state[0]) - self.__dict__.update(state[1]) - - def __reduce__(self): - state = (dict(self), self.__dict__) - return (__newobj__, (self.__class__,), state) - - - def __init__(self, parent, depth, main, indict=None, name=None): - """ - * parent is the section above - * depth is the depth level of this section - * main is the main ConfigObj - * indict is a dictionary to initialise the section with - """ - if indict is None: - indict = {} - dict.__init__(self) - # used for nesting level *and* interpolation - self.parent = parent - # used for the interpolation attribute - self.main = main - # level of nesting depth of this Section - self.depth = depth - # purely for information - self.name = name - # - self._initialise() - # we do this explicitly so that __setitem__ is used properly - # (rather than just passing to ``dict.__init__``) - for entry, value in indict.items(): - self[entry] = value - - - def _initialise(self): - # the sequence of scalar values in this Section - self.scalars = [] - # the sequence of sections in this Section - self.sections = [] - # for comments :-) - self.comments = {} - self.inline_comments = {} - # the configspec - self.configspec = None - # for defaults - self.defaults = [] - self.default_values = {} - self.extra_values = [] - self._created = False - - - def _interpolate(self, key, value): - try: - # do we already have an interpolation engine? - engine = self._interpolation_engine - except AttributeError: - # not yet: first time running _interpolate(), so pick the engine - name = self.main.interpolation - if name == True: # note that "if name:" would be incorrect here - # backwards-compatibility: interpolation=True means use default - name = DEFAULT_INTERPOLATION - name = name.lower() # so that "Template", "template", etc. all work - class_ = interpolation_engines.get(name, None) - if class_ is None: - # invalid value for self.main.interpolation - self.main.interpolation = False - return value - else: - # save reference to engine so we don't have to do this again - engine = self._interpolation_engine = class_(self) - # let the engine do the actual work - return engine.interpolate(key, value) - - - def __getitem__(self, key): - """Fetch the item and do string interpolation.""" - val = dict.__getitem__(self, key) - if self.main.interpolation: - if isinstance(val, six.string_types): - return self._interpolate(key, val) - if isinstance(val, list): - def _check(entry): - if isinstance(entry, six.string_types): - return self._interpolate(key, entry) - return entry - new = [_check(entry) for entry in val] - if new != val: - return new - return val - - - def __setitem__(self, key, value, unrepr=False): - """ - Correctly set a value. - - Making dictionary values Section instances. - (We have to special case 'Section' instances - which are also dicts) - - Keys must be strings. - Values need only be strings (or lists of strings) if - ``main.stringify`` is set. - - ``unrepr`` must be set when setting a value to a dictionary, without - creating a new sub-section. - """ - if not isinstance(key, six.string_types): - raise ValueError('The key "%s" is not a string.' % key) - - # add the comment - if key not in self.comments: - self.comments[key] = [] - self.inline_comments[key] = '' - # remove the entry from defaults - if key in self.defaults: - self.defaults.remove(key) - # - if isinstance(value, Section): - if key not in self: - self.sections.append(key) - dict.__setitem__(self, key, value) - elif isinstance(value, dict) and not unrepr: - # First create the new depth level, - # then create the section - if key not in self: - self.sections.append(key) - new_depth = self.depth + 1 - dict.__setitem__( - self, - key, - Section( - self, - new_depth, - self.main, - indict=value, - name=key)) - else: - if key not in self: - self.scalars.append(key) - if not self.main.stringify: - if isinstance(value, six.string_types): - pass - elif isinstance(value, (list, tuple)): - for entry in value: - if not isinstance(entry, six.string_types): - raise TypeError('Value is not a string "%s".' % entry) - else: - raise TypeError('Value is not a string "%s".' % value) - dict.__setitem__(self, key, value) - - - def __delitem__(self, key): - """Remove items from the sequence when deleting.""" - dict. __delitem__(self, key) - if key in self.scalars: - self.scalars.remove(key) - else: - self.sections.remove(key) - del self.comments[key] - del self.inline_comments[key] - - - def get(self, key, default=None): - """A version of ``get`` that doesn't bypass string interpolation.""" - try: - return self[key] - except KeyError: - return default - - - def update(self, indict): - """ - A version of update that uses our ``__setitem__``. - """ - for entry in indict: - self[entry] = indict[entry] - - - def pop(self, key, default=MISSING): - """ - 'D.pop(k[,d]) -> v, remove specified key and return the corresponding value. - If key is not found, d is returned if given, otherwise KeyError is raised' - """ - try: - val = self[key] - except KeyError: - if default is MISSING: - raise - val = default - else: - del self[key] - return val - - - def popitem(self): - """Pops the first (key,val)""" - sequence = (self.scalars + self.sections) - if not sequence: - raise KeyError(": 'popitem(): dictionary is empty'") - key = sequence[0] - val = self[key] - del self[key] - return key, val - - - def clear(self): - """ - A version of clear that also affects scalars/sections - Also clears comments and configspec. - - Leaves other attributes alone : - depth/main/parent are not affected - """ - dict.clear(self) - self.scalars = [] - self.sections = [] - self.comments = {} - self.inline_comments = {} - self.configspec = None - self.defaults = [] - self.extra_values = [] - - - def setdefault(self, key, default=None): - """A version of setdefault that sets sequence if appropriate.""" - try: - return self[key] - except KeyError: - self[key] = default - return self[key] - - - def items(self): - """D.items() -> list of D's (key, value) pairs, as 2-tuples""" - return list(zip((self.scalars + self.sections), list(self.values()))) - - - def keys(self): - """D.keys() -> list of D's keys""" - return (self.scalars + self.sections) - - - def values(self): - """D.values() -> list of D's values""" - return [self[key] for key in (self.scalars + self.sections)] - - - def iteritems(self): - """D.iteritems() -> an iterator over the (key, value) items of D""" - return iter(list(self.items())) - - - def iterkeys(self): - """D.iterkeys() -> an iterator over the keys of D""" - return iter((self.scalars + self.sections)) - - __iter__ = iterkeys - - - def itervalues(self): - """D.itervalues() -> an iterator over the values of D""" - return iter(list(self.values())) - - - def __repr__(self): - """x.__repr__() <==> repr(x)""" - def _getval(key): - try: - return self[key] - except MissingInterpolationOption: - return dict.__getitem__(self, key) - return '{%s}' % ', '.join([('%s: %s' % (repr(key), repr(_getval(key)))) - for key in (self.scalars + self.sections)]) - - __str__ = __repr__ - __str__.__doc__ = "x.__str__() <==> str(x)" - - - # Extra methods - not in a normal dictionary - - def dict(self): - """ - Return a deepcopy of self as a dictionary. - - All members that are ``Section`` instances are recursively turned to - ordinary dictionaries - by calling their ``dict`` method. - - >>> n = a.dict() - >>> n == a - 1 - >>> n is a - 0 - """ - newdict = {} - for entry in self: - this_entry = self[entry] - if isinstance(this_entry, Section): - this_entry = this_entry.dict() - elif isinstance(this_entry, list): - # create a copy rather than a reference - this_entry = list(this_entry) - elif isinstance(this_entry, tuple): - # create a copy rather than a reference - this_entry = tuple(this_entry) - newdict[entry] = this_entry - return newdict - - - def merge(self, indict): - """ - A recursive update - useful for merging config files. - - >>> a = '''[section1] - ... option1 = True - ... [[subsection]] - ... more_options = False - ... # end of file'''.splitlines() - >>> b = '''# File is user.ini - ... [section1] - ... option1 = False - ... # end of file'''.splitlines() - >>> c1 = ConfigObj(b) - >>> c2 = ConfigObj(a) - >>> c2.merge(c1) - >>> c2 - ConfigObj({'section1': {'option1': 'False', 'subsection': {'more_options': 'False'}}}) - """ - for key, val in list(indict.items()): - if (key in self and isinstance(self[key], dict) and - isinstance(val, dict)): - self[key].merge(val) - else: - self[key] = val - - - def rename(self, oldkey, newkey): - """ - Change a keyname to another, without changing position in sequence. - - Implemented so that transformations can be made on keys, - as well as on values. (used by encode and decode) - - Also renames comments. - """ - if oldkey in self.scalars: - the_list = self.scalars - elif oldkey in self.sections: - the_list = self.sections - else: - raise KeyError('Key "%s" not found.' % oldkey) - pos = the_list.index(oldkey) - # - val = self[oldkey] - dict.__delitem__(self, oldkey) - dict.__setitem__(self, newkey, val) - the_list.remove(oldkey) - the_list.insert(pos, newkey) - comm = self.comments[oldkey] - inline_comment = self.inline_comments[oldkey] - del self.comments[oldkey] - del self.inline_comments[oldkey] - self.comments[newkey] = comm - self.inline_comments[newkey] = inline_comment - - - def walk(self, function, raise_errors=True, - call_on_sections=False, **keywargs): - """ - Walk every member and call a function on the keyword and value. - - Return a dictionary of the return values - - If the function raises an exception, raise the errror - unless ``raise_errors=False``, in which case set the return value to - ``False``. - - Any unrecognised keyword arguments you pass to walk, will be pased on - to the function you pass in. - - Note: if ``call_on_sections`` is ``True`` then - on encountering a - subsection, *first* the function is called for the *whole* subsection, - and then recurses into it's members. This means your function must be - able to handle strings, dictionaries and lists. This allows you - to change the key of subsections as well as for ordinary members. The - return value when called on the whole subsection has to be discarded. - - See the encode and decode methods for examples, including functions. - - .. admonition:: caution - - You can use ``walk`` to transform the names of members of a section - but you mustn't add or delete members. - - >>> config = '''[XXXXsection] - ... XXXXkey = XXXXvalue'''.splitlines() - >>> cfg = ConfigObj(config) - >>> cfg - ConfigObj({'XXXXsection': {'XXXXkey': 'XXXXvalue'}}) - >>> def transform(section, key): - ... val = section[key] - ... newkey = key.replace('XXXX', 'CLIENT1') - ... section.rename(key, newkey) - ... if isinstance(val, (tuple, list, dict)): - ... pass - ... else: - ... val = val.replace('XXXX', 'CLIENT1') - ... section[newkey] = val - >>> cfg.walk(transform, call_on_sections=True) - {'CLIENT1section': {'CLIENT1key': None}} - >>> cfg - ConfigObj({'CLIENT1section': {'CLIENT1key': 'CLIENT1value'}}) - """ - out = {} - # scalars first - for i in range(len(self.scalars)): - entry = self.scalars[i] - try: - val = function(self, entry, **keywargs) - # bound again in case name has changed - entry = self.scalars[i] - out[entry] = val - except Exception: - if raise_errors: - raise - else: - entry = self.scalars[i] - out[entry] = False - # then sections - for i in range(len(self.sections)): - entry = self.sections[i] - if call_on_sections: - try: - function(self, entry, **keywargs) - except Exception: - if raise_errors: - raise - else: - entry = self.sections[i] - out[entry] = False - # bound again in case name has changed - entry = self.sections[i] - # previous result is discarded - out[entry] = self[entry].walk( - function, - raise_errors=raise_errors, - call_on_sections=call_on_sections, - **keywargs) - return out - - - def as_bool(self, key): - """ - Accepts a key as input. The corresponding value must be a string or - the objects (``True`` or 1) or (``False`` or 0). We allow 0 and 1 to - retain compatibility with Python 2.2. - - If the string is one of ``True``, ``On``, ``Yes``, or ``1`` it returns - ``True``. - - If the string is one of ``False``, ``Off``, ``No``, or ``0`` it returns - ``False``. - - ``as_bool`` is not case sensitive. - - Any other input will raise a ``ValueError``. - - >>> a = ConfigObj() - >>> a['a'] = 'fish' - >>> a.as_bool('a') - Traceback (most recent call last): - ValueError: Value "fish" is neither True nor False - >>> a['b'] = 'True' - >>> a.as_bool('b') - 1 - >>> a['b'] = 'off' - >>> a.as_bool('b') - 0 - """ - val = self[key] - if val == True: - return True - elif val == False: - return False - else: - try: - if not isinstance(val, six.string_types): - # TODO: Why do we raise a KeyError here? - raise KeyError() - else: - return self.main._bools[val.lower()] - except KeyError: - raise ValueError('Value "%s" is neither True nor False' % val) - - - def as_int(self, key): - """ - A convenience method which coerces the specified value to an integer. - - If the value is an invalid literal for ``int``, a ``ValueError`` will - be raised. - - >>> a = ConfigObj() - >>> a['a'] = 'fish' - >>> a.as_int('a') - Traceback (most recent call last): - ValueError: invalid literal for int() with base 10: 'fish' - >>> a['b'] = '1' - >>> a.as_int('b') - 1 - >>> a['b'] = '3.2' - >>> a.as_int('b') - Traceback (most recent call last): - ValueError: invalid literal for int() with base 10: '3.2' - """ - return int(self[key]) - - - def as_float(self, key): - """ - A convenience method which coerces the specified value to a float. - - If the value is an invalid literal for ``float``, a ``ValueError`` will - be raised. - - >>> a = ConfigObj() - >>> a['a'] = 'fish' - >>> a.as_float('a') #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - ValueError: invalid literal for float(): fish - >>> a['b'] = '1' - >>> a.as_float('b') - 1.0 - >>> a['b'] = '3.2' - >>> a.as_float('b') #doctest: +ELLIPSIS - 3.2... - """ - return float(self[key]) - - - def as_list(self, key): - """ - A convenience method which fetches the specified value, guaranteeing - that it is a list. - - >>> a = ConfigObj() - >>> a['a'] = 1 - >>> a.as_list('a') - [1] - >>> a['a'] = (1,) - >>> a.as_list('a') - [1] - >>> a['a'] = [1] - >>> a.as_list('a') - [1] - """ - result = self[key] - if isinstance(result, (tuple, list)): - return list(result) - return [result] - - - def restore_default(self, key): - """ - Restore (and return) default value for the specified key. - - This method will only work for a ConfigObj that was created - with a configspec and has been validated. - - If there is no default value for this key, ``KeyError`` is raised. - """ - default = self.default_values[key] - dict.__setitem__(self, key, default) - if key not in self.defaults: - self.defaults.append(key) - return default - - - def restore_defaults(self): - """ - Recursively restore default values to all members - that have them. - - This method will only work for a ConfigObj that was created - with a configspec and has been validated. - - It doesn't delete or modify entries without default values. - """ - for key in self.default_values: - self.restore_default(key) - - for section in self.sections: - self[section].restore_defaults() - - -class ConfigObj(Section): - """An object to read, create, and write config files.""" - - _keyword = re.compile(r'''^ # line start - (\s*) # indentation - ( # keyword - (?:".*?")| # double quotes - (?:'.*?')| # single quotes - (?:[^'"=].*?) # no quotes - ) - \s*=\s* # divider - (.*) # value (including list values and comments) - $ # line end - ''', - re.VERBOSE) - - _sectionmarker = re.compile(r'''^ - (\s*) # 1: indentation - ((?:\[\s*)+) # 2: section marker open - ( # 3: section name open - (?:"\s*\S.*?\s*")| # at least one non-space with double quotes - (?:'\s*\S.*?\s*')| # at least one non-space with single quotes - (?:[^'"\s].*?) # at least one non-space unquoted - ) # section name close - ((?:\s*\])+) # 4: section marker close - \s*(\#.*)? # 5: optional comment - $''', - re.VERBOSE) - - # this regexp pulls list values out as a single string - # or single values and comments - # FIXME: this regex adds a '' to the end of comma terminated lists - # workaround in ``_handle_value`` - _valueexp = re.compile(r'''^ - (?: - (?: - ( - (?: - (?: - (?:".*?")| # double quotes - (?:'.*?')| # single quotes - (?:[^'",\#][^,\#]*?) # unquoted - ) - \s*,\s* # comma - )* # match all list items ending in a comma (if any) - ) - ( - (?:".*?")| # double quotes - (?:'.*?')| # single quotes - (?:[^'",\#\s][^,]*?)| # unquoted - (?:(? 1: - msg = "Parsing failed with several errors.\nFirst error %s" % info - error = ConfigObjError(msg) - else: - error = self._errors[0] - # set the errors attribute; it's a list of tuples: - # (error_type, message, line_number) - error.errors = self._errors - # set the config attribute - error.config = self - raise error - # delete private attributes - del self._errors - - if configspec is None: - self.configspec = None - else: - self._handle_configspec(configspec) - - - def _initialise(self, options=None): - if options is None: - options = OPTION_DEFAULTS - - # initialise a few variables - self.filename = None - self._errors = [] - self.raise_errors = options['raise_errors'] - self.interpolation = options['interpolation'] - self.list_values = options['list_values'] - self.create_empty = options['create_empty'] - self.file_error = options['file_error'] - self.stringify = options['stringify'] - self.indent_type = options['indent_type'] - self.encoding = options['encoding'] - self.default_encoding = options['default_encoding'] - self.BOM = False - self.newlines = None - self.write_empty_values = options['write_empty_values'] - self.unrepr = options['unrepr'] - - self.initial_comment = [] - self.final_comment = [] - self.configspec = None - - if self._inspec: - self.list_values = False - - # Clear section attributes as well - Section._initialise(self) - - - def __repr__(self): - def _getval(key): - try: - return self[key] - except MissingInterpolationOption: - return dict.__getitem__(self, key) - return ('ConfigObj({%s})' % - ', '.join([('%s: %s' % (repr(key), repr(_getval(key)))) - for key in (self.scalars + self.sections)])) - - - def _handle_bom(self, infile): - """ - Handle any BOM, and decode if necessary. - - If an encoding is specified, that *must* be used - but the BOM should - still be removed (and the BOM attribute set). - - (If the encoding is wrongly specified, then a BOM for an alternative - encoding won't be discovered or removed.) - - If an encoding is not specified, UTF8 or UTF16 BOM will be detected and - removed. The BOM attribute will be set. UTF16 will be decoded to - unicode. - - NOTE: This method must not be called with an empty ``infile``. - - Specifying the *wrong* encoding is likely to cause a - ``UnicodeDecodeError``. - - ``infile`` must always be returned as a list of lines, but may be - passed in as a single string. - """ - - if ((self.encoding is not None) and - (self.encoding.lower() not in BOM_LIST)): - # No need to check for a BOM - # the encoding specified doesn't have one - # just decode - return self._decode(infile, self.encoding) - - if isinstance(infile, (list, tuple)): - line = infile[0] - else: - line = infile - - if isinstance(line, six.text_type): - # it's already decoded and there's no need to do anything - # else, just use the _decode utility method to handle - # listifying appropriately - return self._decode(infile, self.encoding) - - if self.encoding is not None: - # encoding explicitly supplied - # And it could have an associated BOM - # TODO: if encoding is just UTF16 - we ought to check for both - # TODO: big endian and little endian versions. - enc = BOM_LIST[self.encoding.lower()] - if enc == 'utf_16': - # For UTF16 we try big endian and little endian - for BOM, (encoding, final_encoding) in list(BOMS.items()): - if not final_encoding: - # skip UTF8 - continue - if infile.startswith(BOM): - ### BOM discovered - ##self.BOM = True - # Don't need to remove BOM - return self._decode(infile, encoding) - - # If we get this far, will *probably* raise a DecodeError - # As it doesn't appear to start with a BOM - return self._decode(infile, self.encoding) - - # Must be UTF8 - BOM = BOM_SET[enc] - if not line.startswith(BOM): - return self._decode(infile, self.encoding) - - newline = line[len(BOM):] - - # BOM removed - if isinstance(infile, (list, tuple)): - infile[0] = newline - else: - infile = newline - self.BOM = True - return self._decode(infile, self.encoding) - - # No encoding specified - so we need to check for UTF8/UTF16 - for BOM, (encoding, final_encoding) in list(BOMS.items()): - if not isinstance(line, six.binary_type) or not line.startswith(BOM): - # didn't specify a BOM, or it's not a bytestring - continue - else: - # BOM discovered - self.encoding = final_encoding - if not final_encoding: - self.BOM = True - # UTF8 - # remove BOM - newline = line[len(BOM):] - if isinstance(infile, (list, tuple)): - infile[0] = newline - else: - infile = newline - # UTF-8 - if isinstance(infile, six.text_type): - return infile.splitlines(True) - elif isinstance(infile, six.binary_type): - return infile.decode('utf-8').splitlines(True) - else: - return self._decode(infile, 'utf-8') - # UTF16 - have to decode - return self._decode(infile, encoding) - - - if six.PY2 and isinstance(line, str): - # don't actually do any decoding, since we're on python 2 and - # returning a bytestring is fine - return self._decode(infile, None) - # No BOM discovered and no encoding specified, default to UTF-8 - if isinstance(infile, six.binary_type): - return infile.decode('utf-8').splitlines(True) - else: - return self._decode(infile, 'utf-8') - - - def _a_to_u(self, aString): - """Decode ASCII strings to unicode if a self.encoding is specified.""" - if isinstance(aString, six.binary_type) and self.encoding: - return aString.decode(self.encoding) - else: - return aString - - - def _decode(self, infile, encoding): - """ - Decode infile to unicode. Using the specified encoding. - - if is a string, it also needs converting to a list. - """ - if isinstance(infile, six.string_types): - return infile.splitlines(True) - if isinstance(infile, six.binary_type): - # NOTE: Could raise a ``UnicodeDecodeError`` - if encoding: - return infile.decode(encoding).splitlines(True) - else: - return infile.splitlines(True) - - if encoding: - for i, line in enumerate(infile): - if isinstance(line, six.binary_type): - # NOTE: The isinstance test here handles mixed lists of unicode/string - # NOTE: But the decode will break on any non-string values - # NOTE: Or could raise a ``UnicodeDecodeError`` - infile[i] = line.decode(encoding) - return infile - - - def _decode_element(self, line): - """Decode element to unicode if necessary.""" - if isinstance(line, six.binary_type) and self.default_encoding: - return line.decode(self.default_encoding) - else: - return line - - - # TODO: this may need to be modified - def _str(self, value): - """ - Used by ``stringify`` within validate, to turn non-string values - into strings. - """ - if not isinstance(value, six.string_types): - # intentially 'str' because it's just whatever the "normal" - # string type is for the python version we're dealing with - return str(value) - else: - return value - - - def _parse(self, infile): - """Actually parse the config file.""" - temp_list_values = self.list_values - if self.unrepr: - self.list_values = False - - comment_list = [] - done_start = False - this_section = self - maxline = len(infile) - 1 - cur_index = -1 - reset_comment = False - - while cur_index < maxline: - if reset_comment: - comment_list = [] - cur_index += 1 - line = infile[cur_index] - sline = line.strip() - # do we have anything on the line ? - if not sline or sline.startswith('#'): - reset_comment = False - comment_list.append(line) - continue - - if not done_start: - # preserve initial comment - self.initial_comment = comment_list - comment_list = [] - done_start = True - - reset_comment = True - # first we check if it's a section marker - mat = self._sectionmarker.match(line) - if mat is not None: - # is a section line - (indent, sect_open, sect_name, sect_close, comment) = mat.groups() - if indent and (self.indent_type is None): - self.indent_type = indent - cur_depth = sect_open.count('[') - if cur_depth != sect_close.count(']'): - self._handle_error("Cannot compute the section depth", - NestingError, infile, cur_index) - continue - - if cur_depth < this_section.depth: - # the new section is dropping back to a previous level - try: - parent = self._match_depth(this_section, - cur_depth).parent - except SyntaxError: - self._handle_error("Cannot compute nesting level", - NestingError, infile, cur_index) - continue - elif cur_depth == this_section.depth: - # the new section is a sibling of the current section - parent = this_section.parent - elif cur_depth == this_section.depth + 1: - # the new section is a child the current section - parent = this_section - else: - self._handle_error("Section too nested", - NestingError, infile, cur_index) - continue - - sect_name = self._unquote(sect_name) - if sect_name in parent: - self._handle_error('Duplicate section name', - DuplicateError, infile, cur_index) - continue - - # create the new section - this_section = Section( - parent, - cur_depth, - self, - name=sect_name) - parent[sect_name] = this_section - parent.inline_comments[sect_name] = comment - parent.comments[sect_name] = comment_list - continue - # - # it's not a section marker, - # so it should be a valid ``key = value`` line - mat = self._keyword.match(line) - if mat is None: - self._handle_error( - 'Invalid line ({0!r}) (matched as neither section nor keyword)'.format(line), - ParseError, infile, cur_index) - else: - # is a keyword value - # value will include any inline comment - (indent, key, value) = mat.groups() - if indent and (self.indent_type is None): - self.indent_type = indent - # check for a multiline value - if value[:3] in ['"""', "'''"]: - try: - value, comment, cur_index = self._multiline( - value, infile, cur_index, maxline) - except SyntaxError: - self._handle_error( - 'Parse error in multiline value', - ParseError, infile, cur_index) - continue - else: - if self.unrepr: - comment = '' - try: - value = unrepr(value) - except Exception as e: - if type(e) == UnknownType: - msg = 'Unknown name or type in value' - else: - msg = 'Parse error from unrepr-ing multiline value' - self._handle_error(msg, UnreprError, infile, - cur_index) - continue - else: - if self.unrepr: - comment = '' - try: - value = unrepr(value) - except Exception as e: - if isinstance(e, UnknownType): - msg = 'Unknown name or type in value' - else: - msg = 'Parse error from unrepr-ing value' - self._handle_error(msg, UnreprError, infile, - cur_index) - continue - else: - # extract comment and lists - try: - (value, comment) = self._handle_value(value) - except SyntaxError: - self._handle_error( - 'Parse error in value', - ParseError, infile, cur_index) - continue - # - key = self._unquote(key) - if key in this_section: - self._handle_error( - 'Duplicate keyword name', - DuplicateError, infile, cur_index) - continue - # add the key. - # we set unrepr because if we have got this far we will never - # be creating a new section - this_section.__setitem__(key, value, unrepr=True) - this_section.inline_comments[key] = comment - this_section.comments[key] = comment_list - continue - # - if self.indent_type is None: - # no indentation used, set the type accordingly - self.indent_type = '' - - # preserve the final comment - if not self and not self.initial_comment: - self.initial_comment = comment_list - elif not reset_comment: - self.final_comment = comment_list - self.list_values = temp_list_values - - - def _match_depth(self, sect, depth): - """ - Given a section and a depth level, walk back through the sections - parents to see if the depth level matches a previous section. - - Return a reference to the right section, - or raise a SyntaxError. - """ - while depth < sect.depth: - if sect is sect.parent: - # we've reached the top level already - raise SyntaxError() - sect = sect.parent - if sect.depth == depth: - return sect - # shouldn't get here - raise SyntaxError() - - - def _handle_error(self, text, ErrorClass, infile, cur_index): - """ - Handle an error according to the error settings. - - Either raise the error or store it. - The error will have occured at ``cur_index`` - """ - line = infile[cur_index] - cur_index += 1 - message = '{0} at line {1}.'.format(text, cur_index) - error = ErrorClass(message, cur_index, line) - if self.raise_errors: - # raise the error - parsing stops here - raise error - # store the error - # reraise when parsing has finished - self._errors.append(error) - - - def _unquote(self, value): - """Return an unquoted version of a value""" - if not value: - # should only happen during parsing of lists - raise SyntaxError - if (value[0] == value[-1]) and (value[0] in ('"', "'")): - value = value[1:-1] - return value - - - def _quote(self, value, multiline=True): - """ - Return a safely quoted version of a value. - - Raise a ConfigObjError if the value cannot be safely quoted. - If multiline is ``True`` (default) then use triple quotes - if necessary. - - * Don't quote values that don't need it. - * Recursively quote members of a list and return a comma joined list. - * Multiline is ``False`` for lists. - * Obey list syntax for empty and single member lists. - - If ``list_values=False`` then the value is only quoted if it contains - a ``\\n`` (is multiline) or '#'. - - If ``write_empty_values`` is set, and the value is an empty string, it - won't be quoted. - """ - if multiline and self.write_empty_values and value == '': - # Only if multiline is set, so that it is used for values not - # keys, and not values that are part of a list - return '' - - if multiline and isinstance(value, (list, tuple)): - if not value: - return ',' - elif len(value) == 1: - return self._quote(value[0], multiline=False) + ',' - return ', '.join([self._quote(val, multiline=False) - for val in value]) - if not isinstance(value, six.string_types): - if self.stringify: - # intentially 'str' because it's just whatever the "normal" - # string type is for the python version we're dealing with - value = str(value) - else: - raise TypeError('Value "%s" is not a string.' % value) - - if not value: - return '""' - - no_lists_no_quotes = not self.list_values and '\n' not in value and '#' not in value - need_triple = multiline and ((("'" in value) and ('"' in value)) or ('\n' in value )) - hash_triple_quote = multiline and not need_triple and ("'" in value) and ('"' in value) and ('#' in value) - check_for_single = (no_lists_no_quotes or not need_triple) and not hash_triple_quote - - if check_for_single: - if not self.list_values: - # we don't quote if ``list_values=False`` - quot = noquot - # for normal values either single or double quotes will do - elif '\n' in value: - # will only happen if multiline is off - e.g. '\n' in key - raise ConfigObjError('Value "%s" cannot be safely quoted.' % value) - elif ((value[0] not in wspace_plus) and - (value[-1] not in wspace_plus) and - (',' not in value)): - quot = noquot - else: - quot = self._get_single_quote(value) - else: - # if value has '\n' or "'" *and* '"', it will need triple quotes - quot = self._get_triple_quote(value) - - if quot == noquot and '#' in value and self.list_values: - quot = self._get_single_quote(value) - - return quot % value - - - def _get_single_quote(self, value): - if ("'" in value) and ('"' in value): - raise ConfigObjError('Value "%s" cannot be safely quoted.' % value) - elif '"' in value: - quot = squot - else: - quot = dquot - return quot - - - def _get_triple_quote(self, value): - if (value.find('"""') != -1) and (value.find("'''") != -1): - raise ConfigObjError('Value "%s" cannot be safely quoted.' % value) - if value.find('"""') == -1: - quot = tdquot - else: - quot = tsquot - return quot - - - def _handle_value(self, value): - """ - Given a value string, unquote, remove comment, - handle lists. (including empty and single member lists) - """ - if self._inspec: - # Parsing a configspec so don't handle comments - return (value, '') - # do we look for lists in values ? - if not self.list_values: - mat = self._nolistvalue.match(value) - if mat is None: - raise SyntaxError() - # NOTE: we don't unquote here - return mat.groups() - # - mat = self._valueexp.match(value) - if mat is None: - # the value is badly constructed, probably badly quoted, - # or an invalid list - raise SyntaxError() - (list_values, single, empty_list, comment) = mat.groups() - if (list_values == '') and (single is None): - # change this if you want to accept empty values - raise SyntaxError() - # NOTE: note there is no error handling from here if the regex - # is wrong: then incorrect values will slip through - if empty_list is not None: - # the single comma - meaning an empty list - return ([], comment) - if single is not None: - # handle empty values - if list_values and not single: - # FIXME: the '' is a workaround because our regex now matches - # '' at the end of a list if it has a trailing comma - single = None - else: - single = single or '""' - single = self._unquote(single) - if list_values == '': - # not a list value - return (single, comment) - the_list = self._listvalueexp.findall(list_values) - the_list = [self._unquote(val) for val in the_list] - if single is not None: - the_list += [single] - return (the_list, comment) - - - def _multiline(self, value, infile, cur_index, maxline): - """Extract the value, where we are in a multiline situation.""" - quot = value[:3] - newvalue = value[3:] - single_line = self._triple_quote[quot][0] - multi_line = self._triple_quote[quot][1] - mat = single_line.match(value) - if mat is not None: - retval = list(mat.groups()) - retval.append(cur_index) - return retval - elif newvalue.find(quot) != -1: - # somehow the triple quote is missing - raise SyntaxError() - # - while cur_index < maxline: - cur_index += 1 - newvalue += '\n' - line = infile[cur_index] - if line.find(quot) == -1: - newvalue += line - else: - # end of multiline, process it - break - else: - # we've got to the end of the config, oops... - raise SyntaxError() - mat = multi_line.match(line) - if mat is None: - # a badly formed line - raise SyntaxError() - (value, comment) = mat.groups() - return (newvalue + value, comment, cur_index) - - - def _handle_configspec(self, configspec): - """Parse the configspec.""" - # FIXME: Should we check that the configspec was created with the - # correct settings ? (i.e. ``list_values=False``) - if not isinstance(configspec, ConfigObj): - try: - configspec = ConfigObj(configspec, - raise_errors=True, - file_error=True, - _inspec=True) - except ConfigObjError as e: - # FIXME: Should these errors have a reference - # to the already parsed ConfigObj ? - raise ConfigspecError('Parsing configspec failed: %s' % e) - except IOError as e: - raise IOError('Reading configspec failed: %s' % e) - - self.configspec = configspec - - - - def _set_configspec(self, section, copy): - """ - Called by validate. Handles setting the configspec on subsections - including sections to be validated by __many__ - """ - configspec = section.configspec - many = configspec.get('__many__') - if isinstance(many, dict): - for entry in section.sections: - if entry not in configspec: - section[entry].configspec = many - - for entry in configspec.sections: - if entry == '__many__': - continue - if entry not in section: - section[entry] = {} - section[entry]._created = True - if copy: - # copy comments - section.comments[entry] = configspec.comments.get(entry, []) - section.inline_comments[entry] = configspec.inline_comments.get(entry, '') - - # Could be a scalar when we expect a section - if isinstance(section[entry], Section): - section[entry].configspec = configspec[entry] - - - def _write_line(self, indent_string, entry, this_entry, comment): - """Write an individual line, for the write method""" - # NOTE: the calls to self._quote here handles non-StringType values. - if not self.unrepr: - val = self._decode_element(self._quote(this_entry)) - else: - val = repr(this_entry) - return '%s%s%s%s%s' % (indent_string, - self._decode_element(self._quote(entry, multiline=False)), - self._a_to_u(' = '), - val, - self._decode_element(comment)) - - - def _write_marker(self, indent_string, depth, entry, comment): - """Write a section marker line""" - return '%s%s%s%s%s' % (indent_string, - self._a_to_u('[' * depth), - self._quote(self._decode_element(entry), multiline=False), - self._a_to_u(']' * depth), - self._decode_element(comment)) - - - def _handle_comment(self, comment): - """Deal with a comment.""" - if not comment: - return '' - start = self.indent_type - if not comment.startswith('#'): - start += self._a_to_u(' # ') - return (start + comment) - - - # Public methods - - def write(self, outfile=None, section=None): - """ - Write the current ConfigObj as a file - - tekNico: FIXME: use StringIO instead of real files - - >>> filename = a.filename - >>> a.filename = 'test.ini' - >>> a.write() - >>> a.filename = filename - >>> a == ConfigObj('test.ini', raise_errors=True) - 1 - >>> import os - >>> os.remove('test.ini') - """ - if self.indent_type is None: - # this can be true if initialised from a dictionary - self.indent_type = DEFAULT_INDENT_TYPE - - out = [] - cs = self._a_to_u('#') - csp = self._a_to_u('# ') - if section is None: - int_val = self.interpolation - self.interpolation = False - section = self - for line in self.initial_comment: - line = self._decode_element(line) - stripped_line = line.strip() - if stripped_line and not stripped_line.startswith(cs): - line = csp + line - out.append(line) - - indent_string = self.indent_type * section.depth - for entry in (section.scalars + section.sections): - if entry in section.defaults: - # don't write out default values - continue - for comment_line in section.comments[entry]: - comment_line = self._decode_element(comment_line.lstrip()) - if comment_line and not comment_line.startswith(cs): - comment_line = csp + comment_line - out.append(indent_string + comment_line) - this_entry = section[entry] - comment = self._handle_comment(section.inline_comments[entry]) - - if isinstance(this_entry, Section): - # a section - out.append(self._write_marker( - indent_string, - this_entry.depth, - entry, - comment)) - out.extend(self.write(section=this_entry)) - else: - out.append(self._write_line( - indent_string, - entry, - this_entry, - comment)) - - if section is self: - for line in self.final_comment: - line = self._decode_element(line) - stripped_line = line.strip() - if stripped_line and not stripped_line.startswith(cs): - line = csp + line - out.append(line) - self.interpolation = int_val - - if section is not self: - return out - - if (self.filename is None) and (outfile is None): - # output a list of lines - # might need to encode - # NOTE: This will *screw* UTF16, each line will start with the BOM - if self.encoding: - out = [l.encode(self.encoding) for l in out] - if (self.BOM and ((self.encoding is None) or - (BOM_LIST.get(self.encoding.lower()) == 'utf_8'))): - # Add the UTF8 BOM - if not out: - out.append('') - out[0] = BOM_UTF8 + out[0] - return out - - # Turn the list to a string, joined with correct newlines - newline = self.newlines or os.linesep - if (getattr(outfile, 'mode', None) is not None and outfile.mode == 'w' - and sys.platform == 'win32' and newline == '\r\n'): - # Windows specific hack to avoid writing '\r\r\n' - newline = '\n' - output = self._a_to_u(newline).join(out) - if not output.endswith(newline): - output += newline - - if isinstance(output, six.binary_type): - output_bytes = output - else: - output_bytes = output.encode(self.encoding or - self.default_encoding or - 'ascii') - - if self.BOM and ((self.encoding is None) or match_utf8(self.encoding)): - # Add the UTF8 BOM - output_bytes = BOM_UTF8 + output_bytes - - if outfile is not None: - outfile.write(output_bytes) - else: - with open(self.filename, 'wb') as h: - h.write(output_bytes) - - def validate(self, validator, preserve_errors=False, copy=False, - section=None): - """ - Test the ConfigObj against a configspec. - - It uses the ``validator`` object from *validate.py*. - - To run ``validate`` on the current ConfigObj, call: :: - - test = config.validate(validator) - - (Normally having previously passed in the configspec when the ConfigObj - was created - you can dynamically assign a dictionary of checks to the - ``configspec`` attribute of a section though). - - It returns ``True`` if everything passes, or a dictionary of - pass/fails (True/False). If every member of a subsection passes, it - will just have the value ``True``. (It also returns ``False`` if all - members fail). - - In addition, it converts the values from strings to their native - types if their checks pass (and ``stringify`` is set). - - If ``preserve_errors`` is ``True`` (``False`` is default) then instead - of a marking a fail with a ``False``, it will preserve the actual - exception object. This can contain info about the reason for failure. - For example the ``VdtValueTooSmallError`` indicates that the value - supplied was too small. If a value (or section) is missing it will - still be marked as ``False``. - - You must have the validate module to use ``preserve_errors=True``. - - You can then use the ``flatten_errors`` function to turn your nested - results dictionary into a flattened list of failures - useful for - displaying meaningful error messages. - """ - if section is None: - if self.configspec is None: - raise ValueError('No configspec supplied.') - if preserve_errors: - # We do this once to remove a top level dependency on the validate module - # Which makes importing configobj faster - from validate import VdtMissingValue - self._vdtMissingValue = VdtMissingValue - - section = self - - if copy: - section.initial_comment = section.configspec.initial_comment - section.final_comment = section.configspec.final_comment - section.encoding = section.configspec.encoding - section.BOM = section.configspec.BOM - section.newlines = section.configspec.newlines - section.indent_type = section.configspec.indent_type - - # - # section.default_values.clear() #?? - configspec = section.configspec - self._set_configspec(section, copy) - - - def validate_entry(entry, spec, val, missing, ret_true, ret_false): - section.default_values.pop(entry, None) - - try: - section.default_values[entry] = validator.get_default_value(configspec[entry]) - except (KeyError, AttributeError, validator.baseErrorClass): - # No default, bad default or validator has no 'get_default_value' - # (e.g. SimpleVal) - pass - - try: - check = validator.check(spec, - val, - missing=missing - ) - except validator.baseErrorClass as e: - if not preserve_errors or isinstance(e, self._vdtMissingValue): - out[entry] = False - else: - # preserve the error - out[entry] = e - ret_false = False - ret_true = False - else: - ret_false = False - out[entry] = True - if self.stringify or missing: - # if we are doing type conversion - # or the value is a supplied default - if not self.stringify: - if isinstance(check, (list, tuple)): - # preserve lists - check = [self._str(item) for item in check] - elif missing and check is None: - # convert the None from a default to a '' - check = '' - else: - check = self._str(check) - if (check != val) or missing: - section[entry] = check - if not copy and missing and entry not in section.defaults: - section.defaults.append(entry) - return ret_true, ret_false - - # - out = {} - ret_true = True - ret_false = True - - unvalidated = [k for k in section.scalars if k not in configspec] - incorrect_sections = [k for k in configspec.sections if k in section.scalars] - incorrect_scalars = [k for k in configspec.scalars if k in section.sections] - - for entry in configspec.scalars: - if entry in ('__many__', '___many___'): - # reserved names - continue - if (not entry in section.scalars) or (entry in section.defaults): - # missing entries - # or entries from defaults - missing = True - val = None - if copy and entry not in section.scalars: - # copy comments - section.comments[entry] = ( - configspec.comments.get(entry, [])) - section.inline_comments[entry] = ( - configspec.inline_comments.get(entry, '')) - # - else: - missing = False - val = section[entry] - - ret_true, ret_false = validate_entry(entry, configspec[entry], val, - missing, ret_true, ret_false) - - many = None - if '__many__' in configspec.scalars: - many = configspec['__many__'] - elif '___many___' in configspec.scalars: - many = configspec['___many___'] - - if many is not None: - for entry in unvalidated: - val = section[entry] - ret_true, ret_false = validate_entry(entry, many, val, False, - ret_true, ret_false) - unvalidated = [] - - for entry in incorrect_scalars: - ret_true = False - if not preserve_errors: - out[entry] = False - else: - ret_false = False - msg = 'Value %r was provided as a section' % entry - out[entry] = validator.baseErrorClass(msg) - for entry in incorrect_sections: - ret_true = False - if not preserve_errors: - out[entry] = False - else: - ret_false = False - msg = 'Section %r was provided as a single value' % entry - out[entry] = validator.baseErrorClass(msg) - - # Missing sections will have been created as empty ones when the - # configspec was read. - for entry in section.sections: - # FIXME: this means DEFAULT is not copied in copy mode - if section is self and entry == 'DEFAULT': - continue - if section[entry].configspec is None: - unvalidated.append(entry) - continue - if copy: - section.comments[entry] = configspec.comments.get(entry, []) - section.inline_comments[entry] = configspec.inline_comments.get(entry, '') - check = self.validate(validator, preserve_errors=preserve_errors, copy=copy, section=section[entry]) - out[entry] = check - if check == False: - ret_true = False - elif check == True: - ret_false = False - else: - ret_true = False - - section.extra_values = unvalidated - if preserve_errors and not section._created: - # If the section wasn't created (i.e. it wasn't missing) - # then we can't return False, we need to preserve errors - ret_false = False - # - if ret_false and preserve_errors and out: - # If we are preserving errors, but all - # the failures are from missing sections / values - # then we can return False. Otherwise there is a - # real failure that we need to preserve. - ret_false = not any(out.values()) - if ret_true: - return True - elif ret_false: - return False - return out - - - def reset(self): - """Clear ConfigObj instance and restore to 'freshly created' state.""" - self.clear() - self._initialise() - # FIXME: Should be done by '_initialise', but ConfigObj constructor (and reload) - # requires an empty dictionary - self.configspec = None - # Just to be sure ;-) - self._original_configspec = None - - - def reload(self): - """ - Reload a ConfigObj from file. - - This method raises a ``ReloadError`` if the ConfigObj doesn't have - a filename attribute pointing to a file. - """ - if not isinstance(self.filename, six.string_types): - raise ReloadError() - - filename = self.filename - current_options = {} - for entry in OPTION_DEFAULTS: - if entry == 'configspec': - continue - current_options[entry] = getattr(self, entry) - - configspec = self._original_configspec - current_options['configspec'] = configspec - - self.clear() - self._initialise(current_options) - self._load(filename, configspec) - - - -class SimpleVal(object): - """ - A simple validator. - Can be used to check that all members expected are present. - - To use it, provide a configspec with all your members in (the value given - will be ignored). Pass an instance of ``SimpleVal`` to the ``validate`` - method of your ``ConfigObj``. ``validate`` will return ``True`` if all - members are present, or a dictionary with True/False meaning - present/missing. (Whole missing sections will be replaced with ``False``) - """ - - def __init__(self): - self.baseErrorClass = ConfigObjError - - def check(self, check, member, missing=False): - """A dummy check method, always returns the value unchanged.""" - if missing: - raise self.baseErrorClass() - return member - - -def flatten_errors(cfg, res, levels=None, results=None): - """ - An example function that will turn a nested dictionary of results - (as returned by ``ConfigObj.validate``) into a flat list. - - ``cfg`` is the ConfigObj instance being checked, ``res`` is the results - dictionary returned by ``validate``. - - (This is a recursive function, so you shouldn't use the ``levels`` or - ``results`` arguments - they are used by the function.) - - Returns a list of keys that failed. Each member of the list is a tuple:: - - ([list of sections...], key, result) - - If ``validate`` was called with ``preserve_errors=False`` (the default) - then ``result`` will always be ``False``. - - *list of sections* is a flattened list of sections that the key was found - in. - - If the section was missing (or a section was expected and a scalar provided - - or vice-versa) then key will be ``None``. - - If the value (or section) was missing then ``result`` will be ``False``. - - If ``validate`` was called with ``preserve_errors=True`` and a value - was present, but failed the check, then ``result`` will be the exception - object returned. You can use this as a string that describes the failure. - - For example *The value "3" is of the wrong type*. - """ - if levels is None: - # first time called - levels = [] - results = [] - if res == True: - return sorted(results) - if res == False or isinstance(res, Exception): - results.append((levels[:], None, res)) - if levels: - levels.pop() - return sorted(results) - for (key, val) in list(res.items()): - if val == True: - continue - if isinstance(cfg.get(key), dict): - # Go down one level - levels.append(key) - flatten_errors(cfg[key], val, levels, results) - continue - results.append((levels[:], key, val)) - # - # Go up one level - if levels: - levels.pop() - # - return sorted(results) - - -def get_extra_values(conf, _prepend=()): - """ - Find all the values and sections not in the configspec from a validated - ConfigObj. - - ``get_extra_values`` returns a list of tuples where each tuple represents - either an extra section, or an extra value. - - The tuples contain two values, a tuple representing the section the value - is in and the name of the extra values. For extra values in the top level - section the first member will be an empty tuple. For values in the 'foo' - section the first member will be ``('foo',)``. For members in the 'bar' - subsection of the 'foo' section the first member will be ``('foo', 'bar')``. - - NOTE: If you call ``get_extra_values`` on a ConfigObj instance that hasn't - been validated it will return an empty list. - """ - out = [] - - out.extend([(_prepend, name) for name in conf.extra_values]) - for name in conf.sections: - if name not in conf.extra_values: - out.extend(get_extra_values(conf[name], _prepend + (name,))) - return out - - -"""*A programming language is a medium of expression.* - Paul Graham""" From ad985a2552fe84da3323ca007230fd425d891900 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 15 Apr 2025 20:23:24 +0200 Subject: [PATCH 303/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3a5416e..f31b01a 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.3", "lxmf>=0.6.2", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.4", "lxmf>=0.6.3", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From aa4feeb29c957defec74860e9d413ff6b32466fc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 15 Apr 2025 20:24:48 +0200 Subject: [PATCH 304/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 43c4ab0..22049ab 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.6.1" +__version__ = "0.6.2" From dfa7adc21e63c21a4f694b04dc4631c9375722e3 Mon Sep 17 00:00:00 2001 From: RFNexus Date: Sun, 20 Apr 2025 09:18:56 -0400 Subject: [PATCH 305/592] Interface Management initial (2/2) --- nomadnet/_version.py | 2 +- nomadnet/ui/textui/Guide.py | 219 ++++- nomadnet/ui/textui/Interfaces.py | 749 +++++++++++++----- .../additional_urwid_widgets/FormWidgets.py | 255 ++++++ setup.py | 2 +- 5 files changed, 1016 insertions(+), 211 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 43c4ab0..22049ab 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.6.1" +__version__ = "0.6.2" diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 94f83d0..4b4ca2a 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -387,8 +387,223 @@ Links can be inserted into micron documents. See the `*Markup`* section of this ''' -TOPIC_INTERFACES = ''' ->TODO +TOPIC_INTERFACES = '''>Interfaces + +Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. + +The number of distinct network topologies you can create with Reticulum is more or less endless, but common to them all is that you will need to define one or more interfaces for Reticulum to use. + +The `![ Interfaces ]`! section of NomadNet lets you add, monitor, and update interfaces configured for your Reticulum instance. + +If you are starting NomadNet for the first time you will find that an `!AutoInterface`! has been added by default. This interface will try to use your available network device to communicate with other peers discovered on your local network. + +Interfaces come in many different types and can interact with physical mediums like LoRa radios or standard IP networks. + +>>Viewing Interfaces + +To view more info about an interface, navigate using the `!Up`! and `!Down`! arrow keys or by clicking with the mouse. Pressing `! < Enter >`! on a selected interface will bring you to a detailed interface view, which will show configuration parameters and realtime charts. From here you can also disable or edit the interface. To change the orientation of the TX/RX charts, press `!< V >`! for a vertical layout, and `!< H >`! for a horizontal view. + +>>Updating Interfaces + +To edit an interface, select the interface and press `!< Ctrl + E >`!. + +To remove an interface, select the interface and press `!< Ctrl + X >`!. You can also perform both of these actions from the details view. + +>>Adding Interfaces + +To add a new interface, press `!< Ctrl + A >`!. From here you can select which type of interface you want to add. Each unique interface type will have different configuration options. + +`Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b + + +>Interface Types + +>>AutoInterface + +The Auto Interface enables communication with other discoverable Reticulum nodes over autoconfigured IPv6 and UDP. It does not need any functional IP infrastructure like routers or DHCP servers, but will require at least some sort of switching medium between peers (a wired switch, a hub, a WiFi access point or similar). + +``` +Required Parameters: +Interface Name + +Optional Parameters: +Devices: Specific network devices to use +Ignored Devices: Network devices to exclude +Group ID: Create isolated networks on the same physical LAN +Discovery Scope: Can set to link, admin, site, organisation or global +``` + +The AutoInterface is ideal for quickly connecting to other Reticulum nodes on your local network without complex configuration. + +>>TCPClientInterface + +The TCP Client interface connects to remote TCP server interfaces, enabling communication over the Internet or private networks. + +``` +Required Parameters: +Target Host: Hostname or IP address of the server +Target Port: Port number to connect to + +Optional Parameters: +I2P Tunneled: Enable for connecting through I2P +KISS Framing: Enable for KISS framing for software modems +``` + +This interface is commonly used to connect to Reticulum testnets or other persistent nodes on the Internet. + +>>TCPServerInterface + +The TCP Server interface listens for incoming connections, allowing other Reticulum peers to connect to your node using TCPClientInterface. + +``` +Required Parameters: +Listen IP: IP address to bind to (0.0.0.0 for all interfaces) +Listen Port: Port number to listen on + +Optional Parameters: +Prefer IPv6: Bind to IPv6 address if available +I2P Tunneled: Enable for I2P tunnel support +Device: Specific network device to use (e,g +``` + +Useful when you want other nodes to be able to connect to your Transport instance over TCP/IP. + +>>UDPInterface + +The UDP interface allows communication over IP networks using UDP packets. + +``` +Required Parameters: +Listen IP: IP address to bind to +Listen Port: Port to listen on +Forward IP: IP address to forward to (Can be broadcast address) +Forward Port: Port to forward to + +Optional Parameters: +Device: Specific network device to use +``` + +>>I2PInterface + +The I2P interface enables connections over the Invisible Internet Protocol. The I2PInterface requires an I2P daemon to be running on your system, such as `!i2pd`! + +``` +Optional Parameters: +Peers: I2P addresses to connect to (Can be left as none if running as a Transport) +``` + + +>>RNodeInterface + +The RNode interface allows using LoRa transceivers running RNode firmware as Reticulum network interfaces. + +``` +Required Parameters: +Port: Serial port or BLE device path +Frequency: Operating frequency in MHz +Bandwidth: Channel bandwidth +TX Power: Transmit power in dBm +Spreading Factor: LoRa spreading factor (7-12) +Coding Rate: LoRa coding rate (4:5-4:8) + +Optional Parameters: +ID Callsign: Station identification +ID Interval: Identification interval in seconds +Airtime Limits: Control duty cycle +``` + +The interface includes a parameter calculator to estimate link budget, sensitivity, and data rate on the air based on your settings. + +>>RNodeMultiInterface + +The RNode Multi Interface is designed for use with specific hardware platforms that support multiple subinterfaces or virtual radios. For most LoRa hardware platforms, you will want to use the standard `!RNodeInterface`! instead. + +``` +Required Parameters: +Port: Serial port or BLE device path +Subinterfaces: Configuration for each subinterface / virtual radio port + +``` + +>>SerialInterface + +The Serial interface enables using direct serial connections as Reticulum interfaces. + +``` +Required Parameters: +Port: Serial port path +Speed: Baud rate of serial device +Databits: Number of data bits +Parity: Parity setting +Stopbits: Number of stop bits +``` + +>>KISSInterface + +The KISS interface supports packet radio modems and TNCs using the KISS protocol. + +``` +Required Parameters: + +Port: Serial port path +Speed: Baud rate of serial device +Databits: Number of data bits +Parity: Parity setting +Stopbits: Number of stop bits +Preamble: Modem preamble in milliseconds +TX Tail: Transmit tail in milliseconds +Slottime: CSMA slottime in milliseconds +Persistence: CSMA persistence value + +Optional Parameters: + +ID Callsign: Station identification +ID Interval: Identification interval in seconds +Flow Control: Enable packet flow control +``` + +>>PipeInterface + +The Pipe interface allows using external programs as Reticulum interfaces via stdin and stdout. + +``` +Required Parameters: + +Command: External command to execute + +Optional Parameters: +Respawn Delay: Seconds to wait before restarting after failure +``` + +< +>> +-∿ + For more information and to view the full Interface documentation consult the Reticulum manual or visit https://reticulum.network/manual/interfaces.html (Requires external Internet connection) + + +>Interface Access Code (IFAC) Settings + +Interface Access Codes create private networks and securely segment network traffic. Under `!Show more options`!, you'll find these IFAC settings: + +`!Virtual Network Name`! (network_name): +When added, creates a logical network that only communicates with other interfaces using the same name. This allows multiple separate Reticulum networks to exist on the same physical channel or medium. + +`!IFAC Passphrase`! (passphrase): +Sets an authentication passphrase for the interface. Only interfaces configured with the same passphrase will be able to communicate. Can be used with or without a network name. + +`!IFAC Size`! (ifac_size): +Controls the length of Interface Authentication Codes (8-512 bits). Larger sizes provide stronger security but add overhead to each packet. The default of `!8`! is usually appropriate for most uses. + +>Interface Modes + +When running a Transport node, you can configure interface modes that affect how Reticulum selects paths, propagates announces, and discovers routes: + +`!full`!: Default mode with all discovery, meshing and transport functionality +`!gateway`!: Discovers paths on behalf of other nodes +`!access_point`!: Operates as a network access point +`!roaming`!: For physically mobile interfaces +`!boundary`!: For interfaces connecting different network segments + ''' TOPIC_CONVERSATIONS = '''>Conversations diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index 281e505..b87698c 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -15,6 +15,13 @@ INTERFACE_GLYPHS = { } ### HELPER ### +PLATFORM_IS_LINUX = False +try: + PLATFORM_IS_LINUX = (RNS.vendor.platformutils.is_android() or + RNS.vendor.platformutils.is_linux()) +except Exception: + pass + def _get_interface_icon(glyphset, iface_type): glyphset_index = 1 # Default to unicode if glyphset == "plain": @@ -23,6 +30,7 @@ def _get_interface_icon(glyphset, iface_type): glyphset_index = 2 # nerdfont type_to_glyph_tuple = { + "BackboneInterface": "NetworkInterfaceType", "AutoInterface": "NetworkInterfaceType", "TCPClientInterface": "NetworkInterfaceType", "TCPServerInterface": "NetworkInterfaceType", @@ -46,10 +54,10 @@ def _get_interface_icon(glyphset, iface_type): return glyph_tuple[glyphset_index + 1] # Fallback - return "(#)" if glyphset == "plain" else "🙾" if glyphset == "unicode" else " " + return "(#)" if glyphset == "plain" else "\U0001f67e" if glyphset == "unicode" else "\ued95" def format_bytes(bytes_value): - units = ['bytes', 'KB/s', 'MB/s', 'GB/s', 'TB/s'] + units = ['bytes', 'KB', 'MB', 'GB', 'TB'] size = float(bytes_value) unit_index = 0 @@ -62,6 +70,7 @@ def format_bytes(bytes_value): else: return f"{size:.1f} {units[unit_index]}" + ### PORT FUNCTIONS ### PYSERIAL_AVAILABLE = False # If NomadNet is installed on environments with rnspure instead of rns, pyserial won't be available try: @@ -389,6 +398,66 @@ COMMON_INTERFACE_OPTIONS = [ ] INTERFACE_FIELDS = { + "BackboneInterface": [ + { + "config_key": "listen_on", + "type": "edit", + "label": "Listen On: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else 4242 + }, + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "default": "", + "placeholder": "e.g., eth0", + "transform": lambda x: x.strip() + }, + { + "config_key": "remote", + "type": "edit", + "label": "Remote: ", + "default": "", + "placeholder": "e.g., a remote TCPServerInterface location", + "transform": lambda x: x.strip() + }, + { + "config_key": "target_host", + "type": "edit", + "label": "Target Host: ", + "default": "", + "placeholder": "e.g., 201:5d78:af73:5caf:a4de:a79f:3278:71e5", + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Target Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else 4242 + }, + { + "config_key": "prefer_ipv6", + "type": "checkbox", + "label": "", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + ], "AutoInterface": [ { @@ -687,6 +756,73 @@ INTERFACE_FIELDS = { ] } ], + "RNodeMultiInterface": [ + get_port_field(), + { + "config_key": "subinterfaces", + "type": "multitable", + "fields": { + "frequency": { + "label": "Freq (Hz)", + "type": "edit", + "validation": ["required", "float"], + "transform": lambda x: int(x) if x else None + }, + "bandwidth": { + "label": "BW (Hz)", + "type": "edit", + "options": ["7800", "10400", "15600", "20800", "31250", "41700", "62500", "125000", "250000", "500000", "1625000"], + "transform": lambda x: int(x) if x else None + }, + "txpower": { + "label": "TX (dBm)", + "type": "edit", + "validation": ["required", "number"], + "transform": lambda x: int(x) if x else None + }, + "vport": { + "label": "V.Port", + "type": "edit", + "validation": ["required", "number"], + "transform": lambda x: int(x) if x else None + }, + "spreadingfactor": { + "label": "SF", + "type": "edit", + "transform": lambda x: int(x) if x else None + }, + "codingrate": { + "label": "CR", + "type": "edit", + "transform": lambda x: int(x) if x else None + } + }, + "validation": ["required"], + "transform": lambda x: x + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + } + ] + } + ], "SerialInterface": [ get_port_field(), { @@ -954,6 +1090,24 @@ INTERFACE_FIELDS = { ] } ], + "CustomInterface": [ + { + "config_key": "type", + "type": "edit", + "label": "Interface Type: ", + "default": "", + "placeholder": "Name of custom interface class", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "custom_parameters", + "type": "keyvaluepairs", + "label": "Parameters: ", + "validation": [], + "transform": lambda x: x + }, + ], "default": [ { @@ -1059,7 +1213,18 @@ class SelectableInterfaceItem(urwid.WidgetWrap): return super().render(size, focus=focus) def keypress(self, size, key): - if key == "enter": + if key == "up": + listbox = self.parent.box_adapter._original_widget + walker = listbox.body + + interface_items = [i for i, item in enumerate(walker) + if isinstance(item, SelectableInterfaceItem)] + + if interface_items and walker[listbox.focus_position] is self and \ + listbox.focus_position == interface_items[0]: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": self.parent.switch_to_show_interface(self.name) return None return key @@ -1236,10 +1401,10 @@ class InterfaceFiller(urwid.WidgetWrap): # edit interface self.app.ui.main_display.sub_displays.interface_display.edit_selected_interface() return None - elif key == "tab": - # navigation - self.app.ui.main_display.frame.focus_position = "header" - return + elif key == "ctrl w": + # open config file editor + self.app.ui.main_display.sub_displays.interface_display.open_config_editor() + return None return super().keypress(size, key) @@ -1280,7 +1445,6 @@ class AddInterfaceView(urwid.WidgetWrap): form_pile = urwid.Pile(pile_items) form_filler = urwid.Filler(form_pile, valign="top") - #todo form_box = urwid.LineBox( form_filler, title="Add Interface", @@ -1298,7 +1462,6 @@ class AddInterfaceView(urwid.WidgetWrap): width=('relative', 85), valign='middle', height=('relative', 85), - ) super().__init__(self.overlay) @@ -1306,7 +1469,7 @@ class AddInterfaceView(urwid.WidgetWrap): if field["type"] == "dropdown": widget = FormDropdown( config_key=field["config_key"], - label=field["label"], + label=field.get("label", ""), options=field["options"], default=field.get("default"), validation_types=field.get("validation", []), @@ -1315,7 +1478,7 @@ class AddInterfaceView(urwid.WidgetWrap): elif field["type"] == "checkbox": widget = FormCheckbox( config_key=field["config_key"], - label=field["label"], + label=field.get("label", ""), state=field.get("default", False), validation_types=field.get("validation", []), transform=field.get("transform") @@ -1327,6 +1490,19 @@ class AddInterfaceView(urwid.WidgetWrap): validation_types=field.get("validation", []), transform=field.get("transform") ) + elif field["type"] == "multitable": + widget = FormMultiTable( + config_key=field["config_key"], + fields=field.get("fields", {}), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "keyvaluepairs": + widget = FormKeyValuePairs( + config_key=field["config_key"], + validation_types=field.get("validation", []), + transform=field.get("transform") + ) else: widget = FormEdit( config_key=field["config_key"], @@ -1337,8 +1513,12 @@ class AddInterfaceView(urwid.WidgetWrap): transform=field.get("transform") ) + label = field.get("label", "") + if not label: + label = " ".join(word.capitalize() for word in field["config_key"].split('_')) + ": " + self.fields[field["config_key"]] = { - 'label': field["label"], + 'label': label, 'widget': widget } @@ -1349,7 +1529,7 @@ class AddInterfaceView(urwid.WidgetWrap): if option["type"] == "checkbox": widget = FormCheckbox( config_key=option["config_key"], - label=option["label"], + label=option.get("label", ""), state=option.get("default", False), validation_types=option.get("validation", []), transform=option.get("transform") @@ -1357,7 +1537,7 @@ class AddInterfaceView(urwid.WidgetWrap): elif option["type"] == "dropdown": widget = FormDropdown( config_key=option["config_key"], - label=option["label"], + label=option.get("label", ""), options=option["options"], default=option.get("default"), validation_types=option.get("validation", []), @@ -1380,44 +1560,47 @@ class AddInterfaceView(urwid.WidgetWrap): transform=option.get("transform") ) + label = option.get("label", "") + if not label: + label = " ".join(word.capitalize() for word in option["config_key"].split('_')) + ": " + self.additional_fields[option["config_key"]] = { - 'label': option["label"], + 'label': label, 'widget': widget, 'type': option["type"] } def _initialize_common_fields(self): - if self.parent.app.rns.transport_enabled(): # Transport mode options COMMON_INTERFACE_OPTIONS.extend([ - { - "config_key": "outgoing", - "type": "checkbox", - "label": "Allow outgoing traffic", - "default": True, - "validation": [], - "transform": lambda x: bool(x) - }, - { - "config_key": "mode", - "type": "dropdown", - "label": "Interface Mode: ", - "options": ["full", "gateway", "access_point", "roaming", "boundary"], - "default": "full", - "validation": [], - "transform": lambda x: x - }, - { - "config_key": "announce_cap", - "type": "edit", - "label": "Announce Cap: ", - "placeholder": "Default: 2.0", - "default": "", - "validation": ["float"], - "transform": lambda x: float(x) if x.strip() else 2.0 - } - ]) + { + "config_key": "outgoing", + "type": "checkbox", + "label": "Allow outgoing traffic", + "default": True, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "mode", + "type": "dropdown", + "label": "Interface Mode: ", + "options": ["full", "gateway", "access_point", "roaming", "boundary"], + "default": "full", + "validation": [], + "transform": lambda x: x + }, + { + "config_key": "announce_cap", + "type": "edit", + "label": "Announce Cap: ", + "placeholder": "Default: 2.0", + "default": "", + "validation": ["float"], + "transform": lambda x: float(x) if x.strip() else 2.0 + } + ]) for option in COMMON_INTERFACE_OPTIONS: if option["type"] == "checkbox": @@ -1447,8 +1630,6 @@ class AddInterfaceView(urwid.WidgetWrap): transform=option.get("transform") ) - - self.common_fields[option["config_key"]] = { 'label': option["label"], 'widget': widget, @@ -1461,13 +1642,22 @@ class AddInterfaceView(urwid.WidgetWrap): def _build_form_layout(self, iface_fields): pile_items = [] - pile_items.append(urwid.Text(("form_title", f"Add new {_get_interface_icon(self.parent.glyphset, self.iface_type)} {self.iface_type}"), align="center")) + pile_items.append(urwid.Text( + ("form_title", f"Add new {_get_interface_icon(self.parent.glyphset, self.iface_type)} {self.iface_type}"), + align="center")) pile_items.append(urwid.Divider("─")) for key in ["name"] + [f["config_key"] for f in iface_fields]: field = self.fields[key] widget = field["widget"] + # Special case for multitable and keyvaluepairs - they already have their own layout + if isinstance(widget, (FormMultiTable, FormKeyValuePairs)): + pile_items.append(urwid.Text(("key", field["label"]), align="left")) + pile_items.append(widget) + pile_items.append(urwid.Padding(widget.error_widget, left=2)) + continue + field_pile = urwid.Pile([ urwid.Columns([ (26, urwid.Text(("key", field["label"]), align="right")), @@ -1476,7 +1666,8 @@ class AddInterfaceView(urwid.WidgetWrap): urwid.Padding(widget.error_widget, left=24) ]) - if self.iface_type in ["RNodeInterface", "SerialInterface", "AX25KISSInterface", "KISSInterface"] and key == "port": + if self.iface_type in ["RNodeInterface", "RNodeMultiInterface", "SerialInterface", "AX25KISSInterface", + "KISSInterface"] and key == "port": refresh_btn = urwid.Button("Refresh Ports", on_press=self.refresh_ports) refresh_btn = urwid.AttrMap(refresh_btn, "button_normal", focus_map="button_focus") refresh_row = urwid.Padding(refresh_btn, left=26, width=20) @@ -1495,7 +1686,7 @@ class AddInterfaceView(urwid.WidgetWrap): self.ifac_options_button = urwid.AttrMap(self.ifac_options_button, "button_normal", focus_map="button_focus") self.ifac_options_widget = urwid.Pile([]) - if self.iface_type == "RNodeInterface": + if self.iface_type in ["RNodeInterface", "RNodeMultiInterface"]: self.calculator_button = urwid.Button("Show On-Air Calculations", on_press=self.toggle_calculator) self.calculator_button = urwid.AttrMap(self.calculator_button, "button_normal", focus_map="button_focus") @@ -1512,7 +1703,7 @@ class AddInterfaceView(urwid.WidgetWrap): self.more_options_button, self.more_options_widget, ]) - if self.iface_type == "RNodeInterface": + if self.iface_type in ["RNodeInterface", "RNodeMultiInterface"]: self.rnode_calculator = RNodeCalculator(self) self.calculator_visible = False self.calculator_widget = urwid.Pile([]) @@ -1564,7 +1755,6 @@ class AddInterfaceView(urwid.WidgetWrap): pile_contents.append(urwid.Divider("─")) if self.common_fields: - # pile_contents.append(urwid.Text(("interface_title", "Common "), align="center")) for key, field in self.common_fields.items(): widget = field['widget'] @@ -1697,18 +1887,36 @@ class AddInterfaceView(urwid.WidgetWrap): if not all_valid: return - interface_config = { - "type": self.iface_type, - "interface_enabled": True - } + if self.iface_type == "CustomInterface": + custom_type = self.fields.get('type', {}).get('widget').get_value() + interface_config = { + "type": custom_type, + "interface_enabled": True + } + else: + interface_config = { + "type": self.iface_type, + "interface_enabled": True + } for field_key, field in self.fields.items(): - if field_key != "name": + if field_key not in ["name", "custom_parameters", "type"]: widget = field["widget"] value = widget.get_value() - if value is not None and value != "": + + if field_key == "subinterfaces" and self.iface_type == "RNodeMultiInterface" and isinstance(value, + dict): + for subname, subconfig in value.items(): + interface_config[f"{subname}"] = subconfig + elif value is not None and value != "": interface_config[widget.config_key] = value + if self.iface_type == "CustomInterface" and "custom_parameters" in self.fields: + custom_params = self.fields["custom_parameters"]["widget"].get_value() + if isinstance(custom_params, dict): + for param_key, param_value in custom_params.items(): + interface_config[param_key] = param_value + for field_key, field in self.additional_fields.items(): widget = field["widget"] value = widget.get_value() @@ -1721,23 +1929,22 @@ class AddInterfaceView(urwid.WidgetWrap): if value is not None and value != "": interface_config[widget.config_key] = value - # Add interface to RNS config try: interfaces = self.parent.app.rns.config['interfaces'] - interfaces[name] = interface_config - self.parent.app.rns.config.write() - print(self.parent.glyphset) + + display_type = custom_type if self.iface_type == "CustomInterface" else self.iface_type + new_item = SelectableInterfaceItem( parent=self.parent, name=name, is_connected=False, # will always be false until restart is_enabled=True, - iface_type=self.iface_type, + iface_type=display_type, tx=0, rx=0, - icon=_get_interface_icon(self.parent.glyphset, self.iface_type), + icon=_get_interface_icon(self.parent.glyphset, display_type), iface_options=interface_config ) @@ -1754,7 +1961,6 @@ class AddInterfaceView(urwid.WidgetWrap): self.parent.switch_to_list() def show_message(self, message, title="Notice"): - def dismiss_dialog(button): self.parent.switch_to_list() @@ -1781,127 +1987,106 @@ class AddInterfaceView(urwid.WidgetWrap): self.parent.widget = overlay self.parent.app.ui.main_display.update_active_sub_display() + class EditInterfaceView(AddInterfaceView): def __init__(self, parent, iface_name): self.parent = parent self.iface_name = iface_name self.interface_config = parent.app.rns.config['interfaces'][iface_name] - self.iface_type = self.interface_config.get("type", "Unknown") + config_type = self.interface_config.get("type", "Unknown") + + # check if this is a custom interface type + known_types = list(INTERFACE_FIELDS.keys()) + if config_type not in known_types: + self.original_type = config_type + self.iface_type = "CustomInterface" + else: + self.original_type = None + self.iface_type = config_type super().__init__(parent, self.iface_type) self.overlay.top_w.title_widget.set_text(f"Edit Interface: {iface_name}") + self._populate_form_fields() def _populate_form_fields(self): - self.fields['name']['widget'].edit_text = self.iface_name + if self.original_type and self.iface_type == "CustomInterface": + if "type" in self.fields: + self.fields["type"]["widget"].edit_text = self.original_type + + if "custom_parameters" in self.fields: + custom_params = {} + standard_keys = ["type", "interface_enabled", "enabled", "description", + "network_name", "bitrate", "passphrase", "ifac_size", + "mode", "outgoing", "announce_cap"] + + for key, value in self.interface_config.items(): + if key not in standard_keys: + custom_params[key] = value + + self.fields["custom_parameters"]["widget"].set_value(custom_params) + for key, field in self.fields.items(): - if key != 'name' and key in self.interface_config: + if key not in ['name', 'type', 'custom_parameters']: widget = field['widget'] - value = self.interface_config[key] - if key == 'frequency': - # convert Hz back to MHz - value = float(value) / 1000000 - # decimal format - value = f"{value:.6f}".rstrip('0').rstrip('.') if '.' in f"{value:.6f}" else f"{value}" + if key == "subinterfaces" and isinstance(widget, FormMultiTable): + self._populate_subinterfaces(widget) + continue - if hasattr(widget, 'edit_text'): - # For text input fields, set edit_text - widget.edit_text = str(value) - elif hasattr(widget, 'set_state'): - # For checkboxes - widget.set_state(bool(value)) - elif isinstance(widget, FormDropdown): - # For dropdowns - update selected and update display - str_value = str(value) - if str_value in widget.options: - widget.selected = str_value - widget.main_button.base_widget.set_text(str_value) - else: - for opt in widget.options: - try: - if widget.transform(opt) == value: - widget.selected = opt - widget.main_button.base_widget.set_text(opt) - break - except: - pass - elif isinstance(widget, FormMultiList): - if isinstance(value, str): - items = [item.strip() for item in value.split(',') if item.strip()] - self._populate_multilist(widget, items) - elif isinstance(value, list): - self._populate_multilist(widget, value) + if key in self.interface_config: + value = self.interface_config[key] + + if key == 'frequency': + value = float(value) / 1000000 + value = f"{value:.6f}".rstrip('0').rstrip('.') if '.' in f"{value:.6f}" else f"{value}" + + self._set_field_value(widget, value) for key, field in self.additional_fields.items(): if key in self.interface_config: - widget = field['widget'] - value = self.interface_config[key] - - if hasattr(widget, 'edit_text'): - widget.edit_text = str(value) - elif hasattr(widget, 'set_state'): - # RNS.log(f"KEY: {key} VAL: {value}") - checkbox_state = value if isinstance(value, bool) else value.strip().lower() not in ('false', 'off', 'no', '0') - widget.set_state(checkbox_state) - elif isinstance(widget, FormDropdown): - str_value = str(value) - if str_value in widget.options: - widget.selected = str_value - widget.main_button.base_widget.set_text(str_value) - else: - # Try to match after transform - for opt in widget.options: - try: - if widget.transform(opt) == value: - widget.selected = opt - widget.main_button.base_widget.set_text(opt) - break - except: - pass - elif isinstance(widget, FormMultiList): - if isinstance(value, str): - items = [item.strip() for item in value.split(',') if item.strip()] - self._populate_multilist(widget, items) - elif isinstance(value, list): - self._populate_multilist(widget, value) + self._set_field_value(field['widget'], self.interface_config[key]) for key, field in self.common_fields.items(): if key in self.interface_config: - widget = field['widget'] - value = self.interface_config[key] + self._set_field_value(field['widget'], self.interface_config[key]) - if hasattr(widget, 'edit_text'): - widget.edit_text = str(value) - elif hasattr(widget, 'set_state'): - widget.set_state(bool(value)) - elif isinstance(widget, FormDropdown): - str_value = str(value) - if str_value in widget.options: - widget.selected = str_value - widget.main_button.base_widget.set_text(str_value) - else: - # Try to match after transform - for opt in widget.options: - try: - if widget.transform(opt) == value: - widget.selected = opt - widget.main_button.base_widget.set_text(opt) - break - except: - pass - elif isinstance(widget, FormMultiList): - if isinstance(value, str): - items = [item.strip() for item in value.split(',') if item.strip()] - self._populate_multilist(widget, items) - elif isinstance(value, list): - self._populate_multilist(widget, value) + def _set_field_value(self, widget, value): + if hasattr(widget, 'edit_text'): + widget.edit_text = str(value) + elif hasattr(widget, 'set_state'): + checkbox_state = value if isinstance(value, bool) else value.strip().lower() not in ( + 'false', 'off', 'no', '0') + widget.set_state(checkbox_state) + elif isinstance(widget, FormDropdown): + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + else: + # Try to match after transform + for opt in widget.options: + try: + if widget.transform(opt) == value: + widget.selected = opt + widget.main_button.base_widget.set_text(opt) + break + except: + pass + elif isinstance(widget, FormMultiList): + self._populate_multilist(widget, value) + + def _populate_multilist(self, widget, value): + items = [] + if isinstance(value, str): + items = [item.strip() for item in value.split(',') if item.strip()] + elif isinstance(value, list): + items = value - def _populate_multilist(self, widget, items): while len(widget.entries) > 1: widget.remove_entry(None, widget.entries[-1]) @@ -1917,24 +2102,64 @@ class EditInterfaceView(AddInterfaceView): edit_widget = entry.contents[0][0] edit_widget.edit_text = items[i] + def _populate_subinterfaces(self, widget): + subinterfaces = {} + + for key, value in self.interface_config.items(): + if isinstance(value, dict): + if key.startswith('[[[') and key.endswith(']]]'): + clean_key = key[3:-3] # removes [[[...]]] + subinterfaces[clean_key] = value + elif key not in ["type", "interface_enabled", "enabled", "port", + "id_callsign", "id_interval"]: + subinterfaces[key] = value + + if subinterfaces: + widget.set_value(subinterfaces) + def on_save(self, button): if not self.validate_all(): return new_name = self.fields['name']["widget"].get_value() or self.iface_name + if new_name != self.iface_name and new_name in self.parent.app.rns.config['interfaces']: + self.fields['name']["widget"].error = f"Interface name '{new_name}' already exists" + self.fields['name']["widget"].error_widget.set_text(("error", self.fields['name']["widget"].error)) + return + + if self.iface_type == "CustomInterface": + interface_type = self.fields.get('type', {}).get('widget').get_value() + else: + interface_type = self.iface_type + updated_config = { - "type": self.iface_type, + "type": interface_type, "interface_enabled": True } for field_key, field in self.fields.items(): - if field_key != "name": + if field_key not in ["name", "custom_parameters", "type"]: widget = field["widget"] value = widget.get_value() if value is not None and value != "": updated_config[widget.config_key] = value + if self.iface_type == "CustomInterface" and "custom_parameters" in self.fields: + custom_params = self.fields["custom_parameters"]["widget"].get_value() + if isinstance(custom_params, dict): + for param_key, param_value in custom_params.items(): + updated_config[param_key] = param_value + + elif self.iface_type == "RNodeMultiInterface" and "subinterfaces" in self.fields: + subinterfaces = self.fields["subinterfaces"]["widget"].get_value() + + for subname, subconfig in subinterfaces.items(): + if any(k.startswith('[[[') for k in self.interface_config.keys()): + updated_config[f"[[[{subname}]]]"] = subconfig + else: + updated_config[subname] = subconfig + for field_key, field in self.additional_fields.items(): widget = field["widget"] value = widget.get_value() @@ -1963,13 +2188,14 @@ class EditInterfaceView(AddInterfaceView): self.parent.app.rns.config.write() + display_type = interface_type + for item in self.parent.interface_items: if item.name == new_name: - item.iface_type = self.iface_type + item.iface_type = display_type break self.parent._rebuild_list() - self.show_message(f"Interface {new_name} updated. Restart NomadNet for these changes to take effect") except Exception as e: @@ -1992,6 +2218,8 @@ class ShowInterface(urwid.WidgetWrap): self.config_rows = [] + self.history_length=60 + # get interface stats interface_stats = self.parent.app.rns.get_interface_stats() stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} @@ -2070,7 +2298,7 @@ class ShowInterface(urwid.WidgetWrap): self.info_rows.append(self.stat_row) self.info_rows.append(urwid.Divider("-")) - self.bandwidth_chart = InterfaceBandwidthChart(history_length=60, glyphset=self.parent.glyphset) + self.bandwidth_chart = InterfaceBandwidthChart(history_length=self.history_length, glyphset=self.parent.glyphset) self.bandwidth_chart.update(self.rx, self.tx) self.rx_chart_text = urwid.Text("Loading RX data...", align='left') @@ -2117,7 +2345,8 @@ class ShowInterface(urwid.WidgetWrap): screen = urwid.raw_display.Screen() screen_cols, _ = screen.get_cols_rows() - if screen_cols >= 100: + # RNS.log(screen_cols) + if screen_cols >= 145: self.charts_widget = self.horizontal_charts self.is_horizontal = True @@ -2410,12 +2639,11 @@ class ShowInterface(urwid.WidgetWrap): self.frame.focus_position = 'footer' footer_pile = self.frame.footer if isinstance(footer_pile, urwid.Pile): - footer_pile.focus_position = 1 # button row + footer_pile.focus_position = 1 # button row button_row = footer_pile.contents[1][0] if isinstance(button_row, urwid.Columns): button_row.focus_position = 0 return None - # If we're currently in footer change the focus elif self.frame.focus_position == 'footer': footer_pile = self.frame.footer if isinstance(footer_pile, urwid.Pile): @@ -2451,6 +2679,33 @@ class ShowInterface(urwid.WidgetWrap): elif button_row.focus_position == 0: # back button self.frame.focus_position = 'body' return None + elif key == 'down': + if self.frame.focus_position == 'body': + result = super().keypress(size, key) + # if the key wasn't consumed, we're at the bottom + if result == 'down': + self.frame.focus_position = 'footer' + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + footer_pile.focus_position = 1 # button row + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + button_row.focus_position = 0 # focus on back button + return None + return result + elif key == 'up': + if self.frame.focus_position == 'footer': + self.frame.focus_position = 'body' + listbox = self.frame.body + if hasattr(listbox, 'body') and len(listbox.body) > 0: + listbox.focus_position = len(listbox.body) - 1 + return None + elif self.frame.focus_position == 'body': + result = super().keypress(size, key) + # if the key wasn't consumed, we're at the top + if result == 'up': + pass + return result elif key == "h" and self.is_connected: # horizontal layout if not self.is_horizontal: self.switch_to_horizontal() @@ -2496,37 +2751,60 @@ class ShowInterface(urwid.WidgetWrap): if not self.started: return - interface_stats = self.parent.app.rns.get_interface_stats() - stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} - stats = stats_lookup.get(self.iface_name, {}) + try: + interface_stats = self.parent.app.rns.get_interface_stats() + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + stats = stats_lookup.get(self.iface_name, {}) - tx = stats.get("txb", self.tx) - rx = stats.get("rxb", self.rx) + tx = stats.get("txb", self.tx) + rx = stats.get("rxb", self.rx) - new_connection_status = stats.get("status", False) - if new_connection_status != self.is_connected: - self.update_connection_display(new_connection_status) + new_connection_status = stats.get("status", False) + if new_connection_status != self.is_connected: + self.update_connection_display(new_connection_status) - if not self.is_connected: - return + if not self.is_connected: + return - self.tx_text.set_text(("value", format_bytes(tx))) - self.rx_text.set_text(("value", format_bytes(rx))) + self.tx_text.set_text(("value", format_bytes(tx))) + self.rx_text.set_text(("value", format_bytes(rx))) - self.bandwidth_chart.update(rx, tx) + self.bandwidth_chart.update(rx, tx) - rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) + rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) - self.rx_chart_text.set_text(rx_chart) - self.tx_chart_text.set_text(tx_chart) - self.rx_peak_text.set_text(f"Peak: {peak_rx}") - self.tx_peak_text.set_text(f"Peak: {peak_tx}") + self.rx_chart_text.set_text(rx_chart) + self.tx_chart_text.set_text(tx_chart) + self.rx_peak_text.set_text(f"Peak: {peak_rx}") + self.tx_peak_text.set_text(f"Peak: {peak_tx}") - self.tx = tx - self.rx = rx + self.tx = tx + self.rx = rx + except Exception as e: + if not hasattr(self.parent, + 'disconnect_overlay') or self.parent.widget is not self.parent.disconnect_overlay: + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text("Waiting to Reconnect...", align="center") + ]) + dialog_content = urwid.Filler(dialog_text) + dialog_box = urwid.LineBox(dialog_content) - if self.started: - loop.set_alarm_in(1, self.update_bandwidth_charts) + self.parent.disconnect_overlay = urwid.Overlay( + dialog_box, + self, + align='center', + width=35, + valign='middle', + height=4 + ) + + self.parent.widget = self.parent.disconnect_overlay + self.parent.app.ui.main_display.update_active_sub_display() + self.started = False + finally: + if self.started: + loop.set_alarm_in(1, self.update_bandwidth_charts) def on_back(self, button): self.started = False @@ -2666,18 +2944,44 @@ class InterfaceDisplay: loop.set_alarm_in(5, self.check_terminal_size) def poll_stats(self, loop, user_data): - interface_stats = self.app.rns.get_interface_stats() - stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} - for item in self.interface_items: - # use interface name as the key - stats_for_interface = stats_lookup.get(item.name) - if stats_for_interface: - tx = stats_for_interface.get("txb", 0) - rx = stats_for_interface.get("rxb", 0) - item.update_stats(tx, rx) + try: + if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() - loop.set_alarm_in(1, self.poll_stats) + interface_stats = self.app.rns.get_interface_stats() + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + for item in self.interface_items: + # use interface name as the key + stats_for_interface = stats_lookup.get(item.name) + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + item.update_stats(tx, rx) + except Exception as e: + if not hasattr(self, 'disconnect_overlay') or self.widget is not self.disconnect_overlay: + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text(("Waiting to Reconnect..."), align="center") + ]) + dialog_content = urwid.Filler(dialog_text) + dialog_box = urwid.LineBox(dialog_content) + self.disconnect_overlay = urwid.Overlay( + dialog_box, + self.interfaces_display, + align='center', + width=35, + valign='middle', + height=4 + ) + + if self.widget is self.interfaces_display: + self.widget = self.disconnect_overlay + self.app.ui.main_display.update_active_sub_display() + finally: + if self.started: + loop.set_alarm_in(1, self.poll_stats) def shortcuts(self): return self.shortcuts_display @@ -2717,19 +3021,23 @@ class InterfaceDisplay: add_option("TCP Server Interface", "TCPServerInterface") add_option("UDP Interface", "UDPInterface") add_option("I2P Interface", "I2PInterface") + if PLATFORM_IS_LINUX: + add_option("Backbone Interface", "BackboneInterface") - add_heading(f"{rnode_icon} RNodes") - add_option("RNode Interface", "RNodeInterface") - # add_option("RNode Multi Interface", "RNodeMultiInterface") TODO + if PYSERIAL_AVAILABLE: + add_heading(f"{rnode_icon} RNodes") + add_option("RNode Interface", "RNodeInterface") + add_option("RNode Multi Interface", "RNodeMultiInterface") - add_heading(f"{serial_icon} Hardware") - add_option("Serial Interface", "SerialInterface") - add_option("KISS Interface", "KISSInterface") - add_option("AX.25 KISS Interface", "AX25KISSInterface") + add_heading(f"{serial_icon} Hardware") + add_option("Serial Interface", "SerialInterface") + add_option("KISS Interface", "KISSInterface") + add_option("AX.25 KISS Interface", "AX25KISSInterface") add_heading(f"{other_icon} Other") add_option("Pipe Interface", "PipeInterface") - # add_option("Custom Interface", "CustomInterface") TODO + add_option("Custom Interface", "CustomInterface") + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)) dialog = DialogLineBox(listbox, parent=self, title="Select Interface Type") @@ -2834,11 +3142,38 @@ class InterfaceDisplay: self.box_adapter._original_widget.body = walker self.box_adapter._original_widget.focus_position = len(new_list) - 1 + def open_config_editor(self): + import platform + + editor_cmd = self.app.config["textui"]["editor"] + + if platform.system() == "Darwin" and editor_cmd == "editor": + editor_cmd = "nano" + + editor_term = urwid.Terminal( + (editor_cmd, self.app.rns.configpath), + encoding='utf-8', + main_loop=self.app.ui.loop, + ) + + def quit_term(*args, **kwargs): + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.request_redraw() + + urwid.connect_signal(editor_term, 'closed', quit_term) + + editor_box = urwid.LineBox(editor_term, title="Editing RNS Config") + self.widget = editor_box + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + editor_term.change_focus(True) + ### SHORTCUTS ### class InterfaceDisplayShortcuts: def __init__(self, app): self.app = app - self.default_shortcuts = "[C-a] Add Interface [C-e] Edit Interface [C-x] Remove Interface [Enter] Show Interface" + self.default_shortcuts = "[C-a] Add Interface [C-e] Edit Interface [C-x] Remove Interface [Enter] Show Interface [C-w] Open Text Editor" self.current_shortcuts = self.default_shortcuts self.widget = urwid.AttrMap( urwid.Text(self.current_shortcuts), @@ -2853,7 +3188,7 @@ class InterfaceDisplayShortcuts: self.update_shortcuts(self.default_shortcuts) def set_show_interface_shortcuts(self): - show_shortcuts = "[Back] Return to List [Tab] Navigate [Shift-tab] Change Focus [h] Horizontal Charts [v] Vertical Charts [Up/Down] Scroll" + show_shortcuts = "[Up/Down] Navigate [Tab] Switch Focus [h] Horizontal Charts [v] Vertical Charts " self.update_shortcuts(show_shortcuts) def set_add_interface_shortcuts(self): diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py index 4e4809a..f3e95b7 100644 --- a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -279,5 +279,260 @@ class FormMultiList(urwid.Pile, FormField): self.error = "At least one entry is required" break + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + +class FormMultiTable(urwid.Pile, FormField): + def __init__(self, config_key, fields, validation_types=None, transform=None, **kwargs): + self.entries = [] + self.fields = fields + self.error_widget = urwid.Text("") + self.error = None + self.validation_types = validation_types or [] + + header_columns = [('weight', 3, urwid.Text(("list_focus", "Name")))] + for field_key, field_config in self.fields.items(): + header_columns.append(('weight', 2, urwid.Text(("list_focus", field_config.get("label", field_key))))) + header_columns.append((4, urwid.Text(("list_focus", "")))) + + self.header_row = urwid.Columns(header_columns) + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add ", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [ + self.header_row, + urwid.Divider("-"), + first_entry, + add_button_padded + ] + + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self, name="", values=None): + if values is None: + values = {} + + name_edit = urwid.Edit("", name) + + columns = [('weight', 3, name_edit)] + + field_widgets = {} + for field_key, field_config in self.fields.items(): + field_value = values.get(field_key, "") + + if field_config.get("type") == "checkbox": + widget = urwid.CheckBox("", state=bool(field_value)) + elif field_config.get("type") == "dropdown": + # TODO: dropdown in MultiTable + widget = urwid.Edit("", str(field_value)) + else: + widget = urwid.Edit("", str(field_value)) + + field_widgets[field_key] = widget + columns.append(('weight', 2, widget)) + + remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) + columns.append((4, remove_button)) + + entry_row = urwid.Columns(columns) + entry_row.name_edit = name_edit + entry_row.field_widgets = field_widgets + + return entry_row + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return [ + self.header_row, + urwid.Divider("-") + ] + self.entries + [ + urwid.Padding(self.add_button, left=2, right=2) + ] + + def get_value(self): + values = {} + for entry in self.entries: + name = entry.name_edit.edit_text.strip() + if name: + subinterface = {} + subinterface["interface_enabled"] = True + + for field_key, widget in entry.field_widgets.items(): + field_config = self.fields.get(field_key, {}) + + if hasattr(widget, "get_state"): + value = widget.get_state() + elif hasattr(widget, "edit_text"): + value = widget.edit_text.strip() + + transform = field_config.get("transform") + if transform and value: + try: + value = transform(value) + except (ValueError, TypeError): + pass + value = "" + + if value: + subinterface[field_key] = value + + values[name] = subinterface + + return self.transform(values) if self.transform else values + + def set_value(self, value): + self.entries = [] + + if not value: + self.entries.append(self.create_entry_row()) + else: + for name, config in value.items(): + self.entries.append(self.create_entry_row(name=name, values=config)) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def validate(self): + values = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "At least one subinterface is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + +class FormKeyValuePairs(urwid.Pile, FormField): + def __init__(self, config_key, validation_types=None, transform=None, **kwargs): + self.entries = [] + self.error_widget = urwid.Text("") + self.error = None + self.validation_types = validation_types or [] + + header_columns = [ + ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Key"), "multitable_header")), + ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Value"), "multitable_header")), + (4, urwid.AttrMap(urwid.Text("Action"), "multitable_header")) + ] + + self.header_row = urwid.AttrMap(urwid.Columns(header_columns), "multitable_header") + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add Parameter", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [ + self.header_row, + urwid.Divider("-"), + first_entry, + add_button_padded + ] + + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self, key="", value=""): + key_edit = urwid.Edit("", key) + value_edit = urwid.Edit("", value) + + remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) + + entry_row = urwid.Columns([ + ('weight', 1, key_edit), + ('weight', 1, value_edit), + (4, remove_button) + ]) + + entry_row.key_edit = key_edit + entry_row.value_edit = value_edit + + return entry_row + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return [ + self.header_row, + urwid.Divider("-") + ] + self.entries + [ + urwid.Padding(self.add_button, left=2, right=2) + ] + + def get_value(self): + values = {} + for entry in self.entries: + key = entry.key_edit.edit_text.strip() + value = entry.value_edit.edit_text.strip() + + if key: + if value.isdigit(): + values[key] = int(value) + elif value.replace('.', '', 1).isdigit() and value.count('.') <= 1: + values[key] = float(value) + elif value.lower() == 'true': + values[key] = True + elif value.lower() == 'false': + values[key] = False + else: + values[key] = value + + return self.transform(values) if self.transform else values + + def set_value(self, value): + self.entries = [] + + if not value or not isinstance(value, dict): + self.entries.append(self.create_entry_row()) + else: + for key, val in value.items(): + self.entries.append(self.create_entry_row(key=key, value=str(val))) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def validate(self): + values = self.get_value() + self.error = None + + keys = [entry.key_edit.edit_text.strip() for entry in self.entries + if entry.key_edit.edit_text.strip()] + if len(keys) != len(set(keys)): + self.error = "Duplicate keys are not allowed" + self.error_widget.set_text(("error", self.error)) + return False + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "Atleast one parameter is required" + break + self.error_widget.set_text(("error", self.error or "")) return self.error is None \ No newline at end of file diff --git a/setup.py b/setup.py index 3a5416e..f31b01a 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.3", "lxmf>=0.6.2", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.4", "lxmf>=0.6.3", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From a0d6b752a388e934fdeadb16daf4c0877f99c7c1 Mon Sep 17 00:00:00 2001 From: RFNexus Date: Sun, 20 Apr 2025 23:17:24 -0400 Subject: [PATCH 306/592] Fixes for RNodeMultiInterface --- nomadnet/ui/textui/Interfaces.py | 12 ++++-------- .../vendor/additional_urwid_widgets/FormWidgets.py | 3 +-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index b87698c..0a4cc8b 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -1686,7 +1686,7 @@ class AddInterfaceView(urwid.WidgetWrap): self.ifac_options_button = urwid.AttrMap(self.ifac_options_button, "button_normal", focus_map="button_focus") self.ifac_options_widget = urwid.Pile([]) - if self.iface_type in ["RNodeInterface", "RNodeMultiInterface"]: + if self.iface_type in ["RNodeInterface"]: self.calculator_button = urwid.Button("Show On-Air Calculations", on_press=self.toggle_calculator) self.calculator_button = urwid.AttrMap(self.calculator_button, "button_normal", focus_map="button_focus") @@ -1703,7 +1703,7 @@ class AddInterfaceView(urwid.WidgetWrap): self.more_options_button, self.more_options_widget, ]) - if self.iface_type in ["RNodeInterface", "RNodeMultiInterface"]: + if self.iface_type in ["RNodeInterface"]: self.rnode_calculator = RNodeCalculator(self) self.calculator_visible = False self.calculator_widget = urwid.Pile([]) @@ -2139,7 +2139,7 @@ class EditInterfaceView(AddInterfaceView): } for field_key, field in self.fields.items(): - if field_key not in ["name", "custom_parameters", "type"]: + if field_key not in ["name", "custom_parameters", "type", "subinterfaces"]: widget = field["widget"] value = widget.get_value() if value is not None and value != "": @@ -2153,12 +2153,8 @@ class EditInterfaceView(AddInterfaceView): elif self.iface_type == "RNodeMultiInterface" and "subinterfaces" in self.fields: subinterfaces = self.fields["subinterfaces"]["widget"].get_value() - for subname, subconfig in subinterfaces.items(): - if any(k.startswith('[[[') for k in self.interface_config.keys()): - updated_config[f"[[[{subname}]]]"] = subconfig - else: - updated_config[subname] = subconfig + updated_config[subname] = subconfig for field_key, field in self.additional_fields.items(): widget = field["widget"] diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py index f3e95b7..55f9a03 100644 --- a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -386,8 +386,7 @@ class FormMultiTable(urwid.Pile, FormField): try: value = transform(value) except (ValueError, TypeError): - pass - value = "" + value = "" if value: subinterface[field_key] = value From ab8b30999ab1f773944e9a124fae9a5d2510c7ea Mon Sep 17 00:00:00 2001 From: RFNexus Date: Tue, 22 Apr 2025 18:33:33 -0400 Subject: [PATCH 307/592] fixes for TCPServer, TCPClient, and UDPInterface field definitions --- nomadnet/ui/textui/Interfaces.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index 0a4cc8b..b3d5f78 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -414,7 +414,7 @@ INTERFACE_FIELDS = { "default": "", "placeholder": "e.g., 4242", "validation": ["number"], - "transform": lambda x: int(x.strip()) if x.strip() else 4242 + "transform": lambda x: int(x.strip()) if x.strip() else None }, { "config_key": "device", @@ -447,7 +447,7 @@ INTERFACE_FIELDS = { "default": "", "placeholder": "e.g., 4242", "validation": ["number"], - "transform": lambda x: int(x.strip()) if x.strip() else 4242 + "transform": lambda x: int(x.strip()) if x.strip() else None }, { "config_key": "prefer_ipv6", @@ -525,8 +525,8 @@ INTERFACE_FIELDS = { "label": "Listen Port: ", "default": "", "placeholder": "e.g., 4242", - "validation": ["required", "number"], - "transform": lambda x: int(x.strip()) if x.strip() else 4242 + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None }, { "additional_options": [ @@ -553,7 +553,7 @@ INTERFACE_FIELDS = { "placeholder": "A specific network device to listen on - e.g. eth0", "default": "", "validation": [], - "transform": lambda x: x.strip() + "transform": lambda x: x.strip() if x.strip() else None }, { "config_key": "port", @@ -562,7 +562,7 @@ INTERFACE_FIELDS = { "default": "", "placeholder": "e.g., 4242", "validation": ["number"], - "transform": lambda x: int(x.strip()) if x.strip() else 4242 + "transform": lambda x: int(x.strip()) if x.strip() else None }, ] } @@ -584,7 +584,7 @@ INTERFACE_FIELDS = { "default": "", "placeholder": "e.g., 8080", "validation": ["required", "number"], - "transform": lambda x: int(x.strip()) if x.strip() else 4242 + "transform": lambda x: int(x.strip()) if x.strip() else None }, { "additional_options": [ @@ -623,8 +623,8 @@ INTERFACE_FIELDS = { "label": "Listen Port: ", "default": "", "placeholder": "e.g., 4242", - "validation": ["required", "number"], - "transform": lambda x: int(x.strip()) if x.strip() else 4242 + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None }, { "config_key": "forward_ip", @@ -642,7 +642,7 @@ INTERFACE_FIELDS = { "default": "", "placeholder": "e.g., 4242", "validation": ["required", "number"], - "transform": lambda x: int(x.strip()) if x.strip() else 4242 + "transform": lambda x: int(x.strip()) if x.strip() else None }, { "additional_options": [ @@ -662,7 +662,7 @@ INTERFACE_FIELDS = { "default": "", "placeholder": "e.g., 4242", "validation": ["number"], - "transform": lambda x: int(x.strip()) if x.strip() else 4242 + "transform": lambda x: int(x.strip()) if x.strip() else None }, ] } From 4eed5bab4851d254a1ca5ecc9e24ebdef0215833 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 29 Apr 2025 11:43:46 +0200 Subject: [PATCH 308/592] Updated readme --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 313c0af..416eb7c 100755 --- a/README.md +++ b/README.md @@ -146,13 +146,13 @@ You can help support the continued development of open, free and private communi ``` 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w ``` -- Ethereum - ``` - 0xFDabC71AC4c0C78C95aDDDe3B4FA19d6273c5E73 - ``` - Bitcoin ``` - 35G9uWVzrpJJibzUwpNUQGQNFzLirhrYAH + bc1p4a6axuvl7n9hpapfj8sv5reqj8kz6uxa67d5en70vzrttj0fmcusgxsfk5 + ``` +- Ethereum + ``` + 0xae89F3B94fC4AD6563F0864a55F9a697a90261ff ``` - Ko-Fi: https://ko-fi.com/markqvist From b9ac735308f697ff0d98f572fc94c3e8202095cc Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 8 May 2025 18:40:04 -0500 Subject: [PATCH 309/592] update to use super().__init__(widget) --- nomadnet/vendor/Scrollable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index eedb1ab..e54e1b0 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -50,7 +50,7 @@ class Scrollable(urwid.WidgetDecoration): self._old_cursor_coords = None self._rows_max_cached = 0 self.force_forward_keypress = force_forward_keypress - self.__super.__init__(widget) + super().__init__(widget) def render(self, size, focus=False): maxcol, maxrow = size @@ -340,7 +340,7 @@ class ScrollBar(urwid.WidgetDecoration): """ if BOX not in widget.sizing(): raise ValueError('Not a box widget: %r' % widget) - self.__super.__init__(widget) + super().__init__(widget) self._thumb_char = thumb_char self._trough_char = trough_char self.scrollbar_side = side From f107a9ea306d59cac2dc39ffb94fbe53a165d5d2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 16:42:50 +0200 Subject: [PATCH 310/592] Use RNS file responses for file downloads instead of reading entire file into memory --- nomadnet/Node.py | 15 ++++-------- nomadnet/ui/textui/Browser.py | 43 +++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 5c2a3e0..755c002 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -61,16 +61,14 @@ class Node: self.destination.register_request_handler( "/page/index.mu", response_generator = self.serve_default_index, - allow = RNS.Destination.ALLOW_ALL - ) + allow = RNS.Destination.ALLOW_ALL) for page in self.servedpages: request_path = "/page"+page.replace(self.app.pagespath, "") self.destination.register_request_handler( request_path, response_generator = self.serve_page, - allow = RNS.Destination.ALLOW_ALL - ) + allow = RNS.Destination.ALLOW_ALL) def register_files(self): # TODO: Deregister previously registered files @@ -83,8 +81,8 @@ class Node: self.destination.register_request_handler( request_path, response_generator = self.serve_file, - allow = RNS.Destination.ALLOW_ALL - ) + allow = RNS.Destination.ALLOW_ALL, + auto_compress = 32_000_000) def scan_pages(self, base_path): files = [file for file in os.listdir(base_path) if os.path.isfile(os.path.join(base_path, file)) and file[:1] != "."] @@ -205,10 +203,7 @@ class Node: file_name = path.replace("/file/", "", 1) try: RNS.log("Serving file: "+file_path, RNS.LOG_VERBOSE) - fh = open(file_path, "rb") - file_data = fh.read() - fh.close() - return [file_name, file_data] + return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}] except Exception as e: RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_ERROR) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 5e412a4..c465dff 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1,8 +1,10 @@ import RNS import LXMF +import io import os import time import urwid +import shutil import nomadnet import subprocess import threading @@ -1036,23 +1038,36 @@ class Browser: def file_received(self, request_receipt): try: - file_name = request_receipt.response[0] - file_data = request_receipt.response[1] - file_destination_name = file_name.split("/") - file_destination_name = file_destination_name[len(file_destination_name)-1] - file_destination = self.app.downloads_path+"/"+file_destination_name + if type(request_receipt.response) == io.BufferedReader: + if request_receipt.metadata != None: + file_name = os.path.basename(request_receipt.metadata["name"].decode("utf-8")) + file_handle = request_receipt.response + file_destination = self.app.downloads_path+"/"+file_name - - counter = 0 - while os.path.isfile(file_destination): - counter += 1 - file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter) + counter = 0 + while os.path.isfile(file_destination): + counter += 1 + file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter) - fh = open(file_destination, "wb") - fh.write(file_data) - fh.close() + shutil.move(file_handle.name, file_destination) - self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + else: + file_name = request_receipt.response[0] + file_data = request_receipt.response[1] + file_destination_name = file_name.split("/") + file_destination_name = file_destination_name[len(file_destination_name)-1] + file_destination = self.app.downloads_path+"/"+file_destination_name + + counter = 0 + while os.path.isfile(file_destination): + counter += 1 + file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter) + + fh = open(file_destination, "wb") + fh.write(file_data) + fh.close() + + self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) self.status = Browser.DONE self.response_progress = 0 From 8d464fcfe4f0ab249c5728a07f9f851f25b6f4d7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 16:43:19 +0200 Subject: [PATCH 311/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 22049ab..63af887 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.6.2" +__version__ = "0.6.3" From dfb234c3a98ecebc1a896e782ffa4553c676035d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 16:43:42 +0200 Subject: [PATCH 312/592] Updated RNS dependency --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f31b01a..879fa6f 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.4", "lxmf>=0.6.3", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.6", "lxmf>=0.6.3", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From d1c7f3dcfe885b17802f495aa553dab51a5afbcf Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 17:21:43 +0200 Subject: [PATCH 313/592] Added transfer speed to progress bar --- nomadnet/ui/textui/Browser.py | 60 +++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index c465dff..36e01e6 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -93,7 +93,10 @@ class Browser: self.link = None self.loopback = None self.status = Browser.DISCONECTED + self.progress_updated_at = None + self.previous_progress = 0 self.response_progress = 0 + self.response_speed = None self.response_size = None self.response_transfer_size = None self.saved_file_name = None @@ -318,7 +321,7 @@ class Browser: def make_status_widget(self): if self.response_progress > 0: - pb = ResponseProgressBar("progress_empty" , "progress_full", current=self.response_progress, done=1.0, satt=None) + pb = ResponseProgressBar("progress_empty" , "progress_full", current=self.response_progress, done=1.0, satt=None, owner=self) widget = urwid.Pile([urwid.Divider(self.g["divider1"]), pb]) else: widget = urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text(self.status_text())]) @@ -427,6 +430,9 @@ class Browser: self.attr_maps = [] self.status = Browser.DISCONECTED self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None @@ -537,6 +543,9 @@ class Browser: self.status = Browser.DONE self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 except Exception as e: RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -586,6 +595,9 @@ class Browser: # Send the request self.status = Browser.REQUESTING self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.saved_file_name = None @@ -755,6 +767,9 @@ class Browser: self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.saved_file_name = None @@ -802,6 +817,9 @@ class Browser: self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.saved_file_name = None @@ -868,6 +886,9 @@ class Browser: # Send the request self.status = Browser.REQUESTING self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.saved_file_name = None @@ -913,6 +934,9 @@ class Browser: def link_establishment_timeout(self): self.status = Browser.LINK_TIMEOUT self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.link = None @@ -927,6 +951,9 @@ class Browser: self.markup = self.page_data.decode("utf-8") self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.loaded_from_cache = False # Simple header handling. Should be expanded when more @@ -1070,6 +1097,9 @@ class Browser: self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) self.status = Browser.DONE self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.update_display() except Exception as e: @@ -1081,6 +1111,9 @@ class Browser: if request_receipt.request_id == self.last_request_id: self.status = Browser.REQUEST_FAILED self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None @@ -1093,6 +1126,9 @@ class Browser: else: self.status = Browser.REQUEST_FAILED self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None @@ -1107,6 +1143,9 @@ class Browser: def request_timeout(self, request_receipt=None): self.status = Browser.REQUEST_TIMEOUT self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None @@ -1123,6 +1162,17 @@ class Browser: self.response_time = request_receipt.get_response_time() self.response_size = request_receipt.response_size self.response_transfer_size = request_receipt.response_transfer_size + + now = time.time() + if self.progress_updated_at == None: self.progress_updated_at = now + if now > self.progress_updated_at+1: + td = now - self.progress_updated_at + pd = self.response_progress - self.previous_progress + bd = pd*self.response_size + self.response_speed = (bd/td)*8 + self.previous_progress = self.response_progress + self.progress_updated_at = now + self.update_display() @@ -1173,8 +1223,14 @@ class Browser: class ResponseProgressBar(urwid.ProgressBar): + def __init__(self, empty, full, current=None, done=None, satt=None, owner=None): + super().__init__(empty, full, current=current, done=done, satt=satt) + self.owner = owner + def get_text(self): - return "Receiving response "+super().get_text() + if self.owner.response_speed: speed_str = " "+RNS.prettyspeed(self.owner.response_speed) + else: speed_str = "" + return "Receiving response "+super().get_text().replace(" %", "%")+speed_str # A convenience function for printing a human- # readable file size From b02263138219de3efcec899549ef31bd4987def2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 17:38:28 +0200 Subject: [PATCH 314/592] Fixed multiple urwid screen init calls --- nomadnet/ui/textui/Interfaces.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index b3d5f78..367c682 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -1,5 +1,6 @@ import RNS import time +import nomadnet from math import log10, pow from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * @@ -70,6 +71,9 @@ def format_bytes(bytes_value): else: return f"{size:.1f} {units[unit_index]}" +def _get_cols_rows(): + return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() + ### PORT FUNCTIONS ### PYSERIAL_AVAILABLE = False # If NomadNet is installed on environments with rnspure instead of rns, pyserial won't be available @@ -2215,6 +2219,7 @@ class ShowInterface(urwid.WidgetWrap): self.config_rows = [] self.history_length=60 + RNS.log(f"Col/rows: {_get_cols_rows()}") # get interface stats interface_stats = self.parent.app.rns.get_interface_stats() @@ -2339,8 +2344,7 @@ class ShowInterface(urwid.WidgetWrap): self.charts_widget = self.vertical_charts self.is_horizontal = False - screen = urwid.raw_display.Screen() - screen_cols, _ = screen.get_cols_rows() + screen_cols, _ = _get_cols_rows() # RNS.log(screen_cols) if screen_cols >= 145: self.charts_widget = self.horizontal_charts @@ -2819,7 +2823,7 @@ class InterfaceDisplay: self.glyphset = self.app.config["textui"]["glyphs"] self.g = self.app.ui.glyphs - self.terminal_cols, self.terminal_rows = urwid.raw_display.Screen().get_cols_rows() + self.terminal_cols, self.terminal_rows = _get_cols_rows() self.iface_row_offset = 4 self.list_rows = self.terminal_rows - self.iface_row_offset @@ -2926,7 +2930,7 @@ class InterfaceDisplay: self.switch_to_edit_interface(interface_name) def check_terminal_size(self, loop, user_data): - new_cols, new_rows = loop.screen.get_cols_rows() + new_cols, new_rows = _get_cols_rows() if new_rows != self.terminal_rows or new_cols != self.terminal_cols: self.terminal_cols, self.terminal_rows = new_cols, new_rows From 3ace3289183c36edec03ebb2d29086703ad5665e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 17:45:49 +0200 Subject: [PATCH 315/592] Scale interface graphs according to terminal size --- nomadnet/ui/textui/Interfaces.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index 367c682..4134a5f 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -2218,8 +2218,12 @@ class ShowInterface(urwid.WidgetWrap): self.config_rows = [] - self.history_length=60 - RNS.log(f"Col/rows: {_get_cols_rows()}") + screen_cols, _ = _get_cols_rows() + margin = 22 + if screen_cols >= 145: + self.history_length = screen_cols//2-margin + else: + self.history_length = screen_cols-margin # get interface stats interface_stats = self.parent.app.rns.get_interface_stats() From c35f6418a2e97b20bc499ddc9eab9e42cefb1d12 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 18:11:35 +0200 Subject: [PATCH 316/592] Added formatted bitrate values to interface charts --- nomadnet/ui/textui/Interfaces.py | 12 ++++++------ nomadnet/vendor/AsciiChart.py | 6 +++++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index 4134a5f..e0f24a9 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -1303,8 +1303,8 @@ class InterfaceBandwidthChart: self.rx_rates.pop(0) self.tx_rates.pop(0) - self.rx_rates.append(rx_delta) - self.tx_rates.append(tx_delta) + self.rx_rates.append(rx_delta*8) + self.tx_rates.append(tx_delta*8) if self.update_count >= self.stabilization_updates: self.initialization_complete = True @@ -1328,14 +1328,14 @@ class InterfaceBandwidthChart: peak_rx = self.peak_rx_for_display if self.initialization_complete else 0 peak_tx = self.peak_tx_for_display if self.initialization_complete else 0 - peak_rx_str = format_bytes(peak_rx) - peak_tx_str = format_bytes(peak_tx) + peak_rx_str = RNS.prettyspeed(peak_rx*8) + peak_tx_str = RNS.prettyspeed(peak_tx*8) rx_chart = chart.plot( [rx_data], { 'height': height, - 'format': '{:8.1f}', + 'format': RNS.prettyspeed, 'min': 0, 'max': self.max_rx_rate * 1.1, } @@ -1345,7 +1345,7 @@ class InterfaceBandwidthChart: [tx_data], { 'height': height, - 'format': '{:8.1f}', + 'format': RNS.prettyspeed, 'min': 0, 'max': self.max_tx_rate * 1.1, } diff --git a/nomadnet/vendor/AsciiChart.py b/nomadnet/vendor/AsciiChart.py index 4689590..cd24d56 100644 --- a/nomadnet/vendor/AsciiChart.py +++ b/nomadnet/vendor/AsciiChart.py @@ -46,7 +46,11 @@ class AsciiChart: result = [[' '] * width for i in range(rows + 1)] for y in range(min2, max2 + 1): - label = placeholder.format(maximum - ((y - min2) * interval / (rows if rows else 1))) + if callable(placeholder): + label = placeholder(maximum - ((y - min2) * interval / (rows if rows else 1))).rjust(12) + else: + label = placeholder.format(maximum - ((y - min2) * interval / (rows if rows else 1))) + result[y - min2][max(offset - len(label), 0)] = label result[y - min2][offset - 1] = symbols[0] if y == 0 else symbols[1] From b6e4d8441a47403c40494dea95ef716a08453bb7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 18:43:13 +0200 Subject: [PATCH 317/592] Handle first-char escape properly. Fixes #67. --- nomadnet/ui/textui/MicronParser.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index bbda2c8..9f277ba 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -73,8 +73,9 @@ def markup_to_attrmaps(markup, url_delegate = None): return attrmaps - +import RNS def parse_line(line, state, url_delegate): + pre_escape = False if len(line) > 0: first_char = line[0] @@ -88,6 +89,7 @@ def parse_line(line, state, url_delegate): # Check if the command is an escape if first_char == "\\": line = line[1:] + pre_escape = True # Check for comments elif first_char == "#": @@ -142,7 +144,7 @@ def parse_line(line, state, url_delegate): else: return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))] - output = make_output(state, line, url_delegate) + output = make_output(state, line, url_delegate, pre_escape) if output != None: text_only = True @@ -425,7 +427,7 @@ def make_style(state): return name -def make_output(state, line, url_delegate): +def make_output(state, line, url_delegate, pre_escape=False): output = [] if state["literal"]: if line == "\\`=": @@ -434,8 +436,10 @@ def make_output(state, line, url_delegate): else: part = "" mode = "text" - escape = False + escape = pre_escape skip = 0 + + RNS.log(f"Line [ESC {escape}] [MODE {mode}: {line}") for i in range(0, len(line)): c = line[i] if skip > 0: @@ -666,6 +670,7 @@ def make_output(state, line, url_delegate): part = "" else: part += c + escape = False if i == len(line)-1: if len(part) > 0: From 96823c8546f86c2aeaa5fb21581e7b70475d5e2b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 20:59:26 +0200 Subject: [PATCH 318/592] Added page background and foreground headers to micron --- nomadnet/ui/textui/Browser.py | 93 ++++++++++++++++++++++++++++-- nomadnet/ui/textui/Guide.py | 4 ++ nomadnet/ui/textui/MicronParser.py | 44 ++++++++------ 3 files changed, 119 insertions(+), 22 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 36e01e6..b740023 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -8,7 +8,7 @@ import shutil import nomadnet import subprocess import threading -from .MicronParser import markup_to_attrmaps +from .MicronParser import markup_to_attrmaps, make_style, default_state from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.Scrollable import * @@ -99,6 +99,8 @@ class Browser: self.response_speed = None self.response_size = None self.response_transfer_size = None + self.page_background_color = None + self.page_foreground_color = None self.saved_file_name = None self.page_data = None self.displayed_page_data = None @@ -157,10 +159,16 @@ class Browser: self.browser_footer = self.make_status_widget() self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) self.link_status_showing = False + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) else: self.link_status_showing = True self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to: "+str(link_target))]), "browser_controls") self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) def expand_shorthands(self, destination_type): if destination_type == "nnn": @@ -321,7 +329,14 @@ class Browser: def make_status_widget(self): if self.response_progress > 0: - pb = ResponseProgressBar("progress_empty" , "progress_full", current=self.response_progress, done=1.0, satt=None, owner=self) + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + style_name_inverted = make_style(default_state(bg=self.page_foreground_color, fg=self.page_background_color)) + else: + style_name = "progress_empty" + style_name_inverted = "progress_full" + + pb = ResponseProgressBar(style_name , style_name_inverted, current=self.response_progress, done=1.0, satt=None, owner=self) widget = urwid.Pile([urwid.Divider(self.g["divider1"]), pb]) else: widget = urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text(self.status_text())]) @@ -368,6 +383,10 @@ class Browser: else: self.display_widget.set_attr_map({None: "body_text"}) self.browser_header = self.make_control_widget() + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_header.set_attr_map({None: style_name}) + if self.destination_hash != None: remote_display_string = self.app.directory.simplest_display_str(self.destination_hash) else: @@ -381,6 +400,13 @@ class Browser: if self.status == Browser.DONE: self.browser_footer = self.make_status_widget() self.update_page_display() + + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_body.set_attr_map({None: style_name}) + self.browser_footer.set_attr_map({None: style_name}) + self.browser_header.set_attr_map({None: style_name}) + self.display_widget.set_attr_map({None: style_name}) elif self.status == Browser.LINK_TIMEOUT: self.browser_body = self.make_request_failed_widget() @@ -394,6 +420,12 @@ class Browser: ) self.browser_footer = self.make_status_widget() + + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) + self.browser_header.set_attr_map({None: style_name}) + self.display_widget.set_attr_map({None: style_name}) elif self.status == Browser.REQUEST_FAILED: self.browser_body = self.make_request_failed_widget() @@ -764,7 +796,24 @@ class Browser: self.status = Browser.DONE self.page_data = cached self.markup = self.page_data.decode("utf-8") - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) self.response_progress = 0 self.response_speed = None @@ -814,7 +863,24 @@ class Browser: self.status = Browser.DONE self.page_data = page_data self.markup = self.page_data.decode("utf-8") - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) self.response_progress = 0 self.response_speed = None @@ -949,7 +1015,24 @@ class Browser: self.status = Browser.DONE self.page_data = request_receipt.response self.markup = self.page_data.decode("utf-8") - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) self.response_progress = 0 self.response_speed = None self.progress_updated_at = None diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 4b4ca2a..352e71f 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1268,6 +1268,9 @@ You can use `B5d5`F222 color `f`B333 `Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft` `` +>Page Foreground and Background Colors + +To specify a background color for the entire page, place the `!#!bg=X`! header on one of the first lines of your page, where `!X`! is the color you want to use, for example `!444`!. If you're also using the cache control header, the background specifier must come `*after`* the cache control header. Likewise, you can specify the default text color by using the `!#!fg=X`! header. >Links @@ -1385,6 +1388,7 @@ A masked input field: `B444``B333 Full control: `B444``B333 `b + >>> Checkboxes In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options. diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 9f277ba..70f9f0f 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -31,20 +31,14 @@ SYNTH_SPECS = {} SECTION_INDENT = 2 INDENT_RIGHT = 1 -def markup_to_attrmaps(markup, url_delegate = None): - global SELECTED_STYLES - if nomadnet.NomadNetworkApp.get_shared_instance().config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK: - SELECTED_STYLES = STYLES_DARK - else: - SELECTED_STYLES = STYLES_LIGHT - - attrmaps = [] - +def default_state(fg=None, bg=None): + if fg == None: fg = SELECTED_STYLES["plain"]["fg"] + if bg == None: bg = DEFAULT_BG state = { "literal": False, "depth": 0, - "fg_color": SELECTED_STYLES["plain"]["fg"], - "bg_color": DEFAULT_BG, + "fg_color": fg, + "bg_color": bg, "formatting": { "bold": False, "underline": False, @@ -54,7 +48,25 @@ def markup_to_attrmaps(markup, url_delegate = None): }, "default_align": "left", "align": "left", + "default_fg": fg, + "default_bg": bg, } + return state + +def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None): + global SELECTED_STYLES + if nomadnet.NomadNetworkApp.get_shared_instance().config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK: + SELECTED_STYLES = STYLES_DARK + else: + SELECTED_STYLES = STYLES_LIGHT + + attrmaps = [] + + fgc = None; bgc = DEFAULT_BG + if bg_color != None: bgc = bg_color + if fg_color != None: fgc = fg_color + + state = default_state(fgc, bgc) # Split entire document into lines for # processing. @@ -73,7 +85,6 @@ def markup_to_attrmaps(markup, url_delegate = None): return attrmaps -import RNS def parse_line(line, state, url_delegate): pre_escape = False if len(line) > 0: @@ -439,7 +450,6 @@ def make_output(state, line, url_delegate, pre_escape=False): escape = pre_escape skip = 0 - RNS.log(f"Line [ESC {escape}] [MODE {mode}: {line}") for i in range(0, len(line)): c = line[i] if skip > 0: @@ -458,20 +468,20 @@ def make_output(state, line, url_delegate, pre_escape=False): state["fg_color"] = color skip = 3 elif c == "f": - state["fg_color"] = SELECTED_STYLES["plain"]["fg"] + state["fg_color"] = state["default_fg"] elif c == "B": if len(line) >= i+4: color = line[i+1:i+4] state["bg_color"] = color skip = 3 elif c == "b": - state["bg_color"] = DEFAULT_BG + state["bg_color"] = state["default_bg"] elif c == "`": state["formatting"]["bold"] = False state["formatting"]["underline"] = False state["formatting"]["italic"] = False - state["fg_color"] = SELECTED_STYLES["plain"]["fg"] - state["bg_color"] = DEFAULT_BG + state["fg_color"] = state["default_fg"] + state["bg_color"] = state["default_bg"] state["align"] = state["default_align"] elif c == "c": if state["align"] != "center": From c9de9244f437df0d1cf7435ae49443706c25eac6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 20:59:43 +0200 Subject: [PATCH 319/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 63af887..49e0fc1 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.6.3" +__version__ = "0.7.0" From 2c7b35033b1019309c3461d624d118e9aa7ff11b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 11 May 2025 21:27:32 +0200 Subject: [PATCH 320/592] Fixed legacy mode download when file exists --- nomadnet/ui/textui/Browser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index b740023..1962714 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1164,20 +1164,20 @@ class Browser: else: file_name = request_receipt.response[0] file_data = request_receipt.response[1] - file_destination_name = file_name.split("/") - file_destination_name = file_destination_name[len(file_destination_name)-1] + file_destination_name = os.path.basename(file_name) file_destination = self.app.downloads_path+"/"+file_destination_name counter = 0 while os.path.isfile(file_destination): counter += 1 - file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter) + file_destination = self.app.downloads_path+"/"+file_destination_name+"."+str(counter) fh = open(file_destination, "wb") fh.write(file_data) fh.close() self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + self.status = Browser.DONE self.response_progress = 0 self.response_speed = None From 58407b9ec46044fbc496933cd5eeaef0e4f12c7e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 12 May 2025 11:51:12 +0200 Subject: [PATCH 321/592] Mask IFAC passphrase in interface view --- nomadnet/ui/textui/Interfaces.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index e0f24a9..85b9b12 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -2223,7 +2223,7 @@ class ShowInterface(urwid.WidgetWrap): if screen_cols >= 145: self.history_length = screen_cols//2-margin else: - self.history_length = screen_cols-margin + self.history_length = screen_cols-(margin+2) # get interface stats interface_stats = self.parent.app.rns.get_interface_stats() @@ -2402,6 +2402,8 @@ class ShowInterface(urwid.WidgetWrap): elif key == "bandwidth": int_value = int(value) value_str = f"{int_value / 1000:.1f} kHz" + elif key == "passphrase": + value_str = len(value)*"*" else: value_str = str(value) # format display keys: "listen_port" => Listen Port From 8e0f782df0e517b0e10e5aaedb234e629d4c450c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 15 May 2025 22:20:03 +0200 Subject: [PATCH 322/592] Updated LXMF dependency --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 879fa6f..e5560a6 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.6", "lxmf>=0.6.3", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=0.9.6", "lxmf>=0.7.1", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From a20b4c9bc360f6dfc118bfb797964de64dc7b90f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 17 May 2025 10:26:59 +0200 Subject: [PATCH 323/592] Added funding --- FUNDING.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 FUNDING.yml diff --git a/FUNDING.yml b/FUNDING.yml new file mode 100644 index 0000000..d125d55 --- /dev/null +++ b/FUNDING.yml @@ -0,0 +1,3 @@ +liberapay: Reticulum +ko_fi: markqvist +custom: "https://unsigned.io/donate" From 2d87214cd0aea9d031a0df148916f967f500e749 Mon Sep 17 00:00:00 2001 From: tothedaring <137025834+tothedaring@users.noreply.github.com> Date: Fri, 23 May 2025 14:35:41 -0400 Subject: [PATCH 324/592] Update NomadNetworkApp.py Update line 1271 (line 253 in config file) to reflect current CUPS options and removing deprecated options per issue 4010 (https://github.com/apple/cups/issues/4010) --- nomadnet/NomadNetworkApp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 81dc68f..3389249 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -1268,7 +1268,7 @@ print_command = lp # This one is more suitable for full-sheet # printers. -# print_command = lp -d PRINTER_NAME -o page-left=36 -o page-top=36 -o page-right=36 -o page-bottom=36 +# print_command = lp -d PRINTER_NAME-o job-priority=100 -o media=Custom.75x75mm -o orientation-requested=3 '''.splitlines() From bf4b9e55aefa54daf55d87e49863108035d645bb Mon Sep 17 00:00:00 2001 From: tothedaring <137025834+tothedaring@users.noreply.github.com> Date: Sun, 25 May 2025 16:01:23 -0400 Subject: [PATCH 325/592] added explanation of CUPS variables and added example for reciept printer --- nomadnet/NomadNetworkApp.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 3389249..73386cf 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -1259,16 +1259,38 @@ print_messages = No print_command = lp # You can specify what printer to use -# print_command = lp -d PRINTER_NAME +# print_command = lp -d [PRINTER_NAME] # Or specify more advanced options. This # example works well for small thermal- -# roll printers. -# print_command = lp -d PRINTER_NAME -o cpi=16 -o lpi=8 +# roll printers: +# print_command = lp -d [PRINTER_NAME] -o cpi=16 -o lpi=8 # This one is more suitable for full-sheet -# printers. -# print_command = lp -d PRINTER_NAME-o job-priority=100 -o media=Custom.75x75mm -o orientation-requested=3 +# printers. It will print a QR code at the center of any media +# your printer will accept, print in portrait mode, and move the message to +# the top of the print queue: +# print_command = lp -d [PRINTER_NAME] -o job-priority=100 -o media=Custom.75x75mm -o orientation-requested=3 + +# But you can modify the size to fit your needs. +# The custom media option accepts millimeters, centimeters, and +# inches in a width by length format like so: +# -o media=Custom.[WIDTH]x[LENGTH][mm,cm,in] +# +# The job priority option accepts 1-100, though you can remove it +# entirely if you aren't concerned with a print queue: +# -o job-priority=[1-100] +# +# Finally, the orientation option allows for 90 degree rotations beginning with 3, so: +# -o orientation-requested=4 (landscape, 90 degrees) +# -o orientation-requested=5 (reverse portrait, 180 degrees) +# +# Here is the full command with the recommended customizable variables: +# print_command = lp -d [PRINTER_NAME] -o job-priority=[N] -o media=[MEDIA_SIZE] -o orientation-requested=[N] -o sides=one-sided + +# For example, here's a configuration for USB thermal printer that uses the POS-58 PPD driver +# with rolls 47.98x209.9mm in size: +# print_command = lp -d [PRINTER_NAME] -o job-priority=100 -o media=custom_47.98x209.9mm_47.98x209.9mm -o sides=one-sided '''.splitlines() From f0c3b898aeca7bd05fb2f0db7efd53139e278a45 Mon Sep 17 00:00:00 2001 From: Linux in a Bit <105567407+LinuxinaBit@users.noreply.github.com> Date: Thu, 29 May 2025 16:50:53 -0500 Subject: [PATCH 326/592] Update Guide.py - Authenticating Users section --- nomadnet/ui/textui/Guide.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 352e71f..93d06ad 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -345,7 +345,7 @@ By default, you can find the examples in `!~/.nomadnetwork/examples`!. If you bu Sometimes, you don't want everyone to be able to view certain pages or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. -To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.allowed". +To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this: From b2f48d585305b0ceb03f51b21740e10c370d5eda Mon Sep 17 00:00:00 2001 From: Dionysis Grigoropoulos Date: Wed, 25 Jun 2025 23:48:26 +0300 Subject: [PATCH 327/592] micronparser: Fix a parsing bug --- nomadnet/ui/textui/MicronParser.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 70f9f0f..00507a7 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -148,6 +148,9 @@ def parse_line(line, state, url_delegate): elif first_char == "-": if len(line) == 2: divider_char = line[1] + # Control characters don't make sense here and otherwise crash nomadnet + if ord(divider_char) < 32: + divider_char = "\u2500" else: divider_char = "\u2500" if state["depth"] == 0: From 34e0bde0b46efc4f7c6d4311beceb46632c5f237 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 13 Jul 2025 15:15:03 +0200 Subject: [PATCH 328/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 49e0fc1..777f190 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.7.0" +__version__ = "0.8.0" diff --git a/setup.py b/setup.py index e5560a6..2a6c01f 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=0.9.6", "lxmf>=0.7.1", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.0.0", "lxmf>=0.8.0", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 3fedd0af30bad0d0a61f92e4e3e8cff4f6f50a94 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 29 Oct 2025 23:19:33 +0100 Subject: [PATCH 329/592] Added separate LXMF PN message size and sync transfer size limits --- nomadnet/Directory.py | 3 ++- nomadnet/NomadNetworkApp.py | 25 +++++++++++++++++++------ nomadnet/_version.py | 2 +- nomadnet/ui/textui/Network.py | 15 +++++++++------ 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index ac03155..b9089f8 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -193,7 +193,8 @@ class Directory: found_node = True break - if not found_node: + # TODO: Remove debug and rethink this (needs way to set PN when node is saved) + if True or not found_node: if self.app.compact_stream: try: remove_announces = [] diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 73386cf..462bcce 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -128,6 +128,7 @@ class NomadNetworkApp: self.notify_on_new_message = True self.lxmf_max_propagation_size = None + self.lxmf_max_sync_size = None self.lxmf_max_incoming_size = None self.periodic_lxmf_sync = True @@ -302,7 +303,7 @@ class NomadNetworkApp: self.message_router = LXMF.LXMRouter( identity = self.identity, storagepath = self.storagepath, autopeer = True, - propagation_limit = self.lxmf_max_propagation_size, delivery_limit = self.lxmf_max_incoming_size, + propagation_limit = self.lxmf_max_propagation_size, sync_limit = self.lxmf_max_sync_size, delivery_limit = self.lxmf_max_incoming_size, max_peers = self.max_peers, static_peers = static_peers, ) @@ -888,6 +889,14 @@ class NomadNetworkApp: value = 1 self.lxmf_max_propagation_size = value + if not "max_sync_size" in self.config["node"]: + self.lxmf_max_sync_size = 256*40 + else: + value = self.config["node"].as_float("max_sync_size") + if value < self.lxmf_max_propagation_size: + value = self.lxmf_max_propagation_size + self.lxmf_max_sync_size = value + if not "announce_at_start" in self.config["node"]: self.node_announce_at_start = False else: @@ -1182,16 +1191,20 @@ disable_propagation = Yes # message_storage_limit = 2000 # The maximum accepted transfer size per in- -# coming propagation transfer, in kilobytes. -# This also sets the upper limit for the size -# of single messages accepted onto this node. +# coming propagation message, in kilobytes. +# This sets the upper limit for the size of +# single messages accepted onto this node. +max_transfer_size = 256 + +# The maximum accepted transfer size per in- +# coming propagation node sync. # # If a node wants to propagate a larger number # of messages to this node, than what can fit # within this limit, it will prioritise sending -# the smallest, newest messages first, and try +# the smallest messages first, and try again # with any remaining messages at a later point. -max_transfer_size = 256 +max_sync_size = 10240 # You can tell the LXMF message router to # prioritise storage for one or more diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 777f190..8088f75 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.8.0" +__version__ = "0.8.1" diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 63f00ec..e59dc1a 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1843,15 +1843,18 @@ class LXMFPeerEntry(urwid.WidgetWrap): style = "list_unresponsive" focus_style = "list_focus_unresponsive" - if peer.propagation_transfer_limit: - txfer_limit = RNS.prettysize(peer.propagation_transfer_limit*1000) - else: - txfer_limit = "No" + if peer.propagation_transfer_limit: txfer_limit = RNS.prettysize(peer.propagation_transfer_limit*1000) + else: txfer_limit = "No" + + if peer.propagation_sync_limit: sync_limit = RNS.prettysize(peer.propagation_sync_limit*1000) + else: sync_limit = "Unknown" + ar = round(peer.acceptance_rate*100, 2) peer_info_str = sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard)) - peer_info_str += "\n "+str(peer.unhandled_message_count)+f" unhandled LXMs, {txfer_limit} sync limit\n" + peer_info_str += f"\n {sync_limit} sync limit, {txfer_limit} msg limit\n" peer_info_str += f" {RNS.prettyspeed(peer.sync_transfer_rate)} STR, " - peer_info_str += f"{RNS.prettyspeed(peer.link_establishment_rate)} LER, {ar}% AR\n" + peer_info_str += f"{RNS.prettyspeed(peer.link_establishment_rate)} LER" + peer_info_str += "\n "+str(peer.unhandled_message_count)+f" unhandled LXMs, {ar}% AR" widget = ListEntry(peer_info_str) self.display_widget = urwid.AttrMap(widget, style, focus_style) self.display_widget.destination_hash = destination_hash From 0baebe5a3cb25c217a0016766506744e26062e1a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 30 Oct 2025 14:09:12 +0100 Subject: [PATCH 330/592] Propagation node stamp cost handling --- nomadnet/ui/textui/Network.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index e59dc1a..dba502b 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1844,16 +1844,22 @@ class LXMFPeerEntry(urwid.WidgetWrap): focus_style = "list_focus_unresponsive" if peer.propagation_transfer_limit: txfer_limit = RNS.prettysize(peer.propagation_transfer_limit*1000) - else: txfer_limit = "No" + else: txfer_limit = "No" if peer.propagation_sync_limit: sync_limit = RNS.prettysize(peer.propagation_sync_limit*1000) - else: sync_limit = "Unknown" + else: sync_limit = "Unknown" + + if peer.propagation_stamp_cost: sct = peer.propagation_stamp_cost + else: sct = "Unknown" + + if peer.propagation_stamp_cost_flexibility: scf = f" (flex {peer.propagation_stamp_cost_flexibility})" + else: scf = "" ar = round(peer.acceptance_rate*100, 2) peer_info_str = sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard)) - peer_info_str += f"\n {sync_limit} sync limit, {txfer_limit} msg limit\n" - peer_info_str += f" {RNS.prettyspeed(peer.sync_transfer_rate)} STR, " - peer_info_str += f"{RNS.prettyspeed(peer.link_establishment_rate)} LER" + peer_info_str += f"\n {sync_limit} sync limit, {txfer_limit} msg limit" + peer_info_str += f"\n {RNS.prettyspeed(peer.sync_transfer_rate)} STR, {RNS.prettyspeed(peer.link_establishment_rate)} LER" + peer_info_str += f"\n {sct} propagation cost{scf}" peer_info_str += "\n "+str(peer.unhandled_message_count)+f" unhandled LXMs, {ar}% AR" widget = ListEntry(peer_info_str) self.display_widget = urwid.AttrMap(widget, style, focus_style) From 025cae6ebfc1598d8b3213494ad1395b43689288 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 30 Oct 2025 14:12:46 +0100 Subject: [PATCH 331/592] Updated version and dependencies --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 8088f75..3e2f46a 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.8.1" +__version__ = "0.9.0" diff --git a/setup.py b/setup.py index 2a6c01f..c83d543 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.0.0", "lxmf>=0.8.0", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.0.1", "lxmf>=0.9.0", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 373315423ee29db4483088eab3f6938f30ffac2d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 1 Nov 2025 17:49:39 +0100 Subject: [PATCH 332/592] Updated config file descriptions. Ensure propagation node is disabled by default. --- nomadnet/NomadNetworkApp.py | 21 +++++++++++++++------ nomadnet/ui/textui/Network.py | 2 +- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 462bcce..80d0037 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -124,7 +124,7 @@ class NomadNetworkApp: self.peer_announce_at_start = True self.try_propagation_on_fail = True - self.disable_propagation = False + self.disable_propagation = True self.notify_on_new_message = True self.lxmf_max_propagation_size = None @@ -877,7 +877,7 @@ class NomadNetworkApp: self.node_name = self.config["node"]["node_name"] if not "disable_propagation" in self.config["node"]: - self.disable_propagation = False + self.disable_propagation = True else: self.disable_propagation = self.config["node"].as_bool("disable_propagation") @@ -1171,10 +1171,19 @@ announce_at_start = Yes # When Nomad Network is hosting a page-serving # node, it can also act as an LXMF propagation -# node. If there is already a large amount of +# node. This is a convenient feature that lets +# you easily set up and run a propagation node +# on the network, but it is not as fully +# featured as using the lxmd program to host a +# propagation node. For complete control and +# flexibility, use lxmd to run a PN. For a +# small local system or network, the built-in +# PN functionality will suffice for most cases. +# +# If there is already a large amount of # propagation nodes on the network, or you # simply want to run a pageserving-only node, -# you can disable running a propagation node. +# you should disable running a propagation node. # Due to lots of propagation nodes being # available, this is currently the default. disable_propagation = Yes @@ -1217,8 +1226,8 @@ max_sync_size = 10240 # You can configure the maximum number of other # propagation nodes that this node will peer -# with automatically. The default is 50. -# max_peers = 25 +# with automatically. The default is 20. +# max_peers = 20 # You can configure a list of static propagation # node peers, that this node will always be diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index dba502b..6e6e23a 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1859,7 +1859,7 @@ class LXMFPeerEntry(urwid.WidgetWrap): peer_info_str = sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard)) peer_info_str += f"\n {sync_limit} sync limit, {txfer_limit} msg limit" peer_info_str += f"\n {RNS.prettyspeed(peer.sync_transfer_rate)} STR, {RNS.prettyspeed(peer.link_establishment_rate)} LER" - peer_info_str += f"\n {sct} propagation cost{scf}" + peer_info_str += f"\n Propagation cost {sct}{scf}" peer_info_str += "\n "+str(peer.unhandled_message_count)+f" unhandled LXMs, {ar}% AR" widget = ListEntry(peer_info_str) self.display_widget = urwid.AttrMap(widget, style, focus_style) From ad2cefa32902dbdc95da9ad26104d1b1f86643ac Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 1 Nov 2025 18:27:47 +0100 Subject: [PATCH 333/592] Allow configuring propagation node stamp cost --- nomadnet/Directory.py | 6 ++-- nomadnet/NomadNetworkApp.py | 56 ++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index b9089f8..0286084 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -6,6 +6,8 @@ import nomadnet import threading import RNS.vendor.umsgpack as msgpack +from LXMF import pn_announce_data_is_valid + class PNAnnounceHandler: def __init__(self, owner): self.aspect_filter = "lxmf.propagation" @@ -13,10 +15,10 @@ class PNAnnounceHandler: def received_announce(self, destination_hash, announced_identity, app_data): try: - if type(app_data) == bytes: + if pn_announce_data_is_valid(app_data): data = msgpack.unpackb(app_data) - if data[0] == True: + if data[2] == True: RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash)) associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 80d0037..2680986 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -130,6 +130,7 @@ class NomadNetworkApp: self.lxmf_max_propagation_size = None self.lxmf_max_sync_size = None self.lxmf_max_incoming_size = None + self.node_propagation_cost = LXMF.LXMRouter.PROPAGATION_COST self.periodic_lxmf_sync = True self.lxmf_sync_interval = 360*60 @@ -304,7 +305,7 @@ class NomadNetworkApp: self.message_router = LXMF.LXMRouter( identity = self.identity, storagepath = self.storagepath, autopeer = True, propagation_limit = self.lxmf_max_propagation_size, sync_limit = self.lxmf_max_sync_size, delivery_limit = self.lxmf_max_incoming_size, - max_peers = self.max_peers, static_peers = static_peers, + max_peers = self.max_peers, static_peers = static_peers, propagation_cost=self.node_propagation_cost ) self.message_router.register_delivery_callback(self.lxmf_delivery) @@ -910,6 +911,13 @@ class NomadNetworkApp: if value < 1: value = 1 self.node_announce_interval = value + + if not "propagation_cost" in self.config["node"]: + self.node_propagation_cost = 16 + else: + value = self.config["node"].as_int("propagation_cost") + if value < 13: value = 13 + self.node_propagation_cost = value if "pages_path" in self.config["node"]: self.pagespath = self.config["node"]["pages_path"] @@ -1186,8 +1194,40 @@ announce_at_start = Yes # you should disable running a propagation node. # Due to lots of propagation nodes being # available, this is currently the default. + disable_propagation = Yes +# For clients and other propagation nodes +# delivering messages via this node, you can +# configure the minimum required propagation +# stamp costs. All messages delivered to the +# propagation node network must have a valid +# propagation stamp, or they will be rejected. +# Clients automatically detect the stamp cost +# for the node they are delivering to, and +# compute a corresponding stamp before trying +# to deliver the message to the propagation +# node. +# +# Propagation stamps are easier to verify in +# large batches, and therefore also somewhat +# easier to compute for the senders. As such, +# a reasonable propagation stamp cost should +# be a bit higher than the normal peer-to-peer +# stamp costs. +# +# Propagation stamps does not incur any extra +# load for propagation nodes processing them, +# since they are only required to verify that +# they are correct, and only the generation +# is computationally costly. Setting a sensible +# propagation stamp cost (and periodically +# checking the average network consensus) helps +# keep spam and misuse out of the propagation +# node network. + +propagation_cost = 16 + # The maximum amount of storage to use for # the LXMF Propagation Node message store, # specified in megabytes. When this limit @@ -1197,12 +1237,14 @@ disable_propagation = Yes # new and small. Large and old messages will # be removed first. This setting is optional # and defaults to 2 gigabytes. + # message_storage_limit = 2000 # The maximum accepted transfer size per in- # coming propagation message, in kilobytes. # This sets the upper limit for the size of # single messages accepted onto this node. + max_transfer_size = 256 # The maximum accepted transfer size per in- @@ -1213,6 +1255,7 @@ max_transfer_size = 256 # within this limit, it will prioritise sending # the smallest messages first, and try again # with any remaining messages at a later point. + max_sync_size = 10240 # You can tell the LXMF message router to @@ -1222,29 +1265,34 @@ max_sync_size = 10240 # keeping messages for destinations specified # with this option. This setting is optional, # and generally you do not need to use it. + # prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf # You can configure the maximum number of other # propagation nodes that this node will peer # with automatically. The default is 20. + # max_peers = 20 # You can configure a list of static propagation # node peers, that this node will always be # peered with, by specifying a list of # destination hashes. + # static_peers = e17f833c4ddf8890dd3a79a6fea8161d, 5a2d0029b6e5ec87020abaea0d746da4 # You can specify the interval in minutes for # rescanning the hosted pages path. By default, # this option is disabled, and the pages path # will only be scanned on startup. + # page_refresh_interval = 0 # You can specify the interval in minutes for # rescanning the hosted files path. By default, # this option is disabled, and the files path # will only be scanned on startup. + # file_refresh_interval = 0 [printing] @@ -1253,6 +1301,7 @@ max_sync_size = 10240 # various kinds of information and messages. # Printing messages is disabled by default + print_messages = No # You can configure a custom template for @@ -1260,24 +1309,29 @@ print_messages = No # option, set a path to the template and # restart Nomad Network, a default template # will be created that you can edit. + # message_template = ~/.nomadnetwork/print_template_msg.txt # You can configure Nomad Network to only # print messages from trusted destinations. + # print_from = trusted # Or specify the source LXMF addresses that # will automatically have messages printed # on arrival. + # print_from = 76fe5751a56067d1e84eef3e88eab85b, 0e70b5848eb57c13154154feaeeb89b7 # Or allow printing from anywhere, if you # are feeling brave and adventurous. + # print_from = everywhere # You can configure the printing command. # This will use the default CUPS printer on # your system. + print_command = lp # You can specify what printer to use From e6688b157e18b25d30220e88f7a6fac5aa88e959 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 3 Nov 2025 12:38:07 +0100 Subject: [PATCH 334/592] Ensure static_peers is initialized regardless of config --- nomadnet/NomadNetworkApp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 2680986..ff554e6 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -122,6 +122,7 @@ class NomadNetworkApp: self.page_refresh_interval = 0 self.file_refresh_interval = 0 + self.static_peers = [] self.peer_announce_at_start = True self.try_propagation_on_fail = True self.disable_propagation = True From eac9021c75ce7f3b5c0ba5b5f49570d51c36b463 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 3 Nov 2025 22:31:34 +0100 Subject: [PATCH 335/592] Updated required LXMF to 0.9.1 --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 3e2f46a..d69d16e 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.0" +__version__ = "0.9.1" diff --git a/setup.py b/setup.py index c83d543..fa80b3c 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.0.1", "lxmf>=0.9.0", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.0.1", "lxmf>=0.9.1", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 6d2bf21f0daf0ad7d5205b0f55f211b4559485cc Mon Sep 17 00:00:00 2001 From: Zenith Date: Fri, 12 Dec 2025 19:21:03 -0500 Subject: [PATCH 336/592] Strip whitespace from LXMF address and URL dialog --- nomadnet/ui/textui/Browser.py | 2 +- nomadnet/ui/textui/Conversations.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 1962714..f1b28ca 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -697,7 +697,7 @@ class Browser: def confirmed(sender): try: - self.retrieve_url(e_url.get_edit_text()) + self.retrieve_url(e_url.get_edit_text().strip()) except Exception as e: self.browser_footer = urwid.Text("Could not open link: "+str(e)) self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 9a5405d..94c5015 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -331,7 +331,7 @@ class ConversationsDisplay(): existing_conversations = nomadnet.Conversation.conversation_list(self.app) display_name = e_name.get_edit_text() - source_hash_text = e_id.get_edit_text() + source_hash_text = e_id.get_edit_text().strip() source_hash = bytes.fromhex(source_hash_text) trust_level = DirectoryEntry.UNTRUSTED if r_unknown.state == True: @@ -412,7 +412,7 @@ class ConversationsDisplay(): try: local_delivery_signal = "local_delivery_occurred" duplicate_signal = "duplicate_lxm" - lxm_uri = e_uri.get_edit_text() + lxm_uri = e_uri.get_edit_text().strip() ingest_result = self.app.message_router.ingest_lxm_uri( lxm_uri, From abde448e0099a34e8553907e9ad7d27c97603267 Mon Sep 17 00:00:00 2001 From: Zenith Date: Fri, 12 Dec 2025 20:50:11 -0500 Subject: [PATCH 337/592] Add count and search options to announce stream. Add toggle for destination and announce data on list. Fix list dialog closing on new announce --- nomadnet/ui/textui/Network.py | 92 +++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 6e6e23a..718d0a8 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -250,7 +250,7 @@ class AnnounceInfo(urwid.WidgetWrap): class AnnounceStreamEntry(urwid.WidgetWrap): - def __init__(self, app, announce, delegate): + def __init__(self, app, announce, delegate, show_destination=False): full_time_format = "%Y-%m-%d %H:%M:%S" date_time_format = "%Y-%m-%d" time_time_format = "%H:%M:%S" @@ -274,7 +274,16 @@ class AnnounceStreamEntry(urwid.WidgetWrap): ts_string = dt.strftime(date_only_format) trust_level = self.app.directory.trust_level(source_hash) - display_str = self.app.directory.simplest_display_str(source_hash) + + if show_destination: + display_str = RNS.hexrep(source_hash, delimit=False) + else: + try: + display_str = announce[2].decode("utf-8") + if len(display_str) > 32: + display_str = display_str[:32] + "..." + except: + display_str = self.app.directory.simplest_display_str(source_hash) if trust_level == DirectoryEntry.UNTRUSTED: symbol = g["cross"] @@ -381,22 +390,33 @@ class AnnounceStream(urwid.WidgetWrap): self.ilb = None self.no_content = True self.current_tab = "nodes" + self.show_destination = False + self.search_text = "" self.added_entries = [] self.widget_list = [] - self.update_widget_list() - # Create tab buttons - self.tab_nodes = TabButton("Nodes", on_press=self.show_nodes_tab) - self.tab_peers = TabButton("Peers", on_press=self.show_peers_tab) - self.tab_pn = TabButton("Propagation Nodes", on_press=self.show_pn_tab) + self.tab_nodes = TabButton("Nodes (0)", on_press=self.show_nodes_tab) + self.tab_peers = TabButton("Peers (0)", on_press=self.show_peers_tab) + self.tab_pn = TabButton("Propagation Nodes (0)", on_press=self.show_pn_tab) - # Create tab bar with proportional widths self.tab_bar = urwid.Columns([ ('weight', 1, self.tab_nodes), ('weight', 1, self.tab_peers), ('weight', 3, self.tab_pn), - ], dividechars=1) # Add 1 character spacing between tabs + ], dividechars=1) + + self.search_edit = urwid.Edit(caption="Search: ") + urwid.connect_signal(self.search_edit, 'change', self.on_search_change) + + self.display_toggle = TabButton("Show: Name", on_press=self.toggle_display_mode) + + self.filter_bar = urwid.Columns([ + ('weight', 2, self.search_edit), + ('weight', 1, self.display_toggle), + ], dividechars=1) + + self.update_widget_list() self.ilb = ExceptionHandlingListBox( self.widget_list, @@ -406,9 +426,9 @@ class AnnounceStream(urwid.WidgetWrap): #highlight_offFocus="list_off_focus" ) - # Combine tab bar and list box self.pile = urwid.Pile([ ('pack', self.tab_bar), + ('pack', self.filter_bar), ('weight', 1, self.ilb), ]) @@ -423,6 +443,18 @@ class AnnounceStream(urwid.WidgetWrap): return super(AnnounceStream, self).keypress(size, key) + def on_search_change(self, widget, text): + self.search_text = text.lower() + self.update_widget_list() + + def toggle_display_mode(self, button): + self.show_destination = not self.show_destination + if self.show_destination: + self.display_toggle.set_label("Show: Dest") + else: + self.display_toggle.set_label("Show: Name") + self.update_widget_list() + def delete_selected_entry(self): if self.ilb.get_selected_item() != None: self.app.directory.remove_announce_with_timestamp(self.ilb.get_selected_item().original_widget.timestamp) @@ -438,19 +470,36 @@ class AnnounceStream(urwid.WidgetWrap): self.widget_list = [] new_entries = [] + node_count = 0 + peer_count = 0 + pn_count = 0 + for e in self.app.directory.announce_stream: announce_type = e[3] - # Filter based on current tab - if self.current_tab == "nodes" and (announce_type == "node" or announce_type == True): - new_entries.append(e) - elif self.current_tab == "peers" and (announce_type == "peer" or announce_type == False): - new_entries.append(e) - elif self.current_tab == "pn" and announce_type == "pn": - new_entries.append(e) + if self.search_text: + try: + announce_data = e[2].decode("utf-8").lower() + except: + announce_data = "" + if self.search_text not in announce_data: + continue + + if announce_type == "node" or announce_type == True: + node_count += 1 + if self.current_tab == "nodes": + new_entries.append(e) + elif announce_type == "peer" or announce_type == False: + peer_count += 1 + if self.current_tab == "peers": + new_entries.append(e) + elif announce_type == "pn": + pn_count += 1 + if self.current_tab == "pn": + new_entries.append(e) for e in new_entries: - nw = AnnounceStreamEntry(self.app, e, self) + nw = AnnounceStreamEntry(self.app, e, self, show_destination=self.show_destination) nw.timestamp = e[0] self.widget_list.append(nw) @@ -460,6 +509,10 @@ class AnnounceStream(urwid.WidgetWrap): self.no_content = True self.widget_list = [urwid.Text(f"No {self.current_tab} announces", align='center')] + self.tab_nodes.set_label(f"Nodes ({node_count})") + self.tab_peers.set_label(f"Peers ({peer_count})") + self.tab_pn.set_label(f"Propagation Nodes ({pn_count})") + if self.ilb: self.ilb.set_body(self.widget_list) @@ -1648,7 +1701,6 @@ class NetworkDisplay(): def reinit_known_nodes(self): self.known_nodes_display = KnownNodes(self.app) self.known_nodes_display.delegate = self - self.close_list_dialogs() self.announce_stream_display.rebuild_widget_list() def reinit_lxmf_peers(self): @@ -1908,4 +1960,4 @@ def pretty_date(time=False): return str(int(day_diff / 7)) + " weeks ago" if day_diff < 365: return str(int(day_diff / 30)) + " months ago" - return str(int(day_diff / 365)) + " years ago" + return str(int(day_diff / 365)) + " years ago" \ No newline at end of file From 9c79496504e8809e94d8c34520327c36b2c0202d Mon Sep 17 00:00:00 2001 From: Belousov Oleg Date: Fri, 19 Dec 2025 23:50:02 +0300 Subject: [PATCH 338/592] Fixed work from compiled pyc files --- nomadnet/__init__.py | 6 ++++-- nomadnet/ui/__init__.py | 6 ++++-- nomadnet/ui/textui/__init__.py | 6 ++++-- nomadnet/vendor/__init__.py | 6 ++++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/nomadnet/__init__.py b/nomadnet/__init__.py index 8757b04..eb90424 100644 --- a/nomadnet/__init__.py +++ b/nomadnet/__init__.py @@ -8,8 +8,10 @@ from .Node import Node from .ui import * -modules = glob.glob(os.path.dirname(__file__)+"/*.py") -__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) def panic(): os._exit(255) \ No newline at end of file diff --git a/nomadnet/ui/__init__.py b/nomadnet/ui/__init__.py index fbafc03..b11aadb 100644 --- a/nomadnet/ui/__init__.py +++ b/nomadnet/ui/__init__.py @@ -3,8 +3,10 @@ import glob import RNS import nomadnet -modules = glob.glob(os.path.dirname(__file__)+"/*.py") -__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) UI_NONE = 0x00 diff --git a/nomadnet/ui/textui/__init__.py b/nomadnet/ui/textui/__init__.py index e3164b0..72302cb 100644 --- a/nomadnet/ui/textui/__init__.py +++ b/nomadnet/ui/textui/__init__.py @@ -1,5 +1,7 @@ import os import glob -modules = glob.glob(os.path.dirname(__file__)+"/*.py") -__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) diff --git a/nomadnet/vendor/__init__.py b/nomadnet/vendor/__init__.py index e3164b0..72302cb 100644 --- a/nomadnet/vendor/__init__.py +++ b/nomadnet/vendor/__init__.py @@ -1,5 +1,7 @@ import os import glob -modules = glob.glob(os.path.dirname(__file__)+"/*.py") -__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) From a2e6a06a356b1ef4cabbc9143a976a62464c8212 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 22 Dec 2025 11:18:21 +0100 Subject: [PATCH 339/592] Added warning and explanation on corrupt peer settings file --- nomadnet/NomadNetworkApp.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index ff554e6..80323a8 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -245,8 +245,10 @@ class NomadNetworkApp: self.peer_settings["served_file_requests"] = 0 except Exception as e: - RNS.log("Could not load local peer settings from "+self.peersettingspath, RNS.LOG_ERROR) - RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) + RNS.log(f"Could not load local peer settings from {self.peersettingspath}", RNS.LOG_ERROR) + RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR) + RNS.log(f"This likely means that the peer settings file has become corrupt.", RNS.LOG_ERROR) + RNS.log(f"You can try deleting the file at {self.peersettingspath} and restarting nomadnet.", RNS.LOG_ERROR) nomadnet.panic() else: try: From 5253cccfa7278a33ffd7f781a438a6040cfd8a02 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 22 Dec 2025 11:27:05 +0100 Subject: [PATCH 340/592] Save peer settings file using write-then-copy --- nomadnet/NomadNetworkApp.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 80323a8..69a4fe0 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -245,6 +245,7 @@ class NomadNetworkApp: self.peer_settings["served_file_requests"] = 0 except Exception as e: + RNS.logdest = RNS.LOG_STDOUT RNS.log(f"Could not load local peer settings from {self.peersettingspath}", RNS.LOG_ERROR) RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR) RNS.log(f"This likely means that the peer settings file has become corrupt.", RNS.LOG_ERROR) @@ -560,9 +561,9 @@ class NomadNetworkApp: return self.message_router.get_outbound_propagation_node() def save_peer_settings(self): - file = open(self.peersettingspath, "wb") - file.write(msgpack.packb(self.peer_settings)) - file.close() + tmp_path = f"{self.peersettingspath}.tmp" + with open(tmp_path, "wb") as file: file.write(msgpack.packb(self.peer_settings)) + os.replace(tmp_path, self.peersettingspath) def lxmf_delivery(self, message): time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) From db3642ee05f4cd673d8573a03445263ce60e5129 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 22 Dec 2025 11:41:59 +0100 Subject: [PATCH 341/592] Parse intro_time as float. Fixes #79. --- nomadnet/NomadNetworkApp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 69a4fe0..9cf19b8 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -808,7 +808,7 @@ class NomadNetworkApp: if not "intro_time" in self.config["textui"]: self.config["textui"]["intro_time"] = 1 else: - self.config["textui"]["intro_time"] = self.config["textui"].as_int("intro_time") + self.config["textui"]["intro_time"] = self.config["textui"].as_float("intro_time") if not "intro_text" in self.config["textui"]: self.config["textui"]["intro_text"] = "Nomad Network" From dbd1d87adbb3102922d1ab6aa188175b95649fd8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 22 Dec 2025 12:42:32 +0100 Subject: [PATCH 342/592] Fixed micron fg/bg color header parsing. --- nomadnet/ui/textui/Browser.py | 12 ++++++------ nomadnet/ui/textui/MicronParser.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index f1b28ca..4ff35d8 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -799,7 +799,7 @@ class Browser: self.page_background_color = None bgpos = self.markup.find("#!bg=") - if bgpos: + if bgpos >= 0: endpos = self.markup.find("\n", bgpos) if endpos-(bgpos+5) == 3: bg = self.markup[bgpos+5:endpos] @@ -807,7 +807,7 @@ class Browser: self.page_foreground_color = None fgpos = self.markup.find("#!fg=") - if fgpos: + if fgpos >= 0: endpos = self.markup.find("\n", fgpos) if endpos-(fgpos+5) == 3: fg = self.markup[fgpos+5:endpos] @@ -866,7 +866,7 @@ class Browser: self.page_background_color = None bgpos = self.markup.find("#!bg=") - if bgpos: + if bgpos >= 0: endpos = self.markup.find("\n", bgpos) if endpos-(bgpos+5) == 3: bg = self.markup[bgpos+5:endpos] @@ -874,7 +874,7 @@ class Browser: self.page_foreground_color = None fgpos = self.markup.find("#!fg=") - if fgpos: + if fgpos >= 0: endpos = self.markup.find("\n", fgpos) if endpos-(fgpos+5) == 3: fg = self.markup[fgpos+5:endpos] @@ -1018,7 +1018,7 @@ class Browser: self.page_background_color = None bgpos = self.markup.find("#!bg=") - if bgpos: + if bgpos >= 0: endpos = self.markup.find("\n", bgpos) if endpos-(bgpos+5) == 3: bg = self.markup[bgpos+5:endpos] @@ -1026,7 +1026,7 @@ class Browser: self.page_foreground_color = None fgpos = self.markup.find("#!fg=") - if fgpos: + if fgpos >= 0: endpos = self.markup.find("\n", fgpos) if endpos-(fgpos+5) == 3: fg = self.markup[fgpos+5:endpos] diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 00507a7..34915a5 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -649,7 +649,7 @@ def make_output(state, line, url_delegate, pre_escape=False): orig_spec = speclist[4] if url_delegate != None: - linkspec = LinkSpec(link_url, orig_spec) + linkspec = LinkSpec(link_url, orig_spec, cm=cm) if link_fields != "": lf = link_fields.split("|") if len(lf) > 0: @@ -696,11 +696,11 @@ def make_output(state, line, url_delegate, pre_escape=False): class LinkSpec(urwid.AttrSpec): - def __init__(self, link_target, orig_spec): + def __init__(self, link_target, orig_spec, cm=256): self.link_target = link_target self.link_fields = None - super().__init__(orig_spec.foreground, orig_spec.background) + super().__init__(orig_spec.foreground, orig_spec.background, colors=cm) class LinkableText(urwid.Text): From d4a3a91e045dc7eb31c89d5881b6ad3cfa81b14f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 22 Dec 2025 12:51:31 +0100 Subject: [PATCH 343/592] Updated micron text aligment parsing. Fixes #83. --- nomadnet/ui/textui/MicronParser.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 34915a5..4ab0b46 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -487,20 +487,14 @@ def make_output(state, line, url_delegate, pre_escape=False): state["bg_color"] = state["default_bg"] state["align"] = state["default_align"] elif c == "c": - if state["align"] != "center": - state["align"] = "center" - else: - state["align"] = state["default_align"] + if state["align"] != "center": state["align"] = "center" + # else: state["align"] = state["default_align"] elif c == "l": - if state["align"] != "left": - state["align"] = "left" - else: - state["align"] = state["default_align"] + if state["align"] != "left": state["align"] = "left" + # else: state["align"] = state["default_align"] elif c == "r": - if state["align"] != "right": - state["align"] = "right" - else: - state["align"] = state["default_align"] + if state["align"] != "right": state["align"] = "right" + # else: state["align"] = state["default_align"] elif c == "a": state["align"] = state["default_align"] From cb87148ec3b52b126abbea576c18efb81400e76a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 22 Dec 2025 13:08:39 +0100 Subject: [PATCH 344/592] Updated dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fa80b3c..cf006ea 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.0.1", "lxmf>=0.9.1", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.0.4", "lxmf>=0.9.3", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From c9ca0a2fd17257f14951033e3c501781ea3ec074 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 22 Dec 2025 13:09:17 +0100 Subject: [PATCH 345/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index d69d16e..a2fecb4 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.1" +__version__ = "0.9.2" From 5de1b93fd92cd8d363f239416181c673344c1a48 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 22 Dec 2025 22:28:42 +0100 Subject: [PATCH 346/592] Updated readme --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 416eb7c..52a8566 100755 --- a/README.md +++ b/README.md @@ -148,14 +148,17 @@ You can help support the continued development of open, free and private communi ``` - Bitcoin ``` - bc1p4a6axuvl7n9hpapfj8sv5reqj8kz6uxa67d5en70vzrttj0fmcusgxsfk5 + bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6 ``` - Ethereum ``` - 0xae89F3B94fC4AD6563F0864a55F9a697a90261ff + 0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E ``` +- Liberapay: https://liberapay.com/Reticulum/ + - Ko-Fi: https://ko-fi.com/markqvist + ## Development Roadmap - New major features @@ -171,8 +174,6 @@ You can help support the continued development of open, free and private communi - Better navigation handling when requests fail (also because of closed links) - Retry failed messages mechanism - Re-arrange buttons to be more consistent - - Input field for pages - - Post mechanism - Term compatibility notice in readme - Selected icon in conversation list - Possibly a Search Local Nodes function From 3e7e55a9ca92bbc789994f49782ac4defad71b74 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 27 Dec 2025 12:25:25 +0100 Subject: [PATCH 347/592] Fixed announce stream keyboard navigation --- nomadnet/ui/textui/Network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 718d0a8..3edf31b 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -436,7 +436,7 @@ class AnnounceStream(urwid.WidgetWrap): super().__init__(urwid.LineBox(self.display_widget, title="Announce Stream")) def keypress(self, size, key): - if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): + if key == "up" and self.pile.focus == self.tab_bar: nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() From 5ceb0c671e4a2020efaa627d3f118af76ebcc5c4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 27 Dec 2025 13:29:10 +0100 Subject: [PATCH 348/592] Fixed composite unicode characters breaking text alignment rendering --- nomadnet/Directory.py | 9 +++++---- nomadnet/ui/textui/Browser.py | 7 ++++--- nomadnet/ui/textui/Helpers.py | 0 nomadnet/ui/textui/Network.py | 5 +++-- nomadnet/util.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 nomadnet/ui/textui/Helpers.py create mode 100644 nomadnet/util.py diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 0286084..5e33576 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -7,6 +7,7 @@ import threading import RNS.vendor.umsgpack as msgpack from LXMF import pn_announce_data_is_valid +from nomadnet.util import strip_modifiers class PNAnnounceHandler: def __init__(self, owner): @@ -229,7 +230,7 @@ class Directory: def display_name(self, source_hash): if source_hash in self.directory_entries: - return self.directory_entries[source_hash].display_name + return strip_modifiers(self.directory_entries[source_hash].display_name) else: return None @@ -241,7 +242,7 @@ class Directory: if dn == None: return RNS.prettyhexrep(source_hash) else: - return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">" + return strip_modifiers(dn)+" <"+RNS.hexrep(source_hash, delimit=False)+">" else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" else: @@ -250,13 +251,13 @@ class Directory: if dn == None: return RNS.prettyhexrep(source_hash) else: - return dn + return strip_modifiers(dn) else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" def alleged_display_str(self, source_hash): if source_hash in self.directory_entries: - return self.directory_entries[source_hash].display_name + return strip_modifiers(self.directory_entries[source_hash].display_name) else: return None diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 4ff35d8..ef37b61 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -11,6 +11,7 @@ import threading from .MicronParser import markup_to_attrmaps, make_style, default_state from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.Scrollable import * +from nomadnet.util import strip_modifiers class BrowserFrame(urwid.Frame): def keypress(self, size, key): @@ -813,7 +814,7 @@ class Browser: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) self.response_progress = 0 self.response_speed = None @@ -880,7 +881,7 @@ class Browser: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) self.response_progress = 0 self.response_speed = None @@ -1032,7 +1033,7 @@ class Browser: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) self.response_progress = 0 self.response_speed = None self.progress_updated_at = None diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py new file mode 100644 index 0000000..e69de29 diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 3edf31b..242118d 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -6,6 +6,7 @@ import threading from datetime import datetime from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY +from nomadnet.util import strip_modifiers from .Browser import Browser @@ -279,7 +280,7 @@ class AnnounceStreamEntry(urwid.WidgetWrap): display_str = RNS.hexrep(source_hash, delimit=False) else: try: - display_str = announce[2].decode("utf-8") + display_str = strip_modifiers(announce[2].decode("utf-8")) if len(display_str) > 32: display_str = display_str[:32] + "..." except: @@ -608,7 +609,7 @@ class KnownNodeInfo(urwid.WidgetWrap): if node_entry == None: display_str = self.app.directory.simplest_display_str(source_hash) else: - display_str = node_entry.display_name + display_str = strip_modifiers(node_entry.display_name) addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" diff --git a/nomadnet/util.py b/nomadnet/util.py new file mode 100644 index 0000000..d1d38c4 --- /dev/null +++ b/nomadnet/util.py @@ -0,0 +1,30 @@ +import re +import unicodedata +import RNS + +def strip_modifiers(text): + def process_characters(text): + result = [] + i = 0 + while i < len(text): + char = text[i] + category = unicodedata.category(char) + + if category.startswith(('L', 'N', 'P', 'S')): + result.append(char) + i += 1 + elif category.startswith(('M', 'Sk', 'Cf')) or char in '\u200d\u200c': + i += 1 + else: + result.append(char) + i += 1 + + return ''.join(result) + + stripped = process_characters(text) + stripped = re.sub(r'[\uFE00-\uFE0F]', '', stripped) + stripped = re.sub(r'[\U000E0100-\U000E01EF]', '', stripped, flags=re.UNICODE) + stripped = re.sub(r'[\U0001F3FB-\U0001F3FF]', '', stripped, flags=re.UNICODE) + stripped = re.sub(r'[\u200D\u200C]', '', stripped) + + return stripped From 7ed85ad398e0f4c3338c3bfb745ab1d756d951f7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 27 Dec 2025 13:30:11 +0100 Subject: [PATCH 349/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index a2fecb4..c598173 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.2" +__version__ = "0.9.3" From 93cc790ebdc3d96123be87e4db7d930869c3b571 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 28 Dec 2025 01:02:17 +0100 Subject: [PATCH 350/592] Updated readme --- MIRROR.md | 33 +++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 35 insertions(+) create mode 100644 MIRROR.md diff --git a/MIRROR.md b/MIRROR.md new file mode 100644 index 0000000..ef2fdd4 --- /dev/null +++ b/MIRROR.md @@ -0,0 +1,33 @@ +This repository is a public mirror. All potential future development is happening elsewhere. + +I am stepping back from all public-facing interaction with this project. Reticulum has always been primarily my work, and continuing in the current public, internet-facing model is no longer sustainable. + +The software remains available for use as-is. Occasional updates may appear at unpredictable intervals, but there will be no support, no responses to issues, no discussions, and no community management in this or any other public venue. If it doesn't work for you, it doesn't work. That is the entire extent of available troubleshooting assistance I can offer you. + +If you've followed this project for a while, you already know what this means. You know who designed, wrote and tested this, and you know how many years of my life it took. You'll also know about both my particular challenges and strengths, and how I believe anything worth building needs to be built and maintained with our own hands. + +Seven months ago, I said I needed to step back, that I was exhausted, and that I needed to recover. I believed a public resolve would be enough to effectuate that, but while striving to get just a few more useful features and protocols out, the unproductive requests and demands also ramped up, and I got pulled back into the same patterns and draining interactions that I'd explicitly said I couldn't sustain anymore. + +So here's what you might have already guessed: I'm done playing the game by rules I can't win at. + +Everything you need is right here, and by any sensible measure, it's done. Anyone who wants to invest the time, skill and persistence can build on it, or completely re-imagine it with different priorities. That was always the point. + +The people who actually contributed - you know who you are, and you know I mean it when I say: Thank you. All of you who've used this to build something real - that was the goal, and you did it without needing me to hold your hand. + +The rest of you: You have what you need. Use it or don't. I am not going to be the person who explains it to you anymore. + +This is not a temporary break. It's not "see you after some rest", but a recognition that the current model is fundamentally incompatible with my life, my health, and my reality. + +If you want to support continued work, you can do so at the donation links listed in this repository. But please understand, that this is not purchasing support or guaranteeing updates. It is support for work that happens on my timeline, according to my capacity, which at the moment is not what it was. + +If you want Reticulum to continue evolving, you have the power to make that happen. The protocol is public domain. The code is open source. Everything you need is right here. I've provided the tools, but building what comes next is not my responsibility anymore. It's yours. + +To the small group of people who has actually been here, and understood what this work was and what it cost - you already know where to find me if it actually matters. + +To everyone else: This is where we part ways. No hard feelings. It's just time. + +--- + +असतो मा सद्गमय +तमसो मा ज्योतिर्गमय +मृत्योर्मा अमृतं गमय diff --git a/README.md b/README.md index 52a8566..b721ea6 100755 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Nomad Network - Communicate Freely +*This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.* + Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. ![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) From 5f3b2eb020da1b2bd21d2923e7073a541e03c11c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 28 Dec 2025 11:39:15 +0100 Subject: [PATCH 351/592] Convert Windows line endings to UNIX format, fixes display issues with micron files created on Windows --- nomadnet/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/util.py b/nomadnet/util.py index d1d38c4..8f077f6 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -1,6 +1,5 @@ import re import unicodedata -import RNS def strip_modifiers(text): def process_characters(text): @@ -26,5 +25,6 @@ def strip_modifiers(text): stripped = re.sub(r'[\U000E0100-\U000E01EF]', '', stripped, flags=re.UNICODE) stripped = re.sub(r'[\U0001F3FB-\U0001F3FF]', '', stripped, flags=re.UNICODE) stripped = re.sub(r'[\u200D\u200C]', '', stripped) + stripped = re.sub(r'\r\n?', '\n', stripped) return stripped From 35910343fed9dcc5118150b6aa14d0aa6a99fb13 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 28 Dec 2025 11:40:05 +0100 Subject: [PATCH 352/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index c598173..e94731c 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.3" +__version__ = "0.9.4" From c4bf97dae2a7cace7af865f6444931c5080d6859 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 28 Dec 2025 23:41:08 +0100 Subject: [PATCH 353/592] Fixed cleanup race condition on browser page close --- nomadnet/ui/textui/Browser.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index ef37b61..ab9f069 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -37,19 +37,20 @@ class BrowserFrame(urwid.Frame): if hasattr(self.delegate, "page_pile") and self.delegate.page_pile: def df(loop, user_data): st = None - nf = self.delegate.page_pile.focus - if hasattr(nf, "key_timeout"): - st = nf - elif hasattr(nf, "original_widget"): - no = nf.original_widget - if hasattr(no, "original_widget"): - st = no.original_widget - else: - if hasattr(no, "key_timeout"): - st = no - - if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): - st.keypress(None, None) + if self.delegate.page_pile: + nf = self.delegate.page_pile.focus + if hasattr(nf, "key_timeout"): + st = nf + elif hasattr(nf, "original_widget"): + no = nf.original_widget + if hasattr(no, "original_widget"): + st = no.original_widget + else: + if hasattr(no, "key_timeout"): + st = no + + if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): + st.keypress(None, None) nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.25, df) From d3ddfd6c9ca9030df607f63803ca325d2fc860a0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 29 Dec 2025 00:34:42 +0100 Subject: [PATCH 354/592] Strip invalid rendering emojis --- nomadnet/util.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nomadnet/util.py b/nomadnet/util.py index 8f077f6..1a31304 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -1,6 +1,8 @@ import re import unicodedata +invalid_rendering = ["🕵️"] + def strip_modifiers(text): def process_characters(text): result = [] @@ -19,6 +21,9 @@ def strip_modifiers(text): i += 1 return ''.join(result) + + for char in invalid_rendering: + text = text.replace(char, " ") stripped = process_characters(text) stripped = re.sub(r'[\uFE00-\uFE0F]', '', stripped) From b4c656770ff78c0048df5be2b05ebf15f0d77629 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 29 Dec 2025 14:37:18 +0100 Subject: [PATCH 355/592] Strip unrenderable unicode sequences in info box --- nomadnet/ui/textui/Network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 242118d..fcf2a4e 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -85,7 +85,7 @@ class AnnounceInfo(urwid.WidgetWrap): type_string = "Peer " + g["peer"] try: - data_str = announce[2].decode("utf-8") + data_str = strip_modifiers(announce[2].decode("utf-8")) data_style = "" if trust_level != DirectoryEntry.TRUSTED and len(data_str) > 32: data_str = data_str[:32]+" [...]" From 004fa3690e8b8c3264a147e68b2f97724a2096ef Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 29 Dec 2025 14:58:29 +0100 Subject: [PATCH 356/592] Cleanup --- nomadnet/ui/textui/MicronParser.py | 91 ++++++++++-------------------- 1 file changed, 30 insertions(+), 61 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 4ab0b46..4ad0191 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -283,14 +283,11 @@ def make_style(state): if color[0] == "g": val = int(color[1:2]) - if val < 25: - result = "black" - elif val < 50: - result = "dark gray" - elif val < 75: - result = "light gray" - else: - result = "white" + if val < 25: result = "black" + elif val < 50: result = "dark gray" + elif val < 75: result = "light gray" + else: result = "white" + else: r = int(color[0], 16) g = int(color[1], 16) @@ -298,65 +295,43 @@ def make_style(state): if r == g == b: val = int(color[0], 16)*6 - if val < 12: - result = "black" - elif val < 50: - result = "dark gray" - elif val < 80: - result = "light gray" - else: - result = "white" + if val < 12: result = "black" + elif val < 50: result = "dark gray" + elif val < 80: result = "light gray" + else: result = "white" else: if r == b: if r > g: - if r > t: - result = "light magenta" - else: - result = "dark magenta" + if r > t: result = "light magenta" + else: result = "dark magenta" else: - if g > t: - result = "light green" - else: - result = "dark green" + if g > t: result = "light green" + else: result = "dark green" if b == g: if b > r: - if b > t: - result = "light cyan" - else: - result = "dark cyan" + if b > t: result = "light cyan" + else: result = "dark cyan" else: - if r > t: - result = "light red" - else: - result = "dark red" + if r > t: result = "light red" + else: result = "dark red" if g == r: if g > b: - if g > t: - result = "yellow" - else: - result = "brown" + if g > t: result = "yellow" + else: result = "brown" else: - if b > t: - result = "light blue" - else: - result = "dark blue" + if b > t: result = "light blue" + else: result = "dark blue" if r > g and r > b: - if r > t: - result = "light red" - else: - result = "dark red" + if r > t: result = "light red" + else: result = "dark red" if g > r and g > b: - if g > t: - result = "light green" - else: - result = "dark green" + if g > t: result = "light green" + else: result = "dark green" if b > g and b > r: - if b > t: - result = "light blue" - else: - result = "dark blue" + if b > t: result = "light blue" + else: result = "dark blue" except Exception as e: result = "default" @@ -422,12 +397,9 @@ def make_style(state): bg = state["bg_color"] format_string = "" - if bold: - format_string += ",bold" - if underline: - format_string += ",underline" - if italic: - format_string += ",italics" + if bold: format_string += ",bold" + if underline: format_string += ",underline" + if italic: format_string += ",italics" name = "micron_"+fg+"_"+bg+"_"+format_string if not name in SYNTH_STYLES: @@ -488,13 +460,10 @@ def make_output(state, line, url_delegate, pre_escape=False): state["align"] = state["default_align"] elif c == "c": if state["align"] != "center": state["align"] = "center" - # else: state["align"] = state["default_align"] elif c == "l": if state["align"] != "left": state["align"] = "left" - # else: state["align"] = state["default_align"] elif c == "r": if state["align"] != "right": state["align"] = "right" - # else: state["align"] = state["default_align"] elif c == "a": state["align"] = state["default_align"] From 156e4f379ebbb2e288eb6332e57268f7d77acfc0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 29 Dec 2025 19:47:08 +0100 Subject: [PATCH 357/592] Implemented partials for micron pages --- nomadnet/ui/textui/Browser.py | 169 ++++++++++++++++++++++++++++- nomadnet/ui/textui/MicronParser.py | 48 +++++++- 2 files changed, 208 insertions(+), 9 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index ab9f069..eda957b 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -8,6 +8,7 @@ import shutil import nomadnet import subprocess import threading +from threading import Lock from .MicronParser import markup_to_attrmaps, make_style, default_state from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.Scrollable import * @@ -113,6 +114,9 @@ class Browser: self.frame = None self.attr_maps = [] self.page_pile = None + self.page_partials = {} + self.updater_running = False + self.partial_updater_lock = Lock() self.build_display() self.history = [] @@ -231,12 +235,6 @@ class Browser: else: pass # do nothing if checkbox is not check - - - - - - recurse_down(self.attr_maps) RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) @@ -319,6 +317,7 @@ class Browser: self.browser_footer = urwid.Text("") self.page_pile = None + self.page_partials = {} self.browser_body = urwid.Filler( urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), urwid.MIDDLE, @@ -375,6 +374,7 @@ class Browser: if self.status == Browser.DISCONECTED: self.display_widget.set_attr_map({None: "inactive_text"}) self.page_pile = None + self.page_partials = {} self.browser_body = urwid.Filler( urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), urwid.MIDDLE, @@ -448,7 +448,164 @@ class Browser: pile = urwid.Pile(self.attr_maps) pile.automove_cursor_on_scroll = True self.page_pile = pile + self.page_partials = {} self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") + self.detect_partials() + + def parse_url(self, url): + path = None + destination_hash = None + components = url.split(":") + if len(components) == 1: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") + path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + elif len(components) == 2: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") + path = components[1] + if len(path) == 0: path = Browser.DEFAULT_PATH + else: + if len(components[0]) == 0: + if self.destination_hash != None: + destination_hash = self.destination_hash + path = components[1] + if len(path) == 0: path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + + return destination_hash, path + + def detect_partials(self): + for w in self.attr_maps: + o = w._original_widget + if hasattr(o, "partial_id"): + RNS.log(f"Found partial: {o.partial_id} / {o.partial_url} / {o.partial_refresh}") + partial = {"id": o.partial_id, "url": o.partial_url, "fields": o.partial_fields, "refresh": o.partial_refresh, + "content": None, "updated": None, "update_requested": None, "request_id": None, "destination": None, + "link": None, "pile": o, "attr_maps": None, "failed": False, "pr_throttle": 0} + + self.page_partials[o.partial_id] = partial + + if len(self.page_partials) > 0: self.start_partial_updater() + + def partial_failed(self, request_receipt): + RNS.log("Loading page partial failed", RNS.LOG_ERROR) + for pid in self.page_partials: + partial = self.page_partials[pid] + if partial["request_id"] == request_receipt.request_id: + try: + partial["updated"] = time.time() + partial["request_id"] = None + partial["content"] = None + partial["attr_maps"] = None + url = partial["url"] + pile = partial["pile"] + pile.contents = [(urwid.Text(f"Could not load partial {url}: The resource transfer failed"), pile.options())] + except Exception as e: + RNS.log(f"Error in partial failed callback: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + + def partial_progressed(self, request_receipt): + pass + + def partial_received(self, request_receipt): + for pid in self.page_partials: + partial = self.page_partials[pid] + if partial["request_id"] == request_receipt.request_id: + try: + partial["updated"] = partial["update_requested"] + partial["request_id"] = None + partial["content"] = request_receipt.response.decode("utf-8").rstrip() + partial["attr_maps"] = markup_to_attrmaps(strip_modifiers(partial["content"]), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + pile = partial["pile"] + pile.contents = [(e, pile.options()) for e in partial["attr_maps"]] + + except Exception as e: + RNS.trace_exception(e) + + def __load_partial(self, partial): + if partial["failed"] == True: return + try: partial_destination_hash, path = self.parse_url(partial["url"]) + except Exception as e: + RNS.log(f"Could not parse partial URL: {e}", RNS.LOG_ERROR) + partial["failed"] = True + pile = partial["pile"] + url = partial["url"] + pile.contents = [(urwid.Text(f"Could not load partial {url}: {e}"), pile.options())] + return + + if partial_destination_hash != self.loopback and not RNS.Transport.has_path(partial_destination_hash): + if time.time() <= partial["pr_throttle"]: return + else: + partial["pr_throttle"] = time.time()+15 + RNS.log(f"Requesting path for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + RNS.Transport.request_path(partial_destination_hash) + pr_time = time.time()+RNS.Transport.first_hop_timeout(partial_destination_hash) + while not RNS.Transport.has_path(partial_destination_hash): + now = time.time() + if now > pr_time+self.timeout: return + time.sleep(0.25) + + for pid in self.page_partials: + other_partial = self.page_partials[pid] + if other_partial["link"]: + existing_link = other_partial["link"] + if existing_link.destination.hash == partial_destination_hash and existing_link.status == RNS.Link.ACTIVE: + RNS.log(f"Re-using existing link: {existing_link}", RNS.LOG_EXTREME) + partial["link"] = existing_link + break + + if not partial["link"]: + RNS.log(f"Establishing link for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + identity = RNS.Identity.recall(partial_destination_hash) + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects) + + def established(link): + RNS.log(f"Link established for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + + def closed(link): + RNS.log(f"Link closed for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + partial["link"] = None + + partial["link"] = RNS.Link(destination, established_callback = established, closed_callback = closed) + timeout = time.time()+self.timeout + while partial["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1) + + if partial["link"] and partial["link"].status == RNS.Link.ACTIVE and partial["request_id"] == None: + RNS.log(f"Sending request for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + receipt = partial["link"].request(path, data = None, response_callback = self.partial_received, + failed_callback = self.partial_failed, progress_callback = self.partial_progressed) + + if receipt: partial["request_id"] = receipt.request_id + else: RNS.log(f"Partial request failed", RNS.LOG_ERROR) + + def start_partial_updater(self): + if not self.updater_running: self.update_partials() + + def update_partials(self, loop=None, user_data=None): + with self.partial_updater_lock: + def job(): + for pid in self.page_partials: + try: + partial = self.page_partials[pid] + if partial["failed"]: continue + if not partial["updated"] or (partial["refresh"] != None and time.time() > partial["updated"]+partial["refresh"]): + partial["update_requested"] = time.time() + self.__load_partial(partial) + except Exception as e: RNS.log(f"Error updating page partial: {e}", RNS.LOG_ERROR) + + threading.Thread(target=job, daemon=True).start() + + if len(self.page_partials) > 0: + self.updater_running = True + self.app.ui.loop.set_alarm_in(1, self.update_partials) + else: + self.updater_running = False def identify(self): if self.link != None: diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 4ad0191..6f42a44 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -2,6 +2,7 @@ import nomadnet import urwid import random import time +import RNS from urwid.util import is_mouse_press from urwid.text_layout import calc_coords @@ -85,6 +86,45 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None return attrmaps +def parse_partial(line): + try: + endpos = line.find("}") + if endpos == -1: return None + else: + partial_data = line[0:endpos] + + partial_components = partial_data.split("`") + if len(partial_components) == 1: + partial_url = partial_components[0] + partial_refresh = None + partial_fields = "" + elif len(partial_components) == 2: + partial_url = partial_components[0] + partial_refresh = float(partial_components[1]) + partial_fields = "" + # TODO: Implement fields and parameters for partials + # elif len(partial_components) == 3: + # partial_url = partial_components[0] + # partial_refresh = float(partial_components[1]) + # partial_fields = partial_components[2] + else: + partial_url = "" + partial_fields = "" + partial_refresh = None + + if partial_refresh and partial_refresh < 1: partial_refresh = None + + if len(partial_url): + pile = urwid.Pile([urwid.Text(f"⧖")]) + partial_descriptor = "|".join(partial_components) + pile.partial_id = RNS.hexrep(RNS.Identity.full_hash(partial_descriptor.encode("utf-8")), delimit=False) + pile.partial_url = partial_url + pile.partial_fields = partial_fields + pile.partial_refresh = partial_refresh + return [pile] + + except Exception as e: return None + def parse_line(line, state, url_delegate): pre_escape = False if len(line) > 0: @@ -106,6 +146,10 @@ def parse_line(line, state, url_delegate): elif first_char == "#": return None + # Check for partials + elif line.startswith("`{"): + return parse_partial(line[2:]) + # Check for section heading reset elif first_char == "<": state["depth"] = 0 @@ -620,9 +664,7 @@ def make_output(state, line, url_delegate, pre_escape=False): output.append((linkspec, link_label)) else: - output.append(make_part(state, link_label)) - - + output.append(make_part(state, link_label)) mode = "text" if len(part) > 0: From b35cc04f57a2363bfcf2d49fd096bb44053358be Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 29 Dec 2025 19:52:59 +0100 Subject: [PATCH 358/592] Added partials to guide --- nomadnet/ui/textui/Guide.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 93d06ad..e245fb3 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1302,6 +1302,24 @@ Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/inde When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL. +>Partials + +You can include partials in pages, which will load asynchronously once the page itself has loaded. + +`Faaa +`= +`{f64a846313b874ee4a357040807f8c77:/page/partial_1.mu} +`= +`` + +It's also possible to set an auto-refresh interval for partials. The following partial will update every 10 seconds. + +`Faaa +`= +`{f64a846313b874ee4a357040807f8c77:/page/refreshing_partial.mu`10} +`= +`` + >Fields & Requests Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. From f2d0ea9910a06caaa3d9070b2e879840d84ed8ab Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 29 Dec 2025 20:24:12 +0100 Subject: [PATCH 359/592] Added field and variable parsing to partials --- nomadnet/ui/textui/Browser.py | 59 +++++++++++++++++++++++++++++- nomadnet/ui/textui/MicronParser.py | 12 +++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index eda957b..545a826 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -578,12 +578,69 @@ class Browser: if partial["link"] and partial["link"].status == RNS.Link.ACTIVE and partial["request_id"] == None: RNS.log(f"Sending request for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) - receipt = partial["link"].request(path, data = None, response_callback = self.partial_received, + receipt = partial["link"].request(path, data=self.__get_partial_request_data(partial), response_callback = self.partial_received, failed_callback = self.partial_failed, progress_callback = self.partial_progressed) if receipt: partial["request_id"] = receipt.request_id else: RNS.log(f"Partial request failed", RNS.LOG_ERROR) + def __get_partial_request_data(self, partial): + request_data = None + if partial["fields"] != None: + link_data = partial["fields"] + link_fields = [] + request_data = {} + all_fields = True if "*" in link_data else False + + for e in link_data: + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + else: + link_fields.append(e) + + def recurse_down(w): + if isinstance(w, list): + for t in w: + recurse_down(t) + elif isinstance(w, tuple): + for t in w: + recurse_down(t) + elif hasattr(w, "contents"): + recurse_down(w.contents) + elif hasattr(w, "original_widget"): + recurse_down(w.original_widget) + elif hasattr(w, "_original_widget"): + recurse_down(w._original_widget) + else: + if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields): + field_key = "field_" + w.field_name + if isinstance(w, urwid.Edit): + request_data[field_key] = w.edit_text + elif isinstance(w, urwid.RadioButton): + if w.state: + user_data = getattr(w, "field_value", None) + if user_data is not None: + request_data[field_key] = user_data + elif isinstance(w, urwid.CheckBox): + user_data = getattr(w, "field_value", "1") + if w.state: + existing_value = request_data.get(field_key, '') + if existing_value: + # Concatenate the new value with the existing one + request_data[field_key] = existing_value + ',' + user_data + else: + # Initialize the field with the current value + request_data[field_key] = user_data + else: + pass # do nothing if checkbox is not check + + recurse_down(self.attr_maps) + RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + + return request_data + def start_partial_updater(self): if not self.updater_running: self.update_partials() diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 6f42a44..7ca715e 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -102,11 +102,10 @@ def parse_partial(line): partial_url = partial_components[0] partial_refresh = float(partial_components[1]) partial_fields = "" - # TODO: Implement fields and parameters for partials - # elif len(partial_components) == 3: - # partial_url = partial_components[0] - # partial_refresh = float(partial_components[1]) - # partial_fields = partial_components[2] + elif len(partial_components) == 3: + partial_url = partial_components[0] + partial_refresh = float(partial_components[1]) + partial_fields = partial_components[2] else: partial_url = "" partial_fields = "" @@ -114,6 +113,9 @@ def parse_partial(line): if partial_refresh and partial_refresh < 1: partial_refresh = None + pf = partial_fields.split("|") + if len(pf) > 0: partial_fields = pf + if len(partial_url): pile = urwid.Pile([urwid.Text(f"⧖")]) partial_descriptor = "|".join(partial_components) From 11ccc76d93e04da06ddbefbf3cf9013d75986679 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 29 Dec 2025 22:21:51 +0100 Subject: [PATCH 360/592] Added partial-updating links --- nomadnet/ui/textui/Browser.py | 65 +++++++++++++++++------------- nomadnet/ui/textui/Guide.py | 48 +++++++++++++--------- nomadnet/ui/textui/MicronParser.py | 13 ++++-- nomadnet/util.py | 2 +- 4 files changed, 79 insertions(+), 49 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 545a826..b0885b7 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -185,6 +185,7 @@ class Browser: return destination_type def handle_link(self, link_target, link_data = None): + partial_ids = None request_data = None if link_data != None: link_fields = [] @@ -244,6 +245,10 @@ class Browser: if len(components) == 2: destination_type = self.expand_shorthands(components[0]) link_target = components[1] + elif link_target.startswith("p:"): + comps = link_target.split(":") + if len(comps) > 1: partial_ids = comps[1:] + destination_type = "partial" else: destination_type = "nomadnetwork.node" link_target = components[0] @@ -264,6 +269,9 @@ class Browser: RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG) self.handle_lxmf_link(link_target) + elif destination_type == "partial": + if partial_ids != None and len(partial_ids) > 0: self.handle_partial_updates(partial_ids) + else: RNS.log("No known handler for destination type "+str(destination_type), RNS.LOG_DEBUG) self.browser_footer = urwid.Text("Could not open link: "+"No known handler for destination type "+str(destination_type)) @@ -483,13 +491,13 @@ class Browser: def detect_partials(self): for w in self.attr_maps: o = w._original_widget - if hasattr(o, "partial_id"): - RNS.log(f"Found partial: {o.partial_id} / {o.partial_url} / {o.partial_refresh}") - partial = {"id": o.partial_id, "url": o.partial_url, "fields": o.partial_fields, "refresh": o.partial_refresh, - "content": None, "updated": None, "update_requested": None, "request_id": None, "destination": None, - "link": None, "pile": o, "attr_maps": None, "failed": False, "pr_throttle": 0} + if hasattr(o, "partial_hash"): + RNS.log(f"Found partial: {o.partial_hash} / {o.partial_url} / {o.partial_refresh}") + partial = {"hash": o.partial_hash, "id": o.partial_id, "url": o.partial_url, "fields": o.partial_fields, + "refresh": o.partial_refresh, "content": None, "updated": None, "update_requested": None, "request_id": None, + "destination": None, "link": None, "pile": o, "attr_maps": None, "failed": False, "pr_throttle": 0} - self.page_partials[o.partial_id] = partial + self.page_partials[o.partial_hash] = partial if len(self.page_partials) > 0: self.start_partial_updater() @@ -560,7 +568,7 @@ class Browser: partial["link"] = existing_link break - if not partial["link"]: + if not partial["link"] or partial["link"].status == RNS.Link.CLOSED: RNS.log(f"Establishing link for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) identity = RNS.Identity.recall(partial_destination_hash) destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects) @@ -644,6 +652,19 @@ class Browser: def start_partial_updater(self): if not self.updater_running: self.update_partials() + def handle_partial_updates(self, partial_ids): + RNS.log(f"Update partials: {partial_ids}") + def job(): + for pid in self.page_partials: + try: + partial = self.page_partials[pid] + if partial["id"] in partial_ids: + partial["update_requested"] = time.time() + self.__load_partial(partial) + except Exception as e: RNS.log(f"Error updating page partial: {e}", RNS.LOG_ERROR) + + threading.Thread(target=job, daemon=True).start() + def update_partials(self, loop=None, user_data=None): with self.partial_updater_lock: def job(): @@ -702,35 +723,25 @@ class Browser: components = url.split(":") if len(components) == 1: if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: - try: - destination_hash = bytes.fromhex(components[0]) - except Exception as e: - raise ValueError("Malformed URL") + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") path = Browser.DEFAULT_PATH - else: - raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") elif len(components) == 2: if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: - try: - destination_hash = bytes.fromhex(components[0]) - except Exception as e: - raise ValueError("Malformed URL") + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") path = components[1] - if len(path) == 0: - path = Browser.DEFAULT_PATH + if len(path) == 0: path = Browser.DEFAULT_PATH else: if len(components[0]) == 0: if self.destination_hash != None: destination_hash = self.destination_hash path = components[1] - if len(path) == 0: - path = Browser.DEFAULT_PATH - else: - raise ValueError("Malformed URL") - else: - raise ValueError("Malformed URL") - else: - raise ValueError("Malformed URL") + if len(path) == 0: path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") if destination_hash != None and path != None: if path.startswith("/file/"): diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index e245fb3..ddb2cb3 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1302,24 +1302,6 @@ Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/inde When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL. ->Partials - -You can include partials in pages, which will load asynchronously once the page itself has loaded. - -`Faaa -`= -`{f64a846313b874ee4a357040807f8c77:/page/partial_1.mu} -`= -`` - -It's also possible to set an auto-refresh interval for partials. The following partial will update every 10 seconds. - -`Faaa -`= -`{f64a846313b874ee4a357040807f8c77:/page/refreshing_partial.mu`10} -`= -`` - >Fields & Requests Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. @@ -1473,6 +1455,36 @@ This line will `` +>Partials + +You can include partials in pages, which will load asynchronously once the page itself has loaded. + +`Faaa +`= +`{f64a846313b874ee4a357040807f8c77:/page/partial_1.mu} +`= +`` + +It's also possible to set an auto-refresh interval for partials. Omit or set to 0 to disable. The following partial will update every 10 seconds. + +`Faaa +`= +`{f64a846313b874ee4a357040807f8c77:/page/refreshing_partial.mu`10} +`= +`` + +You can include field values and variables in partial updates, and by setting the `!pid`! variable, you can create links that update one or more specific partials. + +`Faaa +`= +Name: `B444``b + +`F38a`[Say hello`p:32]`f + +`{f64a846313b874e84a357039807f8c77:/page/hello_partial.mu`0`pid=32|user_name} +`= +`` + >Literals To display literal content, for example source-code, or blocks of text that should not be interpreted by micron, you can use literal blocks, specified by the \\`= tag. Below is the source code of this entire document, presented as a literal block. diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 7ca715e..0599216 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -93,6 +93,7 @@ def parse_partial(line): else: partial_data = line[0:endpos] + partial_id = None partial_components = partial_data.split("`") if len(partial_components) == 1: partial_url = partial_components[0] @@ -111,15 +112,21 @@ def parse_partial(line): partial_fields = "" partial_refresh = None - if partial_refresh and partial_refresh < 1: partial_refresh = None + if partial_refresh != None and partial_refresh < 1: partial_refresh = None pf = partial_fields.split("|") - if len(pf) > 0: partial_fields = pf + if len(pf) > 0: + partial_fields = pf + for f in pf: + if f.startswith("pid="): + pcs = f.split("=") + partial_id = pcs[1] if len(partial_url): pile = urwid.Pile([urwid.Text(f"⧖")]) partial_descriptor = "|".join(partial_components) - pile.partial_id = RNS.hexrep(RNS.Identity.full_hash(partial_descriptor.encode("utf-8")), delimit=False) + pile.partial_id = partial_id + pile.partial_hash = RNS.hexrep(RNS.Identity.full_hash(partial_descriptor.encode("utf-8")), delimit=False) pile.partial_url = partial_url pile.partial_fields = partial_fields pile.partial_refresh = partial_refresh diff --git a/nomadnet/util.py b/nomadnet/util.py index 1a31304..0d8c79f 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -1,7 +1,7 @@ import re import unicodedata -invalid_rendering = ["🕵️"] +invalid_rendering = ["🕵️", "☝"] def strip_modifiers(text): def process_characters(text): From efd62850125fda221779cb01a885ac2928f6ca1e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 29 Dec 2025 22:22:26 +0100 Subject: [PATCH 361/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index e94731c..f8c6ac7 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.4" +__version__ = "0.9.5" From 237d56059d4cfb4f72cf338af2166d7d3a7ac170 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 30 Dec 2025 12:16:24 +0100 Subject: [PATCH 362/592] Markup handling improvements --- nomadnet/ui/textui/Browser.py | 10 +++++++--- nomadnet/ui/textui/MicronParser.py | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index b0885b7..391f893 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1040,7 +1040,8 @@ class Browser: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg - self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] self.response_progress = 0 self.response_speed = None @@ -1107,7 +1108,8 @@ class Browser: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg - self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] self.response_progress = 0 self.response_speed = None @@ -1259,7 +1261,9 @@ class Browser: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg - self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] + self.response_progress = 0 self.response_speed = None self.progress_updated_at = None diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 0599216..d8adbb4 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -146,6 +146,12 @@ def parse_line(line, state, url_delegate): # Only parse content if not in literal state if not state["literal"]: + # Apply markup sanitization + if first_char == ">" and "`<" in line: + # Remove heading status from lines containing fields + line = line.lstrip(">") + first_char = line[0] + # Check if the command is an escape if first_char == "\\": line = line[1:] From f75d25f85776c89fee31e836f099bb2e3a5422d1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 30 Dec 2025 12:41:53 +0100 Subject: [PATCH 363/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index f8c6ac7..50533e3 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.5" +__version__ = "0.9.6" From 29b625574629b1ba54a7cb31072dbce6514873f6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 2 Jan 2026 00:45:46 +0100 Subject: [PATCH 364/592] Avoid running stat updater multiple time. Run stat updates non-blocking. --- nomadnet/ui/textui/Interfaces.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index 85b9b12..b295cdd 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -1,6 +1,7 @@ import RNS import time import nomadnet +import threading from math import log10, pow from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * @@ -2226,7 +2227,7 @@ class ShowInterface(urwid.WidgetWrap): self.history_length = screen_cols-(margin+2) # get interface stats - interface_stats = self.parent.app.rns.get_interface_stats() + interface_stats = self.parent.app.interface_stats stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} self.stats = stats_lookup.get(iface_name, {}) @@ -2758,7 +2759,7 @@ class ShowInterface(urwid.WidgetWrap): return try: - interface_stats = self.parent.app.rns.get_interface_stats() + interface_stats = self.parent.app.interface_stats stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} stats = stats_lookup.get(self.iface_name, {}) @@ -2825,6 +2826,8 @@ class InterfaceDisplay: def __init__(self, app): self.app = app self.started = False + self.poll_scheduler = False + self.size_scheduler = False self.interface_items = [] self.glyphset = self.app.config["textui"]["glyphs"] self.g = self.app.ui.glyphs @@ -2857,7 +2860,8 @@ class InterfaceDisplay: processed_interfaces[interface_name] = interface_data - interface_stats = app.rns.get_interface_stats() + app.interface_stats = app.rns.get_interface_stats() + interface_stats = app.interface_stats stats_lookup = {interface['short_name']: interface for interface in interface_stats['interfaces']} # print(stats_lookup) for interface_name, interface_data in processed_interfaces.items(): @@ -2916,8 +2920,8 @@ class InterfaceDisplay: def start(self): # started from Main.py self.started = True - self.app.ui.loop.set_alarm_in(1, self.poll_stats) - self.app.ui.loop.set_alarm_in(5, self.check_terminal_size) + if not self.poll_scheduler: self.app.ui.loop.set_alarm_in(1, self.poll_stats) + if not self.size_scheduler: self.app.ui.loop.set_alarm_in(5, self.check_terminal_size) def switch_to_edit_interface(self, iface_name): self.edit_interface_view = EditInterfaceView(self, iface_name) @@ -2936,6 +2940,7 @@ class InterfaceDisplay: self.switch_to_edit_interface(interface_name) def check_terminal_size(self, loop, user_data): + self.size_scheduler = True new_cols, new_rows = _get_cols_rows() if new_rows != self.terminal_rows or new_cols != self.terminal_cols: @@ -2950,12 +2955,15 @@ class InterfaceDisplay: loop.set_alarm_in(5, self.check_terminal_size) def poll_stats(self, loop, user_data): + self.poll_scheduler = True try: if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: self.widget = self.interfaces_display self.app.ui.main_display.update_active_sub_display() - interface_stats = self.app.rns.get_interface_stats() + def job(): self.app.interface_stats = self.app.rns.get_interface_stats() + threading.Thread(target=job, daemon=True).start() + interface_stats = self.app.interface_stats stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} for item in self.interface_items: # use interface name as the key From 70baa59ac2913cb0007862d76b75e086bfa51f7b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 4 Jan 2026 01:05:29 +0100 Subject: [PATCH 365/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 50533e3..f5b7730 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.6" +__version__ = "0.9.7" diff --git a/setup.py b/setup.py index cf006ea..5732319 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.0.4", "lxmf>=0.9.3", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.1.0", "lxmf>=0.9.4", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 97709058b063895bc3a0803d3cc5869b0553f185 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 6 Jan 2026 17:18:40 +0100 Subject: [PATCH 366/592] Cleanup --- nomadnet/Node.py | 2 +- nomadnet/ui/textui/Browser.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 755c002..8f03115 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -173,7 +173,7 @@ class Node: if isinstance(e, str) and (e.startswith("field_") or e.startswith("var_")): env_map[e] = data[e] - generated = subprocess.run([file_path], stdout=subprocess.PIPE, env=env_map) + generated = subprocess.run([file_path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env_map) return generated.stdout else: fh = open(file_path, "rb") diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 391f893..a5a390c 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1081,7 +1081,7 @@ class Browser: if "PATH" in os.environ: env_map["PATH"] = os.environ["PATH"] - generated = subprocess.run([page_path], stdout=subprocess.PIPE, env=env_map) + generated = subprocess.run([page_path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env_map) page_data = generated.stdout else: file = open(page_path, "rb") From dd33483cbd58eb0a7ce762be538944af349d7438 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 6 Jan 2026 17:48:17 +0100 Subject: [PATCH 367/592] Versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5732319..090e7bc 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.1.0", "lxmf>=0.9.4", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.1.1", "lxmf>=0.9.4", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From acdae8c11da6d571e3e7cb238d05e6e5369b2fcf Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 12 Jan 2026 00:24:50 +0100 Subject: [PATCH 368/592] Fixed missing saved file name display on file download --- nomadnet/ui/textui/Browser.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index a5a390c..e2fb0df 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1392,6 +1392,8 @@ class Browser: shutil.move(file_handle.name, file_destination) + self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + else: file_name = request_receipt.response[0] file_data = request_receipt.response[1] From a2a789e61f5a670802d4741fff24a583c78f4ef3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 17 Jan 2026 21:39:29 +0100 Subject: [PATCH 369/592] Updated versions --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 090e7bc..898c0a3 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ + import setuptools exec(open("nomadnet/_version.py", "r").read()) @@ -30,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.1.1", "lxmf>=0.9.4", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.1.3", "lxmf>=0.9.4", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 324ddec19ed784b0583322de8a22f12b43a878d7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 17 Jan 2026 21:40:08 +0100 Subject: [PATCH 370/592] Updated versions --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index f5b7730..a25765c 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.7" +__version__ = "0.9.8" From e256002a0d63c38ab9c78a7c782233b48a6ecf08 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:19:11 -0400 Subject: [PATCH 371/592] Add LXMF attachment support, message caching, and conversation UI improvements --- nomadnet/Conversation.py | 559 ++++++++++++++++++-- nomadnet/NomadNetworkApp.py | 14 + nomadnet/ui/TextUI.py | 3 + nomadnet/ui/textui/Conversations.py | 769 ++++++++++++++++++++++++++-- 4 files changed, 1256 insertions(+), 89 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 9b3f804..a4917cb 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -2,6 +2,7 @@ import os import RNS import LXMF import shutil +import msgpack import nomadnet from nomadnet.Directory import DirectoryEntry @@ -66,20 +67,29 @@ class Conversation: ingested_path = lxmessage.write_to_directory(conversation_path) + try: + ConversationMessage.extract_attachments_from_lxm(lxmessage, app) + except Exception as e: + RNS.log("Error extracting attachments: "+str(e), RNS.LOG_ERROR) + if RNS.hexrep(source_hash, delimit=False) in Conversation.cached_conversations: conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)] conversation.scan_storage() - if not source_hash in Conversation.unread_conversations: - Conversation.unread_conversations[source_hash] = True - try: - dirname = RNS.hexrep(source_hash, delimit=False) - open(app.conversationpath + "/" + dirname + "/unread", 'a').close() - except Exception as e: - pass + if source_hash in Conversation.unread_conversations: + Conversation.unread_conversations[source_hash] += 1 + else: + Conversation.unread_conversations[source_hash] = 1 - if Conversation.created_callback != None: - Conversation.created_callback() + try: + dirname = RNS.hexrep(source_hash, delimit=False) + with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf: + uf.write(str(Conversation.unread_conversations[source_hash])) + except Exception as e: + pass + + if Conversation.created_callback != None: + Conversation.created_callback() return ingested_path @@ -94,12 +104,17 @@ class Conversation: app_data = RNS.Identity.recall_app_data(source_hash) display_name = app.directory.display_name(source_hash) - unread = False + unread = 0 if source_hash in Conversation.unread_conversations: - unread = True + unread = Conversation.unread_conversations[source_hash] elif os.path.isfile(app.conversationpath + "/" + dirname + "/unread"): - Conversation.unread_conversations[source_hash] = True - unread = True + try: + with open(app.conversationpath + "/" + dirname + "/unread", "r") as uf: + content = uf.read().strip() + unread = int(content) if content else 1 + except Exception: + unread = 1 + Conversation.unread_conversations[source_hash] = unread if display_name == None and app_data: display_name = LXMF.display_name_from_app_data(app_data) @@ -110,14 +125,20 @@ class Conversation: sort_name = display_name trust_level = app.directory.trust_level(source_hash, display_name) - - entry = (source_hash_text, display_name, trust_level, sort_name, unread) + + conversation_dir = app.conversationpath + "/" + dirname + try: + last_activity = os.path.getmtime(conversation_dir) + except Exception: + last_activity = 0 + + entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity) conversations.append(entry) except Exception as e: RNS.log("Error while loading conversation "+str(dirname)+", skipping it. The contained exception was: "+str(e), RNS.LOG_ERROR) - conversations.sort(key=lambda e: (-e[2], e[3], e[0]), reverse=False) + conversations.sort(key=lambda e: e[5], reverse=True) return conversations @@ -172,14 +193,46 @@ class Conversation: def scan_storage(self): old_len = len(self.messages) + existing = {} + for msg in self.messages: + existing[msg.file_path] = msg + + index = ConversationMessage.read_index(self.messages_path) + self.messages = [] for filename in os.listdir(self.messages_path): if len(filename) == RNS.Identity.HASHLENGTH//8*2: message_path = self.messages_path + "/" + filename - self.messages.append(ConversationMessage(message_path)) + if message_path in existing: + old_msg = existing[message_path] + try: + current_mtime = os.path.getmtime(message_path) + if current_mtime > old_msg.sort_timestamp: + old_msg._cached_state = None + old_msg._cached_method = None + old_msg.sort_timestamp = current_mtime + except Exception: + pass + self.messages.append(old_msg) + else: + msg = ConversationMessage(message_path) + if filename in index: + msg.restore_from_index(index[filename]) + self.messages.append(msg) new_len = len(self.messages) + needs_index_update = [] + for msg in self.messages: + filename = os.path.basename(msg.file_path) + if msg._cached_state is not None: + if filename not in index: + needs_index_update.append(msg) + elif "content" not in index[filename]: + needs_index_update.append(msg) + if needs_index_update: + ConversationMessage.write_index(self.messages_path, needs_index_update) + if new_len > old_len: self.unread = True @@ -208,7 +261,7 @@ class Conversation: def register_changed_callback(self, callback): self.__changed_callback = callback - def send(self, content="", title=""): + def send(self, content="", title="", fields=None): if self.send_destination: dest = self.send_destination source = self.app.lxmf_destination @@ -225,7 +278,7 @@ class Conversation: if self.app.directory.trust_level(dest.hash) == DirectoryEntry.TRUSTED: dest_is_trusted = True - lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method, include_ticket=dest_is_trusted) + lxm = LXMF.LXMessage(dest, source, content, title=title, fields=fields, desired_method=desired_method, include_ticket=dest_is_trusted) lxm.register_delivery_callback(self.message_notification) lxm.register_failed_callback(self.message_notification) @@ -330,9 +383,32 @@ class ConversationMessage: self.timestamp = None self.lxm = None + self._cached_hash = None + self._cached_state = None + self._cached_title = None + self._cached_content = None + self._cached_source_hash = None + self._cached_transport_encrypted = None + self._cached_transport_encryption = None + self._cached_signature_validated = None + self._cached_unverified_reason = None + self._cached_method = None + self._cached_has_attachments = None + self._cached_attachment_names = None + + self.sort_timestamp = os.path.getmtime(file_path) if os.path.isfile(file_path) else 0 + + filename = os.path.basename(file_path) + if len(filename) == RNS.Identity.HASHLENGTH//8*2: + try: + self._cached_hash = bytes.fromhex(filename) + except Exception: + pass + def load(self): try: - self.lxm = LXMF.LXMessage.unpack_from_file(open(self.file_path, "rb")) + with open(self.file_path, "rb") as lxm_file: + self.lxm = LXMF.LXMessage.unpack_from_file(lxm_file) self.loaded = True self.timestamp = self.lxm.timestamp self.sort_timestamp = os.path.getmtime(self.file_path) @@ -351,6 +427,78 @@ class ConversationMessage: if not found: self.lxm.state = LXMF.LXMessage.FAILED + if self._cached_hash is None: + self._cached_hash = self.lxm.hash + self._cached_state = self.lxm.state + if self._cached_source_hash is None: + self._cached_source_hash = self.lxm.source_hash + self._cached_transport_encrypted = self.lxm.transport_encrypted + self._cached_transport_encryption = self.lxm.transport_encryption + if self._cached_signature_validated is None: + self._cached_signature_validated = self.lxm.signature_validated + self._cached_method = self.lxm.method + if self._cached_unverified_reason is None and hasattr(self.lxm, "unverified_reason"): + self._cached_unverified_reason = self.lxm.unverified_reason + self._cached_title = self.lxm.title_as_string() + self._cached_content = self.lxm.content_as_string() + + if self._cached_has_attachments is None: + found_in_fields = False + fields = None + if hasattr(self.lxm, "get_fields"): + fields = self.lxm.get_fields() + if fields and isinstance(fields, dict): + found_in_fields = ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + if found_in_fields: + names = [] + file_atts = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + for att in file_atts: + if isinstance(att, list) and len(att) >= 2: + size = len(att[1]) if isinstance(att[1], bytes) else 0 + names.append(("file", str(att[0]), size)) + if LXMF.FIELD_IMAGE in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) + if data: + size = len(data) + ext = ConversationMessage._ext_from_media_format(fmt, data) + names.append(("file", "image"+ext, size)) + if LXMF.FIELD_AUDIO in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) + if data: + size = len(data) + ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) + names.append(("file", "audio"+ext, size)) + self._cached_has_attachments = True + self._cached_attachment_names = names + + if not found_in_fields: + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest and len(manifest["files"]) > 0: + names = [] + for entry in manifest["files"]: + names.append(("file", entry["name"], entry.get("size", 0))) + self._cached_has_attachments = True + self._cached_attachment_names = names + else: + self._cached_has_attachments = False + self._cached_attachment_names = [] + else: + self._cached_has_attachments = False + self._cached_attachment_names = [] + + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() + ConversationMessage.extract_attachments_from_lxm(self.lxm, app) + ConversationMessage.strip_attachments_from_file(self.file_path, app) + except Exception: + pass + except Exception as e: RNS.log("Error while loading LXMF message "+str(self.file_path)+" from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -364,60 +512,395 @@ class ConversationMessage: os.unlink(self.file_path) def get_timestamp(self): + if self.timestamp is not None: + return self.timestamp if not self.loaded: self.load() - return self.timestamp def get_title(self): + if self._cached_title is not None: + return self._cached_title if not self.loaded: self.load() - - return self.lxm.title_as_string() + return self._cached_title if self._cached_title is not None else "" def get_content(self): + if self._cached_content is not None: + return self._cached_content if not self.loaded: self.load() - - return self.lxm.content_as_string() + return self._cached_content if self._cached_content is not None else "" def get_hash(self): + if self._cached_hash is not None: + return self._cached_hash if not self.loaded: self.load() - - return self.lxm.hash + return self._cached_hash def get_state(self): + if self._cached_state is not None: + return self._cached_state if not self.loaded: self.load() - - return self.lxm.state + return self._cached_state def get_transport_encryption(self): + if self._cached_transport_encryption is not None: + return self._cached_transport_encryption if not self.loaded: self.load() - - return self.lxm.transport_encryption + return self._cached_transport_encryption def get_transport_encrypted(self): + if self._cached_transport_encrypted is not None: + return self._cached_transport_encrypted if not self.loaded: self.load() - - return self.lxm.transport_encrypted + return self._cached_transport_encrypted def signature_validated(self): + if self._cached_signature_validated is not None: + return self._cached_signature_validated if not self.loaded: self.load() - - return self.lxm.signature_validated + return self._cached_signature_validated def get_signature_description(self): if self.signature_validated(): return "Signature Verified" else: - if self.lxm.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN: + reason = self._cached_unverified_reason + if reason == LXMF.LXMessage.SOURCE_UNKNOWN: return "Unknown Origin" - elif self.lxm.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID: + elif reason == LXMF.LXMessage.SIGNATURE_INVALID: return "Invalid Signature" else: - return "Unknown signature validation failure" \ No newline at end of file + return "Unknown signature validation failure" + + def get_fields(self): + if not self.loaded: + self.load() + + if self.lxm and hasattr(self.lxm, "get_fields"): + fields = self.lxm.get_fields() + if fields and isinstance(fields, dict): + return fields + + return {} + + def has_attachments(self): + if self._cached_has_attachments is not None: + return self._cached_has_attachments + fields = self.get_fields() + return ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + + def get_file_attachments(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest: + result = [] + for entry in manifest["files"]: + fpath = os.path.join(att_dir, entry["stored_name"]) + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + result.append([entry["name"], f.read()]) + return result + + fields = self.get_fields() + return fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + + def get_image(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + fpath = os.path.join(att_dir, "image") + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + return f.read() + + fields = self.get_fields() + return fields.get(LXMF.FIELD_IMAGE, None) + + def get_audio(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + fpath = os.path.join(att_dir, "audio") + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + return f.read() + + fields = self.get_fields() + return fields.get(LXMF.FIELD_AUDIO, None) + + def get_attachment_file_path(self, field_type, field_index=0): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest and field_index < len(manifest["files"]): + return os.path.join(att_dir, manifest["files"][field_index]["stored_name"]) + # Fallback for old extraction format + if field_type == "image": + fpath = os.path.join(att_dir, "image") + if os.path.isfile(fpath): + return fpath + elif field_type == "audio": + fpath = os.path.join(att_dir, "audio") + if os.path.isfile(fpath): + return fpath + return None + + def _attachment_dir(self): + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() + msg_hash = self.get_hash() + if msg_hash: + return os.path.join(app.attachmentpath, RNS.hexrep(msg_hash, delimit=False)) + except Exception: + pass + return None + + def _read_attachment_manifest(self, att_dir): + manifest_path = os.path.join(att_dir, "manifest") + if os.path.isfile(manifest_path): + try: + with open(manifest_path, "rb") as f: + return msgpack.unpackb(f.read(), raw=False) + except Exception: + pass + return None + + @staticmethod + def extract_attachments_from_lxm(lxmessage, app): + if not hasattr(lxmessage, "get_fields"): + return + fields = lxmessage.get_fields() + if not fields or not isinstance(fields, dict): + return + + has_any = ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + if not has_any: + return + + msg_hash_hex = RNS.hexrep(lxmessage.hash, delimit=False) + att_dir = os.path.join(app.attachmentpath, msg_hash_hex) + if os.path.isdir(att_dir): + return + + os.makedirs(att_dir) + manifest = {"files": []} + + file_attachments = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + if file_attachments: + for idx, att in enumerate(file_attachments): + if isinstance(att, list) and len(att) >= 2: + filename = str(att[0]) + data = att[1] if isinstance(att[1], bytes) else b"" + stored_name = "file_"+str(idx) + with open(os.path.join(att_dir, stored_name), "wb") as f: + f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + if LXMF.FIELD_IMAGE in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) + if data: + ext = ConversationMessage._ext_from_media_format(fmt, data) + filename = "image" + ext + stored_name = "file_"+str(len(manifest["files"])) + with open(os.path.join(att_dir, stored_name), "wb") as f: + f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + if LXMF.FIELD_AUDIO in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) + if data: + ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) + filename = "audio" + ext + stored_name = "file_"+str(len(manifest["files"])) + with open(os.path.join(att_dir, stored_name), "wb") as f: + f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + with open(os.path.join(att_dir, "manifest"), "wb") as f: + f.write(msgpack.packb(manifest)) + + @staticmethod + def _unpack_media_field(field_data): + """Normalize FIELD_IMAGE/FIELD_AUDIO which can be raw bytes or [format, bytes]. + Format element can be a string ('webp') or integer (LXMF audio mode constant).""" + if isinstance(field_data, bytes): + return None, field_data + elif isinstance(field_data, list) and len(field_data) >= 2 and isinstance(field_data[1], bytes): + return field_data[0], field_data[1] + return None, None + + @staticmethod + def _detect_image_ext(data): + if not isinstance(data, bytes) or len(data) < 12: + return ".bin" + if data[:8] == b'\x89PNG\r\n\x1a\n': + return ".png" + elif data[:3] == b'\xff\xd8\xff': + return ".jpg" + elif data[:4] == b'GIF8': + return ".gif" + elif data[:4] == b'RIFF' and data[8:12] == b'WEBP': + return ".webp" + elif data[:4] == b'\x00\x00\x00\x1c' or data[:4] == b'\x00\x00\x00\x18': + return ".heic" + return ".bin" + + @staticmethod + def _detect_audio_ext(data): + if not isinstance(data, bytes) or len(data) < 12: + return ".bin" + if data[:4] == b'OggS': + return ".ogg" + elif data[:2] == b'\xff\xfb' or data[:3] == b'ID3': + return ".mp3" + elif data[:4] == b'RIFF' and data[8:12] == b'WAVE': + return ".wav" + elif data[:4] == b'fLaC': + return ".flac" + return ".bin" + + @staticmethod + def _ext_from_media_format(fmt, data, is_audio=False): + """Derive file extension from format identifier and data. + fmt can be a string ('webp'), an integer (LXMF audio mode), or None.""" + if isinstance(fmt, str) and len(fmt) > 0: + return "." + fmt.lower().strip(".") + if isinstance(fmt, int) and is_audio: + if fmt >= 16 and fmt <= 25: + return ".ogg" + elif fmt >= 1 and fmt <= 9: + return ".c2" + if is_audio: + return ConversationMessage._detect_audio_ext(data) + return ConversationMessage._detect_image_ext(data) + + @staticmethod + def strip_attachments_from_file(file_path, app): + try: + with open(file_path, "rb") as f: + container = msgpack.unpackb(f.read(), strict_map_key=False) + + lxmf_bytes = container[b"lxmf_bytes"] if b"lxmf_bytes" in container else container.get("lxmf_bytes") + if lxmf_bytes is None: + return + + dest_len = LXMF.LXMessage.DESTINATION_LENGTH + sig_len = LXMF.LXMessage.SIGNATURE_LENGTH + header_len = 2 * dest_len + sig_len + + header = lxmf_bytes[:header_len] + payload_bytes = lxmf_bytes[header_len:] + payload = msgpack.unpackb(payload_bytes, strict_map_key=False) + + if len(payload) < 4 or not isinstance(payload[3], dict): + return + + fields = payload[3] + attachment_keys = [LXMF.FIELD_FILE_ATTACHMENTS, LXMF.FIELD_IMAGE, LXMF.FIELD_AUDIO] + if not any(k in fields for k in attachment_keys): + return + + # Compute message hash matching LXMF's logic + if len(payload) > 4: + hash_payload = msgpack.packb(payload[:4]) + else: + hash_payload = payload_bytes + msg_hash = RNS.Identity.full_hash(lxmf_bytes[:2*dest_len] + hash_payload) + msg_hash_hex = RNS.hexrep(msg_hash, delimit=False) + + att_dir = os.path.join(app.attachmentpath, msg_hash_hex) + if not os.path.isdir(att_dir): + return + + for k in attachment_keys: + if k in fields: + del fields[k] + + new_lxmf_bytes = header + msgpack.packb(payload) + key = b"lxmf_bytes" if b"lxmf_bytes" in container else "lxmf_bytes" + container[key] = new_lxmf_bytes + + with open(file_path, "wb") as f: + f.write(msgpack.packb(container)) + + except Exception as e: + RNS.log("Error stripping attachments from LXM file: "+str(e), RNS.LOG_ERROR) + + def to_index_entry(self): + return { + "timestamp": self.timestamp, + "sort_timestamp": self.sort_timestamp, + "state": self._cached_state, + "title": self._cached_title, + "content": self._cached_content, + "source_hash": self._cached_source_hash, + "transport_encrypted": self._cached_transport_encrypted, + "transport_encryption": self._cached_transport_encryption, + "signature_validated": self._cached_signature_validated, + "unverified_reason": self._cached_unverified_reason, + "method": self._cached_method, + "has_attachments": self._cached_has_attachments, + "attachment_names": self._cached_attachment_names, + } + + def restore_from_index(self, entry): + self.timestamp = entry.get("timestamp") + self.sort_timestamp = entry.get("sort_timestamp", self.sort_timestamp) + self._cached_state = entry.get("state") + self._cached_title = entry.get("title") + self._cached_content = entry.get("content") + self._cached_source_hash = entry.get("source_hash") + self._cached_transport_encrypted = entry.get("transport_encrypted") + self._cached_transport_encryption = entry.get("transport_encryption") + self._cached_signature_validated = entry.get("signature_validated") + self._cached_unverified_reason = entry.get("unverified_reason") + self._cached_method = entry.get("method") + self._cached_has_attachments = entry.get("has_attachments") + self._cached_attachment_names = entry.get("attachment_names") + + @staticmethod + def read_index(conversation_path): + index_path = os.path.join(conversation_path, ".index") + if os.path.isfile(index_path): + try: + with open(index_path, "rb") as f: + return msgpack.unpackb(f.read(), raw=False) + except Exception: + pass + return {} + + @staticmethod + def write_index(conversation_path, messages): + index_path = os.path.join(conversation_path, ".index") + index = {} + if os.path.isfile(index_path): + try: + with open(index_path, "rb") as f: + index = msgpack.unpackb(f.read(), raw=False) + except Exception: + index = {} + + for msg in messages: + if msg._cached_state is not None: + filename = os.path.basename(msg.file_path) + index[filename] = msg.to_index_entry() + + try: + with open(index_path, "wb") as f: + f.write(msgpack.packb(index)) + except Exception as e: + RNS.log("Error writing conversation index: "+str(e), RNS.LOG_ERROR) \ No newline at end of file diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 9cf19b8..183730c 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -107,6 +107,7 @@ class NomadNetworkApp: self.directorypath = self.configdir+"/storage/directory" self.peersettingspath = self.configdir+"/storage/peersettings" self.tmpfilespath = self.configdir+"/storage/tmp" + self.attachmentpath = self.configdir+"/storage/attachments" self.pagespath = self.configdir+"/storage/pages" self.filespath = self.configdir+"/storage/files" @@ -114,6 +115,7 @@ class NomadNetworkApp: self.examplespath = self.configdir+"/examples" self.downloads_path = os.path.expanduser("~/Downloads") + self.attachment_save_path = None self.firstrun = False self.should_run_jobs = True @@ -165,6 +167,9 @@ class NomadNetworkApp: if not os.path.isdir(self.tmpfilespath): os.makedirs(self.tmpfilespath) + + if not os.path.isdir(self.attachmentpath): + os.makedirs(self.attachmentpath) else: self.clear_tmp_dir() @@ -736,6 +741,10 @@ class NomadNetworkApp: value = self.config["client"]["downloads_path"] self.downloads_path = os.path.expanduser(value) + if option == "attachment_save_path": + value = self.config["client"]["attachment_save_path"] + self.attachment_save_path = os.path.expanduser(value) + if option == "announce_at_start": value = self.config["client"].as_bool(option) self.peer_announce_at_start = value @@ -1059,6 +1068,11 @@ destination = file enable_client = yes user_interface = text downloads_path = ~/Downloads + +# Where to save received attachments. If not set, +# attachments will be saved to the downloads path. +# attachment_save_path = ~/Downloads + notify_on_new_message = yes # By default, the peer is announced at startup diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 11dd2c7..a1356f3 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -146,6 +146,9 @@ GLYPHS = { ("qrcode", "QR", "\u25a4", "\uf029"), ("selected", "[*] ", "\u25CF", "\u25CF"), ("unselected", "[ ] ", "\u25CB", "\u25CB"), + ("file", "[F]", "\u25a4", "\uf15b"), + ("image", "[I]", "\u25a3", "\uf1c5"), + ("audio", "[~]", "\u266b", "\uf1c7"), } class TextUI: diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 94c5015..c0a576e 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1,26 +1,63 @@ import RNS import os +import shutil import time import nomadnet import LXMF import urwid -from datetime import datetime +from datetime import datetime, timedelta from nomadnet.Directory import DirectoryEntry + + +def relative_time(timestamp): + now = time.time() + delta = now - timestamp + if delta < 0: + return "just now" + elif delta < 60: + return "just now" + elif delta < 3600: + m = int(delta / 60) + return str(m)+"m ago" + elif delta < 86400: + h = int(delta / 3600) + return str(h)+"h ago" + elif delta < 172800: + return "yesterday" + elif delta < 604800: + d = int(delta / 86400) + return str(d)+"d ago" + elif delta < 2592000: + w = int(delta / 604800) + return str(w)+"w ago" + else: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d") + + +def _format_size(size): + if size < 1024: + return str(size)+" B" + elif size < 1048576: + return str(round(size/1024, 1))+" KB" + else: + return str(round(size/1048576, 1))+" MB" + + from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox class ConversationListDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-g] Fullscreen"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-o] Sort [C-g] Fullscreen"), "shortcutbar") class ConversationDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-k] Clear [C-w] Close [C-u] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-a] Attach [C-s] Save [C-k] Clear [C-w] Close [C-u] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") class ConversationsArea(urwid.LineBox): def keypress(self, size, key): @@ -36,6 +73,8 @@ class ConversationsArea(urwid.LineBox): self.delegate.sync_conversations() elif key == "ctrl g": self.delegate.toggle_fullscreen() + elif key == "ctrl o": + self.delegate.toggle_list_sort() elif key == "tab": self.delegate.app.ui.main_display.frame.focus_position = "header" elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): @@ -46,7 +85,11 @@ class ConversationsArea(urwid.LineBox): class DialogLineBox(urwid.LineBox): def keypress(self, size, key): if key == "esc": - self.delegate.update_conversation_list() + if hasattr(self.delegate, "update_conversation_list"): + self.delegate.update_conversation_list() + elif hasattr(self.delegate, "dialog_active"): + self.delegate.dialog_active = False + self.delegate.conversation_changed(None) else: return super(DialogLineBox, self).keypress(size, key) @@ -55,11 +98,15 @@ class ConversationsDisplay(): given_list_width = 52 cached_conversation_widgets = {} + SORT_RECENT = 0 + SORT_NAME = 1 + def __init__(self, app): self.app = app self.dialog_open = False self.sync_dialog = None self.currently_displayed_conversation = None + self.list_sort_mode = ConversationsDisplay.SORT_RECENT def disp_list_shortcuts(sender, arg1, arg2): self.shortcuts_display = self.list_shortcuts @@ -85,16 +132,23 @@ class ConversationsDisplay(): nomadnet.Conversation.created_callback = self.update_conversation_list def focus_change_event(self): - # This hack corrects buggy styling behaviour in IndicativeListBox if not self.dialog_open: - ilb_position = self.ilb.get_selected_position() self.update_conversation_list() - if ilb_position != None: - self.ilb.select_item(ilb_position) + + def toggle_list_sort(self): + if self.list_sort_mode == ConversationsDisplay.SORT_RECENT: + self.list_sort_mode = ConversationsDisplay.SORT_NAME + else: + self.list_sort_mode = ConversationsDisplay.SORT_RECENT + self.update_conversation_list() def update_listbox(self): + conversations = self.app.conversations() + if self.list_sort_mode == ConversationsDisplay.SORT_NAME: + conversations.sort(key=lambda e: (e[3].lower(), e[0])) + conversation_list_widgets = [] - for conversation in self.app.conversations(): + for conversation in conversations: conversation_list_widgets.append(self.conversation_list_widget(conversation)) self.list_widgets = conversation_list_widgets @@ -706,9 +760,13 @@ class ConversationsDisplay(): pass def update_conversation_list(self): - ilb_position = self.ilb.get_selected_position() + selected_hash = None + selected_item = self.ilb.get_selected_item() + if selected_item is not None: + if hasattr(selected_item, "source_hash"): + selected_hash = selected_item.source_hash + self.update_listbox() - # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) if not (self.dialog_open and self.sync_dialog != None): self.columns_widget.contents[0] = (self.listbox, options) @@ -726,8 +784,11 @@ class ConversationsDisplay(): ) self.columns_widget.contents[0] = (overlay, options) - if ilb_position != None: - self.ilb.select_item(ilb_position) + if selected_hash is not None: + for idx, widget in enumerate(self.list_widgets): + if widget.source_hash == selected_hash: + self.ilb.select_item(idx) + break nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() if self.app.ui.main_display.sub_displays.active_display == self.app.ui.main_display.sub_displays.conversations_display: @@ -803,10 +864,11 @@ class ConversationsDisplay(): def conversation_list_widget(self, conversation): - trust_level = conversation[2] - display_name = conversation[1] - source_hash = conversation[0] - unread = conversation[4] + trust_level = conversation[2] + display_name = conversation[1] + source_hash = conversation[0] + unread = conversation[4] + last_activity = conversation[5] g = self.app.ui.glyphs @@ -842,9 +904,15 @@ class ConversationsDisplay(): if trust_level != DirectoryEntry.UNTRUSTED: if unread: if source_hash != self.currently_displayed_conversation: - display_text += " "+g["unread"] + if unread > 1: + display_text += " "+g["unread"]+" ("+str(unread)+")" + else: + display_text += " "+g["unread"] + if last_activity > 0: + display_text += "\n "+relative_time(last_activity) + widget = ListEntry(display_text) urwid.connect_signal(widget, "click", self.display_conversation, conversation[0]) display_widget = urwid.AttrMap(widget, style, focus_style) @@ -893,6 +961,10 @@ class MessageEdit(urwid.Edit): self.delegate.send_message() elif key == "ctrl p": self.delegate.paper_message() + elif key == "ctrl a": + self.delegate.attach_file() + elif key == "ctrl s": + self.delegate.save_focused_attachments() elif key == "ctrl k": self.delegate.clear_editor() elif key == "up": @@ -913,7 +985,9 @@ class MessageEdit(urwid.Edit): class ConversationFrame(urwid.Frame): def keypress(self, size, key): if self.focus_position == "body": - if key == "up" and self.delegate.messagelist.top_is_visible: + if getattr(self.delegate, "dialog_active", False) or getattr(self.delegate, "dialog_open", False): + return super(ConversationFrame, self).keypress(size, key) + elif key == "up" and self.delegate.messagelist.top_is_visible: nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "down" and self.delegate.messagelist.bottom_is_visible: self.focus_position = "footer" @@ -941,6 +1015,8 @@ class ConversationWidget(urwid.WidgetWrap): self.message_widgets = [] self.sort_by_timestamp = False self.updating_message_widgets = False + self.pending_attachments = [] + self.dialog_active = False self.update_message_widgets() @@ -956,13 +1032,17 @@ class ConversationWidget(urwid.WidgetWrap): msg_editor.delegate = self msg_editor.name = "content_editor" - header = None + self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") + self._update_peer_info() + + header_widgets = [self.peer_info_widget] if self.conversation.trust_level == DirectoryEntry.UNTRUSTED: - header = urwid.AttrMap( + header_widgets.append(urwid.AttrMap( urwid.Padding( urwid.Text(g["warning"]+" Warning: Conversation with untrusted peer "+g["warning"], align=urwid.CENTER)), "msg_warning_untrusted", - ) + )) + header = urwid.Pile(header_widgets) self.minimal_editor = urwid.AttrMap(msg_editor, "msg_editor") self.minimal_editor.name = "minimal_editor" @@ -1001,6 +1081,40 @@ class ConversationWidget(urwid.WidgetWrap): super().__init__(self.display_widget) + def _update_peer_info(self): + g = self.app.ui.glyphs + source_hash_bytes = bytes.fromhex(self.source_hash) + + display_name = self.app.directory.display_name(source_hash_bytes) + app_data = None + if display_name is None or self.app.message_router.get_outbound_stamp_cost(source_hash_bytes) is None: + app_data = RNS.Identity.recall_app_data(source_hash_bytes) + + if display_name is None: + if app_data: + display_name = LXMF.display_name_from_app_data(app_data) + if display_name is None: + display_name = RNS.prettyhexrep(source_hash_bytes) + + stamp_cost = self.app.message_router.get_outbound_stamp_cost(source_hash_bytes) + if stamp_cost is None and app_data: + stamp_cost = LXMF.stamp_cost_from_app_data(app_data) + + hops = RNS.Transport.hops_to(source_hash_bytes) + if hops >= RNS.Transport.PATHFINDER_M: + hops_str = "unknown" + else: + hops_str = str(hops)+" hop" + ("s" if hops != 1 else "") + + right_parts = [] + if stamp_cost is not None: + right_parts.append("Stamp: "+str(stamp_cost)) + right_parts.append(g["speed"]+hops_str) + + left = " "+display_name + right = " ".join(right_parts)+" " + self.peer_info_widget.original_widget.set_text(left+" | "+right) + def clear_history_dialog(self): def dismiss_dialog(sender): self.dialog_open = False @@ -1039,20 +1153,38 @@ class ConversationWidget(urwid.WidgetWrap): self.frame.contents["body"] = (overlay, self.frame.options()) self.frame.focus_position = "body" + def _build_footer(self): + g = self.app.ui.glyphs + if self.full_editor_active: + editor = self.full_editor + else: + editor = self.minimal_editor + + if self.pending_attachments: + attachment_texts = [] + for path in self.pending_attachments: + attachment_texts.append(os.path.basename(path)) + indicator = urwid.AttrMap( + urwid.Text(g["file"]+" "+str(len(self.pending_attachments))+" file(s): "+", ".join(attachment_texts)), + "msg_header_sent", + ) + return urwid.Pile([indicator, editor]) + else: + return editor + def toggle_editor(self): if self.full_editor_active: - self.frame.contents["footer"] = (self.minimal_editor, None) self.full_editor_active = False else: - self.frame.contents["footer"] = (self.full_editor, None) self.full_editor_active = True + self.frame.contents["footer"] = (self._build_footer(), None) def check_editor_allowed(self): g = self.app.ui.glyphs if self.frame: allowed = nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)) if allowed: - self.frame.contents["footer"] = (self.minimal_editor, None) + self.frame.contents["footer"] = (self._build_footer(), None) else: warning = urwid.AttrMap( urwid.Padding(urwid.Text( @@ -1096,10 +1228,16 @@ class ConversationWidget(urwid.WidgetWrap): elif key == "ctrl o": self.sort_by_timestamp ^= True self.conversation_changed(None) + elif key == "ctrl a": + self.attach_file() + elif key == "ctrl s": + self.save_focused_attachments() else: return super(ConversationWidget, self).keypress(size, key) def conversation_changed(self, conversation): + if hasattr(self, "peer_info_widget"): + self._update_peer_info() self.update_message_widgets(replace = True) def update_message_widgets(self, replace = False): @@ -1115,7 +1253,8 @@ class ConversationWidget(urwid.WidgetWrap): added_hashes.append(message_hash) message_widget = LXMessageWidget(message) self.message_widgets.append(message_widget) - + message.unload() + if self.sort_by_timestamp: self.message_widgets.sort(key=lambda m: m.timestamp, reverse=False) else: @@ -1134,15 +1273,155 @@ class ConversationWidget(urwid.WidgetWrap): def clear_editor(self): self.content_editor.set_edit_text("") self.title_editor.set_edit_text("") + self.pending_attachments = [] + self.frame.contents["footer"] = (self._build_footer(), None) + + def _collect_attachment_refs(self): + g = self.app.ui.glyphs + refs = [] + sorted_messages = sorted(self.conversation.messages, key=lambda m: m.sort_timestamp, reverse=True) + for conv_message in sorted_messages: + if not conv_message.has_attachments(): + continue + + cached_names = conv_message._cached_attachment_names or [] + att_file_idx = 0 + for atype, aname, *arest in cached_names: + asize = arest[0] if arest else 0 + glyph = g["file"] if atype == "file" else g[atype] + label = glyph+" "+aname + if asize > 0: + label += " ("+_format_size(asize)+")" + if atype == "file": + refs.append((label, aname, conv_message, "file", att_file_idx)) + att_file_idx += 1 + else: + refs.append((label, aname, conv_message, atype, 0)) + + return refs + + def save_focused_attachments(self): + g = self.app.ui.glyphs + self.dialog_active = True + + try: + attachment_items = self._collect_attachment_refs() + except Exception as e: + RNS.log("Error collecting attachments: "+str(e), RNS.LOG_ERROR) + attachment_items = [] + + save_dir = self.app.attachment_save_path if self.app.attachment_save_path else self.app.downloads_path + + def dismiss_dialog(sender): + self.dialog_active = False + self.conversation_changed(None) + + if not attachment_items: + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("No attachments in this conversation.\n"), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), title="Attachments" + ) + dialog.delegate = self + bottom = self.messagelist + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=45, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + return + + checkboxes = [] + for label, filename, conv_msg, field_type, field_index in attachment_items: + cb = urwid.CheckBox(label, state=False) + cb._attachment_filename = filename + cb._conv_message = conv_msg + cb._field_type = field_type + cb._field_index = field_index + checkboxes.append(cb) + + status_text = urwid.Text("") + + def do_save(sender): + saved = [] + errors = [] + for cb in checkboxes: + if cb.get_state(): + try: + src_path = cb._conv_message.get_attachment_file_path(cb._field_type, cb._field_index) + if src_path and os.path.isfile(src_path): + path = _copy_attachment_to_dest(cb._attachment_filename, src_path) + saved.append(path) + except Exception as e: + errors.append(str(e)) + + if saved: + lines = [g["check"]+" Copied "+str(len(saved))+" file(s) to "+save_dir+":"] + for p in saved: + lines.append(" "+os.path.basename(p)) + if errors: + lines.append(g["cross"]+" "+str(len(errors))+" failed") + status_text.set_text("\n".join(lines)) + elif errors: + status_text.set_text(g["cross"]+" Failed: "+errors[0]) + else: + status_text.set_text("No files selected") + + dialog_widgets = list(checkboxes) + dialog_widgets.append(urwid.Divider(g["divider1"])) + dialog_widgets.append(urwid.Text("Copy to: "+save_dir)) + dialog_widgets.append(status_text) + dialog_widgets.append(urwid.Text("")) + dialog_widgets.append(urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Copy to Downloads", on_press=do_save)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Close", on_press=dismiss_dialog)), + ])) + + dialog = DialogLineBox(urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)), title="Attachments") + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=("relative", 80), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" def send_message(self): content = self.content_editor.get_edit_text() title = self.title_editor.get_edit_text() if not content == "": - if self.conversation.send(content, title): + fields = None + if self.pending_attachments: + file_attachments = [] + for file_path in self.pending_attachments: + try: + with open(file_path, "rb") as af: + file_data = af.read() + file_name = os.path.basename(file_path) + file_attachments.append([file_name, file_data]) + except Exception as e: + RNS.log("Error reading attachment "+str(file_path)+": "+str(e), RNS.LOG_ERROR) + + if file_attachments: + fields = {LXMF.FIELD_FILE_ATTACHMENTS: file_attachments} + + if self.conversation.send(content, title, fields=fields): self.clear_editor() - else: - pass + + def attach_file(self): + self.dialog_active = True + browser = FileBrowserDialog(self) + bottom = self.messagelist + overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def file_browser_closed(self): + self.dialog_active = False + self.frame.contents["footer"] = (self._build_footer(), None) + self.conversation_changed(None) def paper_message_saved(self, path): g = self.app.ui.glyphs @@ -1275,55 +1554,443 @@ class LXMessageWidget(urwid.WidgetWrap): g = app.ui.glyphs self.timestamp = message.get_timestamp() self.sort_timestamp = message.sort_timestamp + self.transfer_done = False + self._live_lxm = None + + msg_hash = message.get_hash() + msg_state = message.get_state() + msg_source_hash = message._cached_source_hash + msg_method = message._cached_method time_format = app.time_format message_time = datetime.fromtimestamp(self.timestamp) encryption_string = "" if message.get_transport_encrypted(): - encryption_string = " ["+g["encrypted"]+" "+str(message.get_transport_encryption())+"]" + encryption_string = " "+g["encrypted"] else: - encryption_string = " ["+g["plaintext"]+" "+str(message.get_transport_encryption())+"]" - - title_string = message_time.strftime(time_format)+encryption_string + encryption_string = " "+g["plaintext"] - if app.lxmf_destination.hash == message.lxm.source_hash: - if message.lxm.state == LXMF.LXMessage.DELIVERED: + title_string = relative_time(self.timestamp)+" | "+message_time.strftime(time_format)+encryption_string + + is_outbound = False + if msg_source_hash is None: + header_style = "msg_header_failed" + title_string = g["warning"]+" "+title_string + elif app.lxmf_destination.hash == msg_source_hash: + is_outbound = True + if msg_state == LXMF.LXMessage.DELIVERED: header_style = "msg_header_delivered" - title_string = g["check"]+" "+title_string - elif message.lxm.state == LXMF.LXMessage.FAILED: + title_string = g["check"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.FAILED: header_style = "msg_header_failed" - title_string = g["cross"]+" "+title_string - elif message.lxm.method == LXMF.LXMessage.PROPAGATED and message.lxm.state == LXMF.LXMessage.SENT: + title_string = g["cross"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.REJECTED: + header_style = "msg_header_failed" + title_string = g["cross"]+" "+g["arrow_r"]+" Rejected "+title_string + elif msg_method == LXMF.LXMessage.PROPAGATED and msg_state == LXMF.LXMessage.SENT: header_style = "msg_header_propagated" - title_string = g["sent"]+" "+title_string - elif message.lxm.method == LXMF.LXMessage.PAPER and message.lxm.state == LXMF.LXMessage.PAPER: + title_string = g["sent"]+" "+g["arrow_r"]+" "+title_string + elif msg_method == LXMF.LXMessage.PAPER and msg_state == LXMF.LXMessage.PAPER: header_style = "msg_header_propagated" - title_string = g["papermsg"]+" "+title_string - elif message.lxm.state == LXMF.LXMessage.SENT: + title_string = g["papermsg"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.SENT: header_style = "msg_header_sent" - title_string = g["sent"]+" "+title_string + title_string = g["sent"]+" "+g["arrow_r"]+" "+title_string else: header_style = "msg_header_sent" title_string = g["arrow_r"]+" "+title_string else: if message.signature_validated(): header_style = "msg_header_ok" - title_string = g["check"]+" "+title_string + title_string = g["check"]+" "+g["arrow_l"]+" "+title_string else: header_style = "msg_header_caution" - title_string = g["warning"]+" "+message.get_signature_description() + "\n " + title_string + title_string = g["warning"]+" "+g["arrow_l"]+" "+message.get_signature_description() + "\n " + title_string if message.get_title() != "": title_string += " | " + message.get_title() + has_attachments = message.has_attachments() + cached_names = message._cached_attachment_names or [] + + if has_attachments and cached_names: + attachment_strings = [] + for atype, aname, *arest in cached_names: + attachment_strings.append(g[atype if atype != "file" else "file"]+" "+aname) + title_string += " | " + " ".join(attachment_strings) + title = urwid.AttrMap(urwid.Text(title_string), header_style) - display_widget = urwid.Pile([ - title, - urwid.Text(message.get_content()), - urwid.Text("") + self.progress_widget = urwid.Text("") + self.progress_attr = urwid.AttrMap(self.progress_widget, "progress_full") + + content_text = message.get_content() + content_lines = content_text.split("\n") + indented = "\n".join(" "+line for line in content_lines) + + pile_widgets = [title] + + if is_outbound and msg_state is not None and msg_state < LXMF.LXMessage.SENT and msg_hash is not None: + try: + for pending in app.message_router.pending_outbound: + if pending.hash == msg_hash: + if pending.representation == LXMF.LXMessage.RESOURCE: + self._live_lxm = pending + break + except Exception: + pass + + if self._live_lxm is not None: + pct = int(self._live_lxm.progress * 100) + bar_width = 20 + filled = int(bar_width * self._live_lxm.progress) + if app.ui.colormode >= 256: + bar = "\u2588" * filled + "\u2591" * (bar_width - filled) + else: + bar = "#" * filled + "-" * (bar_width - filled) + self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") + pile_widgets.append(self.progress_attr) + self._start_progress_poll() + + pile_widgets.append(urwid.Text(indented)) + + if has_attachments and cached_names: + att_file_idx = 0 + for atype, aname, *arest in cached_names: + glyph = g["file"] if atype == "file" else g[atype] + asize = arest[0] if arest else 0 + label = " "+glyph+" "+aname + if asize > 0: + label += " ("+_format_size(asize)+")" + if atype == "file": + pile_widgets.append(ClickableAttachment(label, aname, message, "file", att_file_idx)) + att_file_idx += 1 + else: + pile_widgets.append(ClickableAttachment(label, aname, message, atype)) + + pile_widgets.append(urwid.Text("")) + + super().__init__(urwid.Pile(pile_widgets)) + + def _start_progress_poll(self): + try: + loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop + if loop: + loop.set_alarm_in(0.3, self._poll_progress) + except Exception: + pass + + def _poll_progress(self, loop=None, user_data=None): + if self.transfer_done: + return + + if self._live_lxm is None: + self.transfer_done = True + return + + app = nomadnet.NomadNetworkApp.get_shared_instance() + g = app.ui.glyphs + progress = self._live_lxm.progress + state = self._live_lxm.state + pct = int(progress * 100) + + if state == LXMF.LXMessage.FAILED: + self.progress_widget.set_text(" "+g["cross"]+" Transfer failed") + self.transfer_done = True + self._live_lxm = None + elif state == LXMF.LXMessage.REJECTED: + self.progress_widget.set_text(" "+g["cross"]+" Rejected: too large or not accepted") + self.transfer_done = True + self._live_lxm = None + elif state >= LXMF.LXMessage.SENT: + self.progress_widget.set_text("") + self.transfer_done = True + self._live_lxm = None + else: + bar_width = 20 + filled = int(bar_width * progress) + if app.ui.colormode >= 256: + bar = "\u2588" * filled + "\u2591" * (bar_width - filled) + else: + bar = "#" * filled + "-" * (bar_width - filled) + self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") + + if not self.transfer_done: + try: + ui_loop = app.ui.loop + if ui_loop: + ui_loop.set_alarm_in(0.3, self._poll_progress) + ui_loop.draw_screen() + except Exception: + pass + + +class ClickableAttachment(urwid.Text): + def __init__(self, label, filename, conv_message, field_type, field_index=0): + self.filename = filename + self.conv_message = conv_message + self.field_type = field_type + self.field_index = field_index + self.saved = False + super().__init__(label) + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._save() + return True + return False + + def _save(self): + if self.saved: + return + app = nomadnet.NomadNetworkApp.get_shared_instance() + g = app.ui.glyphs + try: + src_path = self.conv_message.get_attachment_file_path(self.field_type, self.field_index) + if src_path and os.path.isfile(src_path): + save_path = _copy_attachment_to_dest(self.filename, src_path) + else: + if self.field_type == "file": + attachments = self.conv_message.get_file_attachments() + if self.field_index < len(attachments): + att = attachments[self.field_index] + if isinstance(att, list) and len(att) >= 2: + data = att[1] if isinstance(att[1], bytes) else b"" + else: + data = b"" + else: + data = b"" + elif self.field_type == "image": + data = self.conv_message.get_image() + data = data if isinstance(data, bytes) else b"" + elif self.field_type == "audio": + data = self.conv_message.get_audio() + data = data if isinstance(data, bytes) else b"" + else: + data = b"" + self.conv_message.unload() + if not data: + return + save_path = _save_attachment_to_disk(self.filename, data) + + self.saved = True + self.set_text(" "+g["check"]+" Copied to: "+save_path) + except Exception as e: + RNS.log("Error saving attachment: "+str(e), RNS.LOG_ERROR) + self.set_text(" "+g["cross"]+" Save failed: "+str(e)) + + +def _copy_attachment_to_dest(filename, src_path): + app = nomadnet.NomadNetworkApp.get_shared_instance() + save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path + if not os.path.isdir(save_dir): + os.makedirs(save_dir) + save_path = os.path.join(save_dir, filename) + counter = 0 + base, ext = os.path.splitext(filename) + while os.path.isfile(save_path): + counter += 1 + save_path = os.path.join(save_dir, base+"_"+str(counter)+ext) + shutil.copy2(src_path, save_path) + return save_path + + +def _save_attachment_to_disk(filename, data): + app = nomadnet.NomadNetworkApp.get_shared_instance() + save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path + if not os.path.isdir(save_dir): + os.makedirs(save_dir) + save_path = os.path.join(save_dir, filename) + counter = 0 + base, ext = os.path.splitext(filename) + while os.path.isfile(save_path): + counter += 1 + save_path = os.path.join(save_dir, base+"_"+str(counter)+ext) + with open(save_path, "wb") as f: + f.write(data) + return save_path + + +class FileBrowserEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False): + self.full_path = full_path + self.name = name + self.is_dir = is_dir + self.is_parent = is_parent + self.selected = selected + g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs + if is_parent: + display = g["arrow_l"]+" .." + elif is_dir: + display = g["arrow_r"]+" "+name+"/" + elif selected: + display = g["check"]+" "+name + else: + display = " "+name + self.text_widget = urwid.SelectableIcon(display, 0) + if is_dir or is_parent: + style = "list_trusted" + focus_style = "list_focus" + elif selected: + style = "list_trusted" + focus_style = "list_focus_trusted" + else: + style = "list_unknown" + focus_style = "list_focus" + display_widget = urwid.AttrMap(self.text_widget, style, focus_style) + super().__init__(display_widget) + + def keypress(self, size, key): + if key == "enter": + self._emit("click") + else: + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class FileBrowserDialog(urwid.WidgetWrap): + def __init__(self, delegate): + self.delegate = delegate + app = nomadnet.NomadNetworkApp.get_shared_instance() + self.g = app.ui.glyphs + self.current_path = os.path.expanduser("~") + + self.path_label = urwid.Text("") + self.status_label = urwid.Text("") + self.file_walker = urwid.SimpleFocusListWalker([]) + self.file_listbox = urwid.ListBox(self.file_walker) + + self.button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)), ]) - super().__init__(display_widget) + header_pile = urwid.Pile([ + self.path_label, + self.status_label, + urwid.Divider(self.g["divider1"]), + ]) + + footer_pile = urwid.Pile([ + urwid.Divider(self.g["divider1"]), + self.button_columns, + ]) + + self._populate() + + self.browser_frame = urwid.Frame( + self.file_listbox, + header=header_pile, + footer=footer_pile, + ) + + linebox = urwid.LineBox(self.browser_frame, title="Attach File") + super().__init__(linebox) + + def _update_status(self): + pending = self.delegate.pending_attachments + if pending: + names = [os.path.basename(p) for p in pending] + self.status_label.set_text(" "+self.g["file"]+" "+str(len(pending))+" selected: "+", ".join(names)) + else: + self.status_label.set_text(" No files selected") + + def _populate(self): + self.path_label.set_text(" "+self.current_path) + self._update_status() + + focus_pos = None + try: + focus_pos = self.file_listbox.focus_position + except Exception: + pass + + entries = [] + parent = os.path.dirname(self.current_path) + if parent != self.current_path: + entry = FileBrowserEntry("..", parent, is_parent=True) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + try: + items = sorted(os.listdir(self.current_path)) + except PermissionError: + entries.append(urwid.Text(("error_text", " Permission denied"))) + self.file_walker[:] = entries + return + + dirs = [] + files = [] + for item in items: + if item.startswith("."): + continue + full = os.path.join(self.current_path, item) + if os.path.isdir(full): + dirs.append((item, full)) + elif os.path.isfile(full): + files.append((item, full)) + + for name, full in dirs: + entry = FileBrowserEntry(name, full, is_dir=True) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + for name, full in files: + is_selected = full in self.delegate.pending_attachments + entry = FileBrowserEntry(name, full, selected=is_selected) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + if not dirs and not files: + entries.append(urwid.Text(("inactive_text", " (empty)"))) + + self.file_walker[:] = entries + if focus_pos is not None and focus_pos < len(entries): + self.file_listbox.set_focus(focus_pos) + elif entries: + self.file_listbox.set_focus(0) + + def _entry_clicked(self, entry_widget, user_data=None): + entry = user_data if user_data else entry_widget + if entry.is_dir or entry.is_parent: + self.current_path = entry.full_path + self._populate() + else: + if entry.full_path in self.delegate.pending_attachments: + self.delegate.pending_attachments.remove(entry.full_path) + else: + self.delegate.pending_attachments.append(entry.full_path) + self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None) + self._populate() + + def _dismiss(self, sender): + self.delegate.file_browser_closed() + + def _cancel(self, sender): + self.delegate.pending_attachments.clear() + self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None) + self.delegate.file_browser_closed() + + def keypress(self, size, key): + if key == "esc": + self.delegate.file_browser_closed() + return + result = super().keypress(size, key) + if result == "down" and self.browser_frame.focus_position == "body": + self.browser_frame.focus_position = "footer" + return + elif result == "up" and self.browser_frame.focus_position == "footer": + self.browser_frame.focus_position = "body" + return + return result + class SyncProgressBar(urwid.ProgressBar): def get_text(self): From 39fce2a7b89696ae237e0f4af47a094519e0571c Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:32:18 -0400 Subject: [PATCH 372/592] Fix for invalid message signatures after index failes to write --- nomadnet/ui/textui/Conversations.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index c0a576e..e68f3c2 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -9,6 +9,7 @@ import urwid from datetime import datetime, timedelta from nomadnet.Directory import DirectoryEntry +from nomadnet.Conversation import ConversationMessage def relative_time(timestamp): @@ -1247,14 +1248,25 @@ class ConversationWidget(urwid.WidgetWrap): self.updating_message_widgets = True self.message_widgets = [] added_hashes = [] + needs_index = [] for message in self.conversation.messages: message_hash = message.get_hash() if not message_hash in added_hashes: added_hashes.append(message_hash) + was_loaded = message.loaded message_widget = LXMessageWidget(message) self.message_widgets.append(message_widget) + if not was_loaded and message.loaded: + needs_index.append(message) message.unload() + if needs_index: + try: + ConversationMessage.write_index( + self.conversation.messages_path, needs_index) + except Exception: + pass + if self.sort_by_timestamp: self.message_widgets.sort(key=lambda m: m.timestamp, reverse=False) else: From 6866d97cf64b7cdbb9e785034da20eea73e2df03 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 6 Apr 2026 18:47:06 +0200 Subject: [PATCH 373/592] True-color support --- nomadnet/ui/textui/MicronParser.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index d8adbb4..a53e614 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -498,16 +498,26 @@ def make_output(state, line, url_delegate, pre_escape=False): state["formatting"]["italic"] ^= True elif c == "F": if len(line) >= i+4: - color = line[i+1:i+4] - state["fg_color"] = color - skip = 3 + if line[i+1] == "T" and len(line) >= i+8: + color = line[i+2:i+8] + state["fg_color"] = color + skip = 7 + else: + color = line[i+1:i+4] + state["fg_color"] = color + skip = 3 elif c == "f": state["fg_color"] = state["default_fg"] elif c == "B": if len(line) >= i+4: - color = line[i+1:i+4] - state["bg_color"] = color - skip = 3 + if line[i+1] == "T" and len(line) >= i+8: + color = line[i+2:i+8] + state["bg_color"] = color + skip = 7 + else: + color = line[i+1:i+4] + state["bg_color"] = color + skip = 3 elif c == "b": state["bg_color"] = state["default_bg"] elif c == "`": From c7e473452ae6d142e22f3c5cbafd12a481cacc82 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 6 Apr 2026 18:47:25 +0200 Subject: [PATCH 374/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index a25765c..88081a7 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.8" +__version__ = "0.9.9" From 398f4b01c8cc6bbbe1ca5d0cccd565cf9703196a Mon Sep 17 00:00:00 2001 From: joakim Date: Tue, 7 Apr 2026 13:37:15 +0200 Subject: [PATCH 375/592] Fix msgpack import --- nomadnet/Conversation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index a4917cb..336ae75 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -2,7 +2,7 @@ import os import RNS import LXMF import shutil -import msgpack +import RNS.vendor.umsgpack as msgpack import nomadnet from nomadnet.Directory import DirectoryEntry @@ -903,4 +903,4 @@ class ConversationMessage: with open(index_path, "wb") as f: f.write(msgpack.packb(index)) except Exception as e: - RNS.log("Error writing conversation index: "+str(e), RNS.LOG_ERROR) \ No newline at end of file + RNS.log("Error writing conversation index: "+str(e), RNS.LOG_ERROR) From 4033b414a5c8afeb236e06b548bc53173e315a98 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 21 Apr 2026 10:55:44 +0200 Subject: [PATCH 376/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 88081a7..c0984d5 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.9" +__version__ = "0.9.10" diff --git a/setup.py b/setup.py index 898c0a3..43de775 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.1.3", "lxmf>=0.9.4", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.1.7", "lxmf>=0.9.5", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 236365b618fa70316dc888f623baa73efb6e5715 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 21 Apr 2026 19:29:33 +0200 Subject: [PATCH 377/592] Versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 43de775..a3d22ed 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.1.7", "lxmf>=0.9.5", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.1.8", "lxmf>=0.9.5", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 6b49114f97ca74fd3ac8806eab89a148b53c82a3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 22 Apr 2026 13:40:55 +0200 Subject: [PATCH 378/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index c0984d5..43e38a8 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.10" +__version__ = "0.9.11" diff --git a/setup.py b/setup.py index a3d22ed..ed7c9cd 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.1.8", "lxmf>=0.9.5", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.1.9", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From c6e76ea38f0625ac55012ea90793e8d34aaaa72b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 28 Apr 2026 22:29:28 +0200 Subject: [PATCH 379/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ed7c9cd..bbdd4f9 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.1.9", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.0", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From eee7f8b416cfb73004388e27d143128412853bcb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 2 May 2026 02:01:26 +0200 Subject: [PATCH 380/592] Improved link handling --- nomadnet/ui/textui/Browser.py | 32 +++++++++++++++++++++++++----- nomadnet/ui/textui/MicronParser.py | 2 +- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index e2fb0df..caa0c5e 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -152,7 +152,10 @@ class Browser: return RNS.hexrep(RNS.Identity.full_hash(url), delimit=False) - def marked_link(self, link_target): + def marked_link(self, link_target, link_fields=None): + if link_fields: + fields_str = "|".join(link_fields) + link_target = f"{link_target}`{fields_str}" if self.status == Browser.DONE: self.link_target = link_target self.app.ui.loop.set_alarm_in(0.1, self.marked_link_job) @@ -170,7 +173,9 @@ class Browser: self.browser_footer.set_attr_map({None: style_name}) else: self.link_status_showing = True - self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to: "+str(link_target))]), "browser_controls") + lt_str = str(link_target) + if len(lt_str) > 80: lt_str = lt_str[:80]+"…" + self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to: "+lt_str)]), "browser_controls") self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) if self.page_background_color != None or self.page_foreground_color != None: style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) @@ -714,6 +719,23 @@ class Browser: def retrieve_url(self, url, request_data = None): + components = url.split("`") + if len(components) == 2: + url = components[0] + try: + link_fields_str = components[1] + if link_fields_str != "": + if not request_data: request_data = {} + for e in link_fields_str.split("|"): + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + + except Exception as e: + RNS.log(f"Malformed URL: {e}", RNS.LOG_WARNING) + raise ValueError(f"Malformed URL: {e}") + self.previous_destination_hash = self.destination_hash self.previous_path = self.path @@ -879,7 +901,7 @@ class Browser: self.link.teardown() def write_history(self): - entry = [self.destination_hash, self.path] + entry = [self.destination_hash, self.path, self.request_data] self.history.insert(self.history_ptr, entry) self.history_ptr += 1 @@ -894,7 +916,7 @@ class Browser: entry = self.history[target_ptr-1] url = RNS.hexrep(entry[0], delimit=False)+":"+entry[1] self.history_ptr = target_ptr - self.retrieve_url(url) + self.retrieve_url(url, request_data=entry[2]) def forward(self): target_ptr = self.history_ptr+1 @@ -904,7 +926,7 @@ class Browser: entry = self.history[target_ptr-1] url = RNS.hexrep(entry[0], delimit=False)+":"+entry[1] self.history_ptr = target_ptr - self.retrieve_url(url) + self.retrieve_url(url, request_data=entry[2]) def reload(self): if not self.reloading and self.status == Browser.DONE: diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index a53e614..6327cb8 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -782,7 +782,7 @@ class LinkableText(urwid.Text): if item != None: if isinstance(item, LinkSpec): if self.delegate != None: - self.delegate.marked_link(item.link_target) + self.delegate.marked_link(item.link_target, item.link_fields) else: if self.delegate != None: self.delegate.marked_link(None) From 0aa9e45d97201b48a7adc8c86f9d3a943d2da897 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 2 May 2026 02:03:47 +0200 Subject: [PATCH 381/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 43e38a8..5b70908 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.11" +__version__ = "0.9.12" From a42e1b8a3dd42f1f9cdff8634f48fb069140810d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 3 May 2026 18:06:36 +0200 Subject: [PATCH 382/592] Fixed page background and foreground colors not supporting truecolor --- nomadnet/ui/textui/Browser.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index caa0c5e..fcc205a 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1053,6 +1053,9 @@ class Browser: if endpos-(bgpos+5) == 3: bg = self.markup[bgpos+5:endpos] self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg self.page_foreground_color = None fgpos = self.markup.find("#!fg=") @@ -1061,6 +1064,9 @@ class Browser: if endpos-(fgpos+5) == 3: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] @@ -1121,6 +1127,9 @@ class Browser: if endpos-(bgpos+5) == 3: bg = self.markup[bgpos+5:endpos] self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg self.page_foreground_color = None fgpos = self.markup.find("#!fg=") @@ -1129,6 +1138,9 @@ class Browser: if endpos-(fgpos+5) == 3: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] @@ -1274,6 +1286,9 @@ class Browser: if endpos-(bgpos+5) == 3: bg = self.markup[bgpos+5:endpos] self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg self.page_foreground_color = None fgpos = self.markup.find("#!fg=") @@ -1282,6 +1297,9 @@ class Browser: if endpos-(fgpos+5) == 3: fg = self.markup[fgpos+5:endpos] self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] From 4d25e17f65784f1fe55d77a4a29c450a1bc1aeee Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 May 2026 01:18:37 +0200 Subject: [PATCH 383/592] Added table rendering to micron --- nomadnet/ui/textui/Guide.py | 24 ++++++++++++ nomadnet/ui/textui/MicronParser.py | 59 ++++++++++++++++++++++++++++++ setup.py | 2 +- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index ddb2cb3..03becdd 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1302,6 +1302,30 @@ Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/inde When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL. +>Tables + +You can include rendered tables by enclosing them in \\`t tags. Optionally, you can also specify alignment and max rendering width by adding these properties to the opening \\`t tag, like \\`tc30. Here's an example: + +`Faaa +`= +`t +| Name | Price | Qty | +| ---- | :---: | --: | +| `F3a3Apple`f | Free | `!5`! | +| Orange | Ask, nicely | 3 | +`t +`= +`` + +The above markup produces the following table: + +`t +| Name | Price | Qty | +| ---- | :---: | --: | +| `F3a3Apple`f | Free | `!5`! | +| Orange | Ask, nicely | 3 | +`t + >Fields & Requests Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 6327cb8..75fa13b 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -5,6 +5,7 @@ import time import RNS from urwid.util import is_mouse_press from urwid.text_layout import calc_coords +from RNS.Utilities.rngit.util import MarkdownToMicron DEFAULT_FG_DARK = "ddd" DEFAULT_FG_LIGHT = "222" @@ -31,12 +32,17 @@ SYNTH_SPECS = {} SECTION_INDENT = 2 INDENT_RIGHT = 1 +MAX_TABLE_WIDTH = 100 def default_state(fg=None, bg=None): if fg == None: fg = SELECTED_STYLES["plain"]["fg"] if bg == None: bg = DEFAULT_BG state = { "literal": False, + "table_mode": False, + "table_buffer": [], + "table_align": None, + "table_maxwidth": MAX_TABLE_WIDTH, "depth": 0, "fg_color": fg, "bg_color": bg, @@ -134,6 +140,29 @@ def parse_partial(line): except Exception as e: return None +def render_table(lines, state, url_delegate): + if len(lines) < 2: return None + if state["table_maxwidth"]: max_width = state["table_maxwidth"] + else: max_width = MAX_TABLE_WIDTH + if state["table_align"]: align = state["table_align"] + else: align = None + + converter = MarkdownToMicron(max_width=max_width) + micron_lines = converter.format_table_raw(lines, align=align) + + widgets = [] + for line in micron_lines: + # Disable table mode to avoid recursion + was_table_mode = state["table_mode"] + state["table_mode"] = False + + line_widgets = parse_line(line, state, url_delegate) + state["table_mode"] = was_table_mode + + if line_widgets: widgets.extend(line_widgets) + + return widgets if widgets else None + def parse_line(line, state, url_delegate): pre_escape = False if len(line) > 0: @@ -161,6 +190,36 @@ def parse_line(line, state, url_delegate): elif first_char == "#": return None + # Check for tables + if line.startswith("`t"): + line = line[2:] + align = line[0] if len(line) and line[0] in ["l", "c", "r"] else None + max_width = None + if align: line = line[1:] + if len(line): + try: max_width = int(line) + except: pass + + if state["table_mode"]: + widgets = render_table(state["table_buffer"], state, url_delegate) + state["table_mode"] = False + state["table_buffer"] = [] + state["table_align"] = None + state["table_maxwidth"] = MAX_TABLE_WIDTH + return widgets + + else: + state["table_mode"] = True + state["table_buffer"] = [] + state["table_align"] = align + state["table_maxwidth"] = max_width + return None + + # Buffer the line if in table mode + if state["table_mode"]: + state["table_buffer"].append(line) + return None + # Check for partials elif line.startswith("`{"): return parse_partial(line[2:]) diff --git a/setup.py b/setup.py index bbdd4f9..a880c39 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.0", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.1", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 37af0e66dd265b13fb81225e3e0b0193dbee1d34 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 May 2026 01:44:41 +0200 Subject: [PATCH 384/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 5b70908..5becc17 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.9.12" +__version__ = "1.0.0" From 738cb9989c17732675f9bfab3d4b0433b4a69bd3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 May 2026 20:51:34 +0200 Subject: [PATCH 385/592] Default to truecolor and nerdfonts rendering --- nomadnet/NomadNetworkApp.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 183730c..13f3371 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -1153,8 +1153,8 @@ theme = dark # colormode = monochrome # colormode = 16 # colormode = 88 -colormode = 256 -# colormode = 24bit +# colormode = 256 +colormode = 24bit # By default, unicode glyphs are used. If # you have a Nerd Font installed, you can @@ -1163,8 +1163,8 @@ colormode = 256 # your terminal doesn't support unicode. # glyphs = plain -glyphs = unicode -# glyphs = nerdfont +# glyphs = unicode +glyphs = nerdfont # You can specify whether mouse events # should be considered as input to the From e79798c826856cf5f1abed906accd17bcebf3eb9 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 May 2026 20:51:58 +0200 Subject: [PATCH 386/592] Added ability to include requst data for file downloads --- nomadnet/ui/textui/Browser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index fcc205a..fbcbc81 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -769,7 +769,7 @@ class Browser: if path.startswith("/file/"): if destination_hash != self.loopback: if destination_hash == self.destination_hash: - self.download_file(destination_hash, path) + self.download_file(destination_hash, path, data=request_data) else: RNS.log("Cannot request file download from a node that is not currently connected.", RNS.LOG_ERROR) RNS.log("The requested URL was: "+str(url), RNS.LOG_ERROR) @@ -831,7 +831,7 @@ class Browser: except Exception as e: RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR) - def download_file(self, destination_hash, path): + def download_file(self, destination_hash, path, data=None): if self.link == None or self.link.destination.hash != self.destination_hash: if not RNS.Transport.has_path(self.destination_hash): self.status = Browser.NO_PATH @@ -886,7 +886,7 @@ class Browser: self.update_display() receipt = self.link.request( path, - data = None, + data = data, response_callback = self.file_received, failed_callback = self.request_failed, progress_callback = self.response_progressed From d87700c7a9363d48b8e188e61dbae0458e7a0930 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 May 2026 20:53:13 +0200 Subject: [PATCH 387/592] Updated version --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 5becc17..5c4105c 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.0.1" diff --git a/setup.py b/setup.py index a880c39..924d023 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.1", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.2", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 859f76d4a983474ca6e545db7ad91da162007254 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 May 2026 23:05:22 +0200 Subject: [PATCH 388/592] Fixed file download speed display on stream-based resource transfers --- nomadnet/ui/textui/Browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index fbcbc81..a13f3e5 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1524,7 +1524,7 @@ class Browser: if now > self.progress_updated_at+1: td = now - self.progress_updated_at pd = self.response_progress - self.previous_progress - bd = pd*self.response_size + bd = pd*self.response_transfer_size self.response_speed = (bd/td)*8 self.previous_progress = self.response_progress self.progress_updated_at = now From b1bdb46b9930cfb26c065b3beee0634fc7e1cc25 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 4 May 2026 23:35:23 +0200 Subject: [PATCH 389/592] Fixed file saved notice sometimes disappearing immediately after file download completes --- nomadnet/ui/textui/Browser.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index a13f3e5..f4785bc 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -105,6 +105,8 @@ class Browser: self.page_background_color = None self.page_foreground_color = None self.saved_file_name = None + self.file_saved_at = 0 + self.file_save_notice_timeout = 3 self.page_data = None self.displayed_page_data = None self.auth_identity = auth_identity @@ -162,6 +164,7 @@ class Browser: def marked_link_job(self, sender, event): link_target = self.link_target + can_display = time.time() > self.file_saved_at + self.file_save_notice_timeout if link_target == None: if self.link_status_showing: @@ -172,14 +175,15 @@ class Browser: style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) self.browser_footer.set_attr_map({None: style_name}) else: - self.link_status_showing = True - lt_str = str(link_target) - if len(lt_str) > 80: lt_str = lt_str[:80]+"…" - self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to: "+lt_str)]), "browser_controls") - self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) - if self.page_background_color != None or self.page_foreground_color != None: - style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) - self.browser_footer.set_attr_map({None: style_name}) + if can_display: + self.link_status_showing = True + lt_str = str(link_target) + if len(lt_str) > 80: lt_str = lt_str[:80]+"…" + self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to: "+lt_str)]), "browser_controls") + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) def expand_shorthands(self, destination_type): if destination_type == "nnn": @@ -1433,6 +1437,7 @@ class Browser: shutil.move(file_handle.name, file_destination) self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + self.file_saved_at = time.time() else: file_name = request_receipt.response[0] @@ -1458,6 +1463,7 @@ class Browser: self.previous_progress = 0 self.update_display() + except Exception as e: RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -1521,7 +1527,7 @@ class Browser: now = time.time() if self.progress_updated_at == None: self.progress_updated_at = now - if now > self.progress_updated_at+1: + if now > self.progress_updated_at+0.5: td = now - self.progress_updated_at pd = self.response_progress - self.previous_progress bd = pd*self.response_transfer_size From 90023a9b07aea7c0a759fa861f9497a6ef971e7a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 5 May 2026 21:46:16 +0200 Subject: [PATCH 390/592] Split announce stream into separate lists and cleanup, and collect as class property --- nomadnet/Directory.py | 78 ++++++++++++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 5e33576..1d86e9e 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -55,9 +55,27 @@ class Directory: RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + @property + def announce_stream(self): + return self._node_announces+self._peer_announces+self._pn_announces + + def _clean_node_announces(self): + while len(self._node_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._node_announces.pop() + + def _clean_peer_announces(self): + while len(self._peer_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._peer_announces.pop() + + def _clean_pn_announces(self): + while len(self._pn_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._pn_announces.pop() + def __init__(self, app): self.directory_entries = {} - self.announce_stream = [] + self._node_announces = [] + self._peer_announces = [] + self._pn_announces = [] self.app = app self.announce_lock = threading.Lock() self.load_from_disk() @@ -123,7 +141,10 @@ class Directory: self.directory_entries = entries - self.announce_stream = unpacked_directory["announce_stream"] + astream = unpacked_directory["announce_stream"] + self._node_announces = [e for e in astream if e[3] == "node"] + self._peer_announces = [e for e in astream if e[3] == "peer"] + self._pn_announces = [e for e in astream if e[3] == "pn"] except Exception as e: RNS.log("Could not load directory from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -134,20 +155,19 @@ class Directory: if self.app.compact_stream: try: remove_announces = [] - for announce in self.announce_stream: + for announce in self._peer_announces: if announce[1] == source_hash: remove_announces.append(announce) for a in remove_announces: - self.announce_stream.remove(a) + self._peer_announces.remove(a) except Exception as e: RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, "peer")) - while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: - self.announce_stream.pop() + self._peer_announces.insert(0, (timestamp, source_hash, app_data, "peer")) + self._clean_peer_announces() if hasattr(self.app, "ui") and self.app.ui != None: if hasattr(self.app.ui, "main_display"): @@ -159,20 +179,19 @@ class Directory: if self.app.compact_stream: try: remove_announces = [] - for announce in self.announce_stream: + for announce in self._node_announces: if announce[1] == source_hash: remove_announces.append(announce) for a in remove_announces: - self.announce_stream.remove(a) + self._node_announces.remove(a) except Exception as e: RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, "node")) - while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: - self.announce_stream.pop() + self._node_announces.insert(0, (timestamp, source_hash, app_data, "node")) + self._clean_node_announces() if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: existing_entry = self.find(source_hash) @@ -191,7 +210,7 @@ class Directory: found_node = True break - for e in self.announce_stream: + for e in self._pn_announces: if e[1] == associated_node: found_node = True break @@ -201,32 +220,51 @@ class Directory: if self.app.compact_stream: try: remove_announces = [] - for announce in self.announce_stream: + for announce in self._pn_announces: if announce[1] == source_hash: remove_announces.append(announce) for a in remove_announces: - self.announce_stream.remove(a) + self._pn_announces.remove(a) except Exception as e: RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, "pn")) - while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: - self.announce_stream.pop() + self._pn_announces.insert(0, (timestamp, source_hash, app_data, "pn")) + self._clean_pn_announces() if hasattr(self.app, "ui") and hasattr(self.app.ui, "main_display"): self.app.ui.main_display.sub_displays.network_display.directory_change_callback() def remove_announce_with_timestamp(self, timestamp): selected_announce = None - for announce in self.announce_stream: + for announce in self._node_announces: if announce[0] == timestamp: selected_announce = announce + break if selected_announce != None: - self.announce_stream.remove(selected_announce) + self._node_announces.remove(selected_announce) + return + + for announce in self._peer_announces: + if announce[0] == timestamp: + selected_announce = announce + break + + if selected_announce != None: + self._peer_announces.remove(selected_announce) + return + + for announce in self._pn_announces: + if announce[0] == timestamp: + selected_announce = announce + break + + if selected_announce != None: + self._pn_announces.remove(selected_announce) + return def display_name(self, source_hash): if source_hash in self.directory_entries: From 8cba30e6ff1d02d0df14caf29c37ac0709501867 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 May 2026 05:06:17 +0200 Subject: [PATCH 391/592] Added draggable scrollbar in browser, thanks to @RFNexus --- nomadnet/vendor/Scrollable.py | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index e54e1b0..b36db19 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -346,6 +346,14 @@ class ScrollBar(urwid.WidgetDecoration): self.scrollbar_side = side self.scrollbar_width = max(1, width) self._original_widget_size = (0, 0) + self._sb_visible = False + self._sb_col_start = 0 + self._sb_col_end = 0 + self._sb_thumb_height = 1 + self._sb_ow_rows_max = 0 + self._sb_maxrow = 0 + self._sb_dragging = False + self._sb_drag_offset = 0 def render(self, size, focus=False): maxcol, maxrow = size @@ -360,6 +368,7 @@ class ScrollBar(urwid.WidgetDecoration): if ow_rows_max <= maxrow: # Canvas fits without scrolling - no scrollbar needed self._original_widget_size = size + self._sb_visible = False return ow.render(size, focus) ow_rows_max = ow_base.rows_max(ow_size, focus) @@ -400,9 +409,22 @@ class ScrollBar(urwid.WidgetDecoration): combinelist = [(ow_canv, None, True, ow_size[0]), (sb_canv, None, False, sb_width)] + + + + + + self._sb_visible = True + self._sb_thumb_height = thumb_height + self._sb_ow_rows_max = ow_rows_max + self._sb_maxrow = maxrow if self._scrollbar_side != SCROLLBAR_LEFT: + self._sb_col_start = ow_size[0] + self._sb_col_end = ow_size[0] + sb_width return urwid.CanvasJoin(combinelist) else: + self._sb_col_start = 0 + self._sb_col_end = sb_width return urwid.CanvasJoin(reversed(combinelist)) @property @@ -450,21 +472,63 @@ class ScrollBar(urwid.WidgetDecoration): def mouse_event(self, size, event, button, col, row, focus): ow = self._original_widget ow_size = self._original_widget_size + ow_base = self.scrolling_base_widget if hasattr(self, 'scrolling_base_widget') else ow + on_sb = (self._sb_visible + and self._sb_col_start <= col < self._sb_col_end) + + if self._sb_dragging: + if 'drag' in event: + self._set_scrollpos_from_row(row) + return True + if 'release' in event or 'press' in event: + self._sb_dragging = False + if 'release' in event: + return True + + # Begin a click/drag + if on_sb and 'press' in event and button == 1 and hasattr(ow_base, 'set_scrollpos'): + pos = ow_base.get_scrollpos(ow_size) + posmax = max(1, self._sb_ow_rows_max - self._sb_maxrow) + track_len = max(1, self._sb_maxrow - self._sb_thumb_height) + top_height = int(round(track_len * pos / posmax)) + if top_height <= row < top_height + self._sb_thumb_height: + self._sb_drag_offset = row - top_height + else: + # jump + self._sb_drag_offset = self._sb_thumb_height // 2 + self._set_scrollpos_from_row(row) + self._sb_dragging = True + return True + handled = False if hasattr(ow, 'mouse_event'): handled = ow.mouse_event(ow_size, event, button, col, row, focus) if not handled and hasattr(ow, 'set_scrollpos'): + step = 3 if button == 4: # scroll wheel up pos = ow.get_scrollpos(ow_size) - newpos = pos - 1 + newpos = pos - step if newpos < 0: newpos = 0 ow.set_scrollpos(newpos) return True elif button == 5: # scroll wheel down pos = ow.get_scrollpos(ow_size) - ow.set_scrollpos(pos + 1) + ow.set_scrollpos(pos + step) return True return False + + def _set_scrollpos_from_row(self, row): + ow_base = self.scrolling_base_widget + if not hasattr(ow_base, 'set_scrollpos'): + return + posmax = self._sb_ow_rows_max - self._sb_maxrow + if posmax <= 0: + return + track_len = max(1, self._sb_maxrow - self._sb_thumb_height) + top = max(0, min(track_len, row - self._sb_drag_offset)) + new_pos = int(round(top * posmax / track_len)) + new_pos = max(0, min(posmax, new_pos)) + ow_base.set_scrollpos(new_pos) From 2fda909da73a454f1be90582b63c6d652ce7bc4d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 May 2026 05:07:12 +0200 Subject: [PATCH 392/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 5c4105c..7863915 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.0.1" +__version__ = "1.0.2" diff --git a/setup.py b/setup.py index 924d023..97b527d 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.2", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.3", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], python_requires=">=3.7", ) From 137b64e677c3a2344f02995eed19c580e90eb644 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 May 2026 13:41:46 +0200 Subject: [PATCH 393/592] Implemented scroll acceleration --- nomadnet/vendor/Scrollable.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index b36db19..5ddab2d 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -9,8 +9,10 @@ # GNU General Public License for more details # http://www.gnu.org/licenses/gpl-3.0.txt +import time import urwid from urwid.widget import BOX, FIXED, FLOW +from collections import deque # Scroll actions SCROLL_LINE_UP = 'line up' @@ -354,6 +356,10 @@ class ScrollBar(urwid.WidgetDecoration): self._sb_maxrow = 0 self._sb_dragging = False self._sb_drag_offset = 0 + self._accel = 1 + self._sevt_interval = 0 + self._sevt_start = 0 + self._sfreq_deque = deque(maxlen=5) def render(self, size, focus=False): maxcol, maxrow = size @@ -505,12 +511,25 @@ class ScrollBar(urwid.WidgetDecoration): handled = ow.mouse_event(ow_size, event, button, col, row, focus) if not handled and hasattr(ow, 'set_scrollpos'): - step = 3 + self._sevt_interval = time.time()-self._sevt_start + if self._sevt_interval > 0.3: self._sevt_start = 0; self._accel = 1; self._sfreq_deque.clear() + if self._sevt_start == 0: self._sevt_start = time.time() + if self._sevt_interval > 0: + sevt_freq = 1/self._sevt_interval + self._sfreq_deque.append(sevt_freq) + freq_smth = sum(self._sfreq_deque)/len(self._sfreq_deque) + if len(self._sfreq_deque) >= self._sfreq_deque.maxlen: + if freq_smth > 25.0: self._accel = max(self._accel, 3) + elif freq_smth > 12.0: self._accel = max(self._accel, 2) + else: self._accel = max(self._accel, 1) + + # import RNS; RNS.log(f"a={self._accel} s={RNS.prettyfrequency(freq_smth)} f={RNS.prettyfrequency(sevt_freq)}", RNS.LOG_CRITICAL) + + step = self._accel if button == 4: # scroll wheel up pos = ow.get_scrollpos(ow_size) newpos = pos - step - if newpos < 0: - newpos = 0 + if newpos < 0: newpos = 0 ow.set_scrollpos(newpos) return True elif button == 5: # scroll wheel down From a46525cae97ef8290d8828567f4acac0ef3c636b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 May 2026 14:18:58 +0200 Subject: [PATCH 394/592] Fixed annoying cursor reset behavior on scroll --- nomadnet/ui/textui/MicronParser.py | 2 +- nomadnet/vendor/Scrollable.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 75fa13b..a043f99 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -802,7 +802,7 @@ class LinkableText(urwid.Text): super().__init__(text, align=align) self.delegate = delegate self._cursor_position = 0 - self.key_timeout = 3 + self.key_timeout = 2 self.in_columns = False if self.delegate != None: self.delegate.last_keypress = 0 diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index 5ddab2d..7accf8f 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -52,6 +52,8 @@ class Scrollable(urwid.WidgetDecoration): self._old_cursor_coords = None self._rows_max_cached = 0 self.force_forward_keypress = force_forward_keypress + self.force_cursor_update = False + self.cursor_update_forced_at = 0 super().__init__(widget) def render(self, size, focus=False): @@ -106,7 +108,9 @@ class Scrollable(urwid.WidgetDecoration): # Trimmed canvas contains the cursor, e.g. in an Edit widget self._forward_keypress = True else: - if canv_full.cursor is not None: + force_check = self.force_cursor_update and (time.time()-self.cursor_update_forced_at < 0.25) + if canv_full.cursor is not None or force_check: + self.force_cursor_update = False # Full canvas contains the cursor, but scrolled out of view self._forward_keypress = False @@ -511,6 +515,8 @@ class ScrollBar(urwid.WidgetDecoration): handled = ow.mouse_event(ow_size, event, button, col, row, focus) if not handled and hasattr(ow, 'set_scrollpos'): + ow.force_cursor_update = True + ow.cursor_update_forced_at = time.time() self._sevt_interval = time.time()-self._sevt_start if self._sevt_interval > 0.3: self._sevt_start = 0; self._accel = 1; self._sfreq_deque.clear() if self._sevt_start == 0: self._sevt_start = time.time() From 6363311a71d285e4ef9d660774fed2761d48909a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 May 2026 14:21:17 +0200 Subject: [PATCH 395/592] Adjusted logging --- nomadnet/Directory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 1d86e9e..442983b 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -20,7 +20,7 @@ class PNAnnounceHandler: data = msgpack.unpackb(app_data) if data[2] == True: - RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash)) + RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity) From fcde1c92db5273694e51bd1fcc04a0adbec118b6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 May 2026 14:24:23 +0200 Subject: [PATCH 396/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 7863915..976498a 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.0.2" +__version__ = "1.0.3" From 29a2c9f7c6308251d456d7f02b01bea65b946ae5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 6 May 2026 18:44:17 +0200 Subject: [PATCH 397/592] Added better name sanitization options --- nomadnet/Conversation.py | 38 +++++++--- nomadnet/Directory.py | 38 +++++----- nomadnet/NomadNetworkApp.py | 10 +++ nomadnet/ui/TextUI.py | 1 + nomadnet/ui/textui/Browser.py | 17 +++-- nomadnet/ui/textui/Conversations.py | 9 ++- nomadnet/ui/textui/Guide.py | 6 ++ nomadnet/ui/textui/Network.py | 29 ++++---- nomadnet/util.py | 110 +++++++++++++++++++++++++++- setup.py | 4 +- 10 files changed, 208 insertions(+), 54 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 336ae75..14844d3 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -5,6 +5,7 @@ import shutil import RNS.vendor.umsgpack as msgpack import nomadnet from nomadnet.Directory import DirectoryEntry +from LXMF import display_name_from_app_data class Conversation: cached_conversations = {} @@ -16,6 +17,9 @@ class Conversation: def received_announce(destination_hash, announced_identity, app_data): app = nomadnet.NomadNetworkApp.get_shared_instance() + if not display_name_from_app_data(app_data): + RNS.log("Ignored invalid lxmf.delivery announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + if not destination_hash in app.ignored_list: destination_hash_text = RNS.hexrep(destination_hash, delimit=False) # Check if the announced destination is in @@ -32,8 +36,7 @@ class Conversation: # for nomadnets storage and other handling functions. dn = LXMF.display_name_from_app_data(app_data) app_data = b"" - if dn != None: - app_data = dn.encode("utf-8") + if dn != None: app_data = dn.encode("utf-8") # Add the announce to the directory announce # stream logger @@ -670,9 +673,16 @@ class ConversationMessage: if os.path.isfile(manifest_path): try: with open(manifest_path, "rb") as f: - return msgpack.unpackb(f.read(), raw=False) - except Exception: - pass + manifest = msgpack.unpackb(f.read(), raw=False) + for f in manifest["files"]: + f["name"] = os.path.basename(f["name"]).encode("utf-8").decode("utf-8") + + return manifest + + except Exception as e: + RNS.log(f"Error loading attachment manifest: {e}") + return None + return None @staticmethod @@ -702,13 +712,17 @@ class ConversationMessage: file_attachments = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) if file_attachments: for idx, att in enumerate(file_attachments): - if isinstance(att, list) and len(att) >= 2: - filename = str(att[0]) - data = att[1] if isinstance(att[1], bytes) else b"" - stored_name = "file_"+str(idx) - with open(os.path.join(att_dir, stored_name), "wb") as f: - f.write(data) - manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + try: + if isinstance(att, list) and len(att) >= 2: + filename = os.path.basename(str(att[0])).encode("utf-8").decode("utf-8") + data = att[1] if isinstance(att[1], bytes) else b"" + stored_name = "file_"+str(idx) + with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + except Exception as e: + RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR) + continue if LXMF.FIELD_IMAGE in fields: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 442983b..a3f86d5 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -8,6 +8,7 @@ import RNS.vendor.umsgpack as msgpack from LXMF import pn_announce_data_is_valid from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name class PNAnnounceHandler: def __init__(self, owner): @@ -156,11 +157,9 @@ class Directory: try: remove_announces = [] for announce in self._peer_announces: - if announce[1] == source_hash: - remove_announces.append(announce) + if announce[1] == source_hash: remove_announces.append(announce) - for a in remove_announces: - self._peer_announces.remove(a) + for a in remove_announces: self._peer_announces.remove(a) except Exception as e: RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) @@ -272,26 +271,27 @@ class Directory: else: return None - def simplest_display_str(self, source_hash): + def simplest_display_str(self, source_hash, san=True): + def s(name): + if self.app.config["textui"]["sanitize_names"] and san: return sanitize_name(name) + else: return strip_modifiers(name) + trust_level = self.trust_level(source_hash) if trust_level == DirectoryEntry.WARNING or trust_level == DirectoryEntry.UNTRUSTED: if source_hash in self.directory_entries: - dn = self.directory_entries[source_hash].display_name - if dn == None: - return RNS.prettyhexrep(source_hash) - else: - return strip_modifiers(dn)+" <"+RNS.hexrep(source_hash, delimit=False)+">" - else: - return "<"+RNS.hexrep(source_hash, delimit=False)+">" + dn = s(self.directory_entries[source_hash].display_name) + if not dn: return RNS.prettyhexrep(source_hash) + else: return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">" + + else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" + else: if source_hash in self.directory_entries: - dn = self.directory_entries[source_hash].display_name - if dn == None: - return RNS.prettyhexrep(source_hash) - else: - return strip_modifiers(dn) - else: - return "<"+RNS.hexrep(source_hash, delimit=False)+">" + dn = s(self.directory_entries[source_hash].display_name) + if not dn: return RNS.prettyhexrep(source_hash) + else: return dn + + else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" def alleged_display_str(self, source_hash): if source_hash in self.directory_entries: diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 13f3371..62d2086 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -838,6 +838,11 @@ class NomadNetworkApp: else: self.config["textui"]["hide_guide"] = self.config["textui"].as_bool("hide_guide") + if not "sanitize_names" in self.config["textui"]: + self.config["textui"]["sanitize_names"] = True + else: + self.config["textui"]["sanitize_names"] = self.config["textui"].as_bool("sanitize_names") + if not "animation_interval" in self.config["textui"]: self.config["textui"]["animation_interval"] = 1 else: @@ -1178,6 +1183,11 @@ editor = nano # show up in the menu, you can disable it. hide_guide = no +# You can select whether or not to perform +# text sanitization on names received in +# announces. +sanitize_names = yes + [node] # Whether to enable node hosting diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index a1356f3..48bd74f 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -28,6 +28,7 @@ THEMES = { ("error_text", "dark red", "default", "default", "dark red", "default"), ("warning_text", "yellow", "default", "default", "#ba4", "default"), ("inactive_text", "dark gray", "default", "default", "dark gray", "default"), + ("browser_inactive", "dark gray", "default", "default", "#444", "default"), ("buttons", "light green,bold", "default", "default", "#00a533", "default"), ("msg_editor", "black", "light cyan", "standout", "#111", "#0bb"), ("msg_header_ok", "black", "light green", "standout", "#111", "#6b2"), diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index f4785bc..4c0fc57 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -13,6 +13,7 @@ from .MicronParser import markup_to_attrmaps, make_style, default_state from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.Scrollable import * from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name class BrowserFrame(urwid.Frame): def keypress(self, size, key): @@ -288,6 +289,10 @@ class Browser: def handle_lxmf_link(self, link_target): try: + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + if not type(link_target) is str: raise ValueError("Invalid data type for LXMF link") @@ -307,7 +312,7 @@ class Browser: display_name = None if display_name_data != None: - display_name = LXMF.display_name_from_app_data(display_name_data) + display_name = san(LXMF.display_name_from_app_data(display_name_data)) if not source_hash_text in [c[0] for c in existing_conversations]: entry = DirectoryEntry(bytes.fromhex(source_hash_text), display_name=display_name) @@ -343,7 +348,7 @@ class Browser: self.frame = BrowserFrame(self.browser_body, header=self.browser_header, footer=self.browser_footer) self.frame.delegate = self self.linebox = urwid.LineBox(self.frame, title="Remote Node") - self.display_widget = urwid.AttrMap(self.linebox, "inactive_text") + self.display_widget = urwid.AttrMap(self.linebox, "browser_inactive") def make_status_widget(self): if self.response_progress > 0: @@ -389,7 +394,7 @@ class Browser: def update_display(self): if self.status == Browser.DISCONECTED: - self.display_widget.set_attr_map({None: "inactive_text"}) + self.display_widget.set_attr_map({None: "browser_inactive"}) self.page_pile = None self.page_partials = {} self.browser_body = urwid.Filler( @@ -1073,7 +1078,7 @@ class Browser: self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) - except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] self.response_progress = 0 self.response_speed = None @@ -1147,7 +1152,7 @@ class Browser: self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) - except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] self.response_progress = 0 self.response_speed = None @@ -1306,7 +1311,7 @@ class Browser: self.page_foreground_color = fg try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) - except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")] + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] self.response_progress = 0 self.response_speed = None diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index e68f3c2..2af34f1 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -11,6 +11,9 @@ from datetime import datetime, timedelta from nomadnet.Directory import DirectoryEntry from nomadnet.Conversation import ConversationMessage +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name + def relative_time(timestamp): now = time.time() @@ -1083,6 +1086,10 @@ class ConversationWidget(urwid.WidgetWrap): super().__init__(self.display_widget) def _update_peer_info(self): + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + g = self.app.ui.glyphs source_hash_bytes = bytes.fromhex(self.source_hash) @@ -1093,7 +1100,7 @@ class ConversationWidget(urwid.WidgetWrap): if display_name is None: if app_data: - display_name = LXMF.display_name_from_app_data(app_data) + display_name = san(LXMF.display_name_from_app_data(app_data)) if display_name is None: display_name = RNS.prettyhexrep(source_hash_bytes) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 03becdd..ce6bcc5 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -803,6 +803,12 @@ The `!nerdfont`! set allows using a much wider range of glyphs, icons and graphi Determines whether the program should react to mouse/touch input. Must be a boolean value. < +>>> +`!sanitize_names = yes`! +>>>> +This option allows sanitizing announced names before they are displayed in the user interface. +< + >>> `!hide_guide = no`! >>>> diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index fcf2a4e..aead47d 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -7,6 +7,7 @@ from datetime import datetime from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name from .Browser import Browser @@ -275,16 +276,19 @@ class AnnounceStreamEntry(urwid.WidgetWrap): ts_string = dt.strftime(date_only_format) trust_level = self.app.directory.trust_level(source_hash) + + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) if show_destination: display_str = RNS.hexrep(source_hash, delimit=False) else: - try: - display_str = strip_modifiers(announce[2].decode("utf-8")) - if len(display_str) > 32: - display_str = display_str[:32] + "..." - except: - display_str = self.app.directory.simplest_display_str(source_hash) + try: display_str = san(announce[2].decode("utf-8")) + except: display_str = self.app.directory.simplest_display_str(source_hash) + + if not display_str: display_str = RNS.prettyhexrep(source_hash) + if len(display_str) > 34: display_str = display_str[:34] + "…" if trust_level == DirectoryEntry.UNTRUSTED: symbol = g["cross"] @@ -488,16 +492,15 @@ class AnnounceStream(urwid.WidgetWrap): if announce_type == "node" or announce_type == True: node_count += 1 - if self.current_tab == "nodes": - new_entries.append(e) + if self.current_tab == "nodes": new_entries.append(e) + elif announce_type == "peer" or announce_type == False: peer_count += 1 - if self.current_tab == "peers": - new_entries.append(e) + if self.current_tab == "peers": new_entries.append(e) + elif announce_type == "pn": pn_count += 1 - if self.current_tab == "pn": - new_entries.append(e) + if self.current_tab == "pn": new_entries.append(e) for e in new_entries: nw = AnnounceStreamEntry(self.app, e, self, show_destination=self.show_destination) @@ -980,7 +983,7 @@ class NodeEntry(urwid.WidgetWrap): g = self.app.ui.glyphs trust_level = self.app.directory.trust_level(source_hash) - display_str = self.app.directory.simplest_display_str(source_hash) + display_str = self.app.directory.simplest_display_str(source_hash, san=False) if trust_level == DirectoryEntry.UNTRUSTED: symbol = g["cross"] diff --git a/nomadnet/util.py b/nomadnet/util.py index 0d8c79f..9f2562a 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -3,6 +3,59 @@ import unicodedata invalid_rendering = ["🕵️", "☝"] +# Unicode blocks to strip (symbols, dingbats, emoji ranges) +STRIP_BLOCKS_RE = re.compile( + '[' + '\U0001F600-\U0001F64F' # Emoticons + '\U0001F300-\U0001F5FF' # Misc Symbols & Pictographs + '\U0001F680-\U0001F6FF' # Transport & Map Symbols + '\U0001F700-\U0001F77F' # Alchemical Symbols + '\U0001F780-\U0001F7FF' # Geometric Shapes Extended + '\U0001F800-\U0001F8FF' # Supplemental Arrows-C + '\U0001F900-\U0001F9FF' # Supplemental Symbols & Pictographs + '\U0001FA00-\U0001FA6F' # Chess Symbols + '\U0001FA70-\U0001FAFF' # Symbols & Pictographs Extended-A + '\U0001F1E0-\U0001F1FF' # Flags (iOS/regional indicators) + '\u2600-\u26FF' # Misc Symbols (☀, ☁, ☂, etc.) + '\u2700-\u27BF' # Dingbats (✂, ✈, ✉, ✌, etc.) + '\uFE00-\uFE0F' # Variation Selectors + '\U000E0100-\U000E01EF' # Variation Selectors Supplement + '\U0001F3FB-\U0001F3FF' # Emoji modifiers (skin tones) + ']+', + flags=re.UNICODE +) + +# Control characters and zero-width characters to strip +STRIP_CONTROL_RE = re.compile( + '[' + '\x00-\x08' # C0 controls (NUL-BS) + '\x0B\x0C' # VT, FF + '\x0E-\x1F' # C0 controls (SO-US) + '\x7F-\x9F' # DEL and C1 controls + '\u200B-\u200F' # Zero-width chars, LRM, RLM, etc. + '\u202A-\u202E' # Bidi embedding controls + '\u2060-\u206F' # Format chars (word joiner, etc.) + '\uFEFF' # BOM / Zero Width NBSP + '\uFFF0-\uFFF8' # Specials + ']+', + flags=re.UNICODE +) + +# Surrogates and private use areas +# Shouldn't appear in valid UTF-8, but strip just in case +STRIP_PRIVATE_RE = re.compile( + '[' + '\uD800-\uDFFF' # Surrogates + '\uE000-\uF8FF' # Private Use Area + '\uF900-\uFAFF' # CJK Compatibility Ideographs (keep? strip for safety) + '\uFE10-\uFE1F' # Vertical Forms + '\uFE20-\uFE2F' # Combining Half Marks + '\U000F0000-\U000FFFFF' # Supplementary Private Use Area-A + '\U00100000-\U0010FFFF' # Supplementary Private Use Area-B + ']+', + flags=re.UNICODE +) + def strip_modifiers(text): def process_characters(text): result = [] @@ -14,14 +67,18 @@ def strip_modifiers(text): if category.startswith(('L', 'N', 'P', 'S')): result.append(char) i += 1 + elif category.startswith(('M', 'Sk', 'Cf')) or char in '\u200d\u200c': i += 1 + else: result.append(char) i += 1 return ''.join(result) + if text == None: return None + for char in invalid_rendering: text = text.replace(char, " ") @@ -32,4 +89,55 @@ def strip_modifiers(text): stripped = re.sub(r'[\u200D\u200C]', '', stripped) stripped = re.sub(r'\r\n?', '\n', stripped) - return stripped + return stripped.strip().replace("\x00", "") + +def sanitize_name(name): + if name is None: return None + + # Convert to string and normalize to NFKC + # NFKC: Compatibility decomposition followed by canonical composition + # This handles: ① to 1, Ⅰ to I, etc., while keeping composed forms + name = str(name) + name = unicodedata.normalize('NFKC', name) + + # Build result using category-based filtering + result = [] + for char in name: + cat = unicodedata.category(char) + cat_prefix = cat[0] if cat else 'C' + + # Allow letters (L*), numbers (N*), and punctuation (P*) + if cat_prefix in ('L', 'N', 'P'): result.append(char) + + # Allow space separator, normalize to regular space + elif cat == 'Zs': result.append(' ') + + # Convert line/paragraph separators to space + elif cat in ('Zl', 'Zp'): result.append(' ') + + # Allow spacing combining marks (Mc) for Indic, Hebrew, etc. + elif cat == 'Mc': result.append(char) + + # Allow modifier letters (Lm) - e.g., ʰ, ʱ, ː + elif cat == 'Lm': result.append(char) + + # Strip everything else: + # - Mn (Nonspacing Mark): diacritics, combining marks (Zalgo) + # - Me (Enclosing Mark): enclosing combining marks + # - C* (Controls, Format, Surrogates, Private Use, Unassigned) + # - S* (Symbols: currency, math, modifiers, other) + + name = ''.join(result) + + # Additional block-based stripping for symbols that categories missed + name = STRIP_BLOCKS_RE.sub('', name) + name = STRIP_CONTROL_RE.sub('', name) + name = STRIP_PRIVATE_RE.sub('', name) + + # Collapse multiple whitespace characters + name = re.sub(r'\s+', ' ', name) + + # Strip leading/trailing whitespace + name = name.strip() + + return name diff --git a/setup.py b/setup.py index 97b527d..593fe8c 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.3", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"], - python_requires=">=3.7", + install_requires=["rns>=1.2.3", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"], + python_requires=">=3.8", ) From 0e68f9caed46844f4c69bdaa41be1d50450b92dc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 7 May 2026 20:24:46 +0200 Subject: [PATCH 398/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 593fe8c..491d348 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.3", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.4", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From 09c1179b007a80f7c62d88ec22d41f4d6ba7e1bf Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 9 May 2026 15:13:56 +0200 Subject: [PATCH 399/592] Fixed downloaded file sizes displaying incorrectly on stream-based resource transfers --- nomadnet/_version.py | 2 +- nomadnet/ui/textui/Browser.py | 28 +++++++++++++++++++--------- setup.py | 2 +- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 976498a..92192ee 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.0.3" +__version__ = "1.0.4" diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 4c0fc57..ada2ffe 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -106,6 +106,7 @@ class Browser: self.page_background_color = None self.page_foreground_color = None self.saved_file_name = None + self.saved_file_size = 0 self.file_saved_at = 0 self.file_save_notice_timeout = 3 self.page_data = None @@ -826,6 +827,8 @@ class Browser: fs.close() self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 self.update_display() else: @@ -891,6 +894,7 @@ class Browser: self.response_size = None self.response_transfer_size = None self.saved_file_name = None + self.saved_file_size = 0 self.update_display() receipt = self.link.request( @@ -1087,6 +1091,7 @@ class Browser: self.response_size = None self.response_transfer_size = None self.saved_file_name = None + self.saved_file_size = 0 self.loaded_from_cache = True self.update_display() @@ -1161,6 +1166,7 @@ class Browser: self.response_size = None self.response_transfer_size = None self.saved_file_name = None + self.saved_file_size = 0 self.loaded_from_cache = False self.update_display() @@ -1230,6 +1236,7 @@ class Browser: self.response_size = None self.response_transfer_size = None self.saved_file_name = None + self.saved_file_size = 0 self.update_display() @@ -1442,6 +1449,8 @@ class Browser: shutil.move(file_handle.name, file_destination) self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 self.file_saved_at = time.time() else: @@ -1460,6 +1469,8 @@ class Browser: fh.close() self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 self.status = Browser.DONE self.response_progress = 0 @@ -1545,12 +1556,13 @@ class Browser: def status_text(self): if self.status == Browser.DONE and self.response_transfer_size != None: - if self.response_time != None: - response_time_str = "{:.2f}".format(self.response_time) - else: - response_time_str = "None" + if self.response_time != None: response_time_str = "{:.2f}".format(self.response_time) + else: response_time_str = "None" - stats_string = " "+self.g["page"]+size_str(self.response_size) + if self.saved_file_name == None: resp_size = self.response_size + else: resp_size = self.saved_file_size + + stats_string = " "+self.g["page"]+size_str(resp_size) stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str stats_string += "s "+self.g["speed"]+size_str(self.response_transfer_size/self.response_time, suffix="b")+"/s" elif self.loaded_from_cache: @@ -1579,10 +1591,8 @@ class Browser: elif self.status == Browser.RECEIVING_RESPONSE: return "Receiving response..." elif self.status == Browser.DONE: - if self.saved_file_name == None: - return "Done"+stats_string - else: - return "Saved "+str(self.saved_file_name)+stats_string + if self.saved_file_name == None: return "Done"+stats_string + else: return "Saved "+str(self.saved_file_name)+stats_string elif self.status == Browser.DISCONECTED: return "Disconnected" else: diff --git a/setup.py b/setup.py index 491d348..b38d9d7 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.4", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.5", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From 19e5563060a1d7f4eca7d50bc35d506322e70ced Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 9 May 2026 15:42:53 +0200 Subject: [PATCH 400/592] Check there's actually an announce to delete --- nomadnet/ui/textui/Network.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index aead47d..9997619 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -461,9 +461,11 @@ class AnnounceStream(urwid.WidgetWrap): self.update_widget_list() def delete_selected_entry(self): - if self.ilb.get_selected_item() != None: - self.app.directory.remove_announce_with_timestamp(self.ilb.get_selected_item().original_widget.timestamp) - self.rebuild_widget_list() + sel = self.ilb.get_selected_item() + if sel != None and hasattr(sel, "original_widget") and sel.original_widget: + if hasattr(sel.original_widget, "timestamp"): + self.app.directory.remove_announce_with_timestamp(sel.original_widget.timestamp) + self.rebuild_widget_list() def rebuild_widget_list(self): self.no_content = True From e17d9a8cea71c8311e1a07bf299dcd85fd7dd982 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 11 May 2026 00:51:26 -0400 Subject: [PATCH 401/592] Initial RRC protocol support and Channels functionality --- nomadnet/NomadNetworkApp.py | 11 + nomadnet/RRC.py | 1069 +++++++++++++++++++++++ nomadnet/ui/TextUI.py | 16 +- nomadnet/ui/textui/Browser.py | 61 ++ nomadnet/ui/textui/Channels.py | 1488 ++++++++++++++++++++++++++++++++ nomadnet/ui/textui/Main.py | 12 +- nomadnet/vendor/cbor.py | 430 +++++++++ 7 files changed, 3084 insertions(+), 3 deletions(-) create mode 100644 nomadnet/RRC.py create mode 100644 nomadnet/ui/textui/Channels.py create mode 100644 nomadnet/vendor/cbor.py diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 62d2086..b0d99e2 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -41,6 +41,13 @@ class NomadNetworkApp: RNS.log("Saving directory...", RNS.LOG_VERBOSE) self.directory.save_to_disk() + if hasattr(self, "rrc") and self.rrc is not None: + try: + self.rrc.save() + self.rrc.shutdown() + except Exception: + pass + if hasattr(self.ui, "restore_ixon"): if self.ui.restore_ixon: try: @@ -301,6 +308,10 @@ class NomadNetworkApp: self.directory = nomadnet.Directory(self) + from nomadnet.RRC import RRCManager + self.rrc = RRCManager(self) + self.rrc.load() + static_peers = [] for static_peer in self.static_peers: try: diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py new file mode 100644 index 0000000..ff3b95b --- /dev/null +++ b/nomadnet/RRC.py @@ -0,0 +1,1069 @@ +import os +import re +import time +import threading +import hashlib +from collections import deque + +import RNS + +from nomadnet.vendor import cbor + + +_MENTION_RE_CACHE = {} + + +def _mention_re(nick): + if not isinstance(nick, str) or not nick: + return None + pat = _MENTION_RE_CACHE.get(nick) + if pat is None: + pat = re.compile(r"(? 32: + _MENTION_RE_CACHE.clear() + _MENTION_RE_CACHE[nick] = pat + return pat + +# https://github.com/kc1awv/rrcd/blob/main/rrcd/constants.py +RRC_VERSION = 1 + +K_V = 0 +K_T = 1 +K_ID = 2 +K_TS = 3 +K_SRC = 4 +K_ROOM = 5 +K_BODY = 6 +K_NICK = 7 + +T_HELLO = 1 +T_WELCOME = 2 + +T_JOIN = 10 +T_JOINED = 11 +T_PART = 12 +T_PARTED = 13 + +T_MSG = 20 +T_NOTICE = 21 + +T_PING = 30 +T_PONG = 31 + +T_ERROR = 40 + +T_RESOURCE_ENVELOPE = 50 + +B_HELLO_NAME = 0 +B_HELLO_VER = 1 +B_HELLO_CAPS = 2 + +B_WELCOME_HUB = 0 +B_WELCOME_VER = 1 +B_WELCOME_CAPS = 2 +B_WELCOME_LIMITS = 3 + +L_MAX_NICK_BYTES = 0 +L_MAX_ROOM_NAME_BYTES = 1 +L_MAX_MSG_BODY_BYTES = 2 +L_MAX_ROOMS_PER_SESSION = 3 +L_RATE_LIMIT_MSGS_PER_MINUTE= 4 + +CAP_RESOURCE_ENVELOPE = 0 + +B_RES_ID = 0 +B_RES_KIND = 1 +B_RES_SIZE = 2 +B_RES_SHA256 = 3 +B_RES_ENCODING = 4 + +RES_KIND_NOTICE = "notice" +RES_KIND_MOTD = "motd" +RES_KIND_BLOB = "blob" + +DEFAULT_DEST_NAME = "rrc.hub" +DEFAULT_MAX_NICK_BYTES = 32 +DEFAULT_MAX_ROOM_BYTES = 64 +DEFAULT_MAX_MSG_BYTES = 350 +DEFAULT_MAX_ROOMS = 32 +DEFAULT_RATE_PER_MINUTE = 240 + + + + + + +def _now_ms(): + return int(time.time()*1000) + + +def _msg_id(): + return os.urandom(8) + + +def _parse_room_list_notice(text): + if not isinstance(text, str): + return None + stripped = text.strip() + if stripped == "No public rooms registered": + return {} + lines = text.split("\n") + if not lines or not lines[0].lstrip().startswith("Registered public rooms"): + return None + rooms = {} + for line in lines[1:]: + s = line.strip() + if not s: + continue + if " - " in s: + name, topic = s.split(" - ", 1) + rooms[name.strip().lower()] = topic.strip() or None + else: + rooms[s.strip().lstrip("#").lower()] = None + return rooms + + +def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None): + env = { + K_V: RRC_VERSION, + K_T: int(msg_type), + K_ID: mid or _msg_id(), + K_TS: ts or _now_ms(), + K_SRC: src, + } + if room is not None: + env[K_ROOM] = room + if body is not None: + env[K_BODY] = body + if nick is not None and nick != "": + env[K_NICK] = nick + return env + + +class RRCMessage: + def __init__(self, kind, room, src, nick, text, ts): + self.kind = kind + self.room = room + self.src = src + self.nick = nick + self.text = text + self.ts = ts + self.mention = False + + +class RRCHub: + STATUS_DISCONNECTED = 0 + STATUS_CONNECTING = 1 + STATUS_CONNECTED = 2 + STATUS_FAILED = 3 + + def __init__(self, manager, hub_hash, dest_name=None, name=None): + self.manager = manager + self.hub_hash = hub_hash + self.dest_name = dest_name or DEFAULT_DEST_NAME + self.name = name or RNS.prettyhexrep(hub_hash) + + self.link = None + self.status = RRCHub.STATUS_DISCONNECTED + self.status_text = "Disconnected" + self.welcomed = False + self.hub_name = None + self.hub_version = None + + self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES + self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES + self.max_msg_body_bytes = DEFAULT_MAX_MSG_BYTES + self.max_rooms_per_session = DEFAULT_MAX_ROOMS + self.rate_limit_msgs_per_minute = DEFAULT_RATE_PER_MINUTE + + self.rooms = set() + self.messages = {} + self.notices = [] + self.unread_rooms = set() + self.mention_rooms = set() + self.members = {} + self.nicks = {} + + self.auto_reconnect = False + self.auto_list = False + + self._lock = threading.RLock() + self._resource_expectations = {} + self._sent_ids = deque(maxlen=256) + + self._hello_thread = None + self._stop_hello = threading.Event() + self._manual_disconnect = False + self._reconnect_attempts = 0 + self._reconnect_timer = None + self._pending_pings = {} + + self.available_rooms = {} + self._silent_list_pending = 0 + + def _log(self, msg, level=None): + if level is None: + level = RNS.LOG_INFO + RNS.log("[RRC "+self.name+"] "+msg, level) + + def add_room(self, room): + room_n = self._normalize_room(room) + with self._lock: + self.rooms.add(room_n) + if room_n not in self.messages: + self.messages[room_n] = [] + self.manager.save() + self.manager._notify_change(self) + return room_n + + def remove_room(self, room): + r = self._normalize_room(room) + with self._lock: + self.rooms.discard(r) + self.messages.pop(r, None) + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self.members.pop(r, None) + self.manager.save() + self.manager._notify_change(self) + + def get_members(self, room): + with self._lock: + return list(self.members.get(room, set())) + + def display_name_for(self, peer): + if not isinstance(peer, (bytes, bytearray)): + return "" + ph = bytes(peer) + with self._lock: + nick = self.nicks.get(ph) + if nick: + return nick + return ph.hex()[:10] + + def mark_read(self, room): + r = self._normalize_room(room) + with self._lock: + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self.manager._notify_change(self) + + def _normalize_room(self, room): + r = (room or "").strip().lower() + if not r: + raise ValueError("room must not be empty") + return r + + def _set_status(self, status, text=None): + self.status = status + if text is not None: + self.status_text = text + self.manager._notify_change(self) + + def connect(self): + with self._lock: + if self.status in (RRCHub.STATUS_CONNECTING, RRCHub.STATUS_CONNECTED): + return + self._manual_disconnect = False + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + self._set_status(RRCHub.STATUS_CONNECTING, "Connecting") + + t = threading.Thread(target=self._connect_worker, daemon=True) + t.start() + + def _connect_worker(self): + try: + timeout_s = 20.0 + if not RNS.Transport.has_path(self.hub_hash): + RNS.Transport.request_path(self.hub_hash) + deadline = time.monotonic() + min(5.0, timeout_s) + while time.monotonic() < deadline: + if RNS.Transport.has_path(self.hub_hash): + break + time.sleep(0.1) + + hub_identity = None + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + hub_identity = RNS.Identity.recall(self.hub_hash) + if hub_identity is not None: + break + time.sleep(0.2) + + if hub_identity is None: + self._set_status(RRCHub.STATUS_FAILED, "Hub identity unknown") + return + + app_name, aspects = RNS.Destination.app_and_aspects_from_name(self.dest_name) + hub_dest = RNS.Destination( + hub_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + app_name, + *aspects, + ) + + if hub_dest.hash != self.hub_hash: + self._set_status(RRCHub.STATUS_FAILED, "Hash/destination name mismatch") + return + + self._stop_hello.clear() + link = RNS.Link(hub_dest, established_callback=self._on_established, closed_callback=self._on_closed) + link.set_packet_callback(lambda data, pkt: self._on_packet(data)) + with self._lock: + self.link = link + + except Exception as e: + self._set_status(RRCHub.STATUS_FAILED, "Connect error: "+str(e)) + + def _on_established(self, link): + try: + link.set_resource_strategy(RNS.Link.ACCEPT_APP) + link.set_resource_callback(self._resource_advertised) + link.set_resource_started_callback(self._resource_advertised) + link.set_resource_concluded_callback(self._resource_concluded) + except Exception: + pass + + try: + link.identify(self.manager.identity) + except Exception as e: + self._log("identify failed: "+str(e), RNS.LOG_ERROR) + try: link.teardown() + except Exception: pass + return + + self._set_status(RRCHub.STATUS_CONNECTING, "Identified, sending HELLO") + + def hello_loop(): + attempts = 0 + while not self._stop_hello.is_set() and not self.welcomed and attempts < 5: + with self._lock: + cur_link = self.link + if cur_link is None or cur_link.status != RNS.Link.ACTIVE: + return + try: + self._send_hello(cur_link) + except Exception as e: + self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR) + attempts += 1 + self._stop_hello.wait(timeout=3.0) + if not self.welcomed and not self._stop_hello.is_set(): + self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout") + try: + with self._lock: + if self.link is not None: + self.link.teardown() + except Exception: + pass + + self._hello_thread = threading.Thread(target=hello_loop, daemon=True) + self._hello_thread.start() + + def _send_hello(self, link): + body = { + B_HELLO_NAME: "nomadnet", + B_HELLO_VER: "0.1", + B_HELLO_CAPS: {CAP_RESOURCE_ENVELOPE: True}, + } + env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body) + nick = self.manager.get_nickname() + if nick: + env[K_NICK] = nick + payload = cbor.encode(env) + RNS.Packet(link, payload).send() + + def _on_closed(self, link): + self._stop_hello.set() + with self._lock: + self.link = None + self.welcomed = False + self.members.clear() + self._resource_expectations.clear() + should_reconnect = self.auto_reconnect and not self._manual_disconnect + self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") + if should_reconnect: + self._schedule_reconnect() + + def _schedule_reconnect(self): + with self._lock: + self._reconnect_attempts += 1 + backoff = min(60.0, max(1.0, 2.0 ** min(self._reconnect_attempts, 6))) + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + + def fire(): + with self._lock: + self._reconnect_timer = None + if self._manual_disconnect or not self.auto_reconnect: + return + self._set_status(RRCHub.STATUS_CONNECTING, "Reconnecting (attempt "+str(self._reconnect_attempts)+")") + self.connect() + + self._reconnect_timer = threading.Timer(backoff, fire) + self._reconnect_timer.daemon = True + self._reconnect_timer.start() + self._set_status(RRCHub.STATUS_DISCONNECTED, "Reconnect in "+str(int(backoff))+"s") + + + + def disconnect(self): + self._stop_hello.set() + with self._lock: + self._manual_disconnect = True + self._reconnect_attempts = 0 + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + link = self.link + self.link = None + if link is not None: + try: link.teardown() + except Exception: pass + self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") + + def set_auto_reconnect(self, enabled): + with self._lock: + self.auto_reconnect = bool(enabled) + if not enabled and self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + self.manager.save() + self.manager._notify_change(self) + + def set_auto_list(self, enabled): + with self._lock: + self.auto_list = bool(enabled) + self.manager.save() + self.manager._notify_change(self) + + def _packet_would_fit(self, link, payload): + try: + pkt = RNS.Packet(link, payload) + pkt.pack() + return True + except Exception: + return False + + def _send_env(self, env): + with self._lock: + link = self.link + if link is None or link.status != RNS.Link.ACTIVE: + raise RuntimeError("not connected") + payload = cbor.encode(env) + if not self._packet_would_fit(link, payload): + raise RuntimeError("message exceeds link MTU") + RNS.Packet(link, payload).send() + + def join_room(self, room, key=None): + r = self._normalize_room(room) + body = key if (isinstance(key, str) and key) else None + env = _make_envelope(T_JOIN, src=self.manager.identity.hash, room=r, body=body) + nick = self.manager.get_nickname() + if nick: + env[K_NICK] = nick + self._send_env(env) + with self._lock: + if r not in self.messages: + self.messages[r] = [] + self.manager._notify_change(self) + + def send_command(self, text, room=None): + if not isinstance(text, str) or not text.startswith("/"): + raise ValueError("command must start with /") + env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=room, body=text) + nick = self.manager.get_nickname() + if nick: + env[K_NICK] = nick + self._send_env(env) + + def send_ping(self, room=None): + body = os.urandom(8) + env = _make_envelope(T_PING, src=self.manager.identity.hash, body=body) + with self._lock: + now_ms = _now_ms() + self._pending_pings[body] = (now_ms, room) + expired = [k for k, v in self._pending_pings.items() if now_ms - v[0] > 15000] + for k in expired: + self._pending_pings.pop(k, None) + self._send_env(env) + return body + + def part_room(self, room): + room_n = self._normalize_room(room) + env = _make_envelope(T_PART, src=self.manager.identity.hash, room=room_n) + try: + self._send_env(env) + except Exception: + pass + with self._lock: + self.rooms.discard(room_n) + self.manager.save() + self.manager._notify_change(self) + + def send_message(self, room, text): + r = self._normalize_room(room) + if not isinstance(text, str) or not text.strip(): + raise ValueError("message text must be non-empty") + if len(text.encode("utf-8")) > self.max_msg_body_bytes: + raise ValueError("message too long for hub limit") + env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=r, body=text) + nick = self.manager.get_nickname() + if nick: + env[K_NICK] = nick + mid = env[K_ID] + if isinstance(mid, (bytes, bytearray)): + self._sent_ids.append(bytes(mid)) + self._send_env(env) + self._record_message(RRCMessage("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) + return mid + + def _record_message(self, msg, local=False): + with self._lock: + buf = self.messages.setdefault(msg.room or "*", []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + if not local and msg.room: + if msg.room != self.manager.active_room_for(self): + self.unread_rooms.add(msg.room) + if msg.mention: + self.mention_rooms.add(msg.room) + self.manager._notify_messages(self, msg) + + def _record_system(self, room, text): + if not room: + return + msg = RRCMessage("system", room, None, None, text, _now_ms()) + with self._lock: + buf = self.messages.setdefault(room, []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + self.manager._notify_messages(self, msg) + + def _record_notice(self, msg): + target_room = msg.room + if not target_room: + target_room = self.manager.active_room_for(self) + if target_room: + msg.room = target_room + + with self._lock: + self.notices.append(msg) + if len(self.notices) > 200: + del self.notices[:len(self.notices)-200] + if target_room: + buf = self.messages.setdefault(target_room, []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + if target_room != self.manager.active_room_for(self): + self.unread_rooms.add(target_room) + self.manager._notify_messages(self, msg) + + def get_messages(self, room): + with self._lock: + buf = list(self.messages.get(room, [])) + return buf + + def _on_packet(self, data): + try: + env = cbor.decode(data) + except Exception as e: + self._log("decode failed: "+str(e), RNS.LOG_DEBUG) + return + if not isinstance(env, dict): + return + try: + t = env.get(K_T) + except Exception: + return + + if t == T_PING: + try: + pong = _make_envelope(T_PONG, src=self.manager.identity.hash, body=env.get(K_BODY)) + self._send_env(pong) + except Exception: + pass + return + + if t == T_PONG: + body = env.get(K_BODY) + if isinstance(body, (bytes, bytearray)): + key = bytes(body) + with self._lock: + pending = self._pending_pings.pop(key, None) + if pending is not None: + sent_ms, room = pending + rtt_ms = max(0, _now_ms() - sent_ms) + self._record_system(room, "Pong from hub: "+str(rtt_ms)+" ms") + return + + if t == T_WELCOME: + self.welcomed = True + body = env.get(K_BODY) + if isinstance(body, dict): + hub_name = body.get(B_WELCOME_HUB) + if isinstance(hub_name, str): + self.hub_name = hub_name + ver = body.get(B_WELCOME_VER) + if isinstance(ver, str): + self.hub_version = ver + limits = body.get(B_WELCOME_LIMITS) + if isinstance(limits, dict): + if L_MAX_NICK_BYTES in limits: + self.max_nick_bytes = int(limits[L_MAX_NICK_BYTES]) + if L_MAX_ROOM_NAME_BYTES in limits: + self.max_room_name_bytes = int(limits[L_MAX_ROOM_NAME_BYTES]) + if L_MAX_MSG_BODY_BYTES in limits: + self.max_msg_body_bytes = int(limits[L_MAX_MSG_BODY_BYTES]) + if L_MAX_ROOMS_PER_SESSION in limits: + self.max_rooms_per_session = int(limits[L_MAX_ROOMS_PER_SESSION]) + if L_RATE_LIMIT_MSGS_PER_MINUTE in limits: + self.rate_limit_msgs_per_minute = int(limits[L_RATE_LIMIT_MSGS_PER_MINUTE]) + self._set_status(RRCHub.STATUS_CONNECTED, "Connected") + with self._lock: + self._reconnect_attempts = 0 + self.manager._on_welcome(self) + if self.auto_list: + try: + with self._lock: + self._silent_list_pending += 1 + self.send_command("/list", room=None) + except Exception: + with self._lock: + if self._silent_list_pending > 0: + self._silent_list_pending -= 1 + return + + if t == T_JOINED: + room = env.get(K_ROOM) + if isinstance(room, str) and room: + r = room.strip().lower() + src = env.get(K_SRC) + nick = env.get(K_NICK) + body = env.get(K_BODY) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + self_join = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + with self._lock: + self.rooms.add(r) + if r not in self.messages: + self.messages[r] = [] + members = self.members.setdefault(r, set()) + if isinstance(body, list): + for entry in body: + if isinstance(entry, (bytes, bytearray)): + members.add(bytes(entry)) + if isinstance(src, (bytes, bytearray)): + sb = bytes(src) + if own_hash is None or sb != own_hash: + members.add(sb) + if isinstance(nick, str) and nick: + self.nicks[sb] = nick + if own_hash is not None: + members.add(own_hash) + if self_join: + self._record_system(r, "You joined #"+r) + self.manager.save() + elif isinstance(src, (bytes, bytearray)): + self._record_system(r, self.display_name_for(src)+" joined") + self.manager._notify_change(self) + return + + if t == T_PARTED: + room = env.get(K_ROOM) + if isinstance(room, str) and room: + r = room.strip().lower() + src = env.get(K_SRC) + body = env.get(K_BODY) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + self_part = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + with self._lock: + members = self.members.get(r) + if isinstance(body, list): + for entry in body: + if isinstance(entry, (bytes, bytearray)) and members is not None: + members.discard(bytes(entry)) + elif isinstance(src, (bytes, bytearray)) and members is not None: + members.discard(bytes(src)) + if self_part: + self.rooms.discard(r) + self.members.pop(r, None) + if self_part: + self.manager.save() + if not self_part and isinstance(src, (bytes, bytearray)): + self._record_system(r, self.display_name_for(src)+" left") + self.manager._notify_change(self) + return + + if t == T_MSG: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + nick = env.get(K_NICK) + mid = env.get(K_ID) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash: + if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids: + return + if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick: + with self._lock: + self.nicks[bytes(src)] = nick + if isinstance(room, str) and room: + self.members.setdefault(room.strip().lower(), set()).add(bytes(src)) + if isinstance(body, str): + msg = RRCMessage( + "msg", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + nick if isinstance(nick, str) else None, + body, + _now_ms(), + ) + is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + if not is_own: + own_nick = self.manager.get_nickname() + pat = _mention_re(own_nick) + if pat is not None and pat.search(body): + msg.mention = True + self._record_message(msg) + return + + if t == T_NOTICE: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + if isinstance(body, str): + parsed = _parse_room_list_notice(body) + if parsed is not None: + with self._lock: + self.available_rooms = parsed + silent = self._silent_list_pending > 0 + if silent: + self._silent_list_pending -= 1 + self.manager._notify_change(self) + if silent: + return + msg = RRCMessage( + "notice", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + None, + body, + _now_ms(), + ) + self._record_notice(msg) + return + + if t == T_ERROR: + body = env.get(K_BODY) + room = env.get(K_ROOM) + text = body if isinstance(body, str) else "(error)" + msg = RRCMessage( + "error", + room.strip().lower() if isinstance(room, str) else None, + None, + None, + text, + _now_ms(), + ) + self._record_notice(msg) + return + + if t == T_RESOURCE_ENVELOPE: + body = env.get(K_BODY) + if not isinstance(body, dict): + return + try: + rid = body.get(B_RES_ID) + kind = body.get(B_RES_KIND) + size = body.get(B_RES_SIZE) + sha256 = body.get(B_RES_SHA256) + encoding = body.get(B_RES_ENCODING) + if not isinstance(rid, (bytes, bytearray)): return + if not isinstance(kind, str): return + if not isinstance(size, int) or size <= 0: return + room = env.get(K_ROOM) + with self._lock: + self._resource_expectations[bytes(rid)] = { + "kind": kind, + "size": size, + "sha256": bytes(sha256) if isinstance(sha256, (bytes, bytearray)) else None, + "encoding": encoding if isinstance(encoding, str) else "utf-8", + "room": room.strip().lower() if isinstance(room, str) else None, + "expires": time.monotonic()+30.0, + } + except Exception: + pass + return + + def _resource_advertised(self, resource): + try: + if hasattr(resource, "get_data_size"): + size = resource.get_data_size() + elif hasattr(resource, "total_size"): + size = resource.total_size + else: + size = getattr(resource, "size", 0) + except Exception: + return False + if size > 262144: + return False + return True + + def _resource_concluded(self, resource): + try: + if resource.status != RNS.Resource.COMPLETE: + try: + if hasattr(resource, "data") and resource.data: + resource.data.close() + except Exception: + pass + return + try: + size = resource.total_size if hasattr(resource, "total_size") else getattr(resource, "size", 0) + except Exception: + size = 0 + data = None + try: + data = resource.data.read() + finally: + try: + if hasattr(resource, "data") and resource.data: + resource.data.close() + except Exception: + pass + if data is None: + return + + now = time.monotonic() + matched = None + with self._lock: + expired = [k for k, v in self._resource_expectations.items() if v["expires"] < now] + for k in expired: + self._resource_expectations.pop(k, None) + for k, exp in list(self._resource_expectations.items()): + if exp["size"] == len(data): + matched = exp + self._resource_expectations.pop(k, None) + break + + kind = matched["kind"] if matched else RES_KIND_BLOB + room = matched["room"] if matched else None + encoding = matched["encoding"] if matched else "utf-8" + sha = matched["sha256"] if matched else None + if sha is not None: + if hashlib.sha256(data).digest() != sha: + return + if kind in (RES_KIND_NOTICE, RES_KIND_MOTD): + try: + text = data.decode(encoding, errors="replace") + except Exception: + return + msg = RRCMessage("notice", room, None, None, text, _now_ms()) + self._record_notice(msg) + except Exception as e: + self._log("resource handling failed: "+str(e), RNS.LOG_ERROR) + + +class RRCManager: + def __init__(self, app): + self.app = app + self.hubs = [] + self._lock = threading.RLock() + self._change_callback = None + self._message_callback = None + self._active_hub = None + self._active_room = None + self._loaded = False + self._loading = False + self._save_lock = threading.Lock() + + @property + def identity(self): + return self.app.identity + + def get_nickname(self): + try: + n = self.app.peer_settings.get("display_name") + if isinstance(n, str): + return n + except Exception: + pass + return None + + def set_change_callback(self, cb): + self._change_callback = cb + + def set_message_callback(self, cb): + self._message_callback = cb + + def _notify_change(self, hub=None): + try: + if self._change_callback is not None: + self._change_callback(hub) + except Exception: + pass + + def _notify_messages(self, hub, msg): + try: + if self._message_callback is not None: + self._message_callback(hub, msg) + except Exception: + pass + + def _on_welcome(self, hub): + for r in list(hub.rooms): + try: + hub.join_room(r) + except Exception: + pass + + def set_active(self, hub, room): + self._active_hub = hub + self._active_room = room + if hub is not None and room is not None: + hub.mark_read(room) + + def active_room_for(self, hub): + if self._active_hub is hub: + return self._active_room + return None + + def has_unread(self): + with self._lock: + for hub in self.hubs: + if hub.unread_rooms: + return True + return False + + def add_hub(self, hub_hash, dest_name=None, name=None): + with self._lock: + for h in self.hubs: + if h.hub_hash == hub_hash and (h.dest_name == (dest_name or DEFAULT_DEST_NAME)): + return h + hub = RRCHub(self, hub_hash, dest_name=dest_name, name=name) + self.hubs.append(hub) + self.save() + self._notify_change() + return hub + + def remove_hub(self, hub): + with self._lock: + if hub in self.hubs: + self.hubs.remove(hub) + try: + hub.disconnect() + except Exception: + pass + self.save() + self._notify_change() + + def find_hub(self, hub_hash, dest_name=None): + dn = dest_name or DEFAULT_DEST_NAME + with self._lock: + for h in self.hubs: + if h.hub_hash == hub_hash and h.dest_name == dn: + return h + return None + + def _store_path(self): + return os.path.join(self.app.storagepath, "rrc_hubs") + + def load(self): + if self._loaded: + return + self._loaded = True + path = self._store_path() + if not os.path.isfile(path): + return + self._loading = True + try: + with open(path, "rb") as f: + data = f.read() + obj = cbor.decode(data) + if not isinstance(obj, dict): + return + entries = obj.get("hubs") + if not isinstance(entries, list): + return + for e in entries: + if not isinstance(e, dict): + continue + hh = e.get("hash") + if not isinstance(hh, (bytes, bytearray)): + continue + dn = e.get("dest_name") + nm = e.get("name") + hub = self.add_hub(bytes(hh), dest_name=dn if isinstance(dn, str) else None, name=nm if isinstance(nm, str) else None) + rooms = e.get("rooms") + if isinstance(rooms, list): + for r in rooms: + if isinstance(r, str): + hub.add_room(r) + parted = e.get("parted_rooms") + if isinstance(parted, list): + for r in parted: + if isinstance(r, str): + try: + rn = hub._normalize_room(r) + with hub._lock: + hub.messages.setdefault(rn, []) + except Exception: + pass + ar = e.get("auto_reconnect") + if isinstance(ar, bool): + hub.auto_reconnect = ar + al = e.get("auto_list") + if isinstance(al, bool): + hub.auto_list = al + except Exception as e: + RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR) + finally: + self._loading = False + + def save(self): + if self._loading: + return + path = self._store_path() + tmp_path = path + ".tmp" + with self._save_lock: + try: + entries = [] + with self._lock: + for h in self.hubs: + joined = set(h.rooms) + parted = set(h.messages.keys()) - joined + entries.append({ + "hash": h.hub_hash, + "dest_name": h.dest_name, + "name": h.name, + "rooms": sorted(joined), + "parted_rooms": sorted(parted), + "auto_reconnect": bool(h.auto_reconnect), + "auto_list": bool(h.auto_list), + }) + data = cbor.encode({"hubs": entries}) + with open(tmp_path, "wb") as f: + f.write(data) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + os.replace(tmp_path, path) + except Exception as e: + # + # + # + # + try: os.unlink(tmp_path) + except Exception: pass + + def shutdown(self): + for h in list(self.hubs): + try: + h.disconnect() + except Exception: + pass diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 48bd74f..f05ae81 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -59,6 +59,13 @@ THEMES = { ("placeholder", "dark gray", "default", "default", "dark gray", "default"), ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"), ("error", "light red,blink", "default", "blink", "#f44,blink", "default"), + ("irc_ts", "dark gray", "default", "default", "#888", "default"), + ("irc_nick_self", "light green", "default", "default", "#6c5", "default"), + ("irc_nick_peer", "light cyan", "default", "default", "#3cd", "default"), + ("irc_notice", "yellow", "default", "default", "#fd3", "default"), + ("irc_error", "light red", "default", "default", "#f55", "default"), + ("irc_system", "dark gray", "default", "default", "#888", "default"), + ("irc_mention", "light red,bold", "default", "bold", "#fb4,bold", "default"), ], }, @@ -103,7 +110,14 @@ THEMES = { ("placeholder", "light gray", "default", "default", "#999", "default"), ("placeholder_text", "light gray", "default", "default", "#999", "default"), ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), - ], + ("irc_ts", "dark gray", "default", "default", "#888", "default"), + ("irc_nick_self", "dark green", "default", "default", "#3a0", "default"), + ("irc_nick_peer", "dark cyan", "default", "default", "#077", "default"), + ("irc_notice", "brown", "default", "default", "#a70", "default"), + ("irc_error", "dark red", "default", "default", "#a22", "default"), + ("irc_system", "dark gray", "default", "default", "#888", "default"), + ("irc_mention", "dark red,bold", "default", "bold", "#c50,bold", "default"), + ], } } diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index ada2ffe..1994ac9 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -192,6 +192,8 @@ class Browser: return "nomadnetwork.node" elif destination_type == "lxmf": return "lxmf.delivery" + elif destination_type == "rrc": + return "rrc.hub.session" else: return destination_type @@ -250,6 +252,11 @@ class Browser: recurse_down(self.attr_maps) RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + # rrc://[:]/ URL form + if link_target.startswith("rrc://"): + self.handle_rrc_link(link_target[6:]) + return + components = link_target.split("@") destination_type = None @@ -280,6 +287,10 @@ class Browser: RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG) self.handle_lxmf_link(link_target) + elif destination_type == "rrc.hub.session": + RNS.log("Passing RRC link to handler", RNS.LOG_DEBUG) + self.handle_rrc_link(link_target) + elif destination_type == "partial": if partial_ids != None and len(partial_ids) > 0: self.handle_partial_updates(partial_ids) @@ -331,6 +342,56 @@ class Browser: self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + def handle_rrc_link(self, link_target): + try: + if not isinstance(link_target, str): + raise ValueError("invalid RRC link payload") + rest = link_target.strip() + if rest.startswith("/"): + rest = rest[1:] + hub_part, _, room = rest.partition("/") + hex_part, _, dest = hub_part.partition(":") + hex_part = hex_part.strip() + dest = dest.strip() or None + try: + hub_hash = bytes.fromhex(hex_part) + except Exception: + raise ValueError("invalid hub hash") + expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8 + if len(hub_hash) != expected_len: + raise ValueError("hub hash must be "+str(expected_len)+" bytes") + + room = room.strip().lstrip("#").strip() + room_norm = None + if room: + try: + # validate the room name early; pass the raw value through + from nomadnet.RRC import RRCHub as _RRCHubCls # noqa + room_norm = room.lower() + except Exception: + room_norm = None + + existing = self.app.rrc.find_hub(hub_hash, dest_name=dest) + self.app.ui.main_display.show_channels(None) + channels = self.app.ui.main_display.sub_displays.channels_display + + if existing is not None: + channels.update_list() + if room_norm: + channels._select_room(None, (existing, room_norm)) + else: + channels._select_hub(None, existing) + return + + + channels.confirm_new_hub_dialog(hub_hash, dest, room_norm) + + except Exception as e: + RNS.log("Could not open RRC link: "+str(e), RNS.LOG_ERROR) + self.browser_footer = urwid.Text("Could not open RRC link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + def micron_released_focus(self): if self.delegate != None: self.delegate.focus_lists() diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py new file mode 100644 index 0000000..5a9c7f0 --- /dev/null +++ b/nomadnet/ui/textui/Channels.py @@ -0,0 +1,1488 @@ +import collections +import os +import re +import time + +import RNS +import urwid + +import nomadnet +from nomadnet.RRC import RRCHub +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox +from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec + + +class _ChatLinkableText(LinkableText): + def render(self, size, focus=False): + c = urwid.Text.render(self, size, focus) + if focus: + c = urwid.CompositeCanvas(c) + c.cursor = self.get_cursor_coords(size) + if self.delegate is not None: + self.peek_link() + return c + + +_LINK_RE = re.compile( + r"(?P(?(?(? 0 and last_space >= len(chunk) // 2: + chunk = chunk[:last_space] + if not chunk: + chunk = remaining[:1] + chunks.append(chunk.rstrip()) + remaining = remaining[len(chunk):].lstrip() + return chunks + + +def _split_message(text, max_bytes): + if not text: + return [text] + parts = [text] + for _attempt in range(10): + K_guess = max(1, len(parts)) + prefix_bytes = len(("({}/{}) ".format(K_guess, K_guess)).encode("utf-8")) + budget = max_bytes - prefix_bytes + if budget <= 0: + return None + parts = _chunk_by_bytes(text, budget) + if len(parts) == K_guess: + break + K = len(parts) + return ["({}/{}) ".format(i+1, K) + p for i, p in enumerate(parts)] + + +def _scan_mentions(text, own_nick): + if not own_nick or not text: + return + pat = re.compile(r"(?= last_end: + filtered.append(s) + last_end = s[1] + spans = filtered + + if not spans: + return [(body_attr, body)], False + + out = [] + pos = 0 + has_links = False + for start, end, kind, target in spans: + if start > pos: + out.append((body_attr, body[pos:start])) + if kind == "mention": + out.append(("irc_mention", body[start:end])) + else: + base = _LINK_ATTRS[kind] + out.append((LinkSpec(kind+":"+target, base, cm=256), body[start:end])) + has_links = True + pos = end + if pos < len(body): + out.append((body_attr, body[pos:])) + return out, has_links + + +def _short_hash(b, n=10): + if isinstance(b, (bytes, bytearray)): + return bytes(b).hex()[:n] + return "?" + + +def _format_ts(ts_ms): + try: + return time.strftime("%H:%M:%S", time.localtime(ts_ms/1000.0)) + except Exception: + return "" + + +class ChannelsListShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar") + + +class ChannelsRoomShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave Room [C-k] Clear Editor [C-u] Toggle Users [Tab] Switch Focus"), "shortcutbar") + + +class ChannelsDialogLineBox(urwid.LineBox): + def keypress(self, size, key): + if key == "esc": + if hasattr(self.delegate, "close_dialog"): + self.delegate.close_dialog() + else: + return super(ChannelsDialogLineBox, self).keypress(size, key) + + +class ChannelListEntry(urwid.Text): + _selectable = True + signals = ["click"] + + def keypress(self, size, key): + if self._command_map[key] != urwid.ACTIVATE: + return key + self._emit("click") + + def mouse_event(self, size, event, button, x, y, focus): + if button != 1 or not urwid.util.is_mouse_press(event): + return False + self._emit("click") + return True + + +class ChannelsListArea(urwid.LineBox): + def keypress(self, size, key): + if key == "ctrl n": + self.delegate.new_hub_dialog() + elif key == "ctrl a": + self.delegate.join_room_dialog() + elif key == "ctrl r": + self.delegate.connect_selected() + elif key == "ctrl w": + self.delegate.disconnect_selected() + elif key == "ctrl t": + self.delegate.toggle_auto_reconnect_selected() + elif key == "ctrl e": + self.delegate.edit_hub_dialog() + elif key == "ctrl x": + self.delegate.remove_selected_dialog() + elif key == "tab": + self.delegate.app.ui.main_display.frame.focus_position = "header" + elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): + self.delegate.app.ui.main_display.frame.focus_position = "header" + else: + return super(ChannelsListArea, self).keypress(size, key) + + +class RoomMessageEdit(urwid.Edit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tab_state = None + + def keypress(self, size, key): + if key == "tab": + if self._try_tab_complete(): + return None + return key + self._tab_state = None + if key == "ctrl d": + self.delegate.send_message() + elif key == "ctrl k": + self.set_edit_text("") + elif key == "ctrl l": + self.delegate.leave_room() + elif key == "ctrl u": + self.delegate.toggle_users() + elif key == "up": + y = self.get_cursor_coords(size)[1] + if y == 0: + self.delegate.frame.focus_position = "body" + else: + return super(RoomMessageEdit, self).keypress(size, key) + else: + return super(RoomMessageEdit, self).keypress(size, key) + + def _candidates(self, prefix_lower): + delegate = getattr(self, "delegate", None) + if delegate is None or delegate.hub is None or delegate.room is None: + return [] + members = delegate.hub.get_members(delegate.room) + own_hash = None + try: + if delegate.app.identity is not None: + own_hash = delegate.app.identity.hash + except Exception: + pass + names = set() + for m in members: + if own_hash is not None and m == own_hash: + continue + names.add(delegate.hub.display_name_for(m)) + return sorted([n for n in names if n.lower().startswith(prefix_lower)], + key=str.lower) + + def _try_tab_complete(self): + text = self.get_edit_text() + pos = self.edit_pos + state = self._tab_state + + if state is not None and state.get("cursor_after") == pos: + prefix_lower = state["prefix"] + token_start = state["token_start"] + has_at = state["has_at"] + matches = self._candidates(prefix_lower) + if not matches: + self._tab_state = None + return False + idx = (state["idx"] + 1) % len(matches) + else: + start = pos + while start > 0 and (text[start-1].isalnum() or text[start-1] in "_-"): + start -= 1 + has_at = start > 0 and text[start-1] == "@" + token_start = start - 1 if has_at else start + token = text[start:pos] + if not token: + return False + prefix_lower = token.lower() + matches = self._candidates(prefix_lower) + if not matches: + return False + idx = 0 + + selected = matches[idx] + if has_at: + replacement = "@" + selected + elif token_start == 0: + replacement = selected + ": " + else: + replacement = selected + + new_text = text[:token_start] + replacement + text[pos:] + new_cursor = token_start + len(replacement) + self.set_edit_text(new_text) + self.set_edit_pos(new_cursor) + self._tab_state = { + "prefix": prefix_lower, + "token_start": token_start, + "has_at": has_at, + "cursor_after": new_cursor, + "idx": idx, + } + return True + + +class RoomFrame(urwid.Frame): + def keypress(self, size, key): + if key == "ctrl u": + self.delegate.toggle_users() + return None + if key == "tab": + if self.focus_position == "body": + self.focus_position = "footer" + else: + self.focus_position = "body" + elif self.focus_position == "body": + if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible: + self.focus_position = "footer" + elif key == "up" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.top_is_visible: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + else: + return super(RoomFrame, self).keypress(size, key) + else: + return super(RoomFrame, self).keypress(size, key) + + +class RoomWidget(urwid.WidgetWrap): + USERS_PANE_WIDTH = 22 + + def __init__(self, display, hub, room): + self.display = display + self.hub = hub + self.room = room + self.app = nomadnet.NomadNetworkApp.get_shared_instance() + + self.messagelist = None + self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") + self._update_peer_info() + + editor = RoomMessageEdit(caption="", edit_text="", multiline=True) + editor.delegate = self + self.editor = editor + editor_attr = urwid.AttrMap(editor, "msg_editor") + + self.link_delegate = _ChatLinkDelegate(self.display, self.hub) + self.update_messages() + + self.frame = RoomFrame( + self.messagelist, + header=self.peer_info_widget, + footer=editor_attr, + focus_part="footer", + ) + self.frame.delegate = self + + self.chat_box = urwid.LineBox(self.frame) + self.users_pile = urwid.Pile([urwid.Text("")]) + self.users_box = urwid.LineBox(urwid.Filler(self.users_pile, "top"), title="Users") + self._refresh_users_pane() + + self.users_visible = self.display.users_visible + self.columns = urwid.Columns([(urwid.WEIGHT, 1, self.chat_box)], dividechars=0, focus_column=0) + self._apply_users_visibility() + super().__init__(self.columns) + + def toggle_users(self): + self.users_visible = not self.users_visible + self.display.users_visible = self.users_visible + self._apply_users_visibility() + + def _apply_users_visibility(self): + if self.users_visible: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_box, self.columns.options(urwid.GIVEN, RoomWidget.USERS_PANE_WIDTH)), + ] + else: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + ] + self.columns.focus_position = 0 + + def _refresh_users_pane(self): + g = self.app.ui.glyphs + if self.hub is None or self.room is None: + self.users_pile.contents = [(urwid.Text(""), self.users_pile.options())] + return + members = self.hub.get_members(self.room) + own_hash = self.app.identity.hash if self.app.identity is not None else None + names = [] + for m in members: + if own_hash is not None and m == own_hash: + names.append((self.hub.display_name_for(m), True)) + else: + names.append((self.hub.display_name_for(m), False)) + names.sort(key=lambda x: x[0].lower()) + + rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))] + for name, is_self in names: + if is_self: + rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted")) + else: + rows.append(urwid.AttrMap(urwid.Text(" "+g["peer"]+" "+name), "connected_status")) + if not names: + rows.append(urwid.Text(" (no members)")) + self.users_pile.contents = [(w, self.users_pile.options()) for w in rows] + + def _update_peer_info(self): + if self.hub is None or self.room is None: + self.peer_info_widget.original_widget.set_text("") + return + + status_label = { + RRCHub.STATUS_DISCONNECTED: "Disconnected", + RRCHub.STATUS_CONNECTING: "Connecting", + RRCHub.STATUS_CONNECTED: "Connected", + RRCHub.STATUS_FAILED: "Failed", + }.get(self.hub.status, "") + + server = "" + if self.hub.hub_name: + server = " "+self.app.ui.glyphs["divider1"]+" "+self.hub.hub_name + if self.hub.hub_version: + server += " v"+self.hub.hub_version + left = " #"+self.room+server+" ("+self.hub.name+")" + right = status_label+" " + self.peer_info_widget.original_widget.set_text(left+" | "+right) + + MAX_RENDERED_MESSAGES = 500 + + def update_messages(self, replace=False): + msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] + widgets = [] + for m in msgs: + widgets.append(_message_widget(self.app, self.hub, m, link_delegate=self.link_delegate)) + + if not widgets: + widgets = [urwid.Text([("irc_system", " "+self.app.ui.glyphs["info"]+" No messages yet")])] + self._empty_placeholder = True + else: + self._empty_placeholder = False + + self.messagelist = IndicativeListBox(widgets, position=len(widgets)-1) + self.messagelist.name = "messagelist" + if replace and hasattr(self, "frame"): + self.frame.contents["body"] = (self.messagelist, None) + if hasattr(self, "users_pile"): + self._refresh_users_pane() + + def append_message(self, msg): + if self.messagelist is None: + self.update_messages(replace=True) + return + try: + widget = _message_widget(self.app, self.hub, msg, link_delegate=self.link_delegate) + wrapped = urwid.AttrMap(widget, None) + body = self.messagelist._listbox.body + if getattr(self, "_empty_placeholder", False): + del body[:] + self._empty_placeholder = False + body.append(wrapped) + while len(body) > self.MAX_RENDERED_MESSAGES: + del body[0] + try: + self.messagelist._listbox.set_focus(len(body)-1) + except Exception: + pass + except Exception as e: + RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG) + self.update_messages(replace=True) + if hasattr(self, "users_pile"): + self._refresh_users_pane() + + def send_message(self): + text = self.editor.get_edit_text() + if not text.strip(): + return + if text.lstrip().startswith("/"): + self._handle_slash_command(text.lstrip()) + self.editor.set_edit_text("") + return + if self.hub.status != RRCHub.STATUS_CONNECTED: + try: + self.hub.connect() + except Exception: + pass + return + limit = self.hub.max_msg_body_bytes or 350 + if len(text.encode("utf-8")) > limit: + self._open_split_dialog(text, limit) + return + try: + self.hub.send_message(self.room, text) + self.editor.set_edit_text("") + except Exception as e: + RNS.log("Failed to send RRC message: "+str(e), RNS.LOG_ERROR) + + def _open_split_dialog(self, text, limit): + body_bytes = len(text.encode("utf-8")) + parts = _split_message(text, limit) + if not parts: + self._local_message("error", + "Message is "+str(body_bytes)+" bytes but per-message limit is too small to split.") + return + K = len(parts) + preview = parts[0] + if len(preview) > 70: + preview = preview[:70] + "…" + preview = preview.replace("\n", " ").replace("\t", " ") + + error_text = urwid.Text("") + + def cancel(sender): + self.display.close_dialog() + + def send_split(sender): + try: + for p in parts: + self.hub.send_message(self.room, p) + self.editor.set_edit_text("") + self.display.close_dialog() + except Exception as e: + error_text.set_text(("error_text", "Send failed: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text(" Message is "+str(body_bytes)+" bytes."), + urwid.Text(" Hub limit : "+str(limit)+" bytes per message."), + urwid.Text(""), + urwid.Text(" Split into "+str(K)+" message"+("s" if K != 1 else "")+"."), + urwid.Text(" Preview of part 1:"), + urwid.AttrMap(urwid.Text(" "+preview), "irc_system"), + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Send Split", on_press=send_split)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel)), + ]) + ]), title="Message Too Long" + ) + dialog.delegate = self.display + self.display._show_dialog_overlay(dialog) + + def _local_message(self, kind, text): + from nomadnet.RRC import RRCMessage + msg = RRCMessage(kind, self.room, None, None, text, int(time.time()*1000)) + with self.hub._lock: + buf = self.hub.messages.setdefault(self.room, []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + self.hub.manager._notify_messages(self.hub, msg) + # printed /help + SLASH_HELP = [ + "/help - show this list", + "/ping - measure round-trip to hub", + "/list - list public rooms on this hub", + "/join - join a room on this hub", + "/part [room] - leave a room (default: current)", + "/leave [room] - alias for /part", + "/nick - set your display name", + "/who [room] - list users (current room if omitted)", + "/names [room] - alias for /who", + "/clear - clear local messages in this room", + "/connect - connect this hub", + "/disconnect - disconnect this hub", + "/quit - alias for /disconnect", + "", + "Server-side commands (auth enforced by hub):", + "/topic [text] - view or set room topic", + "/mode [+-flags] [arg] - view or set room modes", + "/register - register the current room", + "/unregister - unregister the current room", + "/kick - remove user from room", + "/ban add|del|list [target] - room ban list", + "/invite add|del|list [target] - room invite list", + "/op - grant op", + "/deop - revoke op", + "/voice - grant voice", + "/devoice - revoke voice", + "/kline add|del|list [target] - global ban", + "/stats - server statistics", + "/reload - reload server config", + ] + + # commands that we forward to the server verbatim + SERVER_SLASH_COMMANDS = { + "who", "names", + "topic", "mode", "kick", "kline", + "ban", "invite", "kline", + "op", "deop", "voice", "devoice", + "register", "unregister", + "stats", "reload", + } + + def _require_connected(self): + if self.hub.status != RRCHub.STATUS_CONNECTED: + self._local_message("error", "Not connected to hub") + return False + return True + + def _handle_slash_command(self, text): + parts = text[1:].split(None, 1) + if not parts or not parts[0]: + self._local_message("error", "Empty command") + return + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + + if cmd == "help": + for line in self.SLASH_HELP: + self._local_message("system", line) + return + + if cmd == "ping": + if not self._require_connected(): + return + try: + self.hub.send_ping(room=self.room) + self._local_message("system", "Ping sent") + except Exception as e: + self._local_message("error", "Ping failed: "+str(e)) + return + + if cmd == "list": + if not self._require_connected(): + return + try: + self.hub.send_command("/list", room=self.room) + except Exception as e: + self._local_message("error", "/list failed: "+str(e)) + return + + if cmd in ("join", "j"): + if not arg: + self._local_message("error", "Usage: /join ") + return + target = arg.lstrip("#").strip() + try: + self.hub.add_room(target) + if self.hub.status == RRCHub.STATUS_CONNECTED: + self.hub.join_room(target) + self.display.update_list() + self.display._select_room(None, (self.hub, target.lower())) + except Exception as e: + self._local_message("error", "Join failed: "+str(e)) + return + + if cmd in ("part", "leave"): + target = (arg.lstrip("#").strip().lower()) if arg else self.room + try: + self.hub.part_room(target) + self.display.update_list() + if target == self.room: + self.display.show_placeholder() + except Exception as e: + self._local_message("error", "Part failed: "+str(e)) + return + + if cmd == "nick": + if not arg: + cur = self.app.rrc.get_nickname() or "(unset)" + self._local_message("system", "Current nick: "+cur) + return + limit = self.hub.max_nick_bytes or 32 + if len(arg.encode("utf-8")) > limit: + self._local_message("error", "Nick too long (max "+str(limit)+" bytes)") + return + try: + self.app.set_display_name(arg) + self._local_message("system", "Nick set to "+arg) + except Exception as e: + self._local_message("error", "Nick change failed: "+str(e)) + return + + if cmd == "clear": + with self.hub._lock: + self.hub.messages[self.room] = [] + self.update_messages(replace=True) + return + + if cmd == "connect": + try: + self.hub.connect() + self._local_message("system", "Connecting...") + except Exception as e: + self._local_message("error", "Connect failed: "+str(e)) + return + + if cmd in ("disconnect", "quit"): + try: + self.hub.disconnect() + except Exception as e: + self._local_message("error", "Disconnect failed: "+str(e)) + return + + if cmd in self.SERVER_SLASH_COMMANDS: + if not self._require_connected(): + return + try: + self.hub.send_command("/"+cmd+(" "+arg if arg else ""), room=self.room) + except Exception as e: + self._local_message("error", "/"+cmd+" failed: "+str(e)) + return + + self._local_message("error", "Unknown command: /"+cmd+" (try /help)") + + def leave_room(self): + try: + self.hub.part_room(self.room) + except Exception: + pass + self.display.update_list() + self.display.show_placeholder() + + +def _ts_prefix(ts_ms): + t = _format_ts(ts_ms) if ts_ms else " " + return ("irc_ts", " ["+t+"] ") + + +class _ChatLinkDelegate: + def __init__(self, display, hub): + self.display = display + self.hub = hub + self.app = display.app + self.last_keypress = 0 + + def marked_link(self, target, fields=None): + pass + + def micron_released_focus(self): + pass + + def handle_link(self, target, fields=None): + if target is None: + return + kind, _, payload = target.partition(":") + try: + if kind == "room": + self._open_room(payload) + elif kind == "lxmf": + self._open_lxmf(payload) + elif kind == "page": + self._open_page(payload) + except Exception as e: + RNS.log("Chat link handler failed: "+str(e), RNS.LOG_ERROR) + + def _open_room(self, room): + room = (room or "").strip().lower() + if not room: + return + if room not in self.hub.rooms and self.hub.status == RRCHub.STATUS_CONNECTED: + try: self.hub.join_room(room) + except Exception: pass + self.hub.add_room(room) + self.display.update_list() + self.display._select_room(None, (self.hub, room)) + + def _open_lxmf(self, hash_hex): + try: + bytes.fromhex(hash_hex) + except Exception: + return + from nomadnet.Directory import DirectoryEntry + existing = [c[0] for c in nomadnet.Conversation.conversation_list(self.app)] + if hash_hex not in existing: + display_name = None + try: + data = RNS.Identity.recall_app_data(bytes.fromhex(hash_hex)) + if data is not None: + import LXMF + display_name = LXMF.display_name_from_app_data(data) + except Exception: + pass + try: + self.app.directory.remember(DirectoryEntry(bytes.fromhex(hash_hex), display_name=display_name)) + except Exception: + pass + try: + nomadnet.Conversation(hash_hex, self.app, initiator=True) + except Exception: + pass + conversations = self.app.ui.main_display.sub_displays.conversations_display + conversations.update_conversation_list() + conversations.display_conversation(None, hash_hex) + self.app.ui.main_display.show_conversations(None) + + def _open_page(self, url): + if not url: + return + self.app.ui.main_display.show_network(None) + try: + self.app.ui.main_display.sub_displays.network_display.browser.retrieve_url(url) + except Exception as e: + RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) + + +def _message_widget(app, hub, m, link_delegate=None): + g = app.ui.glyphs + own_nick = None + try: + own_nick = app.rrc.get_nickname() + except Exception: + pass + + if m.kind == "system": + spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_system", " "+g["arrow_r"]+" ")] + spans + return _wrap_text(markup, link_delegate if has_links else None) + + if m.kind == "notice": + spans, has_links = _body_markup(m.text or "", body_attr="irc_notice", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_notice", " "+g["info"]+" ")] + spans + return _wrap_text(markup, link_delegate if has_links else None) + + if m.kind == "error": + spans, has_links = _body_markup(m.text or "", body_attr="irc_error", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_error", " "+g["warning"]+" ")] + spans + return _wrap_text(markup, link_delegate if has_links else None) + + own = False + try: + if hub is not None and m.src is not None and app.identity is not None: + own = bytes(m.src) == app.identity.hash + except Exception: + pass + + if m.nick: + sender = m.nick + elif isinstance(m.src, (bytes, bytearray)): + sender = _short_hash(m.src) + else: + sender = "?" + + nick_attr = "irc_nick_self" if own else "irc_nick_peer" + body = m.text or "" + spans, has_links = _body_markup(body, body_attr="body_text", own_nick=None if own else own_nick) + markup = [_ts_prefix(m.ts), (nick_attr, "<"+sender+">"), ("body_text", " ")] + spans + return _wrap_text(markup, link_delegate if has_links else None) + + +def _wrap_text(markup, link_delegate): + if link_delegate is not None: + return _ChatLinkableText(markup, align="left", delegate=link_delegate) + return urwid.Text(markup) + + +class ChannelsDisplay(): + list_width = 0.33 + given_list_width = 36 + + def __init__(self, app): + self.app = app + self.dialog_open = False + self.list_widgets = [] + self.selected_key = None + self.current_room_widget = None + self.users_visible = True + + self._build_listbox() + + self.list_shortcuts = ChannelsListShortcuts(self.app) + self.room_shortcuts = ChannelsRoomShortcuts(self.app) + self.shortcuts_display = self.list_shortcuts + + self.placeholder = urwid.LineBox(urwid.Filler(urwid.Text("\n Select or add a hub to begin", align=urwid.CENTER), "top")) + self.right = self.placeholder + + self.columns_widget = urwid.Columns( + [ + (ChannelsDisplay.given_list_width, self.listbox), + (urwid.WEIGHT, 1, self.right), + ], + dividechars=0, focus_column=0, box_columns=[0], + ) + self.widget = urwid.WidgetPlaceholder(self.columns_widget) + + self._pending_actions = collections.deque() + self._wake_fd = None + try: + self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending) + except Exception: + pass + + self._mention_bell_last = {} + + self.app.rrc.set_change_callback(self._on_rrc_change) + self.app.rrc.set_message_callback(self._on_rrc_message) + + def start(self): + self.update_list() + + def shortcuts(self): + try: + focus_path = self.columns_widget.get_focus_path() + except Exception: + focus_path = None + if focus_path and focus_path[0] == 1: + return self.room_shortcuts + return self.list_shortcuts + + def _build_listbox(self): + self._compose_list_widgets() + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=lambda a, b: None, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels") + self.listbox.delegate = self + + def _compose_list_widgets(self): + widgets = [] + manager = self.app.rrc + + if not manager.hubs: + entry = urwid.AttrMap(urwid.Text("\n No hubs yet. Press Ctrl-N to add one."), "list_unknown") + widgets.append(entry) + self.list_widgets = widgets + return + + g = self.app.ui.glyphs + for hub_idx, hub in enumerate(manager.hubs): + if hub_idx > 0: + spacer = urwid.Text("") + spacer.row_kind = "spacer" + widgets.append(spacer) + if hub.status == RRCHub.STATUS_CONNECTED: + status_glyph = g["check"] + style = "list_trusted" + elif hub.status == RRCHub.STATUS_CONNECTING: + status_glyph = g["info"] + style = "list_unresponsive" + elif hub.status == RRCHub.STATUS_FAILED: + status_glyph = g["cross"] + style = "list_untrusted" + else: + status_glyph = " " + style = "list_unknown" + + entry = ChannelListEntry(status_glyph+" "+hub.name) + urwid.connect_signal(entry, "click", self._select_hub, hub) + attr = urwid.AttrMap(entry, style, "list_focus") + attr.row_kind = "hub" + attr.hub = hub + attr.room = None + widgets.append(attr) + + for room in sorted(list(hub.rooms | set(hub.messages.keys()))): + if not room: + continue + is_joined = room in hub.rooms + mentioned = room in hub.mention_rooms + unread = room in hub.unread_rooms + if mentioned: + marker = g["warning"] + room_style = "irc_mention" + elif unread: + marker = g["unread"] + room_style = "list_unresponsive" + elif not is_joined: + marker = " " + room_style = "list_unknown" + else: + marker = " " + room_style = "list_trusted" if hub.status == RRCHub.STATUS_CONNECTED else "list_unknown" + room_entry = ChannelListEntry(" "+marker+" #"+room) + urwid.connect_signal(room_entry, "click", self._select_room, (hub, room)) + room_attr = urwid.AttrMap(room_entry, room_style, "list_focus") + room_attr.row_kind = "room" + room_attr.hub = hub + room_attr.room = room + widgets.append(room_attr) + + self.list_widgets = widgets + + def update_list(self): + prev_key = self.selected_key + self._compose_list_widgets() + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=lambda a, b: None, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels") + self.listbox.delegate = self + + options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width) + if not self.dialog_open: + self.columns_widget.contents[0] = (self.listbox, options) + + if prev_key is not None: + for idx, w in enumerate(self.list_widgets): + key = self._row_key(w) + if key == prev_key: + try: self.ilb.select_item(idx) + except Exception: pass + break + + self._refresh_active_header() + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _row_key(self, w): + if not hasattr(w, "row_kind"): + return None + if w.row_kind == "hub": + return ("hub", w.hub.hub_hash, w.hub.dest_name) + if w.row_kind == "room": + return ("room", w.hub.hub_hash, w.hub.dest_name, w.room) + return None + + def _refresh_active_header(self): + if self.current_room_widget is not None: + try: + self.current_room_widget._update_peer_info() + except Exception: + pass + return + if self.selected_key and self.selected_key[0] == "hub": + for h in self.app.rrc.hubs: + if h.hub_hash == self.selected_key[1] and h.dest_name == self.selected_key[2]: + self._show_hub_info(h) + break + + def _select_hub(self, sender, hub): + self.selected_key = ("hub", hub.hub_hash, hub.dest_name) + self.app.rrc.set_active(hub, None) + self._maybe_autoconnect(hub) + self._show_hub_info(hub) + + def _select_room(self, sender, payload): + hub, room = payload + self.selected_key = ("room", hub.hub_hash, hub.dest_name, room) + self.app.rrc.set_active(hub, room) + self._maybe_autoconnect(hub) + if room not in hub.rooms and hub.status == RRCHub.STATUS_CONNECTED: + try: hub.join_room(room) + except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR) + self._show_room(hub, room) + + def _maybe_autoconnect(self, hub): + if hub.status in (RRCHub.STATUS_DISCONNECTED, RRCHub.STATUS_FAILED): + try: + hub.connect() + except Exception as e: + RNS.log("Auto-connect failed: "+str(e), RNS.LOG_ERROR) + + def _show_hub_info(self, hub): + g = self.app.ui.glyphs + status_label = { + RRCHub.STATUS_DISCONNECTED: "Disconnected", + RRCHub.STATUS_CONNECTING: "Connecting", + RRCHub.STATUS_CONNECTED: "Connected", + RRCHub.STATUS_FAILED: "Failed", + }.get(hub.status, "") + status_attr = { + RRCHub.STATUS_DISCONNECTED: "list_unknown", + RRCHub.STATUS_CONNECTING: "list_unresponsive", + RRCHub.STATUS_CONNECTED: "connected_status", + RRCHub.STATUS_FAILED: "list_untrusted", + }.get(hub.status, "list_unknown") + + lines = [ + urwid.Text(""), + urwid.Text(" Hub : "+hub.name), + urwid.Text(" Address : "+hub.hub_hash.hex()), + urwid.AttrMap(urwid.Text(" Status : "+status_label+" ("+hub.status_text+")"), status_attr), + ] + if hub.hub_name: + ver = " v"+str(hub.hub_version) if hub.hub_version else "" + lines.append(urwid.Text(" Server : "+str(hub.hub_name)+ver)) + + ar_glyph = g["check"] if hub.auto_reconnect else g["cross"] + ar_attr = "list_trusted" if hub.auto_reconnect else "list_unknown" + ar_text = "On" if hub.auto_reconnect else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoRcn : "+ar_glyph+" "+ar_text+" (Ctrl-T to toggle)"), ar_attr)) + + al_glyph = g["check"] if hub.auto_list else g["cross"] + al_attr = "list_trusted" if hub.auto_list else "list_unknown" + al_text = "On" if hub.auto_list else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoList : "+al_glyph+" "+al_text+" (Ctrl-E to edit)"), al_attr)) + + lines.append(urwid.Divider(g["divider1"])) + + if hub.status == RRCHub.STATUS_CONNECTED: + lines.append(urwid.Text(" Connected. Use Ctrl-A to add a room.")) + elif hub.status == RRCHub.STATUS_CONNECTING: + lines.append(urwid.AttrMap(urwid.Text(" Connecting..."), "list_unresponsive")) + else: + lines.append(urwid.Text(" Use Ctrl-R to connect.")) + + if hub.rooms: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" Joined rooms:")) + for r in sorted(hub.rooms): + entry = ChannelListEntry(" #"+r) + urwid.connect_signal(entry, "click", self._select_room, (hub, r)) + lines.append(urwid.AttrMap(entry, "list_trusted", "list_focus")) + + available = sorted( + (name, topic) for name, topic in hub.available_rooms.items() + if name and name not in hub.rooms + ) + if available: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" Available rooms:")) + for name, topic in available: + label = " #"+name + if topic: + label += " "+g["arrow_r"]+" "+topic + entry = ChannelListEntry(label) + urwid.connect_signal(entry, "click", self._select_room, (hub, name)) + lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus")) + + info = urwid.LineBox(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name) + self.current_room_widget = None + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (info, options) + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def show_placeholder(self): + self.current_room_widget = None + self.selected_key = None + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (self.placeholder, options) + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def _show_room(self, hub, room): + widget = RoomWidget(self, hub, room) + self.current_room_widget = widget + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (widget, options) + self.columns_widget.focus_position = 1 + self.shortcuts_display = self.room_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def _selected_row(self): + item = self.ilb.get_selected_item() + if item is None: + return None + return item + + def connect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + try: + item.hub.connect() + except Exception as e: + RNS.log("Connect failed: "+str(e), RNS.LOG_ERROR) + + def disconnect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + try: + item.hub.disconnect() + except Exception: + pass + + def toggle_auto_reconnect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + item.hub.set_auto_reconnect(not item.hub.auto_reconnect) + if self.current_room_widget is None: + self._show_hub_info(item.hub) + + def remove_selected_dialog(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + hub = item.hub + room = getattr(item, "room", None) + + def confirmed(sender): + self.close_dialog() + if room is not None: + try: hub.part_room(room) + except Exception: pass + hub.remove_room(room) + else: + self.app.rrc.remove_hub(hub) + self.update_list() + self.show_placeholder() + + def dismiss(sender): + self.close_dialog() + + if room is not None: + prompt = "Leave and remove room\n#"+room+"\non hub "+hub.name+"?" + else: + prompt = "Remove hub\n"+hub.name+"\nfrom this client?\n All Message history will be discarded." + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(prompt+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss)), + ]) + ]), title="?" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def new_hub_dialog(self): + e_hash = urwid.Edit(caption="Hub address : ", edit_text="") + e_name = urwid.Edit(caption="Display name: ", edit_text="") + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + hh_text = e_hash.get_edit_text().strip().lower() + if hh_text.startswith("0x"): + hh_text = hh_text[2:] + hh = bytes.fromhex(hh_text) + if len(hh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + raise ValueError("Hash length must be "+str(RNS.Reticulum.TRUNCATED_HASHLENGTH//8)+" bytes") + nm = e_name.get_edit_text().strip() or None + self.app.rrc.add_hub(hh, name=nm) + self.close_dialog() + self.update_list() + except Exception as e: + error_text.set_text(("error_text", "Could not add hub: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + e_hash, + e_name, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Add", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="New Hub" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def confirm_new_hub_dialog(self, hub_hash, dest_name, room): + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + hub = self.app.rrc.add_hub(hub_hash, dest_name=dest_name) + self.close_dialog() + self.update_list() + if room: + self._select_room(None, (hub, room)) + else: + self._select_hub(None, hub) + except Exception as e: + error_text.set_text(("error_text", "Could not add hub: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text(" A page is requesting to open an RRC hub."), + urwid.Text(""), + urwid.Text(" Address : "+hub_hash.hex()), + urwid.Text(" Aspect : "+(dest_name or "rrc.hub")), + urwid.Text(" Room : "+("#"+room if room else "(none)")), + urwid.Text(""), + urwid.AttrMap(urwid.Text( + " Opening will add this hub to your client,"), "list_unknown"), + urwid.AttrMap(urwid.Text( + " ,and connect to it, and reveal your identity hash"), "list_unknown"), + urwid.AttrMap(urwid.Text( + " to the hub operator."), "list_unknown"), + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Open", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]) + ]), title="Open RRC hub?" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def edit_hub_dialog(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + hub = item.hub + + e_name = urwid.Edit(caption="Display name : ", edit_text=hub.name or "") + cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect) + cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list) + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + nm = e_name.get_edit_text().strip() or hub.name + hub.name = nm + hub.set_auto_reconnect(cb_autorcn.get_state()) + hub.set_auto_list(cb_autolist.get_state()) + self.app.rrc.save() + self.close_dialog() + self.update_list() + if self.selected_key and self.selected_key[0] == "hub" and self.selected_key[1] == hub.hub_hash: + self._show_hub_info(hub) + except Exception as e: + error_text.set_text(("error_text", "Could not save: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(" Address : "+hub.hub_hash.hex()), + urwid.Text(" Server : "+(hub.hub_name or "(unknown until connected)")), + urwid.Divider(self.app.ui.glyphs["divider1"]), + e_name, + urwid.Text(""), + cb_autorcn, + cb_autolist, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="Edit Hub" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def join_room_dialog(self): + item = self._selected_row() + hub = None + if item is not None and hasattr(item, "hub"): + hub = item.hub + if hub is None: + if self.app.rrc.hubs: + hub = self.app.rrc.hubs[0] + else: + return + + e_room = urwid.Edit(caption="Room : #", edit_text="") + e_key = urwid.Edit(caption="Key : ", edit_text="", mask="*") + error_text = urwid.Text("") + + key_section_placeholder = urwid.WidgetPlaceholder(urwid.Text("")) + + def update_key_visibility(checkbox, state): + if state: + key_section_placeholder.original_widget = e_key + else: + key_section_placeholder.original_widget = urwid.Text("") + + cb_key = urwid.CheckBox("Keyed room (+k)", state=False, on_state_change=update_key_visibility) + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + room = e_room.get_edit_text().strip() + if not room: + raise ValueError("Room name is required") + key = e_key.get_edit_text().strip() if cb_key.get_state() else None + key = key or None + hub.add_room(room) + if hub.status == RRCHub.STATUS_CONNECTED: + hub.join_room(room, key=key) + self.close_dialog() + self.update_list() + self._select_room(None, (hub, room.lower())) + except Exception as e: + error_text.set_text(("error_text", "Could not join: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(" Hub : "+hub.name), + e_room, + cb_key, + key_section_placeholder, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Join", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="Add Room" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def _show_dialog_overlay(self, dialog): + self.dialog_open = True + overlay = urwid.Overlay( + dialog, + self.columns_widget, + align=urwid.CENTER, + width=(urwid.RELATIVE, 60), + min_width=40, + valign=urwid.MIDDLE, + height=urwid.PACK, + ) + self.widget.original_widget = overlay + + def close_dialog(self): + self.dialog_open = False + self.widget.original_widget = self.columns_widget + + def _process_pending(self, data): + while True: + try: + action = self._pending_actions.popleft() + except IndexError: + break + try: + action() + except Exception as e: + RNS.log("RRC UI action failed: "+str(e), RNS.LOG_ERROR) + return True + + def _wake(self, action): + self._pending_actions.append(action) + if self._wake_fd is not None: + try: + os.write(self._wake_fd, b".") + return + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None)) + except Exception: + pass + + def _on_rrc_change(self, hub): + self._wake(self.update_list) + + def _on_rrc_message(self, hub, msg): + def action(): + is_active = (self.current_room_widget is not None + and self.current_room_widget.hub is hub + and self.current_room_widget.room == msg.room) + if getattr(msg, "mention", False) and not is_active: + self._ring_mention_bell(hub, msg.room) + if is_active: + self.current_room_widget.append_message(msg) + self.update_list() + self._wake(action) + + def _ring_mention_bell(self, hub, room): + key = (hub.hub_hash, room or "") + now = time.monotonic() + last = self._mention_bell_last.get(key, 0.0) + if now - last < 5.0: + return + self._mention_bell_last[key] = now + try: + import sys + sys.stdout.write("\x07") + sys.stdout.flush() + except Exception: + pass diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 34d88c3..9f14c22 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -2,6 +2,7 @@ import RNS from .Network import * from .Conversations import * +from .Channels import * from .Directory import * from .Config import * from .Interfaces import * @@ -15,6 +16,7 @@ class SubDisplays(): self.app = app self.network_display = NetworkDisplay(self.app) self.conversations_display = ConversationsDisplay(self.app) + self.channels_display = ChannelsDisplay(self.app) self.directory_display = DirectoryDisplay(self.app) self.config_display = ConfigDisplay(self.app) self.interface_display = InterfaceDisplay(self.app) @@ -103,6 +105,11 @@ class MainDisplay(): self.sub_displays.active_display = self.sub_displays.conversations_display self.update_active_sub_display() + def show_channels(self, user_data): + self.sub_displays.active_display = self.sub_displays.channels_display + self.update_active_sub_display() + self.sub_displays.channels_display.start() + def show_directory(self, user_data): self.sub_displays.active_display = self.sub_displays.directory_display self.update_active_sub_display() @@ -181,6 +188,7 @@ class MenuDisplay(): menu_text = (urwid.PACK, self.menu_indicator) button_network = (11, MenuButton("Network", on_press=handler.show_network)) button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) + button_channels = (12, MenuButton("Channels", on_press=handler.show_channels)) button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) button_map = (7, MenuButton("Map", on_press=handler.show_map)) button_log = (7, MenuButton("Log", on_press=handler.show_log)) @@ -191,9 +199,9 @@ class MenuDisplay(): # buttons = [menu_text, button_conversations, button_node, button_directory, button_map] if self.app.config["textui"]["hide_guide"]: - buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_quit] + buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_quit] else: - buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_guide, button_quit] + buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_guide, button_quit] columns = MenuColumns(buttons, dividechars=1) columns.handler = handler diff --git a/nomadnet/vendor/cbor.py b/nomadnet/vendor/cbor.py new file mode 100644 index 0000000..6c63096 --- /dev/null +++ b/nomadnet/vendor/cbor.py @@ -0,0 +1,430 @@ +# https://github.com/brianolson/cbor_py +# Copyright 2014-2015 Brian Olson +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import re +import struct +from io import BytesIO + + +CBOR_TYPE_MASK = 0xE0 # top 3 bits +CBOR_INFO_BITS = 0x1F # low 5 bits + + +CBOR_UINT = 0x00 +CBOR_NEGINT = 0x20 +CBOR_BYTES = 0x40 +CBOR_TEXT = 0x60 +CBOR_ARRAY = 0x80 +CBOR_MAP = 0xA0 +CBOR_TAG = 0xC0 +CBOR_7 = 0xE0 # float and other types + +CBOR_UINT8_FOLLOWS = 24 # 0x18 +CBOR_UINT16_FOLLOWS = 25 # 0x19 +CBOR_UINT32_FOLLOWS = 26 # 0x1a +CBOR_UINT64_FOLLOWS = 27 # 0x1b +CBOR_VAR_FOLLOWS = 31 # 0x1f + +CBOR_BREAK = 0xFF + +CBOR_FALSE = (CBOR_7 | 20) +CBOR_TRUE = (CBOR_7 | 21) +CBOR_NULL = (CBOR_7 | 22) +CBOR_UNDEFINED = (CBOR_7 | 23) # js 'undefined' value + +CBOR_FLOAT16 = (CBOR_7 | 25) +CBOR_FLOAT32 = (CBOR_7 | 26) +CBOR_FLOAT64 = (CBOR_7 | 27) + +CBOR_TAG_DATE_STRING = 0 # RFC3339 +CBOR_TAG_DATE_ARRAY = 1 # any number, seconds since 1970-01-01T00:00:00 UTC +CBOR_TAG_BIGNUM = 2 # big-endian byte string follows +CBOR_TAG_NEGBIGNUM = 3 # big-endian byte string follows +CBOR_TAG_DECIMAL = 4 +CBOR_TAG_BIGFLOAT = 5 +CBOR_TAG_BASE64URL = 21 +CBOR_TAG_BASE64 = 22 +CBOR_TAG_BASE16 = 23 +CBOR_TAG_CBOR = 24 + +CBOR_TAG_URI = 32 +CBOR_TAG_REGEX = 35 +CBOR_TAG_MIME = 36 +CBOR_TAG_CBOR_FILEHEADER = 55799 # 0xd9d9f7 + +_CBOR_TAG_BIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_BIGNUM) +_CBOR_TAG_NEGBIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_NEGBIGNUM) + + +def _dumps_bignum_to_bytearray(val): + out = [] + while val > 0: + out.insert(0, val & 0x0ff) + val = val >> 8 + return bytes(out) + + +def dumps_int(val): + "return bytes representing int val in CBOR" + if val >= 0: + if val <= 23: + return struct.pack('B', val) + if val <= 0x0ff: + return struct.pack('BB', CBOR_UINT8_FOLLOWS, val) + if val <= 0x0ffff: + return struct.pack('!BH', CBOR_UINT16_FOLLOWS, val) + if val <= 0x0ffffffff: + return struct.pack('!BI', CBOR_UINT32_FOLLOWS, val) + if val <= 0x0ffffffffffffffff: + return struct.pack('!BQ', CBOR_UINT64_FOLLOWS, val) + outb = _dumps_bignum_to_bytearray(val) + return _CBOR_TAG_BIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb + val = -1 - val + return _encode_type_num(CBOR_NEGINT, val) + + +def dumps_float(val): + return struct.pack("!Bd", CBOR_FLOAT64, val) + + +def _encode_type_num(cbor_type, val): + """For some CBOR primary type [0..7] and an auxiliary unsigned number, + return CBOR encoded bytes.""" + assert val >= 0 + if val <= 23: + return struct.pack('B', cbor_type | val) + if val <= 0x0ff: + return struct.pack('BB', cbor_type | CBOR_UINT8_FOLLOWS, val) + if val <= 0x0ffff: + return struct.pack('!BH', cbor_type | CBOR_UINT16_FOLLOWS, val) + if val <= 0x0ffffffff: + return struct.pack('!BI', cbor_type | CBOR_UINT32_FOLLOWS, val) + if (((cbor_type == CBOR_NEGINT) and (val <= 0x07fffffffffffffff)) or + ((cbor_type != CBOR_NEGINT) and (val <= 0x0ffffffffffffffff))): + return struct.pack('!BQ', cbor_type | CBOR_UINT64_FOLLOWS, val) + if cbor_type != CBOR_NEGINT: + raise Exception("value too big for CBOR unsigned number: {0!r}".format(val)) + outb = _dumps_bignum_to_bytearray(val) + return _CBOR_TAG_NEGBIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb + + +def dumps_string(val, is_text=None, is_bytes=None): + if isinstance(val, str): + val = val.encode('utf8') + is_text = True + is_bytes = False + if (is_bytes) or not (is_text == True): + return _encode_type_num(CBOR_BYTES, len(val)) + val + return _encode_type_num(CBOR_TEXT, len(val)) + val + + +def dumps_array(arr, sort_keys=False): + head = _encode_type_num(CBOR_ARRAY, len(arr)) + parts = [dumps(x, sort_keys=sort_keys) for x in arr] + return head + b''.join(parts) + + +def dumps_dict(d, sort_keys=False): + head = _encode_type_num(CBOR_MAP, len(d)) + parts = [head] + if sort_keys: + for k in sorted(d.keys()): + v = d[k] + parts.append(dumps(k, sort_keys=sort_keys)) + parts.append(dumps(v, sort_keys=sort_keys)) + else: + for k, v in d.items(): + parts.append(dumps(k, sort_keys=sort_keys)) + parts.append(dumps(v, sort_keys=sort_keys)) + return b''.join(parts) + + +def dumps_bool(b): + if b: + return struct.pack('B', CBOR_TRUE) + return struct.pack('B', CBOR_FALSE) + + +def dumps_tag(t, sort_keys=False): + return _encode_type_num(CBOR_TAG, t.tag) + dumps(t.value, sort_keys=sort_keys) + + +def dumps(ob, sort_keys=False): + if ob is None: + return struct.pack('B', CBOR_NULL) + if isinstance(ob, bool): + return dumps_bool(ob) + if isinstance(ob, (str, bytes, bytearray)): + if isinstance(ob, bytearray): + ob = bytes(ob) + return dumps_string(ob) + if isinstance(ob, (list, tuple)): + return dumps_array(ob, sort_keys=sort_keys) + if isinstance(ob, dict): + return dumps_dict(ob, sort_keys=sort_keys) + if isinstance(ob, float): + return dumps_float(ob) + if isinstance(ob, int): + return dumps_int(ob) + if isinstance(ob, Tag): + return dumps_tag(ob, sort_keys=sort_keys) + raise Exception("don't know how to cbor serialize object of type %s" % type(ob)) + + +def dump(obj, fp, sort_keys=False): + """obj: Python object to serialize. fp: file-like object capable of .write(bytes).""" + blob = dumps(obj, sort_keys=sort_keys) + fp.write(blob) + + +class Tag(object): + def __init__(self, tag=None, value=None): + self.tag = tag + self.value = value + + def __repr__(self): + return "Tag({0!r}, {1!r})".format(self.tag, self.value) + + def __eq__(self, other): + if not isinstance(other, Tag): + return False + return (self.tag == other.tag) and (self.value == other.value) + + def __hash__(self): + return hash((self.tag, self.value)) if not isinstance(self.value, (list, dict, bytearray)) else id(self) + + +def loads(data): + """Parse CBOR bytes and return Python objects.""" + if data is None: + raise ValueError("got None for buffer to decode in loads") + if isinstance(data, (bytes, bytearray, memoryview)): + fp = BytesIO(bytes(data)) + else: + fp = data + return _loads(fp)[0] + + +def load(fp): + """Parse and return object from fp, a file-like object supporting .read(n).""" + return _loads(fp)[0] + + +_MAX_DEPTH = 100 + + +def _tag_aux(fp, tb): + bytes_read = 1 + tag = tb & CBOR_TYPE_MASK + tag_aux = tb & CBOR_INFO_BITS + if tag_aux <= 23: + aux = tag_aux + elif tag_aux == CBOR_UINT8_FOLLOWS: + data = fp.read(1) + aux = struct.unpack_from("!B", data, 0)[0] + bytes_read += 1 + elif tag_aux == CBOR_UINT16_FOLLOWS: + data = fp.read(2) + aux = struct.unpack_from("!H", data, 0)[0] + bytes_read += 2 + elif tag_aux == CBOR_UINT32_FOLLOWS: + data = fp.read(4) + aux = struct.unpack_from("!I", data, 0)[0] + bytes_read += 4 + elif tag_aux == CBOR_UINT64_FOLLOWS: + data = fp.read(8) + aux = struct.unpack_from("!Q", data, 0)[0] + bytes_read += 8 + else: + assert tag_aux == CBOR_VAR_FOLLOWS, "bogus tag {0:02x}".format(tb) + aux = None + + return tag, tag_aux, aux, bytes_read + + +def _read_byte(fp): + tb = fp.read(1) + if len(tb) == 0: + raise EOFError() + return tb[0] + + +def _loads_var_array(fp, limit, depth, returntags, bytes_read): + ob = [] + tb = _read_byte(fp) + while tb != CBOR_BREAK: + (subob, sub_len) = _loads_tb(fp, tb, limit, depth, returntags) + bytes_read += 1 + sub_len + ob.append(subob) + tb = _read_byte(fp) + return (ob, bytes_read + 1) + + +def _loads_var_map(fp, limit, depth, returntags, bytes_read): + ob = {} + tb = _read_byte(fp) + while tb != CBOR_BREAK: + (subk, sub_len) = _loads_tb(fp, tb, limit, depth, returntags) + bytes_read += 1 + sub_len + (subv, sub_len) = _loads(fp, limit, depth, returntags) + bytes_read += sub_len + ob[subk] = subv + tb = _read_byte(fp) + return (ob, bytes_read + 1) + + +def _loads_array(fp, limit, depth, returntags, aux, bytes_read): + ob = [] + for _ in range(aux): + subob, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + ob.append(subob) + return ob, bytes_read + + +def _loads_map(fp, limit, depth, returntags, aux, bytes_read): + ob = {} + for _ in range(aux): + subk, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + subv, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + ob[subk] = subv + return ob, bytes_read + + +def _loads(fp, limit=None, depth=0, returntags=False): + "return (object, bytes read)" + if depth > _MAX_DEPTH: + raise Exception("hit CBOR loads recursion depth limit") + tb = _read_byte(fp) + return _loads_tb(fp, tb, limit, depth, returntags) + + +def _loads_tb(fp, tb, limit=None, depth=0, returntags=False): + # Some special cases of CBOR_7 best handled by special struct.unpack logic here + if tb == CBOR_FLOAT16: + data = fp.read(2) + hibyte, lowbyte = struct.unpack_from("BB", data, 0) + exp = (hibyte >> 2) & 0x1F + mant = ((hibyte & 0x03) << 8) | lowbyte + if exp == 0: + val = mant * (2.0 ** -24) + elif exp == 31: + val = float('Inf') if mant == 0 else float('NaN') + else: + val = (mant + 1024.0) * (2 ** (exp - 25)) + if hibyte & 0x80: + val = -1.0 * val + return (val, 3) + elif tb == CBOR_FLOAT32: + data = fp.read(4) + pf = struct.unpack_from("!f", data, 0) + return (pf[0], 5) + elif tb == CBOR_FLOAT64: + data = fp.read(8) + pf = struct.unpack_from("!d", data, 0) + return (pf[0], 9) + + tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb) + + if tag == CBOR_UINT: + return (aux, bytes_read) + elif tag == CBOR_NEGINT: + return (-1 - aux, bytes_read) + elif tag == CBOR_BYTES: + ob, subpos = loads_bytes(fp, aux) + return (ob, bytes_read + subpos) + elif tag == CBOR_TEXT: + raw, subpos = loads_bytes(fp, aux, btag=CBOR_TEXT) + ob = raw.decode('utf8') + return (ob, bytes_read + subpos) + elif tag == CBOR_ARRAY: + if aux is None: + return _loads_var_array(fp, limit, depth, returntags, bytes_read) + return _loads_array(fp, limit, depth, returntags, aux, bytes_read) + elif tag == CBOR_MAP: + if aux is None: + return _loads_var_map(fp, limit, depth, returntags, bytes_read) + return _loads_map(fp, limit, depth, returntags, aux, bytes_read) + elif tag == CBOR_TAG: + ob, subpos = _loads(fp, limit, depth + 1, returntags) + bytes_read += subpos + if returntags: + ob = Tag(aux, ob) + else: + ob = tagify(ob, aux) + return ob, bytes_read + elif tag == CBOR_7: + if tb == CBOR_TRUE: + return (True, bytes_read) + if tb == CBOR_FALSE: + return (False, bytes_read) + if tb == CBOR_NULL: + return (None, bytes_read) + if tb == CBOR_UNDEFINED: + return (None, bytes_read) + raise ValueError("unknown cbor tag 7 byte: {:02x}".format(tb)) + + +def loads_bytes(fp, aux, btag=CBOR_BYTES): + if aux is not None: + ob = fp.read(aux) + return (ob, aux) + chunklist = [] + total_bytes_read = 0 + while True: + tb = fp.read(1)[0] + if tb == CBOR_BREAK: + total_bytes_read += 1 + break + tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb) + assert tag == btag, 'variable length value contains unexpected component' + ob = fp.read(aux) + chunklist.append(ob) + total_bytes_read += bytes_read + aux + return (b''.join(chunklist), total_bytes_read) + + +def _bytes_to_biguint(bs): + out = 0 + for ch in bs: + out = out << 8 + out = out | ch + return out + + +def tagify(ob, aux): + if aux == CBOR_TAG_DATE_STRING: + # RFC3339 date string parsing not implemented; return as Tag. + return Tag(aux, ob) + if aux == CBOR_TAG_DATE_ARRAY: + return datetime.datetime.fromtimestamp(ob, tz=datetime.timezone.utc) + if aux == CBOR_TAG_BIGNUM: + return _bytes_to_biguint(ob) + if aux == CBOR_TAG_NEGBIGNUM: + return -1 - _bytes_to_biguint(ob) + if aux == CBOR_TAG_REGEX: + return re.compile(ob) + return Tag(aux, ob) + + +def encode(obj, sort_keys=False): + return dumps(obj, sort_keys=sort_keys) + + +def decode(data): + return loads(data) From 602ebd852052b002f7ab3951ad55318fd18d615f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 11 May 2026 20:00:13 +0200 Subject: [PATCH 402/592] Fixed missing none check for stats --- nomadnet/ui/textui/Browser.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index ada2ffe..57a25fc 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1562,9 +1562,13 @@ class Browser: if self.saved_file_name == None: resp_size = self.response_size else: resp_size = self.saved_file_size - stats_string = " "+self.g["page"]+size_str(resp_size) - stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str - stats_string += "s "+self.g["speed"]+size_str(self.response_transfer_size/self.response_time, suffix="b")+"/s" + if resp_size: + stats_string = " "+self.g["page"]+size_str(resp_size) + if self.response_transfer_size: + stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str + if self.response_transfer_size and self.response_time: + stats_string += "s "+self.g["speed"]+size_str(self.response_transfer_size/self.response_time, suffix="b")+"/s" + elif self.loaded_from_cache: stats_string = " (cached)" else: From 07ba36a21cf2da2e2eddaf82e282b4da7ab63533 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 11 May 2026 21:16:26 -0400 Subject: [PATCH 403/592] Use K_BODY for join/part events, changes to /nick behavior --- nomadnet/RRC.py | 120 +++++++++++++++++++++++---------- nomadnet/ui/textui/Channels.py | 17 +++-- 2 files changed, 96 insertions(+), 41 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index ff3b95b..3250750 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -201,6 +201,11 @@ class RRCHub: self.available_rooms = {} self._silent_list_pending = 0 + self.nick_override = None + self._pending_joins = set() + self._pending_parts = set() + self._silent_joins = set() + def _log(self, msg, level=None): if level is None: level = RNS.LOG_INFO @@ -369,7 +374,7 @@ class RRCHub: B_HELLO_CAPS: {CAP_RESOURCE_ENVELOPE: True}, } env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body) - nick = self.manager.get_nickname() + nick = self.get_effective_nick() if nick: env[K_NICK] = nick payload = cbor.encode(env) @@ -382,6 +387,9 @@ class RRCHub: self.welcomed = False self.members.clear() self._resource_expectations.clear() + self._pending_joins.clear() + self._pending_parts.clear() + self._silent_joins.clear() should_reconnect = self.auto_reconnect and not self._manual_disconnect self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") if should_reconnect: @@ -439,6 +447,20 @@ class RRCHub: self.manager.save() self.manager._notify_change(self) + def get_effective_nick(self): + if isinstance(self.nick_override, str) and self.nick_override: + return self.nick_override + return self.manager.get_nickname() + + def set_nick_override(self, nick): + with self._lock: + if nick is None or (isinstance(nick, str) and nick == ""): + self.nick_override = None + else: + self.nick_override = str(nick) + self.manager.save() + self.manager._notify_change(self) + def _packet_would_fit(self, link, payload): try: pkt = RNS.Packet(link, payload) @@ -457,13 +479,17 @@ class RRCHub: raise RuntimeError("message exceeds link MTU") RNS.Packet(link, payload).send() - def join_room(self, room, key=None): + def join_room(self, room, key=None, silent=False): r = self._normalize_room(room) body = key if (isinstance(key, str) and key) else None env = _make_envelope(T_JOIN, src=self.manager.identity.hash, room=r, body=body) - nick = self.manager.get_nickname() + nick = self.get_effective_nick() if nick: env[K_NICK] = nick + with self._lock: + self._pending_joins.add(r) + if silent: + self._silent_joins.add(r) self._send_env(env) with self._lock: if r not in self.messages: @@ -474,7 +500,7 @@ class RRCHub: if not isinstance(text, str) or not text.startswith("/"): raise ValueError("command must start with /") env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=room, body=text) - nick = self.manager.get_nickname() + nick = self.get_effective_nick() if nick: env[K_NICK] = nick self._send_env(env) @@ -494,6 +520,8 @@ class RRCHub: def part_room(self, room): room_n = self._normalize_room(room) env = _make_envelope(T_PART, src=self.manager.identity.hash, room=room_n) + with self._lock: + self._pending_parts.add(room_n) try: self._send_env(env) except Exception: @@ -510,7 +538,7 @@ class RRCHub: if len(text.encode("utf-8")) > self.max_msg_body_bytes: raise ValueError("message too long for hub limit") env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=r, body=text) - nick = self.manager.get_nickname() + nick = self.get_effective_nick() if nick: env[K_NICK] = nick mid = env[K_ID] @@ -643,33 +671,40 @@ class RRCHub: room = env.get(K_ROOM) if isinstance(room, str) and room: r = room.strip().lower() - src = env.get(K_SRC) - nick = env.get(K_NICK) body = env.get(K_BODY) own_hash = self.manager.identity.hash if self.manager.identity is not None else None - self_join = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + + body_hashes = [] + if isinstance(body, list): + body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))] + with self._lock: + self_join = r in self._pending_joins + silent = r in self._silent_joins + if self_join: + self._pending_joins.discard(r) + if silent: + self._silent_joins.discard(r) + self.rooms.add(r) if r not in self.messages: self.messages[r] = [] members = self.members.setdefault(r, set()) - if isinstance(body, list): - for entry in body: - if isinstance(entry, (bytes, bytearray)): - members.add(bytes(entry)) - if isinstance(src, (bytes, bytearray)): - sb = bytes(src) - if own_hash is None or sb != own_hash: - members.add(sb) - if isinstance(nick, str) and nick: - self.nicks[sb] = nick + for h in body_hashes: + members.add(h) if own_hash is not None: members.add(own_hash) + if self_join: - self._record_system(r, "You joined #"+r) + if not silent: + self._record_system(r, "You joined #"+r) self.manager.save() - elif isinstance(src, (bytes, bytearray)): - self._record_system(r, self.display_name_for(src)+" joined") + else: + joiner = None + if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash): + joiner = body_hashes[0] + if joiner is not None: + self._record_system(r, self.display_name_for(joiner)+" joined") self.manager._notify_change(self) return @@ -677,25 +712,34 @@ class RRCHub: room = env.get(K_ROOM) if isinstance(room, str) and room: r = room.strip().lower() - src = env.get(K_SRC) body = env.get(K_BODY) own_hash = self.manager.identity.hash if self.manager.identity is not None else None - self_part = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + + body_hashes = [] + if isinstance(body, list): + body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))] + with self._lock: + self_part = r in self._pending_parts + if self_part: + self._pending_parts.discard(r) + members = self.members.get(r) - if isinstance(body, list): - for entry in body: - if isinstance(entry, (bytes, bytearray)) and members is not None: - members.discard(bytes(entry)) - elif isinstance(src, (bytes, bytearray)) and members is not None: - members.discard(bytes(src)) + if members is not None: + for h in body_hashes: + members.discard(h) if self_part: self.rooms.discard(r) self.members.pop(r, None) + if self_part: self.manager.save() - if not self_part and isinstance(src, (bytes, bytearray)): - self._record_system(r, self.display_name_for(src)+" left") + else: + parter = None + if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash): + parter = body_hashes[0] + if parter is not None: + self._record_system(r, self.display_name_for(parter)+" left") self.manager._notify_change(self) return @@ -725,7 +769,7 @@ class RRCHub: ) is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash if not is_own: - own_nick = self.manager.get_nickname() + own_nick = self.get_effective_nick() pat = _mention_re(own_nick) if pat is not None and pat.search(body): msg.mention = True @@ -918,7 +962,7 @@ class RRCManager: def _on_welcome(self, hub): for r in list(hub.rooms): try: - hub.join_room(r) + hub.join_room(r, silent=True) except Exception: pass @@ -1020,6 +1064,9 @@ class RRCManager: al = e.get("auto_list") if isinstance(al, bool): hub.auto_list = al + no = e.get("nick") + if isinstance(no, str) and no: + hub.nick_override = no except Exception as e: RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR) finally: @@ -1037,7 +1084,7 @@ class RRCManager: for h in self.hubs: joined = set(h.rooms) parted = set(h.messages.keys()) - joined - entries.append({ + entry = { "hash": h.hub_hash, "dest_name": h.dest_name, "name": h.name, @@ -1045,7 +1092,10 @@ class RRCManager: "parted_rooms": sorted(parted), "auto_reconnect": bool(h.auto_reconnect), "auto_list": bool(h.auto_list), - }) + } + if isinstance(h.nick_override, str) and h.nick_override: + entry["nick"] = h.nick_override + entries.append(entry) data = cbor.encode({"hubs": entries}) with open(tmp_path, "wb") as f: f.write(data) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 5a9c7f0..abbc3dd 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -566,7 +566,7 @@ class RoomWidget(urwid.WidgetWrap): "/join - join a room on this hub", "/part [room] - leave a room (default: current)", "/leave [room] - alias for /part", - "/nick - set your display name", + "/nick - set your nick on this hub only", "/who [room] - list users (current room if omitted)", "/names [room] - alias for /who", "/clear - clear local messages in this room", @@ -667,16 +667,18 @@ class RoomWidget(urwid.WidgetWrap): if cmd == "nick": if not arg: - cur = self.app.rrc.get_nickname() or "(unset)" - self._local_message("system", "Current nick: "+cur) + cur = self.hub.get_effective_nick() or " unset" + src = "nick: " if (isinstance(self.hub.nick_override, str) and self.hub.nick_override) else "global" + self._local_message("system", "Nick on this hub: "+cur+" ("+src+")") return limit = self.hub.max_nick_bytes or 32 if len(arg.encode("utf-8")) > limit: self._local_message("error", "Nick too long (max "+str(limit)+" bytes)") return try: - self.app.set_display_name(arg) - self._local_message("system", "Nick set to "+arg) + self.hub.set_nick_override(arg) + self._local_message("system", "Nick on this hub set to "+arg+ + " (use /nick with no argument to view)") except Exception as e: self._local_message("error", "Nick change failed: "+str(e)) return @@ -808,7 +810,10 @@ def _message_widget(app, hub, m, link_delegate=None): g = app.ui.glyphs own_nick = None try: - own_nick = app.rrc.get_nickname() + if hub is not None: + own_nick = hub.get_effective_nick() + else: + own_nick = app.rrc.get_nickname() except Exception: pass From deb8c7f55d6bc1fb33e8db49c5a429dc70889473 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 10:42:57 -0400 Subject: [PATCH 404/592] Don't set STATUS_CONNECTING before we've connected (Patch from @pintobyte) --- nomadnet/RRC.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 3250750..5cf3b21 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -273,7 +273,11 @@ class RRCHub: if self._reconnect_timer is not None: self._reconnect_timer.cancel() self._reconnect_timer = None - self._set_status(RRCHub.STATUS_CONNECTING, "Connecting") + if self._reconnect_attempts > 0: + text = "Reconnecting (attempt "+str(self._reconnect_attempts)+")" + else: + text = "Connecting" + self._set_status(RRCHub.STATUS_CONNECTING, text) t = threading.Thread(target=self._connect_worker, daemon=True) t.start() @@ -407,7 +411,6 @@ class RRCHub: self._reconnect_timer = None if self._manual_disconnect or not self.auto_reconnect: return - self._set_status(RRCHub.STATUS_CONNECTING, "Reconnecting (attempt "+str(self._reconnect_attempts)+")") self.connect() self._reconnect_timer = threading.Timer(backoff, fire) From 3187fe296c99ae4506488bacd617d79076a6b9c4 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 12:08:42 -0400 Subject: [PATCH 405/592] Add HubInfoArea focus shortcuts, fix for pending room joins --- nomadnet/RRC.py | 15 ++++++++++++- nomadnet/ui/textui/Browser.py | 9 +------- nomadnet/ui/textui/Channels.py | 41 +++++++++++++++++++++++++++++----- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 5cf3b21..cd425c9 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -809,9 +809,22 @@ class RRCHub: body = env.get(K_BODY) room = env.get(K_ROOM) text = body if isinstance(body, str) else "(error)" + r = room.strip().lower() if isinstance(room, str) else None + rollback_join = False + if r: + with self._lock: + if r in self._pending_joins: + rollback_join = True + self._pending_joins.discard(r) + self._silent_joins.discard(r) + self._pending_parts.discard(r) + if rollback_join: + self.rooms.discard(r) + if rollback_join: + self.manager.save() msg = RRCMessage( "error", - room.strip().lower() if isinstance(room, str) else None, + r, None, None, text, diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 1994ac9..4b3042a 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -362,14 +362,7 @@ class Browser: raise ValueError("hub hash must be "+str(expected_len)+" bytes") room = room.strip().lstrip("#").strip() - room_norm = None - if room: - try: - # validate the room name early; pass the raw value through - from nomadnet.RRC import RRCHub as _RRCHubCls # noqa - room_norm = room.lower() - except Exception: - room_norm = None + room_norm = room.lower() if room else None existing = self.app.rrc.find_hub(hub_hash, dest_name=dest) self.app.ui.main_display.show_channels(None) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index abbc3dd..5fdca96 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -211,6 +211,32 @@ class ChannelsListArea(urwid.LineBox): return super(ChannelsListArea, self).keypress(size, key) +class HubInfoArea(urwid.LineBox): + def keypress(self, size, key): + if key == "ctrl n": + self.delegate.new_hub_dialog() + return None + if key == "ctrl a": + self.delegate.join_room_dialog() + return None + if key == "ctrl r": + self.delegate.connect_selected() + return None + if key == "ctrl w": + self.delegate.disconnect_selected() + return None + if key == "ctrl t": + self.delegate.toggle_auto_reconnect_selected() + return None + if key == "ctrl e": + self.delegate.edit_hub_dialog() + return None + if key == "ctrl x": + self.delegate.remove_selected_dialog() + return None + return super(HubInfoArea, self).keypress(size, key) + + class RoomMessageEdit(urwid.Edit): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1052,9 +1078,13 @@ class ChannelsDisplay(): self.selected_key = ("room", hub.hub_hash, hub.dest_name, room) self.app.rrc.set_active(hub, room) self._maybe_autoconnect(hub) - if room not in hub.rooms and hub.status == RRCHub.STATUS_CONNECTED: - try: hub.join_room(room) - except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR) + if room not in hub.rooms: + if hub.status == RRCHub.STATUS_CONNECTED: + try: hub.join_room(room) + except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR) + else: + try: hub.add_room(room) + except Exception as e: RNS.log("Pending join queue failed: "+str(e), RNS.LOG_ERROR) self._show_room(hub, room) def _maybe_autoconnect(self, hub): @@ -1131,7 +1161,8 @@ class ChannelsDisplay(): urwid.connect_signal(entry, "click", self._select_room, (hub, name)) lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus")) - info = urwid.LineBox(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name) + info = HubInfoArea(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name) + info.delegate = self self.current_room_widget = None options = self.columns_widget.options(urwid.WEIGHT, 1) self.columns_widget.contents[1] = (info, options) @@ -1295,7 +1326,7 @@ class ChannelsDisplay(): urwid.AttrMap(urwid.Text( " Opening will add this hub to your client,"), "list_unknown"), urwid.AttrMap(urwid.Text( - " ,and connect to it, and reveal your identity hash"), "list_unknown"), + " and reveal your identity hash to the hub"), "list_unknown"), urwid.AttrMap(urwid.Text( " to the hub operator."), "list_unknown"), urwid.Text(""), From 6e0d8b9f4c3540745c71fd5c622a0e210f8b1133 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 12:27:22 -0400 Subject: [PATCH 406/592] AutoWho to populate nicks in user sidebar. Thanks @pintobyte --- nomadnet/RRC.py | 91 +++++++++++++++++++++++++++++++--- nomadnet/ui/textui/Channels.py | 12 ++++- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index cd425c9..fa8d088 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -101,6 +101,39 @@ def _msg_id(): return os.urandom(8) +# greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)" +_WHO_ENTRY_RE = re.compile( + r"(?:^|,\s)" + r"(?:(?P.+?)\s\((?P[0-9a-fA-F]+)\)|(?P[0-9a-fA-F]+))" + r"(?=,\s|$)" +) + + +# hub /who response format: "members in : nick1 (hex12), nick2 (hex12), , ..." +# nicked users carry only a 12-hex prefix of their identity hash; un-nicked users appear as the full hex +def _parse_who_notice(text): + if not isinstance(text, str): + return None + prefix = "members in " + if not text.startswith(prefix): + return None + sep_idx = text.find(": ", len(prefix)) + if sep_idx < 0: + return None + room = text[len(prefix):sep_idx].strip().lower() + if not room: + return None + body = text[sep_idx+2:].strip() + entries = [] + if body and body != "(none)": + for m in _WHO_ENTRY_RE.finditer(body): + if m.group("nick") is not None: + entries.append((m.group("nick").strip(), m.group("np").lower())) + elif m.group("bh") is not None: + entries.append((None, m.group("bh").lower())) + return (room, entries) + + def _parse_room_list_notice(text): if not isinstance(text, str): return None @@ -186,6 +219,7 @@ class RRCHub: self.auto_reconnect = False self.auto_list = False + self.auto_who = False self._lock = threading.RLock() self._resource_expectations = {} @@ -200,6 +234,7 @@ class RRCHub: self.available_rooms = {} self._silent_list_pending = 0 + self._silent_who_rooms = set() self.nick_override = None self._pending_joins = set() @@ -394,6 +429,7 @@ class RRCHub: self._pending_joins.clear() self._pending_parts.clear() self._silent_joins.clear() + self._silent_who_rooms.clear() should_reconnect = self.auto_reconnect and not self._manual_disconnect self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") if should_reconnect: @@ -418,7 +454,6 @@ class RRCHub: self._reconnect_timer.start() self._set_status(RRCHub.STATUS_DISCONNECTED, "Reconnect in "+str(int(backoff))+"s") - def disconnect(self): self._stop_hello.set() @@ -435,19 +470,28 @@ class RRCHub: except Exception: pass self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") - def set_auto_reconnect(self, enabled): + def set_auto_reconnect(self, enabled, save=True): with self._lock: self.auto_reconnect = bool(enabled) if not enabled and self._reconnect_timer is not None: self._reconnect_timer.cancel() self._reconnect_timer = None - self.manager.save() + if save: + self.manager.save() self.manager._notify_change(self) - def set_auto_list(self, enabled): + def set_auto_list(self, enabled, save=True): with self._lock: self.auto_list = bool(enabled) - self.manager.save() + if save: + self.manager.save() + self.manager._notify_change(self) + + def set_auto_who(self, enabled, save=True): + with self._lock: + self.auto_who = bool(enabled) + if save: + self.manager.save() self.manager._notify_change(self) def get_effective_nick(self): @@ -701,6 +745,14 @@ class RRCHub: if self_join: if not silent: self._record_system(r, "You joined #"+r) + if self.auto_who: + try: + with self._lock: + self._silent_who_rooms.add(r) + self.send_command("/who "+r, room=r) + except Exception: + with self._lock: + self._silent_who_rooms.discard(r) self.manager.save() else: joiner = None @@ -794,6 +846,29 @@ class RRCHub: self.manager._notify_change(self) if silent: return + parsed_who = _parse_who_notice(body) + if parsed_who is not None: + who_room, who_entries = parsed_who + with self._lock: + members = self.members.setdefault(who_room, set()) + for nick, hash_hex in who_entries: + try: + hash_bytes = bytes.fromhex(hash_hex) + except Exception: + continue + if nick is None: + members.add(hash_bytes) + continue + for ph in members: + if ph.startswith(hash_bytes): + self.nicks[ph] = nick + break + silent_who = who_room in self._silent_who_rooms + if silent_who: + self._silent_who_rooms.discard(who_room) + self.manager._notify_change(self) + if silent_who: + return msg = RRCMessage( "notice", room.strip().lower() if isinstance(room, str) else None, @@ -1080,6 +1155,9 @@ class RRCManager: al = e.get("auto_list") if isinstance(al, bool): hub.auto_list = al + aw = e.get("auto_who") + if isinstance(aw, bool): + hub.auto_who = aw no = e.get("nick") if isinstance(no, str) and no: hub.nick_override = no @@ -1108,6 +1186,7 @@ class RRCManager: "parted_rooms": sorted(parted), "auto_reconnect": bool(h.auto_reconnect), "auto_list": bool(h.auto_list), + "auto_who": bool(h.auto_who), } if isinstance(h.nick_override, str) and h.nick_override: entry["nick"] = h.nick_override @@ -1120,7 +1199,7 @@ class RRCManager: except Exception: pass os.replace(tmp_path, path) except Exception as e: - # + # # # # diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 5fdca96..52342c7 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1129,6 +1129,11 @@ class ChannelsDisplay(): al_text = "On" if hub.auto_list else "Off" lines.append(urwid.AttrMap(urwid.Text(" AutoList : "+al_glyph+" "+al_text+" (Ctrl-E to edit)"), al_attr)) + aw_glyph = g["check"] if hub.auto_who else g["cross"] + aw_attr = "list_trusted" if hub.auto_who else "list_unknown" + aw_text = "On" if hub.auto_who else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoWho : "+aw_glyph+" "+aw_text+" (Ctrl-E to edit)"), aw_attr)) + lines.append(urwid.Divider(g["divider1"])) if hub.status == RRCHub.STATUS_CONNECTED: @@ -1350,6 +1355,7 @@ class ChannelsDisplay(): e_name = urwid.Edit(caption="Display name : ", edit_text=hub.name or "") cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect) cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list) + cb_autowho = urwid.CheckBox("Auto-fetch members on room join", state=hub.auto_who) error_text = urwid.Text("") def dismiss(sender): @@ -1359,8 +1365,9 @@ class ChannelsDisplay(): try: nm = e_name.get_edit_text().strip() or hub.name hub.name = nm - hub.set_auto_reconnect(cb_autorcn.get_state()) - hub.set_auto_list(cb_autolist.get_state()) + hub.set_auto_reconnect(cb_autorcn.get_state(), save=False) + hub.set_auto_list(cb_autolist.get_state(), save=False) + hub.set_auto_who(cb_autowho.get_state(), save=False) self.app.rrc.save() self.close_dialog() self.update_list() @@ -1378,6 +1385,7 @@ class ChannelsDisplay(): urwid.Text(""), cb_autorcn, cb_autolist, + cb_autowho, urwid.Text(""), error_text, urwid.Columns([ From 1c117507c48508e8af2e3f8a23f3ef2dc4c3b4dd Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 12:43:48 -0400 Subject: [PATCH 407/592] Change AutoWho regex to match partial hashes --- nomadnet/RRC.py | 4 ++-- nomadnet/ui/textui/Channels.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index fa8d088..bc1766c 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -104,7 +104,7 @@ def _msg_id(): # greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)" _WHO_ENTRY_RE = re.compile( r"(?:^|,\s)" - r"(?:(?P.+?)\s\((?P[0-9a-fA-F]+)\)|(?P[0-9a-fA-F]+))" + r"(?:(?P[0-9a-fA-F]{32})|(?P.+?)\s\((?P[0-9a-fA-F]{12})\))" r"(?=,\s|$)" ) @@ -279,7 +279,7 @@ class RRCHub: nick = self.nicks.get(ph) if nick: return nick - return ph.hex()[:10] + return ph.hex()[:12] def mark_read(self, room): r = self._normalize_room(room) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 52342c7..0e62a7c 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -137,7 +137,7 @@ def _body_markup(body, body_attr="body_text", own_nick=None): return out, has_links -def _short_hash(b, n=10): +def _short_hash(b, n=12): if isinstance(b, (bytes, bytearray)): return bytes(b).hex()[:n] return "?" From 2977d9ed71ca611153fccbabd13bc48597822bc9 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 16:24:42 -0400 Subject: [PATCH 408/592] Fixes --- nomadnet/RRC.py | 16 ++++++++-------- nomadnet/ui/textui/Channels.py | 10 +++++++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index bc1766c..982dbe1 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -745,14 +745,14 @@ class RRCHub: if self_join: if not silent: self._record_system(r, "You joined #"+r) - if self.auto_who: - try: - with self._lock: - self._silent_who_rooms.add(r) - self.send_command("/who "+r, room=r) - except Exception: - with self._lock: - self._silent_who_rooms.discard(r) + if self.auto_who: + try: + with self._lock: + self._silent_who_rooms.add(r) + self.send_command("/who "+r, room=r) + except Exception: + with self._lock: + self._silent_who_rooms.discard(r) self.manager.save() else: joiner = None diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 0e62a7c..4043529 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1503,7 +1503,15 @@ class ChannelsDisplay(): pass def _on_rrc_change(self, hub): - self._wake(self.update_list) + def action(): + self.update_list() + if (self.current_room_widget is not None + and self.current_room_widget.hub is hub): + try: + self.current_room_widget._refresh_users_pane() + except Exception: + pass + self._wake(action) def _on_rrc_message(self, hub, msg): def action(): From 5c76d509d7fb48e6774f45fdcba792af8aabfdea Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 17:53:51 -0400 Subject: [PATCH 409/592] Fixes --- nomadnet/Conversation.py | 64 ++++++++++++++++++++++------- nomadnet/ui/textui/Channels.py | 17 ++++++++ nomadnet/ui/textui/Conversations.py | 32 ++++++++------- 3 files changed, 83 insertions(+), 30 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 14844d3..d7039dc 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -1,4 +1,5 @@ import os +import re import RNS import LXMF import shutil @@ -459,22 +460,25 @@ class ConversationMessage: if found_in_fields: names = [] file_atts = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) - for att in file_atts: + for idx, att in enumerate(file_atts): if isinstance(att, list) and len(att) >= 2: size = len(att[1]) if isinstance(att[1], bytes) else 0 - names.append(("file", str(att[0]), size)) + safe = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx)) + names.append(("file", safe, size)) if LXMF.FIELD_IMAGE in fields: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) if data: size = len(data) ext = ConversationMessage._ext_from_media_format(fmt, data) - names.append(("file", "image"+ext, size)) + safe = ConversationMessage.safe_attachment_name("image"+ext, fallback="image") + names.append(("file", safe, size)) if LXMF.FIELD_AUDIO in fields: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) if data: size = len(data) ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) - names.append(("file", "audio"+ext, size)) + safe = ConversationMessage.safe_attachment_name("audio"+ext, fallback="audio") + names.append(("file", safe, size)) self._cached_has_attachments = True self._cached_attachment_names = names @@ -674,11 +678,18 @@ class ConversationMessage: try: with open(manifest_path, "rb") as f: manifest = msgpack.unpackb(f.read(), raw=False) - for f in manifest["files"]: - f["name"] = os.path.basename(f["name"]).encode("utf-8").decode("utf-8") - + safe_files = [] + for idx, entry in enumerate(manifest.get("files", [])): + if not isinstance(entry, dict): + continue + entry["name"] = ConversationMessage.safe_attachment_name(entry.get("name"), fallback="attachment_"+str(idx)) + stored = entry.get("stored_name") + if not isinstance(stored, str) or not re.fullmatch(r"file_\d+", stored): + continue + safe_files.append(entry) + manifest["files"] = safe_files return manifest - + except Exception as e: RNS.log(f"Error loading attachment manifest: {e}") return None @@ -714,12 +725,12 @@ class ConversationMessage: for idx, att in enumerate(file_attachments): try: if isinstance(att, list) and len(att) >= 2: - filename = os.path.basename(str(att[0])).encode("utf-8").decode("utf-8") + filename = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx)) data = att[1] if isinstance(att[1], bytes) else b"" stored_name = "file_"+str(idx) with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) - + except Exception as e: RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR) continue @@ -728,7 +739,7 @@ class ConversationMessage: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) if data: ext = ConversationMessage._ext_from_media_format(fmt, data) - filename = "image" + ext + filename = ConversationMessage.safe_attachment_name("image" + ext, fallback="image") stored_name = "file_"+str(len(manifest["files"])) with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) @@ -738,7 +749,7 @@ class ConversationMessage: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) if data: ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) - filename = "audio" + ext + filename = ConversationMessage.safe_attachment_name("audio" + ext, fallback="audio") stored_name = "file_"+str(len(manifest["files"])) with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) @@ -747,6 +758,29 @@ class ConversationMessage: with open(os.path.join(att_dir, "manifest"), "wb") as f: f.write(msgpack.packb(manifest)) + @staticmethod + def safe_attachment_name(name, fallback="attachment"): + try: + if isinstance(name, bytes): + name = name.decode("utf-8", errors="replace") + elif not isinstance(name, str): + name = str(name) if name is not None else "" + except Exception: + name = "" + name = re.sub(r"[\x00-\x1f\x7f]", "", name) + parts = re.split(r"[/\\]", name) + name = parts[-1] if parts else "" + if ":" in name: + name = name.split(":")[-1] + name = name.lstrip(".") + if not name or name in (".", ".."): + return fallback + if len(name) > 200: + base, ext = os.path.splitext(name) + ext = ext[:16] + name = base[:200 - len(ext)] + ext + return name + @staticmethod def _unpack_media_field(field_data): """Normalize FIELD_IMAGE/FIELD_AUDIO which can be raw bytes or [format, bytes]. @@ -789,10 +823,10 @@ class ConversationMessage: @staticmethod def _ext_from_media_format(fmt, data, is_audio=False): - """Derive file extension from format identifier and data. - fmt can be a string ('webp'), an integer (LXMF audio mode), or None.""" if isinstance(fmt, str) and len(fmt) > 0: - return "." + fmt.lower().strip(".") + safe = re.sub(r"[^A-Za-z0-9]", "", fmt).lower()[:8] + if safe: + return "." + safe if isinstance(fmt, int) and is_audio: if fmt >= 16 and fmt <= 25: return ".ogg" diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 4043529..abaed97 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -372,6 +372,7 @@ class RoomWidget(urwid.WidgetWrap): editor = RoomMessageEdit(caption="", edit_text="", multiline=True) editor.delegate = self self.editor = editor + urwid.connect_signal(editor, "postchange", self._on_editor_change) editor_attr = urwid.AttrMap(editor, "msg_editor") self.link_delegate = _ChatLinkDelegate(self.display, self.hub) @@ -474,6 +475,10 @@ class RoomWidget(urwid.WidgetWrap): self.messagelist = IndicativeListBox(widgets, position=len(widgets)-1) self.messagelist.name = "messagelist" + try: + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass if replace and hasattr(self, "frame"): self.frame.contents["body"] = (self.messagelist, None) if hasattr(self, "users_pile"): @@ -495,6 +500,7 @@ class RoomWidget(urwid.WidgetWrap): del body[0] try: self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") except Exception: pass except Exception as e: @@ -503,6 +509,17 @@ class RoomWidget(urwid.WidgetWrap): if hasattr(self, "users_pile"): self._refresh_users_pane() + def _on_editor_change(self, editor, old_text): + if self.messagelist is None: + return + try: + body = self.messagelist._listbox.body + if len(body) > 0: + self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass + def send_message(self): text = self.editor.get_edit_text() if not text.strip(): diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 2af34f1..4adefff 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1798,32 +1798,34 @@ class ClickableAttachment(urwid.Text): self.set_text(" "+g["cross"]+" Save failed: "+str(e)) -def _copy_attachment_to_dest(filename, src_path): +def _resolve_attachment_save_path(filename): app = nomadnet.NomadNetworkApp.get_shared_instance() save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path if not os.path.isdir(save_dir): os.makedirs(save_dir) - save_path = os.path.join(save_dir, filename) + safe_name = ConversationMessage.safe_attachment_name(filename) + base_dir = os.path.realpath(save_dir) + os.sep + candidate = os.path.realpath(os.path.join(save_dir, safe_name)) + if not (candidate + os.sep).startswith(base_dir): + raise OSError(13, os.strerror(13)) counter = 0 - base, ext = os.path.splitext(filename) - while os.path.isfile(save_path): + base, ext = os.path.splitext(safe_name) + while os.path.isfile(candidate): counter += 1 - save_path = os.path.join(save_dir, base+"_"+str(counter)+ext) + candidate = os.path.realpath(os.path.join(save_dir, base+"_"+str(counter)+ext)) + if not (candidate + os.sep).startswith(base_dir): + raise OSError(13, os.strerror(13)) + return candidate + + +def _copy_attachment_to_dest(filename, src_path): + save_path = _resolve_attachment_save_path(filename) shutil.copy2(src_path, save_path) return save_path def _save_attachment_to_disk(filename, data): - app = nomadnet.NomadNetworkApp.get_shared_instance() - save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path - if not os.path.isdir(save_dir): - os.makedirs(save_dir) - save_path = os.path.join(save_dir, filename) - counter = 0 - base, ext = os.path.splitext(filename) - while os.path.isfile(save_path): - counter += 1 - save_path = os.path.join(save_dir, base+"_"+str(counter)+ext) + save_path = _resolve_attachment_save_path(filename) with open(save_path, "wb") as f: f.write(data) return save_path From c4b0bd0b5796d0699a5efa824e546e03958de394 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 16 May 2026 22:58:56 +0200 Subject: [PATCH 410/592] Uuh... We didn't have periodic lxmf announces? --- nomadnet/NomadNetworkApp.py | 15 ++++++++++++++- nomadnet/ui/textui/Guide.py | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index b0d99e2..b40ddba 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -268,7 +268,7 @@ class NomadNetworkApp: RNS.log("No peer settings file found, creating new...") self.peer_settings = { "display_name": "Anonymous Peer", - "announce_interval": None, + "announce_interval": 6*60*60, "last_announce": None, "node_last_announce": None, "propagation_node": None, @@ -447,6 +447,9 @@ class NomadNetworkApp: RNS.log("Initiating automatic LXMF sync", RNS.LOG_VERBOSE) self.request_lxmf_sync(limit=self.lxmf_sync_limit) + if now > self.peer_settings["last_announce"] + self.peer_settings["announce_interval"]: + self.announce_now() + time.sleep(self.job_interval) def set_display_name(self, display_name): @@ -530,6 +533,7 @@ class NomadNetworkApp: self.message_router.cancel_propagation_node_requests() def announce_now(self): + RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE) self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost) self.lxmf_destination.display_name = self.peer_settings["display_name"] self.message_router.announce(self.lxmf_destination.hash) @@ -760,6 +764,11 @@ class NomadNetworkApp: value = self.config["client"].as_bool(option) self.peer_announce_at_start = value + if option == "announce_interval": + value = self.config["client"].as_int(option) + if value < 30: value = 30 + self.peer_settings["announce_interval"] = value*60 + if option == "try_propagation_on_send_fail": value = self.config["client"].as_bool(option) self.try_propagation_on_fail = value @@ -1095,6 +1104,10 @@ notify_on_new_message = yes # to let other peers reach it immediately. announce_at_start = yes +# Automatic announce interval in minutes for +# your LXMF address. 6 hours by default. +announce_interval = 360 + # By default, the client will try to deliver a # message via the LXMF propagation network, if # a direct delivery to the recipient is not diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index ce6bcc5..d67c4c4 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -717,6 +717,12 @@ Sets whether to output a notification character (bell or flash) to the terminal Determines whether your LXMF address is automatically announced when the program starts. Must be a boolean value. < +>>> +`!announce_interval = 360`! +>>>> +Determines how often, in minutes, your LXMF address is announced on the network. Defaults to 6 hours. +< + >>> `!try_propagation_on_send_fail = yes`! >>>> From 74ecdf7a43c4b35fda062517b8e8fbf9ee5d132b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 16 May 2026 22:59:14 +0200 Subject: [PATCH 411/592] Cleanup --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b721ea6..5d53d01 100755 --- a/README.md +++ b/README.md @@ -194,4 +194,4 @@ Nomad Network is beta software, and should be considered as such. While it has b ![Screenshot 4](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/4.png) -![Screenshot 5](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/5.png) +![Screenshot 5](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/5.png) \ No newline at end of file From 5c9a5cd4786250210341f89f1d9ae73dbb8a10eb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 01:09:55 +0200 Subject: [PATCH 412/592] Added RRC room history persistence by neutral --- nomadnet/NomadNetworkApp.py | 64 ++++++++----- nomadnet/RRC.py | 160 +++++++++++++++++++++++++++++++-- nomadnet/ui/textui/Channels.py | 3 +- nomadnet/ui/textui/Guide.py | 10 +++ 4 files changed, 209 insertions(+), 28 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index b40ddba..a81c762 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -146,10 +146,12 @@ class NomadNetworkApp: self.lxmf_sync_interval = 360*60 self.lxmf_sync_limit = 8 self.compact_stream = False - + self.required_stamp_cost = None self.accept_invalid_stamps = False + self.rrc_history_per_room_cap = 500 + if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -200,11 +202,11 @@ class NomadNetworkApp: import shutil examplespath = os.path.join(os.path.dirname(__file__), "examples") shutil.copytree(examplespath, self.examplespath, ignore=shutil.ignore_patterns("__pycache__")) - + except Exception as e: RNS.log("Could not copy examples into the "+self.examplespath+" directory.", RNS.LOG_ERROR) RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) - + RNS.log("Could not load config file, creating default configuration file...") self.createDefaultConfig() self.firstrun = True @@ -377,7 +379,7 @@ class NomadNetworkApp: if not self.disable_propagation: RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) - + self.node = nomadnet.Node(self) else: self.node = None @@ -442,7 +444,7 @@ class NomadNetworkApp: RNS.log("Starting job scheduler now", RNS.LOG_DEBUG) while self.should_run_jobs: now = time.time() - + if now > self.peer_settings["last_lxmf_sync"] + self.lxmf_sync_interval: RNS.log("Initiating automatic LXMF sync", RNS.LOG_VERBOSE) self.request_lxmf_sync(limit=self.lxmf_sync_limit) @@ -576,7 +578,7 @@ class NomadNetworkApp: self.peer_settings["propagation_node"] = node_hash self.save_peer_settings() self.autoselect_propagation_node() - + def get_default_propagation_node(self): return self.message_router.get_outbound_propagation_node() @@ -608,7 +610,7 @@ class NomadNetworkApp: if self.print_messages: if self.print_all_messages: return True - + else: source_hash_text = RNS.hexrep(message.source_hash, delimit=False) @@ -651,7 +653,7 @@ class NomadNetworkApp: received = time.time() g = self.ui.glyphs - + m_rtime = datetime.fromtimestamp(message.timestamp) stime = m_rtime.strftime(self.time_format) @@ -718,7 +720,7 @@ class NomadNetworkApp: def createDefaultConfig(self): self.config = ConfigObj(__default_nomadnet_config__) self.config.filename = self.configpath - + if not os.path.isdir(self.configdir): os.makedirs(self.configdir) self.config.write() @@ -784,7 +786,7 @@ class NomadNetworkApp: self.lxmf_sync_interval = value if option == "lxmf_sync_limit": - value = self.config["client"].as_int(option) + value = self.config["client"].as_int(option) if value > 0: self.lxmf_sync_limit = value @@ -810,7 +812,7 @@ class NomadNetworkApp: self.accept_invalid_stamps = value if option == "max_accepted_size": - value = self.config["client"].as_float(option) + value = self.config["client"].as_float(option) if value > 0: self.lxmf_max_incoming_size = value @@ -900,6 +902,16 @@ class NomadNetworkApp: if value == "web": self.uimode = nomadnet.ui.UI_WEB + if "rrc" in self.config: + for option in self.config["rrc"]: + if option == "history_per_room_cap": + try: + value = self.config["rrc"].as_int(option) + except Exception: + value = None + if value is not None and value >= 0: + self.rrc_history_per_room_cap = value + if "node" in self.config: if not "enable_node" in self.config["node"]: self.enable_node = False @@ -956,10 +968,10 @@ class NomadNetworkApp: value = self.config["node"].as_int("propagation_cost") if value < 13: value = 13 self.node_propagation_cost = value - + if "pages_path" in self.config["node"]: self.pagespath = self.config["node"]["pages_path"] - + if not "page_refresh_interval" in self.config["node"]: self.page_refresh_interval = 0 else: @@ -967,11 +979,11 @@ class NomadNetworkApp: if value < 0: value = 0 self.page_refresh_interval = value - + if "files_path" in self.config["node"]: self.filespath = self.config["node"]["files_path"] - + if not "file_refresh_interval" in self.config["node"]: self.file_refresh_interval = 0 else: @@ -979,7 +991,7 @@ class NomadNetworkApp: if value < 0: value = 0 self.file_refresh_interval = value - + if "prioritise_destinations" in self.config["node"]: self.prioritised_lxmf_destinations = self.config["node"].as_list("prioritise_destinations") @@ -990,7 +1002,7 @@ class NomadNetworkApp: self.static_peers = self.config["node"].as_list("static_peers") else: self.static_peers = [] - + if not "max_peers" in self.config["node"]: self.max_peers = None else: @@ -1030,13 +1042,13 @@ class NomadNetworkApp: self.print_all_messages = True if self.config["printing"]["print_from"].lower() == "trusted": - + self.print_all_messages = False self.print_trusted_messages = True if len(self.config["printing"]["print_from"]) == (RNS.Identity.TRUNCATED_HASHLENGTH//8)*2: self.allowed_message_print_destinations.append(self.config["printing"]["print_from"]) - + if type(self.config["printing"]["print_from"]) == list: self.allowed_message_print_destinations = self.config["printing"].as_list("print_from") for allowed_entry in self.allowed_message_print_destinations: @@ -1056,7 +1068,7 @@ class NomadNetworkApp: template_file.write(__printing_template_msg__.encode("utf-8")) self.printing_template_msg = __printing_template_msg__ - + @staticmethod def get_shared_instance(): if NomadNetworkApp._shared_instance != None: @@ -1212,6 +1224,18 @@ hide_guide = no # announces. sanitize_names = yes +[rrc] + +# Maximum number of messages retained per +# room in the in-memory scrollback buffer, +# and the number of messages restored from +# on-disk history at startup. The on-disk +# log itself is appended to indefinitely; +# this cap only controls how much backlog +# is visible. Set to 0 to keep every message +# in memory (full history). +history_per_room_cap = 500 + [node] # Whether to enable node hosting diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 982dbe1..7c92db3 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -10,6 +10,17 @@ import RNS from nomadnet.vendor import cbor +HISTORY_DIR_NAME = "rrc_history" +HISTORY_FILENAME_SANITIZE_RE = re.compile(r"[^a-z0-9._-]+") + +H_KIND = "k" +H_SRC = "s" +H_NICK = "n" +H_TEXT = "t" +H_TS = "ts" +H_MENTION = "m" + + _MENTION_RE_CACHE = {} @@ -241,6 +252,8 @@ class RRCHub: self._pending_parts = set() self._silent_joins = set() + self._history_write_failed = False + def _log(self, msg, level=None): if level is None: level = RNS.LOG_INFO @@ -264,9 +277,20 @@ class RRCHub: self.unread_rooms.discard(r) self.mention_rooms.discard(r) self.members.pop(r, None) + self._delete_history(r) self.manager.save() self.manager._notify_change(self) + def clear_messages(self, room): + r = self._normalize_room(room) + with self._lock: + if r in self.messages: + self.messages[r] = [] + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self._delete_history(r) + self.manager._notify_change(self) + def get_members(self, room): with self._lock: return list(self.members.get(room, set())) @@ -595,28 +619,124 @@ class RRCHub: self._record_message(RRCMessage("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) return mid + def _per_room_cap(self): + try: + v = int(getattr(self.manager.app, "rrc_history_per_room_cap", 0)) + except Exception: + return None + return v if v > 0 else None + + def _entry_for(self, msg): + return { + H_KIND: msg.kind, + H_SRC: bytes(msg.src) if isinstance(msg.src, (bytes, bytearray)) else None, + H_NICK: msg.nick if isinstance(msg.nick, str) else None, + H_TEXT: msg.text if isinstance(msg.text, str) else "", + H_TS: int(msg.ts) if isinstance(msg.ts, int) else _now_ms(), + H_MENTION: bool(getattr(msg, "mention", False)), + } + + def _msg_from_entry(self, room, entry): + if not isinstance(entry, dict): + return None + m = RRCMessage( + entry.get(H_KIND) if isinstance(entry.get(H_KIND), str) else "msg", + room, + entry.get(H_SRC) if isinstance(entry.get(H_SRC), (bytes, bytearray)) else None, + entry.get(H_NICK) if isinstance(entry.get(H_NICK), str) else None, + entry.get(H_TEXT) if isinstance(entry.get(H_TEXT), str) else "", + entry.get(H_TS) if isinstance(entry.get(H_TS), int) else 0, + ) + m.mention = bool(entry.get(H_MENTION, False)) + return m + + def _persistable_room(self, room): + return isinstance(room, str) and room and room != "*" + + def _append_history(self, room, msg): + if not self._persistable_room(room): + return + try: + self.manager._ensure_history_dir(self) + path = self.manager._history_path(self, room) + with open(path, "ab") as f: + f.write(cbor.encode(self._entry_for(msg))) + self._history_write_failed = False + except Exception as e: + if not self._history_write_failed: + self._history_write_failed = True + self._log("history persistence failed, suppressing further warnings until recovery: "+str(e), RNS.LOG_ERROR) + + def _delete_history(self, room): + if not self._persistable_room(room): + return + path = self.manager._history_path(self, room) + try: + if os.path.isfile(path): + os.unlink(path) + except Exception: + pass + + def _load_history(self): + with self._lock: + rooms = list(self.messages.keys()) + for room in rooms: + if not self._persistable_room(room): + continue + path = self.manager._history_path(self, room) + if not os.path.isfile(path): + continue + window = deque(maxlen=self._per_room_cap()) + decode_error = None + try: + with open(path, "rb") as f: + while True: + try: + window.append(cbor.load(f)) + except EOFError: + break + except Exception as ex: + decode_error = ex + break + except OSError as ex: + self._log("history load failed for #"+room+": "+str(ex), RNS.LOG_ERROR) + continue + if decode_error is not None: + self._log("history file for #"+room+" is corrupt, truncating to last "+str(len(window))+" valid messages: "+str(decode_error), RNS.LOG_ERROR) + msgs = [] + for e in window: + m = self._msg_from_entry(room, e) + if m is not None: + msgs.append(m) + with self._lock: + self.messages[room] = msgs + def _record_message(self, msg, local=False): + cap = self._per_room_cap() with self._lock: buf = self.messages.setdefault(msg.room or "*", []) buf.append(msg) - if len(buf) > 500: - del buf[:len(buf)-500] + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] if not local and msg.room: if msg.room != self.manager.active_room_for(self): self.unread_rooms.add(msg.room) if msg.mention: self.mention_rooms.add(msg.room) + self._append_history(msg.room, msg) self.manager._notify_messages(self, msg) def _record_system(self, room, text): if not room: return msg = RRCMessage("system", room, None, None, text, _now_ms()) + cap = self._per_room_cap() with self._lock: buf = self.messages.setdefault(room, []) buf.append(msg) - if len(buf) > 500: - del buf[:len(buf)-500] + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] + self._append_history(room, msg) self.manager._notify_messages(self, msg) def _record_notice(self, msg): @@ -626,6 +746,7 @@ class RRCHub: if target_room: msg.room = target_room + cap = self._per_room_cap() with self._lock: self.notices.append(msg) if len(self.notices) > 200: @@ -633,10 +754,12 @@ class RRCHub: if target_room: buf = self.messages.setdefault(target_room, []) buf.append(msg) - if len(buf) > 500: - del buf[:len(buf)-500] + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] if target_room != self.manager.active_room_for(self): self.unread_rooms.add(target_room) + if target_room: + self._append_history(target_room, msg) self.manager._notify_messages(self, msg) def get_messages(self, room): @@ -1108,6 +1231,27 @@ class RRCManager: def _store_path(self): return os.path.join(self.app.storagepath, "rrc_hubs") + def _history_root(self): + return os.path.join(self.app.storagepath, HISTORY_DIR_NAME) + + def _history_dir(self, hub): + hub_key = hub.hub_hash.hex() + if hub.dest_name and hub.dest_name != DEFAULT_DEST_NAME: + suffix = hashlib.sha256(hub.dest_name.encode("utf-8")).hexdigest()[:8] + hub_key = hub_key + "__" + suffix + return os.path.join(self._history_root(), hub_key) + + def _history_path(self, hub, room): + sanitized = HISTORY_FILENAME_SANITIZE_RE.sub("_", room or "")[:64] + room_hash = hashlib.sha256((room or "").encode("utf-8")).hexdigest()[:8] + filename = (sanitized + "_" + room_hash + ".log") if sanitized else (room_hash + ".log") + return os.path.join(self._history_dir(hub), filename) + + def _ensure_history_dir(self, hub): + d = self._history_dir(hub) + os.makedirs(d, exist_ok=True) + return d + def load(self): if self._loaded: return @@ -1161,6 +1305,10 @@ class RRCManager: no = e.get("nick") if isinstance(no, str) and no: hub.nick_override = no + try: + hub._load_history() + except Exception as ex: + RNS.log("Failed to load RRC history for "+hub.name+": "+str(ex), RNS.LOG_ERROR) except Exception as e: RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR) finally: diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index abaed97..74fc6e2 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -727,8 +727,7 @@ class RoomWidget(urwid.WidgetWrap): return if cmd == "clear": - with self.hub._lock: - self.hub.messages[self.room] = [] + self.hub.clear_messages(self.room) self.update_messages(replace=True) return diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index d67c4c4..92c4c98 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -771,6 +771,16 @@ The maximum accepted unpacked size for messages received directly from other pee With this option enabled, Nomad Network will only display one entry in the announce stream per destination. Older announces are culled when a new one arrives. < +>> RRC Section + +This section hold configuration directives related to the RRC client behaviour. It is delimited by the `![rrc]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!history_per_room_cap = 500`! +>>>> +Maximum number of messages retained per room in the in-memory scrollback buffer, and the number of messages restored from on-disk history at startup. The on-disk log itself is appended to indefinitely; this cap only controls how much backlog is visible. Set to 0 to keep every message in memory. +< + >> Text UI Section This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows: From 0f0090d69c67cd90bd996bf4ae0378363fe68b72 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 01:11:15 +0200 Subject: [PATCH 413/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 92192ee..6849410 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.0.4" +__version__ = "1.1.0" diff --git a/setup.py b/setup.py index b38d9d7..f3cfec0 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.5", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.7", "lxmf>=0.9.8", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From 5f2d9207c2881df5e199752b2e72cbea78fd3c82 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 01:25:28 +0200 Subject: [PATCH 414/592] Arrows point left on part --- nomadnet/ui/textui/Channels.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 74fc6e2..a96bd0f 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -860,18 +860,19 @@ def _message_widget(app, hub, m, link_delegate=None): pass if m.kind == "system": + evt_icon = g["arrow_l"] if m.text.endswith(" left") else g["arrow_r"] spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick) - markup = [_ts_prefix(m.ts), ("irc_system", " "+g["arrow_r"]+" ")] + spans + markup = [_ts_prefix(m.ts), ("irc_system", evt_icon+" ")] + spans return _wrap_text(markup, link_delegate if has_links else None) if m.kind == "notice": spans, has_links = _body_markup(m.text or "", body_attr="irc_notice", own_nick=own_nick) - markup = [_ts_prefix(m.ts), ("irc_notice", " "+g["info"]+" ")] + spans + markup = [_ts_prefix(m.ts), ("irc_notice", g["info"]+" ")] + spans return _wrap_text(markup, link_delegate if has_links else None) if m.kind == "error": spans, has_links = _body_markup(m.text or "", body_attr="irc_error", own_nick=own_nick) - markup = [_ts_prefix(m.ts), ("irc_error", " "+g["warning"]+" ")] + spans + markup = [_ts_prefix(m.ts), ("irc_error", g["warning"]+" ")] + spans return _wrap_text(markup, link_delegate if has_links else None) own = False From 9e8d5fa96048def667f7dc47fee253c9861a49a2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 01:36:18 +0200 Subject: [PATCH 415/592] Allow configuring lxmf.delivery announce interval --- nomadnet/NomadNetworkApp.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index a81c762..3e1b1ab 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -86,6 +86,8 @@ class NomadNetworkApp: self.uimode = None + self.announce_interval = 6*60*60 + if configdir == None: self.configdir = NomadNetworkApp.configdir else: @@ -258,6 +260,8 @@ class NomadNetworkApp: if not "served_file_requests" in self.peer_settings: self.peer_settings["served_file_requests"] = 0 + self.peer_settings["announce_interval"] = self.announce_interval + except Exception as e: RNS.logdest = RNS.LOG_STDOUT RNS.log(f"Could not load local peer settings from {self.peersettingspath}", RNS.LOG_ERROR) @@ -270,7 +274,7 @@ class NomadNetworkApp: RNS.log("No peer settings file found, creating new...") self.peer_settings = { "display_name": "Anonymous Peer", - "announce_interval": 6*60*60, + "announce_interval": self.announce_interval, "last_announce": None, "node_last_announce": None, "propagation_node": None, @@ -769,7 +773,7 @@ class NomadNetworkApp: if option == "announce_interval": value = self.config["client"].as_int(option) if value < 30: value = 30 - self.peer_settings["announce_interval"] = value*60 + self.announce_interval = value*60 if option == "try_propagation_on_send_fail": value = self.config["client"].as_bool(option) From e946120dc9d0a69b424aff82673d69bbe3f16776 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 02:02:27 +0200 Subject: [PATCH 416/592] Fixed urwid race/crash in conversations by neutral --- nomadnet/ui/textui/Conversations.py | 45 +++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 4adefff..57521e3 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1,4 +1,5 @@ import RNS +import collections import os import shutil import time @@ -133,7 +134,40 @@ class ConversationsDisplay(): self.shortcuts_display = self.list_shortcuts self.widget = self.columns_widget - nomadnet.Conversation.created_callback = self.update_conversation_list + + self._pending_actions = collections.deque() + self._wake_fd = None + try: + self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending) + except Exception: + pass + + nomadnet.Conversation.created_callback = lambda: self._wake(self.update_conversation_list) + + def _process_pending(self, data): + while True: + try: + action = self._pending_actions.popleft() + except IndexError: + break + try: + action() + except Exception as e: + RNS.log("Conversations UI action failed: "+str(e), RNS.LOG_ERROR) + return True + + def _wake(self, action): + self._pending_actions.append(action) + if self._wake_fd is not None: + try: + os.write(self._wake_fd, b".") + return + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None)) + except Exception: + pass def focus_change_event(self): if not self.dialog_open: @@ -1024,7 +1058,7 @@ class ConversationWidget(urwid.WidgetWrap): self.update_message_widgets() - self.conversation.register_changed_callback(self.conversation_changed) + self.conversation.register_changed_callback(self._on_conversation_changed_from_callback) #title_editor = MessageEdit(caption="\u270E", edit_text="", multiline=False) title_editor = MessageEdit(caption="", edit_text="", multiline=False) @@ -1243,6 +1277,13 @@ class ConversationWidget(urwid.WidgetWrap): else: return super(ConversationWidget, self).keypress(size, key) + def _on_conversation_changed_from_callback(self, conversation): + delegate = getattr(self, "delegate", None) + if delegate is not None and hasattr(delegate, "_wake"): + delegate._wake(lambda: self.conversation_changed(conversation)) + else: + self.conversation_changed(conversation) + def conversation_changed(self, conversation): if hasattr(self, "peer_info_widget"): self._update_peer_info() From 7e904ede5fed7cd41b6734cadaabc34ff2110a44 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Sat, 16 May 2026 12:13:27 -0400 Subject: [PATCH 417/592] add tools/rrc-log-dump.py for dumping on-disk rrc logs --- tools/rrc-log-dump.py | 118 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100755 tools/rrc-log-dump.py diff --git a/tools/rrc-log-dump.py b/tools/rrc-log-dump.py new file mode 100755 index 0000000..30c9bb8 --- /dev/null +++ b/tools/rrc-log-dump.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Dump the contents of rrc on-disk room history logs. + +Usage: + rrc-log-dump.py + + may be: + - a single .log file + - a hub directory under rrc_history/ + - the rrc_history/ root (or any ancestor; .log files are found recursively) +""" + +import os +import sys +import time + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(SCRIPT_DIR, "..", "nomadnet", "vendor")) +import cbor + + +H_KIND = "k" +H_SRC = "s" +H_NICK = "n" +H_TEXT = "t" +H_TS = "ts" +H_MENTION = "m" + +KIND_COLOR = { + "msg": "", + "system": "\033[2m", + "notice": "\033[36m", + "error": "\033[31m", +} +RESET = "\033[0m" + + +def _fmt_ts(ts_ms): + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts_ms) // 1000)) + except Exception: + return "????-??-?? ??:??:??" + + +def _fmt_speaker(entry): + nick = entry.get(H_NICK) + src = entry.get(H_SRC) + src_hex = src.hex()[:12] if isinstance(src, (bytes, bytearray)) else None + if nick and src_hex: + return "%s (%s)" % (nick, src_hex) + if nick: + return nick + if src_hex: + return src_hex + return "" + + +def dump_file(path, use_color): + print("=== %s ===" % path) + count = 0 + truncated = False + with open(path, "rb") as f: + while True: + try: + entry = cbor.load(f) + except EOFError: + break + except Exception as ex: + truncated = True + print(" [corrupt record after entry %d: %s]" % (count, ex), file=sys.stderr) + break + if not isinstance(entry, dict): + print(" [skipping non-dict entry: %r]" % (entry,), file=sys.stderr) + continue + kind = entry.get(H_KIND) or "msg" + ts = _fmt_ts(entry.get(H_TS, 0)) + speaker = _fmt_speaker(entry) + text = entry.get(H_TEXT) or "" + mention = " *" if entry.get(H_MENTION) else " " + color = KIND_COLOR.get(kind, "") if use_color else "" + end = RESET if (color and use_color) else "" + line = "%s %-6s%s %-32s %s" % (ts, kind, mention, speaker, text) + print(color + line + end) + count += 1 + suffix = " (truncated)" if truncated else "" + print("--- %d entries%s ---" % (count, suffix)) + + +def main(argv): + if len(argv) != 2 or argv[1] in ("-h", "--help"): + print(__doc__, file=sys.stderr) + return 2 + target = argv[1] + use_color = sys.stdout.isatty() + if os.path.isfile(target): + dump_file(target, use_color) + return 0 + if os.path.isdir(target): + files = [] + for root, _dirs, names in os.walk(target): + for name in names: + if name.endswith(".log"): + files.append(os.path.join(root, name)) + files.sort() + if not files: + print("no .log files found under %s" % target, file=sys.stderr) + return 1 + for i, path in enumerate(files): + if i > 0: + print() + dump_file(path, use_color) + return 0 + print("not a file or directory: %s" % target, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 087261c8bb836dc5bb9b48ef25be7cc542fa6f4e Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Fri, 15 May 2026 20:40:48 -0400 Subject: [PATCH 418/592] channels: don't snap window to bottom of chat when a message arrives --- nomadnet/ui/textui/Channels.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index a96bd0f..cd75aa9 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -492,17 +492,19 @@ class RoomWidget(urwid.WidgetWrap): widget = _message_widget(self.app, self.hub, msg, link_delegate=self.link_delegate) wrapped = urwid.AttrMap(widget, None) body = self.messagelist._listbox.body + was_at_bottom = getattr(self, "_empty_placeholder", False) or getattr(self.messagelist, "bottom_is_visible", True) if getattr(self, "_empty_placeholder", False): del body[:] self._empty_placeholder = False body.append(wrapped) while len(body) > self.MAX_RENDERED_MESSAGES: del body[0] - try: - self.messagelist._listbox.set_focus(len(body)-1) - self.messagelist._listbox.set_focus_valign("bottom") - except Exception: - pass + if was_at_bottom: + try: + self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass except Exception as e: RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG) self.update_messages(replace=True) From 93b5496fc4bd9238942987033ed161a3aff896a2 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Sun, 17 May 2026 17:35:24 -0400 Subject: [PATCH 419/592] rrc: truncate in-memory messages to rrc.history_per_room_cap instead of 500 --- nomadnet/ui/textui/Channels.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index cd75aa9..b763182 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -459,8 +459,6 @@ class RoomWidget(urwid.WidgetWrap): right = status_label+" " self.peer_info_widget.original_widget.set_text(left+" | "+right) - MAX_RENDERED_MESSAGES = 500 - def update_messages(self, replace=False): msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] widgets = [] @@ -497,8 +495,10 @@ class RoomWidget(urwid.WidgetWrap): del body[:] self._empty_placeholder = False body.append(wrapped) - while len(body) > self.MAX_RENDERED_MESSAGES: - del body[0] + cap = getattr(self.app, "rrc_history_per_room_cap", 0) + if cap and cap > 0: + while len(body) > cap: + del body[0] if was_at_bottom: try: self.messagelist._listbox.set_focus(len(body)-1) From f304485f0d9f43475622b41cadf3b60784a41a4a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 18 May 2026 14:04:00 +0200 Subject: [PATCH 420/592] Updated guide --- nomadnet/ui/textui/Guide.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 92c4c98..6c5a292 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -449,7 +449,7 @@ I2P Tunneled: Enable for connecting through I2P KISS Framing: Enable for KISS framing for software modems ``` -This interface is commonly used to connect to Reticulum testnets or other persistent nodes on the Internet. +This interface is commonly used to connect to Reticulum gateways or other persistent nodes on the Internet. >>TCPServerInterface @@ -998,24 +998,11 @@ For future reference, you can download the Reticulum Manual in PDF format here: It might be nice to keep that handy when you are not connected to the Internet, as it is full of information and examples that are also very relevant to Nomad Network. ->The Reticulum Testnet +>The Distributed Backbone -If you have Internet access, and just want to get started experimenting, you are welcome to join the Unsigned.io RNS Testnet. The testnet is just that, an informal network for testing and experimenting. It will be up most of the time, and anyone can join, but it also means that there's no guarantees for service availability. +If you have Internet access, and just want to get started experimenting, you can connected to the distributed global Reticulum backbone. For more information about this, see the Reticulum Manual, or sites such as https://directory.rns.recips or https://rmap.world. -The Testnet also runs the latest version of Reticulum, often even a short while before it is publicly released, which means strange behaviour might occur. If none of that scares you, add the following interface to your Reticulum configuration file to join: - ->> -[[RNS Testnet Dublin]] - type = TCPClientInterface - enabled = yes - target_host = dublin.connect.reticulum.network - target_port = 4965 -< - -If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: - - - Dublin Hub Testnet Node : `!`[abb3ebcd03cb2388a838e70c001291f9]`! - - Frankfurt Hub Testnet Node : `!`[ea6a715f814bdc37e56f80c34da6ad51]`! +If you connect to the public backbone, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: To browse pages on a node that is not currently known, open the URL dialog in the `![ Network ]`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!< Go >`! or press enter. Nomadnet will attempt to discover and connect to the requested node. You can save the currently connected node by pressing `!Ctrl+S`!. ''' From aefc455c003b7c7839ca8cedfbf583456f2992f0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 18 May 2026 14:28:54 +0200 Subject: [PATCH 421/592] Link display truncation --- nomadnet/ui/textui/Browser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index ae22239..1913fb9 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -180,8 +180,9 @@ class Browser: if can_display: self.link_status_showing = True lt_str = str(link_target) - if len(lt_str) > 80: lt_str = lt_str[:80]+"…" - self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to: "+lt_str)]), "browser_controls") + lmax = 128 + if len(lt_str) > lmax: lt_str = lt_str[:lmax]+"…" + self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to "+lt_str)]), "browser_controls") self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) if self.page_background_color != None or self.page_foreground_color != None: style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) From 56da0e7353b7a7ddc3965acfc76a615ebba605e6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 18 May 2026 14:58:22 +0200 Subject: [PATCH 422/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 6849410..a82b376 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" diff --git a/setup.py b/setup.py index f3cfec0..181cab0 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.7", "lxmf>=0.9.8", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.9", "lxmf>=0.9.8", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From da201fb3b6f6e74881dd9ab78416fe8e19e379e2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 18 May 2026 15:18:05 +0200 Subject: [PATCH 423/592] Added option to filter sys and notice events from RRC room history on load --- nomadnet/NomadNetworkApp.py | 18 +++++++++++++----- nomadnet/RRC.py | 12 ++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 3e1b1ab..9a9a500 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -153,7 +153,7 @@ class NomadNetworkApp: self.accept_invalid_stamps = False self.rrc_history_per_room_cap = 500 - + self.rrc_filter_loaded_history = True if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -909,12 +909,15 @@ class NomadNetworkApp: if "rrc" in self.config: for option in self.config["rrc"]: if option == "history_per_room_cap": - try: - value = self.config["rrc"].as_int(option) - except Exception: - value = None + try: value = self.config["rrc"].as_int(option) + except Exception: value = None if value is not None and value >= 0: self.rrc_history_per_room_cap = value + + if option == "filter_loaded_history": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_filter_loaded_history = value if "node" in self.config: if not "enable_node" in self.config["node"]: @@ -1240,6 +1243,11 @@ sanitize_names = yes # in memory (full history). history_per_room_cap = 500 +# You can choose whether to filter system +# and notice events when room history is +# loaded. +filter_loaded_history = yes + [node] # Whether to enable node hosting diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 7c92db3..6cc28ab 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -626,6 +626,11 @@ class RRCHub: return None return v if v > 0 else None + def _filter_history(self): + try: v = getattr(self.manager.app, "rrc_filter_loaded_history", True) + except Exception: return True + return v + def _entry_for(self, msg): return { H_KIND: msg.kind, @@ -704,9 +709,16 @@ class RRCHub: if decode_error is not None: self._log("history file for #"+room+" is corrupt, truncating to last "+str(len(window))+" valid messages: "+str(decode_error), RNS.LOG_ERROR) msgs = [] + filter_msgs = self._filter_history() for e in window: m = self._msg_from_entry(room, e) if m is not None: + if filter_msgs: + should_filter = False + if m.kind == "system": should_filter = True + elif m.kind == "notice": should_filter = True + if should_filter: continue + msgs.append(m) with self._lock: self.messages[room] = msgs From 68c26a697b4f0172d935dffd94a04348b7880e4b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 19 May 2026 14:50:31 +0200 Subject: [PATCH 424/592] Added sys/notice timeout config --- nomadnet/NomadNetworkApp.py | 13 +++++++++ nomadnet/RRC.py | 39 ++++++++++++++++++++++++++ nomadnet/ui/textui/Channels.py | 50 ++++++++++++++++++++++++---------- nomadnet/ui/textui/Guide.py | 12 ++++++++ 4 files changed, 100 insertions(+), 14 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 9a9a500..4e14b50 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -154,6 +154,7 @@ class NomadNetworkApp: self.rrc_history_per_room_cap = 500 self.rrc_filter_loaded_history = True + self.rrc_ephemeral_notices = 600 if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -918,6 +919,11 @@ class NomadNetworkApp: try: value = self.config["rrc"].as_bool(option) except Exception: value = True self.rrc_filter_loaded_history = value + + if option == "ephemeral_notices": + try: value = self.config["rrc"].as_float(option) + except Exception: value = 0 + self.rrc_ephemeral_notices = value*60 if "node" in self.config: if not "enable_node" in self.config["node"]: @@ -1248,6 +1254,13 @@ history_per_room_cap = 500 # loaded. filter_loaded_history = yes +# You can choose whether notices and sys +# messages persist indefinitely, or are +# removed from the message history after +# the specified time in minutes. Set to 0 +# to disable and keep forever. +ephemeral_notices = 10 + [node] # Whether to enable node hosting diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 6cc28ab..1335fe1 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -201,6 +201,9 @@ class RRCHub: STATUS_CONNECTED = 2 STATUS_FAILED = 3 + CLEAN_HISTORY_INTERVAL = 5 + SYS_NOTICE_TIMEOUT = 600 + def __init__(self, manager, hub_hash, dest_name=None, name=None): self.manager = manager self.hub_hash = hub_hash @@ -242,6 +245,8 @@ class RRCHub: self._reconnect_attempts = 0 self._reconnect_timer = None self._pending_pings = {} + self._last_history_clean = 0 + self.clean_last_removed = 0 self.available_rooms = {} self._silent_list_pending = 0 @@ -631,6 +636,11 @@ class RRCHub: except Exception: return True return v + def _ephemeral_notices_history(self): + try: v = getattr(self.manager.app, "rrc_ephemeral_notices", self.SYS_NOTICE_TIMEOUT) + except Exception: return self.SYS_NOTICE_TIMEOUT + return v + def _entry_for(self, msg): return { H_KIND: msg.kind, @@ -723,6 +733,32 @@ class RRCHub: with self._lock: self.messages[room] = msgs + def _clean_history(self): + now = time.time() + cleaned = False + remove_after = self._ephemeral_notices_history() + if now > self._last_history_clean + self.CLEAN_HISTORY_INTERVAL: + RNS.log(f"Cleaning loaded message history", RNS.LOG_DEBUG) + with self._lock: + try: + for r in self.messages: + old = set() + for m in self.messages[r]: + age = now-m.ts/1000.0 + should_filter = False + if m.kind == "system": should_filter = True + elif m.kind == "notice": should_filter = True + if should_filter and age > remove_after: old.add(m) + + for m in old: + self.messages[r].remove(m) + cleaned = True + + except Exception as e: RNS.trace_exception(e) + + self._last_history_clean = time.time() + if cleaned: self.clean_last_removed = time.time() + def _record_message(self, msg, local=False): cap = self._per_room_cap() with self._lock: @@ -736,6 +772,7 @@ class RRCHub: if msg.mention: self.mention_rooms.add(msg.room) self._append_history(msg.room, msg) + self._clean_history() self.manager._notify_messages(self, msg) def _record_system(self, room, text): @@ -749,6 +786,7 @@ class RRCHub: if cap is not None and len(buf) > cap: del buf[:len(buf)-cap] self._append_history(room, msg) + self._clean_history() self.manager._notify_messages(self, msg) def _record_notice(self, msg): @@ -772,6 +810,7 @@ class RRCHub: self.unread_rooms.add(target_room) if target_room: self._append_history(target_room, msg) + self._clean_history() self.manager._notify_messages(self, msg) def get_messages(self, room): diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index b763182..513c88f 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -29,13 +29,6 @@ _LINK_RE = re.compile( r"|(?P(? self.last_history_clean: + try: + hub_msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] + self.last_history_clean = time.time() + c = self.messagelist.body_len() + old = set() + for i in range(0, c): + msg = None + w = self.messagelist.get_item(i) + if hasattr(w, "_original_widget"): o = w._original_widget + else: o = None + if hasattr(o, "msg"): msg = o.msg + elif hasattr(w, "msg"): msg = w.msg + if msg and not msg in hub_msgs: old.add(i) + + for i in reversed(list(old)): self.messagelist.delete_position(i) + + except Exception as e: + RNS.log("Error while cleaning room history", RNS.LOG_ERROR) + RNS.trace_exception(e) + body.append(wrapped) cap = getattr(self.app, "rrc_history_per_room_cap", 0) if cap and cap > 0: @@ -865,17 +879,23 @@ def _message_widget(app, hub, m, link_delegate=None): evt_icon = g["arrow_l"] if m.text.endswith(" left") else g["arrow_r"] spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick) markup = [_ts_prefix(m.ts), ("irc_system", evt_icon+" ")] + spans - return _wrap_text(markup, link_delegate if has_links else None) + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget if m.kind == "notice": spans, has_links = _body_markup(m.text or "", body_attr="irc_notice", own_nick=own_nick) markup = [_ts_prefix(m.ts), ("irc_notice", g["info"]+" ")] + spans - return _wrap_text(markup, link_delegate if has_links else None) + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget if m.kind == "error": spans, has_links = _body_markup(m.text or "", body_attr="irc_error", own_nick=own_nick) markup = [_ts_prefix(m.ts), ("irc_error", g["warning"]+" ")] + spans - return _wrap_text(markup, link_delegate if has_links else None) + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget own = False try: @@ -895,7 +915,9 @@ def _message_widget(app, hub, m, link_delegate=None): body = m.text or "" spans, has_links = _body_markup(body, body_attr="body_text", own_nick=None if own else own_nick) markup = [_ts_prefix(m.ts), (nick_attr, "<"+sender+">"), ("body_text", " ")] + spans - return _wrap_text(markup, link_delegate if has_links else None) + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget def _wrap_text(markup, link_delegate): diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 6c5a292..66def19 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -781,6 +781,18 @@ This section hold configuration directives related to the RRC client behaviour. Maximum number of messages retained per room in the in-memory scrollback buffer, and the number of messages restored from on-disk history at startup. The on-disk log itself is appended to indefinitely; this cap only controls how much backlog is visible. Set to 0 to keep every message in memory. < +>>> +`!filter_loaded_history = yes`! +>>>> +Whether to filter notices and system message when initially loading room history. +< + +>>> +`!ephemeral_notices = 10`! +>>>> +If enabled, notices and system messages will disappear from the history after a while. 0 disables, otherwise sets the timeout in minutes. +< + >> Text UI Section This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows: From 47afeb2d3b05c3756adfb3fc9082ca46f8ffde1a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 19 May 2026 18:15:40 +0200 Subject: [PATCH 425/592] Use micron engine for RRC rendering. Added support for links with request vars. --- nomadnet/ui/textui/Channels.py | 94 ++++++++++++++++++++++++------ nomadnet/ui/textui/MicronParser.py | 14 +++-- 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 513c88f..00162f9 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -10,6 +10,27 @@ import nomadnet from nomadnet.RRC import RRCHub from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec +from .MicronParser import markup_to_attrmaps +from nomadnet.util import strip_modifiers + + +theme_dark = { "ts": "888", + "nick_self": "6c5", + "nick_peer": "3cd", + "notice": "fd3", + "error": "f55", + "system": "888", + "mention": "fb4", + "link": "79d", } + +theme_light = { "ts": "888", + "nick_self": "3a0", + "nick_peer": "077", + "notice": "a70", + "error": "a22", + "system": "888", + "mention": "c50", + "link": "79d", } class _ChatLinkableText(LinkableText): @@ -95,7 +116,7 @@ def _scan_mentions(text, own_nick): yield m.start(), m.end(), "mention", None -def _body_markup(body, body_attr="body_text", own_nick=None): +def _body_markup(body, body_attr="body_text", own_nick=None, check_links=True): spans = list(_scan_links(body)) spans.extend(_scan_mentions(body, own_nick)) spans.sort(key=lambda s: s[0]) @@ -120,8 +141,12 @@ def _body_markup(body, body_attr="body_text", own_nick=None): out.append(("irc_mention", body[start:end])) else: base = _LINK_ATTRS[kind] - out.append((LinkSpec(kind+":"+target, base, cm=256), body[start:end])) - has_links = True + if check_links: + out.append((LinkSpec(kind+":"+target, base, cm=256), body[start:end])) + has_links = True + else: + out.append((f"link_{kind}", body[start:end])) + has_links = True pos = end if pos < len(body): out.append((body_attr, body[pos:])) @@ -786,6 +811,10 @@ def _ts_prefix(ts_ms): t = _format_ts(ts_ms) if ts_ms else " " return ("irc_ts", " ["+t+"] ") +def _ts_prefix_raw(ts_ms): + t = _format_ts(ts_ms) if ts_ms else " " + return "["+t+"] " + class _ChatLinkDelegate: def __init__(self, display, hub): @@ -801,18 +830,23 @@ class _ChatLinkDelegate: pass def handle_link(self, target, fields=None): - if target is None: - return - kind, _, payload = target.partition(":") + if target is None: return try: - if kind == "room": - self._open_room(payload) - elif kind == "lxmf": - self._open_lxmf(payload) + components = target.split("://") + if len(components) < 2: return + kind = components[0] + payload = components[1] + if kind == "room": self._open_room(payload.lstrip("#")) + elif kind == "lxmf": self._open_lxmf(payload.lstrip("lxmf@")) elif kind == "page": - self._open_page(payload) - except Exception as e: - RNS.log("Chat link handler failed: "+str(e), RNS.LOG_ERROR) + final_url = payload + if fields: + final_url += "`" + for f in fields: final_url += f"{f}|" + final_url.rstrip("|") + self._open_page(final_url) + else: RNS.log(f"Invalid URL: {target}", RNS.LOG_WARNING) + except Exception as e: RNS.log("Chat link handler failed: "+str(e), RNS.LOG_ERROR) def _open_room(self, room): room = (room or "").strip().lower() @@ -863,8 +897,8 @@ class _ChatLinkDelegate: except Exception as e: RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) - def _message_widget(app, hub, m, link_delegate=None): + t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light g = app.ui.glyphs own_nick = None try: @@ -913,12 +947,38 @@ def _message_widget(app, hub, m, link_delegate=None): nick_attr = "irc_nick_self" if own else "irc_nick_peer" body = m.text or "" - spans, has_links = _body_markup(body, body_attr="body_text", own_nick=None if own else own_nick) - markup = [_ts_prefix(m.ts), (nick_attr, "<"+sender+">"), ("body_text", " ")] + spans - final_widget = _wrap_text(markup, link_delegate if has_links else None) + spans, has_links = _body_markup(body, body_attr="body_text", own_nick=None if own else own_nick, check_links=False) + ld = link_delegate if has_links else None + + message_body = "" + for span in spans: + ms = span[0] + mb = span[1] + if ms.startswith("irc_mention"): message_body += f"`!`F{t['mention']}{mb}`f`!" + elif ms.startswith("link_"): + kind = ms[len("link_"):] + label = mb.split("`")[0] + url = f"{kind}://{mb}" + link_mu = f"`_`F{t['link']}`[{label}`{url}]`f`_" + message_body += link_mu + else: message_body += mb + + nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" + irc_ts = f"`F{t['ts']}" + + prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}`f {nick_attr}<{sender}>`f " + + rendered = _render_body(f"{prefix_micron}{message_body}", link_delegate=ld) + final_widget = urwid.Padding(urwid.Pile(rendered), left=1) + final_widget.msg = m return final_widget +def _render_body(markup, link_delegate=None): + try: return markup_to_attrmaps(strip_modifiers(markup), url_delegate=link_delegate, link_class=_ChatLinkableText) + except Exception as e: + RNS.trace_exception(e) + return [] def _wrap_text(markup, link_delegate): if link_delegate is not None: diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index a043f99..bf1ed30 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -60,7 +60,11 @@ def default_state(fg=None, bg=None): } return state -def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None): +def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None): + global LINK_CLASS + if link_class: LINK_CLASS = link_class + else: LINK_CLASS = LinkableText + global SELECTED_STYLES if nomadnet.NomadNetworkApp.get_shared_instance().config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK: SELECTED_STYLES = STYLES_DARK @@ -290,7 +294,7 @@ def parse_line(line, state, url_delegate): for o in output: if isinstance(o, tuple): if url_delegate != None: - tw = LinkableText(o, align=state["align"], delegate=url_delegate) + tw = LINK_CLASS(o, align=state["align"], delegate=url_delegate) tw.in_columns = True else: tw = urwid.Text(o, align=state["align"]) @@ -343,7 +347,7 @@ def parse_line(line, state, url_delegate): else: if url_delegate != None: - text_widget = LinkableText(output, align=state["align"], delegate=url_delegate) + text_widget = LINK_CLASS(output, align=state["align"], delegate=url_delegate) else: text_widget = urwid.Text(output, align=state["align"]) @@ -973,4 +977,6 @@ class LinkableText(urwid.Text): return True except Exception as e: - return False \ No newline at end of file + return False + +LINK_CLASS = LinkableText \ No newline at end of file From 4114df70d1cd02c27ca0ca7a7cd6e74370ee362f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 19 May 2026 21:23:02 +0200 Subject: [PATCH 426/592] Added markdown rendering support, config options for enabling/disabling markdown and micron rendering, justify_msgs and space_msgs layout options. --- nomadnet/NomadNetworkApp.py | 30 ++++++++++++++ nomadnet/ui/textui/Channels.py | 73 +++++++++++++++++++++++++++++++--- nomadnet/ui/textui/Guide.py | 19 +++++++++ 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 4e14b50..f3afd73 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -155,6 +155,10 @@ class NomadNetworkApp: self.rrc_history_per_room_cap = 500 self.rrc_filter_loaded_history = True self.rrc_ephemeral_notices = 600 + self.rrc_ui_justify_msgs = False + self.rrc_ui_space_msgs = False + self.rrc_ui_render_markdown = True + self.rrc_ui_render_micron = True if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -925,6 +929,26 @@ class NomadNetworkApp: except Exception: value = 0 self.rrc_ephemeral_notices = value*60 + if option == "justify_msgs": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_ui_justify_msgs = value + + if option == "space_msgs": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_ui_space_msgs = value + + if option == "render_markdown": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_ui_render_markdown = value + + if option == "render_micron": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_ui_render_micron = value + if "node" in self.config: if not "enable_node" in self.config["node"]: self.enable_node = False @@ -1261,6 +1285,12 @@ filter_loaded_history = yes # to disable and keep forever. ephemeral_notices = 10 +# Other display and formatting options: +render_markdown = yes +render_micron = yes +justify_msgs = no +space_msgs = no + [node] # Whether to enable node hosting diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 00162f9..ea119d8 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -10,6 +10,8 @@ import nomadnet from nomadnet.RRC import RRCHub from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec +from RNS.Utilities.rngit.util import MarkdownToMicron +from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps from nomadnet.util import strip_modifiers @@ -897,6 +899,49 @@ class _ChatLinkDelegate: except Exception as e: RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) +def strip_micron(text): + text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text) + text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text) + text = re.sub(r'`[!*_=]', '', text) + text = re.sub(r'`f`b', '', text) + text = re.sub(r'`f', '', text) + text = re.sub(r'`b', '', text) + text = re.sub(r'`<', '', text) + text = re.sub(r'`>', '', text) + text = re.sub(r'`{', '', text) + return text + +def strip_escaped_micron(text): + text = re.sub(r'¦[FB][0-9a-fA-F]{3}', '', text) + text = re.sub(r'¦[FB]T[0-9a-fA-F]{6}', '', text) + text = re.sub(r'¦[!*_=]', '', text) + text = re.sub(r'¦f`b', '', text) + text = re.sub(r'¦f', '', text) + text = re.sub(r'¦b', '', text) + text = re.sub(r'¦<', '', text) + text = re.sub(r'¦>', '', text) + text = re.sub(r'¦{', '', text) + return text + +def unescape_micron(text): + text = re.sub(r'¦([FB][0-9a-fA-F]{3})', r'`\1', text) + text = re.sub(r'¦([FB]T[0-9a-fA-F]{6})', r'`\1', text) + text = re.sub(r'¦([!*_=])', r'`\1', text) + text = re.sub(r'¦(f`b)', r'`\1', text) + text = re.sub(r'¦(f)', r'`\1', text) + text = re.sub(r'¦(b)', r'`\1', text) + text = re.sub(r'¦(<)', r'`\1', text) + text = re.sub(r'¦(>)', r'`\1', text) + text = re.sub(r'¦({)', r'`\1', text) + return text + +def strip_non_formatting_tags(text): + text = re.sub(r'`<', '', text) + text = re.sub(r'`>', '', text) + text = re.sub(r'`{', '', text) + return text + +mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) def _message_widget(app, hub, m, link_delegate=None): t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light g = app.ui.glyphs @@ -960,16 +1005,34 @@ def _message_widget(app, hub, m, link_delegate=None): label = mb.split("`")[0] url = f"{kind}://{mb}" link_mu = f"`_`F{t['link']}`[{label}`{url}]`f`_" + link_md = f"[{label}]({url})" message_body += link_mu - else: message_body += mb + else: + if app.rrc_ui_render_micron: + mbo = mdc.format_block(mb) if app.rrc_ui_render_markdown else mb + mbo = unescape_micron(mbo) + message_body += strip_non_formatting_tags(mbo) + + else: + mbo = mdc.format_block(strip_escaped_micron(mb)) if app.rrc_ui_render_markdown else strip_escaped_micron(mb) + message_body += strip_non_formatting_tags(mbo) nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" irc_ts = f"`F{t['ts']}" - prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}`f {nick_attr}<{sender}>`f " - - rendered = _render_body(f"{prefix_micron}{message_body}", link_delegate=ld) - final_widget = urwid.Padding(urwid.Pile(rendered), left=1) + prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" + nick_micron = f"`f{nick_attr}<{sender}>`f " + if app.rrc_ui_justify_msgs: + prefix_rendered = _render_body(f"{prefix_micron}") + body_rendered = _render_body(f"{nick_micron}{message_body}") + if app.rrc_ui_space_msgs: body_rendered.append(urwid.Text("")) + columns = urwid.Columns([(urwid.PACK, urwid.Pile(prefix_rendered)), urwid.Pile(body_rendered)], dividechars=1) + final_widget = urwid.Padding(columns, left=1) + + else: + rendered = _render_body(f"{prefix_micron}{nick_micron}{message_body}", link_delegate=ld) + if app.rrc_ui_space_msgs: rendered.append(urwid.Text("")) + final_widget = urwid.Padding(urwid.Pile(rendered), left=1) final_widget.msg = m return final_widget diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 66def19..840dcc8 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -793,6 +793,25 @@ Whether to filter notices and system message when initially loading room history If enabled, notices and system messages will disappear from the history after a while. 0 disables, otherwise sets the timeout in minutes. < +>>> +`!render_markdown = yes`! +>>>> +Whether or not to render markdown formatting in messages. +< + +>>> +`!render_micron = yes`! +>>>> +Whether or not to render micron formatting in messages. When using micron in messages, use the ¦ character in place of backticks. +< + +>>> +`!justify_msgs = no`! +`!space_msgs = no`! +>>>> +Rendering layout options for messages. +< + >> Text UI Section This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows: From f640f074d85121887a02d1db4863d6ca1cdd1659 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 19 May 2026 23:20:19 +0200 Subject: [PATCH 427/592] Added markdown rendering and compose support to messages --- nomadnet/Conversation.py | 13 +++++++ nomadnet/NomadNetworkApp.py | 10 ++++- nomadnet/ui/TextUI.py | 3 +- nomadnet/ui/__init__.py | 2 + nomadnet/ui/textui/Channels.py | 59 +++++------------------------ nomadnet/ui/textui/Conversations.py | 30 +++++++++++++-- nomadnet/ui/textui/Guide.py | 6 +++ nomadnet/util.py | 42 ++++++++++++++++++++ 8 files changed, 109 insertions(+), 56 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index d7039dc..26a5089 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -417,6 +417,7 @@ class ConversationMessage: self.timestamp = self.lxm.timestamp self.sort_timestamp = os.path.getmtime(self.file_path) + if self.lxm.state > LXMF.LXMessage.GENERATING and self.lxm.state < LXMF.LXMessage.SENT: found = False @@ -597,6 +598,18 @@ class ConversationMessage: return {} + def content_renderer(self): + if not self.loaded: self.load() + RNS.log(self.lxm) + if self.lxm and hasattr(self.lxm, "get_fields"): + fields = self.lxm.get_fields() + RNS.log(f"has fields: {fields}") + if fields and isinstance(fields, dict): + if LXMF.FIELD_RENDERER in fields: + return fields[LXMF.FIELD_RENDERER] + + return None + def has_attachments(self): if self._cached_has_attachments is not None: return self._cached_has_attachments diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index f3afd73..e578bbd 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -138,6 +138,7 @@ class NomadNetworkApp: self.try_propagation_on_fail = True self.disable_propagation = True self.notify_on_new_message = True + self.compose_markdown = True self.lxmf_max_propagation_size = None self.lxmf_max_sync_size = None @@ -836,6 +837,10 @@ class NomadNetworkApp: value = self.config["client"].as_bool(option) self.notify_on_new_message = value + if option == "compose_in_markdown": + value = self.config["client"].as_bool(option) + self.compose_markdown = value + if option == "user_interface": value = value.lower() if value == "none": @@ -1146,7 +1151,6 @@ downloads_path = ~/Downloads # Where to save received attachments. If not set, # attachments will be saved to the downloads path. # attachment_save_path = ~/Downloads - notify_on_new_message = yes # By default, the peer is announced at startup @@ -1210,6 +1214,10 @@ max_accepted_size = 500 # been received, for every destination. compact_announce_stream = yes +# You can choose whether or not to compose +# messages in markdown format. +compose_in_markdown = yes + [textui] # Amount of time to show intro screen diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index f05ae81..3cf4a3c 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -5,6 +5,7 @@ import os import platform import nomadnet +from nomadnet.ui import THEME_DARK, THEME_LIGHT from nomadnet.ui.textui import * from nomadnet import NomadNetworkApp @@ -13,8 +14,6 @@ COLORMODE_16 = 16 COLORMODE_88 = 88 COLORMODE_256 = 256 COLORMODE_TRUE = 2**24 -THEME_DARK = 0x01 -THEME_LIGHT = 0x02 THEMES = { THEME_DARK: { diff --git a/nomadnet/ui/__init__.py b/nomadnet/ui/__init__.py index b11aadb..cb06cf8 100644 --- a/nomadnet/ui/__init__.py +++ b/nomadnet/ui/__init__.py @@ -8,6 +8,8 @@ pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") modules = py_modules+pyc_modules __all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) +THEME_DARK = 0x01 +THEME_LIGHT = 0x02 UI_NONE = 0x00 UI_MENU = 0x01 diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index ea119d8..be26dee 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -13,10 +13,11 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps -from nomadnet.util import strip_modifiers +from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags -theme_dark = { "ts": "888", +theme_dark = { "text": "ddd", + "ts": "888", "nick_self": "6c5", "nick_peer": "3cd", "notice": "fd3", @@ -25,7 +26,8 @@ theme_dark = { "ts": "888", "mention": "fb4", "link": "79d", } -theme_light = { "ts": "888", +theme_light = { "text": "111", + "ts": "888", "nick_self": "3a0", "nick_peer": "077", "notice": "a70", @@ -899,47 +901,6 @@ class _ChatLinkDelegate: except Exception as e: RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) -def strip_micron(text): - text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text) - text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text) - text = re.sub(r'`[!*_=]', '', text) - text = re.sub(r'`f`b', '', text) - text = re.sub(r'`f', '', text) - text = re.sub(r'`b', '', text) - text = re.sub(r'`<', '', text) - text = re.sub(r'`>', '', text) - text = re.sub(r'`{', '', text) - return text - -def strip_escaped_micron(text): - text = re.sub(r'¦[FB][0-9a-fA-F]{3}', '', text) - text = re.sub(r'¦[FB]T[0-9a-fA-F]{6}', '', text) - text = re.sub(r'¦[!*_=]', '', text) - text = re.sub(r'¦f`b', '', text) - text = re.sub(r'¦f', '', text) - text = re.sub(r'¦b', '', text) - text = re.sub(r'¦<', '', text) - text = re.sub(r'¦>', '', text) - text = re.sub(r'¦{', '', text) - return text - -def unescape_micron(text): - text = re.sub(r'¦([FB][0-9a-fA-F]{3})', r'`\1', text) - text = re.sub(r'¦([FB]T[0-9a-fA-F]{6})', r'`\1', text) - text = re.sub(r'¦([!*_=])', r'`\1', text) - text = re.sub(r'¦(f`b)', r'`\1', text) - text = re.sub(r'¦(f)', r'`\1', text) - text = re.sub(r'¦(b)', r'`\1', text) - text = re.sub(r'¦(<)', r'`\1', text) - text = re.sub(r'¦(>)', r'`\1', text) - text = re.sub(r'¦({)', r'`\1', text) - return text - -def strip_non_formatting_tags(text): - text = re.sub(r'`<', '', text) - text = re.sub(r'`>', '', text) - text = re.sub(r'`{', '', text) - return text mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) def _message_widget(app, hub, m, link_delegate=None): @@ -1023,22 +984,22 @@ def _message_widget(app, hub, m, link_delegate=None): prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" nick_micron = f"`f{nick_attr}<{sender}>`f " if app.rrc_ui_justify_msgs: - prefix_rendered = _render_body(f"{prefix_micron}") - body_rendered = _render_body(f"{nick_micron}{message_body}") + prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"]) + body_rendered = _render_body(f"{nick_micron}{message_body}", fg=t["text"]) if app.rrc_ui_space_msgs: body_rendered.append(urwid.Text("")) columns = urwid.Columns([(urwid.PACK, urwid.Pile(prefix_rendered)), urwid.Pile(body_rendered)], dividechars=1) final_widget = urwid.Padding(columns, left=1) else: - rendered = _render_body(f"{prefix_micron}{nick_micron}{message_body}", link_delegate=ld) + rendered = _render_body(f"{prefix_micron}{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) if app.rrc_ui_space_msgs: rendered.append(urwid.Text("")) final_widget = urwid.Padding(urwid.Pile(rendered), left=1) final_widget.msg = m return final_widget -def _render_body(markup, link_delegate=None): - try: return markup_to_attrmaps(strip_modifiers(markup), url_delegate=link_delegate, link_class=_ChatLinkableText) +def _render_body(markup, link_delegate=None, fg="bbb"): + try: return markup_to_attrmaps(strip_modifiers(markup), url_delegate=link_delegate, link_class=_ChatLinkableText, fg_color=fg) except Exception as e: RNS.trace_exception(e) return [] diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 57521e3..872b49b 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -15,6 +15,11 @@ from nomadnet.Conversation import ConversationMessage from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name +from RNS.Utilities.rngit.util import MarkdownToMicron +from RNS.Utilities.rngit.highlight import SyntaxHighlighter +from .MicronParser import markup_to_attrmaps +from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags +from nomadnet.ui import THEME_DARK, THEME_LIGHT def relative_time(timestamp): now = time.time() @@ -1302,7 +1307,7 @@ class ConversationWidget(urwid.WidgetWrap): if not message_hash in added_hashes: added_hashes.append(message_hash) was_loaded = message.loaded - message_widget = LXMessageWidget(message) + message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"]) self.message_widgets.append(message_widget) if not was_loaded and message.loaded: needs_index.append(message) @@ -1467,6 +1472,10 @@ class ConversationWidget(urwid.WidgetWrap): if file_attachments: fields = {LXMF.FIELD_FILE_ATTACHMENTS: file_attachments} + if self.app.compose_markdown: + if not fields: fields = {} + fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN + if self.conversation.send(content, title, fields=fields): self.clear_editor() @@ -1609,7 +1618,9 @@ class ConversationWidget(urwid.WidgetWrap): class LXMessageWidget(urwid.WidgetWrap): - def __init__(self, message): + mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) + + def __init__(self, message, theme=THEME_DARK): app = nomadnet.NomadNetworkApp.get_shared_instance() g = app.ui.glyphs self.timestamp = message.get_timestamp() @@ -1623,6 +1634,7 @@ class LXMessageWidget(urwid.WidgetWrap): msg_method = message._cached_method time_format = app.time_format message_time = datetime.fromtimestamp(self.timestamp) + renderer = message.content_renderer() encryption_string = "" if message.get_transport_encrypted(): encryption_string = " "+g["encrypted"] @@ -1685,7 +1697,16 @@ class LXMessageWidget(urwid.WidgetWrap): content_text = message.get_content() content_lines = content_text.split("\n") - indented = "\n".join(" "+line for line in content_lines) + markdown = renderer == LXMF.RENDERER_MARKDOWN + + default_fg = "bbb" if theme == THEME_DARK else "444" + if markdown: + formatted = self.mdc.format_block(content_text) + message_body = strip_non_formatting_tags(formatted) + rendered = markup_to_attrmaps(strip_modifiers(message_body), url_delegate=None, fg_color=default_fg, bg_color=None) + content_pile = urwid.Padding(urwid.Pile(rendered), left=2, right=2) + + else: indented = "\n".join(" "+line for line in content_lines) pile_widgets = [title] @@ -1711,7 +1732,8 @@ class LXMessageWidget(urwid.WidgetWrap): pile_widgets.append(self.progress_attr) self._start_progress_poll() - pile_widgets.append(urwid.Text(indented)) + if markdown: pile_widgets.append(content_pile) + else: pile_widgets.append(urwid.Text(indented)) if has_attachments and cached_names: att_file_idx = 0 diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 840dcc8..f80e870 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -771,6 +771,12 @@ The maximum accepted unpacked size for messages received directly from other pee With this option enabled, Nomad Network will only display one entry in the announce stream per destination. Older announces are culled when a new one arrives. < +>>> +`!compose_in_markdown = yes`! +>>>> +Configures whether sent messages are composed in markdown format. +< + >> RRC Section This section hold configuration directives related to the RRC client behaviour. It is delimited by the `![rrc]`! header in the configuration file. Available directives, along with their default values, are as follows: diff --git a/nomadnet/util.py b/nomadnet/util.py index 9f2562a..9b3427c 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -141,3 +141,45 @@ def sanitize_name(name): name = name.strip() return name + +def strip_micron(text): + text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text) + text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text) + text = re.sub(r'`[!*_=]', '', text) + text = re.sub(r'`f`b', '', text) + text = re.sub(r'`f', '', text) + text = re.sub(r'`b', '', text) + text = re.sub(r'`<', '', text) + text = re.sub(r'`>', '', text) + text = re.sub(r'`{', '', text) + return text + +def strip_escaped_micron(text): + text = re.sub(r'¦[FB][0-9a-fA-F]{3}', '', text) + text = re.sub(r'¦[FB]T[0-9a-fA-F]{6}', '', text) + text = re.sub(r'¦[!*_=]', '', text) + text = re.sub(r'¦f`b', '', text) + text = re.sub(r'¦f', '', text) + text = re.sub(r'¦b', '', text) + text = re.sub(r'¦<', '', text) + text = re.sub(r'¦>', '', text) + text = re.sub(r'¦{', '', text) + return text + +def unescape_micron(text): + text = re.sub(r'¦([FB][0-9a-fA-F]{3})', r'`\1', text) + text = re.sub(r'¦([FB]T[0-9a-fA-F]{6})', r'`\1', text) + text = re.sub(r'¦([!*_=])', r'`\1', text) + text = re.sub(r'¦(f`b)', r'`\1', text) + text = re.sub(r'¦(f)', r'`\1', text) + text = re.sub(r'¦(b)', r'`\1', text) + text = re.sub(r'¦(<)', r'`\1', text) + text = re.sub(r'¦(>)', r'`\1', text) + text = re.sub(r'¦({)', r'`\1', text) + return text + +def strip_non_formatting_tags(text): + text = re.sub(r'`<', '', text) + text = re.sub(r'`>', '', text) + text = re.sub(r'`{', '', text) + return text \ No newline at end of file From 1e8b0780e04541ac3d23fc0e6d6f5513297a54a6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 19 May 2026 23:34:41 +0200 Subject: [PATCH 428/592] Updated makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0aac3f3..0970114 100644 --- a/Makefile +++ b/Makefile @@ -22,4 +22,4 @@ release: remove_symlinks build_wheel create_symlinks upload: @echo Uploading to PyPi... - twine upload dist/* + twine upload dist/*.whl dist/*.tar.gz From 0c38f2dcdfb7bec69c14c9d1d75b2f7d9326eed7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 20 May 2026 01:22:08 +0200 Subject: [PATCH 429/592] Thanks neutral :) --- nomadnet/NomadNetworkApp.py | 6 +++--- nomadnet/ui/textui/Channels.py | 2 +- nomadnet/ui/textui/Guide.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index e578bbd..7b4483a 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -156,7 +156,7 @@ class NomadNetworkApp: self.rrc_history_per_room_cap = 500 self.rrc_filter_loaded_history = True self.rrc_ephemeral_notices = 600 - self.rrc_ui_justify_msgs = False + self.rrc_ui_justify_msgs = True self.rrc_ui_space_msgs = False self.rrc_ui_render_markdown = True self.rrc_ui_render_micron = True @@ -936,7 +936,7 @@ class NomadNetworkApp: if option == "justify_msgs": try: value = self.config["rrc"].as_bool(option) - except Exception: value = False + except Exception: value = True self.rrc_ui_justify_msgs = value if option == "space_msgs": @@ -1296,7 +1296,7 @@ ephemeral_notices = 10 # Other display and formatting options: render_markdown = yes render_micron = yes -justify_msgs = no +justify_msgs = yes space_msgs = no [node] diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index be26dee..e7e72df 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -985,7 +985,7 @@ def _message_widget(app, hub, m, link_delegate=None): nick_micron = f"`f{nick_attr}<{sender}>`f " if app.rrc_ui_justify_msgs: prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"]) - body_rendered = _render_body(f"{nick_micron}{message_body}", fg=t["text"]) + body_rendered = _render_body(f"{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) if app.rrc_ui_space_msgs: body_rendered.append(urwid.Text("")) columns = urwid.Columns([(urwid.PACK, urwid.Pile(prefix_rendered)), urwid.Pile(body_rendered)], dividechars=1) final_widget = urwid.Padding(columns, left=1) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index f80e870..7a8e05a 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -812,7 +812,7 @@ Whether or not to render micron formatting in messages. When using micron in mes < >>> -`!justify_msgs = no`! +`!justify_msgs = yes`! `!space_msgs = no`! >>>> Rendering layout options for messages. From af74a326a986a9be79d59c163849cadce4be862d Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Wed, 20 May 2026 09:23:10 -0400 Subject: [PATCH 430/592] Conversations: urwid is not thread-safe. prevent the possibility of updating the UI on a non-main thread by assigning delegate in the ConversationWidget constructor --- nomadnet/ui/textui/Conversations.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 872b49b..3c9705c 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -884,8 +884,7 @@ class ConversationsDisplay(): conversation_widget.check_editor_allowed() return conversation_widget else: - widget = ConversationWidget(source_hash) - widget.delegate = self + widget = ConversationWidget(source_hash, delegate=self) ConversationsDisplay.cached_conversation_widgets[source_hash] = widget widget.check_editor_allowed() @@ -1042,9 +1041,10 @@ class ConversationFrame(urwid.Frame): return super(ConversationFrame, self).keypress(size, key) class ConversationWidget(urwid.WidgetWrap): - def __init__(self, source_hash): + def __init__(self, source_hash, delegate): self.app = nomadnet.NomadNetworkApp.get_shared_instance() g = self.app.ui.glyphs + self.delegate = delegate if source_hash == None: self.frame = None display_widget = urwid.LineBox(urwid.Filler(urwid.Text("\n No conversation selected"), "top")) @@ -1057,7 +1057,6 @@ class ConversationWidget(urwid.WidgetWrap): self.conversation = nomadnet.Conversation(source_hash, nomadnet.NomadNetworkApp.get_shared_instance()) self.message_widgets = [] self.sort_by_timestamp = False - self.updating_message_widgets = False self.pending_attachments = [] self.dialog_active = False @@ -1283,11 +1282,7 @@ class ConversationWidget(urwid.WidgetWrap): return super(ConversationWidget, self).keypress(size, key) def _on_conversation_changed_from_callback(self, conversation): - delegate = getattr(self, "delegate", None) - if delegate is not None and hasattr(delegate, "_wake"): - delegate._wake(lambda: self.conversation_changed(conversation)) - else: - self.conversation_changed(conversation) + self.delegate._wake(lambda: self.conversation_changed(conversation)) def conversation_changed(self, conversation): if hasattr(self, "peer_info_widget"): @@ -1295,10 +1290,6 @@ class ConversationWidget(urwid.WidgetWrap): self.update_message_widgets(replace = True) def update_message_widgets(self, replace = False): - while self.updating_message_widgets: - time.sleep(0.5) - - self.updating_message_widgets = True self.message_widgets = [] added_hashes = [] needs_index = [] @@ -1307,7 +1298,12 @@ class ConversationWidget(urwid.WidgetWrap): if not message_hash in added_hashes: added_hashes.append(message_hash) was_loaded = message.loaded - message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"]) + try: + message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"]) + except Exception as e: + RNS.log("Skipping message widget for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_DEBUG) + message.unload() + continue self.message_widgets.append(message_widget) if not was_loaded and message.loaded: needs_index.append(message) @@ -1332,8 +1328,6 @@ class ConversationWidget(urwid.WidgetWrap): self.frame.contents["body"] = (self.messagelist, None) nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() - self.updating_message_widgets = False - def clear_editor(self): self.content_editor.set_edit_text("") From 84eabeabab94e76b317906292ec9bfcfadca7ecc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 20 May 2026 18:27:22 +0200 Subject: [PATCH 431/592] Removed debug logging. Sorry for the logspam. --- nomadnet/Conversation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 26a5089..63b99c8 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -600,10 +600,8 @@ class ConversationMessage: def content_renderer(self): if not self.loaded: self.load() - RNS.log(self.lxm) if self.lxm and hasattr(self.lxm, "get_fields"): fields = self.lxm.get_fields() - RNS.log(f"has fields: {fields}") if fields and isinstance(fields, dict): if LXMF.FIELD_RENDERER in fields: return fields[LXMF.FIELD_RENDERER] From 9a764a187a5bd5c50272c45af160b391d38bc8f3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 20 May 2026 19:45:15 +0200 Subject: [PATCH 432/592] Adjusted logging --- nomadnet/Conversation.py | 2 -- nomadnet/ui/textui/Conversations.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 26a5089..63b99c8 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -600,10 +600,8 @@ class ConversationMessage: def content_renderer(self): if not self.loaded: self.load() - RNS.log(self.lxm) if self.lxm and hasattr(self.lxm, "get_fields"): fields = self.lxm.get_fields() - RNS.log(f"has fields: {fields}") if fields and isinstance(fields, dict): if LXMF.FIELD_RENDERER in fields: return fields[LXMF.FIELD_RENDERER] diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 3c9705c..49bb6e7 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1301,7 +1301,7 @@ class ConversationWidget(urwid.WidgetWrap): try: message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"]) except Exception as e: - RNS.log("Skipping message widget for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_DEBUG) + RNS.log("Skipping message loading for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_WARNING) message.unload() continue self.message_widgets.append(message_widget) From 6484037c95a2417109b5dc069dd5b7340836ab38 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 20 May 2026 19:57:01 +0200 Subject: [PATCH 433/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index a82b376..72f26f5 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.1.1" +__version__ = "1.1.2" diff --git a/setup.py b/setup.py index 181cab0..01cdc05 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.9", "lxmf>=0.9.8", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.9", "lxmf>=0.9.9", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From 3ce348dbb224cd88e1a8088c6627465ef871ad79 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Wed, 20 May 2026 16:16:24 -0400 Subject: [PATCH 434/592] Add trusted/untrusted tabs with trust banner, peer-info ping/block/note/pi propagation-node picker and last-sync indicator, warning badge for failed outgoing messages, ASCII qrcodes QR, for own own and peer LXMF addresses, RRC MOTD pane, collapsible/clickable channel and users panes with F8 join/part collapse, Guide info update, anchor microntags # --- nomadnet/Conversation.py | 56 +- nomadnet/Directory.py | 12 +- nomadnet/NomadNetworkApp.py | 64 +- nomadnet/RRC.py | 13 +- nomadnet/ui/TextUI.py | 1 + nomadnet/ui/textui/Browser.py | 55 ++ nomadnet/ui/textui/Channels.py | 418 ++++++++++- nomadnet/ui/textui/Conversations.py | 1017 +++++++++++++++++++++++++-- nomadnet/ui/textui/Guide.py | 257 ++++++- nomadnet/ui/textui/MicronParser.py | 72 +- 10 files changed, 1826 insertions(+), 139 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index d7039dc..2adb9c3 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -11,6 +11,7 @@ from LXMF import display_name_from_app_data class Conversation: cached_conversations = {} unread_conversations = {} + failed_conversations = {} created_callback = None aspect_filter = "lxmf.delivery" @@ -59,7 +60,7 @@ class Conversation: source_hash = lxmessage.destination_hash else: source_hash = lxmessage.source_hash - + source_hash_path = RNS.hexrep(source_hash, delimit=False) conversation_path = app.conversationpath + "/" + source_hash_path @@ -80,17 +81,34 @@ class Conversation: conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)] conversation.scan_storage() - if source_hash in Conversation.unread_conversations: - Conversation.unread_conversations[source_hash] += 1 - else: - Conversation.unread_conversations[source_hash] = 1 + lxm_state = getattr(lxmessage, "state", None) + is_failed = lxm_state in (LXMF.LXMessage.FAILED, LXMF.LXMessage.REJECTED) - try: - dirname = RNS.hexrep(source_hash, delimit=False) - with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf: - uf.write(str(Conversation.unread_conversations[source_hash])) - except Exception as e: - pass + if not originator: + if source_hash in Conversation.unread_conversations: + Conversation.unread_conversations[source_hash] += 1 + else: + Conversation.unread_conversations[source_hash] = 1 + + try: + dirname = RNS.hexrep(source_hash, delimit=False) + with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf: + uf.write(str(Conversation.unread_conversations[source_hash])) + except Exception: + pass + + elif is_failed: + if source_hash in Conversation.failed_conversations: + Conversation.failed_conversations[source_hash] += 1 + else: + Conversation.failed_conversations[source_hash] = 1 + + try: + dirname = RNS.hexrep(source_hash, delimit=False) + with open(app.conversationpath + "/" + dirname + "/failed", "w") as ff: + ff.write(str(Conversation.failed_conversations[source_hash])) + except Exception: + pass if Conversation.created_callback != None: Conversation.created_callback() @@ -120,6 +138,18 @@ class Conversation: unread = 1 Conversation.unread_conversations[source_hash] = unread + failed = 0 + if source_hash in Conversation.failed_conversations: + failed = Conversation.failed_conversations[source_hash] + elif os.path.isfile(app.conversationpath + "/" + dirname + "/failed"): + try: + with open(app.conversationpath + "/" + dirname + "/failed", "r") as ff: + content = ff.read().strip() + failed = int(content) if content else 1 + except Exception: + failed = 1 + Conversation.failed_conversations[source_hash] = failed + if display_name == None and app_data: display_name = LXMF.display_name_from_app_data(app_data) @@ -127,7 +157,7 @@ class Conversation: sort_name = "" else: sort_name = display_name - + trust_level = app.directory.trust_level(source_hash, display_name) conversation_dir = app.conversationpath + "/" + dirname @@ -136,7 +166,7 @@ class Conversation: except Exception: last_activity = 0 - entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity) + entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity, failed) conversations.append(entry) except Exception as e: diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index a3f86d5..dae86ec 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -90,7 +90,7 @@ class Directory: packed_list = [] for source_hash in self.directory_entries: e = self.directory_entries[source_hash] - packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank)) + packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank, getattr(e, "notes", ""))) directory = { "entry_list": packed_list, @@ -138,7 +138,12 @@ class Directory: else: sort_rank = None - entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank) + if len(e) > 7: + notes = e[7] + else: + notes = None + + entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank, notes=notes) self.directory_entries = entries @@ -412,7 +417,7 @@ class DirectoryEntry: DIRECT = 0x01 PROPAGATED = 0x02 - def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None): + def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None, notes=None): if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: self.source_hash = source_hash self.display_name = display_name @@ -426,5 +431,6 @@ class DirectoryEntry: self.trust_level = trust_level self.hosts_node = hosts_node self.identify = identify_on_connect + self.notes = notes or "" else: raise TypeError("Attempt to add invalid source hash to directory") diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index f3afd73..a9316f9 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -543,6 +543,52 @@ class NomadNetworkApp: if self.message_router.propagation_transfer_state != LXMF.LXMRouter.PR_IDLE: self.message_router.cancel_propagation_node_requests() + def _persist_ignored_list(self): + try: + with open(self.ignoredpath, "wb") as fh: + for h in self.ignored_list: + fh.write((RNS.hexrep(h, delimit=False)+"\n").encode("utf-8")) + except Exception as e: + RNS.log("Could not persist ignored list: "+str(e), RNS.LOG_ERROR) + + def block_destination(self, dest_hash, reason=None): + if not isinstance(dest_hash, (bytes, bytearray)): + return False + dest_hash = bytes(dest_hash) + try: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + RNS.Transport.blackhole_identity(identity.hash, reason=reason) + except Exception as e: + RNS.log("Could not blackhole identity: "+str(e), RNS.LOG_ERROR) + if dest_hash not in self.ignored_list: + self.ignored_list.append(dest_hash) + self._persist_ignored_list() + try: + self.message_router.ignore_destination(dest_hash) + except Exception: + pass + return True + + def unblock_destination(self, dest_hash): + if not isinstance(dest_hash, (bytes, bytearray)): + return False + dest_hash = bytes(dest_hash) + try: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + RNS.Transport.unblackhole_identity(identity.hash) + except Exception as e: + RNS.log("Could not lift blackhole on identity: "+str(e), RNS.LOG_ERROR) + if dest_hash in self.ignored_list: + self.ignored_list.remove(dest_hash) + self._persist_ignored_list() + try: + self.message_router.unignore_destination(dest_hash) + except Exception: + pass + return True + def announce_now(self): RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE) self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost) @@ -704,16 +750,24 @@ class NomadNetworkApp: return False def conversation_is_unread(self, source_hash): - if bytes.fromhex(source_hash) in nomadnet.Conversation.unread_conversations: + src_bytes = bytes.fromhex(source_hash) + if src_bytes in nomadnet.Conversation.unread_conversations: return True - else: - return False + if src_bytes in nomadnet.Conversation.failed_conversations: + return True + return False def mark_conversation_read(self, source_hash): - if bytes.fromhex(source_hash) in nomadnet.Conversation.unread_conversations: - nomadnet.Conversation.unread_conversations.pop(bytes.fromhex(source_hash)) + src_bytes = bytes.fromhex(source_hash) + if src_bytes in nomadnet.Conversation.unread_conversations: + nomadnet.Conversation.unread_conversations.pop(src_bytes) if os.path.isfile(self.conversationpath + "/" + source_hash + "/unread"): os.unlink(self.conversationpath + "/" + source_hash + "/unread") + if src_bytes in nomadnet.Conversation.failed_conversations: + nomadnet.Conversation.failed_conversations.pop(src_bytes) + if os.path.isfile(self.conversationpath + "/" + source_hash + "/failed"): + try: os.unlink(self.conversationpath + "/" + source_hash + "/failed") + except Exception: pass def notify_message_recieved(self): if self.uimode == nomadnet.ui.UI_TEXT: diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 1335fe1..4aafc85 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -216,6 +216,7 @@ class RRCHub: self.welcomed = False self.hub_name = None self.hub_version = None + self.motd = None self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES @@ -453,6 +454,7 @@ class RRCHub: with self._lock: self.link = None self.welcomed = False + self.motd = None self.members.clear() self._resource_expectations.clear() self._pending_joins.clear() @@ -1043,9 +1045,14 @@ class RRCHub: self.manager._notify_change(self) if silent_who: return + room_n = room.strip().lower() if isinstance(room, str) else None + if room_n is None and isinstance(body, str) and body.strip(): + with self._lock: + self.motd = body + self.manager._notify_change(self) msg = RRCMessage( "notice", - room.strip().lower() if isinstance(room, str) else None, + room_n, bytes(src) if isinstance(src, (bytes, bytearray)) else None, None, body, @@ -1172,6 +1179,10 @@ class RRCHub: text = data.decode(encoding, errors="replace") except Exception: return + if kind == RES_KIND_MOTD: + with self._lock: + self.motd = text + self.manager._notify_change(self) msg = RRCMessage("notice", room, None, None, text, _now_ms()) self._record_notice(msg) except Exception as e: diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index f05ae81..89d34c9 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -164,6 +164,7 @@ GLYPHS = { ("file", "[F]", "\u25a4", "\uf15b"), ("image", "[I]", "\u25a3", "\uf1c5"), ("audio", "[~]", "\u266b", "\uf1c7"), + ("pin", "*", "\u2605", "\uf08d"), } class TextUI: diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 1913fb9..20c6e20 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -253,6 +253,11 @@ class Browser: recurse_down(self.attr_maps) RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + # In-document anchor link (#name or empty #) + if link_target.startswith("#"): + self._jump_to_anchor(link_target[1:]) + return + # rrc://[:]/ URL form if link_target.startswith("rrc://"): self.handle_rrc_link(link_target[6:]) @@ -300,6 +305,56 @@ class Browser: self.browser_footer = urwid.Text("Could not open link: "+"No known handler for destination type "+str(destination_type)) self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + def _jump_to_anchor(self, name): + anchors = getattr(self.attr_maps, "anchors", None) or {} + header_rows = getattr(self.attr_maps, "header_rows", None) or [] + + cols = self._content_cols() + + target_idx = None + if name: + target_idx = anchors.get(name) + if target_idx is None: + self.browser_footer = urwid.Text("Unknown anchor: #"+name) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + return + else: + current = 0 + try: + current = self.browser_body.original_widget.original_widget.get_scrollpos() + except Exception: + current = 0 + for hr in header_rows: + if self._rows_above(hr, cols) > current: + target_idx = hr + break + if target_idx is None: + return + + try: + scrollable = self.browser_body.original_widget.original_widget + scrollable.set_scrollpos(self._rows_above(int(target_idx), cols)) + except Exception as e: + RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) + + def _content_cols(self): + try: + cols = self.app.ui.loop.screen.get_cols_rows()[0] + except Exception: + cols = 100 + return max(40, cols - 3) + + def _rows_above(self, index, cols): + if index <= 0 or not self.attr_maps: + return 0 + total = 0 + for i in range(min(index, len(self.attr_maps))): + try: + total += self.attr_maps[i].rows((cols,)) + except Exception: + total += 1 + return total + def handle_lxmf_link(self, link_target): try: def san(name): diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index ea119d8..afa568b 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -13,7 +13,8 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps -from nomadnet.util import strip_modifiers +from nomadnet.util import strip_modifiers, sanitize_name +from nomadnet.vendor.Scrollable import Scrollable, ScrollBar theme_dark = { "ts": "888", @@ -171,13 +172,13 @@ def _format_ts(ts_ms): class ChannelsListShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove [C-y] Toggle Channels"), "shortcutbar") class ChannelsRoomShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave Room [C-k] Clear Editor [C-u] Toggle Users [Tab] Switch Focus"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave [C-k] Clear [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] Focus"), "shortcutbar") class ChannelsDialogLineBox(urwid.LineBox): @@ -205,7 +206,101 @@ class ChannelListEntry(urwid.Text): return True +class ChannelsExpandGutter(urwid.WidgetWrap): + def __init__(self, app, delegate): + self.app = app + self.delegate = delegate + glyph = app.ui.glyphs.get("arrow_r", ">") + if len(glyph) > 1: + glyph = ">" + self._inner = urwid.SolidFill(glyph) + super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus")) + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event): + try: + self.delegate.toggle_channel_list() + return True + except Exception: + pass + return False + + def selectable(self): + return False + + +class UsersExpandGutter(urwid.WidgetWrap): + def __init__(self, app, delegate): + self.app = app + self.delegate = delegate + glyph = app.ui.glyphs.get("arrow_l", "<") + if len(glyph) > 1: + glyph = "<" + self._inner = urwid.SolidFill(glyph) + super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus")) + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event): + try: + self.delegate.toggle_users() + return True + except Exception: + pass + return False + + def selectable(self): + return False + + +class UsersBox(urwid.LineBox): + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event) and row == 0: + try: + self.delegate.toggle_users() + return True + except Exception: + pass + return super().mouse_event(size, event, button, col, row, focus) + + def keypress(self, size, key): + if key == "tab": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.columns.focus_position = 0 + rw.frame.focus_position = "footer" + return None + except Exception: + pass + if key == "ctrl u": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.toggle_users() + return None + except Exception: + pass + if key == "ctrl y": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.display.toggle_channel_list() + return None + except Exception: + pass + return super().keypress(size, key) + + class ChannelsListArea(urwid.LineBox): + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event) and row == 0: + try: + self.delegate.toggle_channel_list() + return True + except Exception: + pass + return super().mouse_event(size, event, button, col, row, focus) + def keypress(self, size, key): if key == "ctrl n": self.delegate.new_hub_dialog() @@ -221,6 +316,12 @@ class ChannelsListArea(urwid.LineBox): self.delegate.edit_hub_dialog() elif key == "ctrl x": self.delegate.remove_selected_dialog() + elif key == "ctrl y": + self.delegate.toggle_channel_list() + return None + elif key == "f8": + self.delegate.toggle_join_part_collapse() + return None elif key == "tab": self.delegate.app.ui.main_display.frame.focus_position = "header" elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): @@ -252,6 +353,12 @@ class HubInfoArea(urwid.LineBox): if key == "ctrl x": self.delegate.remove_selected_dialog() return None + if key == "ctrl y": + self.delegate.toggle_channel_list() + return None + if key == "f8": + self.delegate.toggle_join_part_collapse() + return None return super(HubInfoArea, self).keypress(size, key) @@ -274,6 +381,10 @@ class RoomMessageEdit(urwid.Edit): self.delegate.leave_room() elif key == "ctrl u": self.delegate.toggle_users() + elif key == "ctrl y": + self.delegate.display.toggle_channel_list() + elif key == "f8": + self.delegate.display.toggle_join_part_collapse() elif key == "up": y = self.get_cursor_coords(size)[1] if y == 0: @@ -358,11 +469,31 @@ class RoomFrame(urwid.Frame): if key == "ctrl u": self.delegate.toggle_users() return None + if key == "ctrl y": + self.delegate.display.toggle_channel_list() + return None + if key == "f8": + self.delegate.display.toggle_join_part_collapse() + return None if key == "tab": + rw = self.delegate + users_focusable = ( + rw is not None + and getattr(rw, "users_visible", False) + and len(rw.columns.contents) > 1 + and rw.columns.contents[1][0] is rw.users_box + ) if self.focus_position == "body": self.focus_position = "footer" + elif users_focusable: + try: + rw.columns.focus_position = 1 + return None + except Exception: + self.focus_position = "body" else: self.focus_position = "body" + return None elif self.focus_position == "body": if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible: self.focus_position = "footer" @@ -406,8 +537,11 @@ class RoomWidget(urwid.WidgetWrap): self.frame.delegate = self self.chat_box = urwid.LineBox(self.frame) - self.users_pile = urwid.Pile([urwid.Text("")]) - self.users_box = urwid.LineBox(urwid.Filler(self.users_pile, "top"), title="Users") + self.users_walker = urwid.SimpleFocusListWalker([urwid.Text("")]) + self.users_listbox = urwid.ListBox(self.users_walker) + self.users_box = UsersBox(self.users_listbox, title="Users") + self.users_box.delegate = self + self.users_gutter = UsersExpandGutter(self.app, self) self._refresh_users_pane() self.users_visible = self.display.users_visible @@ -428,34 +562,73 @@ class RoomWidget(urwid.WidgetWrap): ] else: self.columns.contents = [ - (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_gutter, self.columns.options(urwid.GIVEN, 1)), ] self.columns.focus_position = 0 def _refresh_users_pane(self): g = self.app.ui.glyphs + walker = self.users_walker if self.hub is None or self.room is None: - self.users_pile.contents = [(urwid.Text(""), self.users_pile.options())] + walker[:] = [urwid.Text("")] return members = self.hub.get_members(self.room) own_hash = self.app.identity.hash if self.app.identity is not None else None - names = [] - for m in members: - if own_hash is not None and m == own_hash: - names.append((self.hub.display_name_for(m), True)) - else: - names.append((self.hub.display_name_for(m), False)) - names.sort(key=lambda x: x[0].lower()) + def _safe_name(raw): + if not raw: return "" + try: + if self.app.config["textui"]["sanitize_names"]: + return sanitize_name(str(raw)) or "" + return strip_modifiers(str(raw)) or "" + except Exception: + return str(raw or "") - rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))] - for name, is_self in names: + entries = [] + for m in members: + entries.append((_safe_name(self.hub.display_name_for(m)), m, own_hash is not None and m == own_hash)) + entries.sort(key=lambda x: x[0].lower()) + + prev_focus_key = None + try: + prev_idx = walker.focus + if prev_idx is not None and 0 <= prev_idx < len(walker): + prev_focus_key = getattr(walker[prev_idx], "user_hash", None) + except Exception: + prev_focus_key = None + + rows = [urwid.Text(" "+str(len(entries))+" user"+("s" if len(entries) != 1 else ""))] + for name, peer_hash, is_self in entries: if is_self: - rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted")) + label = " "+g["arrow_r"]+" "+name + style = "list_trusted" else: - rows.append(urwid.AttrMap(urwid.Text(" "+g["peer"]+" "+name), "connected_status")) - if not names: + label = " "+g["peer"]+" "+name + style = "connected_status" + entry = ChannelListEntry(label) + urwid.connect_signal(entry, "click", self.display.show_user_info, (self.hub, peer_hash, name)) + row = urwid.AttrMap(entry, style, "list_focus") + row.user_hash = peer_hash + rows.append(row) + if not entries: rows.append(urwid.Text(" (no members)")) - self.users_pile.contents = [(w, self.users_pile.options()) for w in rows] + + walker[:] = rows + + new_focus = None + if prev_focus_key is not None: + for idx, w in enumerate(walker): + if getattr(w, "user_hash", None) == prev_focus_key: + new_focus = idx + break + if new_focus is None: + for idx, w in enumerate(walker): + if hasattr(w, "user_hash"): + new_focus = idx + break + if new_focus is not None: + try: walker.set_focus(new_focus) + except Exception: pass def _update_peer_info(self): if self.hub is None or self.room is None: @@ -481,8 +654,22 @@ class RoomWidget(urwid.WidgetWrap): def update_messages(self, replace=False): msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] widgets = [] + collapse = getattr(self.display, "collapse_join_part", False) + run = [] + + def flush_run(): + if not run: + return + widgets.append(_collapsed_joinpart_widget(self.app, len(run))) + run.clear() + for m in msgs: + if collapse and _is_joinpart_system(m): + run.append(m) + continue + flush_run() widgets.append(_message_widget(self.app, self.hub, m, link_delegate=self.link_delegate)) + flush_run() if not widgets: widgets = [urwid.Text([("irc_system", " "+self.app.ui.glyphs["info"]+" No messages yet")])] @@ -498,7 +685,7 @@ class RoomWidget(urwid.WidgetWrap): pass if replace and hasattr(self, "frame"): self.frame.contents["body"] = (self.messagelist, None) - if hasattr(self, "users_pile"): + if hasattr(self, "users_walker"): self._refresh_users_pane() def append_message(self, msg): @@ -549,7 +736,7 @@ class RoomWidget(urwid.WidgetWrap): except Exception as e: RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG) self.update_messages(replace=True) - if hasattr(self, "users_pile"): + if hasattr(self, "users_walker"): self._refresh_users_pane() def _on_editor_change(self, editor, old_text): @@ -942,6 +1129,32 @@ def strip_non_formatting_tags(text): return text mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) + +_MOTD_ROOM_RE = re.compile(r"(?" + return "(select propagation node)" + + def _show_collapsed(self): + btn = urwid.Button("▾ "+self._label_for(self._current)) + urwid.connect_signal(btn, "click", self._on_expand_click) + self._pile.contents = [ + (urwid.AttrMap(btn, None, focus_map="list_focus"), self._pile.options()), + ] + + def _on_expand_click(self, _btn): + self._show_expanded() + + def _on_back_click(self, _btn): + self._show_collapsed() + + def _make_row_click(self, picked_hash): + def _click(_btn): + try: + self._current = picked_hash + self._on_change(picked_hash) + except Exception as e: + RNS.log("Propagation node change handler failed: "+str(e), RNS.LOG_ERROR) + self._show_collapsed() + return _click + + def _show_expanded(self): + rows = [] + for ph, label in self._options: + marker = "● " if ph == self._current else "○ " + row = urwid.Button(marker+label) + urwid.connect_signal(row, "click", self._make_row_click(ph)) + rows.append(urwid.AttrMap(row, None, focus_map="list_focus")) + + if not rows: + rows.append(urwid.Text(" (no propagation nodes seen yet)")) + + list_height = min(10, max(3, len(rows))) + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(rows)) + boxed = urwid.BoxAdapter(listbox, list_height) + + back = urwid.Button("◀ Back") + urwid.connect_signal(back, "click", self._on_back_click) + + self._pile.contents = [ + (urwid.AttrMap(back, None, focus_map="list_focus"), self._pile.options()), + (boxed, self._pile.options()), + ] + + class DialogLineBox(urwid.LineBox): def keypress(self, size, key): if key == "esc": @@ -106,17 +186,23 @@ class ConversationsDisplay(): SORT_RECENT = 0 SORT_NAME = 1 + LIST_FILTER_TRUSTED = "trusted" + LIST_FILTER_UNTRUSTED = "untrusted" + def __init__(self, app): self.app = app self.dialog_open = False self.sync_dialog = None self.currently_displayed_conversation = None self.list_sort_mode = ConversationsDisplay.SORT_RECENT + self.list_filter = ConversationsDisplay.LIST_FILTER_TRUSTED + self.show_blocked = False def disp_list_shortcuts(sender, arg1, arg2): self.shortcuts_display = self.list_shortcuts self.app.ui.main_display.update_active_shortcuts() + self._build_persistent_listbox() self.update_listbox() self.columns_widget = urwid.Columns( @@ -133,7 +219,7 @@ class ConversationsDisplay(): self.editor_shortcuts = ConversationDisplayShortcuts(self.app) self.shortcuts_display = self.list_shortcuts - self.widget = self.columns_widget + self.widget = urwid.WidgetPlaceholder(self.columns_widget) self._pending_actions = collections.deque() self._wake_fd = None @@ -144,6 +230,11 @@ class ConversationsDisplay(): nomadnet.Conversation.created_callback = lambda: self._wake(self.update_conversation_list) + try: + self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) + except Exception: + pass + def _process_pending(self, data): while True: try: @@ -180,26 +271,279 @@ class ConversationsDisplay(): self.list_sort_mode = ConversationsDisplay.SORT_RECENT self.update_conversation_list() - def update_listbox(self): - conversations = self.app.conversations() - if self.list_sort_mode == ConversationsDisplay.SORT_NAME: - conversations.sort(key=lambda e: (e[3].lower(), e[0])) + def _conversation_filter_predicate(self, conversation): + try: + trust_level = conversation[2] + except Exception: + return False + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED: + return trust_level in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) + return trust_level == DirectoryEntry.TRUSTED - conversation_list_widgets = [] - for conversation in conversations: - conversation_list_widgets.append(self.conversation_list_widget(conversation)) + def _set_filter(self, key): + if self.list_filter == key: + return + self.list_filter = key + try: + self.update_conversation_list() + except Exception as e: + RNS.log("Failed to apply conversation filter: "+str(e), RNS.LOG_ERROR) + + def _on_show_blocked_change(self, _cb, new_state): + self.show_blocked = new_state + try: + self.update_conversation_list() + except Exception as e: + RNS.log("Failed to toggle show-blocked: "+str(e), RNS.LOG_ERROR) + + def _apply_pile_layout(self): + pack_opts = self.pile.options('pack') + weight_opts = self.pile.options('weight', 1) + items = [(self.tab_bar, pack_opts)] + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED: + items.append((self.show_blocked_checkbox, pack_opts)) + items.append((self.ilb, weight_opts)) + items.append((self.sync_status_text, pack_opts)) + try: + prev_focus = self.pile.focus_position + except Exception: + prev_focus = None + self.pile.contents = items + try: + if prev_focus is not None and prev_focus < len(items): + self.pile.focus_position = prev_focus + except Exception: + pass + + def _blocked_row_widget(self, dest_hash): + g = self.app.ui.glyphs + entry = self.app.directory.find(dest_hash) + display_name = None + if entry is not None and getattr(entry, "display_name", None): + display_name = strip_modifiers(entry.display_name) + if not display_name: + display_name = RNS.prettyhexrep(dest_hash) + label = " "+g["cross"]+" [blocked] "+display_name+" <"+RNS.hexrep(dest_hash, delimit=False)+">" + widget = ListEntry(label) + urwid.connect_signal(widget, "click", self._unblock_dialog, dest_hash) + attr = urwid.AttrMap(widget, "list_untrusted", "list_focus_untrusted") + attr.blocked_dest_hash = dest_hash + return attr + + def _unblock_dialog(self, _sender, dest_hash): + self.dialog_open = True + + def dismiss(_b): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(_b): + self.dialog_open = False + try: + self.app.unblock_destination(dest_hash) + except Exception as e: + RNS.log("Unblock failed: "+str(e), RNS.LOG_ERROR) + self.update_conversation_list() + + try: + who = self.app.directory.simplest_display_str(dest_hash) + except Exception: + who = RNS.hexrep(dest_hash, delimit=False) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Unblock "+str(who)+"?\n\nThis lifts the RNS blackhole on the peer's identity\nand removes them from your ignored list.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, unblock", on_press=confirmed)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]), + ]), title="Confirm unblock" + ) + dialog.delegate = self + + bottom = self.listbox + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.columns_widget.contents[0] = ( + overlay, + self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.columns_widget.focus_position = 0 + except Exception: + pass + + def _build_persistent_listbox(self): + self.tab_trusted = TabButton("Trusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED)) + self.tab_untrusted = TabButton("Untrusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED)) + + self.tab_bar = urwid.Columns([ + ('weight', 1, self.tab_trusted), + ('weight', 1, self.tab_untrusted), + ], dividechars=1) + + self.show_blocked_checkbox = urwid.CheckBox("Show blocked (0)", state=self.show_blocked) + urwid.connect_signal(self.show_blocked_checkbox, "change", self._on_show_blocked_change) - self.list_widgets = conversation_list_widgets self.ilb = IndicativeListBox( - self.list_widgets, + [urwid.Text("")], on_selection_change=self.conversation_list_selection, initialization_is_selection_change=False, - highlight_offFocus="list_off_focus" + highlight_offFocus="list_off_focus", ) - self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Conversations") + self.sync_status_text = urwid.AttrMap(urwid.Text(self._sync_status_line(), align=urwid.LEFT), "shortcutbar") + + self.pile = urwid.Pile([ + ('pack', self.tab_bar), + ('weight', 1, self.ilb), + ('pack', self.sync_status_text), + ]) + try: self.pile.focus_position = 1 + except Exception: pass + + self.listbox = ConversationsArea(self.pile, title="Conversations") self.listbox.delegate = self + def update_listbox(self): + if not hasattr(self, "pile"): + self._build_persistent_listbox() + + try: + conversations = self.app.conversations() + except Exception as e: + RNS.log("Failed to enumerate conversations: "+str(e), RNS.LOG_ERROR) + conversations = [] + + try: + if self.list_sort_mode == ConversationsDisplay.SORT_NAME: + conversations.sort(key=lambda e: (e[3].lower(), e[0])) + except Exception: + pass + + def _is_pinned(c): + try: + entry = self.app.directory.find(bytes.fromhex(c[0])) + return entry is not None and entry.sort_rank is not None + except Exception: + return False + try: + conversations = sorted(conversations, key=lambda c: 0 if _is_pinned(c) else 1) + except Exception: + pass + + glyphs = self.app.ui.glyphs + def _alerts(c): + return bool(c[4]) or (len(c) > 6 and bool(c[6])) + trusted_count = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED) + untrusted_count = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN)) + trusted_unread = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED and _alerts(c)) + untrusted_unread = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) and _alerts(c)) + + def _label(name, total, unread): + if unread: + return f"{name} ({total}) {glyphs['unread']} {unread}" + return f"{name} ({total})" + + self.tab_trusted.set_label(_label("Trusted", trusted_count, trusted_unread)) + self.tab_untrusted.set_label(_label("Untrusted", untrusted_count, untrusted_unread)) + + filtered = [c for c in conversations if self._conversation_filter_predicate(c)] + + conversation_list_widgets = [] + for conversation in filtered: + try: + conversation_list_widgets.append(self.conversation_list_widget(conversation)) + except Exception as e: + try: hh = conversation[0] + except Exception: hh = "?" + RNS.log("Skipping conversation row for "+str(hh)+": "+str(e), RNS.LOG_ERROR) + + blocked_count = len(self.app.ignored_list) if hasattr(self.app, "ignored_list") else 0 + try: + self.show_blocked_checkbox.set_label(f"Show blocked ({blocked_count})") + except Exception: + pass + + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED and self.show_blocked: + for blocked_hash in list(self.app.ignored_list): + try: + conversation_list_widgets.append(self._blocked_row_widget(blocked_hash)) + except Exception as e: + RNS.log("Skipping blocked row: "+str(e), RNS.LOG_ERROR) + + empty_placeholder = False + if not conversation_list_widgets: + empty_label = { + ConversationsDisplay.LIST_FILTER_TRUSTED: "No trusted conversations", + ConversationsDisplay.LIST_FILTER_UNTRUSTED: "No untrusted conversations", + }.get(self.list_filter, "No conversations") + conversation_list_widgets = [urwid.Text(empty_label, align='center')] + empty_placeholder = True + + self.list_widgets = conversation_list_widgets + + try: + self.ilb.set_body(conversation_list_widgets) + except Exception as e: + RNS.log("Failed to populate conversation list: "+str(e), RNS.LOG_ERROR) + + self._apply_pile_layout() + + if empty_placeholder: + try: self.pile.focus_position = 0 + except Exception: pass + + try: + self.sync_status_text.original_widget.set_text(self._sync_status_line()) + except Exception: + pass + + def _sync_status_line(self): + try: + last = self.app.peer_settings.get("last_lxmf_sync") + except Exception: + last = None + if not last: + when = "never" + else: + try: + when = relative_time(float(last)) + except Exception: + when = "unknown" + + node_label = None + try: + pn_hash = self.app.get_default_propagation_node() + if pn_hash is not None: + pn_ident = RNS.Identity.recall(pn_hash) + if pn_ident is not None: + node_dest = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) + entry = self.app.directory.find(node_dest) + if entry is not None and getattr(entry, "display_name", None): + node_label = strip_modifiers(str(entry.display_name)) or None + if node_label is None: + node_label = "<"+RNS.hexrep(pn_hash, delimit=False)[:8]+"…>" + except Exception: + node_label = None + + line = " Last sync: "+when + if node_label: + line += " ("+node_label+")" + return line + + def _refresh_sync_status(self, _loop=None, _data=None): + try: + if hasattr(self, "sync_status_text") and self.sync_status_text is not None: + self.sync_status_text.original_widget.set_text(self._sync_status_line()) + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) + except Exception: + pass + def delete_selected_conversation(self): self.dialog_open = True item = self.ilb.get_selected_item() @@ -248,6 +592,218 @@ class ConversationsDisplay(): options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) + def _refresh_open_conversation_widget(self, source_hash_text): + widget = ConversationsDisplay.cached_conversation_widgets.get(source_hash_text) + if widget is None: + return + try: + widget._trust_banner_dismissed = False + except Exception: + pass + try: + widget._refresh_trust_banner() + except Exception: + pass + try: + widget._update_peer_info() + except Exception: + pass + try: + widget.update_message_widgets(replace=True) + except Exception: + pass + + def show_my_qr(self): + try: + addr = RNS.hexrep(self.app.lxmf_destination.hash, delimit=False) + except Exception: + return + try: + display = self.app.peer_settings.get("display_name") or "My LXMF" + except Exception: + display = "My LXMF" + self.show_qr_dialog(addr, title=display) + + def show_qr_dialog(self, data, title=None): + qr_text = None + try: + import qrcode + try: + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) + qr.add_data(data) + qr.make() + import io + buf = io.StringIO() + qr.print_ascii(out=buf, invert=False) + qr_text = buf.getvalue().rstrip("\n") + except Exception as e: + RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) + qr_text = None + except Exception: + qr_text = None + + def dismiss(_b): + self._restore_listbox_pane() + + rows = [urwid.Text("")] + if qr_text is not None: + rows.append(urwid.Text(qr_text, align=urwid.CENTER)) + rows.append(urwid.Text("")) + else: + rows.append(urwid.Text("LXMF destination address:", align=urwid.CENTER)) + rows.append(urwid.Text("")) + rows += [ + urwid.Text("< "+data+" >", align=urwid.CENTER), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Text("")), + (12, urwid.Button("Close", on_press=dismiss)), + (urwid.WEIGHT, 1, urwid.Text("")), + ]), + urwid.Text(""), + ] + dialog_title = "LXMF Address" if qr_text is None else "QR Code" + dialog = DialogLineBox(urwid.Pile(rows), title=dialog_title) + dialog.delegate = self + self._overlay_dialog(dialog) + + def _overlay_dialog(self, dialog): + overlay = urwid.Overlay( + dialog, self.columns_widget, + align=urwid.CENTER, width=(urwid.RELATIVE, 70), + valign=urwid.MIDDLE, height=urwid.PACK, + min_width=44, + ) + try: + self.widget.original_widget = overlay + self.dialog_open = True + except Exception: + pass + + def _restore_listbox_pane(self): + try: + self.widget.original_widget = self.columns_widget + self.dialog_open = False + self.update_conversation_list() + except Exception: + pass + + def _ping_peer_from_dialog(self, source_hash_text, status_widget, ping_button): + try: + dest = bytes.fromhex(source_hash_text) + except Exception: + status_widget.set_text(("error_text", "Invalid address")) + return + + identity = RNS.Identity.recall(dest) + if identity is None: + status_widget.set_text(("error_text", "Identity unknown; query first")) + return + + if not RNS.Transport.has_path(dest): + status_widget.set_text("No path; requesting…") + try: RNS.Transport.request_path(dest) + except Exception: pass + + status_widget.set_text("Pinging…") + ping_button.set_label("Pinging…") + started_at = time.time() + + def schedule_ui(fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *_: fn()) + except Exception: + try: fn() + except Exception: pass + + def on_established(link): + elapsed_ms = int((time.time() - started_at) * 1000) + try: + hops = RNS.Transport.hops_to(dest) + if hops is None or hops >= RNS.Transport.PATHFINDER_M: + hops_str = "" + else: + hops_str = f" ({hops} hop{'s' if hops != 1 else ''})" + except Exception: + hops_str = "" + def update(): + status_widget.set_text(f"Pong in {elapsed_ms} ms{hops_str}") + ping_button.set_label("Ping") + schedule_ui(update) + try: link.teardown() + except Exception: pass + + def on_closed(link): + try: + if getattr(link, "status", None) == RNS.Link.ACTIVE: + return + except Exception: + pass + def update(): + if status_widget.text.strip() in ("Pinging…", ""): + status_widget.set_text(("error_text", "Ping failed (no link)")) + ping_button.set_label("Ping") + schedule_ui(update) + + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + RNS.Link(destination, established_callback=on_established, closed_callback=on_closed) + except Exception as e: + status_widget.set_text(("error_text", f"Ping init failed: {e}")) + ping_button.set_label("Ping") + + def _block_peer_from_dialog(self, source_hash_text): + try: + dest = bytes.fromhex(source_hash_text) + except Exception: + return + + def cancel_block(_b): + self.update_conversation_list() + self.dialog_open = False + + def confirm_block(_b): + try: + self.app.block_destination(dest, reason="user-blocked from peer info dialog") + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + try: + self.delete_conversation(source_hash_text) + nomadnet.Conversation.delete_conversation(source_hash_text, self.app) + except Exception: + pass + self.update_conversation_list() + self.dialog_open = False + + try: + who = self.app.directory.simplest_display_str(dest) + except Exception: + who = source_hash_text + + confirm_dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Block "+str(who)+"?\n\nThis blackholes the peer's identity in Reticulum,\nadds them to your ignored list, and deletes any\nconversation with them.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirm_block)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel_block)), + ]), + ]), title="Confirm block" + ) + confirm_dialog.delegate = self + bottom = self.listbox + overlay = urwid.Overlay(confirm_dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.columns_widget.contents[0] = ( + overlay, + self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.columns_widget.focus_position = 0 + self.dialog_open = True + except Exception: + pass + def edit_selected_in_directory(self): g = self.app.ui.glyphs self.dialog_open = True @@ -262,6 +818,7 @@ class ConversationsDisplay(): e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash_text) t_id = urwid.Text("Addr : "+source_hash_text) e_name = urwid.Edit(caption="Name : ",edit_text=display_name) + e_copy = urwid.Edit(caption="Copy : ", edit_text=source_hash_text) selected_id_widget = t_id @@ -272,8 +829,12 @@ class ConversationsDisplay(): direct_selected = True propagated_selected = False + pinned_initial = False + notes_initial = "" + try: - if self.app.directory.find(bytes.fromhex(source_hash_text)): + existing_entry = self.app.directory.find(bytes.fromhex(source_hash_text)) + if existing_entry: trust_level = self.app.directory.trust_level(bytes.fromhex(source_hash_text)) if trust_level == DirectoryEntry.UNTRUSTED: untrusted_selected = True @@ -291,10 +852,16 @@ class ConversationsDisplay(): if self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) == DirectoryEntry.PROPAGATED: direct_selected = False propagated_selected = True - + + pinned_initial = existing_entry.sort_rank is not None + notes_initial = getattr(existing_entry, "notes", "") or "" + except Exception as e: pass + e_notes = urwid.Edit(caption="Notes: ", edit_text=notes_initial, multiline=True) + cb_pin = urwid.CheckBox("Pin to top", state=pinned_initial) + trust_button_group = [] r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted", state=untrusted_selected) r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected) @@ -322,8 +889,11 @@ class ConversationsDisplay(): if r_propagated.state == True: delivery = DirectoryEntry.PROPAGATED - entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery) + sort_rank = 0 if cb_pin.state else None + notes_value = e_notes.get_edit_text() + entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery, sort_rank=sort_rank, notes=notes_value) self.app.directory.remember(entry) + self._refresh_open_conversation_widget(source_hash_text) self.update_conversation_list() self.dialog_open = False self.app.ui.main_display.sub_displays.network_display.directory_change_callback() @@ -363,9 +933,26 @@ class ConversationsDisplay(): urwid.Divider(g["divider1"]), ]) + action_status = urwid.Text("", align=urwid.CENTER) + ping_button = urwid.Button("Ping") + block_button = urwid.Button("Block") + qr_button = urwid.Button("LXMF") + urwid.connect_signal(ping_button, "click", lambda _b: self._ping_peer_from_dialog(source_hash_text, action_status, ping_button)) + urwid.connect_signal(block_button, "click", lambda _b: self._block_peer_from_dialog(source_hash_text)) + urwid.connect_signal(qr_button, "click", lambda _b: self.show_qr_dialog(source_hash_text, title=display_name or source_hash_text)) + + actions_row = urwid.Columns([ + (urwid.WEIGHT, 0.32, ping_button), + (urwid.WEIGHT, 0.02, urwid.Text("")), + (urwid.WEIGHT, 0.32, block_button), + (urwid.WEIGHT, 0.02, urwid.Text("")), + (urwid.WEIGHT, 0.32, qr_button), + ]) + dialog_pile = urwid.Pile([ selected_id_widget, e_name, + e_copy, urwid.Divider(g["divider1"]), r_untrusted, r_unknown, @@ -373,7 +960,13 @@ class ConversationsDisplay(): urwid.Divider(g["divider1"]), r_direct, r_propagated, + urwid.Divider(g["divider1"]), + cb_pin, + e_notes, known_section, + actions_row, + action_status, + urwid.Divider(g["divider1"]), urwid.Columns([ (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), (urwid.WEIGHT, 0.1, urwid.Text("")), @@ -439,6 +1032,9 @@ class ConversationsDisplay(): self.update_conversation_list() + if trust_level != DirectoryEntry.TRUSTED: + if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED: + self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED) self.display_conversation(source_hash_text) self.dialog_open = False @@ -658,10 +1254,84 @@ class ConversationsDisplay(): self.update_conversation_list() + def _decode_pn_app_data(self, app_data): + try: + if not pn_announce_data_is_valid(app_data): + return None + data = msgpack.unpackb(app_data) + enabled = bool(data[2]) + name = None + try: + meta = data[6] + if isinstance(meta, dict) and PN_META_NAME in meta: + raw = meta[PN_META_NAME] + if isinstance(raw, (bytes, bytearray)): + name = raw.decode("utf-8", errors="replace") + except Exception: + name = None + return {"enabled": enabled, "name": name} + except Exception: + return None + + def _pn_dropdown_label(self, pn_hash, meta): + name = (meta or {}).get("name") + if name: + label = strip_modifiers(name) or "" + label = " ".join(label.split()) + else: + label = "" + if not label: + label = RNS.prettyhexrep(pn_hash) + max_len = 40 + if len(label) > max_len: + label = label[:max_len-1]+"…" + tail = "<"+RNS.hexrep(pn_hash, delimit=False)+">" + if tail not in label: + label = label+" "+tail + if meta is None: + status = "[?]" + elif meta.get("enabled"): + status = "[E]" + else: + status = "[D]" + return status+" "+label + + def _build_pn_options(self): + options = [] + seen = set() + + try: + pn_announces = list(self.app.directory._pn_announces) + except Exception: + pn_announces = [] + for tup in pn_announces: + if len(tup) < 3: continue + pn_hash = tup[1] + app_data = tup[2] + if pn_hash in seen: continue + seen.add(pn_hash) + meta = self._decode_pn_app_data(app_data) + options.append((pn_hash, self._pn_dropdown_label(pn_hash, meta))) + + for extra in (self.app.get_user_selected_propagation_node(), self.app.get_default_propagation_node()): + if extra is None or extra in seen: + continue + seen.add(extra) + meta = None + try: + cached = RNS.Identity.recall_app_data(extra) + if cached is not None: + meta = self._decode_pn_app_data(cached) + except Exception: + meta = None + options.append((extra, self._pn_dropdown_label(extra, meta))) + + return options + def sync_conversations(self): g = self.app.ui.glyphs self.dialog_open = True - + def dismiss_dialog(sender): self.dialog_open = False self.sync_dialog = None @@ -704,36 +1374,58 @@ class ConversationsDisplay(): ]) real_sync_button.bc = button_columns + current_default = self.app.get_default_propagation_node() + user_selected = self.app.get_user_selected_propagation_node() + + pn_options = self._build_pn_options() + + selected_target = user_selected if user_selected is not None else current_default + + def on_pn_picked(picked_hash): + try: + self.app.set_user_selected_propagation_node(picked_hash) + except Exception as e: + RNS.log("Could not update propagation node: "+str(e), RNS.LOG_ERROR) + + if pn_options: + node_picker = PropNodePicker(pn_options, selected_target, on_pn_picked) + node_selector = urwid.Pile([ + urwid.Text("Propagation node:"), + node_picker, + ]) + else: + node_selector = None + pn_ident = None - if self.app.get_default_propagation_node() != None: - pn_hash = self.app.get_default_propagation_node() - pn_ident = RNS.Identity.recall(pn_hash) - - if pn_ident == None: + if current_default is not None: + pn_ident = RNS.Identity.recall(current_default) + if pn_ident is None: RNS.log("Propagation node identity is unknown, requesting from network...", RNS.LOG_DEBUG) - RNS.Transport.request_path(pn_hash) + RNS.Transport.request_path(current_default) - if pn_ident != None: - node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) - pn_entry = self.app.directory.find(node_hash) - pn_display_str = " " - if pn_entry != None: - pn_display_str += " "+str(pn_entry.display_name) + if pn_ident is not None or node_selector is not None: + header_str = "" + if pn_ident is not None: + node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) + pn_entry = self.app.directory.find(node_hash) + if pn_entry is not None and getattr(pn_entry, "display_name", None): + header_str = " "+strip_modifiers(str(pn_entry.display_name)) + else: + header_str = " "+RNS.prettyhexrep(current_default) else: - pn_display_str += " "+RNS.prettyhexrep(pn_hash) + header_str = " (no default)" - dialog = DialogLineBox( - urwid.Pile([ - urwid.Text(""+g["node"]+pn_display_str, align=urwid.CENTER), - urwid.Divider(g["divider1"]), - sync_progress, - urwid.Divider(g["divider1"]), - r_mall, - rbs, - urwid.Text(""), - button_columns - ]), title="Message Sync" - ) + pile_items = [ + urwid.Text(""+g["node"]+header_str, align=urwid.CENTER), + urwid.Divider(g["divider1"]), + sync_progress, + urwid.Divider(g["divider1"]), + ] + if node_selector is not None: + pile_items += [node_selector, urwid.Divider(g["divider1"])] + pile_items += [r_mall, rbs, urwid.Text(""), button_columns] + + dialog = DialogLineBox(urwid.Pile(pile_items), title="Message Sync") else: button_columns = urwid.Columns([ (urwid.WEIGHT, 0.45, urwid.Text("" )), @@ -806,9 +1498,9 @@ class ConversationsDisplay(): self.update_listbox() options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) - if not (self.dialog_open and self.sync_dialog != None): + if not self.dialog_open: self.columns_widget.contents[0] = (self.listbox, options) - else: + elif self.sync_dialog is not None: bottom = self.listbox overlay = urwid.Overlay( self.sync_dialog, @@ -821,6 +1513,10 @@ class ConversationsDisplay(): right=2, ) self.columns_widget.contents[0] = (overlay, options) + # else: another dialog (peer info, new conversation, block confirm, etc.) is + # open as an overlay in contents[0]; leave it alone so an incoming message + # doesn't dismiss it. The underlying listbox is a persistent widget and was + # already refreshed by update_listbox() above. if selected_hash is not None: for idx, widget in enumerate(self.list_widgets): @@ -848,17 +1544,16 @@ class ConversationsDisplay(): self.app.mark_conversation_read(self.currently_displayed_conversation) self.currently_displayed_conversation = source_hash - # options = self.widget.options(urwid.WEIGHT, 1-ConversationsDisplay.list_width) - options = self.widget.options(urwid.WEIGHT, 1) - self.widget.contents[1] = (self.make_conversation_widget(source_hash), options) + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (self.make_conversation_widget(source_hash), options) if source_hash == None: - self.widget.focus_position = 0 + self.columns_widget.focus_position = 0 else: if self.app.conversation_is_unread(source_hash): self.app.mark_conversation_read(source_hash) self.update_conversation_list() - self.widget.focus_position = 1 + self.columns_widget.focus_position = 1 conversation_position = None index = 0 for widget in self.list_widgets: @@ -907,6 +1602,7 @@ class ConversationsDisplay(): source_hash = conversation[0] unread = conversation[4] last_activity = conversation[5] + failed = conversation[6] if len(conversation) > 6 else 0 g = self.app.ui.glyphs @@ -920,8 +1616,8 @@ class ConversationsDisplay(): focus_style = "list_focus" elif trust_level == DirectoryEntry.TRUSTED: symbol = g["check"] - style = "list_trusted" - focus_style = "list_focus_trusted" + style = "body_text" + focus_style = "list_focus" elif trust_level == DirectoryEntry.WARNING: symbol = g["warning"] style = "list_warning" @@ -931,27 +1627,35 @@ class ConversationsDisplay(): style = "list_untrusted" focus_style = "list_focus_untrusted" - display_text = symbol + is_pinned = False + try: + entry = self.app.directory.find(bytes.fromhex(source_hash)) + is_pinned = entry is not None and entry.sort_rank is not None + except Exception: + is_pinned = False + + head = symbol + if is_pinned: + head = g.get("pin", "*") + " " + head if display_name != None and display_name != "": - display_text += " "+display_name + head += " "+display_name if trust_level != DirectoryEntry.TRUSTED: - display_text += " <"+source_hash+">" - - if trust_level != DirectoryEntry.UNTRUSTED: - if unread: - if source_hash != self.currently_displayed_conversation: - if unread > 1: - display_text += " "+g["unread"]+" ("+str(unread)+")" - else: - display_text += " "+g["unread"] + head += " <"+source_hash+">" + markup = [head] + if failed and source_hash != self.currently_displayed_conversation: + badge_text = " "+g["warning"]+" ("+str(failed)+")" + markup.append(("msg_header_caution", badge_text)) + elif unread and source_hash != self.currently_displayed_conversation: + badge_text = " "+g["unread"]+" ("+str(unread)+")" + markup.append(("msg_header_delivered", badge_text)) if last_activity > 0: - display_text += "\n "+relative_time(last_activity) + markup.append("\n "+relative_time(last_activity)) - widget = ListEntry(display_text) + widget = ListEntry(markup) urwid.connect_signal(widget, "click", self.display_conversation, conversation[0]) display_widget = urwid.AttrMap(widget, style, focus_style) display_widget.source_hash = source_hash @@ -961,7 +1665,12 @@ class ConversationsDisplay(): def shortcuts(self): - focus_path = self.widget.get_focus_path() + try: + focus_path = self.columns_widget.get_focus_path() + except Exception: + return self.list_shortcuts + if not focus_path: + return self.list_shortcuts if focus_path[0] == 0: return self.list_shortcuts elif focus_path[0] == 1: @@ -1022,10 +1731,26 @@ class MessageEdit(urwid.Edit): class ConversationFrame(urwid.Frame): def keypress(self, size, key): + if self.focus_position == "header": + result = super(ConversationFrame, self).keypress(size, key) + if result == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + if result == "down": + self.focus_position = "body" + return None + return result if self.focus_position == "body": if getattr(self.delegate, "dialog_active", False) or getattr(self.delegate, "dialog_open", False): return super(ConversationFrame, self).keypress(size, key) elif key == "up" and self.delegate.messagelist.top_is_visible: + if getattr(self.delegate, "has_visible_trust_banner", lambda: False)(): + try: + self.delegate._header_pile.focus_position = 1 + self.focus_position = "header" + return None + except Exception: + pass nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "down" and self.delegate.messagelist.bottom_is_visible: self.focus_position = "footer" @@ -1073,14 +1798,10 @@ class ConversationWidget(urwid.WidgetWrap): self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") self._update_peer_info() - header_widgets = [self.peer_info_widget] - if self.conversation.trust_level == DirectoryEntry.UNTRUSTED: - header_widgets.append(urwid.AttrMap( - urwid.Padding( - urwid.Text(g["warning"]+" Warning: Conversation with untrusted peer "+g["warning"], align=urwid.CENTER)), - "msg_warning_untrusted", - )) - header = urwid.Pile(header_widgets) + self._trust_banner_dismissed = False + self._header_pile = urwid.Pile([self.peer_info_widget]) + self._refresh_trust_banner() + header = self._header_pile self.minimal_editor = urwid.AttrMap(msg_editor, "msg_editor") self.minimal_editor.name = "minimal_editor" @@ -1119,6 +1840,137 @@ class ConversationWidget(urwid.WidgetWrap): super().__init__(self.display_widget) + def has_visible_trust_banner(self): + if self._trust_banner_dismissed: + return False + try: + tl = self.app.directory.trust_level(bytes.fromhex(self.source_hash)) + except Exception: + tl = DirectoryEntry.UNKNOWN + return tl != DirectoryEntry.TRUSTED + + def _refresh_trust_banner(self): + contents = [(self.peer_info_widget, self._header_pile.options())] + if self.has_visible_trust_banner(): + banner = self._build_trust_banner() + contents.append((banner, self._header_pile.options())) + self._header_pile.contents = contents + if len(contents) > 1: + try: self._header_pile.focus_position = 1 + except Exception: pass + + def _build_trust_banner(self): + g = self.app.ui.glyphs + msg = urwid.Text(" "+g["warning"]+" This peer isn't trusted yet.") + btn_trust = urwid.Button("Trust", on_press=self._on_trust_click) + btn_block = urwid.Button("Block", on_press=self._on_block_click) + btn_nothing = urwid.Button("Do nothing", on_press=self._on_ignore_click) + row = urwid.Columns([ + ('weight', 1, msg), + ('pack', btn_trust), + (1, urwid.Text(" ")), + ('pack', btn_block), + (1, urwid.Text(" ")), + ('pack', btn_nothing), + (1, urwid.Text(" ")), + ], dividechars=0) + return urwid.AttrMap(row, "msg_warning_untrusted") + + def _on_trust_click(self, _btn): + try: + src = bytes.fromhex(self.source_hash) + existing = self.app.directory.find(src) + display_name = getattr(existing, "display_name", None) if existing is not None else None + preferred = getattr(existing, "preferred_delivery", None) if existing is not None else None + entry = DirectoryEntry(src, display_name, DirectoryEntry.TRUSTED, preferred_delivery=preferred) + self.app.directory.remember(entry) + except Exception as e: + RNS.log("Could not mark peer as trusted: "+str(e), RNS.LOG_ERROR) + self._refresh_trust_banner() + try: + self.frame.focus_position = "footer" + except Exception: + pass + try: + if self.delegate.list_filter != ConversationsDisplay.LIST_FILTER_TRUSTED: + self.delegate._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED) + else: + self.delegate.update_conversation_list() + except Exception as e: + RNS.log("Trust UI refresh failed: "+str(e), RNS.LOG_ERROR) + + def _on_ignore_click(self, _btn): + self._trust_banner_dismissed = True + self._refresh_trust_banner() + + def _on_block_click(self, _btn): + def dismiss(_b): + self.dialog_active = False + try: self.delegate.dialog_open = False + except Exception: pass + try: self.delegate.update_conversation_list() + except Exception: pass + + def confirmed(_b): + self.dialog_active = False + try: self.delegate.dialog_open = False + except Exception: pass + try: + self._block_peer() + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + try: self.delegate.update_conversation_list() + except Exception: pass + + try: + who = self.app.directory.simplest_display_str(bytes.fromhex(self.source_hash)) + except Exception: + who = self.source_hash + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Block "+str(who)+"?\n\nThis will blackhole the peer's identity in Reticulum,\nadd them to your ignored list, and delete this conversation.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirmed)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]), + ]), title="Confirm block" + ) + dialog.delegate = self.delegate + + bottom = self.delegate.listbox + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.delegate.columns_widget.contents[0] = ( + overlay, + self.delegate.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.delegate.columns_widget.focus_position = 0 + self.dialog_active = True + try: self.delegate.dialog_open = True + except Exception: pass + except Exception: + pass + + def _block_peer(self): + try: + src = bytes.fromhex(self.source_hash) + except Exception: + return + + try: + self.app.block_destination(src, reason="user-blocked from nomadnet conversation") + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + + try: + self.delegate.delete_conversation(self.source_hash) + nomadnet.Conversation.delete_conversation(self.source_hash, self.app) + except Exception as e: + RNS.log("Could not delete blocked conversation: "+str(e), RNS.LOG_ERROR) + def _update_peer_info(self): def san(name): if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) @@ -1669,6 +2521,15 @@ class LXMessageWidget(urwid.WidgetWrap): if message.get_title() != "": title_string += " | " + message.get_title() + inbound_untrusted = False + if not is_outbound and msg_source_hash is not None: + try: + sender_trust = app.directory.trust_level(msg_source_hash) + if sender_trust in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN): + inbound_untrusted = True + except Exception: + inbound_untrusted = False + has_attachments = message.has_attachments() cached_names = message._cached_attachment_names or [] @@ -1714,6 +2575,12 @@ class LXMessageWidget(urwid.WidgetWrap): pile_widgets.append(urwid.Text(indented)) if has_attachments and cached_names: + if inbound_untrusted: + pile_widgets.append(urwid.AttrMap( + urwid.Text(" "+g["warning"]+" This attachment came from a peer that's untrusted. Be careful when opening it."), + "list_untrusted", + )) + att_file_idx = 0 for atype, aname, *arest in cached_names: glyph = g["file"] if atype == "file" else g[atype] diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 840dcc8..712b2a1 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -60,6 +60,47 @@ class SelectText(urwid.Text): self._emit('click') return True +def _rows_above(attrmaps, index, cols): + if index <= 0 or not attrmaps: + return 0 + total = 0 + for i in range(min(index, len(attrmaps))): + try: + total += attrmaps[i].rows((cols,)) + except Exception: + total += 1 + return total + + +class GuideLinkDelegate: + def __init__(self, app, reader=None): + self.app = app + self.reader = reader + self.last_keypress = 0 + + def marked_link(self, target, fields=None): + pass + + def micron_released_focus(self): + pass + + def handle_link(self, target, fields=None): + if not target: + return + if target.startswith("#"): + if self.reader is not None: + try: + self.reader.jump_to_anchor(target[1:]) + except Exception as e: + RNS.log("Guide anchor jump failed: "+str(e), RNS.LOG_ERROR) + return + try: + self.app.ui.main_display.show_network(None) + self.app.ui.main_display.sub_displays.network_display.browser.handle_link(target, fields) + except Exception as e: + RNS.log("Could not open guide link: "+str(e), RNS.LOG_ERROR) + + class GuideEntry(urwid.WidgetWrap): def __init__(self, app, parent, reader, topic_name): self.app = app @@ -79,7 +120,7 @@ class GuideEntry(urwid.WidgetWrap): def display_topic(self, event, topic): markup = TOPICS[topic] - attrmaps = markup_to_attrmaps(markup, url_delegate=None) + attrmaps = markup_to_attrmaps(markup, url_delegate=GuideLinkDelegate(self.app, reader=self.reader)) topic_position = None index = 0 @@ -109,6 +150,7 @@ class TopicList(urwid.WidgetWrap): self.topic_list = [ GuideEntry(self.app, self, guide_display, "Introduction"), GuideEntry(self.app, self, guide_display, "Concepts & Terminology"), + GuideEntry(self.app, self, guide_display, "Channels & RRC"), GuideEntry(self.app, self, guide_display, "Interfaces"), GuideEntry(self.app, self, guide_display, "Hosting a Node"), GuideEntry(self.app, self, guide_display, "Configuration Options"), @@ -166,10 +208,53 @@ class GuideDisplay(): def set_content_widgets(self, new_content): options = self.columns.options(width_type=urwid.WEIGHT, width_amount=1-GuideDisplay.list_width, box_widget=True) pile = urwid.Pile(new_content) - content = urwid.LineBox(urwid.AttrMap(ScrollBar(Scrollable(pile), thumb_char="\u2503", trough_char=" "), "scrollbar")) + self._content_attrmaps = new_content + self._content_scrollable = Scrollable(pile) + content = urwid.LineBox(urwid.AttrMap(ScrollBar(self._content_scrollable, thumb_char="\u2503", trough_char=" "), "scrollbar")) self.columns.contents[1] = (content, options) + def jump_to_anchor(self, name): + scrollable = getattr(self, "_content_scrollable", None) + attrmaps = getattr(self, "_content_attrmaps", None) + if scrollable is None or attrmaps is None: + return + anchors = getattr(attrmaps, "anchors", None) or {} + header_rows = getattr(attrmaps, "header_rows", None) or [] + cols = self._content_cols() + target_idx = None + if name: + target_idx = anchors.get(name) + if target_idx is None: + return + else: + try: current = scrollable.get_scrollpos() + except Exception: current = 0 + for hr in header_rows: + if _rows_above(attrmaps, hr, cols) > current: + target_idx = hr + break + if target_idx is None: + return + row_offset = _rows_above(attrmaps, target_idx, cols) + try: + scrollable.set_scrollpos(int(row_offset)) + except Exception: + pass + + def _content_cols(self): + try: + cols = self.app.ui.loop.screen.get_cols_rows()[0] + except Exception: + cols = 100 + try: + widths = self.columns.column_widths((cols,)) + if len(widths) > 1 and widths[1] > 0: + return max(40, widths[1] - 3) + except Exception: + pass + return max(40, int(cols * (1 - GuideDisplay.list_width)) - 3) + def shortcuts(self): return self.shortcuts_display @@ -191,7 +276,7 @@ Nomad Network is build on LXMF and Reticulum, which together provides the crypto Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded Reticulum networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. -The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project on GitHub. +The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project at `_`F00f`[Aleph git`a8d24177d946de4f1f0a0fe1af9a1338:/page/index.mu]`f`_. ''' @@ -612,6 +697,61 @@ Conversations in Nomad Network ''' +TOPIC_CHANNELS = '''>Channels & RRC + +NomadNet includes a built-in client for `*RRC`* (Reticulum Relay Chat), a real-time text chat protocol that runs over Reticulum. The reference RRC server implementation lives at https://github.com/kc1awv/rrcd. Each RRC server is called a `!hub`!, and each hub hosts one or more `!rooms`! (channels) you can join. Hubs are addressed by a Reticulum destination hash, just like nodes and conversations. + +The Channels section of NomadNet (accessible from the main menu, or by pressing the corresponding shortcut) is where you manage your hubs, view connection status, and chat in rooms. + +>>Joining a hub + +To start chatting on a hub you must first add it to your hub list: + +>>> + - Open the `![ Channels ]`! section. + - Press the shortcut to open the `*New Hub`* dialog (shown in the shortcut bar at the bottom). + - Enter the `!hub address`! (the destination hash of the hub, typically 16 hex characters / 8 bytes) and an optional display name. + - Confirm the dialog. The hub will appear in your hub list. +< + +You can sometimes receive a hub link from another user or from a node page. Activating such a link will pre-fill the New Hub dialog (and optionally a room name) for you. + +>>Connecting and listing rooms + +Once a hub is added: + +>>> + - Select the hub in the list and connect to it. Hubs can also be set to auto-reconnect. + - When connected, run `*/list`* to see public rooms hosted on this hub. + - Run `*/join `* to join a room. Joined rooms appear under the hub in your channel list. +< + +>>Chatting + +Inside a room, anything you type that does not start with a `*/`* is sent as a message to everyone in the room. The most common in-room commands are: + +>>> + - `*/help`* show the full list of commands + - `*/who`* list users in the current room + - `*/nick `* set your display name on this hub + - `*/topic [text]`* view or set the room topic + - `*/part [room]`* leave a room (defaults to the current one) + - `*/quit`* disconnect from the hub +< + +Messages support both `*markdown`* and `*micron`* formatting, depending on your render settings (see the `*Configuration Options`* topic). Because the backtick is the micron control character, you must use the `!broken-bar`! character `*¦`* in place of `*\\``* when typing micron formatting in a message. The renderer converts `*¦`* back to `*\\``* for display. + +>>Mentions and privacy + +You will be notified (and the bell may ring, depending on your configuration) when another user mentions your nick with `*@yournick`*. RRC traffic is end-to-end encrypted by Reticulum between you and the hub, but other users in the same room can read what you write there. Treat rooms as semi-public spaces. + +>>Hub etiquette + +Each hub is operated independently and may have its own rules, MOTD, ban list and operator team. Be respecful, follow the hub's MOTD, and remember that operators can kick, ban and set modes on rooms they own. + +''' + + TOPIC_FIRST_RUN = '''>First Time Information Hi there. This first run message will only appear once. It contains a few pointers on getting started with Nomad Network, and getting the most out of the program. @@ -622,7 +762,33 @@ To get the most out of Nomad Network, you will need a terminal that supports UTF It is recommended to use a terminal size of at least 135x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped. -If you don't already have a Nerd Font installed (see https://www.nerdfonts.com/), I also highly recommend to do so, since it will greatly expand the amount of glyphs, icons and graphics that Nomad Network can use. Once you have your terminal set up with a Nerd Font, go to the `![ Config ]`! menu item and enable Nerd Fonts in the configuration instead of normal unicode glyphs. +Nerd Fonts and true-color are enabled by default. To get the full visual experience you should have a Nerd Font installed in your terminal. You can verify this by visiting the `*Display Test`* topic in this guide and checking the `!Nerd Font Rendering Test`! section. If those glyphs render correctly, you are good to go. + +If they do not render, install a Nerd Font and configure your terminal to use it. A few common approaches: + +>>> + - `!Download from nerdfonts.com`! + Visit https://www.nerdfonts.com/font-downloads, pick a font you like + (Jet Brains Mono, Hack and Meslo are popular choices), unzip it + into `*~/.local/share/fonts`* (Linux) or install via Font Book (macOS), + then refresh the font cache with `*fc-cache -fv`* on Linux. + + - `!Package manager`! + Many distros ship Nerd Font packages. On Arch: `*pacman -S ttf-nerd-fonts-symbols`* + or one of the per-family packages. On Debian/Ubuntu look for `*fonts-firacode`* + plus the symbols-only Nerd Font package, or install manually from the + nerdfonts.com release. + + - `!Homebrew (macOS)`! + `*brew install --cask font-fira-code-nerd-font`* (or any other family). + + - `!Configure your terminal`! + After installing, set your terminal emulator's font to the Nerd Font + variant (it usually has "Nerd Font" in the name). Restart NomadNet + so the new font are picked up. +< + +If for some reason you cannot or do not want to use a Nerd Font, you can disable Nerd Font glyphs in the `![ Config ]`! menu and NomadNet will fall back to plain unicode symbols. Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default one that Reticulum auto-generates on your local ethernet/WiFi network, or something much more complex. This short guide won't go into any details on building networks, but you will find other entries in the guide that deal with network setup and configuration. @@ -1073,6 +1239,23 @@ The following line should contain a grayscale gradient bar: Unicode Glyphs : \u2713 \u2715 \u26a0 \u24c3 \u2193 Nerd Font Glyphs : \uf484 \U000f04c5 \U000f0219 \U000f0002 \uf415 \uf023 \uf06e + + +>>Nerd Font Rendering Test + +`cSince Nerd Font glyphs and true-color are enabled by default, the rows below should render as crisp icons rather than empty squares, question marks, or fallback ASCII. If any glyph appears blank or boxed, your terminal is not using a Nerd Font, or the font is missing the required glyph range. +`` + +>>> +Common UI icons : \uf484 \uf415 \uf023 \uf06e \uf055 \uf056 \uf059 +Status / state : \U000f04c5 \U000f0219 \U000f0002 \U000f1397 \U000f12fc \U000f0156 +Network / radio : \U000f05a9 \U000f05aa \U000f05ab \U000f05ac \uf1eb \uf012 +People / messaging: \uf0c0 \uf007 \uf2bd \uf0e0 \uf27a \uf086 +Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0 +< + +`cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions. +`` ''' @@ -1131,6 +1314,22 @@ Nomad Network supports a simple and functional markup language called `*micron`* With micron you can easily create structured documents and pages with formatting, colors, glyphs and icons, ideal for display in terminals. +>Table of Contents + +`F00f`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f +`F00f`_`[Micron Tags`#micron-tags]`_`f +`F00f`_`[High Level Stuff`#high-level-stuff]`_`f +`F00f`_`[Colors`#colors]`_`f +`F00f`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f +`F00f`_`[Links`#links]`_`f +`F00f`_`[Anchors`#anchors]`_`f +`F00f`_`[Tables`#tables]`_`f +`F00f`_`[Fields & Requests`#fields-requests]`_`f +`F00f`_`[Comments`#comments]`_`f +`F00f`_`[Partials`#partials]`_`f +`F00f`_`[Literals`#literals]`_`f +`F00f`_`[Closing Remarks`#closing-remarks]`_`f + >>Recommendations and Requirements While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal `*must`* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better. @@ -1342,6 +1541,55 @@ Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/inde When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL. +>Anchors + +Anchors let you create jump points within a single page similar to anchors in HTML. You declare a position in the page with a name, then link to it from anywhere on the same page. + +>>Auto-anchors from headers + +Every section heading you write also becomes an anchor automatically. The anchor name is the heading text after `*slugifying`*: lowercased, with any run of non-alphanumeric characters replaced by a single hyphen, and leading or trailing hyphens stripped. So `*>Hello World`* becomes the anchor `*hello-world`*, and `*>Introduction & Setup`* becomes `*introduction-setup`*. + +>>Explicit anchors + +If you want an anchor, that isn't tied to a heading, place one anywhere in your text with the \\`: tag, followed by a name. Names may contain the characters `*A-Z`*, `*a-z`*, `*0-9`*, `*_`* and `*-`*, and end at any other character (a space, a newline, or punctuation). + +The anchor itself takes up no space and does not render. Is's just a position marker. An explicit anchor declared on an otherwise empty line binds to that line's position. + +`Faaa +`= +`:install-notes +Some installation notes for the user. + +You can also drop one mid-line. Example: see `:tip-3 ⚠ tip 3 below for caveats. +`= +`` + +>>Linking to an anchor + +Reuse the standard link syntax, with a `*#`*-prefixed URL: + +`Faaa +`= +`[Jump to Install Notes`#install-notes] +`= +`` + +When the user activates the link the browser scrolls the current page to the anchor's row. + +>>Jumping to the next section + +If the URL is just `*#`* with no name after it, the link jumps to the next \\`> header that appears after the link's own position in the document. This is convenient for "Continue ↓" buttons after a long paragraph, without having to name every section: + +`Faaa +`= +`[Continue`#] +`= +`` + +>>Notes on namespaces and collisions + +Auto-anchors from headings and explicit \\`: anchors share a single namespace per page. If an explicit anchor collides with a heading slug, the first one declared is where it will jump to. + >Tables You can include rendered tables by enclosing them in \\`t tags. Optionally, you can also specify alignment and max rendering width by adding these properties to the opening \\`t tag, like \\`tc30. Here's an example: @@ -1564,6 +1812,7 @@ TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, yo TOPICS = { "Introduction": TOPIC_INTRODUCTION, "Concepts & Terminology": TOPIC_CONCEPTS, + "Channels & RRC": TOPIC_CHANNELS, "Conversations": TOPIC_CONVERSATIONS, "Interfaces": TOPIC_INTERFACES, "Hosting a Node": TOPIC_HOSTING, diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index bf1ed30..90a3ad7 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -1,6 +1,7 @@ import nomadnet import urwid import random +import re import time import RNS from urwid.util import is_mouse_press @@ -57,10 +58,32 @@ def default_state(fg=None, bg=None): "align": "left", "default_fg": fg, "default_bg": bg, + "anchors": {}, + "pending_anchors": [], + "header_rows": [], } return state -def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None): + +_MICRON_STRIP_RE = re.compile( + r"`[FB]T[0-9a-fA-F]{6}" + r"|`[FB][0-9a-fA-F]{3}" + r"|`:[A-Za-z0-9_\-]*" + r"|`[!*_=fbacrl`<>{]" +) + +def slugify_micron(text): + if text is None: return "" + stripped = _MICRON_STRIP_RE.sub("", text) + s = re.sub(r"[^A-Za-z0-9]+", "-", stripped).strip("-").lower() + return s + +class _AttrMapList(list): + """list subclass that allows attaching parser metadata like `anchors`.""" + pass + + +def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None): global LINK_CLASS if link_class: LINK_CLASS = link_class else: LINK_CLASS = LinkableText @@ -71,13 +94,17 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None else: SELECTED_STYLES = STYLES_LIGHT - attrmaps = [] + attrmaps = _AttrMapList() fgc = None; bgc = DEFAULT_BG if bg_color != None: bgc = bg_color if fg_color != None: fgc = fg_color state = default_state(fgc, bgc) + if anchors is None: + anchors = state["anchors"] + else: + state["anchors"] = anchors # Split entire document into lines for # processing. @@ -88,12 +115,30 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None display_widgets = parse_line(line, state, url_delegate) else: display_widgets = [urwid.Text("")] - + if display_widgets != None and len(display_widgets) != 0: + row_index = len(attrmaps) + pending = state.get("pending_anchors") or [] + if pending: + for name in pending: + if name and name not in anchors: + anchors[name] = row_index + state["pending_anchors"] = [] + + if state.get("_header_pending"): + state["header_rows"].append(row_index) + state["_header_pending"] = False + for display_widget in display_widgets: attrmap = urwid.AttrMap(display_widget, make_style(state)) attrmaps.append(attrmap) + try: + setattr(attrmaps, "anchors", anchors) + setattr(attrmaps, "header_rows", list(state.get("header_rows", []))) + except Exception: + pass + return attrmaps def parse_partial(line): @@ -239,7 +284,7 @@ def parse_line(line, state, url_delegate): while i < len(line) and line[i] == ">": i += 1 state["depth"] = i - + for j in range(1, i+1): wanted_style = "heading"+str(i) if wanted_style in SELECTED_STYLES: @@ -252,9 +297,14 @@ def parse_line(line, state, url_delegate): heading_style = make_style(state) output = make_output(state, line, url_delegate) - + style_to_state(latched_style, state) + slug = slugify_micron(line) + if slug: + state.setdefault("pending_anchors", []).append(slug) + state["_header_pending"] = True + if len(output) > 0: first_style = output[0][0] @@ -599,6 +649,18 @@ def make_output(state, line, url_delegate, pre_escape=False): elif c == "a": state["align"] = state["default_align"] + elif c == ":": + # Anchor declaration: `:anchor-name (terminated by any non-name char). The anchor is a zero width position narker bound to the current line by markup_to_attrmaps + name_start = i + 1 + name_end = name_start + while name_end < len(line) and (line[name_end].isalnum() or line[name_end] in "_-"): + name_end += 1 + anchor_name = line[name_start:name_end] + if anchor_name: + state.setdefault("pending_anchors", []).append(anchor_name) + skip = (name_end - i) - 1 + if skip < 0: skip = 0 + elif c == '<': if len(part) > 0: output.append(make_part(state, part)) From 503ea58b852a8fa4be4dd7bdcc1cff4488392bc5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 00:17:39 +0200 Subject: [PATCH 435/592] Added RRC nick colors option --- nomadnet/NomadNetworkApp.py | 7 + nomadnet/ui/textui/Channels.py | 43 +++-- nomadnet/ui/textui/Guide.py | 6 + tools/colorgen.py | 321 +++++++++++++++++++++++++++++++++ 4 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 tools/colorgen.py diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 7b4483a..e18663d 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -156,6 +156,7 @@ class NomadNetworkApp: self.rrc_history_per_room_cap = 500 self.rrc_filter_loaded_history = True self.rrc_ephemeral_notices = 600 + self.rrc_nick_colors = True self.rrc_ui_justify_msgs = True self.rrc_ui_space_msgs = False self.rrc_ui_render_markdown = True @@ -944,6 +945,11 @@ class NomadNetworkApp: except Exception: value = False self.rrc_ui_space_msgs = value + if option == "nick_colors": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_nick_colors = value + if option == "render_markdown": try: value = self.config["rrc"].as_bool(option) except Exception: value = True @@ -1296,6 +1302,7 @@ ephemeral_notices = 10 # Other display and formatting options: render_markdown = yes render_micron = yes +nick_colors = yes justify_msgs = yes space_msgs = no diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index e7e72df..07e33fb 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -13,7 +13,8 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps -from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags +from nomadnet.util import sanitize_name, strip_modifiers, strip_micron +from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags theme_dark = { "text": "ddd", @@ -24,7 +25,10 @@ theme_dark = { "text": "ddd", "error": "f55", "system": "888", "mention": "fb4", - "link": "79d", } + "link": "79d", + # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 1.4 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 + "nick_colors": ["f68787", "00c394", "d59e00", "62be00", "a1ac76", "95b600", "76a9ee", "81b385", "7eb1a1", "e89264", "7cb0b0", "00c0c0", "8cacbb", "32b4db", "98a8c3", "bbab00", "95a0fd", "a9a2ca", "ad98fe", "c58ffa", "df83f4", "c49abf", "f380c7", "f484a7"], + } theme_light = { "text": "111", "ts": "888", @@ -34,7 +38,10 @@ theme_light = { "text": "111", "error": "a22", "system": "888", "mention": "c50", - "link": "79d", } + "link": "79d", + # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 0.2 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 > ~/.nomadnetwork/storage/pages/index.mu + "nick_colors": ["ca0000", "008000", "9d1c00", "007800", "2c5200", "006800", "004ac0", "006100", "005d2c", "b70000", "005b5b", "007b7a", "005071", "0064a5", "004580", "714f00", "0026d3", "48318c", "5200d5", "8400cf", "aa00c8", "820079", "c60086", "c80043"], + } class _ChatLinkableText(LinkableText): @@ -451,6 +458,7 @@ class RoomWidget(urwid.WidgetWrap): rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))] for name, is_self in names: + name = sanitize_name(name); name = name[:15]+"…" if len(name) > 16 else name if is_self: rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted")) else: @@ -901,7 +909,14 @@ class _ChatLinkDelegate: except Exception as e: RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) +def get_nick_color(sender_hash, theme, shift=15): + if type(sender_hash) == str: + try: sender_hash = sender_hash.encode("utf-8") + except: pass + if not type(sender_hash) == bytes: return theme["nick_peer"] + return theme["nick_colors"][(int.from_bytes(sender_hash)+shift)%len(theme["nick_colors"])] +room_nick_src_cache = {} mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) def _message_widget(app, hub, m, link_delegate=None): t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light @@ -944,12 +959,12 @@ def _message_widget(app, hub, m, link_delegate=None): except Exception: pass - if m.nick: - sender = m.nick - elif isinstance(m.src, (bytes, bytearray)): - sender = _short_hash(m.src) - else: - sender = "?" + if m.nick: sender = sanitize_name(m.nick) + elif isinstance(m.src, (bytes, bytearray)): sender = _short_hash(m.src) + else: sender = "?" + + if isinstance(m.src, (bytes, bytearray)): + room_nick_src_cache[m.nick] = m.src nick_attr = "irc_nick_self" if own else "irc_nick_peer" body = m.text or "" @@ -960,7 +975,12 @@ def _message_widget(app, hub, m, link_delegate=None): for span in spans: ms = span[0] mb = span[1] - if ms.startswith("irc_mention"): message_body += f"`!`F{t['mention']}{mb}`f`!" + if ms.startswith("irc_mention"): + if not app.rrc_nick_colors: message_body += f"`!`F{t['mention']}{mb}`f`!" + else: + try: message_body += f"`!`FT{get_nick_color(room_nick_src_cache[m.nick], t)}{mb}`f`!" + except: message_body += f"`!`F{t['mention']}{mb}`f`!" + elif ms.startswith("link_"): kind = ms[len("link_"):] label = mb.split("`")[0] @@ -978,7 +998,8 @@ def _message_widget(app, hub, m, link_delegate=None): mbo = mdc.format_block(strip_escaped_micron(mb)) if app.rrc_ui_render_markdown else strip_escaped_micron(mb) message_body += strip_non_formatting_tags(mbo) - nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" + if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t)}" + else: nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" irc_ts = f"`F{t['ts']}" prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 7a8e05a..5953e2b 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -811,6 +811,12 @@ Whether or not to render markdown formatting in messages. Whether or not to render micron formatting in messages. When using micron in messages, use the ¦ character in place of backticks. < +>>> +`!nick_colors = yes`! +>>>> +Whether or not to render RRC nicks in distinct colors based on identity hash. +< + >>> `!justify_msgs = yes`! `!space_msgs = no`! diff --git a/tools/colorgen.py b/tools/colorgen.py new file mode 100644 index 0000000..0ff636a --- /dev/null +++ b/tools/colorgen.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Color Table Generator with Perceptual Normalization + +Generates HSL color tables with optional normalization to uniform perceptual +intensity (CIELAB L*) followed by global lightness scaling. +""" + +import argparse +import colorsys +import math +import sys + + +def hsl_to_rgb(h, s, l): + """Convert HSL (0-360, 0-100, 0-100) to RGB (0.0-1.0).""" + h_norm = (h % 360) / 360.0 + s_norm = max(0, min(100, s)) / 100.0 + l_norm = max(0, min(100, l)) / 100.0 + r, g, b = colorsys.hls_to_rgb(h_norm, l_norm, s_norm) + return (r, g, b) + + +def rgb_to_hex(r, g, b): + """Convert RGB 0.0-1.0 to lowercase hex string.""" + r = max(0, min(1, r)) + g = max(0, min(1, g)) + b = max(0, min(1, b)) + return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" + + +# CIELAB conversion functions +def srgb_to_linear(c): + if c <= 0.04045: + return c / 12.92 + return ((c + 0.055) / 1.055) ** 2.4 + + +def linear_to_srgb(c): + if c <= 0.0031308: + return c * 12.92 + return 1.055 * (c ** (1/2.4)) - 0.055 + + +def rgb_to_lab(r, g, b): + """Convert sRGB (0-1) to CIELAB (L: 0-100, a,b: ~-128 to 127). D65 illuminant.""" + # sRGB to Linear + r_lin = srgb_to_linear(r) + g_lin = srgb_to_linear(g) + b_lin = srgb_to_linear(b) + + # Linear to XYZ (D65) + X = 0.4124564 * r_lin + 0.3575761 * g_lin + 0.1804375 * b_lin + Y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin + Z = 0.0193339 * r_lin + 0.1191920 * g_lin + 0.9503041 * b_lin + + # XYZ to LAB + Xn, Yn, Zn = 95.047, 100.0, 108.883 + delta = 6/29 + + def f(t): + if t > delta**3: + return t ** (1/3) + return t / (3 * delta**2) + 4/29 + + L = 116 * f(Y / Yn) - 16 + a = 500 * (f(X / Xn) - f(Y / Yn)) + b_val = 200 * (f(Y / Yn) - f(Z / Zn)) + + return (L, a, b_val) + + +def lab_to_rgb(L, a, b_val): + """Convert CIELAB to sRGB (0-1).""" + Xn, Yn, Zn = 95.047, 100.0, 108.883 + delta = 6/29 + + def inv_f(y): + if y > delta: + return y ** 3 + return 3 * delta**2 * (y - 4/29) + + L_adj = (L + 16) / 116 + X = Xn * inv_f(L_adj + a / 500) + Y = Yn * inv_f(L_adj) + Z = Zn * inv_f(L_adj - b_val / 200) + + # XYZ to Linear RGB + r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z + g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z + b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z + + r = linear_to_srgb(r_lin) + g = linear_to_srgb(g_lin) + b = linear_to_srgb(b_lin) + + return (r, g, b) + + +def generate_colors( + hues=None, + hue_start=0.0, + hue_step=30.0, + sat_start=70.0, + sat_step=0.0, + sat_steps=1, + light_start=50.0, + light_step=0.0, + light_steps=1, + perceptual_multiplier=1.0, + use_hsl_space=False, + normalize=False, + normalize_target=None, + discards=[] +): + """ + Generate color table with optional perceptual normalization. + + Pipeline: + 1. Generate HSL variations + 2. If normalize: Convert to LAB, set all L* to target (mean or specified) + 3. Apply global perceptual multiplier to L* + 4. Convert to RGB and output hex + """ + # Determine hue list + if hues is not None: + hue_list = [float(h) % 360 for h in hues] + else: + hue_list = [] + current = hue_start % 360 + if hue_step <= 0: + raise ValueError("hue_step must be positive when using start/step mode") + while current < 360: + hue_list.append(current) + current += hue_step + + # Determine if we need LAB processing + needs_lab = normalize or (perceptual_multiplier != 1.0) or not use_hsl_space + + colors = [] + lab_colors = [] # Store as (L, a, b) tuples if processing needed + + for h in hue_list: + for i in range(sat_steps): + s = sat_start + (i * sat_step) + s = max(0, min(100, s)) + + for j in range(light_steps): + # l = light_start + (j * light_step) + l = light_start + (i * light_step) + l = max(0, min(100, l)) + + r, g, b = hsl_to_rgb(h, s, l) + + if not needs_lab: + colors.append(rgb_to_hex(r, g, b)) + else: + L, a, b_lab = rgb_to_lab(r, g, b) + lab_colors.append((L, a, b_lab)) + + filtered_colors = [] + filtered_lab_colors = [] + i = 0 + for c in colors: + i += 1 + if not str(i) in discards: + filtered_colors.append(c) + + i = 0 + for c in lab_colors: + i += 1 + if not str(i) in discards: + filtered_lab_colors.append(c) + + colors = filtered_colors + lab_colors = filtered_lab_colors + + if not needs_lab: + return colors + + # Step 2: Perceptual Normalization (equalize intensity) + if normalize: + if normalize_target is not None: + target_L = normalize_target + else: + # Calculate mean perceptual lightness + target_L = sum(lab[0] for lab in lab_colors) / len(lab_colors) + print(f"Target L*: {target_L}") + + # Clamp target to valid LAB range + target_L = max(0, min(100, target_L)) + + # Normalize all colors to target L, preserving hue (a, b) + lab_colors = [(target_L, a, b) for (L, a, b) in lab_colors] + + # Step 3: Apply final global perceptual multiplier + final_colors = [] + for L, a, b in lab_colors: + L_final = L * perceptual_multiplier + L_final = max(0, min(100, L_final)) + r, g, b = lab_to_rgb(L_final, a, b) + final_colors.append(rgb_to_hex(r, g, b)) + + return final_colors + + +def main(): + parser = argparse.ArgumentParser( + description="Generate perceptually normalized color tables", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Pipeline: HSL Generation → [Normalize to uniform L*] → Global Multiplier → Hex + +Examples: + # 32 colors normalized to same perceptual intensity, then darkened 20% + python3 colorgen.py --hue-start 0 --hue-step 11.25 --sat-start 75 --normalize --perceptual-multiplier 0.8 + + # Force all colors to exactly L*=55, then scale by 1.1 + python3 colorgen.py --hues "0,90,180,270" --normalize --normalize-target 55 --perceptual-multiplier 1.1 + + # Generate with lightness variation, normalize to mean intensity, output brightened + python3 colorgen.py --hue-start 15 --hue-step 30 --light-start 40 --light-step 10 --light-steps 3 --normalize --perceptual-multiplier 1.2 + """ + ) + + # Hue configuration + hue_group = parser.add_mutually_exclusive_group() + hue_group.add_argument("--hues", type=str, metavar="LIST", + help='Comma-separated hue values, e.g., "0,60,120"') + hue_group.add_argument("--hue-start", type=float, default=0.0, + help="Initial hue offset in degrees") + parser.add_argument("--hue-step", type=float, default=30.0, + help="Separation between hues") + + # Saturation/Lightness configuration + parser.add_argument("--sat-start", type=float, default=75.0, + help="Initial saturation (0-100)") + parser.add_argument("--sat-step", type=float, default=0.0, + help="Saturation step") + parser.add_argument("--sat-steps", type=int, default=1, + help="Number of saturation variations") + parser.add_argument("--light-start", type=float, default=50.0, + help="Initial lightness (0-100)") + parser.add_argument("--light-step", type=float, default=0.0, + help="Lightness step") + parser.add_argument("--light-steps", type=int, default=1, + help="Number of lightness variations") + + # Perceptual processing + parser.add_argument("--normalize", action="store_true", + help="Normalize all colors to same perceptual intensity (L*) before applying multiplier") + parser.add_argument("--normalize-target", type=float, default=None, metavar="L", + help="Target L* value (0-100) for normalization. Default: mean of generated colors") + parser.add_argument("--perceptual-multiplier", type=float, default=1.0, + help="Final global lightness multiplier (1.0=no change)") + parser.add_argument("--hsl-space", action="store_true", + help="Skip LAB conversion if not normalizing (faster, less accurate perceptually)") + + # Output + parser.add_argument("--python-list", action="store_true", + help="Output as Python list") + parser.add_argument("--separator", type=str, default="\n", + help="Separator between colors") + parser.add_argument("--stats", action="store_true", + help="Print generation stats to stderr") + hue_group.add_argument("--discard", type=str, metavar="LIST", + help='Discard output indexes, e.g., "20,2,33"') + + args = parser.parse_args() + + if args.hsl_space and args.normalize: + print("Warning: --hsl-space ignored because --normalize requires LAB space", file=sys.stderr) + args.hsl_space = False + + try: + if args.discard: discards = args.discard.split(",") + else: discards = [] + + colors = generate_colors( + hues=args.hues.split(",") if args.hues else None, + hue_start=args.hue_start, + hue_step=args.hue_step, + sat_start=args.sat_start, + sat_step=args.sat_step, + sat_steps=args.sat_steps, + light_start=args.light_start, + light_step=args.light_step, + light_steps=args.light_steps, + perceptual_multiplier=args.perceptual_multiplier, + use_hsl_space=args.hsl_space, + normalize=args.normalize, + normalize_target=args.normalize_target, + discards=discards, + ) + + i = 0 + # Enable to test on light background + # print(f"#!bg=fff\n") + if args.python_list: print("colors = [", end="") + for c in colors: + i+=1 + if args.python_list: + print(f"\"{c}\", ", end="") + + else: print(f"`FT{c}colored {i}`f") + + if args.python_list: print("]") + + if args.stats: + total = len(colors) + mode = "normalized + " if args.normalize else "" + print(f"# Generated {total} colors ({mode}L* × {args.perceptual_multiplier})", + file=sys.stderr) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file From f6158ba4df055d3b1ec8cefdd6332a0786193781 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 00:38:08 +0200 Subject: [PATCH 436/592] Added custom nick color theme option --- nomadnet/NomadNetworkApp.py | 20 ++++++++++++++++++++ nomadnet/ui/textui/Channels.py | 10 ++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index e18663d..3e9b068 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -157,6 +157,7 @@ class NomadNetworkApp: self.rrc_filter_loaded_history = True self.rrc_ephemeral_notices = 600 self.rrc_nick_colors = True + self.rrc_nick_colors_theme = None self.rrc_ui_justify_msgs = True self.rrc_ui_space_msgs = False self.rrc_ui_render_markdown = True @@ -950,6 +951,20 @@ class NomadNetworkApp: except Exception: value = True self.rrc_nick_colors = value + if option == "nick_colors_theme": + try: + colors = self.config["rrc"].as_list(option) + nick_colors_theme = [] + for c in colors: + if len(c) == 6: + try: bytes.fromhex(c) + except: continue + nick_colors_theme.append(c) + self.rrc_nick_colors_theme = nick_colors_theme + except Exception as e: + RNS.log(f"Could not load custom nick colors theme: {e}", RNS.LOG_WARNING) + self.rrc_nick_colors_theme = None + if option == "render_markdown": try: value = self.config["rrc"].as_bool(option) except Exception: value = True @@ -1306,6 +1321,11 @@ nick_colors = yes justify_msgs = yes space_msgs = no +# You can configure your own color theme +# for nick color assignment +# nick_colors_theme = f68787, 00c394, d59e00, ... + + [node] # Whether to enable node hosting diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 07e33fb..143dd0d 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -909,12 +909,14 @@ class _ChatLinkDelegate: except Exception as e: RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) -def get_nick_color(sender_hash, theme, shift=15): +def get_nick_color(sender_hash, theme, app, shift=15): + if app.rrc_nick_colors_theme: nick_colors = app.rrc_nick_colors_theme + else: nick_colors = theme["nick_colors"] if type(sender_hash) == str: try: sender_hash = sender_hash.encode("utf-8") except: pass if not type(sender_hash) == bytes: return theme["nick_peer"] - return theme["nick_colors"][(int.from_bytes(sender_hash)+shift)%len(theme["nick_colors"])] + return nick_colors[(int.from_bytes(sender_hash)+shift)%len(nick_colors)] room_nick_src_cache = {} mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) @@ -978,7 +980,7 @@ def _message_widget(app, hub, m, link_delegate=None): if ms.startswith("irc_mention"): if not app.rrc_nick_colors: message_body += f"`!`F{t['mention']}{mb}`f`!" else: - try: message_body += f"`!`FT{get_nick_color(room_nick_src_cache[m.nick], t)}{mb}`f`!" + try: message_body += f"`!`FT{get_nick_color(room_nick_src_cache[m.nick], t, app)}{mb}`f`!" except: message_body += f"`!`F{t['mention']}{mb}`f`!" elif ms.startswith("link_"): @@ -998,7 +1000,7 @@ def _message_widget(app, hub, m, link_delegate=None): mbo = mdc.format_block(strip_escaped_micron(mb)) if app.rrc_ui_render_markdown else strip_escaped_micron(mb) message_body += strip_non_formatting_tags(mbo) - if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t)}" + if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t, app)}" else: nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" irc_ts = f"`F{t['ts']}" From 0222714f48e735f00e88ddf2cc11dbdae2a24f57 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 02:10:10 +0200 Subject: [PATCH 437/592] Adjusted unread message styling. Remapped QR-code shortcut to ctrl-p (ctrl-q conflicts with quit shortcut). --- nomadnet/ui/TextUI.py | 4 ++++ nomadnet/ui/textui/Conversations.py | 18 ++++++++++++++---- nomadnet/ui/textui/Guide.py | 26 +++++++++++++------------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 3c58e7d..eb4d2ae 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -37,6 +37,8 @@ THEMES = { ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), + ("msg_notice_unread", "light blue", "default", "standout", "#28b", "default"), + ("msg_notice_caution", "yellow", "default", "standout", "#fd3", "default"), ("list_focus", "black", "light gray", "standout", "#111", "#aaa"), ("list_off_focus", "black", "dark gray", "standout", "#111", "#777"), ("list_trusted", "dark green", "default", "default", "#6b2", "default"), @@ -88,6 +90,8 @@ THEMES = { ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), + ("msg_notice_unread", "dark blue", "default", "standout", "#069", "default"), + ("msg_notice_caution", "yellow", "default", "standout", "#fd3", "default"), ("list_focus", "black", "dark gray", "standout", "#111", "#aaa"), ("list_off_focus", "black", "dark gray", "standout", "#111", "#777"), ("list_trusted", "dark green", "default", "default", "#4a0", "default"), diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 3c0dc5a..4b3376c 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -92,7 +92,7 @@ class ConversationsArea(urwid.LineBox): self.delegate.toggle_fullscreen() elif key == "ctrl o": self.delegate.toggle_list_sort() - elif key == "ctrl q": + elif key == "ctrl p": self.delegate.show_my_qr() elif key == "tab": self.delegate.app.ui.main_display.frame.focus_position = "header" @@ -1620,7 +1620,7 @@ class ConversationsDisplay(): focus_style = "list_focus" elif trust_level == DirectoryEntry.TRUSTED: symbol = g["check"] - style = "body_text" + style = "list_normal" focus_style = "list_focus" elif trust_level == DirectoryEntry.WARNING: symbol = g["warning"] @@ -1651,10 +1651,20 @@ class ConversationsDisplay(): markup = [head] if failed and source_hash != self.currently_displayed_conversation: badge_text = " "+g["warning"]+" ("+str(failed)+")" - markup.append(("msg_header_caution", badge_text)) + # markup.append(("msg_notice_caution", badge_text)) + markup.append(badge_text) elif unread and source_hash != self.currently_displayed_conversation: badge_text = " "+g["unread"]+" ("+str(unread)+")" - markup.append(("msg_header_delivered", badge_text)) + # Good idea with having the badges here colored, but + # using the bg color for it is a bit much, I think. + # I set fg color attrmap styles, but that messes up + # the bg on list focus. If there's a way to handle + # that, we can re-enable this. + # markup.append(("msg_notice_unread", badge_text)) + markup.append(badge_text) + + if trust_level == DirectoryEntry.TRUSTED and unread and source_hash != self.currently_displayed_conversation: + style = "msg_notice_unread" if last_activity > 0: markup.append("\n "+relative_time(last_activity)) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 7a68671..561468c 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1328,19 +1328,19 @@ With micron you can easily create structured documents and pages with formatting >Table of Contents -`F00f`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f -`F00f`_`[Micron Tags`#micron-tags]`_`f -`F00f`_`[High Level Stuff`#high-level-stuff]`_`f -`F00f`_`[Colors`#colors]`_`f -`F00f`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f -`F00f`_`[Links`#links]`_`f -`F00f`_`[Anchors`#anchors]`_`f -`F00f`_`[Tables`#tables]`_`f -`F00f`_`[Fields & Requests`#fields-requests]`_`f -`F00f`_`[Comments`#comments]`_`f -`F00f`_`[Partials`#partials]`_`f -`F00f`_`[Literals`#literals]`_`f -`F00f`_`[Closing Remarks`#closing-remarks]`_`f + `F44f`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f + `F44f`_`[Micron Tags`#micron-tags]`_`f + `F44f`_`[High Level Stuff`#high-level-stuff]`_`f + `F44f`_`[Colors`#colors]`_`f + `F44f`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f + `F44f`_`[Links`#links]`_`f + `F44f`_`[Anchors`#anchors]`_`f + `F44f`_`[Tables`#tables]`_`f + `F44f`_`[Fields & Requests`#fields-requests]`_`f + `F44f`_`[Comments`#comments]`_`f + `F44f`_`[Partials`#partials]`_`f + `F44f`_`[Literals`#literals]`_`f + `F44f`_`[Closing Remarks`#closing-remarks]`_`f >>Recommendations and Requirements From 057f24674fe9302ebf81c62be0f26f0d2855d161 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 02:12:57 +0200 Subject: [PATCH 438/592] Remapped QR-code shortcut to ctrl-p (ctrl-q conflicts with quit shortcut). --- nomadnet/ui/textui/Conversations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 4b3376c..4ecbd6d 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -63,7 +63,7 @@ class ConversationListDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-o] Sort [C-q] My LXMF [C-g] Fullscreen"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-o] Sort [C-p] My LXMF [C-g] Fullscreen"), "shortcutbar") class ConversationDisplayShortcuts(): def __init__(self, app): From 1fb7ed5b8d1f9f813f8b3126e8484d0d191054b9 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 02:24:13 +0200 Subject: [PATCH 439/592] Made gutters configurable --- nomadnet/NomadNetworkApp.py | 7 +++++++ nomadnet/ui/textui/Channels.py | 29 +++++++++++++++++++++-------- nomadnet/ui/textui/Guide.py | 1 + 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 97bdb28..31154a0 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -162,6 +162,7 @@ class NomadNetworkApp: self.rrc_ui_space_msgs = False self.rrc_ui_render_markdown = True self.rrc_ui_render_micron = True + self.rrc_show_gutters = False if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -1028,6 +1029,11 @@ class NomadNetworkApp: try: value = self.config["rrc"].as_bool(option) except Exception: value = True self.rrc_ui_render_micron = value + + if option == "show_gutters": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_show_gutters = value if "node" in self.config: if not "enable_node" in self.config["node"]: @@ -1374,6 +1380,7 @@ render_micron = yes nick_colors = yes justify_msgs = yes space_msgs = no +show_gutters = yes # You can configure your own color theme # for nick color assignment diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 687ce96..8e82435 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -551,6 +551,7 @@ class RoomWidget(urwid.WidgetWrap): self.users_box = UsersBox(self.users_listbox, title="Users") self.users_box.delegate = self self.users_gutter = UsersExpandGutter(self.app, self) + self.show_gutters = self.app.rrc_show_gutters self._refresh_users_pane() self.users_visible = self.display.users_visible @@ -570,10 +571,16 @@ class RoomWidget(urwid.WidgetWrap): (self.users_box, self.columns.options(urwid.GIVEN, RoomWidget.USERS_PANE_WIDTH)), ] else: - self.columns.contents = [ - (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), - (self.users_gutter, self.columns.options(urwid.GIVEN, 1)), - ] + if self.show_gutters: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_gutter, self.columns.options(urwid.GIVEN, 1)), + ] + else: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)) + ] + self.columns.focus_position = 0 def _refresh_users_pane(self): @@ -1263,6 +1270,7 @@ class ChannelsDisplay(): self._build_listbox() self.gutter = ChannelsExpandGutter(self.app, self) + self.show_gutters = self.app.rrc_show_gutters self.list_shortcuts = ChannelsListShortcuts(self.app) self.room_shortcuts = ChannelsRoomShortcuts(self.app) @@ -1323,10 +1331,15 @@ class ChannelsDisplay(): try: self.columns_widget.focus_position = 1 except Exception: pass else: - self.columns_widget.contents = [ - (self.gutter, gutter_opts), - (self.right, right_opts), - ] + if self.show_gutters: + self.columns_widget.contents = [ + (self.gutter, gutter_opts), + (self.right, right_opts), + ] + else: + self.columns_widget.contents = [ + (self.right, right_opts), + ] try: self.columns_widget.focus_position = 1 except Exception: pass diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 561468c..19cee6c 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -986,6 +986,7 @@ Whether or not to render RRC nicks in distinct colors based on identity hash. >>> `!justify_msgs = yes`! `!space_msgs = no`! +`!show_gutters = no`! >>>> Rendering layout options for messages. < From e7958ad331032c5b6b0928ba76e99fb6928ac7f0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 02:36:12 +0200 Subject: [PATCH 440/592] Added nick colors in RRC users pane --- nomadnet/ui/textui/Channels.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 8e82435..e7f076c 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -12,7 +12,7 @@ from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter -from .MicronParser import markup_to_attrmaps +from .MicronParser import markup_to_attrmaps, default_state, make_style from nomadnet.util import sanitize_name, strip_modifiers, strip_micron from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags from nomadnet.vendor.Scrollable import Scrollable, ScrollBar @@ -522,6 +522,7 @@ class RoomWidget(urwid.WidgetWrap): self.hub = hub self.room = room self.app = nomadnet.NomadNetworkApp.get_shared_instance() + self.theme = theme_dark if self.app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light self.messagelist = None self.last_history_clean = 0 @@ -617,12 +618,23 @@ class RoomWidget(urwid.WidgetWrap): rows = [urwid.Text(" "+str(len(entries))+" user"+("s" if len(entries) != 1 else ""))] for name, peer_hash, is_self in entries: - if is_self: - label = " "+g["arrow_r"]+" "+name - style = "list_trusted" + if self.app.rrc_nick_colors: + style_state = default_state() + style_state["fg_color"] = get_nick_color(peer_hash, self.theme, self.app) + if is_self: + label = " "+g["arrow_r"]+" "+name + style = make_style(style_state) + else: + label = " "+g["peer"]+" "+name + style = make_style(style_state) + else: - label = " "+g["peer"]+" "+name - style = "connected_status" + if is_self: + label = " "+g["arrow_r"]+" "+name + style = "list_trusted" + else: + label = " "+g["peer"]+" "+name + style = "connected_status" entry = ChannelListEntry(label) urwid.connect_signal(entry, "click", self.display.show_user_info, (self.hub, peer_hash, name)) row = urwid.AttrMap(entry, style, "list_focus") From 316b03ae5217ecb4a76ecb2b0418488a44c69353 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 02:57:04 +0200 Subject: [PATCH 441/592] Added anchor handling for links to other pages --- nomadnet/ui/textui/Browser.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 20c6e20..d3740bf 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1460,6 +1460,9 @@ class Browser: RNS.log("Received page "+str(self.current_url())+", caching for %.3f hours." % (cache_time/60/60), RNS.LOG_DEBUG) self.cache_page(cache_time) + if self.request_data and "var_anchor" in self.request_data: + self._jump_to_anchor(self.request_data["var_anchor"]) + except Exception as e: RNS.log("An error occurred while handling response. The contained exception was: "+str(e)) From 5c01142f8dab18e8f5f9bc415aea9a0a65ad7691 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 02:59:50 +0200 Subject: [PATCH 442/592] Added anchor handling for links to other pages to guide --- nomadnet/ui/textui/Guide.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 19cee6c..9d0d332 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1599,6 +1599,16 @@ If the URL is just `*#`* with no name after it, the link jumps to the next \\`> `= `` +>>Anchors in external links + +If you want to link to an anchor on another page, you can include it as a request variable: + +`Faaa +`= +`[Conclusion`a8d24177d946de4f1f0a0fe1af9a1338:/page/document.mu`anchor=conclusion] +`= +`` + >>Notes on namespaces and collisions Auto-anchors from headings and explicit \\`: anchors share a single namespace per page. If an explicit anchor collides with a heading slug, the first one declared is where it will jump to. From 7b631649478b5feca378c51c358e9e92a0d3422c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 03:10:36 +0200 Subject: [PATCH 443/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 72f26f5..c68196d 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.1.2" +__version__ = "1.2.0" From 7315eb3edb2c7f0d2ea348bdcb5b82178625436d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 03:19:46 +0200 Subject: [PATCH 444/592] Include alignment tags in strip_non_formatting_tags --- nomadnet/util.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nomadnet/util.py b/nomadnet/util.py index 9b3427c..51aa440 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -182,4 +182,7 @@ def strip_non_formatting_tags(text): text = re.sub(r'`<', '', text) text = re.sub(r'`>', '', text) text = re.sub(r'`{', '', text) + text = re.sub(r'`r', '', text) + text = re.sub(r'`c', '', text) + text = re.sub(r'`l', '', text) return text \ No newline at end of file From a3c8b6246b30b26b01526c978602346dd9ba605a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 03:34:20 +0200 Subject: [PATCH 445/592] Close/leave room with ctrl-w --- nomadnet/ui/textui/Channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index e7f076c..94f244f 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -187,7 +187,7 @@ class ChannelsListShortcuts(): class ChannelsRoomShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave [C-k] Clear [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] Focus"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-w] Leave [C-k] Clear [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] Focus"), "shortcutbar") class ChannelsDialogLineBox(urwid.LineBox): @@ -386,7 +386,7 @@ class RoomMessageEdit(urwid.Edit): self.delegate.send_message() elif key == "ctrl k": self.set_edit_text("") - elif key == "ctrl l": + elif key == "ctrl w": self.delegate.leave_room() elif key == "ctrl u": self.delegate.toggle_users() From 25208fc7574590c0c052f421c39236745b6235c9 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 03:59:21 +0200 Subject: [PATCH 446/592] Show full link url in RRC. Allow slightly wonky urls in browser url input box. --- nomadnet/ui/textui/Browser.py | 6 +++++- nomadnet/ui/textui/Channels.py | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index d3740bf..52c98fc 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1069,7 +1069,11 @@ class Browser: def confirmed(sender): try: - self.retrieve_url(e_url.get_edit_text().strip()) + entered_url = e_url.get_edit_text().strip() + if not "`" in entered_url and ":" in entered_url: + pos = entered_url.find("|") + if pos: entered_url = entered_url[:pos]+"`"+entered_url[pos+1:] + self.retrieve_url(entered_url) except Exception as e: self.browser_footer = urwid.Text("Could not open link: "+str(e)) self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 94f244f..8668d16 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1218,9 +1218,11 @@ def _message_widget(app, hub, m, link_delegate=None): elif ms.startswith("link_"): kind = ms[len("link_"):] - label = mb.split("`")[0] + comps = mb.split("`") + label = comps[0] + fields = "|"+comps[1].replace("`", "") if len(comps) > 1 else "" url = f"{kind}://{mb}" - link_mu = f"`_`F{t['link']}`[{label}`{url}]`f`_" + link_mu = f"`_`F{t['link']}`[{label}{fields}`{url}]`f`_" link_md = f"[{label}]({url})" message_body += link_mu else: From df6245be67587629d631b0078e259f0935344d89 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Wed, 20 May 2026 22:28:32 -0400 Subject: [PATCH 447/592] Append data vars to URL in browser. Fixes for Conversation list dialog not closing after save --- nomadnet/ui/textui/Browser.py | 21 +++++++++++++++------ nomadnet/ui/textui/Conversations.py | 23 ++++++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 20c6e20..678c695 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -141,12 +141,21 @@ class Browser: def current_url(self): if self.destination_hash == None: return "" - else: - if self.path == None: - path = "" - else: - path = self.path - return RNS.hexrep(self.destination_hash, delimit=False)+":"+path + path = "" if self.path == None else self.path + url = RNS.hexrep(self.destination_hash, delimit=False)+":"+path + + if isinstance(self.request_data, dict) and self.request_data: + parts = [] + for k, v in self.request_data.items(): + if not isinstance(k, str): + continue + if not k.startswith("var_"): + continue + parts.append(k[4:]+"="+str(v)) + if parts: + url += "`" + "|".join(parts) + + return url def url_hash(self, url): if url == None: diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index d48edc6..1946042 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -809,10 +809,23 @@ class ConversationsDisplay(): self.dialog_open = True item = self.ilb.get_selected_item() if item == None: + self.dialog_open = False return - source_hash_text = item.source_hash - display_name = self.ilb.get_selected_item().display_name - if display_name == None: + source_hash_text = getattr(item, "source_hash", None) + if source_hash_text is None: + blocked = getattr(item, "blocked_dest_hash", None) + if isinstance(blocked, (bytes, bytearray)): + source_hash_text = RNS.hexrep(blocked, delimit=False) + if source_hash_text is None: + self.dialog_open = False + return + display_name = getattr(item, "display_name", None) + if display_name is None: + try: + display_name = self.app.directory.display_name(bytes.fromhex(source_hash_text)) + except Exception: + display_name = None + if display_name is None: display_name = "" e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash_text) @@ -872,8 +885,8 @@ class ConversationsDisplay(): r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected) def dismiss_dialog(sender): - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() def confirmed(sender): try: @@ -894,8 +907,8 @@ class ConversationsDisplay(): entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery, sort_rank=sort_rank, notes=notes_value) self.app.directory.remember(entry) self._refresh_open_conversation_widget(source_hash_text) - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() self.app.ui.main_display.sub_displays.network_display.directory_change_callback() except Exception as e: RNS.log("Could not save directory entry. The contained exception was: "+str(e), RNS.LOG_VERBOSE) From 4fb58b40bbc746523e3940bd0b0e48d77d554449 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Wed, 20 May 2026 18:10:19 -0400 Subject: [PATCH 448/592] Focus the selected user in the Conversations list when picking from Channels --- nomadnet/ui/textui/Channels.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 8668d16..bdac4c2 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1103,7 +1103,16 @@ class _ChatLinkDelegate: except Exception: pass conversations = self.app.ui.main_display.sub_displays.conversations_display - conversations.update_conversation_list() + try: + trust_level = self.app.directory.trust_level(bytes.fromhex(hash_hex)) + except Exception: + trust_level = DirectoryEntry.UNKNOWN + target_filter = (conversations.LIST_FILTER_TRUSTED if trust_level == DirectoryEntry.TRUSTED + else conversations.LIST_FILTER_UNTRUSTED) + if conversations.list_filter != target_filter: + conversations._set_filter(target_filter) + else: + conversations.update_conversation_list() conversations.display_conversation(None, hash_hex) self.app.ui.main_display.show_conversations(None) From 5f45dbe054b65330d487c2e0774ce853c5cd6a23 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Wed, 20 May 2026 21:40:48 -0400 Subject: [PATCH 449/592] Channels: save draft messages on room switch --- nomadnet/ui/textui/Channels.py | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 8668d16..56f4b92 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1281,6 +1281,7 @@ class ChannelsDisplay(): self.users_visible = True self.channel_list_visible = True self.collapse_join_part = False + self._room_drafts = {} self._build_listbox() self.gutter = ChannelsExpandGutter(self.app, self) @@ -1315,9 +1316,43 @@ class ChannelsDisplay(): self.app.rrc.set_message_callback(self._on_rrc_message) def _set_right_widget(self, widget): + prev = getattr(self, "right", None) + if isinstance(prev, RoomWidget) and prev is not widget: + self._save_room_draft(prev) self.right = widget self._apply_channel_list_visibility(focus_right=True) + def _draft_key(self, hub, room): + try: + return (hub.hub_hash, hub.dest_name, room) + except Exception: + return None + + def _save_room_draft(self, room_widget): + try: + key = self._draft_key(room_widget.hub, room_widget.room) + if key is None: + return + text = room_widget.editor.get_edit_text() + if text: + self._room_drafts[key] = text + else: + self._room_drafts.pop(key, None) + except Exception: + pass + + def _restore_room_draft(self, room_widget): + try: + key = self._draft_key(room_widget.hub, room_widget.room) + if key is None: + return + text = self._room_drafts.get(key) + if text: + room_widget.editor.set_edit_text(text) + room_widget.editor.set_edit_pos(len(text)) + except Exception: + pass + def toggle_channel_list(self): if self.channel_list_visible and self.right is self.placeholder: return @@ -1626,6 +1661,7 @@ class ChannelsDisplay(): widget = RoomWidget(self, hub, room) self.current_room_widget = widget self._set_right_widget(widget) + self._restore_room_draft(widget) self.columns_widget.focus_position = len(self.columns_widget.contents)-1 self.shortcuts_display = self.room_shortcuts self.app.ui.main_display.update_active_shortcuts() From 4560f038b1c278d96f93aa3f13245022da366023 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 21 May 2026 04:11:11 -0400 Subject: [PATCH 450/592] Fix Guide.py to allow for scroll_line_up and out of the reader --- nomadnet/ui/textui/Guide.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 712b2a1..4b1a71b 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -72,6 +72,22 @@ def _rows_above(attrmaps, index, cols): return total +class GuideColumns(urwid.Columns): + def keypress(self, size, key): + if key == "up" and self.focus_position == 1: + disp = getattr(self, "guide_display", None) + if disp is not None: + scrollable = getattr(disp, "_content_scrollable", None) + if scrollable is not None: + try: + if scrollable.get_scrollpos() <= 0: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + except Exception: + pass + return super().keypress(size, key) + + class GuideLinkDelegate: def __init__(self, app, reader=None): self.app = app @@ -190,13 +206,14 @@ class GuideDisplay(): self.right_area = urwid.LineBox(urwid.Filler(topic_text, urwid.TOP)) - self.columns = urwid.Columns( + self.columns = GuideColumns( [ (urwid.WEIGHT, GuideDisplay.list_width, self.left_area), (urwid.WEIGHT, 1-GuideDisplay.list_width, self.right_area) ], dividechars=0, focus_column=0 ) + self.columns.guide_display = self self.shortcuts_display = GuideDisplayShortcuts(self.app) self.widget = self.columns From 9f8939e867727558284302e27aacb7ffae66f71b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 11:42:46 +0200 Subject: [PATCH 451/592] Added micron readme --- README.mu | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 README.mu diff --git a/README.mu b/README.mu new file mode 100644 index 0000000..c6db674 --- /dev/null +++ b/README.mu @@ -0,0 +1,119 @@ +>> Nomad Network - Communicate Freely + +Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. + +Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. + +Nomad Network is build on `_`!`[LXMF`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=lxmf]`!`_ and `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_, which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. + +Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over *extremely* low-bandwidth medium, and has been succesfully used over 300bps radio links. + +If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_, which is available for Linux, Android, Windows and macOS. + +>> Notable Features + + - Encrypted messaging over packet-radio, LoRa, WiFi or anything else `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_ supports. + - Zero-configuration, minimal-infrastructure mesh communication + - Distributed and encrypted message store holds messages for offline users + - Connectable nodes that can host pages and files + - Node-side generated pages with PHP, Python, bash or others + - Built-in text-based browser for interacting with contents on nodes + - An easy to use and bandwidth efficient markup language for writing pages + - Page caching in browser + +>> How do I get started? +The easiest way to install Nomad Network is via pip: + +`B333 +`= +# Install Nomad Network and dependencies +pip install nomadnet + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +`= +`b + +If you are using an operating system that blocks normal user package installation via `pip`, you can return `pip` to normal behaviour by editing the `~/.config/pip/pip.conf` file, and adding the following directive in the `[global]` section: + +`B333 +`= +[global] +break-system-packages = true +`= +`b + +Alternatively, you can use the `pipx` tool to install Nomad Network in an isolated environment: + +`B333 +`= +# Install Nomad Network +pipx install nomadnet + +# Optionally install Reticulum utilities +pipx install rns + +# Optionally install standalone LXMF utilities +pipx install lxmf + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +`= +`b + +`!Please Note`!: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + +The first time the program is running, you will be presented with the `!Guide section`!, which contains all the information you need to start using Nomad Network. + +To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the `_`!`[Reticulum documentation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/index.md]`!`_ for info. + +If you want to try Nomad Network without building your own physical network, you can connect to the `_`!`[distributed RNS backbone`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=connect-to-the-distributed-backbone]`!`_ over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: + + - `9ce92808be498e9e05590ff27cbfdfe4` The rns.recipes forum + - `a4a5e861626ce97c9aa544d9ecdf6d22` rmap.world + +To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. + +>>> Install on Android + +You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the `_`!`[Android Installation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=reticulum-on-android]`!`_ section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. + +For a native Android application with a graphical user interface, have a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_. + +>> Help & Discussion + +For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the `_`!`[rns.recipes forum`9ce92808be498e9e05590ff27cbfdfe4]`!`_. + +>> Support Nomad Network +For this to be possible, I need your help. Please support the continued development of open, free and private communications systems by donating via one of the following channels: + +• `!Monero`! + 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w + +• `!Bitcoin`! + bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6 + +• `!Ethereum`! + 0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E + +• `!Liberapay`! + `[https://liberapay.com/Reticulum/] + +• `!Ko-Fi`! + `[https://ko-fi.com/markqvist] + +>> Caveat Emptor + +Nomad Network is experimental software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch. From 02a0a260b8b4b84658d7f50acaf01e03d2e1b8c6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 11:46:46 +0200 Subject: [PATCH 452/592] Readme formatting --- README.mu | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.mu b/README.mu index c6db674..aa7fd0d 100644 --- a/README.mu +++ b/README.mu @@ -6,7 +6,7 @@ Nomad Network allows you to build private and resilient communications platforms Nomad Network is build on `_`!`[LXMF`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=lxmf]`!`_ and `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_, which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. -Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over *extremely* low-bandwidth medium, and has been succesfully used over 300bps radio links. +Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over `*extremely`* low-bandwidth medium, and has been succesfully used over 300bps radio links. If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_, which is available for Linux, Android, Windows and macOS. @@ -40,7 +40,7 @@ nomadnet --help `= `b -If you are using an operating system that blocks normal user package installation via `pip`, you can return `pip` to normal behaviour by editing the `~/.config/pip/pip.conf` file, and adding the following directive in the `[global]` section: +If you are using an operating system that blocks normal user package installation via `B333pip`b, you can return `B333pip`b to normal behaviour by editing the `B333~/.config/pip/pip.conf`b file, and adding the following directive in the `B333[global]`b section: `B333 `= @@ -49,7 +49,7 @@ break-system-packages = true `= `b -Alternatively, you can use the `pipx` tool to install Nomad Network in an isolated environment: +Alternatively, you can use the `B333pipx`b tool to install Nomad Network in an isolated environment: `B333 `= @@ -81,10 +81,10 @@ To use Nomad Network on packet radio or LoRa, you will need to configure your Re If you want to try Nomad Network without building your own physical network, you can connect to the `_`!`[distributed RNS backbone`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=connect-to-the-distributed-backbone]`!`_ over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: - - `9ce92808be498e9e05590ff27cbfdfe4` The rns.recipes forum - - `a4a5e861626ce97c9aa544d9ecdf6d22` rmap.world + - `B3339ce92808be498e9e05590ff27cbfdfe4`b The rns.recipes forum + - `B333a4a5e861626ce97c9aa544d9ecdf6d22`b rmap.world -To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. +To browse pages on a node that is not currently known, open the URL dialog in the `!Network`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!Go`! or press enter. Nomadnet will attempt to discover and connect to the requested node. >>> Install on Android From 1a7aaa1b492fba87858d0d8365c8649566c2ae8d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 11:50:25 +0200 Subject: [PATCH 453/592] Readme formatting --- README.mu | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.mu b/README.mu index aa7fd0d..a96ad7a 100644 --- a/README.mu +++ b/README.mu @@ -22,7 +22,8 @@ If you'd rather want to use an LXMF client with a graphical user interface, you - Page caching in browser >> How do I get started? -The easiest way to install Nomad Network is via pip: + +The easiest way to install Nomad Network is via `B333pip`b: `B333 `= From 3f56567dc3301b29e461871e41ee54c1858b82bb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 11:51:54 +0200 Subject: [PATCH 454/592] Readme formatting --- README.mu | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.mu b/README.mu index a96ad7a..af77dd0 100644 --- a/README.mu +++ b/README.mu @@ -82,8 +82,8 @@ To use Nomad Network on packet radio or LoRa, you will need to configure your Re If you want to try Nomad Network without building your own physical network, you can connect to the `_`!`[distributed RNS backbone`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=connect-to-the-distributed-backbone]`!`_ over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: - - `B3339ce92808be498e9e05590ff27cbfdfe4`b The rns.recipes forum - - `B333a4a5e861626ce97c9aa544d9ecdf6d22`b rmap.world + - `!`_`[ce92808be498e9e05590ff27cbfdfe4]`_`! The rns.recipes forum + - `!`_`[`B333a4a5e861626ce97c9aa544d9ecdf6d22]`_`! rmap.world To browse pages on a node that is not currently known, open the URL dialog in the `!Network`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!Go`! or press enter. Nomadnet will attempt to discover and connect to the requested node. From 77f65828fae567aa044fca160c0a16b22ef377b8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 11:55:09 +0200 Subject: [PATCH 455/592] Readme formatting --- README.mu | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/README.mu b/README.mu index af77dd0..32ceff3 100644 --- a/README.mu +++ b/README.mu @@ -12,14 +12,15 @@ If you'd rather want to use an LXMF client with a graphical user interface, you >> Notable Features - - Encrypted messaging over packet-radio, LoRa, WiFi or anything else `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_ supports. - - Zero-configuration, minimal-infrastructure mesh communication - - Distributed and encrypted message store holds messages for offline users - - Connectable nodes that can host pages and files - - Node-side generated pages with PHP, Python, bash or others - - Built-in text-based browser for interacting with contents on nodes - - An easy to use and bandwidth efficient markup language for writing pages - - Page caching in browser + • Encrypted messaging over packet-radio, LoRa, WiFi or anything else `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_ supports. + • Zero-configuration, minimal-infrastructure mesh communication + • Distributed and encrypted message store holds messages for offline users + • Connectable nodes that can host pages and files + • Node-side generated pages with PHP, Python, bash or others + • Built-in text-based browser for interacting with contents on nodes + • Built-in RRC client for live, many-to-many chat + • An easy to use and bandwidth efficient markup language for writing pages + • Page caching in browser >> How do I get started? @@ -82,8 +83,8 @@ To use Nomad Network on packet radio or LoRa, you will need to configure your Re If you want to try Nomad Network without building your own physical network, you can connect to the `_`!`[distributed RNS backbone`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=connect-to-the-distributed-backbone]`!`_ over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: - - `!`_`[ce92808be498e9e05590ff27cbfdfe4]`_`! The rns.recipes forum - - `!`_`[`B333a4a5e861626ce97c9aa544d9ecdf6d22]`_`! rmap.world + • `!`_`[9ce92808be498e9e05590ff27cbfdfe4]`_`! The rns.recipes forum + • `!`_`[a4a5e861626ce97c9aa544d9ecdf6d22]`_`! rmap.world To browse pages on a node that is not currently known, open the URL dialog in the `!Network`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!Go`! or press enter. Nomadnet will attempt to discover and connect to the requested node. @@ -98,6 +99,7 @@ For a native Android application with a graphical user interface, have a look at For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the `_`!`[rns.recipes forum`9ce92808be498e9e05590ff27cbfdfe4]`!`_. >> Support Nomad Network + For this to be possible, I need your help. Please support the continued development of open, free and private communications systems by donating via one of the following channels: • `!Monero`! From 17807088642afc672a6e5ad6bdf772b937fabe97 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 11:56:05 +0200 Subject: [PATCH 456/592] Readme formatting --- README.mu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.mu b/README.mu index 32ceff3..332b95a 100644 --- a/README.mu +++ b/README.mu @@ -119,4 +119,4 @@ For this to be possible, I need your help. Please support the continued developm >> Caveat Emptor -Nomad Network is experimental software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch. +Nomad Network is experimental software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it `!has not`! been externally security audited, and there could very well be privacy-breaking bugs. Use at your own risk and responsibility. From ff3423a63134a9668db27e32bf1278fbd2976fa4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 12:22:16 +0200 Subject: [PATCH 457/592] Show full nick in user info dialog --- nomadnet/ui/textui/Channels.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index bdac4c2..b45445f 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -604,8 +604,9 @@ class RoomWidget(urwid.WidgetWrap): entries = [] for m in members: safe_name = _safe_name(self.hub.display_name_for(m)) + full_name = safe_name safe_name = safe_name[:15]+"…" if len(safe_name) > 16 else safe_name - entries.append((safe_name, m, own_hash is not None and m == own_hash)) + entries.append((safe_name, m, own_hash is not None and m == own_hash, full_name)) entries.sort(key=lambda x: x[0].lower()) prev_focus_key = None @@ -617,7 +618,7 @@ class RoomWidget(urwid.WidgetWrap): prev_focus_key = None rows = [urwid.Text(" "+str(len(entries))+" user"+("s" if len(entries) != 1 else ""))] - for name, peer_hash, is_self in entries: + for name, peer_hash, is_self, full_name in entries: if self.app.rrc_nick_colors: style_state = default_state() style_state["fg_color"] = get_nick_color(peer_hash, self.theme, self.app) @@ -636,7 +637,7 @@ class RoomWidget(urwid.WidgetWrap): label = " "+g["peer"]+" "+name style = "connected_status" entry = ChannelListEntry(label) - urwid.connect_signal(entry, "click", self.display.show_user_info, (self.hub, peer_hash, name)) + urwid.connect_signal(entry, "click", self.display.show_user_info, (self.hub, peer_hash, full_name)) row = urwid.AttrMap(entry, style, "list_focus") row.user_hash = peer_hash rows.append(row) From 2564b6c59acbca0aed1395fa0cd1be624f08e90c Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Sat, 16 May 2026 15:30:46 -0400 Subject: [PATCH 458/592] add manual propagation node saving --- nomadnet/ui/textui/Conversations.py | 61 +++++++++++++++++++++++++++++ nomadnet/ui/textui/Network.py | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 92b544b..f5f5b2b 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1405,11 +1405,72 @@ class ConversationsDisplay(): except Exception as e: RNS.log("Could not update propagation node: "+str(e), RNS.LOG_ERROR) + def show_set_pn_dialog(_sender): + current_pn = self.app.get_user_selected_propagation_node() + current_str = RNS.hexrep(current_pn, delimit=False) if current_pn is not None else "" + pn_edit = urwid.Edit(caption="Hash : ", edit_text=current_str) + status_text = urwid.Text("", align=urwid.CENTER) + + def reopen_sync(_b=None): + self.sync_conversations() + + def save_pn(_b): + text = pn_edit.get_edit_text().strip().replace(":", "").replace(" ", "") + expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8 + if text == "": + self.app.set_user_selected_propagation_node(None) + else: + try: + node_hash = bytes.fromhex(text) + except ValueError: + status_text.set_text("Invalid hex") + return + if len(node_hash) != expected_len: + status_text.set_text("Must be "+str(expected_len)+" bytes ("+str(expected_len*2)+" hex chars)") + return + self.app.set_user_selected_propagation_node(node_hash) + reopen_sync() + + def clear_pn(_b): + pn_edit.set_edit_text("") + self.app.set_user_selected_propagation_node(None) + reopen_sync() + + inner = DialogLineBox( + urwid.Pile([ + urwid.Text("Enter an LXMF propagation\ndestination hash as hex.", align=urwid.CENTER), + urwid.Divider(), + pn_edit, + urwid.Divider(), + status_text, + urwid.Columns([ + (urwid.WEIGHT, 0.3, urwid.Button("Save", on_press=save_pn)), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, urwid.Button("Clear", on_press=clear_pn)), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, urwid.Button("Close", on_press=reopen_sync)), + ]) + ]), title="Set Propagation Node", + ) + inner.delegate = self + self.sync_dialog = None + overlay = urwid.Overlay( + inner, self.listbox, + align=urwid.CENTER, width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, height=urwid.PACK, + left=2, right=2, + ) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + set_pn_button = urwid.Button("Custom Node...", on_press=show_set_pn_dialog) + if pn_options: node_picker = PropNodePicker(pn_options, selected_target, on_pn_picked) node_selector = urwid.Pile([ urwid.Text("Propagation node:"), node_picker, + set_pn_button, ]) else: node_selector = None diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 9997619..c2e3150 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1339,7 +1339,7 @@ class LocalPeer(urwid.WidgetWrap): (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), (urwid.WEIGHT, 0.1, urwid.Text("")), (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), - ]) + ]), ] ) From fa7ce67eab38d58543ca53230ddf20427c40e8f1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 14:06:15 +0200 Subject: [PATCH 459/592] Fixed guide focus handling --- nomadnet/ui/textui/Guide.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index dfe3e12..b82245b 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -74,17 +74,17 @@ def _rows_above(attrmaps, index, cols): class GuideColumns(urwid.Columns): def keypress(self, size, key): - if key == "up" and self.focus_position == 1: - disp = getattr(self, "guide_display", None) - if disp is not None: - scrollable = getattr(disp, "_content_scrollable", None) - if scrollable is not None: - try: - if scrollable.get_scrollpos() <= 0: - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" - return None - except Exception: - pass + # TODO: Scroll handling is currently very weird + # in the guide reader. When using page up/down + # or mouse scroll, the actual cursor position + # does not follow the scroll, as it does in the + # browser. If you scroll down, and then use the + # arrow keys, the position jumps back up to + # where you "left" the cursor. + # There's handling for this in the Browser code + # but I don't have time to port it over here at + # this point. If someone else takes that one up + # it's :) :) :) return super().keypress(size, key) @@ -98,7 +98,7 @@ class GuideLinkDelegate: pass def micron_released_focus(self): - pass + self.reader.focus_topics() def handle_link(self, target, fields=None): if not target: From 329368fa27fc980a3da2d9cca6d5f437b4b46f38 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 16:28:12 +0200 Subject: [PATCH 460/592] Sanitize names in join/part messages --- nomadnet/ui/textui/Channels.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 3b2e3b1..a40ca5c 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1178,6 +1178,7 @@ def _message_widget(app, hub, m, link_delegate=None): if m.kind == "system": evt_icon = g["arrow_l"] if m.text.endswith(" left") else g["arrow_r"] spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick) + if m.text.endswith(" left") or m.text.endswith(" joined"): spans = [(s[0], sanitize_name(s[1])) for s in spans] markup = [_ts_prefix(m.ts), ("irc_system", evt_icon+" ")] + spans final_widget = _wrap_text(markup, link_delegate if has_links else None) final_widget.msg = m From 6e544c288da28b6b4e00408c090b06fcc7a816e1 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Thu, 21 May 2026 08:53:23 -0400 Subject: [PATCH 461/592] RRC: pin new message notification order to buffer-append order to fix scroll sometimes not moving down with latest message --- nomadnet/RRC.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 4aafc85..c9c26a3 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -773,9 +773,9 @@ class RRCHub: self.unread_rooms.add(msg.room) if msg.mention: self.mention_rooms.add(msg.room) + self.manager._notify_messages(self, msg) self._append_history(msg.room, msg) self._clean_history() - self.manager._notify_messages(self, msg) def _record_system(self, room, text): if not room: @@ -787,9 +787,9 @@ class RRCHub: buf.append(msg) if cap is not None and len(buf) > cap: del buf[:len(buf)-cap] + self.manager._notify_messages(self, msg) self._append_history(room, msg) self._clean_history() - self.manager._notify_messages(self, msg) def _record_notice(self, msg): target_room = msg.room @@ -810,10 +810,10 @@ class RRCHub: del buf[:len(buf)-cap] if target_room != self.manager.active_room_for(self): self.unread_rooms.add(target_room) + self.manager._notify_messages(self, msg) if target_room: self._append_history(target_room, msg) self._clean_history() - self.manager._notify_messages(self, msg) def get_messages(self, room): with self._lock: From d3e5e11e27b80adf1dbacf9fa9e9c395deac0a60 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Thu, 21 May 2026 11:36:53 -0400 Subject: [PATCH 462/592] Channels: more window scroll fixes --- nomadnet/ui/textui/Channels.py | 52 ++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index a40ca5c..d7b8101 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -514,6 +514,43 @@ class RoomFrame(urwid.Frame): return super(RoomFrame, self).keypress(size, key) +class _StickyMessageListBox(IndicativeListBox): + # Tracks whether the user is scrolled to the bottom and re-asserts that + # position on resize. urwid.ListBox stores an inset computed at the prior + # size, so without re-asserting, the focused widget can render only partly + # visible at narrower widths. + def __init__(self, body, **kwargs): + self.sticky_bottom = True + self._last_render_size = None + super().__init__(body, on_selection_change=self._track_sticky, **kwargs) + + def _track_sticky(self, old_pos, new_pos): + if new_pos is None: + self.sticky_bottom = True + else: + try: + self.sticky_bottom = (new_pos == self.rearmost_position()) + except Exception: + pass + + def render(self, size, focus=False): + if (self._last_render_size is not None + and self._last_render_size != size + and self.sticky_bottom): + try: + inner_body = self._listbox.body + if len(inner_body) > 0: + self._listbox.set_focus(len(inner_body)-1) + self._listbox.set_focus_valign("bottom") + except Exception: + pass + self._last_render_size = size + canvas = super().render(size, focus=focus) + if getattr(self, "bottom_is_visible", False): + self.sticky_bottom = True + return canvas + + class RoomWidget(urwid.WidgetWrap): USERS_PANE_WIDTH = 22 @@ -708,7 +745,7 @@ class RoomWidget(urwid.WidgetWrap): else: self._empty_placeholder = False - self.messagelist = IndicativeListBox(widgets, position=len(widgets)-1) + self.messagelist = _StickyMessageListBox(widgets, position=len(widgets)-1) self.messagelist.name = "messagelist" try: self.messagelist._listbox.set_focus_valign("bottom") @@ -727,11 +764,13 @@ class RoomWidget(urwid.WidgetWrap): widget = _message_widget(self.app, self.hub, msg, link_delegate=self.link_delegate) wrapped = urwid.AttrMap(widget, None) body = self.messagelist.get_body() - was_at_bottom = getattr(self, "_empty_placeholder", False) or getattr(self.messagelist, "bottom_is_visible", True) + was_at_bottom = (self.messagelist.sticky_bottom + or getattr(self.messagelist, "bottom_is_visible", True)) + self.messagelist.sticky_bottom = was_at_bottom if getattr(self, "_empty_placeholder", False): del body[:] self._empty_placeholder = False - + if self.hub.clean_last_removed > self.last_history_clean: try: hub_msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] @@ -748,7 +787,7 @@ class RoomWidget(urwid.WidgetWrap): if msg and not msg in hub_msgs: old.add(i) for i in reversed(list(old)): self.messagelist.delete_position(i) - + except Exception as e: RNS.log("Error while cleaning room history", RNS.LOG_ERROR) RNS.trace_exception(e) @@ -778,6 +817,7 @@ class RoomWidget(urwid.WidgetWrap): if len(body) > 0: self.messagelist._listbox.set_focus(len(body)-1) self.messagelist._listbox.set_focus_valign("bottom") + self.messagelist.sticky_bottom = True except Exception: pass @@ -785,6 +825,7 @@ class RoomWidget(urwid.WidgetWrap): text = self.editor.get_edit_text() if not text.strip(): return + self.messagelist.sticky_bottom = True if text.lstrip().startswith("/"): self._handle_slash_command(text.lstrip()) self.editor.set_edit_text("") @@ -825,6 +866,7 @@ class RoomWidget(urwid.WidgetWrap): def send_split(sender): try: + self.messagelist.sticky_bottom = True for p in parts: self.hub.send_message(self.room, p) self.editor.set_edit_text("") @@ -1263,7 +1305,7 @@ def _message_widget(app, hub, m, link_delegate=None): rendered = _render_body(f"{prefix_micron}{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) if app.rrc_ui_space_msgs: rendered.append(urwid.Text("")) final_widget = urwid.Padding(urwid.Pile(rendered), left=1) - + final_widget.msg = m return final_widget From 66f72ead0f74d814f5b02a2897d6971ff767a706 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 22:12:09 +0200 Subject: [PATCH 463/592] Hack for link span and micron handling --- nomadnet/ui/textui/Channels.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index d7b8101..9ea54b0 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1204,6 +1204,7 @@ def get_nick_color(sender_hash, theme, app, shift=15): return nick_colors[(int.from_bytes(sender_hash)+shift)%len(nick_colors)] room_nick_src_cache = {} +invalid_span_starts = [">", "#"] mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) def _message_widget(app, hub, m, link_delegate=None): t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light @@ -1279,14 +1280,25 @@ def _message_widget(app, hub, m, link_delegate=None): link_md = f"[{label}]({url})" message_body += link_mu else: + # This is a hack for now to avoid start-of-span + # being interpreted as start-of-line by the micron + # parser, because the link-detection logic splits + # up lines into spans for link detection. Since + # We're now using the micron engine, we could + # probably avoid this alltogether. But it needs + # another kind of link handling, and I'm not going + # to write that right now, so little hack it is. + if mb[0:1] in invalid_span_starts: yanked = mb[0:1]; mb = mb[1:] + else: yanked = "" + if app.rrc_ui_render_micron: mbo = mdc.format_block(mb) if app.rrc_ui_render_markdown else mb mbo = unescape_micron(mbo) - message_body += strip_non_formatting_tags(mbo) + message_body += yanked+strip_non_formatting_tags(mbo) else: mbo = mdc.format_block(strip_escaped_micron(mb)) if app.rrc_ui_render_markdown else strip_escaped_micron(mb) - message_body += strip_non_formatting_tags(mbo) + message_body += yanked+strip_non_formatting_tags(mbo) if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t, app)}" else: nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" From f8c57e675e6f7aa9a811c65d21fbe873f42736b0 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 22:55:26 +0200 Subject: [PATCH 464/592] Fixed anchors not updating scroll position --- nomadnet/ui/textui/Browser.py | 1 + nomadnet/ui/textui/Guide.py | 29 ++++++++++++++++------------- nomadnet/vendor/Scrollable.py | 5 ++++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 7cc4a65..a08d8e6 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -342,6 +342,7 @@ class Browser: try: scrollable = self.browser_body.original_widget.original_widget + scrollable.anchor_cursor_update = True scrollable.set_scrollpos(self._rows_above(int(target_idx), cols)) except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index b82245b..a738288 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -227,6 +227,7 @@ class GuideDisplay(): pile = urwid.Pile(new_content) self._content_attrmaps = new_content self._content_scrollable = Scrollable(pile) + pile.automove_cursor_on_scroll = True content = urwid.LineBox(urwid.AttrMap(ScrollBar(self._content_scrollable, thumb_char="\u2503", trough_char=" "), "scrollbar")) self.columns.contents[1] = (content, options) @@ -255,6 +256,8 @@ class GuideDisplay(): return row_offset = _rows_above(attrmaps, target_idx, cols) try: + scrollable.force_cursor_update = True + scrollable.anchor_cursor_update = True scrollable.set_scrollpos(int(row_offset)) except Exception: pass @@ -1346,19 +1349,19 @@ With micron you can easily create structured documents and pages with formatting >Table of Contents - `F44f`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f - `F44f`_`[Micron Tags`#micron-tags]`_`f - `F44f`_`[High Level Stuff`#high-level-stuff]`_`f - `F44f`_`[Colors`#colors]`_`f - `F44f`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f - `F44f`_`[Links`#links]`_`f - `F44f`_`[Anchors`#anchors]`_`f - `F44f`_`[Tables`#tables]`_`f - `F44f`_`[Fields & Requests`#fields-requests]`_`f - `F44f`_`[Comments`#comments]`_`f - `F44f`_`[Partials`#partials]`_`f - `F44f`_`[Literals`#literals]`_`f - `F44f`_`[Closing Remarks`#closing-remarks]`_`f + `F79d`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f + `F79d`_`[Micron Tags`#micron-tags]`_`f + `F79d`_`[High Level Stuff`#high-level-stuff]`_`f + `F79d`_`[Colors`#colors]`_`f + `F79d`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f + `F79d`_`[Links`#links]`_`f + `F79d`_`[Anchors`#anchors]`_`f + `F79d`_`[Tables`#tables]`_`f + `F79d`_`[Fields & Requests`#fields-requests]`_`f + `F79d`_`[Comments`#comments]`_`f + `F79d`_`[Partials`#partials]`_`f + `F79d`_`[Literals`#literals]`_`f + `F79d`_`[Closing Remarks`#closing-remarks]`_`f >>Recommendations and Requirements diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index 7accf8f..dc130d4 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -53,6 +53,7 @@ class Scrollable(urwid.WidgetDecoration): self._rows_max_cached = 0 self.force_forward_keypress = force_forward_keypress self.force_cursor_update = False + self.anchor_cursor_update = False self.cursor_update_forced_at = 0 super().__init__(widget) @@ -108,9 +109,11 @@ class Scrollable(urwid.WidgetDecoration): # Trimmed canvas contains the cursor, e.g. in an Edit widget self._forward_keypress = True else: - force_check = self.force_cursor_update and (time.time()-self.cursor_update_forced_at < 0.25) + force_check = self.force_cursor_update and (time.time()-self.cursor_update_forced_at < 0.25) + force_check |= self.anchor_cursor_update if canv_full.cursor is not None or force_check: self.force_cursor_update = False + self.anchor_cursor_update = False # Full canvas contains the cursor, but scrolled out of view self._forward_keypress = False From cf25ed8a64c05eb628cfe5029c63358ebfa371e8 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Thu, 21 May 2026 16:16:06 -0400 Subject: [PATCH 465/592] Browser/Conversations: add ability to copy browser URL and conversation message text --- nomadnet/ui/TextUI.py | 1 + nomadnet/ui/textui/Browser.py | 41 +++++++++++++++++++++++++- nomadnet/ui/textui/Conversations.py | 45 ++++++++++++++++++++++++++--- nomadnet/ui/textui/Helpers.py | 31 ++++++++++++++++++++ nomadnet/ui/textui/Network.py | 2 +- 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index eb4d2ae..15abd7b 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -168,6 +168,7 @@ GLYPHS = { ("image", "[I]", "\u25a3", "\uf1c5"), ("audio", "[~]", "\u266b", "\uf1c7"), ("pin", "*", "\u2605", "\uf08d"), + ("copy", "[C]", "\u29c9", "\uf0c5"), } class TextUI: diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index a08d8e6..46e11f2 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -14,6 +14,7 @@ from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.Scrollable import * from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name +from .Helpers import ClickableIcon, osc52_copy class BrowserFrame(urwid.Frame): def keypress(self, size, key): @@ -33,6 +34,8 @@ class BrowserFrame(urwid.Frame): self.delegate.save_node_dialog() elif key == "ctrl g": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen() + elif key == "ctrl y": + self.delegate.copy_url() elif self.focus_position == "body": if key == "down" or key == "up": try: @@ -488,7 +491,15 @@ class Browser: return urwid.AttrMap(widget, "browser_controls") def make_control_widget(self): - return urwid.AttrMap(urwid.Pile([urwid.Text(self.g["node"]+" "+self.current_url()), urwid.Divider(self.g["divider1"])]), "browser_controls") + url_text = urwid.Text(self.g["node"]+" "+self.current_url()) + copy_glyph = self.g.get("copy", "[C]") + copy_icon = ClickableIcon(copy_glyph, on_click=lambda: self.copy_url()) + copy_width = len(copy_glyph) + 2 + header_row = urwid.Columns([ + ("weight", 1, url_text), + (copy_width, urwid.Padding(copy_icon, left=1, right=1)), + ]) + return urwid.AttrMap(urwid.Pile([header_row, urwid.Divider(self.g["divider1"])]), "browser_controls") def make_request_failed_widget(self): def back_action(sender): @@ -1067,6 +1078,34 @@ class Browser: self.uncache_page(self.current_url()) self.load_page() + def copy_url(self): + url = self.current_url() + if not url: + return + + if not osc52_copy(url): + return + + try: + notice = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Copied URL to clipboard")]), "browser_controls") + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + notice.set_attr_map({None: style_name}) + self.browser_footer = notice + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + def restore_footer(loop, user_data): + if self.status == Browser.DONE: + self.browser_footer = self.make_status_widget() + else: + self.browser_footer = urwid.Text("") + if self.frame is not None: + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + self.app.ui.loop.set_alarm_in(2.0, restore_footer) + except Exception as e: + RNS.log("Could not update footer after URL copy: "+str(e), RNS.LOG_ERROR) + def close_dialogs(self): options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) self.delegate.columns.contents[1] = (self.display_widget, options) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index f5f5b2b..291c7f0 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -20,6 +20,7 @@ from nomadnet.util import sanitize_name from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps +from .Helpers import ClickableIcon, osc52_copy from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags from nomadnet.ui import THEME_DARK, THEME_LIGHT @@ -2235,7 +2236,7 @@ class ConversationWidget(urwid.WidgetWrap): added_hashes.append(message_hash) was_loaded = message.loaded try: - message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"]) + message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"], conversation_widget=self) except Exception as e: RNS.log("Skipping message loading for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_WARNING) message.unload() @@ -2550,9 +2551,10 @@ class ConversationWidget(urwid.WidgetWrap): class LXMessageWidget(urwid.WidgetWrap): mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) - def __init__(self, message, theme=THEME_DARK): + def __init__(self, message, theme=THEME_DARK, conversation_widget=None): app = nomadnet.NomadNetworkApp.get_shared_instance() g = app.ui.glyphs + self._conversation_widget = conversation_widget self.timestamp = message.get_timestamp() self.sort_timestamp = message.sort_timestamp self.transfer_done = False @@ -2629,12 +2631,47 @@ class LXMessageWidget(urwid.WidgetWrap): attachment_strings.append(g[atype if atype != "file" else "file"]+" "+aname) title_string += " | " + " ".join(attachment_strings) - title = urwid.AttrMap(urwid.Text(title_string), header_style) + content_text = message.get_content() + + if content_text: + copy_glyph = g.get("copy", "[C]") + check_glyph = g.get("check", "v").center(len(copy_glyph)) + copy_icon = ClickableIcon(copy_glyph) + + conv_widget = self._conversation_widget + def on_copy_click(icon=copy_icon, content=content_text, cg=copy_glyph, kg=check_glyph, cw=conv_widget): + osc52_copy(content) + icon.set_text(kg) + def _restore(loop, user_data): + icon.set_text(cg) + try: + app.ui.loop.set_alarm_in(2.0, _restore) + except Exception: + icon.set_text(cg) + if cw is not None and cw.frame is not None: + def _refocus(loop, user_data): + try: + cw.frame.focus_position = "footer" + except Exception: + pass + try: + app.ui.loop.set_alarm_in(0, _refocus) + except Exception: + pass + copy_icon._on_click = on_copy_click + + copy_width = len(copy_glyph) + 2 + title_row = urwid.Columns([ + ("weight", 1, urwid.Text(title_string)), + (copy_width, urwid.Padding(copy_icon, left=1, right=1)), + ]) + title = urwid.AttrMap(title_row, header_style) + else: + title = urwid.AttrMap(urwid.Text(title_string), header_style) self.progress_widget = urwid.Text("") self.progress_attr = urwid.AttrMap(self.progress_widget, "progress_full") - content_text = message.get_content() content_lines = content_text.split("\n") markdown = renderer == LXMF.RENDERER_MARKDOWN diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py index e69de29..ec0816e 100644 --- a/nomadnet/ui/textui/Helpers.py +++ b/nomadnet/ui/textui/Helpers.py @@ -0,0 +1,31 @@ +import sys +import base64 +import urwid +import RNS + + +def osc52_copy(text): + if not text: + return False + try: + encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") + sys.stdout.write("\x1b]52;c;" + encoded + "\x07") + sys.stdout.flush() + return True + except Exception as e: + RNS.log("Could not emit clipboard escape sequence: "+str(e), RNS.LOG_ERROR) + return False + + +class ClickableIcon(urwid.Text): + _selectable = False + + def __init__(self, text, on_click=None): + super().__init__(text) + self._on_click = on_click + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and "press" in event and self._on_click is not None: + self._on_click() + return True + return False diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index c2e3150..17130e2 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -16,7 +16,7 @@ class NetworkDisplayShortcuts(): self.app = app g = app.ui.glyphs - self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-g] Fullscreen [C-s / C-b] Save Node"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-y] Copy [C-g] Fullscreen [C-s / C-b] Save Node"), "shortcutbar") class DialogLineBox(urwid.LineBox): def keypress(self, size, key): From 7de0fa9a8f5f8a4753710ceecf778a49fec4980d Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Thu, 21 May 2026 17:22:19 -0400 Subject: [PATCH 466/592] Browser: allow ctrl-k to clear the URL edit field --- nomadnet/ui/textui/Browser.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 46e11f2..6b6e500 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1811,5 +1811,8 @@ class UrlEdit(urwid.Edit): def keypress(self, size, key): if key == "enter": self.confirmed(self) + elif key == "ctrl k": + self.set_edit_text("") + self.set_edit_pos(0) else: return super(UrlEdit, self).keypress(size, key) From fa205354bc18c37eb3c3a119e35a64fb1a37ad03 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Thu, 21 May 2026 17:30:05 -0400 Subject: [PATCH 467/592] Browser: fix for manual URL getting truncated by 1 char when | not in URL --- nomadnet/ui/textui/Browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 6b6e500..f87dc23 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1121,7 +1121,7 @@ class Browser: entered_url = e_url.get_edit_text().strip() if not "`" in entered_url and ":" in entered_url: pos = entered_url.find("|") - if pos: entered_url = entered_url[:pos]+"`"+entered_url[pos+1:] + if pos > 0: entered_url = entered_url[:pos]+"`"+entered_url[pos+1:] self.retrieve_url(entered_url) except Exception as e: self.browser_footer = urwid.Text("Could not open link: "+str(e)) From ab369eef70fc008b2ddbe019328c5897b16008c6 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 21 May 2026 23:56:59 +0200 Subject: [PATCH 468/592] Fixed incorrect anchor jump position calculation --- nomadnet/ui/textui/Browser.py | 30 +++++++++++++++++++----------- nomadnet/ui/textui/Guide.py | 13 +++++-------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index a08d8e6..911e920 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -340,29 +340,37 @@ class Browser: if target_idx is None: return + row_offset = self._rows_above(int(target_idx), cols) + try: scrollable = self.browser_body.original_widget.original_widget scrollable.anchor_cursor_update = True - scrollable.set_scrollpos(self._rows_above(int(target_idx), cols)) - except Exception as e: - RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) + scrollable.set_scrollpos(row_offset) + + except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) def _content_cols(self): try: - cols = self.app.ui.loop.screen.get_cols_rows()[0] + lw = self.delegate.given_list_width + screen_cols = self.app.ui.loop.screen.get_cols_rows()[0] + browser_cols = screen_cols-lw-2 + cols = browser_cols + except Exception: + RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING) + RNS.trace_exception(e) cols = 100 - return max(40, cols - 3) + + return max(40, cols) def _rows_above(self, index, cols): - if index <= 0 or not self.attr_maps: - return 0 + if index <= 0 or not self.attr_maps: return 0 + total = 0 for i in range(min(index, len(self.attr_maps))): - try: - total += self.attr_maps[i].rows((cols,)) - except Exception: - total += 1 + try: total += self.attr_maps[i].rows((cols,)) + except Exception: total += 1 + return total def handle_lxmf_link(self, link_target): diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index a738288..319af2c 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -263,16 +263,13 @@ class GuideDisplay(): pass def _content_cols(self): - try: - cols = self.app.ui.loop.screen.get_cols_rows()[0] - except Exception: - cols = 100 + try: cols = self.app.ui.loop.screen.get_cols_rows()[0] + except Exception: cols = 100 + try: widths = self.columns.column_widths((cols,)) - if len(widths) > 1 and widths[1] > 0: - return max(40, widths[1] - 3) - except Exception: - pass + if len(widths) > 1 and widths[1] > 0: return max(40, widths[1] - 3) + except Exception: pass return max(40, int(cols * (1 - GuideDisplay.list_width)) - 3) def shortcuts(self): From 85fe1dc49425679aba6617a88b24bb92a90fb4a7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 00:40:24 +0200 Subject: [PATCH 469/592] Added url truncation based on browser width to header and footer --- nomadnet/ui/textui/Browser.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 9964177..29649ad 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -192,9 +192,10 @@ class Browser: if can_display: self.link_status_showing = True lt_str = str(link_target) - lmax = 128 - if len(lt_str) > lmax: lt_str = lt_str[:lmax]+"…" - self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to "+lt_str)]), "browser_controls") + lmax = self._content_cols() + lstr = "Link to "+lt_str + if len(lstr) > lmax: lstr = lstr[:lmax-1]+"…" + self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text(lstr)]), "browser_controls") self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) if self.page_background_color != None or self.page_foreground_color != None: style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) @@ -499,8 +500,11 @@ class Browser: return urwid.AttrMap(widget, "browser_controls") def make_control_widget(self): - url_text = urwid.Text(self.g["node"]+" "+self.current_url()) + lstr = self.g["node"]+" "+self.current_url() copy_glyph = self.g.get("copy", "[C]") + lmax = self._content_cols()-len(copy_glyph)-len(copy_glyph)-1 + if len(lstr) > lmax: lstr = lstr[:lmax-1]+"…" + url_text = urwid.Text(lstr) copy_icon = ClickableIcon(copy_glyph, on_click=lambda: self.copy_url()) copy_width = len(copy_glyph) + 2 header_row = urwid.Columns([ From be80565340f9263994ec20f7b2c0bf6fdd166afb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 00:57:10 +0200 Subject: [PATCH 470/592] Fixed conversation dialogs hanging on close --- nomadnet/ui/textui/Conversations.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 291c7f0..ed7944a 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -558,8 +558,8 @@ class ConversationsDisplay(): source_hash = item.source_hash def dismiss_dialog(sender): - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() def confirmed(sender): self.dialog_open = False @@ -765,8 +765,8 @@ class ConversationsDisplay(): return def cancel_block(_b): - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() def confirm_block(_b): try: @@ -778,8 +778,8 @@ class ConversationsDisplay(): nomadnet.Conversation.delete_conversation(source_hash_text, self.app) except Exception: pass - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() try: who = self.app.directory.simplest_display_str(dest) @@ -1027,8 +1027,8 @@ class ConversationsDisplay(): r_trusted = urwid.RadioButton(trust_button_group, "Trusted") def dismiss_dialog(sender): - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() def confirmed(sender): try: @@ -1056,6 +1056,7 @@ class ConversationsDisplay(): self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED) self.display_conversation(source_hash_text) self.dialog_open = False + self.update_conversation_list() except Exception as e: RNS.log("Could not start conversation. The contained exception was: "+str(e), RNS.LOG_VERBOSE) @@ -1112,8 +1113,8 @@ class ConversationsDisplay(): e_uri = urwid.Edit(caption="URI : ",edit_text=lxm_uri) def dismiss_dialog(sender): - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() def confirmed(sender): try: From 475c0ee2a0388cf8470e7f1e90d5decb67b579ea Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 01:14:29 +0200 Subject: [PATCH 471/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 01cdc05..5cf4f71 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.9", "lxmf>=0.9.9", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.3.0", "lxmf>=0.9.9", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From e492cfbce0bf279c4da675845011991f364603ce Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Thu, 21 May 2026 21:24:15 -0400 Subject: [PATCH 472/592] make clipboard copy an opt-in feature --- nomadnet/NomadNetworkApp.py | 22 ++++++++++++++++------ nomadnet/ui/textui/Browser.py | 26 +++++++++++++++++--------- nomadnet/ui/textui/Conversations.py | 2 +- nomadnet/ui/textui/Network.py | 6 +++++- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 31154a0..2df897a 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -936,6 +936,11 @@ class NomadNetworkApp: else: self.config["textui"]["sanitize_names"] = self.config["textui"].as_bool("sanitize_names") + if not "clipboard_copy" in self.config["textui"]: + self.config["textui"]["clipboard_copy"] = False + else: + self.config["textui"]["clipboard_copy"] = self.config["textui"].as_bool("clipboard_copy") + if not "animation_interval" in self.config["textui"]: self.config["textui"]["animation_interval"] = 1 else: @@ -980,12 +985,12 @@ class NomadNetworkApp: except Exception: value = None if value is not None and value >= 0: self.rrc_history_per_room_cap = value - + if option == "filter_loaded_history": try: value = self.config["rrc"].as_bool(option) except Exception: value = True self.rrc_filter_loaded_history = value - + if option == "ephemeral_notices": try: value = self.config["rrc"].as_float(option) except Exception: value = 0 @@ -995,12 +1000,12 @@ class NomadNetworkApp: try: value = self.config["rrc"].as_bool(option) except Exception: value = True self.rrc_ui_justify_msgs = value - + if option == "space_msgs": try: value = self.config["rrc"].as_bool(option) except Exception: value = False self.rrc_ui_space_msgs = value - + if option == "nick_colors": try: value = self.config["rrc"].as_bool(option) except Exception: value = True @@ -1024,12 +1029,12 @@ class NomadNetworkApp: try: value = self.config["rrc"].as_bool(option) except Exception: value = True self.rrc_ui_render_markdown = value - + if option == "render_micron": try: value = self.config["rrc"].as_bool(option) except Exception: value = True self.rrc_ui_render_micron = value - + if option == "show_gutters": try: value = self.config["rrc"].as_bool(option) except Exception: value = False @@ -1350,6 +1355,11 @@ hide_guide = no # announces. sanitize_names = yes +# You can enable clipboard features. This +# relies on your terminal supporting OSC52 +# escape sequences. +clipboard_copy = no + [rrc] # Maximum number of messages retained per diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 29649ad..29becb8 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -35,7 +35,8 @@ class BrowserFrame(urwid.Frame): elif key == "ctrl g": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen() elif key == "ctrl y": - self.delegate.copy_url() + if self.delegate.app.config["textui"]["clipboard_copy"]: + self.delegate.copy_url() elif self.focus_position == "body": if key == "down" or key == "up": try: @@ -500,17 +501,24 @@ class Browser: return urwid.AttrMap(widget, "browser_controls") def make_control_widget(self): - lstr = self.g["node"]+" "+self.current_url() + clipboard_copy_enabled = self.app.config["textui"]["clipboard_copy"] copy_glyph = self.g.get("copy", "[C]") - lmax = self._content_cols()-len(copy_glyph)-len(copy_glyph)-1 + lstr = self.g["node"]+" "+self.current_url() + if clipboard_copy_enabled: + lmax = self._content_cols()-len(copy_glyph)-len(copy_glyph)-1 + else: + lmax = self._content_cols()-1 if len(lstr) > lmax: lstr = lstr[:lmax-1]+"…" url_text = urwid.Text(lstr) - copy_icon = ClickableIcon(copy_glyph, on_click=lambda: self.copy_url()) - copy_width = len(copy_glyph) + 2 - header_row = urwid.Columns([ - ("weight", 1, url_text), - (copy_width, urwid.Padding(copy_icon, left=1, right=1)), - ]) + if clipboard_copy_enabled: + copy_icon = ClickableIcon(copy_glyph, on_click=lambda: self.copy_url()) + copy_width = len(copy_glyph) + 2 + header_row = urwid.Columns([ + ("weight", 1, url_text), + (copy_width, urwid.Padding(copy_icon, left=1, right=1)), + ]) + else: + header_row = url_text return urwid.AttrMap(urwid.Pile([header_row, urwid.Divider(self.g["divider1"])]), "browser_controls") def make_request_failed_widget(self): diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index ed7944a..7900e98 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -2634,7 +2634,7 @@ class LXMessageWidget(urwid.WidgetWrap): content_text = message.get_content() - if content_text: + if content_text and app.config["textui"]["clipboard_copy"]: copy_glyph = g.get("copy", "[C]") check_glyph = g.get("check", "v").center(len(copy_glyph)) copy_icon = ClickableIcon(copy_glyph) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 17130e2..1e06007 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -16,7 +16,11 @@ class NetworkDisplayShortcuts(): self.app = app g = app.ui.glyphs - self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-y] Copy [C-g] Fullscreen [C-s / C-b] Save Node"), "shortcutbar") + shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL " + if app.config["textui"]["clipboard_copy"]: + shortcut_text += "[C-y] Copy " + shortcut_text += "[C-g] Fullscreen [C-s / C-b] Save Node" + self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") class DialogLineBox(urwid.LineBox): def keypress(self, size, key): From a1b1aea83de1aeec360314bfcd25e7f431b6301a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 11:33:13 +0200 Subject: [PATCH 473/592] Slight robustification of theme-based style selection --- nomadnet/ui/textui/MicronParser.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 90a3ad7..f584be2 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -36,6 +36,8 @@ INDENT_RIGHT = 1 MAX_TABLE_WIDTH = 100 def default_state(fg=None, bg=None): + global SELECTED_STYLES + ensure_selected_styles() if fg == None: fg = SELECTED_STYLES["plain"]["fg"] if bg == None: bg = DEFAULT_BG state = { @@ -82,6 +84,11 @@ class _AttrMapList(list): """list subclass that allows attaching parser metadata like `anchors`.""" pass +def ensure_selected_styles(): + global SELECTED_STYLES + theme = nomadnet.NomadNetworkApp.get_shared_instance().config["textui"]["theme"] + if theme == nomadnet.ui.TextUI.THEME_DARK: SELECTED_STYLES = STYLES_DARK + else: SELECTED_STYLES = STYLES_LIGHT def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None): global LINK_CLASS @@ -89,10 +96,7 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None else: LINK_CLASS = LinkableText global SELECTED_STYLES - if nomadnet.NomadNetworkApp.get_shared_instance().config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK: - SELECTED_STYLES = STYLES_DARK - else: - SELECTED_STYLES = STYLES_LIGHT + ensure_selected_styles() attrmaps = _AttrMapList() From d661ba053183dfd1c9d3350bc2a8a7d1a70a2ecf Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 11:48:36 +0200 Subject: [PATCH 474/592] Run UI-side message cleaning with hub lock --- nomadnet/RRC.py | 8 +++++--- nomadnet/ui/textui/Channels.py | 27 ++++++++++++++------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index c9c26a3..04e212e 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -815,9 +815,11 @@ class RRCHub: self._append_history(target_room, msg) self._clean_history() - def get_messages(self, room): - with self._lock: - buf = list(self.messages.get(room, [])) + def get_messages(self, room, take_lock=True): + if take_lock: + with self._lock: buf = list(self.messages.get(room, [])) + else: buf = list(self.messages.get(room, [])) + return buf def _on_packet(self, data): diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 9ea54b0..2bdb882 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -773,20 +773,21 @@ class RoomWidget(urwid.WidgetWrap): if self.hub.clean_last_removed > self.last_history_clean: try: - hub_msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] - self.last_history_clean = time.time() - c = self.messagelist.body_len() - old = set() - for i in range(0, c): - msg = None - w = self.messagelist.get_item(i) - if hasattr(w, "_original_widget"): o = w._original_widget - else: o = None - if hasattr(o, "msg"): msg = o.msg - elif hasattr(w, "msg"): msg = w.msg - if msg and not msg in hub_msgs: old.add(i) + with self.hub._lock: + hub_msgs = self.hub.get_messages(self.room, take_lock=False) if (self.hub is not None and self.room is not None) else [] + self.last_history_clean = time.time() + c = self.messagelist.body_len() + old = set() + for i in range(0, c): + msg = None + w = self.messagelist.get_item(i) + if hasattr(w, "_original_widget"): o = w._original_widget + else: o = None + if hasattr(o, "msg"): msg = o.msg + elif hasattr(w, "msg"): msg = w.msg + if msg and not msg in hub_msgs: old.add(i) - for i in reversed(list(old)): self.messagelist.delete_position(i) + for i in reversed(list(old)): self.messagelist.delete_position(i) except Exception as e: RNS.log("Error while cleaning room history", RNS.LOG_ERROR) From 820f3455a1fb4bd90c75fde4a08d85d41e7a80ac Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 13:43:10 +0200 Subject: [PATCH 475/592] Fixed expired message cleaning sometimes targeting invalid indices --- nomadnet/ui/textui/Channels.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 2bdb882..ae4f19c 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -785,9 +785,12 @@ class RoomWidget(urwid.WidgetWrap): else: o = None if hasattr(o, "msg"): msg = o.msg elif hasattr(w, "msg"): msg = w.msg - if msg and not msg in hub_msgs: old.add(i) + if msg and not msg in hub_msgs: old.add(w) - for i in reversed(list(old)): self.messagelist.delete_position(i) + list_body = self.messagelist.get_body() + for w in list(old): + try: list_body.remove(w) + except: RNS.log(f"Could not remove expired message widget {w}: {e}", RNS.LOG_DEBUG) except Exception as e: RNS.log("Error while cleaning room history", RNS.LOG_ERROR) From 2375a92f26502c1c79e9404451db6271fad658a7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 13:54:33 +0200 Subject: [PATCH 476/592] Added clipboard_copy option to guide --- nomadnet/ui/textui/Guide.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 319af2c..9315fe0 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1052,6 +1052,12 @@ Determines whether the program should react to mouse/touch input. Must be a bool This option allows sanitizing announced names before they are displayed in the user interface. < +>>> +`!clipboard_copy = no`! +>>>> +If your terminal supports it, you can enable copy-to-clipboard functionality using OSC52 escape sequences. +< + >>> `!hide_guide = no`! >>>> From 6446debb524d505d80a9f0949403458c097e4b47 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Fri, 22 May 2026 12:05:15 -0400 Subject: [PATCH 477/592] /me added support for rrcd ACTIONs and advisory K_NICK on join/part --- nomadnet/RRC.py | 77 +++++++++++++++++++++++++++++++++- nomadnet/ui/textui/Channels.py | 22 +++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 04e212e..6a58845 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -57,6 +57,7 @@ T_PARTED = 13 T_MSG = 20 T_NOTICE = 21 +T_ACTION = 22 T_PING = 30 T_PONG = 31 @@ -81,6 +82,7 @@ L_MAX_ROOMS_PER_SESSION = 3 L_RATE_LIMIT_MSGS_PER_MINUTE= 4 CAP_RESOURCE_ENVELOPE = 0 +CAP_ACTION = 1 B_RES_ID = 0 B_RES_KIND = 1 @@ -216,6 +218,7 @@ class RRCHub: self.welcomed = False self.hub_name = None self.hub_version = None + self.hub_caps = {} self.motd = None self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES @@ -440,7 +443,10 @@ class RRCHub: body = { B_HELLO_NAME: "nomadnet", B_HELLO_VER: "0.1", - B_HELLO_CAPS: {CAP_RESOURCE_ENVELOPE: True}, + B_HELLO_CAPS: { + CAP_RESOURCE_ENVELOPE: True, + CAP_ACTION: True, + }, } env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body) nick = self.get_effective_nick() @@ -626,6 +632,23 @@ class RRCHub: self._record_message(RRCMessage("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) return mid + def send_action(self, room, text): + r = self._normalize_room(room) + if not isinstance(text, str) or not text.strip(): + raise ValueError("action text must be non-empty") + if len(text.encode("utf-8")) > self.max_msg_body_bytes: + raise ValueError("action too long for hub limit") + env = _make_envelope(T_ACTION, src=self.manager.identity.hash, room=r, body=text) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + mid = env[K_ID] + if isinstance(mid, (bytes, bytearray)): + self._sent_ids.append(bytes(mid)) + self._send_env(env) + self._record_message(RRCMessage("action", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) + return mid + def _per_room_cap(self): try: v = int(getattr(self.manager.app, "rrc_history_per_room_cap", 0)) @@ -865,6 +888,9 @@ class RRCHub: ver = body.get(B_WELCOME_VER) if isinstance(ver, str): self.hub_version = ver + caps = body.get(B_WELCOME_CAPS) + if isinstance(caps, dict): + self.hub_caps = dict(caps) limits = body.get(B_WELCOME_LIMITS) if isinstance(limits, dict): if L_MAX_NICK_BYTES in limits: @@ -897,6 +923,7 @@ class RRCHub: if isinstance(room, str) and room: r = room.strip().lower() body = env.get(K_BODY) + joiner_nick = env.get(K_NICK) own_hash = self.manager.identity.hash if self.manager.identity is not None else None body_hashes = [] @@ -920,6 +947,13 @@ class RRCHub: if own_hash is not None: members.add(own_hash) + # rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it + # so display_name_for() can render the nick instead of a hash prefix. + if (not self_join) and isinstance(joiner_nick, str) and joiner_nick and len(body_hashes) == 1: + jh = body_hashes[0] + if own_hash is None or jh != own_hash: + self.nicks[jh] = joiner_nick + if self_join: if not silent: self._record_system(r, "You joined #"+r) @@ -946,6 +980,7 @@ class RRCHub: if isinstance(room, str) and room: r = room.strip().lower() body = env.get(K_BODY) + parter_nick = env.get(K_NICK) own_hash = self.manager.identity.hash if self.manager.identity is not None else None body_hashes = [] @@ -957,6 +992,13 @@ class RRCHub: if self_part: self._pending_parts.discard(r) + # rrcd 0.3.2 attaches advisory K_NICK to fanout PARTED; learn it + # before forgetting the member so " left" renders correctly. + if (not self_part) and isinstance(parter_nick, str) and parter_nick and len(body_hashes) == 1: + ph = body_hashes[0] + if own_hash is None or ph != own_hash: + self.nicks[ph] = parter_nick + members = self.members.get(r) if members is not None: for h in body_hashes: @@ -1009,6 +1051,39 @@ class RRCHub: self._record_message(msg) return + if t == T_ACTION: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + nick = env.get(K_NICK) + mid = env.get(K_ID) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash: + if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids: + return + if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick: + with self._lock: + self.nicks[bytes(src)] = nick + if isinstance(room, str) and room: + self.members.setdefault(room.strip().lower(), set()).add(bytes(src)) + if isinstance(body, str): + msg = RRCMessage( + "action", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + nick if isinstance(nick, str) else None, + body, + _now_ms(), + ) + is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + if not is_own: + own_nick = self.get_effective_nick() + pat = _mention_re(own_nick) + if pat is not None and pat.search(body): + msg.mention = True + self._record_message(msg) + return + if t == T_NOTICE: body = env.get(K_BODY) room = env.get(K_ROOM) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index ae4f19c..f9a99f2 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -916,6 +916,7 @@ class RoomWidget(urwid.WidgetWrap): "/join - join a room on this hub", "/part [room] - leave a room (default: current)", "/leave [room] - alias for /part", + "/me - send an action (e.g. /me waves)", "/nick - set your nick on this hub only", "/who [room] - list users (current room if omitted)", "/names [room] - alias for /who", @@ -1015,6 +1016,22 @@ class RoomWidget(urwid.WidgetWrap): self._local_message("error", "Part failed: "+str(e)) return + if cmd == "me": + if not self._require_connected(): + return + if not arg: + self._local_message("error", "Usage: /me ") + return + limit = self.hub.max_msg_body_bytes or 350 + if len(arg.encode("utf-8")) > limit: + self._local_message("error", "Action too long (max "+str(limit)+" bytes)") + return + try: + self.hub.send_action(self.room, arg) + except Exception as e: + self._local_message("error", "/me failed: "+str(e)) + return + if cmd == "nick": if not arg: cur = self.hub.get_effective_nick() or " unset" @@ -1309,7 +1326,10 @@ def _message_widget(app, hub, m, link_delegate=None): irc_ts = f"`F{t['ts']}" prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" - nick_micron = f"`f{nick_attr}<{sender}>`f " + if m.kind == "action": + nick_micron = f"`f{nick_attr}* {sender}`f " + else: + nick_micron = f"`f{nick_attr}<{sender}>`f " if app.rrc_ui_justify_msgs: prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"]) body_rendered = _render_body(f"{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) From f8f875df9d8e045959e490488931abf1fd4d610a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 19:55:49 +0200 Subject: [PATCH 478/592] Experimental actions formatting --- nomadnet/ui/textui/Channels.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index f9a99f2..7933892 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1327,12 +1327,16 @@ def _message_widget(app, hub, m, link_delegate=None): prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" if m.kind == "action": - nick_micron = f"`f{nick_attr}* {sender}`f " + nick_micron = f" `*`f{nick_attr}{sender}`f`* " + body_format = f"`*" + message_body = strip_micron(message_body) else: nick_micron = f"`f{nick_attr}<{sender}>`f " + body_format = f"" + if app.rrc_ui_justify_msgs: prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"]) - body_rendered = _render_body(f"{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) + body_rendered = _render_body(f"{nick_micron}{body_format}{message_body}{body_format}", link_delegate=ld, fg=t["text"]) if app.rrc_ui_space_msgs: body_rendered.append(urwid.Text("")) columns = urwid.Columns([(urwid.PACK, urwid.Pile(prefix_rendered)), urwid.Pile(body_rendered)], dividechars=1) final_widget = urwid.Padding(columns, left=1) From 8d14eb2d7bde8ec0183f0b91e83c5fd30dfc1456 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 20:04:33 +0200 Subject: [PATCH 479/592] Experimental actions formatting --- nomadnet/ui/textui/Channels.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 7933892..e73ff33 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1328,15 +1328,13 @@ def _message_widget(app, hub, m, link_delegate=None): prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" if m.kind == "action": nick_micron = f" `*`f{nick_attr}{sender}`f`* " - body_format = f"`*" - message_body = strip_micron(message_body) + message_body = f"`*`f{nick_attr}{strip_micron(message_body)}`f`*" else: nick_micron = f"`f{nick_attr}<{sender}>`f " - body_format = f"" if app.rrc_ui_justify_msgs: prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"]) - body_rendered = _render_body(f"{nick_micron}{body_format}{message_body}{body_format}", link_delegate=ld, fg=t["text"]) + body_rendered = _render_body(f"{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) if app.rrc_ui_space_msgs: body_rendered.append(urwid.Text("")) columns = urwid.Columns([(urwid.PACK, urwid.Pile(prefix_rendered)), urwid.Pile(body_rendered)], dividechars=1) final_widget = urwid.Padding(columns, left=1) From 7fe7a54db18ad2c8960187309d0b4911de8c3a25 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 22:12:08 +0200 Subject: [PATCH 480/592] Map mentions to nick colors --- nomadnet/ui/textui/Channels.py | 49 +++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index e73ff33..25bf587 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -127,10 +127,18 @@ def _scan_mentions(text, own_nick): for m in pat.finditer(text): yield m.start(), m.end(), "mention", None +def _scan_nick_mentions(text, own_nick): + if not own_nick or not text: return + pat = re.compile(r"(?", "#"] mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) -def _message_widget(app, hub, m, link_delegate=None): +def _message_widget(app, hub, room, m, link_delegate=None): t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light g = app.ui.glyphs own_nick = None @@ -1274,11 +1299,12 @@ def _message_widget(app, hub, m, link_delegate=None): else: sender = "?" if isinstance(m.src, (bytes, bytearray)): - room_nick_src_cache[m.nick] = m.src + if not str(room) in room_nick_src_cache: room_nick_src_cache[str(room)] = {} + room_nick_src_cache[str(room)][str(m.nick)] = m.src nick_attr = "irc_nick_self" if own else "irc_nick_peer" body = m.text or "" - spans, has_links = _body_markup(body, body_attr="body_text", own_nick=None if own else own_nick, check_links=False) + spans, has_links = _body_markup(body, body_attr="body_text", own_nick=own_nick, check_links=False) ld = link_delegate if has_links else None message_body = "" @@ -1288,9 +1314,19 @@ def _message_widget(app, hub, m, link_delegate=None): if ms.startswith("irc_mention"): if not app.rrc_nick_colors: message_body += f"`!`F{t['mention']}{mb}`f`!" else: - try: message_body += f"`!`FT{get_nick_color(room_nick_src_cache[m.nick], t, app)}{mb}`f`!" + try: message_body += f"`!`FT{get_nick_color(app.identity.hash, t, app)}{mb}`f`!" except: message_body += f"`!`F{t['mention']}{mb}`f`!" + elif ms.startswith("nick_mention"): + if not app.rrc_nick_colors: message_body += f"{mb}" + else: + mentioned_nick = mb[1:] + nick_src = get_nick_src(hub, room, mentioned_nick) + if not nick_src: message_body += f"{mb}" + else: + nick_color = get_nick_color(nick_src, t, app) + message_body += f"`!`FT{nick_color}{mb}`f`!" + elif ms.startswith("link_"): kind = ms[len("link_"):] comps = mb.split("`") @@ -1300,6 +1336,7 @@ def _message_widget(app, hub, m, link_delegate=None): link_mu = f"`_`F{t['link']}`[{label}{fields}`{url}]`f`_" link_md = f"[{label}]({url})" message_body += link_mu + else: # This is a hack for now to avoid start-of-span # being interpreted as start-of-line by the micron From 69f17fe3c48c015928d0fb1d7b11476c88634682 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 22:33:15 +0200 Subject: [PATCH 481/592] Changelog: Added option to color mention timestamps --- nomadnet/NomadNetworkApp.py | 7 +++++++ nomadnet/ui/textui/Channels.py | 15 +++++++++++---- nomadnet/ui/textui/Guide.py | 6 ++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 2df897a..8fb63e0 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -158,6 +158,7 @@ class NomadNetworkApp: self.rrc_ephemeral_notices = 600 self.rrc_nick_colors = True self.rrc_nick_colors_theme = None + self.rrc_color_mention_timestamps = True self.rrc_ui_justify_msgs = True self.rrc_ui_space_msgs = False self.rrc_ui_render_markdown = True @@ -1011,6 +1012,11 @@ class NomadNetworkApp: except Exception: value = True self.rrc_nick_colors = value + if option == "color_mention_timestamps": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_color_mention_timestamps = value + if option == "nick_colors_theme": try: colors = self.config["rrc"].as_list(option) @@ -1385,6 +1391,7 @@ filter_loaded_history = yes ephemeral_notices = 10 # Other display and formatting options: +color_mention_timestamps = yes render_markdown = yes render_micron = yes nick_colors = yes diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 25bf587..63b05a1 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1307,14 +1307,22 @@ def _message_widget(app, hub, room, m, link_delegate=None): spans, has_links = _body_markup(body, body_attr="body_text", own_nick=own_nick, check_links=False) ld = link_delegate if has_links else None + irc_ts = f"`F{t['ts']}" message_body = "" for span in spans: ms = span[0] mb = span[1] if ms.startswith("irc_mention"): - if not app.rrc_nick_colors: message_body += f"`!`F{t['mention']}{mb}`f`!" + if not app.rrc_nick_colors: + message_body += f"`!`F{t['mention']}{mb}`f`!" + if app.rrc_color_mention_timestamps: irc_ts = f"`F{t['mention']}" + else: - try: message_body += f"`!`FT{get_nick_color(app.identity.hash, t, app)}{mb}`f`!" + try: + own_nick_color = get_nick_color(app.identity.hash, t, app) + message_body += f"`!`FT{own_nick_color}{mb}`f`!" + if app.rrc_color_mention_timestamps: irc_ts = f"`FT{own_nick_color}" + except: message_body += f"`!`F{t['mention']}{mb}`f`!" elif ms.startswith("nick_mention"): @@ -1360,9 +1368,8 @@ def _message_widget(app, hub, room, m, link_delegate=None): if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t, app)}" else: nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" - irc_ts = f"`F{t['ts']}" - prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" + prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}`f" if m.kind == "action": nick_micron = f" `*`f{nick_attr}{sender}`f`* " message_body = f"`*`f{nick_attr}{strip_micron(message_body)}`f`*" diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 9315fe0..4790f0e 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1000,6 +1000,12 @@ Whether or not to render micron formatting in messages. When using micron in mes Whether or not to render RRC nicks in distinct colors based on identity hash. < +>>> +`!color_mention_timestamps = yes`! +>>>> +Whether or not to color timestamps of messages you were mentioned in. +< + >>> `!justify_msgs = yes`! `!space_msgs = no`! From 5752e7012473bbb4b3f3445f733b65488851fac4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 22:44:49 +0200 Subject: [PATCH 482/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index c68196d..a955fda 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.2.1" From 814cd043b810115a977f2446aa7629e1a4303a05 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 22:57:53 +0200 Subject: [PATCH 483/592] Ensure important future options availability --- nomadnet/NomadNetworkApp.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 8fb63e0..428af97 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -164,6 +164,7 @@ class NomadNetworkApp: self.rrc_ui_render_markdown = True self.rrc_ui_render_micron = True self.rrc_show_gutters = False + self.rrc_enable_esoterics = False if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -1046,6 +1047,11 @@ class NomadNetworkApp: except Exception: value = False self.rrc_show_gutters = value + if option == "enable_esoterics": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_enable_esoterics = value + if "node" in self.config: if not "enable_node" in self.config["node"]: self.enable_node = False From 859aaeeba9bd656fac2407184eb55d0264eb0bfb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 23:10:33 +0200 Subject: [PATCH 484/592] Changelog: Added option to set specific mention color --- nomadnet/NomadNetworkApp.py | 12 ++++++++++++ nomadnet/ui/textui/Channels.py | 7 +++++-- nomadnet/ui/textui/Guide.py | 6 ++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 428af97..522a77c 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -158,6 +158,7 @@ class NomadNetworkApp: self.rrc_ephemeral_notices = 600 self.rrc_nick_colors = True self.rrc_nick_colors_theme = None + self.rrc_mention_color = None self.rrc_color_mention_timestamps = True self.rrc_ui_justify_msgs = True self.rrc_ui_space_msgs = False @@ -1013,6 +1014,13 @@ class NomadNetworkApp: except Exception: value = True self.rrc_nick_colors = value + if option == "mention_color": + value = self.config["rrc"][option] + if len(value) == 6: + try: bytes.fromhex(value) + except Exception: value = None + self.rrc_mention_color = value + if option == "color_mention_timestamps": try: value = self.config["rrc"].as_bool(option) except Exception: value = True @@ -1409,6 +1417,10 @@ show_gutters = yes # for nick color assignment # nick_colors_theme = f68787, 00c394, d59e00, ... +# You can set a specific color for mentions +# of your nick +# mention_color = FFBB44 + [node] diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 63b05a1..6a02747 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1314,12 +1314,15 @@ def _message_widget(app, hub, room, m, link_delegate=None): mb = span[1] if ms.startswith("irc_mention"): if not app.rrc_nick_colors: - message_body += f"`!`F{t['mention']}{mb}`f`!" + if app.rrc_mention_color: mention_color = f"`FT{app.rrc_mention_color}" + else: mention_color = f"`F{t['mention']}" + message_body += f"`!{mention_color}{mb}`f`!" if app.rrc_color_mention_timestamps: irc_ts = f"`F{t['mention']}" else: try: - own_nick_color = get_nick_color(app.identity.hash, t, app) + if not app.rrc_mention_color: own_nick_color = get_nick_color(app.identity.hash, t, app) + else: own_nick_color = app.rrc_mention_color message_body += f"`!`FT{own_nick_color}{mb}`f`!" if app.rrc_color_mention_timestamps: irc_ts = f"`FT{own_nick_color}" diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 4790f0e..6f58f94 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1000,6 +1000,12 @@ Whether or not to render micron formatting in messages. When using micron in mes Whether or not to render RRC nicks in distinct colors based on identity hash. < +>>> +`!mention_color = None`! +>>>> +Sets a specific color for mentions of your nick, for example FFBB44. +< + >>> `!color_mention_timestamps = yes`! >>>> From c2af9f80d11545663532f94168d32aa203a05ff9 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 23:31:19 +0200 Subject: [PATCH 485/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5cf4f71..8a65c40 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.3.0", "lxmf>=0.9.9", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.3.1", "lxmf>=0.9.9", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From b6d2ebbfc789c2ce24b0d0ae6191e58e25bb4eb4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 22 May 2026 23:34:06 +0200 Subject: [PATCH 486/592] Updated makefile --- Makefile | 5 +++++ setup.py | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0970114..5626de8 100644 --- a/Makefile +++ b/Makefile @@ -21,5 +21,10 @@ build_wheel: release: remove_symlinks build_wheel create_symlinks upload: + @echo Ready to publish release over Reticulum + @read VOID + rngit release rns://7649a50d84610232d1416b41d2896aff/reticulum/nomadnet create $$(python setup.py --getversion):dist --name nomadnet + +upload-pip: @echo Uploading to PyPi... twine upload dist/*.whl dist/*.tar.gz diff --git a/setup.py b/setup.py index 8a65c40..b187f29 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ - +import sys import setuptools exec(open("nomadnet/_version.py", "r").read()) @@ -6,6 +6,10 @@ exec(open("nomadnet/_version.py", "r").read()) with open("README.md", "r") as fh: long_description = fh.read() +if "--getversion" in sys.argv: + print(__version__, end="") + exit(0) + package_data = { "": [ "examples/messageboard/*", From 8d6cf7bc54ed426286b64ec8512de24494eb1245 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 23 May 2026 00:18:13 +0200 Subject: [PATCH 487/592] Handle invalid page cache time headers --- nomadnet/ui/textui/Browser.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 29becb8..1cf899b 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1513,10 +1513,12 @@ class Browser: # header tags are added. cache_time = Browser.DEFAULT_CACHE_TIME if self.markup[:4] == "#!c=": - endpos = self.markup.find("\n") - if endpos == -1: - endpos = len(self.markup) - cache_time = int(self.markup[4:endpos]) + try: + endpos = self.markup.find("\n") + if endpos == -1: + endpos = len(self.markup) + cache_time = int(self.markup[4:endpos]) + except Exception as e: RNS.log(f"Invalid page cache time header: {e}", RNS.LOG_DEBUG) self.update_display() From 9977e310f86488cf988d3eec78b59b589c85fa0c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 24 May 2026 21:24:56 +0200 Subject: [PATCH 488/592] Fixed missing byteorder spec in nick color conversion for old snakes --- nomadnet/ui/textui/Channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 6a02747..3fe6d7b 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1232,7 +1232,7 @@ def get_nick_color(sender_hash, theme, app, shift=15): try: sender_hash = sender_hash.encode("utf-8") except: pass if not type(sender_hash) == bytes: return theme["nick_peer"] - return nick_colors[(int.from_bytes(sender_hash)+shift)%len(nick_colors)] + return nick_colors[(int.from_bytes(sender_hash, "big")+shift)%len(nick_colors)] room_nick_src_cache = {} def get_nick_src(hub, room, nick): From 944fc15a2b5d3cb33476eb0d9ad6faa42ef0d445 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 24 May 2026 21:26:20 +0200 Subject: [PATCH 489/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index a955fda..bc86c94 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.2.2" From 2f86d06c25cb023e82e45c6b055fc3af1b3b80e9 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Sat, 23 May 2026 22:09:50 -0400 Subject: [PATCH 490/592] Channels: prevent the channels list from permanently hiding after hiding it and leaving a room --- nomadnet/ui/textui/Channels.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 3fe6d7b..3c980df 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1458,7 +1458,17 @@ class ChannelsDisplay(): if isinstance(prev, RoomWidget) and prev is not widget: self._save_room_draft(prev) self.right = widget - self._apply_channel_list_visibility(focus_right=True) + if widget is self.placeholder: + # The placeholder has nothing to interact with, so always keep the + # hub/channel pane visible (and focused) beside it. + self.channel_list_visible = True + self._apply_channel_list_visibility(focus_right=False) + try: + self.columns_widget.focus_position = 0 + except Exception: + pass + else: + self._apply_channel_list_visibility(focus_right=True) def _draft_key(self, hub, room): try: From ace2969770ebded4cea2d6d23d9823c3641f0965 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Sat, 23 May 2026 21:09:33 -0400 Subject: [PATCH 491/592] fix mouse focus issue in indicative_listbox --- .../widgets/indicative_listbox.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py index 9797319..c0a95b0 100644 --- a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py @@ -257,10 +257,15 @@ class IndicativeListBox(urwid.WidgetWrap): # An event is changed to a modified one ('mouse press' => 'ctrl mouse press'). This prevents the widget from responding # when mouse buttons are also used to navigate between widgets. if event == self._modifier_key.prepend_to("mouse press"): - # Store the focus position before passing the input to the contained list box. That way, it can be compared with the + # Store the focus position before passing the input to the contained list box. That way, it can be compared with the # position after the input is processed. If the list box body is empty, store None. focus_position_before_input = self.get_selected_position() - + + # Also remember the currently selected item itself. A button 1 press makes the contained list box move the focus, but + # the off focus highlighting is only (un)done during 'render()' while the widget has the focus. When the selection is + # changed by mouse while the widget is unfocused, the highlight would otherwise stay stuck on the previous item. + item_before_input = self.get_selected_item() + # left mouse button, if not top bar or bottom bar. if (button == 1.0) and (topBar_rows <= row < (size[1] - bottomBar_rows)): # Because 'row' includes the top bar, the offset must be substracted before passing it to the contained list box. @@ -278,11 +283,20 @@ class IndicativeListBox(urwid.WidgetWrap): was_handeled = self._pass_key_to_contained_listbox(modified_size, "down") focus_position_after_input = self.get_selected_position() - - # If the focus position has changed, execute the hook (if existing). - if (focus_position_before_input != focus_position_after_input) and (self.on_selection_change is not None): - self.on_selection_change(focus_position_before_input, - focus_position_after_input) + + if focus_position_before_input != focus_position_after_input: + # The mouse moved the selection. If off focus highlighting is currently applied (the widget is unfocused), restore + # the previously selected item and clear the stored state, so the next 'render()' re-applies the highlight to the + # newly selected item instead of leaving it stuck on the old one. + if (not self._last_focus_state) and (self._original_item_attr_map is not None) and (item_before_input is not None): + item_before_input.set_attr_map(self._original_item_attr_map) + self._original_item_attr_map = None + self._last_focus_state = None + + # If the focus position has changed, execute the hook (if existing). + if self.on_selection_change is not None: + self.on_selection_change(focus_position_before_input, + focus_position_after_input) return was_handeled From 9e7f912f97d4d396ee0d10bf6ca40239b985461d Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Sat, 23 May 2026 21:19:59 -0400 Subject: [PATCH 492/592] conversation load speedups: use a set instead of a list in update_message_widgets() and cache our ConversationMessages after load --- nomadnet/Conversation.py | 38 ++++++++++++++++++++--------- nomadnet/ui/textui/Conversations.py | 4 +-- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 54f5ac3..2802f9a 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -39,11 +39,11 @@ class Conversation: dn = LXMF.display_name_from_app_data(app_data) app_data = b"" if dn != None: app_data = dn.encode("utf-8") - + # Add the announce to the directory announce # stream logger app.directory.lxmf_announce_received(destination_hash, app_data) - + else: RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) @@ -345,7 +345,7 @@ class Conversation: print_result = self.app.print_file(qr_tmp_path) os.unlink(qr_tmp_path) - + if print_result: message_path = Conversation.ingest(lxm, self.app, originator=True) self.messages.append(ConversationMessage(message_path)) @@ -411,12 +411,18 @@ class Conversation: class ConversationMessage: + # Sentinel marking the renderer as not yet cached, so that a real + # cached value of None (no renderer field) can be told apart from a + # cache miss without forcing a disk load. + _RENDERER_UNSET = object() + def __init__(self, file_path): self.file_path = file_path self.loaded = False self.timestamp = None self.lxm = None + self._cached_renderer = ConversationMessage._RENDERER_UNSET self._cached_hash = None self._cached_state = None self._cached_title = None @@ -472,6 +478,14 @@ class ConversationMessage: if self._cached_signature_validated is None: self._cached_signature_validated = self.lxm.signature_validated self._cached_method = self.lxm.method + if hasattr(self.lxm, "get_fields"): + _fields = self.lxm.get_fields() + if _fields and isinstance(_fields, dict) and LXMF.FIELD_RENDERER in _fields: + self._cached_renderer = _fields[LXMF.FIELD_RENDERER] + else: + self._cached_renderer = None + else: + self._cached_renderer = None if self._cached_unverified_reason is None and hasattr(self.lxm, "unverified_reason"): self._cached_unverified_reason = self.lxm.unverified_reason self._cached_title = self.lxm.title_as_string() @@ -629,14 +643,14 @@ class ConversationMessage: return {} def content_renderer(self): - if not self.loaded: self.load() - if self.lxm and hasattr(self.lxm, "get_fields"): - fields = self.lxm.get_fields() - if fields and isinstance(fields, dict): - if LXMF.FIELD_RENDERER in fields: - return fields[LXMF.FIELD_RENDERER] - - return None + if self._cached_renderer is not ConversationMessage._RENDERER_UNSET: + return self._cached_renderer + if not self.loaded: + self.load() + # load() sets _cached_renderer on success; if it failed, lxm is None + if self._cached_renderer is ConversationMessage._RENDERER_UNSET: + return None + return self._cached_renderer def has_attachments(self): if self._cached_has_attachments is not None: @@ -942,6 +956,7 @@ class ConversationMessage: "signature_validated": self._cached_signature_validated, "unverified_reason": self._cached_unverified_reason, "method": self._cached_method, + "renderer": None if self._cached_renderer is ConversationMessage._RENDERER_UNSET else self._cached_renderer, "has_attachments": self._cached_has_attachments, "attachment_names": self._cached_attachment_names, } @@ -958,6 +973,7 @@ class ConversationMessage: self._cached_signature_validated = entry.get("signature_validated") self._cached_unverified_reason = entry.get("unverified_reason") self._cached_method = entry.get("method") + self._cached_renderer = entry.get("renderer", ConversationMessage._RENDERER_UNSET) self._cached_has_attachments = entry.get("has_attachments") self._cached_attachment_names = entry.get("attachment_names") diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 7900e98..13f33b3 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -2229,12 +2229,12 @@ class ConversationWidget(urwid.WidgetWrap): def update_message_widgets(self, replace = False): self.message_widgets = [] - added_hashes = [] + added_hashes = set() needs_index = [] for message in self.conversation.messages: message_hash = message.get_hash() if not message_hash in added_hashes: - added_hashes.append(message_hash) + added_hashes.add(message_hash) was_loaded = message.loaded try: message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"], conversation_widget=self) From 3586a8ce6a3e2482e4598f17e3e3535debc1e8a9 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Fri, 22 May 2026 20:17:47 -0400 Subject: [PATCH 493/592] Add readline shortcuts globally --- nomadnet/ui/textui/Browser.py | 6 +- nomadnet/ui/textui/Channels.py | 93 ++++++----- nomadnet/ui/textui/Conversations.py | 70 +++++--- nomadnet/ui/textui/Guide.py | 98 +++++++++--- nomadnet/ui/textui/Interfaces.py | 5 +- nomadnet/ui/textui/MicronParser.py | 3 +- nomadnet/ui/textui/Network.py | 9 +- nomadnet/ui/textui/ReadlineEdit.py | 150 ++++++++++++++++++ .../additional_urwid_widgets/FormWidgets.py | 16 +- 9 files changed, 345 insertions(+), 105 deletions(-) create mode 100644 nomadnet/ui/textui/ReadlineEdit.py diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 1cf899b..c7630c1 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -15,6 +15,7 @@ from nomadnet.vendor.Scrollable import * from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name from .Helpers import ClickableIcon, osc52_copy +from .ReadlineEdit import ReadlineMixin, ReadlineEdit class BrowserFrame(urwid.Frame): def keypress(self, size, key): @@ -1829,12 +1830,9 @@ class UrlDialogLineBox(urwid.LineBox): else: return super(UrlDialogLineBox, self).keypress(size, key) -class UrlEdit(urwid.Edit): +class UrlEdit(ReadlineMixin, urwid.Edit): def keypress(self, size, key): if key == "enter": self.confirmed(self) - elif key == "ctrl k": - self.set_edit_text("") - self.set_edit_pos(0) else: return super(UrlEdit, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 3fe6d7b..4389177 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -13,6 +13,7 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps, default_state, make_style +from .ReadlineEdit import ReadlineMixin, ReadlineEdit from nomadnet.util import sanitize_name, strip_modifiers, strip_micron from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags from nomadnet.vendor.Scrollable import Scrollable, ScrollBar @@ -191,13 +192,19 @@ def _format_ts(ts_ms): class ChannelsListShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove [C-y] Toggle Channels"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar") class ChannelsRoomShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-w] Leave [C-k] Clear [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] Focus"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-x] Leave [F8] Collapse [Tab] Complete Nick"), "shortcutbar") + + +class ChannelsRoomBodyShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-x] Leave [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] ↓ Editor"), "shortcutbar") class ChannelsDialogLineBox(urwid.LineBox): @@ -381,7 +388,7 @@ class HubInfoArea(urwid.LineBox): return super(HubInfoArea, self).keypress(size, key) -class RoomMessageEdit(urwid.Edit): +class RoomMessageEdit(ReadlineMixin, urwid.Edit): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tab_state = None @@ -394,14 +401,8 @@ class RoomMessageEdit(urwid.Edit): self._tab_state = None if key == "ctrl d": self.delegate.send_message() - elif key == "ctrl k": - self.set_edit_text("") - elif key == "ctrl w": + elif key == "ctrl x": self.delegate.leave_room() - elif key == "ctrl u": - self.delegate.toggle_users() - elif key == "ctrl y": - self.delegate.display.toggle_channel_list() elif key == "f8": self.delegate.display.toggle_join_part_collapse() elif key == "up": @@ -484,34 +485,37 @@ class RoomMessageEdit(urwid.Edit): class RoomFrame(urwid.Frame): + @property + def focus_position(self): + return urwid.Frame.focus_position.fget(self) + + @focus_position.setter + def focus_position(self, part): + urwid.Frame.focus_position.fset(self, part) + try: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts() + except Exception: + pass + def keypress(self, size, key): - if key == "ctrl u": - self.delegate.toggle_users() - return None - if key == "ctrl y": - self.delegate.display.toggle_channel_list() - return None - if key == "f8": - self.delegate.display.toggle_join_part_collapse() - return None - if key == "tab": - rw = self.delegate - users_focusable = ( - rw is not None - and getattr(rw, "users_visible", False) - and len(rw.columns.contents) > 1 - and rw.columns.contents[1][0] is rw.users_box - ) - if self.focus_position == "body": + if key in ("ctrl u", "ctrl x", "ctrl y", "f8", "tab"): + result = super(RoomFrame, self).keypress(size, key) + if result != key: + return result + if key == "ctrl u": + self.delegate.toggle_users() + return None + if key == "ctrl x": + self.delegate.leave_room() + return None + if key == "ctrl y": + self.delegate.display.toggle_channel_list() + return None + if key == "f8": + self.delegate.display.toggle_join_part_collapse() + return None + if self.focus_position != "footer": self.focus_position = "footer" - elif users_focusable: - try: - rw.columns.focus_position = 1 - return None - except Exception: - self.focus_position = "body" - else: - self.focus_position = "body" return None elif self.focus_position == "body": if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible: @@ -1427,6 +1431,7 @@ class ChannelsDisplay(): self.list_shortcuts = ChannelsListShortcuts(self.app) self.room_shortcuts = ChannelsRoomShortcuts(self.app) + self.room_body_shortcuts = ChannelsRoomBodyShortcuts(self.app) self.shortcuts_display = self.list_shortcuts self.placeholder = urwid.LineBox(urwid.Filler(urwid.Text("\n Select or add a hub to begin", align=urwid.CENTER), "top")) @@ -1539,6 +1544,12 @@ class ChannelsDisplay(): except Exception: focus_path = None if focus_path and focus_path[0] == 1 and self.current_room_widget is not None: + try: + frame = self.current_room_widget.frame + if frame is not None and frame.focus_position == "body": + return self.room_body_shortcuts + except Exception: + pass return self.room_shortcuts return self.list_shortcuts @@ -1876,8 +1887,8 @@ class ChannelsDisplay(): self._show_dialog_overlay(dialog) def new_hub_dialog(self): - e_hash = urwid.Edit(caption="Hub address : ", edit_text="") - e_name = urwid.Edit(caption="Display name: ", edit_text="") + e_hash = ReadlineEdit(caption="Hub address : ", edit_text="") + e_name = ReadlineEdit(caption="Display name: ", edit_text="") error_text = urwid.Text("") def dismiss(sender): @@ -1965,7 +1976,7 @@ class ChannelsDisplay(): return hub = item.hub - e_name = urwid.Edit(caption="Display name : ", edit_text=hub.name or "") + e_name = ReadlineEdit(caption="Display name : ", edit_text=hub.name or "") cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect) cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list) cb_autowho = urwid.CheckBox("Auto-fetch members on room join", state=hub.auto_who) @@ -2022,8 +2033,8 @@ class ChannelsDisplay(): else: return - e_room = urwid.Edit(caption="Room : #", edit_text="") - e_key = urwid.Edit(caption="Key : ", edit_text="", mask="*") + e_room = ReadlineEdit(caption="Room : #", edit_text="") + e_key = ReadlineEdit(caption="Key : ", edit_text="", mask="*") error_text = urwid.Text("") key_section_placeholder = urwid.WidgetPlaceholder(urwid.Text("")) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 7900e98..e276bb2 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -21,6 +21,7 @@ from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps from .Helpers import ClickableIcon, osc52_copy +from .ReadlineEdit import ReadlineMixin, ReadlineEdit from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags from nomadnet.ui import THEME_DARK, THEME_LIGHT @@ -70,7 +71,13 @@ class ConversationDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-a] Attach [C-s] Save [C-k] Clear [C-w] Close [C-u] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-s] Save [Tab] ↑ Messages"), "shortcutbar") + +class ConversationBodyShortcuts(): + def __init__(self, app): + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("[C-s] Save [C-u] Purge [C-o] Sort [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar") class TabButton(urwid.Button): button_left = urwid.Text("[") @@ -223,6 +230,7 @@ class ConversationsDisplay(): self.list_shortcuts = ConversationListDisplayShortcuts(self.app) self.editor_shortcuts = ConversationDisplayShortcuts(self.app) + self.body_shortcuts = ConversationBodyShortcuts(self.app) self.shortcuts_display = self.list_shortcuts self.widget = urwid.WidgetPlaceholder(self.columns_widget) @@ -834,10 +842,10 @@ class ConversationsDisplay(): if display_name is None: display_name = "" - e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash_text) + e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash_text) t_id = urwid.Text("Addr : "+source_hash_text) - e_name = urwid.Edit(caption="Name : ",edit_text=display_name) - e_copy = urwid.Edit(caption="Copy : ", edit_text=source_hash_text) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_name) + e_copy = ReadlineEdit(caption="Copy : ", edit_text=source_hash_text) selected_id_widget = t_id @@ -878,7 +886,7 @@ class ConversationsDisplay(): except Exception as e: pass - e_notes = urwid.Edit(caption="Notes: ", edit_text=notes_initial, multiline=True) + e_notes = ReadlineEdit(caption="Notes: ", edit_text=notes_initial, multiline=True) cb_pin = urwid.CheckBox("Pin to top", state=pinned_initial) trust_button_group = [] @@ -1018,8 +1026,8 @@ class ConversationsDisplay(): source_hash = "" display_name = "" - e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash) - e_name = urwid.Edit(caption="Name : ",edit_text=display_name) + e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_name) trust_button_group = [] r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted") @@ -1110,7 +1118,7 @@ class ConversationsDisplay(): def ingest_lxm_uri(self): self.dialog_open = True lxm_uri = "" - e_uri = urwid.Edit(caption="URI : ",edit_text=lxm_uri) + e_uri = ReadlineEdit(caption="URI : ",edit_text=lxm_uri) def dismiss_dialog(sender): self.dialog_open = False @@ -1410,7 +1418,7 @@ class ConversationsDisplay(): def show_set_pn_dialog(_sender): current_pn = self.app.get_user_selected_propagation_node() current_str = RNS.hexrep(current_pn, delimit=False) if current_pn is not None else "" - pn_edit = urwid.Edit(caption="Hash : ", edit_text=current_str) + pn_edit = ReadlineEdit(caption="Hash : ", edit_text=current_str) status_text = urwid.Text("", align=urwid.CENTER) def reopen_sync(_b=None): @@ -1759,14 +1767,18 @@ class ConversationsDisplay(): focus_path = self.columns_widget.get_focus_path() except Exception: return self.list_shortcuts - if not focus_path: + if not focus_path or focus_path[0] != 1: return self.list_shortcuts - if focus_path[0] == 0: - return self.list_shortcuts - elif focus_path[0] == 1: + try: + cw = self.columns_widget.contents[1][0] + frame = cw.base_widget.frame + if frame is None: + return self.editor_shortcuts + if frame.focus_position == "footer": + return self.editor_shortcuts + return self.body_shortcuts + except Exception: return self.editor_shortcuts - else: - return self.list_shortcuts class ListEntry(urwid.Text): _selectable = True @@ -1792,18 +1804,16 @@ class ListEntry(urwid.Text): self._emit('click') return True -class MessageEdit(urwid.Edit): +class MessageEdit(ReadlineMixin, urwid.Edit): def keypress(self, size, key): if key == "ctrl d": self.delegate.send_message() elif key == "ctrl p": self.delegate.paper_message() - elif key == "ctrl a": + elif key == "ctrl f": self.delegate.attach_file() elif key == "ctrl s": self.delegate.save_focused_attachments() - elif key == "ctrl k": - self.delegate.clear_editor() elif key == "up": y = self.get_cursor_coords(size)[1] if y == 0: @@ -1820,6 +1830,18 @@ class MessageEdit(urwid.Edit): class ConversationFrame(urwid.Frame): + @property + def focus_position(self): + return urwid.Frame.focus_position.fget(self) + + @focus_position.setter + def focus_position(self, part): + urwid.Frame.focus_position.fset(self, part) + try: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts() + except Exception: + pass + def keypress(self, size, key): if self.focus_position == "header": result = super(ConversationFrame, self).keypress(size, key) @@ -1846,8 +1868,6 @@ class ConversationFrame(urwid.Frame): self.focus_position = "footer" else: return super(ConversationFrame, self).keypress(size, key) - elif key == "ctrl k": - self.delegate.clear_editor() else: return super(ConversationFrame, self).keypress(size, key) @@ -2198,7 +2218,11 @@ class ConversationWidget(urwid.WidgetWrap): def keypress(self, size, key): if key == "tab": self.toggle_focus_area() - elif key == "ctrl w": + return None + key = super(ConversationWidget, self).keypress(size, key) + if key is None: + return None + if key == "ctrl w": self.close() elif key == "ctrl u": self.conversation.purge_failed() @@ -2217,7 +2241,7 @@ class ConversationWidget(urwid.WidgetWrap): elif key == "ctrl s": self.save_focused_attachments() else: - return super(ConversationWidget, self).keypress(size, key) + return key def _on_conversation_changed_from_callback(self, conversation): self.delegate._wake(lambda: self.conversation_changed(conversation)) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 6f58f94..1995e0f 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -190,7 +190,7 @@ class TopicList(urwid.WidgetWrap): def keypress(self, size, key): if key == "up" and (self.ilb.first_item_is_selected()): nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" - + return super(TopicList, self).keypress(size, key) class GuideDisplay(): @@ -307,16 +307,68 @@ The different sections of the program has a number of keyboard shortcuts mapped, - Ctrl-E Display and edit selected peer info - Ctrl-X Delete conversation - Ctrl-R Open LXMF syncronisation dialog - ->>>Conversation Display - - Ctrl-D Send message - - Ctrl-K Clear input fields - - Ctrl-T Toggle message title field + - Ctrl-U Ingest LXMF URI + - Ctrl-O Toggle sort mode + - Ctrl-P Display own LXMF address + - Ctrl-G Toggle fullscreen + +>>>Message Editor + - Ctrl-D Send message + - Ctrl-P Compose paper message + - Ctrl-T Toggle message title field + - Ctrl-F Attach file + - Ctrl-S Save focused attachment + - Tab Switch focus to message list + +>>>Message List + - Ctrl-W Close conversation + - Ctrl-U Purge failed messages - Ctrl-O Toggle sort mode - - Ctrl-P Purge failed messages - Ctrl-X Clear conversation history - Ctrl-G Toggle fullscreen conversation - - Ctrl-W Close conversation + - Ctrl-S Save focused attachment + - Tab Switch focus to message editor + +>>`!Channels Window`! +>>>Channel List + - Ctrl-N Add a new hub + - Ctrl-A Add (join) a room + - Ctrl-R Connect to selected hub + - Ctrl-W Disconnect from selected hub + - Ctrl-T Toggle auto-reconnect for selected hub + - Ctrl-E Edit selected hub + - Ctrl-X Remove selected hub + - F8 Toggle join/part collapse + +>>>Message Editor + - Ctrl-D Send message + - Ctrl-X Leave current room + - F8 Toggle join/part collapse + - Tab Complete nickname + +>>>Message List + - Ctrl-X Leave current room + - Ctrl-U Toggle users pane + - Ctrl-Y Toggle channel list + - F8 Toggle join/part collapse + - Tab Switch focus to message editor + +>>`!Input Field Editing`! +All text input fields support readline-style editing shortcuts. When an input +field is focused, these take precedence over any window shortcut mapped to the +same key (so, for example, Ctrl-U edits the line rather than toggling a pane): + - Ctrl-A Move to beginning of line + - Ctrl-E Move to end of line + - Ctrl-U Delete from cursor to beginning of line + - Ctrl-K Delete from cursor to end of line + - Ctrl-W Delete previous word (whitespace-delimited) + - Ctrl-L Delete the entire buffer + - Ctrl-Y Paste (yank) most recently deleted text + - Ctrl-Left Move backward one word + - Ctrl-Right Move forward one word + +Text deleted with Ctrl-U, Ctrl-K, Ctrl-W or Ctrl-L is placed in a shared yank +buffer that Ctrl-Y pastes back, so text can be moved between input fields. >>`!Network Window`! >>>Browser @@ -491,29 +543,29 @@ Links can be inserted into micron documents. See the `*Markup`* section of this TOPIC_INTERFACES = '''>Interfaces -Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. +Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. The number of distinct network topologies you can create with Reticulum is more or less endless, but common to them all is that you will need to define one or more interfaces for Reticulum to use. The `![ Interfaces ]`! section of NomadNet lets you add, monitor, and update interfaces configured for your Reticulum instance. -If you are starting NomadNet for the first time you will find that an `!AutoInterface`! has been added by default. This interface will try to use your available network device to communicate with other peers discovered on your local network. +If you are starting NomadNet for the first time you will find that an `!AutoInterface`! has been added by default. This interface will try to use your available network device to communicate with other peers discovered on your local network. -Interfaces come in many different types and can interact with physical mediums like LoRa radios or standard IP networks. +Interfaces come in many different types and can interact with physical mediums like LoRa radios or standard IP networks. >>Viewing Interfaces -To view more info about an interface, navigate using the `!Up`! and `!Down`! arrow keys or by clicking with the mouse. Pressing `! < Enter >`! on a selected interface will bring you to a detailed interface view, which will show configuration parameters and realtime charts. From here you can also disable or edit the interface. To change the orientation of the TX/RX charts, press `!< V >`! for a vertical layout, and `!< H >`! for a horizontal view. +To view more info about an interface, navigate using the `!Up`! and `!Down`! arrow keys or by clicking with the mouse. Pressing `! < Enter >`! on a selected interface will bring you to a detailed interface view, which will show configuration parameters and realtime charts. From here you can also disable or edit the interface. To change the orientation of the TX/RX charts, press `!< V >`! for a vertical layout, and `!< H >`! for a horizontal view. >>Updating Interfaces -To edit an interface, select the interface and press `!< Ctrl + E >`!. +To edit an interface, select the interface and press `!< Ctrl + E >`!. To remove an interface, select the interface and press `!< Ctrl + X >`!. You can also perform both of these actions from the details view. >>Adding Interfaces -To add a new interface, press `!< Ctrl + A >`!. From here you can select which type of interface you want to add. Each unique interface type will have different configuration options. +To add a new interface, press `!< Ctrl + A >`!. From here you can select which type of interface you want to add. Each unique interface type will have different configuration options. `Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b @@ -548,14 +600,14 @@ Target Port: Port number to connect to Optional Parameters: I2P Tunneled: Enable for connecting through I2P -KISS Framing: Enable for KISS framing for software modems +KISS Framing: Enable for KISS framing for software modems ``` This interface is commonly used to connect to Reticulum gateways or other persistent nodes on the Internet. >>TCPServerInterface -The TCP Server interface listens for incoming connections, allowing other Reticulum peers to connect to your node using TCPClientInterface. +The TCP Server interface listens for incoming connections, allowing other Reticulum peers to connect to your node using TCPClientInterface. ``` Required Parameters: @@ -603,7 +655,7 @@ The RNode interface allows using LoRa transceivers running RNode firmware as Ret Required Parameters: Port: Serial port or BLE device path Frequency: Operating frequency in MHz -Bandwidth: Channel bandwidth +Bandwidth: Channel bandwidth TX Power: Transmit power in dBm Spreading Factor: LoRa spreading factor (7-12) Coding Rate: LoRa coding rate (4:5-4:8) @@ -614,7 +666,7 @@ ID Interval: Identification interval in seconds Airtime Limits: Control duty cycle ``` -The interface includes a parameter calculator to estimate link budget, sensitivity, and data rate on the air based on your settings. +The interface includes a parameter calculator to estimate link budget, sensitivity, and data rate on the air based on your settings. >>RNodeMultiInterface @@ -648,7 +700,7 @@ The KISS interface supports packet radio modems and TNCs using the KISS protocol Required Parameters: Port: Serial port path -Speed: Baud rate of serial device +Speed: Baud rate of serial device Databits: Number of data bits Parity: Parity setting Stopbits: Number of stop bits @@ -1445,7 +1497,7 @@ If no heading text is defined, the section will appear as a sub-section without Tags are used to format text with micron. Some tags can appear anywhere in text, and some must appear at the beginning of a line. If you need to write text that contains a sequence that would be interpreted as a tag, you can escape it with the character \\. -In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\`\\` tag anywhere in the markup, which will immediately remove any formatting previously specified. +In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\`\\` tag anywhere in the markup, which will immediately remove any formatting previously specified. >>Alignment @@ -1646,7 +1698,7 @@ If you want to link to an anchor on another page, you can include it as a reques >>Notes on namespaces and collisions -Auto-anchors from headings and explicit \\`: anchors share a single namespace per page. If an explicit anchor collides with a heading slug, the first one declared is where it will jump to. +Auto-anchors from headings and explicit \\`: anchors share a single namespace per page. If an explicit anchor collides with a heading slug, the first one declared is where it will jump to. >Tables @@ -1761,7 +1813,7 @@ Full control: `B444``B333 >>> Checkboxes -In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options. +In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options. `Faaa `= @@ -1827,7 +1879,7 @@ This line will >Partials -You can include partials in pages, which will load asynchronously once the page itself has loaded. +You can include partials in pages, which will load asynchronously once the page itself has loaded. `Faaa `= diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index b295cdd..fe7d87b 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -6,6 +6,7 @@ from math import log10, pow from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * from nomadnet.vendor.AsciiChart import AsciiChart +from .ReadlineEdit import ReadlineEdit ### GYLPHS ### INTERFACE_GLYPHS = { @@ -235,8 +236,8 @@ class RNodeCalculator(urwid.WidgetWrap): self.link_budget_widget = urwid.Text("Link Budget: Calculating...") self.sensitivity_widget = urwid.Text("Sensitivity: Calculating...") - self.noise_floor_edit = urwid.Edit("", "0") - self.antenna_gain_edit = urwid.Edit("", "0") + self.noise_floor_edit = ReadlineEdit("", "0") + self.antenna_gain_edit = ReadlineEdit("", "0") layout = urwid.Pile([ urwid.Divider("-"), diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index f584be2..83fb6bf 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -6,6 +6,7 @@ import time import RNS from urwid.util import is_mouse_press from urwid.text_layout import calc_coords +from .ReadlineEdit import ReadlineEdit from RNS.Utilities.rngit.util import MarkdownToMicron DEFAULT_FG_DARK = "ddd" @@ -360,7 +361,7 @@ def parse_line(line, state, url_delegate): fn = o["name"] fs = o["style"] fmask = "*" if o["masked"] else None - f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) + f = ReadlineEdit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) f.field_name = fn fa = urwid.AttrMap(f, fs) widgets.append((fw, fa)) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 1e06007..0332139 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -10,6 +10,7 @@ from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name from .Browser import Browser +from .ReadlineEdit import ReadlineEdit class NetworkDisplayShortcuts(): def __init__(self, app): @@ -415,7 +416,7 @@ class AnnounceStream(urwid.WidgetWrap): ('weight', 3, self.tab_pn), ], dividechars=1) - self.search_edit = urwid.Edit(caption="Search: ") + self.search_edit = ReadlineEdit(caption="Search: ") urwid.connect_signal(self.search_edit, 'change', self.on_search_change) self.display_toggle = TabButton("Show: Name", on_press=self.toggle_display_mode) @@ -674,8 +675,8 @@ class KnownNodeInfo(urwid.WidgetWrap): r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected) r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) - e_name = urwid.Edit(caption="Name : ",edit_text=display_str) - e_sort = urwid.Edit(caption="Sort Rank : ",edit_text=sort_str) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_str) + e_sort = ReadlineEdit(caption="Sort Rank : ",edit_text=sort_str) node_ident = RNS.Identity.recall(source_hash) op_hash = None @@ -1269,7 +1270,7 @@ class LocalPeer(urwid.WidgetWrap): t_id = urwid.Text("LXMF Addr : "+RNS.prettyhexrep(self.app.lxmf_destination.hash)) i_id = urwid.Text("Identity : "+RNS.prettyhexrep(self.app.identity.hash)) - e_name = urwid.Edit(caption="Name : ", edit_text=display_name) + e_name = ReadlineEdit(caption="Name : ", edit_text=display_name) def save_query(sender): def dismiss_dialog(sender): diff --git a/nomadnet/ui/textui/ReadlineEdit.py b/nomadnet/ui/textui/ReadlineEdit.py new file mode 100644 index 0000000..85b8dd9 --- /dev/null +++ b/nomadnet/ui/textui/ReadlineEdit.py @@ -0,0 +1,150 @@ +import urwid + + +_KILL_KEYS = frozenset(("ctrl u", "ctrl k", "ctrl w", "ctrl l")) + + +class _KillRing: + """Module-global kill buffer shared across all ReadlineMixin widgets. + + Mirrors GNU readline: consecutive kills accumulate into the same entry + (forward kills append, backward kills prepend); any non-kill keypress + breaks the chain so the next kill replaces the buffer. + """ + text = "" + last_was_kill = False + + @classmethod + def reset_chain(cls): + cls.last_was_kill = False + + @classmethod + def kill(cls, killed, direction): + if not killed: + return + if cls.last_was_kill: + cls.text = cls.text + killed if direction == "forward" else killed + cls.text + else: + cls.text = killed + cls.last_was_kill = True + + +class ReadlineMixin: + """Mixin adding readline-style editing keys to an urwid.Edit-derived widget. + + Bindings (GNU readline defaults, plus ctrl-l as kill-whole-buffer): + ctrl-a beginning-of-line + ctrl-e end-of-line + ctrl-u unix-line-discard (kill from cursor to beginning of line) + ctrl-k kill-line (kill from cursor to end of line) + ctrl-w unix-word-rubout (kill previous whitespace-delimited word) + ctrl-l kill-whole-buffer (kill the entire edit buffer) + ctrl-y yank (insert most-recently-killed text) + ctrl-left backward-word (alphanumeric boundary) + ctrl-right forward-word (alphanumeric boundary) + + "Line" is the current logical line within the edit buffer, delimited by + newlines -- so on multiline Edits these act on the line under the cursor, + not the entire buffer. + + The kill buffer is shared across all widgets using this mixin, so text + killed in one Edit can be yanked into another. + """ + + def keypress(self, size, key): + if key == "ctrl a": self._rl_beg_of_line() + elif key == "ctrl e": self._rl_end_of_line() + elif key == "ctrl u": self._rl_kill_to_beg() + elif key == "ctrl k": self._rl_kill_to_end() + elif key == "ctrl w": self._rl_kill_word_back() + elif key == "ctrl l": self._rl_kill_whole_buffer() + elif key == "ctrl y": self._rl_yank() + elif key == "ctrl left": self._rl_backward_word() + elif key == "ctrl right": self._rl_forward_word() + else: + result = super().keypress(size, key) + if key not in _KILL_KEYS: + _KillRing.reset_chain() + return result + if key not in _KILL_KEYS: + _KillRing.reset_chain() + return None + + def _rl_line_bounds(self): + text, pos = self.edit_text, self.edit_pos + bol = text.rfind("\n", 0, pos) + bol = 0 if bol == -1 else bol + 1 + eol = text.find("\n", pos) + eol = len(text) if eol == -1 else eol + return bol, eol + + def _rl_delete(self, start, end, kill_direction=None): + if start == end: + return + text = self.edit_text + if kill_direction is not None: + _KillRing.kill(text[start:end], kill_direction) + self.set_edit_text(text[:start] + text[end:]) + self.set_edit_pos(start) + + @staticmethod + def _rl_is_word_char(ch): + return ch.isalnum() or ch == "_" + + def _rl_beg_of_line(self): + bol, _ = self._rl_line_bounds() + self.set_edit_pos(bol) + + def _rl_end_of_line(self): + _, eol = self._rl_line_bounds() + self.set_edit_pos(eol) + + def _rl_kill_to_beg(self): + bol, _ = self._rl_line_bounds() + self._rl_delete(bol, self.edit_pos, kill_direction="backward") + + def _rl_kill_to_end(self): + _, eol = self._rl_line_bounds() + self._rl_delete(self.edit_pos, eol, kill_direction="forward") + + def _rl_kill_word_back(self): + text, pos = self.edit_text, self.edit_pos + p = pos + while p > 0 and text[p - 1].isspace(): + p -= 1 + while p > 0 and not text[p - 1].isspace(): + p -= 1 + self._rl_delete(p, pos, kill_direction="backward") + + def _rl_kill_whole_buffer(self): + self._rl_delete(0, len(self.edit_text), kill_direction="forward") + + def _rl_yank(self): + if not _KillRing.text: + return + pos = self.edit_pos + text = self.edit_text + self.set_edit_text(text[:pos] + _KillRing.text + text[pos:]) + self.set_edit_pos(pos + len(_KillRing.text)) + + def _rl_backward_word(self): + text, pos = self.edit_text, self.edit_pos + while pos > 0 and not self._rl_is_word_char(text[pos - 1]): + pos -= 1 + while pos > 0 and self._rl_is_word_char(text[pos - 1]): + pos -= 1 + self.set_edit_pos(pos) + + def _rl_forward_word(self): + text, pos = self.edit_text, self.edit_pos + n = len(text) + while pos < n and not self._rl_is_word_char(text[pos]): + pos += 1 + while pos < n and self._rl_is_word_char(text[pos]): + pos += 1 + self.set_edit_pos(pos) + + +class ReadlineEdit(ReadlineMixin, urwid.Edit): + """Drop-in urwid.Edit replacement with readline-style editing keys.""" + pass diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py index 55f9a03..38a72df 100644 --- a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -1,5 +1,7 @@ import urwid +from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit + class DialogLineBox(urwid.LineBox): def __init__(self, body, parent=None, title="?"): super().__init__(body, title=title) @@ -12,7 +14,7 @@ class DialogLineBox(urwid.LineBox): return None return super().keypress(size, key) -class Placeholder(urwid.Edit): +class Placeholder(ReadlineMixin, urwid.Edit): def __init__(self, caption="", edit_text="", placeholder="", **kwargs): super().__init__(caption, edit_text, **kwargs) self.placeholder = placeholder @@ -240,7 +242,7 @@ class FormMultiList(urwid.Pile, FormField): FormField.__init__(self, config_key, transform) def create_entry_row(self): - edit = urwid.Edit("", "") + edit = ReadlineEdit("", "") entry_row = urwid.Columns([ ('weight', 1, edit), (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), @@ -318,7 +320,7 @@ class FormMultiTable(urwid.Pile, FormField): if values is None: values = {} - name_edit = urwid.Edit("", name) + name_edit = ReadlineEdit("", name) columns = [('weight', 3, name_edit)] @@ -330,9 +332,9 @@ class FormMultiTable(urwid.Pile, FormField): widget = urwid.CheckBox("", state=bool(field_value)) elif field_config.get("type") == "dropdown": # TODO: dropdown in MultiTable - widget = urwid.Edit("", str(field_value)) + widget = ReadlineEdit("", str(field_value)) else: - widget = urwid.Edit("", str(field_value)) + widget = ReadlineEdit("", str(field_value)) field_widgets[field_key] = widget columns.append(('weight', 2, widget)) @@ -451,8 +453,8 @@ class FormKeyValuePairs(urwid.Pile, FormField): FormField.__init__(self, config_key, transform) def create_entry_row(self, key="", value=""): - key_edit = urwid.Edit("", key) - value_edit = urwid.Edit("", value) + key_edit = ReadlineEdit("", key) + value_edit = ReadlineEdit("", value) remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) From a88b31d033010d3b7f3e73d8ebce0c4a6d6f6043 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 25 May 2026 23:40:24 +0200 Subject: [PATCH 494/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index bc86c94..10aa336 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.2" +__version__ = "1.2.3" From 38f9dfbebcb10b429d3a8e176a1b33e463b8f37c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 26 May 2026 23:33:03 +0200 Subject: [PATCH 495/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b187f29..587992d 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.3.1", "lxmf>=0.9.9", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.3.2", "lxmf>=1.0.0", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From aee1f6a774d1f240c0ab8d6f49b1c5096d9abc27 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 27 May 2026 12:14:51 +0200 Subject: [PATCH 496/592] Make prop node sync button first focus on sync dialog open --- nomadnet/ui/textui/Conversations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index fa2ce2d..f022206 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1304,7 +1304,7 @@ class ConversationsDisplay(): def _pn_dropdown_label(self, pn_hash, meta): name = (meta or {}).get("name") if name: - label = strip_modifiers(name) or "" + label = sanitize_name(name) or "" label = " ".join(label.split()) else: label = "" @@ -1510,9 +1510,9 @@ class ConversationsDisplay(): sync_progress, urwid.Divider(g["divider1"]), ] + pile_items += [button_columns, urwid.Text(""), r_mall, rbs] if node_selector is not None: - pile_items += [node_selector, urwid.Divider(g["divider1"])] - pile_items += [r_mall, rbs, urwid.Text(""), button_columns] + pile_items += [urwid.Divider(g["divider1"]), node_selector] dialog = DialogLineBox(urwid.Pile(pile_items), title="Message Sync") else: From c62f7511e6532b8f5858b2cc33d80f7c81ff8f9f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 28 May 2026 14:11:34 +0200 Subject: [PATCH 497/592] Ensure jump_to_anchor doesn't run before layout caching is complete --- nomadnet/ui/textui/Browser.py | 14 ++++++++++++-- nomadnet/ui/textui/Guide.py | 11 ++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index c7630c1..90eaff4 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -270,7 +270,8 @@ class Browser: # In-document anchor link (#name or empty #) if link_target.startswith("#"): - self._jump_to_anchor(link_target[1:]) + def df(loop, user_data): self._jump_to_anchor(link_target[1:]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) return # rrc://[:]/ URL form @@ -1287,6 +1288,10 @@ class Browser: self.history_inc = False self.reloading = False + if self.request_data and "var_anchor" in self.request_data: + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + else: if self.destination_hash != self.loopback: load_thread = threading.Thread(target=self.__load) @@ -1362,6 +1367,10 @@ class Browser: self.history_inc = False self.reloading = False + if self.request_data and "var_anchor" in self.request_data: + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + def __load(self): # If an established link exists, but it doesn't match the target @@ -1537,7 +1546,8 @@ class Browser: self.cache_page(cache_time) if self.request_data and "var_anchor" in self.request_data: - self._jump_to_anchor(self.request_data["var_anchor"]) + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) except Exception as e: RNS.log("An error occurred while handling response. The contained exception was: "+str(e)) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 1995e0f..4316266 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -106,7 +106,8 @@ class GuideLinkDelegate: if target.startswith("#"): if self.reader is not None: try: - self.reader.jump_to_anchor(target[1:]) + def df(loop, user_data): self.reader.jump_to_anchor(target[1:]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) except Exception as e: RNS.log("Guide anchor jump failed: "+str(e), RNS.LOG_ERROR) return @@ -293,7 +294,7 @@ Nomad Network is build on LXMF and Reticulum, which together provides the crypto Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded Reticulum networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. -The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project at `_`F00f`[Aleph git`a8d24177d946de4f1f0a0fe1af9a1338:/page/index.mu]`f`_. +The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project at `_`F79d`[Aleph git`a8d24177d946de4f1f0a0fe1af9a1338:/page/index.mu]`f`_. ''' @@ -1623,7 +1624,7 @@ Here is a link without any label: `[72914442a3689add83a09a767963f57c:/page/index This is a `[labeled link`72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it -Here is `F00a`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f +Here is `F79d`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f `= `` @@ -1635,7 +1636,7 @@ Here is a link without any label: `[72914442a3689add83a09a767963f57c:/page/index This is a `[labeled link`72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it -Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f +Here is `F79d`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f `` @@ -1726,7 +1727,7 @@ The above markup produces the following table: >Fields & Requests -Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. +Nomad Network lets you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. >>Request Links From 1a7288517e4635f93d835e26793e1a0c209cbfaa Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 28 May 2026 14:37:14 +0200 Subject: [PATCH 498/592] Fixed link and mention scans happening inside code blocks --- nomadnet/ui/textui/Channels.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 05e67f6..24bba5b 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -136,10 +136,32 @@ def _scan_nick_mentions(text, own_nick): if nick.lower() != own_nick: yield m.start(), m.end(), f"nick_mention", nick +def _scan_code_blocks(text): + regions = [] + fence_re = re.compile(r'```[^\n]*\n.*?```', re.DOTALL) + for m in fence_re.finditer(text): regions.append((m.start(), m.end())) + + inline_re = re.compile(r'(? r_start: return True + return False + + spans = [(s, e, k, t) for s, e, k, t in spans if not overlaps_code(s, e)] + spans.sort(key=lambda s: s[0]) filtered = [] last_end = 0 From 7bc691169ab299c5f2e090eb00d5ce759517278d Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien <956a74d715fe9cb2e9da0a19b067b414> Date: Thu, 28 May 2026 11:50:01 -0400 Subject: [PATCH 499/592] Conversation: avoid calling conversation_list() on every received announce; this can cause fd starvation during an announce storm --- nomadnet/Conversation.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 2802f9a..c543d9c 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -24,15 +24,13 @@ class Conversation: if not destination_hash in app.ignored_list: destination_hash_text = RNS.hexrep(destination_hash, delimit=False) - # Check if the announced destination is in - # our list of conversations - if destination_hash_text in [e[0] for e in Conversation.conversation_list(app)]: - if app.directory.find(destination_hash): - if Conversation.created_callback != None: - Conversation.created_callback() - else: - if Conversation.created_callback != None: - Conversation.created_callback() + # Notify if the announced destination is one of our + # existing conversations. Checking the directory directly + # avoids scanning and opening every conversation on disk + # for each announce received. + if os.path.isdir(app.conversationpath + "/" + destination_hash_text): + if Conversation.created_callback != None: + Conversation.created_callback() # This reformats the new v0.5.0 announce data back to the expected format # for nomadnets storage and other handling functions. From ad10301569a39d4f43b3d21ae9fc392602c937ca Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien <956a74d715fe9cb2e9da0a19b067b414> Date: Thu, 28 May 2026 11:50:01 -0400 Subject: [PATCH 500/592] Directory/Node: replace some bare open/close's with with's --- nomadnet/Directory.py | 10 ++++------ nomadnet/Node.py | 10 ++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index dae86ec..f850909 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -97,9 +97,8 @@ class Directory: "announce_stream": self.announce_stream } - file = open(self.app.directorypath, "wb") - file.write(msgpack.packb(directory)) - file.close() + with open(self.app.directorypath, "wb") as file: + file.write(msgpack.packb(directory)) except Exception as e: RNS.log("Could not write directory to disk. Then contained exception was: "+str(e), RNS.LOG_ERROR) @@ -107,10 +106,9 @@ class Directory: def load_from_disk(self): if os.path.isfile(self.app.directorypath): try: - file = open(self.app.directorypath, "rb") - unpacked_directory = msgpack.unpackb(file.read()) + with open(self.app.directorypath, "rb") as file: + unpacked_directory = msgpack.unpackb(file.read()) unpacked_list = unpacked_directory["entry_list"] - file.close() entries = {} for e in unpacked_list: diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 8f03115..acf54ff 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -128,9 +128,8 @@ class Node: allowed_input = allowed_result.stdout else: - fh = open(allowed_path, "rb") - allowed_input = fh.read() - fh.close() + with open(allowed_path, "rb") as fh: + allowed_input = fh.read() allowed_hash_strs = allowed_input.splitlines() @@ -176,9 +175,8 @@ class Node: generated = subprocess.run([file_path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env_map) return generated.stdout else: - fh = open(file_path, "rb") - response_data = fh.read() - fh.close() + with open(file_path, "rb") as fh: + response_data = fh.read() return response_data else: RNS.log("Request denied", RNS.LOG_VERBOSE) From bec78cf1e55810fdb0bc98188a818138fca4cf27 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien <956a74d715fe9cb2e9da0a19b067b414> Date: Thu, 28 May 2026 14:39:23 -0400 Subject: [PATCH 501/592] RRC: filter and *then* apply the history cap --- nomadnet/RRC.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 6a58845..e46d31c 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -720,6 +720,7 @@ class RRCHub: def _load_history(self): with self._lock: rooms = list(self.messages.keys()) + filter_msgs = self._filter_history() for room in rooms: if not self._persistable_room(room): continue @@ -732,38 +733,32 @@ class RRCHub: with open(path, "rb") as f: while True: try: - window.append(cbor.load(f)) + entry = cbor.load(f) except EOFError: break except Exception as ex: decode_error = ex break + m = self._msg_from_entry(room, entry) + if m is None: + continue + if filter_msgs and (m.kind == "system" or m.kind == "notice"): + continue + window.append(m) except OSError as ex: self._log("history load failed for #"+room+": "+str(ex), RNS.LOG_ERROR) continue if decode_error is not None: self._log("history file for #"+room+" is corrupt, truncating to last "+str(len(window))+" valid messages: "+str(decode_error), RNS.LOG_ERROR) - msgs = [] - filter_msgs = self._filter_history() - for e in window: - m = self._msg_from_entry(room, e) - if m is not None: - if filter_msgs: - should_filter = False - if m.kind == "system": should_filter = True - elif m.kind == "notice": should_filter = True - if should_filter: continue - - msgs.append(m) with self._lock: - self.messages[room] = msgs + self.messages[room] = list(window) def _clean_history(self): now = time.time() cleaned = False remove_after = self._ephemeral_notices_history() if now > self._last_history_clean + self.CLEAN_HISTORY_INTERVAL: - RNS.log(f"Cleaning loaded message history", RNS.LOG_DEBUG) + RNS.log(f"Cleaning loaded message history", RNS.LOG_DEBUG) with self._lock: try: for r in self.messages: From f919b647c396287011c673b495de76fa7e38cc99 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 29 May 2026 11:27:42 +0200 Subject: [PATCH 502/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 10aa336..b3f9ac7 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.3" +__version__ = "1.2.4" diff --git a/setup.py b/setup.py index 587992d..9460894 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.3.2", "lxmf>=1.0.0", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.3.4", "lxmf>=1.0.0", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From b806bd70890ed89f6bcf4715fefeee8dd3094a25 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien <956a74d715fe9cb2e9da0a19b067b414> Date: Fri, 29 May 2026 12:18:57 -0400 Subject: [PATCH 503/592] RRC: serialize logfile writes to guarantee atomicity on non-*nix systems --- nomadnet/RRC.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index e46d31c..5393973 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -240,6 +240,9 @@ class RRCHub: self.auto_who = False self._lock = threading.RLock() + # we need this lock to serialize writes on non-*nix systems where O_APPEND + # write()'s apparently aren't guaranteed to be atomic + self._history_io_lock = threading.Lock() self._resource_expectations = {} self._sent_ids = deque(maxlen=256) @@ -699,8 +702,10 @@ class RRCHub: try: self.manager._ensure_history_dir(self) path = self.manager._history_path(self, room) - with open(path, "ab") as f: - f.write(cbor.encode(self._entry_for(msg))) + payload = cbor.encode(self._entry_for(msg)) + with self._history_io_lock: + with open(path, "ab") as f: + f.write(payload) self._history_write_failed = False except Exception as e: if not self._history_write_failed: @@ -712,8 +717,9 @@ class RRCHub: return path = self.manager._history_path(self, room) try: - if os.path.isfile(path): - os.unlink(path) + with self._history_io_lock: + if os.path.isfile(path): + os.unlink(path) except Exception: pass From f6d87b482e6e6ba4364a55541cf35771e750eb55 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 31 May 2026 10:35:35 +0200 Subject: [PATCH 504/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index b3f9ac7..b7e1990 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.4" +__version__ = "1.2.5" From 997bae397cf96302ffbdb748920cf7be77e0fef1 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:39:23 -0400 Subject: [PATCH 505/592] MicronParser: shrink overflowing form fields to fit narrow terminals instead of hiding them --- nomadnet/ui/textui/MicronParser.py | 82 +++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 83fb6bf..c274b66 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -217,6 +217,86 @@ def render_table(lines, state, url_delegate): return widgets if widgets else None +class FormColumns(urwid.Columns): + + MIN_FIELD_WIDTH = 4 + + + def _natural_width(widget, maxcol, focused): + # mirror the width urwid.Columns itself computes for a PACK column + if not isinstance(widget, urwid.Widget): + return 0 + sizing = widget.sizing() + if sizing & frozenset((urwid.FIXED, urwid.FLOW)): + width = 0 + if urwid.FIXED in sizing: + width = widget.pack((), focused)[0] + if urwid.FLOW in sizing and (not width or width > maxcol): + width = widget.pack((maxcol,), focused)[0] + return width + return widget.pack((maxcol,), focused)[0] + + def column_widths(self, size, focus=False): + contents = self.contents + field_idxs = [i for i, (w, (t, n, b)) in enumerate(contents) if t == urwid.GIVEN] + + if not field_idxs: + return super().column_widths(size, focus) + + maxcol = size[0] + nat = [] + for i, (w, (t, width, b)) in enumerate(contents): + if t == urwid.GIVEN: + nat.append(width) + elif t == urwid.PACK: + nat.append(self._natural_width(w, maxcol, focus and i == self.focus_position)) + else: + return super().column_widths(size, focus) + + dividers = self.dividechars * max(0, len(nat) - 1) + field_set = set(field_idxs) + fixed = sum(w for i, w in enumerate(nat) if i not in field_set) + requested = sum(nat[i] for i in field_idxs) + available = maxcol - dividers - fixed + + if available >= requested: + return super().column_widths(size, focus) + + if available < self.MIN_FIELD_WIDTH * len(field_idxs): + + return super().column_widths(size, focus) + + + + + + + overflow = requested - available + slack = {i: max(0, nat[i] - self.MIN_FIELD_WIDTH) for i in field_idxs} + total_slack = sum(slack.values()) + if total_slack < overflow: + return super().column_widths(size, focus) + + widths = list(nat) + removed = {i: min(slack[i], overflow * slack[i] // total_slack) for i in field_idxs} + shortfall = overflow - sum(removed.values()) + # Hand out any rounding remainder one column at a time. + pending = [i for i in field_idxs if removed[i] < slack[i]] + k = 0 + while shortfall > 0 and pending: + i = pending[k % len(pending)] + removed[i] += 1 + shortfall -= 1 + if removed[i] >= slack[i]: + pending = [j for j in pending if removed[j] < slack[j]] + k = 0 + else: + k += 1 + for i in field_idxs: + widths[i] = nat[i] - removed[i] + return widths + + def parse_line(line, state, url_delegate): pre_escape = False if len(line) > 0: @@ -396,7 +476,7 @@ def parse_line(line, state, url_delegate): - columns_widget = urwid.Columns(widgets, dividechars=0) + columns_widget = FormColumns(widgets, dividechars=0) text_widget = columns_widget # text_widget = urwid.Text("<"+output+">", align=state["align"]) From 61812aa6ae3e88a7745125ddd16d7567420b5f6f Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:00:46 -0400 Subject: [PATCH 506/592] Add a built-in Micron page/file editor under [ My Node ], extend Micron with nestable collapsible section folding and multi-row form inputs, misc changes --- nomadnet/examples/various/input_fields.py | 2 + nomadnet/ui/TextUI.py | 4 + nomadnet/ui/textui/Browser.py | 106 ++- nomadnet/ui/textui/Conversations.py | 19 +- nomadnet/ui/textui/Editor.py | 1053 +++++++++++++++++++++ nomadnet/ui/textui/Guide.py | 37 + nomadnet/ui/textui/Helpers.py | 15 + nomadnet/ui/textui/Main.py | 22 +- nomadnet/ui/textui/MicronParser.py | 142 ++- nomadnet/ui/textui/Network.py | 120 ++- 10 files changed, 1459 insertions(+), 61 deletions(-) create mode 100644 nomadnet/ui/textui/Editor.py diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py index 5d1eccc..87f4d87 100644 --- a/nomadnet/examples/various/input_fields.py +++ b/nomadnet/examples/various/input_fields.py @@ -25,6 +25,8 @@ An masked field : `B444``b An small field : `B444`<8|small`test>`b, and some more text. +A multi-row field : `B444`<40x4|notes`>`b + Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 15abd7b..9ba9d7d 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -169,6 +169,10 @@ GLYPHS = { ("audio", "[~]", "\u266b", "\uf1c7"), ("pin", "*", "\u2605", "\uf08d"), ("copy", "[C]", "\u29c9", "\uf0c5"), + ("folder", "[+]", "\u25b8", "\uf07b"), + ("folder_open", "[-]", "\u25be", "\uf07c"), + ("fold_open", "-", "▾", "▾"), + ("fold_closed", "+", "▸", "▸"), } class TextUI: diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 90eaff4..cb637d6 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -347,6 +347,8 @@ class Browser: if target_idx is None: return + self.reveal_index(int(target_idx)) + row_offset = self._rows_above(int(target_idx), cols) try: @@ -363,7 +365,7 @@ class Browser: browser_cols = screen_cols-lw-2 cols = browser_cols - except Exception: + except Exception as e: RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING) RNS.trace_exception(e) cols = 100 @@ -373,8 +375,10 @@ class Browser: def _rows_above(self, index, cols): if index <= 0 or not self.attr_maps: return 0 + hidden = self.hidden_indices() total = 0 for i in range(min(index, len(self.attr_maps))): + if i in hidden: continue try: total += self.attr_maps[i].rows((cols,)) except Exception: total += 1 @@ -627,6 +631,106 @@ class Browser: self.page_partials = {} self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") self.detect_partials() + self.init_folds() + + # render an arbitrary markup buffer (used by the page editor preview) and + # return the content widget, with partials/folds/fields wired as usual + def render_markup_buffer(self, markup): + self.status = Browser.DONE + self.markup = markup + self.page_background_color = None + self.page_foreground_color = None + for tag, attr in (("#!bg=", "page_background_color"), ("#!fg=", "page_foreground_color")): + pos = markup.find(tag) + if pos >= 0: + endpos = markup.find("\n", pos) + if endpos < 0: endpos = len(markup) + if endpos-(pos+len(tag)) in (3, 6): + setattr(self, attr, markup[pos+len(tag):endpos]) + try: + self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: + self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render preview: {e}"), "browser_inactive")] + self.update_page_display() + return self.browser_body + + def init_folds(self): + self.fold_sections = {} + self.collapsed_headings = set() + attrmaps = self.attr_maps + header_levels = getattr(attrmaps, "header_levels", None) or {} + row_levels = getattr(attrmaps, "row_levels", None) or {} + collapsibles = getattr(attrmaps, "collapsibles", None) or {} + if not collapsibles: + return + + n = len(attrmaps) + for idx in sorted(collapsibles.keys()): + widget = self.collapsible_widget_at(idx) + if widget is None: continue + depth = header_levels.get(idx, 1) + end = n + for i in range(idx+1, n): + if row_levels.get(i, depth) < depth or \ + (i in header_levels and header_levels.get(i, 1) <= depth): + end = i; break + self.fold_sections[widget] = (idx+1, end) + if collapsibles[idx]: + self.collapsed_headings.add(widget) + try: widget.set_collapsed(True) + except Exception: pass + + if self.collapsed_headings: + self.rebuild_visible() + + def collapsible_widget_at(self, idx): + try: + o = self.attr_maps[idx]._original_widget + if getattr(o, "is_collapsible_heading", False): return o + except Exception: pass + return None + + def hidden_indices(self): + hidden = set() + for widget in getattr(self, "collapsed_headings", ()): + rng = getattr(self, "fold_sections", {}).get(widget) + if rng: hidden.update(range(rng[0], rng[1])) + return hidden + + def rebuild_visible(self): + pile = getattr(self, "page_pile", None) + if pile is None: return + hidden = self.hidden_indices() + opts = pile.options() + pile.contents = [(w, opts) for i, w in enumerate(self.attr_maps) if i not in hidden] + try: + if pile.focus_position >= len(pile.contents): + pile.focus_position = max(0, len(pile.contents)-1) + except Exception: pass + + def fold_changed(self, heading_widget): + if getattr(heading_widget, "collapsed", False): + self.collapsed_headings.add(heading_widget) + else: + self.collapsed_headings.discard(heading_widget) + self.rebuild_visible() + try: + for pos, (w, _) in enumerate(self.page_pile.contents): + if getattr(w, "_original_widget", None) is heading_widget: + self.page_pile.focus_position = pos + break + except Exception: pass + + def reveal_index(self, idx): + changed = False + for widget in list(getattr(self, "collapsed_headings", ())): + rng = getattr(self, "fold_sections", {}).get(widget) + if rng and rng[0] <= idx < rng[1]: + self.collapsed_headings.discard(widget) + try: widget.set_collapsed(False) + except Exception: pass + changed = True + if changed: self.rebuild_visible() def parse_url(self, url): path = None diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index f022206..4d30068 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -20,7 +20,7 @@ from nomadnet.util import sanitize_name from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps -from .Helpers import ClickableIcon, osc52_copy +from .Helpers import ClickableIcon, osc52_copy, qr_ascii from .ReadlineEdit import ReadlineMixin, ReadlineEdit from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags from nomadnet.ui import THEME_DARK, THEME_LIGHT @@ -639,22 +639,7 @@ class ConversationsDisplay(): self.show_qr_dialog(addr, title=display) def show_qr_dialog(self, data, title=None): - qr_text = None - try: - import qrcode - try: - qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) - qr.add_data(data) - qr.make() - import io - buf = io.StringIO() - qr.print_ascii(out=buf, invert=False) - qr_text = buf.getvalue().rstrip("\n") - except Exception as e: - RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) - qr_text = None - except Exception: - qr_text = None + qr_text = qr_ascii(data) def dismiss(_b): self._restore_listbox_pane() diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py new file mode 100644 index 0000000..bc3d4a3 --- /dev/null +++ b/nomadnet/ui/textui/Editor.py @@ -0,0 +1,1053 @@ +import os +import json +import shutil +import urwid +import RNS +import nomadnet + +from .ReadlineEdit import ReadlineEdit +from .Browser import Browser +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox + +PREVIEW_DELAY = 0.4 +PANE_WEIGHTS = {"files": 0.22, "editor": 0.40, "preview": 0.38} +PANE_ORDER = ["files", "editor", "preview"] + + + + +SNIPPETS = [ + ("Heading", ">Heading\n"), + ("Sub-heading", ">>Sub heading\n"), + ("Collapsible", "`+>Section\nbody\n<\n"), + ("Bold", "`!bold`!"), + ("Italic", "`*italic`*"), + ("Underline", "`_underline`_"), + ("Foreground col", "`F00ftext`f"), + ("Background col", "`B400text`b"), + ("Link", "`[label`:/page/index.mu]"), + ("Anchor", "`:anchor-name"), + ("Anchor link", "`[label`#anchor-name]"), + ("Input field", "`<24|name`>"), + ("Masked field", "`"), + ("Checkbox", "`"), + ("Radio", "`<^|name|value`>"), + ("Divider", "-\n"), + ("Table", "`t\n| A | B |\n| 1 | 2 |\n`t\n"), + ("Literal block", "`=\nliteral text\n`=\n"), +] + + + + + + + +EDITOR_STYLES = [ + ("editor_comment", "dark gray", "default", None, "#888", "default"), + ("editor_heading", "light cyan,bold", "default", None, "#0cc", "default"), + ("editor_tag", "light magenta", "default", None, "#c6c", "default"), + ("editor_field", "yellow", "default", None, "#cc0", "default"), + ("editor_link", "light green", "default", None, "#0c6", "default"), + ("editor_dir", "light blue,bold", "default", None, "#39f", "default"), + ("editor_exec", "dark gray", "default", None, "#666", "default"), + ("editor_lineno", "dark gray", "default", None, "#555", "default"), +] + + +def is_executable(path): + try: + return (not RNS.vendor.platformutils.is_windows()) and os.path.isfile(path) and os.access(path, os.X_OK) + except Exception: + return False + + +def _scan_tag(text, i, n): + # text[i] == "`"; return (end_index, attr) + if i + 1 >= n: + return i + 1, "editor_tag" + c = text[i + 1] + if c == "<": + j = text.find(">", i + 2); return (n if j < 0 else j + 1), "editor_field" + if c == "[": + j = text.find("]", i + 2); return (n if j < 0 else j + 1), "editor_link" + if c == "{": + j = text.find("}", i + 2); return (n if j < 0 else j + 1), "editor_link" + if c in "FB": + if i + 2 < n and text[i + 2] == "T": + return min(i + 9, n), "editor_tag" + return min(i + 5, n), "editor_tag" + if c == ":": + j = i + 2 + while j < n and (text[j].isalnum() or text[j] in "_-"): + j += 1 + return j, "editor_tag" + return i + 2, "editor_tag" + + +def highlight_micron(text): + n = len(text) + attrs = [None] * n + i = 0 + line_start = True + while i < n: + c = text[i] + if line_start and c == "#": + j = text.find("\n", i); j = n if j < 0 else j + for k in range(i, j): attrs[k] = "editor_comment" + i = j; continue + if line_start and c == ">": + j = i + while j < n and text[j] == ">": j += 1 + for k in range(i, j): attrs[k] = "editor_heading" + i = j; line_start = False; continue + if line_start and c == "`" and i + 2 < n and text[i + 1] in "+-" and text[i + 2] == ">": + j = i + 2 + while j < n and text[j] == ">": j += 1 + for k in range(i, j): attrs[k] = "editor_heading" + i = j; line_start = False; continue + if c == "\n": + i += 1; line_start = True; continue + if c == "\\" and i + 1 < n: + i += 2; line_start = False; continue + if c == "`": + j, attr = _scan_tag(text, i, n) + for k in range(i, min(j, n)): attrs[k] = attr + i = j; line_start = False; continue + i += 1; line_start = False + + out = [] + run_attr = attrs[0] if n else None + run_len = 0 + for a in attrs: + if a == run_attr: + run_len += 1 + else: + + out.append((run_attr, run_len)); run_attr = a; run_len = 1 + if run_len: + out.append((run_attr, run_len)) + return out + + +class MicronEdit(ReadlineEdit): + def __init__(self, *args, **kwargs): + self._hl_text = None + self._hl_attrib = [] + super().__init__(*args, **kwargs) + + def get_text(self): + text = self.caption + self.edit_text + if text != self._hl_text: + self._hl_text = text + self._hl_attrib = highlight_micron(text) + return text, self._hl_attrib + + +class GutterEdit(MicronEdit): + # MicronEdit with a wrap-aware line-number gutter on the left + def _gutter_width(self, maxcol): + return max(3, len(str(self.edit_text.count("\n") + 1)) + 2) + + def rows(self, size, focus=False): + gw = self._gutter_width(size[0]) + return super().rows((max(1, size[0] - gw),), focus) + + def get_cursor_coords(self, size): + gw = self._gutter_width(size[0]) + coords = super().get_cursor_coords((max(1, size[0] - gw),)) + if coords is None: + return None + return coords[0] + gw, coords[1] + + def move_cursor_to_coords(self, size, x, y): + gw = self._gutter_width(size[0]) + return super().move_cursor_to_coords((max(1, size[0] - gw),), max(0, x - gw), y) + + def render(self, size, focus=False): + maxcol = size[0] + gw = self._gutter_width(maxcol) + tw = max(1, maxcol - gw) + + + + edit_canv = super().render((tw,), focus=False) + h = edit_canv.rows() + rows = [] + for i, line in enumerate(self.edit_text.split("\n"), 1): + rows.append(str(i).rjust(gw - 1) + " ") + rows.extend([""] * (max(1, urwid.Text(line).rows((tw,))) - 1)) + rows = (rows + [""] * h)[:h] + + + + + gutter_canv = urwid.Text([("editor_lineno", "\n".join(r.ljust(gw) for r in rows))], wrap="clip").render((gw,)) + joined = urwid.CanvasJoin([(gutter_canv, None, False, gw), (edit_canv, None, focus, tw)]) + if focus: + coords = super().get_cursor_coords((tw,)) + if coords is not None: + joined.cursor = (coords[0] + gw, coords[1]) + return joined + + def mouse_event(self, size, event, button, x, y, focus): + gw = self._gutter_width(size[0]) + if x < gw: + return True + return super().mouse_event((max(1, size[0] - gw),), event, button, x - gw, y, focus) + + +class FileEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, label, path, depth, executable=False): + self.path = path + self.executable = executable + text = (" " * depth) + "· " + label + (" *" if executable else "") + style = "editor_exec" if executable else None + super().__init__(urwid.AttrMap(urwid.Text(text), style, focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit("click") + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class FolderEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, label, path, depth, expanded, glyphs): + self.path = path + icon = glyphs.get("folder_open" if expanded else "folder", "[-]" if expanded else "[+]") + text = (" " * depth) + icon + " " + label + "/" + super().__init__(urwid.AttrMap(urwid.Text(text), "editor_dir", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit("click") + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class PathDialog(urwid.WidgetWrap): + def __init__(self, title, default, on_ok, on_cancel): + self.on_ok = on_ok + self.on_cancel = on_cancel + self.edit = ReadlineEdit(caption="", edit_text=default) + pile = urwid.Pile([ + urwid.Text(title), + urwid.Divider(), + urwid.AttrMap(self.edit, "list_off_focus", "list_focus"), + urwid.Divider(), + urwid.Columns([ + urwid.Button("OK", on_press=lambda b: self.on_ok(self.edit.edit_text)), + urwid.Button("Cancel", on_press=lambda b: self.on_cancel()), + ], dividechars=2), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + if key == "enter" and self._w.original_widget.original_widget.focus_position == 2: + self.on_ok(self.edit.edit_text); return None + return super().keypress(size, key) + + +class InfoDialog(urwid.WidgetWrap): + def __init__(self, title, message, on_ok): + self.on_ok = on_ok + pile = urwid.Pile([ + urwid.Text(message), + urwid.Divider(), + urwid.Button("OK", on_press=lambda b: on_ok()), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key in ("esc", "enter"): + self.on_ok(); return None + return super().keypress(size, key) + + +class ConfirmDialog(urwid.WidgetWrap): + def __init__(self, title, message, buttons, on_cancel=None): + self.on_cancel = on_cancel + cols = [urwid.Button(label, on_press=lambda b, c=cb: c()) for label, cb in buttons] + pile = urwid.Pile([urwid.Text(message), urwid.Divider(), urwid.Columns(cols, dividechars=2)]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc" and self.on_cancel: + self.on_cancel(); return None + return super().keypress(size, key) + + +class SnippetDialog(urwid.WidgetWrap): + def __init__(self, on_pick, on_cancel): + self.on_cancel = on_cancel + items = [urwid.AttrMap(urwid.Button(name, on_press=lambda b, t=text: on_pick(t)), None, focus_map="list_focus") + for name, text in SNIPPETS] + super().__init__(urwid.LineBox(urwid.ListBox(urwid.SimpleFocusListWalker(items)), title="Insert Micron snippet")) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + return super().keypress(size, key) + + +class FindDialog(urwid.WidgetWrap): + def __init__(self, ed, on_close): + self.ed = ed + self.on_close = on_close + self.find_edit = ReadlineEdit(caption="Find: ") + self.repl_edit = ReadlineEdit(caption="Replace: ") + pile = urwid.Pile([ + urwid.AttrMap(self.find_edit, "list_off_focus", "list_focus"), + urwid.AttrMap(self.repl_edit, "list_off_focus", "list_focus"), + urwid.Divider(), + urwid.Columns([ + urwid.Button("Next", on_press=lambda b: ed.find_next(self.find_edit.edit_text)), + urwid.Button("Replace", on_press=lambda b: ed.replace_one(self.find_edit.edit_text, self.repl_edit.edit_text)), + urwid.Button("All", on_press=lambda b: ed.replace_all(self.find_edit.edit_text, self.repl_edit.edit_text)), + urwid.Button("Close", on_press=lambda b: on_close()), + ], dividechars=1), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title="Find / Replace")) + + def keypress(self, size, key): + if key == "esc": + self.on_close(); return None + return super().keypress(size, key) + + +class PermissionsDialog(urwid.WidgetWrap): + def __init__(self, title, note, content, on_ok, on_cancel): + self.on_ok = on_ok + self.on_cancel = on_cancel + self.edit = ReadlineEdit(caption="", edit_text=content, multiline=True) + pile = urwid.Pile([ + urwid.Text(note), + urwid.Divider(), + urwid.BoxAdapter(urwid.AttrMap(urwid.ListBox(urwid.SimpleListWalker([self.edit])), "list_off_focus", "list_focus"), 7), + urwid.Divider(), + urwid.Columns([ + urwid.Button("Save", on_press=lambda b: self.on_ok(self.edit.edit_text)), + urwid.Button("Cancel", on_press=lambda b: self.on_cancel()), + ], dividechars=2), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + return super().keypress(size, key) + + +class EditorColumns(urwid.Columns): + def keypress(self, size, key): + ed = self.editor_display + if key == "ctrl s": + ed.save_or_saveas(); return None + if key == "ctrl n": + ed.new_file_dialog(); return None + if key == "ctrl f": + ed.find_dialog(); return None + if key == "ctrl g": + ed.snippet_dialog(); return None + if key == "ctrl p": + ed.permissions_dialog(); return None + if key == "f2" and ed.files_focused(): + ed.rename_dialog(); return None + if key == "delete" and ed.files_focused(): + ed.delete_dialog(); return None + if key == "ctrl t": + ed.toggle_pane("files"); return None + if key == "ctrl b": + ed.toggle_pane("editor"); return None + if key == "ctrl r": + ed.toggle_pane("preview"); return None + if key == "tab": + self.focus_position = (self.focus_position + 1) % len(self.contents); return None + if key == "shift tab": + self.focus_position = (self.focus_position - 1) % len(self.contents); return None + if key == "esc": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + return super().keypress(size, key) + + +class PageEditorShortcuts(): + def __init__(self, app): + self.app = app + self.status = urwid.Text("", align=urwid.RIGHT) + base = "[C-n]New [C-s]Save [C-f]Find [C-g]Snippet [C-p]Perms [F2]Rename [Del]Delete [Tab]Pane [C-t/b/r]Toggle [Esc]Menu" + self.widget = urwid.AttrMap(urwid.Columns([urwid.Text(base), self.status], dividechars=2), "shortcutbar") + + def set_status(self, msg): + self.status.set_text(msg) + + +class PageEditorDisplay(): + def __init__(self, app): + self.app = app + self.current_path = None + self.editable = False + self.is_exec = False + self.dirty = False + self.preview_alarm = None + self._loading = False + self._find = None + self._last_file = None + self.expanded = set(p for p in (app.pagespath, app.filespath) if p) + self.filter_mu = False + self.pane_visible = {"files": True, "editor": True, "preview": True} + self.load_state() + self.register_styles() + + self.preview_browser = Browser(self.app, "nomadnetwork", "node", delegate=None) + self.preview_browser.handle_link = self._preview_handle_link + self.preview_browser.marked_link = lambda *a, **k: None + self.preview_browser._content_cols = self._preview_cols + # don't fetch partials in the preview + self.preview_browser.detect_partials = lambda: None + + self.editor = GutterEdit(caption="", edit_text="", multiline=True) + urwid.connect_signal(self.editor, "postchange", self.on_edit_change) + + self.filter_checkbox = urwid.CheckBox("Show only .mu", state=self.filter_mu, on_state_change=self.on_filter_change) + self.tree_walker = urwid.SimpleFocusListWalker(self.build_entries()) + self.tree_listbox = IndicativeListBox(self.tree_walker, initialization_is_selection_change=False, highlight_offFocus="list_off_focus") + self.files_pile = urwid.Pile([ + (urwid.PACK, urwid.AttrMap(self.filter_checkbox, None, focus_map="list_focus")), + (urwid.WEIGHT, 1, self.tree_listbox), + ]) + try: self.files_pile.focus_position = 1 + except Exception: pass + + self.pane_widgets = { + "files": urwid.LineBox(self.files_pile, title="Files"), + "editor": urwid.LineBox(urwid.ListBox(urwid.SimpleListWalker([self.editor])), title="Editor"), + "preview": urwid.LineBox(urwid.SolidFill(" "), title="Preview"), + } + + self.columns = EditorColumns([], dividechars=0) + self.columns.editor_display = self + self._visible = [] + self.rebuild_columns() + + self.shortcuts_display = PageEditorShortcuts(self.app) + self.body = urwid.WidgetPlaceholder(self.columns) + self.widget = self.body + + def register_styles(self): + screen = self.app.ui.screen + for entry in EDITOR_STYLES: + try: screen.register_palette_entry(*entry) + except Exception: pass + + def _preview_handle_link(self, target, link_data=None): + # in the preview follow in page anchors but ignore external navigation + # TODO + + if isinstance(target, str) and target.startswith("#"): + name = target[1:] + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self.preview_browser._jump_to_anchor(name)) + + def _preview_cols(self): + try: + cols = self.app.ui.loop.screen.get_cols_rows()[0] + visible = self.visible_panes() + total = sum(PANE_WEIGHTS[n] for n in visible) + frac = PANE_WEIGHTS["preview"] / total if "preview" in visible else PANE_WEIGHTS["preview"] + return max(20, int(cols * frac) - 3) + except Exception: + return 80 + + def shortcuts(self): + return self.shortcuts_display + + def start(self): + self.refresh_tree() + if self._last_file and os.path.isfile(self._last_file) and self.current_path is None: + self.load_file(self._last_file) + self._last_file = None + + def files_focused(self): + try: return self.visible_panes()[self.columns.focus_position] == "files" + except Exception: return False + + def _state_file(self): + d = getattr(self.app, "configdir", None) + return os.path.join(d, "editor_state.json") if d else None + + def load_state(self): + p = self._state_file() + if not p or not os.path.isfile(p): + return + try: + with open(p) as f: + st = json.load(f) + self.filter_mu = bool(st.get("filter_mu", self.filter_mu)) + if isinstance(st.get("expanded"), list): + self.expanded = set(st["expanded"]) + pv = st.get("pane_visible") + if isinstance(pv, dict): + for k in self.pane_visible: + if k in pv: self.pane_visible[k] = bool(pv[k]) + self._last_file = st.get("last_file") + except Exception: + pass + + def save_state(self): + p = self._state_file() + if not p: + return + try: + self._atomic_write(p, json.dumps({"filter_mu": self.filter_mu, "expanded": sorted(self.expanded), + "pane_visible": self.pane_visible, "last_file": self.current_path})) + except Exception: + pass + + def _selected_tree(self): + try: + sel = self.tree_listbox.get_selected_item() + return sel.original_widget if sel else None + except Exception: + return None + + def _roots(self): + return [r for r in (self.app.pagespath, self.app.filespath) if r] + + def _is_root(self, path): + try: + rp = os.path.realpath(path) + except Exception: + return False + return any(rp == os.path.realpath(r) for r in self._roots()) + + def _confine(self, path, base=None): + # Never save outside of the Nomad storage directory + path = (path or "").strip() + if not path: + return None + path = os.path.expanduser(path) + if not os.path.isabs(path): + base = base or self.app.pagespath or self.app.filespath + if not base: + return None + path = os.path.join(base, path) + norm = os.path.normpath(path) + for root in self._roots(): + rp = os.path.normpath(root) + if norm == rp or norm.startswith(rp + os.sep): + return norm + return None + + def _atomic_write(self, path, text): + d = os.path.dirname(path) or "." + tmp = os.path.join(d, "." + os.path.basename(path) + ".tmp") + try: + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + os.replace(tmp, path) + except Exception: + try: os.remove(tmp) + except Exception: pass + raise + + def _confirm_overwrite(self, dest, proceed): + self.show_dialog(ConfirmDialog("Overwrite", + "%s already exists.\nOverwrite it?" % os.path.basename(dest), + [("Overwrite", proceed), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + + def visible_panes(self): + return [n for n in PANE_ORDER if self.pane_visible[n]] or ["editor"] + + def rebuild_columns(self): + focused = None + try: focused = self._visible[self.columns.focus_position] + except Exception: pass + visible = self.visible_panes() + self.columns.contents = [ + (self.pane_widgets[n], self.columns.options(urwid.WEIGHT, PANE_WEIGHTS[n], box_widget=True)) + for n in visible + ] + self._visible = visible + # the rpeview starts non-selectable + target = focused if focused in visible else ("files" if "files" in visible else visible[0]) + try: self.columns.focus_position = visible.index(target) + except Exception: pass + + def toggle_pane(self, name): + visible = self.visible_panes() + if self.pane_visible[name] and len(visible) <= 1: + return + self.pane_visible[name] = not self.pane_visible[name] + self.rebuild_columns() + self.save_state() + + def refresh_tree(self): + focus_path = None + try: + sel = self.tree_listbox.get_selected_item() + if sel is not None: + focus_path = getattr(sel.original_widget, "path", None) + except Exception: + pass + entries = self.build_entries() + # indicativelistbox wraps items in attrmap for highlighting + self.tree_walker[:] = [urwid.AttrMap(e, None) for e in entries] + if focus_path is not None: + for i, e in enumerate(entries): + if getattr(e, "path", None) == focus_path: + try: self.tree_listbox.select_item(i) + except Exception: pass + break + + def build_entries(self): + entries = [] + for label, root in (("pages", self.app.pagespath), ("files", self.app.filespath)): + if not root or not os.path.isdir(root): + continue + entries.extend(self.walk_dir(label, root, 0)) + if not entries: + entries = [urwid.Text("no files")] + return entries + + def walk_dir(self, name, path, depth): + out = [] + expanded = path in self.expanded + folder = FolderEntry(name, path, depth, expanded, self.app.ui.glyphs) + urwid.connect_signal(folder, "click", self.on_folder_click) + out.append(folder) + if not expanded: + return out + try: + names = sorted(os.listdir(path), key=str.lower) + except Exception: + return out + dirs = [n for n in names if os.path.isdir(os.path.join(path, n)) and not n.startswith(".") and n != "__pycache__"] + files = [n for n in names if not os.path.isdir(os.path.join(path, n)) and not n.startswith(".")] + if self.filter_mu: + files = [n for n in files if n.endswith(".mu")] + dirs = [d for d in dirs if self._dir_has_mu(os.path.join(path, d))] + for d in dirs: + out.extend(self.walk_dir(d, os.path.join(path, d), depth + 1)) + for fn in files: + full = os.path.join(path, fn) + entry = FileEntry(fn, full, depth + 1, executable=is_executable(full)) + urwid.connect_signal(entry, "click", self.on_file_click) + out.append(entry) + return out + + def _dir_has_mu(self, path): + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + if any(f.endswith(".mu") for f in files): + return True + return False + + def on_filter_change(self, checkbox, state): + self.filter_mu = state + self.refresh_tree() + self.save_state() + + def on_folder_click(self, folder): + if folder.path in self.expanded: + self.expanded.discard(folder.path) + else: + self.expanded.add(folder.path) + self.refresh_tree() + self.save_state() + + def on_file_click(self, entry): + self.open_path(entry.path) + + def open_path(self, path): + if self.dirty: + self.confirm_discard(lambda: self._do_open(path)) + else: + self._do_open(path) + + def _do_open(self, path): + self.load_file(path) + if self.pane_visible["editor"]: + try: self.columns.focus_position = self.visible_panes().index("editor") + except Exception: pass + + def confirm_discard(self, proceed): + def save_then(): + self.close_dialog() + if self.current_path and self.editable: + self.save_file(self.current_path) + proceed() + name = os.path.basename(self.current_path) if self.current_path else "this file" + self.show_dialog(ConfirmDialog("Unsaved changes", "Discard unsaved changes to %s?" % name, + [("Save", save_then), ("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + + def load_file(self, path): + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + self.editable = True + except UnicodeDecodeError: + content = "" + self.editable = False + except Exception as e: + self.set_status("open failed: %s" % e); return + + self.current_path = path + self.is_exec = is_executable(path) + self.dirty = False + self._loading = True + self.editor.set_edit_text(content) + self.editor.set_edit_pos(0) + self._loading = False + + name = os.path.basename(path) + self.update_title() + self.save_state() + if not self.editable: + self.set_status("binary file (not editable)") + elif self.is_exec: + self.set_preview_note(name + " is an executable page. the node runs it and serves its output.") + self.set_status("executable page") + self.show_info_dialog("Executable page", name + " is marked executable (chmod +x). NomadNet runs it and serves its output as the page, so the preview shows the source rather than the rendered output.") + else: + self.render_preview() + self.set_status("opened " + name) + + def update_title(self, modified=False): + + + + if self.current_path is None: + self.pane_widgets["editor"].set_title("Editor"); return + suffix = " (binary)" if not self.editable else (" (executable)" if self.is_exec else "") + if modified: + suffix += " *" + self.pane_widgets["editor"].set_title("Editor: " + os.path.basename(self.current_path) + suffix) + + def set_preview_note(self, msg): + self.pane_widgets["preview"].original_widget = urwid.Filler(urwid.Text(msg), urwid.TOP) + + def show_info_dialog(self, title, message): + self.show_dialog(InfoDialog(title, message, self.close_dialog)) + + def save_or_saveas(self): + if self.current_path is None: + self.save_as_dialog(); return + self.save_file(self.current_path) + + def save_file(self, path): + if not self.editable and self.current_path is not None: + self.set_status("cannot save binary file"); return + try: + self._atomic_write(path, self.editor.edit_text) + self.current_path = path + self.editable = True + self.dirty = False + self.set_modified(False) + self.set_status("saved " + os.path.basename(path)) + self.refresh_tree() + self.save_state() + except Exception as e: + self.set_status("save failed: %s" % e) + + def new_file_dialog(self): + default = (self.app.pagespath or "") + os.sep + self.show_dialog(PathDialog("New (end with / for a folder)", default, self._do_new, self.close_dialog)) + + def _do_new(self, path): + self.close_dialog() + raw = (path or "").strip() + if not raw: + return + is_folder = raw.endswith("/") or raw.endswith(os.sep) + target = self._confine(raw) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + try: + if is_folder: + os.makedirs(target, exist_ok=True) + self.expanded.add(target) + self.refresh_tree(); self.save_state() + self.set_status("created folder " + os.path.basename(target)) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + if not os.path.exists(target): + self._atomic_write(target, "") + self.refresh_tree() + self.open_path(target) + except Exception as e: + self.set_status("new failed: %s" % e) + + def rename_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder to rename"); return + if self._is_root(w.path): + self.set_status("cannot rename the pages/files root"); return + self.show_dialog(PathDialog("Rename", w.path, lambda p, old=w.path: self._do_rename(old, p), self.close_dialog)) + + def _do_rename(self, old, new): + self.close_dialog() + target = self._confine(new, base=os.path.dirname(old)) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + if target == os.path.normpath(old): + return + + def proceed(): + self.close_dialog() + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + os.rename(old, target) + if self.current_path == old: + self.current_path = target; self.update_title() + if old in self.expanded: + self.expanded.discard(old); self.expanded.add(target) + self.refresh_tree(); self.save_state() + self.set_status("renamed to " + os.path.basename(target)) + except Exception as e: + self.set_status("rename failed: %s" % e) + + if os.path.exists(target) and os.path.realpath(target) != os.path.realpath(old): + self._confirm_overwrite(target, proceed) + else: + proceed() + + def delete_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder to delete"); return + path = w.path + if self._is_root(path): + self.set_status("cannot delete the pages/files root"); return + self.show_dialog(ConfirmDialog("Delete", "Delete %s?" % os.path.basename(path), + [("Delete", lambda: self._do_delete(path)), ("Cancel", self.close_dialog)], on_cancel=self.close_dialog)) + + def _do_delete(self, path): + self.close_dialog() + try: + if os.path.isdir(path): + shutil.rmtree(path); self.expanded.discard(path) + else: + os.remove(path) + if self.current_path == path: + self.current_path = None; self.editable = False; self.is_exec = False; self.dirty = False + self._loading = True; self.editor.set_edit_text(""); self._loading = False + self.update_title(); self.set_preview_note("") + self.refresh_tree(); self.save_state() + self.set_status("deleted " + os.path.basename(path)) + except Exception as e: + self.set_status("delete failed: %s" % e) + + def permissions_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder for permissions"); return + path = w.path + is_dir = os.path.isdir(path) + content = "" + if not is_dir and os.path.isfile(path + ".allowed"): + try: content = open(path + ".allowed", encoding="utf-8").read() + except Exception: content = "" + note = ("Allowed identity hashes per line. Empty = public.\n" + + ("Applies to every page in this folder." if is_dir else "Applys to this page.")) + title = "Permissions: " + os.path.basename(path) + ("/" if is_dir else "") + self.show_dialog(PermissionsDialog(title, note, content, + lambda txt: self._save_permissions(path, is_dir, txt), self.close_dialog)) + + def _save_permissions(self, path, is_dir, text): + self.close_dialog() + body = "\n".join(ln.strip() for ln in text.splitlines() if ln.strip()) + try: + if is_dir: + count = 0 + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + for fn in files: + if fn.endswith(".allowed"): + continue + self._write_allowed(os.path.join(root, fn), body) + count += 1 + self.set_status("permissions applied to %d files" % count) + else: + self._write_allowed(path, body) + self.set_status("permissions saved" if body else "permissions cleared (public)") + self.refresh_tree() + except Exception as e: + self.set_status("permissions failed: %s" % e) + + def _write_allowed(self, path, body): + ap = path + ".allowed" + if body: + self._atomic_write(ap, body + "\n") + elif os.path.exists(ap): + os.remove(ap) + + def snippet_dialog(self): + self.show_dialog(SnippetDialog(self._insert_snippet, self.close_dialog)) + + def _insert_snippet(self, text): + self.close_dialog() + if self.pane_visible["editor"]: + try: self.columns.focus_position = self.visible_panes().index("editor") + except Exception: pass + pos = self.editor.edit_pos + t = self.editor.edit_text + self.editor.set_edit_text(t[:pos] + text + t[pos:]) + self.editor.set_edit_pos(pos + len(text)) + + def find_dialog(self): + if self._find is None: + self._find = FindDialog(self, self.close_dialog) + self.show_dialog(self._find) + + def find_next(self, term): + if not term: + return + t = self.editor.edit_text + idx = t.find(term, self.editor.edit_pos) + if idx < 0: + idx = t.find(term, 0) + if idx < 0: + self.set_status("'%s' not found" % term); return + self.editor.set_edit_pos(idx + len(term)) + self.set_status("found at %d" % idx) + + def replace_one(self, term, repl): + if not term: + return + t = self.editor.edit_text + end = self.editor.edit_pos + start = end - len(term) + if start >= 0 and t[start:end] == term: + self.editor.set_edit_text(t[:start] + repl + t[end:]) + self.editor.set_edit_pos(start + len(repl)) + self.set_status("replaced") + else: + self.find_next(term) + + def replace_all(self, term, repl): + if not term: + return + cnt = self.editor.edit_text.count(term) + if cnt: + self.editor.set_edit_text(self.editor.edit_text.replace(term, repl)) + self.set_status("replaced %d" % cnt) + else: + self.set_status("'%s' not found" % term) + + def save_as_dialog(self): + default = self.current_path or ((self.app.pagespath or "") + os.sep) + self.show_dialog(PathDialog("Save as", default, self._do_saveas, self.close_dialog)) + + def _do_saveas(self, path): + self.close_dialog() + target = self._confine(path) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + + def proceed(): + self.close_dialog() + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + except Exception: + pass + self.editable = True + self.save_file(target) + + already = self.current_path is None or os.path.realpath(target) != os.path.realpath(self.current_path) + if os.path.exists(target) and already: + self._confirm_overwrite(target, proceed) + else: + proceed() + + def show_dialog(self, dialog): + self.dialog = dialog + overlay = urwid.Overlay( + dialog, self.columns, + align=urwid.CENTER, width=(urwid.RELATIVE, 60), + valign=urwid.MIDDLE, height=(urwid.RELATIVE, 40), + min_width=30, min_height=8, + ) + self.body.original_widget = overlay + + def close_dialog(self): + self.dialog = None + self.body.original_widget = self.columns + + def on_edit_change(self, widget, old_text): + if self._loading or not self.editable: + return + self.dirty = True + self.set_modified(True) + if self.is_exec: + return + if self.preview_alarm is not None: + try: self.app.ui.loop.remove_alarm(self.preview_alarm) + except Exception: pass + self.preview_alarm = self.app.ui.loop.set_alarm_in(PREVIEW_DELAY, self._render_preview_alarm) + + def _render_preview_alarm(self, loop, user_data): + self.preview_alarm = None + self.render_preview() + + def render_preview(self): + pos = self._preview_scrollpos() + try: + body = self.preview_browser.render_markup_buffer(self.editor.edit_text) + except Exception as e: + body = urwid.Filler(urwid.Text("preview error: %s" % e), urwid.TOP) + self.pane_widgets["preview"].original_widget = body + self._set_preview_scrollpos(pos) + + def _preview_scrollable(self): + try: + return self.preview_browser.browser_body.original_widget.original_widget + except Exception: + return None + + def _preview_scrollpos(self): + s = self._preview_scrollable() + try: return s.get_scrollpos() + except Exception: return None + + def _set_preview_scrollpos(self, pos): + if pos is None: return + s = self._preview_scrollable() + try: s.set_scrollpos(pos) + except Exception: pass + + def set_modified(self, modified): + self.update_title(modified) + + def set_status(self, msg): + self.shortcuts_display.set_status(msg) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 4316266..5ac7241 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1420,6 +1420,7 @@ With micron you can easily create structured documents and pages with formatting `F79d`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f `F79d`_`[Micron Tags`#micron-tags]`_`f `F79d`_`[High Level Stuff`#high-level-stuff]`_`f + `F79d`_`[Collapsible Sections`#collapsible-sections]`_`f `F79d`_`[Colors`#colors]`_`f `F79d`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f `F79d`_`[Links`#links]`_`f @@ -1587,6 +1588,32 @@ A sub sub section. We could continue, but you get the point. Wait! It's worth noting that we can also create sections without headings. They look like this. `` +>Collapsible Sections + +Any section heading can be made collapsible, turning it into an accordion the reader can fold away and reveal on demand. Prefix a heading with \\`+ to make it foldable and open by default, or with \\`- to make it foldable but start collapsed: + +`Faaa +`= +`+>Open by default +This text can be folded away by selecting the heading. + +`->Hidden until expanded +This starts collapsed, and appears when the reader expands it. +`= +`` + +A collapsible heading folds everything beneath it, up to the next heading of the same or a higher level, exactly like a normal section. It can therefore contain anything: text, tables, input fields, sub-sections, and even live partials. + +To fold or unfold a section, move onto its heading and press Enter or Space, or click it with the mouse. Open sections are marked with ▾ and collapsed ones with ▸. + +You can choose your own indicators for a page by placing a #!fold directive near the top, giving the open glyph followed by the collapsed glyph: + +`Faaa +`= +#!fold [-] [+] +`= +`` + >Colors @@ -1781,6 +1808,14 @@ A sized input field: `B444`<16|with_size`>`b `= `` +You can also reserve more than one row for a field by appending `!xROWS`! to the width, which is useful for longer, multi-line input. This creates a field that is 40 columns wide and 5 rows tall: + +`Faaa +`= +A multi-row input field: `B444`<40x5|notes`>`b +`= +`` + It is possible to mask fields, for example for use with passwords and similar: `Faaa @@ -1809,6 +1844,8 @@ A sized input field: `B444`<16|with_size`>`B333 A masked input field: `B444``B333 +A multi-row input field: `B444`<40x5|notes`>`B333 + Full control: `B444``B333 `b diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py index ec0816e..ea97bee 100644 --- a/nomadnet/ui/textui/Helpers.py +++ b/nomadnet/ui/textui/Helpers.py @@ -17,6 +17,21 @@ def osc52_copy(text): return False +def qr_ascii(data): + try: + import qrcode + import io + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) + qr.add_data(data) + qr.make() + buf = io.StringIO() + qr.print_ascii(out=buf, invert=False) + return buf.getvalue().rstrip("\n") + except Exception as e: + RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) + return None + + class ClickableIcon(urwid.Text): _selectable = False diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 9f14c22..a50d8b7 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -9,6 +9,7 @@ from .Interfaces import * from .Map import * from .Log import * from .Guide import * +from .Editor import * import urwid class SubDisplays(): @@ -23,6 +24,7 @@ class SubDisplays(): self.map_display = MapDisplay(self.app) self.log_display = LogDisplay(self.app) self.guide_display = GuideDisplay(self.app) + self.page_editor_display = PageEditorDisplay(self.app) if app.enable_node else None if app.firstrun: self.active_display = self.guide_display @@ -136,6 +138,14 @@ class MainDisplay(): self.sub_displays.active_display = self.sub_displays.guide_display self.update_active_sub_display() + def show_page_editor(self, user_data): + if not self.app.enable_node: + return + self.sub_displays.active_display = self.sub_displays.page_editor_display + self.update_active_sub_display() + self.sub_displays.page_editor_display.start() + self.frame.focus_position = "body" + def update_active_sub_display(self): self.frame.contents["body"] = (self.sub_displays.active().widget, None) self.update_active_shortcuts() @@ -195,13 +205,17 @@ class MenuDisplay(): button_config = (10, MenuButton("Config", on_press=handler.show_config)) button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) + button_mynode = (11, MenuButton("My Node", on_press=handler.show_page_editor)) button_quit = (8, MenuButton("Quit", on_press=handler.quit)) # buttons = [menu_text, button_conversations, button_node, button_directory, button_map] - if self.app.config["textui"]["hide_guide"]: - buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_quit] - else: - buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_guide, button_quit] + buttons = [menu_text, button_conversations, button_network] + if self.app.enable_node: + buttons.append(button_mynode) + buttons += [button_channels, button_log, button_interfaces, button_config] + if not self.app.config["textui"]["hide_guide"]: + buttons.append(button_guide) + buttons.append(button_quit) columns = MenuColumns(buttons, dividechars=1) columns.handler = handler diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index c274b66..0add998 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -36,6 +36,15 @@ SECTION_INDENT = 2 INDENT_RIGHT = 1 MAX_TABLE_WIDTH = 100 +DEFAULT_FOLD_GLYPHS = ("▾", "▸") + +def default_fold_glyphs(): + try: + g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs + return (g.get("fold_open", DEFAULT_FOLD_GLYPHS[0]), g.get("fold_closed", DEFAULT_FOLD_GLYPHS[1])) + except Exception: + return DEFAULT_FOLD_GLYPHS + def default_state(fg=None, bg=None): global SELECTED_STYLES ensure_selected_styles() @@ -132,15 +141,26 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None if state.get("_header_pending"): state["header_rows"].append(row_index) + state.setdefault("header_levels", {})[row_index] = state["depth"] + cp = state.get("_collapsible_pending") + if cp is not None: + state.setdefault("collapsibles", {})[row_index] = bool(cp["collapsed"]) + state["_collapsible_pending"] = None state["_header_pending"] = False + row_levels = state.setdefault("row_levels", {}) for display_widget in display_widgets: + row_levels[len(attrmaps)] = state["depth"] attrmap = urwid.AttrMap(display_widget, make_style(state)) attrmaps.append(attrmap) try: setattr(attrmaps, "anchors", anchors) setattr(attrmaps, "header_rows", list(state.get("header_rows", []))) + setattr(attrmaps, "header_levels", dict(state.get("header_levels", {}))) + setattr(attrmaps, "row_levels", dict(state.get("row_levels", {}))) + setattr(attrmaps, "collapsibles", dict(state.get("collapsibles", {}))) + setattr(attrmaps, "fold_glyphs", state.get("fold_glyphs") or default_fold_glyphs()) except Exception: pass @@ -222,6 +242,7 @@ class FormColumns(urwid.Columns): MIN_FIELD_WIDTH = 4 + @staticmethod def _natural_width(widget, maxcol, focused): # mirror the width urwid.Columns itself computes for a PACK column if not isinstance(widget, urwid.Widget): @@ -297,6 +318,95 @@ class FormColumns(urwid.Columns): return widths +class MicronField(ReadlineEdit): + def __init__(self, *args, min_rows=1, **kwargs): + try: self._min_rows = max(1, int(min_rows)) + except (TypeError, ValueError): self._min_rows = 1 + super().__init__(*args, **kwargs) + + def rows(self, size, focus=False): + return max(self._min_rows, super().rows(size, focus)) + + def render(self, size, focus=False): + canv = super().render(size, focus) + if canv.rows() < self._min_rows: + canv = urwid.CompositeCanvas(canv) + canv.pad_trim_top_bottom(0, self._min_rows - canv.rows()) + return canv + + +class CollapsibleHeading(urwid.WidgetWrap): + _selectable = True + ignore_focus = False + + def __init__(self, segments, align, heading_style, depth, + collapsed=False, glyphs=None, delegate=None): + self._segments = list(segments) + self.align = align + self.heading_style = heading_style + self.depth = depth + self.collapsed = bool(collapsed) + self.glyphs = glyphs if glyphs is not None else default_fold_glyphs() + self.delegate = delegate + self.is_collapsible_heading = True + super().__init__(self._build()) + + def selectable(self): + return True + + def _glyph(self): + return self.glyphs[1] if self.collapsed else self.glyphs[0] + + def _glyph_col(self): + if self._segments and isinstance(self._segments[0], str): + return len(self._segments[0]) + return 0 + + def _build(self): + segs = list(self._segments) + marker = self._glyph() + " " + if segs and isinstance(segs[0], str): + segs.insert(1, marker) + else: + segs.insert(0, marker) + return urwid.AttrMap(urwid.Text(segs, align=self.align), self.heading_style) + + def set_collapsed(self, collapsed): + collapsed = bool(collapsed) + if collapsed != self.collapsed: + self.collapsed = collapsed + self._w = self._build() + self._invalidate() + + def toggle(self): + self.collapsed = not self.collapsed + self._w = self._build() + self._invalidate() + if self.delegate is not None and hasattr(self.delegate, "fold_changed"): + self.delegate.fold_changed(self) + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE or key == " ": + self.toggle() + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and is_mouse_press(event): + self.toggle() + return True + return False + + def render(self, size, focus=False): + canv = super().render(size, focus) + if focus: + canv = urwid.CompositeCanvas(canv) + gx = self._glyph_col() + if self.align == "left" and gx < size[0]: + canv.cursor = (gx, 0) + return canv + + def parse_line(line, state, url_delegate): pre_escape = False if len(line) > 0: @@ -309,6 +419,14 @@ def parse_line(line, state, url_delegate): # Only parse content if not in literal state if not state["literal"]: + collapsible_heading = False + collapsed_initial = False + if (line.startswith("`+") or line.startswith("`-")) and line[2:3] == ">": + collapsible_heading = True + collapsed_initial = line[1] == "-" + line = line[2:] + first_char = line[0] + # Apply markup sanitization if first_char == ">" and "`<" in line: # Remove heading status from lines containing fields @@ -322,6 +440,10 @@ def parse_line(line, state, url_delegate): # Check for comments elif first_char == "#": + parts = line.split() + if parts and parts[0] == "#!fold": + if len(parts) >= 3: state["fold_glyphs"] = (parts[1], parts[2]) + elif len(parts) == 2: state["fold_glyphs"] = (parts[1], parts[1]) return None # Check for tables @@ -395,6 +517,11 @@ def parse_line(line, state, url_delegate): heading_style = first_style output.insert(0, " "*left_indent(state)) + if collapsible_heading: + state["_collapsible_pending"] = {"collapsed": collapsed_initial} + glyphs = state.get("fold_glyphs") or default_fold_glyphs() + return [CollapsibleHeading(output, state["align"], heading_style, + state["depth"], collapsed_initial, glyphs, url_delegate)] return [urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)] else: return None @@ -440,8 +567,9 @@ def parse_line(line, state, url_delegate): fd = o["data"] fn = o["name"] fs = o["style"] + frows = o.get("rows", 1) fmask = "*" if o["masked"] else None - f = ReadlineEdit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) + f = MicronField(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask, min_rows=frows) f.field_name = fn fa = urwid.AttrMap(f, fs) widgets.append((fw, fa)) @@ -759,11 +887,12 @@ def make_output(state, line, url_delegate, pre_escape=False): field_content = line[field_start:backtick_pos] field_masked = False field_width = 24 + field_rows = 1 field_type = "field" field_name = field_content field_value = "" field_data = "" - field_prechecked = False + field_prechecked = False # check if field_content contains '|' if '|' in field_content: @@ -784,10 +913,16 @@ def make_output(state, line, url_delegate, pre_escape=False): # Handle field width if len(field_flags) > 0: + width_flag, _, rows_flag = field_flags.partition("x") try: - field_width = min(int(field_flags), 256) + field_width = min(int(width_flag), 256) except ValueError: pass # Ignore invalid width + if rows_flag: + try: + field_rows = max(1, min(int(rows_flag), 256)) + except ValueError: + pass # Check for value and pre-checked flag if len(f_components) > 2: @@ -831,6 +966,7 @@ def make_output(state, line, url_delegate, pre_escape=False): "type": "field", "name": field_name, "width": field_width, + "rows": field_rows, "masked": field_masked, "data": field_data, "style": make_style(state) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 0332139..3818f6c 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -11,6 +11,7 @@ from nomadnet.util import sanitize_name from .Browser import Browser from .ReadlineEdit import ReadlineEdit +from .Helpers import qr_ascii class NetworkDisplayShortcuts(): def __init__(self, app): @@ -26,7 +27,17 @@ class NetworkDisplayShortcuts(): class DialogLineBox(urwid.LineBox): def keypress(self, size, key): if key == "esc": - self.delegate.update_conversation_list() + delegate = getattr(self, "delegate", None) + if delegate is not None and hasattr(delegate, "update_conversation_list"): + delegate.update_conversation_list() + elif delegate is not None and getattr(delegate, "parent", None) is not None: + try: + delegate.dialog_open = False + options = delegate.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + delegate.parent.left_pile.contents[1] = (type(delegate)(delegate.app, delegate.parent), options) + except Exception: + pass + return None else: return super(DialogLineBox, self).keypress(size, key) @@ -82,7 +93,7 @@ class AnnounceInfo(urwid.WidgetWrap): is_node = False is_pn = False if info_type == "node" or info_type == True: - type_string = "Nomad Network Node " + g["node"] + type_string = "NomadNet Node " + g["node"] is_node = True elif info_type == "pn": type_string = "LXMF Propagation Node " + g["sent"] @@ -634,7 +645,7 @@ class KnownNodeInfo(urwid.WidgetWrap): lxmf_addr_str = "No associated Propagation Node known" - type_string = "Nomad Network Node " + g["node"] + type_string = "NomadNet Node " + g["node"] if trust_level == DirectoryEntry.UNTRUSTED: trust_str = "Untrusted" @@ -1331,22 +1342,27 @@ class LocalPeer(urwid.WidgetWrap): announce_button = urwid.Button("Announce Now", on_press=announce_query) - self.display_widget = urwid.Pile( - [ - t_id, - i_id, - e_name, - urwid.Divider(g["divider1"]), - self.t_last_announce, - announce_button, - urwid.Divider(g["divider1"]), - urwid.Columns([ - (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), - (urwid.WEIGHT, 0.1, urwid.Text("")), - (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), - ]), - ] - ) + peer_widgets = [ + t_id, + i_id, + e_name, + urwid.Divider(g["divider1"]), + self.t_last_announce, + announce_button, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), + ]), + ] + + if self.app.enable_node: + peer_widgets.append( + urwid.AttrMap(urwid.Text(" "+g["node"]+" Node Online", align=urwid.LEFT), "shortcutbar") + ) + + self.display_widget = urwid.Pile(peer_widgets) super().__init__(urwid.LineBox(self.display_widget, title="Local Peer Info")) @@ -1420,6 +1436,37 @@ class NodeInfo(urwid.WidgetWrap): def connect_query(sender): self.parent.browser.retrieve_url(RNS.hexrep(self.app.node.destination.hash, delimit=False)) + def edit_query(sender): + self.app.ui.main_display.show_page_editor(None) + + def show_qr_query(sender): + def dismiss_dialog(sender): + self.dialog_open = False + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (NodeInfo(self.app, self.parent), options) + + dest_addr = RNS.hexrep(self.app.node.destination.hash, delimit=False) + qr_text = qr_ascii(dest_addr) + + rows = [urwid.Text("")] + if qr_text is not None: + rows.append(urwid.Text(qr_text, align=urwid.CENTER)) + else: + rows.append(urwid.Text("Node destination address:", align=urwid.CENTER)) + rows += [ + urwid.Text(""), + urwid.Text("< "+dest_addr+" >", align=urwid.CENTER), + urwid.Text(""), + urwid.Button("Close", on_press=dismiss_dialog), + ] + + dialog = DialogLineBox(urwid.Pile(rows), title="Node Destination") + dialog.delegate = self + + self.dialog_open = True + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (dialog, options) + if NodeInfo.announce_timer == None: self.t_last_announce = NodeAnnounceTime(self.app) NodeInfo.announce_timer = self.t_last_announce @@ -1467,7 +1514,9 @@ class NodeInfo(urwid.WidgetWrap): announce_button = urwid.Button("Announce", on_press=announce_query) connect_button = urwid.Button("Browse", on_press=connect_query) + edit_button = urwid.Button("Edit", on_press=edit_query) reset_button = urwid.Button("Rst Stats", on_press=stats_query) + qr_button = urwid.Button("Show Destination", on_press=show_qr_query) if not self.app.disable_propagation: pile = urwid.Pile([ @@ -1483,14 +1532,13 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_pages, self.t_total_files, urwid.Divider(g["divider1"]), - urwid.Columns([ - (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 6, connect_button), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 8, reset_button), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 7, announce_button), + urwid.Pile([ + urwid.Button("Peer Info", on_press=show_peer_info), + connect_button, + edit_button, + reset_button, + announce_button, + qr_button, ]) ]) else: @@ -1505,14 +1553,13 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_pages, self.t_total_files, urwid.Divider(g["divider1"]), - urwid.Columns([ - (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 6, connect_button), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 8, reset_button), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 7, announce_button), + urwid.Pile([ + urwid.Button("Peer Info", on_press=show_peer_info), + connect_button, + edit_button, + reset_button, + announce_button, + qr_button, ]) ]) else: @@ -1640,10 +1687,11 @@ class NetworkDisplay(): self.known_nodes_display.delegate = self self.list_display = 1 + default_info_display = self.node_info_display if self.app.enable_node else self.local_peer_display self.left_pile = NetworkLeftPile([ (urwid.WEIGHT, 1, self.known_nodes_display), # (urwid.PACK, self.network_stats_display), - (urwid.PACK, self.local_peer_display), + (urwid.PACK, default_info_display), ]) self.left_pile.parent = self From 7b6607928d565d5619ec50183d85a37e6426eb9a Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:04:36 -0400 Subject: [PATCH 507/592] Add highlight and preview character limit --- nomadnet/ui/textui/Editor.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py index bc3d4a3..fd66046 100644 --- a/nomadnet/ui/textui/Editor.py +++ b/nomadnet/ui/textui/Editor.py @@ -12,6 +12,8 @@ from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox PREVIEW_DELAY = 0.4 PANE_WEIGHTS = {"files": 0.22, "editor": 0.40, "preview": 0.38} PANE_ORDER = ["files", "editor", "preview"] +MAX_HIGHLIGHT_CHARS = 50000 +MAX_PREVIEW_CHARS = 100000 @@ -140,7 +142,7 @@ class MicronEdit(ReadlineEdit): text = self.caption + self.edit_text if text != self._hl_text: self._hl_text = text - self._hl_attrib = highlight_micron(text) + self._hl_attrib = [] if len(text) > MAX_HIGHLIGHT_CHARS else highlight_micron(text) return text, self._hl_attrib @@ -173,10 +175,15 @@ class GutterEdit(MicronEdit): edit_canv = super().render((tw,), focus=False) h = edit_canv.rows() + cheap = len(self.edit_text) > MAX_HIGHLIGHT_CHARS rows = [] for i, line in enumerate(self.edit_text.split("\n"), 1): rows.append(str(i).rjust(gw - 1) + " ") - rows.extend([""] * (max(1, urwid.Text(line).rows((tw,))) - 1)) + if cheap: + wrapped = max(1, (len(line) + tw - 1) // tw) + else: + wrapped = max(1, urwid.Text(line).rows((tw,))) + rows.extend([""] * (wrapped - 1)) rows = (rows + [""] * h)[:h] @@ -1021,6 +1028,9 @@ class PageEditorDisplay(): self.render_preview() def render_preview(self): + if len(self.editor.edit_text) > MAX_PREVIEW_CHARS: + self.set_preview_note("File too large to preview (%d KB). Editing still works." % (len(self.editor.edit_text) // 1000)) + return pos = self._preview_scrollpos() try: body = self.preview_browser.render_markup_buffer(self.editor.edit_text) From fb7b42bea2dc2cfc5212bd9bbd7a1aa8d2a33eff Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:19:51 -0400 Subject: [PATCH 508/592] Interface: Add Profiles and grid-view option. Add option to paste in an Interface --- nomadnet/InterfaceProfiles.py | 234 ++++++++++ nomadnet/NomadNetworkApp.py | 3 + nomadnet/ui/TextUI.py | 4 + nomadnet/ui/textui/Interfaces.py | 768 ++++++++++++++++++++++++------- 4 files changed, 834 insertions(+), 175 deletions(-) create mode 100644 nomadnet/InterfaceProfiles.py diff --git a/nomadnet/InterfaceProfiles.py b/nomadnet/InterfaceProfiles.py new file mode 100644 index 0000000..91f9324 --- /dev/null +++ b/nomadnet/InterfaceProfiles.py @@ -0,0 +1,234 @@ +import os +import threading + +import RNS +import RNS.vendor.umsgpack as msgpack + + +class InterfaceProfiles: + VERSION = 1 + + def __init__(self, app): + self.app = app + self.path = os.path.join(app.storagepath, "interface_profiles.msgpack") + self._lock = threading.Lock() + self.profiles = [] + self.load() + + def load(self): + self.profiles = [] + self.default_members = None + try: + if not os.path.isfile(self.path): + return + with open(self.path, "rb") as f: + data = msgpack.unpackb(f.read()) + if not isinstance(data, dict): + return + clean = [] + seen = set() + for p in data.get("profiles") or []: + if not isinstance(p, dict): + continue + pid = str(p.get("id") or "") + if not pid or pid in seen: + continue + members = [str(m) for m in (p.get("members") or []) if isinstance(m, (str, bytes))] + clean.append({"id": pid, "name": str(p.get("name") or ""), "members": members}) + seen.add(pid) + self.profiles = clean + dm = data.get("default_members") + self.default_members = [str(m) for m in dm] if isinstance(dm, list) else None + except Exception as e: + RNS.log("Could not load interface profiles: "+str(e), RNS.LOG_ERROR) + self.profiles = [] + self.default_members = None + + def save(self): + with self._lock: + tmp = self.path + ".tmp" + try: + payload = { + "version": InterfaceProfiles.VERSION, + "profiles": [{"id": p["id"], "name": p["name"], "members": list(p["members"])} for p in self.profiles], + "default_members": self.default_members, + } + with open(tmp, "wb") as f: + f.write(msgpack.packb(payload)) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + os.replace(tmp, self.path) + except Exception as e: + RNS.log("Could not save interface profiles: "+str(e), RNS.LOG_ERROR) + try: os.remove(tmp) + except Exception: pass + + def _interfaces(self): + try: + return self.app.rns.config["interfaces"] + except Exception: + return {} + + def interface_names(self): + try: + return list(self._interfaces().keys()) + except Exception: + return [] + + def _is_enabled(self, iface): + return str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \ + str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + def enabled_set(self): + interfaces = self._interfaces() + result = set() + for name in interfaces: + try: + if self._is_enabled(interfaces[name]): + result.add(name) + except Exception: + pass + return result + + def get(self, pid): + for p in self.profiles: + if p["id"] == pid: + return p + return None + + def members_existing(self, p): + existing = set(self.interface_names()) + return set(m for m in p["members"] if m in existing) + + def active_profile_id(self): + enabled = self.enabled_set() + for p in self.profiles: + if self.members_existing(p) == enabled: + return p["id"] + return None + + def profiles_for(self, iface_name): + return [p for p in self.profiles if iface_name in p["members"]] + + def label_for(self, iface_name): + names = [p["name"] or p["id"] for p in self.profiles_for(iface_name)] + return ", ".join(names) if names else None + + def member_count(self, p): + return len(self.members_existing(p)) + + def _new_id(self): + while True: + pid = os.urandom(4).hex() + if not self.get(pid): + return pid + + def create(self, name): + pid = self._new_id() + self.profiles.append({"id": pid, "name": name or "Profile", "members": []}) + self.save() + return pid + + def rename(self, pid, name): + p = self.get(pid) + if p is not None and name: + p["name"] = name + self.save() + + def delete(self, pid): + self.profiles = [p for p in self.profiles if p["id"] != pid] + self.save() + + def set_members(self, pid, members): + p = self.get(pid) + if p is not None: + valid = set(self.interface_names()) + seen = set() + ordered = [] + for m in members: + if m in valid and m not in seen: + ordered.append(m) + seen.add(m) + p["members"] = ordered + self.save() + + def set_interface_profiles(self, iface_name, profile_ids): + pset = set(profile_ids) + for p in self.profiles: + if p["id"] in pset: + if iface_name not in p["members"]: + p["members"].append(iface_name) + elif iface_name in p["members"]: + p["members"] = [m for m in p["members"] if m != iface_name] + self.save() + + def _apply_members(self, members): + existing = set(self.interface_names()) + members = set(m for m in members if m in existing) + interfaces = self._interfaces() + try: + for name in interfaces: + value = "true" if name in members else "false" + interfaces[name]["interface_enabled"] = value + if "enabled" in interfaces[name]: + interfaces[name]["enabled"] = value + self.app.rns.config.write() + return True + except Exception as e: + RNS.log("Could not apply interface profile: "+str(e), RNS.LOG_ERROR) + return False + + def select_profile(self, pid): + p = self.get(pid) + if p is None: + return False + return self._apply_members(self.members_existing(p)) + + def select_default(self): + if self.default_members is None: + return False + return self._apply_members(set(self.default_members)) + + def update_default_if_custom(self): + # Remember the live enabled-set as "Default" whenever it matches no named + # profile -- i.e. it's the user's manual setup. Only relevant once profiles exist. + if not self.profiles: + return + enabled = self.enabled_set() + matches_named = any(self.members_existing(p) == enabled for p in self.profiles) + if matches_named and self.default_members is not None: + return + new_default = sorted(enabled) + if self.default_members != new_default: + self.default_members = new_default + self.save() + + def remove_interface(self, iface_name): + changed = False + for p in self.profiles: + if iface_name in p["members"]: + p["members"] = [m for m in p["members"] if m != iface_name] + changed = True + if changed: + self.save() + + def rename_interface(self, old_name, new_name): + changed = False + for p in self.profiles: + if old_name in p["members"]: + p["members"] = [new_name if m == old_name else m for m in p["members"]] + changed = True + if changed: + self.save() + + def prune(self): + valid = set(self.interface_names()) + changed = False + for p in self.profiles: + pruned = [m for m in p["members"] if m in valid] + if len(pruned) != len(p["members"]): + p["members"] = pruned + changed = True + if changed: + self.save() diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 522a77c..24a75a7 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -330,6 +330,9 @@ class NomadNetworkApp: self.rrc = RRCManager(self) self.rrc.load() + from nomadnet.InterfaceProfiles import InterfaceProfiles + self.interface_profiles = InterfaceProfiles(self) + static_peers = [] for static_peer in self.static_peers: try: diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 9ba9d7d..dbbd08d 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -55,6 +55,7 @@ THEMES = { ("progress_empty", "light gray", "default", "default", "#ddd", "default"), ("interface_title", "", "", "default", "", ""), ("interface_title_selected", "bold", "", "bold", "", ""), + ("interface_tile_focus", "light cyan,bold", "default", "bold", "#3dd,bold", "default"), ("connected_status", "dark green", "default", "default", "dark green", "default"), ("disconnected_status", "dark red", "default", "default", "dark red", "default"), ("placeholder", "dark gray", "default", "default", "dark gray", "default"), @@ -108,6 +109,7 @@ THEMES = { ("progress_empty", "dark gray", "default", "default", "#ddd", "default"), ("interface_title", "dark gray", "default", "default", "#444", "default"), ("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"), + ("interface_tile_focus", "dark blue,bold", "default", "bold", "#06a,bold", "default"), ("connected_status", "dark green", "default", "default", "#4a0", "default"), ("disconnected_status", "dark red", "default", "default", "#a22", "default"), ("placeholder", "light gray", "default", "default", "#999", "default"), @@ -164,6 +166,8 @@ GLYPHS = { ("qrcode", "QR", "\u25a4", "\uf029"), ("selected", "[*] ", "\u25CF", "\u25CF"), ("unselected", "[ ] ", "\u25CB", "\u25CB"), + ("focus_arrow", ">", "\u25B8", "\u25B8"), + ("sep_dot", "|", "\u00B7", "\u00B7"), ("file", "[F]", "\u25a4", "\uf15b"), ("image", "[I]", "\u25a3", "\uf1c5"), ("audio", "[~]", "\u266b", "\uf1c7"), diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index fe7d87b..e8c984c 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -7,6 +7,7 @@ from math import log10, pow from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * from nomadnet.vendor.AsciiChart import AsciiChart from .ReadlineEdit import ReadlineEdit +from .Helpers import ClickableIcon ### GYLPHS ### INTERFACE_GLYPHS = { @@ -1123,7 +1124,7 @@ INTERFACE_FIELDS = { ### INTERFACE WIDGETS #### class SelectableInterfaceItem(urwid.WidgetWrap): - def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None, profiles_label=None): self.parent = parent self._selectable = True self.icon = icon @@ -1180,6 +1181,12 @@ class SelectableInterfaceItem(urwid.WidgetWrap): ]), ] + if profiles_label is not None: + rows.insert(2, urwid.Columns([ + (10, urwid.Text(("key", "Profiles:"))), + urwid.Text(("value", profiles_label)), + ])) + pile_contents = [title_content] + rows pile = urwid.Pile(pile_contents) @@ -1240,10 +1247,11 @@ class SelectableInterfaceItem(urwid.WidgetWrap): self.rx_widget.set_text(("value", format_bytes(rx))) class InterfaceOptionItem(urwid.WidgetWrap): - def __init__(self, parent_display, label, value): + def __init__(self, parent_display, label, value, on_select=None): self.parent_display = parent_display self.label = label self.value = value + self.on_select = on_select self._selectable = True text_widget = urwid.Text(label, align="left") @@ -1252,13 +1260,25 @@ class InterfaceOptionItem(urwid.WidgetWrap): def selectable(self): return True - def keypress(self, size, key): - if key == "enter": + def _activate(self): + if self.on_select is not None: + self.on_select() + else: self.parent_display.dismiss_dialog() self.parent_display.switch_to_add_interface(self.value) + + def keypress(self, size, key): + if key == "enter": + self._activate() return None return super().keypress(size, key) + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._activate() + return True + return False + class InterfaceBandwidthChart: def __init__(self, history_length=60, glyphset="unicode"): @@ -1395,6 +1415,12 @@ class InterfaceFiller(urwid.WidgetWrap): super().__init__(self.filler) def keypress(self, size, key): + if key == "tab": + self.app.ui.main_display.sub_displays.interface_display.show_profiles() + return None + if key == "ctrl t": + self.app.ui.main_display.sub_displays.interface_display.toggle_view_mode() + return None if key == "ctrl a": # add interface self.app.ui.main_display.sub_displays.interface_display.add_interface() @@ -1717,6 +1743,7 @@ class AddInterfaceView(urwid.WidgetWrap): self.calculator_button, self.calculator_widget, ]) + pile_items.extend(self._build_profiles_section()) pile_items.extend([ urwid.Divider("─"), button_row, @@ -1724,6 +1751,37 @@ class AddInterfaceView(urwid.WidgetWrap): return pile_items + def _build_profiles_section(self): + self.profile_checkboxes = [] + profiles = getattr(self.parent.app, "interface_profiles", None) + if profiles is None or not profiles.profiles: + return [] + iface_name = getattr(self, "iface_name", None) + current = set() + if iface_name: + current = set(p["id"] for p in profiles.profiles_for(iface_name)) + items = [ + urwid.Divider("─"), + urwid.Text(("key", "Profiles"), align="left"), + urwid.Text(("inactive_text", " Assign this interface to one or more profiles."), align="left"), + ] + for p in profiles.profiles: + cb = urwid.CheckBox(p["name"] or "(unnamed)", state=(p["id"] in current)) + cb.profile_id = p["id"] + self.profile_checkboxes.append(cb) + items.append(urwid.Padding(cb, left=2)) + return items + + def _apply_profile_membership(self, iface_name): + profiles = getattr(self.parent.app, "interface_profiles", None) + if profiles is None: + return + checked = [cb.profile_id for cb in getattr(self, "profile_checkboxes", []) if cb.get_state()] + try: + profiles.set_interface_profiles(iface_name, checked) + except Exception: + pass + def toggle_more_options(self, button): if self.more_options_visible: self.more_options_widget.contents = [] @@ -1939,6 +1997,7 @@ class AddInterfaceView(urwid.WidgetWrap): interfaces = self.parent.app.rns.config['interfaces'] interfaces[name] = interface_config self.parent.app.rns.config.write() + self._apply_profile_membership(name) display_type = custom_type if self.iface_type == "CustomInterface" else self.iface_type @@ -2181,6 +2240,9 @@ class EditInterfaceView(AddInterfaceView): del interfaces[self.iface_name] interfaces[new_name] = updated_config + try: self.parent.app.interface_profiles.rename_interface(self.iface_name, new_name) + except Exception: pass + for i, item in enumerate(self.parent.interface_items): if item.name == self.iface_name: self.parent.interface_items[i].name = new_name @@ -2189,6 +2251,7 @@ class EditInterfaceView(AddInterfaceView): interfaces[self.iface_name] = updated_config self.parent.app.rns.config.write() + self._apply_profile_membership(new_name) display_type = interface_type @@ -2514,77 +2577,33 @@ class ShowInterface(urwid.WidgetWrap): body_pile.contents[chart_index] = (new_widget, body_pile.options()) def on_toggle_enabled(self, button): - action = "disable" if self.is_enabled else "enable" + self.is_enabled = not self.is_enabled - def on_confirm_yes(confirm_button): - self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + self.toggle_button.set_label("Disable" if self.is_enabled else "Enable") - self.is_enabled = not self.is_enabled + self.interface_config["interface_enabled"] = self.is_enabled + if "enabled" in self.interface_config: + self.interface_config["enabled"] = self.is_enabled - self.toggle_button.set_label("Disable" if self.is_enabled else "Enable") + try: + interfaces = self.parent.app.rns.config['interfaces'] + interfaces[self.iface_name] = self.interface_config + self.parent.app.rns.config.write() - if "interface_enabled" in self.interface_config: - self.interface_config["interface_enabled"] = self.is_enabled - else: - self.interface_config["enabled"] = self.is_enabled + self.update_status_display() - try: - interfaces = self.parent.app.rns.config['interfaces'] + for item in self.parent.interface_items: + if item.name == self.iface_name: + item.is_enabled = self.is_enabled + item.update_status_display() - interfaces[self.iface_name] = self.interface_config + if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None: + self.parent.app.ui.loop.draw_screen() - self.parent.app.rns.config.write() + self.show_restart_required_message() - self.update_status_display() - - for item in self.parent.interface_items: - if item.name == self.iface_name: - item.is_enabled = self.is_enabled - item.update_status_display() - - if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None: - self.parent.app.ui.loop.draw_screen() - - self.show_restart_required_message() - - except Exception as e: - self.show_error_message(f"Error updating interface: {str(e)}") - - def on_confirm_no(confirm_button): - self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget - - confirm_text = urwid.Text(( - "interface_title", - f"Are you sure you want to {action} the {self.iface_name} interface?" - ), align="center") - - yes_button = urwid.Button("Yes", on_press=on_confirm_yes) - no_button = urwid.Button("No", on_press=on_confirm_no) - - buttons_row = urwid.Columns([ - (urwid.WEIGHT, 0.45, yes_button), - (urwid.WEIGHT, 0.1, urwid.Text("")), - (urwid.WEIGHT, 0.45, no_button), - ]) - - pile = urwid.Pile([ - confirm_text, - urwid.Divider(), - buttons_row - ]) - - dialog = DialogLineBox(pile, title="Confirm") - - overlay = urwid.Overlay( - dialog, - self.parent.app.ui.main_display.frame.body, - align='center', - width=50, - valign='middle', - height=7 - ) - - self.parent.app.ui.main_display.frame.body = overlay + except Exception as e: + self.show_error_message(f"Error updating interface: {str(e)}") def show_restart_required_message(self): @@ -2822,6 +2841,247 @@ class ShowInterface(urwid.WidgetWrap): self.started = False self.parent.switch_to_edit_interface(self.iface_name) +class InterfaceTile(urwid.WidgetWrap): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, icon): + self.parent = parent + self.name = name + self.icon = icon + self.g = parent.g + self.dot_style = "connected_status" if is_enabled else "disconnected_status" + self.dot = self.g['selected'] if is_enabled else self.g['unselected'] + self.title_widget = urwid.Text("", wrap="clip") + typ = urwid.Text(("value", iface_type), wrap="clip") + status = urwid.Text([ + (self.dot_style, "Enabled" if is_enabled else "Disabled"), + ("value", f" {self.g['sep_dot']} "), + ("connected_status" if is_connected else "disconnected_status", "Conn." if is_connected else "Disc."), + ], wrap="clip") + inner = urwid.Pile([self.title_widget, typ, status]) + self.box = urwid.LineBox( + urwid.Padding(inner, left=1, right=1), + tlcorner="╭", trcorner="╮", blcorner="╰", brcorner="╯", + ) + self.attr = urwid.AttrMap(self.box, None, focus_map={None: "interface_tile_focus"}) + super().__init__(self.attr) + self._render_title(False) + + def _render_title(self, focus): + arrow = self.g['focus_arrow'] if focus else " " + style = "interface_tile_focus" if focus else "interface_title" + self.title_widget.set_text([(self.dot_style, self.dot), (style, f" {arrow} {self.icon} {self.name}")]) + + def selectable(self): + return True + + def render(self, size, focus=False): + self._render_title(focus) + return super().render(size, focus) + + def keypress(self, size, key): + if key == "enter": + self.parent.switch_to_show_interface(self.name) + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self.parent.switch_to_show_interface(self.name) + return True + return False + + +### PROFILE VIEWS ### +class ProfileListRow(urwid.WidgetWrap): + def __init__(self, view, pid, label, selected): + self.view = view + self.pid = pid + self.label = label + self.selected = selected + self.g = view.parent.g + self.text_widget = urwid.Text("") + super().__init__(urwid.AttrMap(self.text_widget, None)) + self._render_row(False) + + def _render_row(self, focus): + mark = self.g['selected'] if self.selected else self.g['unselected'] + arrow = self.g['focus_arrow'] if focus else " " + if focus: + style = "interface_tile_focus" + elif self.selected: + style = "connected_status" + else: + style = "interface_title" + self.text_widget.set_text((style, f" {arrow} {mark} {self.label}")) + + def selectable(self): + return True + + def render(self, size, focus=False): + self._render_row(focus) + return super().render(size, focus) + + def keypress(self, size, key): + if key in (" ", "enter"): + if self.pid is not None: + self.view.select(self.pid) + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + if self.pid is not None: + self.view.select(self.pid) + return True + return False + + +class ProfilesView(urwid.WidgetWrap): + def __init__(self, parent): + self.parent = parent + self.app = parent.app + self.profiles = parent.app.interface_profiles + self.profiles.prune() + self.status = urwid.Text("", align="center") + self.walker = urwid.SimpleFocusListWalker([]) + self.listbox = urwid.ListBox(self.walker) + body_rows = max(3, parent.list_rows - 3) + self.pile = urwid.Pile([ + ('pack', parent._make_tab_bar("profiles")), + ('pack', self.status), + ('pack', urwid.Divider()), + urwid.BoxAdapter(self.listbox, body_rows), + ]) + super().__init__(urwid.Filler(self.pile, urwid.TOP)) + self.refresh() + + def refresh(self, focus_pid="__keep__"): + prev = None + try: + w, _ = self.listbox.body.get_focus() + prev = getattr(w, "pid", None) + except Exception: + pass + self.profiles.update_default_if_custom() + active = self.profiles.active_profile_id() + items = [ProfileListRow(self, "__default__", "Default", active is None)] + for p in self.profiles.profiles: + count = self.profiles.member_count(p) + label = f"{p['name'] or '(unnamed)'} ({count} interface{'' if count == 1 else 's'})" + items.append(ProfileListRow(self, p["id"], label, p["id"] == active)) + if len(self.profiles.profiles) == 0: + items.append(urwid.Text("\n No profiles yet. Press Ctrl + A to create one.", align="left")) + self.walker[:] = items + target = focus_pid if focus_pid != "__keep__" else prev + for i, w in enumerate(self.walker): + if getattr(w, "pid", "__x__") == target: + try: self.listbox.focus_position = i + except Exception: pass + break + + def _focused(self): + try: + w, _ = self.listbox.body.get_focus() + return w if isinstance(w, ProfileListRow) else None + except Exception: + return None + + def select(self, pid): + if pid == "__default__": + self.profiles.select_default() + else: + self.profiles.select_profile(pid) + self.refresh(focus_pid=pid) + self.status.set_text(("connected_status", "Restart NomadNet for changes to take effect")) + + def _overlay(self, dialog, height=8, width=50): + frame = self.app.ui.main_display.frame + frame.body = urwid.Overlay(dialog, self, align="center", width=width, + valign="middle", height=height, min_width=20) + + def dismiss_dialog(self): + self.app.ui.main_display.frame.body = self + + def new_profile(self): + edit = ReadlineEdit(caption="Name: ") + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + pid = self.profiles.create(val) + self.refresh(focus_pid=pid) + + self._prompt("New Profile", edit, ok) + + def rename_profile(self): + w = self._focused() + if w is None or w.pid is None: + return + p = self.profiles.get(w.pid) + if p is None: + return + edit = ReadlineEdit(caption="Name: ", edit_text=p["name"]) + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + self.profiles.rename(p["id"], val) + self.refresh(focus_pid=p["id"]) + + self._prompt("Rename Profile", edit, ok) + + def delete_profile(self): + w = self._focused() + if w is None or w.pid is None: + return + p = self.profiles.get(w.pid) + if p is None: + return + + def yes(_b): + self.dismiss_dialog() + self.profiles.delete(p["id"]) + self.refresh() + + def no(_b): + self.dismiss_dialog() + + pile = urwid.Pile([ + urwid.Text(f"Delete profile '{p['name'] or p['id']}'?\nInterfaces keep their current enabled state.", align="center"), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title="Confirm"), height=10) + + def _prompt(self, title, edit, on_ok): + pile = urwid.Pile([ + edit, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("OK", on_press=on_ok)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title)) + + def keypress(self, size, key): + if key in ("tab", "esc"): + self.parent.show_interfaces(); return None + if key == "ctrl a": + self.new_profile(); return None + if key == "ctrl e": + self.rename_profile(); return None + if key == "ctrl x": + self.delete_profile(); return None + return super().keypress(size, key) + + ### MAIN DISPLAY ### class InterfaceDisplay: def __init__(self, app): @@ -2837,87 +3097,93 @@ class InterfaceDisplay: self.iface_row_offset = 4 self.list_rows = self.terminal_rows - self.iface_row_offset - interfaces = app.rns.config['interfaces'] - processed_interfaces = {} + self.active_tab = "interfaces" + self.iface_view_mode = "rows" + self.profiles_view = None - for interface_name, interface in interfaces.items(): - interface_data = interface.copy() + self._build_interface_items() - # handle sub-interfaces for RNodeMultiInterface - if interface_data.get("type") == "RNodeMultiInterface": - sub_interfaces = [] - for sub_name, sub_config in interface_data.items(): - if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", - "configured_bitrate"}: - if isinstance(sub_config, dict): - sub_config["name"] = sub_name - sub_interfaces.append(sub_config) - - # add sub-interfaces to the main interface data - interface_data["sub_interfaces"] = sub_interfaces - - for sub in sub_interfaces: - del interface_data[sub["name"]] - - processed_interfaces[interface_name] = interface_data - - app.interface_stats = app.rns.get_interface_stats() - interface_stats = app.interface_stats - stats_lookup = {interface['short_name']: interface for interface in interface_stats['interfaces']} - # print(stats_lookup) - for interface_name, interface_data in processed_interfaces.items(): - # configobj false values - is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') - - iface_type = interface_data.get("type", "Unknown") - icon = _get_interface_icon(self.glyphset, iface_type) - - stats_for_interface = stats_lookup.get(interface_name) - - if stats_for_interface: - tx = stats_for_interface.get("txb", 0) - rx = stats_for_interface.get("rxb", 0) - is_connected = stats_for_interface["status"] - else: - tx = 0 - rx = 0 - is_connected = False - - item = SelectableInterfaceItem( - parent=self, - name=interface_data.get("name", interface_name), - is_connected=is_connected, - is_enabled=is_enabled, - iface_type=iface_type, - tx=tx, - rx=rx, - icon=icon - ) - - self.interface_items.append(item) - - interface_header = urwid.Text(("interface_title", "Interfaces"), align="center") - if len(self.interface_items) == 0: - interface_header = urwid.Text( - ("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center") - - - list_contents = [ - interface_header, - urwid.Divider(), - ] + self.interface_items - - self.list_walker = urwid.SimpleFocusListWalker(list_contents) + self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents()) self.list_box = urwid.ListBox(self.list_walker) - self.box_adapter = urwid.BoxAdapter(self.list_box, self.list_rows) + self.body_rows = max(3, self.list_rows - 3) + self.box_adapter = urwid.BoxAdapter(self.list_box, self.body_rows) - - pile = urwid.Pile([self.box_adapter]) - self.interfaces_display = InterfaceFiller(pile, self.app) + self.tabs_pile = urwid.Pile([ + ('pack', self._make_tab_bar("interfaces")), + ('pack', urwid.Divider()), + self.box_adapter, + ]) + self.interfaces_display = InterfaceFiller(self.tabs_pile, self.app) self.shortcuts_display = InterfaceDisplayShortcuts(self.app) self.widget = self.interfaces_display + def _interface_list_contents(self): + if not self.interface_items: + return [urwid.Text(("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center")] + return list(self.interface_items) + + def _make_tab_bar(self, active): + iface = f" Interfaces ({len(self.interface_items)}) " + prof = " Profiles " + if active == "interfaces": + iface_w = ClickableIcon(("interface_title_selected", "["+iface+"]"), on_click=self.show_interfaces) + prof_w = ClickableIcon(("interface_title", " "+prof+" "), on_click=self.show_profiles) + else: + iface_w = ClickableIcon(("interface_title", " "+iface+" "), on_click=self.show_interfaces) + prof_w = ClickableIcon(("interface_title_selected", "["+prof+"]"), on_click=self.show_profiles) + return urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', iface_w), + ('pack', urwid.Text(" ")), + ('pack', prof_w), + ('weight', 1, urwid.Text("")), + ], dividechars=0) + + def _build_grid_listbox(self): + tiles = self.interface_tiles if self.interface_tiles else [urwid.Text("No interfaces found. Press Ctrl + A to add a new interface ", align="center")] + self.iface_grid = urwid.GridFlow(tiles, cell_width=34, h_sep=2, v_sep=1, align="center") + return urwid.ListBox(urwid.SimpleFocusListWalker([self.iface_grid])) + + def _set_iface_body(self): + if self.iface_view_mode == "grid": + self.box_adapter.original_widget = self._build_grid_listbox() + else: + self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents()) + self.list_box = urwid.ListBox(self.list_walker) + self.box_adapter.original_widget = self.list_box + + def _focused_interface_name(self): + try: + if self.iface_view_mode == "grid": + return getattr(self.iface_grid.focus, "name", None) + fw, _ = self.box_adapter._original_widget.body.get_focus() + return getattr(fw, "name", None) if isinstance(fw, SelectableInterfaceItem) else None + except Exception: + return None + + def toggle_view_mode(self): + self.iface_view_mode = "grid" if self.iface_view_mode == "rows" else "rows" + self._set_iface_body() + self.app.ui.main_display.update_active_sub_display() + + def show_profiles(self): + if getattr(self.app, "interface_profiles", None) is None: + return + self.active_tab = "profiles" + self.profiles_view = ProfilesView(self) + self.shortcuts_display.set_profiles_tab_shortcuts() + self.widget = self.profiles_view + self.app.ui.main_display.update_active_sub_display() + + def show_interfaces(self): + self.active_tab = "interfaces" + self._build_interface_items() + self._rebuild_list() + self.shortcuts_display.reset_shortcuts() + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + def start(self): # started from Main.py self.started = True @@ -2930,14 +3196,9 @@ class InterfaceDisplay: self.app.ui.main_display.update_active_sub_display() def edit_selected_interface(self): - focus_widget, focus_position = self.box_adapter._original_widget.body.get_focus() - - if not isinstance(focus_widget, SelectableInterfaceItem): + interface_name = self._focused_interface_name() + if interface_name is None: return - - selected_item = focus_widget - interface_name = selected_item.name - self.switch_to_edit_interface(interface_name) def check_terminal_size(self, loop, user_data): @@ -2947,8 +3208,9 @@ class InterfaceDisplay: if new_rows != self.terminal_rows or new_cols != self.terminal_cols: self.terminal_cols, self.terminal_rows = new_cols, new_rows self.list_rows = self.terminal_rows - self.iface_row_offset + self.body_rows = max(3, self.list_rows - 3) - self.box_adapter.height = self.list_rows + self.box_adapter.height = self.body_rows loop.draw_screen() @@ -3009,10 +3271,7 @@ class InterfaceDisplay: show_interface.start() def switch_to_list(self): - self.shortcuts_display.reset_shortcuts() - self.widget = self.interfaces_display - self._rebuild_list() - self.app.ui.main_display.update_active_sub_display() + self.show_interfaces() def add_interface(self): dialog_widgets = [] @@ -3024,6 +3283,10 @@ class InterfaceDisplay: item = InterfaceOptionItem(self, label, value) dialog_widgets.append(item) + add_heading("Paste Configuration") + dialog_widgets.append(InterfaceOptionItem(self, "Paste an interface config…", None, on_select=self.show_paste_config_dialog)) + dialog_widgets.append(urwid.Divider()) + # Get the icons based on plain, unicode, nerdfont glyphset network_icon = _get_interface_icon(self.glyphset, "AutoInterface") rnode_icon = _get_interface_icon(self.glyphset, "RNodeInterface") @@ -3072,28 +3335,117 @@ class InterfaceDisplay: self.widget = overlay self.app.ui.main_display.update_active_sub_display() + def show_paste_config_dialog(self): + self.paste_edit = ReadlineEdit(caption="", multiline=True) + self.paste_status = urwid.Text("") + + instructions = urwid.Text( + "Paste an interface block, including its [[Name]] header, then Create. " + "Multiple interfaces are supported.", align="left") + + editor = urwid.AttrMap( + urwid.LineBox(urwid.BoxAdapter(urwid.ListBox(urwid.SimpleListWalker([self.paste_edit])), 12)), + "list_off_focus", "list_focus") + + pile = urwid.Pile([ + instructions, + urwid.Divider(), + editor, + self.paste_status, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=lambda _b: self._create_interfaces_from_text(self.paste_edit.get_edit_text()))), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + + dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Paste Interface Config") + overlay = urwid.Overlay( + dialog, self.interfaces_display, + align="center", width=("relative", 75), + valign="middle", height=("relative", 75), + min_width=44, min_height=14, + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def _section_to_dict(self, sec): + out = {} + for k, v in sec.items(): + out[k] = self._section_to_dict(v) if hasattr(v, "items") else v + return out + + def _parse_pasted_interfaces(self, text): + import RNS.vendor.configobj as configobj + raw = (text or "").strip("\n") + if not raw.strip(): + return {} + for prefix in ("", "[interfaces]\n"): + try: + parsed = configobj.ConfigObj(infile=(prefix + raw).split("\n")) + except Exception: + continue + found = {} + sections = [] + ifsec = parsed.get("interfaces") + if hasattr(ifsec, "items"): + sections.extend(ifsec.items()) + for name, sec in parsed.items(): + if name != "interfaces" and hasattr(sec, "items"): + sections.append((name, sec)) + for name, sec in sections: + if not hasattr(sec, "items"): + continue + d = self._section_to_dict(sec) + if "type" in d and name not in found: + found[name] = d + if found: + return found + return {} + + def _create_interfaces_from_text(self, text): + parsed = self._parse_pasted_interfaces(text) + if not parsed: + self.paste_status.set_text(("error", " Could not find an interface with a 'type'. Include the [[Name]] header.")) + return + + interfaces = self.app.rns.config['interfaces'] + existing = [n for n in parsed if n in interfaces] + if existing: + self.paste_status.set_text(("error", " Already exists: " + ", ".join(existing))) + return + + try: + for name, conf in parsed.items(): + interfaces[name] = conf + self.app.rns.config.write() + except Exception as e: + self.paste_status.set_text(("error", " Failed: " + str(e))) + return + + self.dismiss_dialog() + self.show_interfaces() + def switch_to_add_interface(self, iface_type): self.add_interface_view = AddInterfaceView(self, iface_type) self.widget = self.add_interface_view self.app.ui.main_display.update_active_sub_display() def remove_selected_interface(self): - focus_widget, focus_position = self.box_adapter._original_widget.body.get_focus() - if not isinstance(focus_widget, SelectableInterfaceItem): + interface_name = self._focused_interface_name() + if interface_name is None: return - selected_item = focus_widget - interface_name = selected_item.name - def on_confirm_yes(button): try: if interface_name in self.app.rns.config['interfaces']: del self.app.rns.config['interfaces'][interface_name] self.app.rns.config.write() + try: self.app.interface_profiles.remove_interface(interface_name) + except Exception: pass - if selected_item in self.interface_items: - self.interface_items.remove(selected_item) - + self._build_interface_items() self._rebuild_list() self.dismiss_dialog() @@ -3142,20 +3494,82 @@ class InterfaceDisplay: self.widget = self.interfaces_display self.app.ui.main_display.update_active_sub_display() + def _build_interface_items(self): + self.interface_items = [] + self.interface_tiles = [] + interfaces = self.app.rns.config['interfaces'] + processed_interfaces = {} + + for interface_name, interface in interfaces.items(): + interface_data = interface.copy() + + if interface_data.get("type") == "RNodeMultiInterface": + sub_interfaces = [] + for sub_name, sub_config in interface_data.items(): + if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", + "configured_bitrate"}: + if isinstance(sub_config, dict): + sub_config["name"] = sub_name + sub_interfaces.append(sub_config) + + interface_data["sub_interfaces"] = sub_interfaces + + for sub in sub_interfaces: + del interface_data[sub["name"]] + + processed_interfaces[interface_name] = interface_data + + self.app.interface_stats = self.app.rns.get_interface_stats() + stats_lookup = {interface['short_name']: interface for interface in self.app.interface_stats['interfaces']} + + profiles = getattr(self.app, "interface_profiles", None) + + for interface_name, interface_data in processed_interfaces.items(): + is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + iface_type = interface_data.get("type", "Unknown") + icon = _get_interface_icon(self.glyphset, iface_type) + + stats_for_interface = stats_lookup.get(interface_name) + + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + is_connected = stats_for_interface["status"] + else: + tx = 0 + rx = 0 + is_connected = False + + profiles_label = profiles.label_for(interface_name) if profiles is not None else None + + item = SelectableInterfaceItem( + parent=self, + name=interface_data.get("name", interface_name), + is_connected=is_connected, + is_enabled=is_enabled, + iface_type=iface_type, + tx=tx, + rx=rx, + icon=icon, + profiles_label=profiles_label + ) + + self.interface_items.append(item) + + tile = InterfaceTile( + parent=self, + name=interface_data.get("name", interface_name), + is_connected=is_connected, + is_enabled=is_enabled, + iface_type=iface_type, + icon=icon, + ) + self.interface_tiles.append(tile) + def _rebuild_list(self): - interface_header = urwid.Text(("interface_title", f"Interfaces ({len(self.interface_items)})"), align="center") - if len(self.interface_items) == 0: - interface_header = urwid.Text(("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center") - - new_list = [ - interface_header, - urwid.Divider(), - ] + self.interface_items - # RNS.log(f"items: {self.interface_items}") - - walker = urwid.SimpleFocusListWalker(new_list) - self.box_adapter._original_widget.body = walker - self.box_adapter._original_widget.focus_position = len(new_list) - 1 + self._set_iface_body() + self.tabs_pile.contents[0] = (self._make_tab_bar("interfaces"), self.tabs_pile.options('pack')) def open_config_editor(self): import platform @@ -3188,7 +3602,7 @@ class InterfaceDisplay: class InterfaceDisplayShortcuts: def __init__(self, app): self.app = app - self.default_shortcuts = "[C-a] Add Interface [C-e] Edit Interface [C-x] Remove Interface [Enter] Show Interface [C-w] Open Text Editor" + self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [Enter] Show [C-t] Grid/Rows [C-w] Editor [Tab] Profiles" self.current_shortcuts = self.default_shortcuts self.widget = urwid.AttrMap( urwid.Text(self.current_shortcuts), @@ -3212,4 +3626,8 @@ class InterfaceDisplayShortcuts: def set_edit_interface_shortcuts(self): edit_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option" - self.update_shortcuts(edit_shortcuts) \ No newline at end of file + self.update_shortcuts(edit_shortcuts) + + def set_profiles_tab_shortcuts(self): + profiles_shortcuts = "[Up/Down] Navigate [Space] Select Profile [C-a] New [C-e] Rename [C-x] Delete [Tab] Interfaces" + self.update_shortcuts(profiles_shortcuts) \ No newline at end of file From 6ca3590dc1231f316d979d4dfbd4635ae47db844 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:59:15 -0400 Subject: [PATCH 509/592] Interfaces: Add shortcut for enabling / disabling from Interfaces screen, don't show stats for disabled interfaces. Allow saving currently enabled interfaces as a profile, show incoming_announce_frequency in stats, remove interface enable dialog, track changes to show restart hint, extra stats, fixea --- nomadnet/InterfaceProfiles.py | 5 + nomadnet/ui/TextUI.py | 12 +- nomadnet/ui/textui/Interfaces.py | 724 ++++++++++++++++++++++++------- 3 files changed, 586 insertions(+), 155 deletions(-) diff --git a/nomadnet/InterfaceProfiles.py b/nomadnet/InterfaceProfiles.py index 91f9324..a6f918c 100644 --- a/nomadnet/InterfaceProfiles.py +++ b/nomadnet/InterfaceProfiles.py @@ -130,6 +130,11 @@ class InterfaceProfiles: self.save() return pid + def save_current_as_profile(self, name): + pid = self.create(name) + self.set_members(pid, sorted(self.enabled_set())) + return pid + def rename(self, pid, name): p = self.get(pid) if p is not None and name: diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index dbbd08d..0708bd2 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -55,9 +55,10 @@ THEMES = { ("progress_empty", "light gray", "default", "default", "#ddd", "default"), ("interface_title", "", "", "default", "", ""), ("interface_title_selected", "bold", "", "bold", "", ""), - ("interface_tile_focus", "light cyan,bold", "default", "bold", "#3dd,bold", "default"), + ("interface_tile_focus", "light blue,bold", "default", "bold", "#5af,bold", "default"), ("connected_status", "dark green", "default", "default", "dark green", "default"), - ("disconnected_status", "dark red", "default", "default", "dark red", "default"), + ("disconnected_status", "dark red", "default", "default", "dark red", "default"), + ("disabled_status", "dark red", "default", "default", "dark red", "default"), ("placeholder", "dark gray", "default", "default", "dark gray", "default"), ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"), ("error", "light red,blink", "default", "blink", "#f44,blink", "default"), @@ -109,9 +110,10 @@ THEMES = { ("progress_empty", "dark gray", "default", "default", "#ddd", "default"), ("interface_title", "dark gray", "default", "default", "#444", "default"), ("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"), - ("interface_tile_focus", "dark blue,bold", "default", "bold", "#06a,bold", "default"), + ("interface_tile_focus", "dark blue,bold", "default", "bold", "#06c,bold", "default"), ("connected_status", "dark green", "default", "default", "#4a0", "default"), - ("disconnected_status", "dark red", "default", "default", "#a22", "default"), + ("disconnected_status", "dark red", "default", "default", "#a22", "default"), + ("disabled_status", "dark red", "default", "default", "#a22", "default"), ("placeholder", "light gray", "default", "default", "#999", "default"), ("placeholder_text", "light gray", "default", "default", "#999", "default"), ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), @@ -168,6 +170,8 @@ GLYPHS = { ("unselected", "[ ] ", "\u25CB", "\u25CB"), ("focus_arrow", ">", "\u25B8", "\u25B8"), ("sep_dot", "|", "\u00B7", "\u00B7"), + ("connected", "+", "\u2713", "\U000f0201"), + ("disconnected", "x", "\u2717", "\U000f0202"), ("file", "[F]", "\u25a4", "\uf15b"), ("image", "[I]", "\u25a3", "\uf1c5"), ("audio", "[~]", "\u266b", "\uf1c7"), diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index e8c984c..6d49738 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -74,6 +74,48 @@ def format_bytes(bytes_value): else: return f"{size:.1f} {units[unit_index]}" + +def format_announce_rate(hz): + try: + hz = float(hz or 0) + except (TypeError, ValueError): + return "-" + if hz <= 0: + return "-" + per_hour = hz * 3600 + if per_hour < 100: + return "<1/hr" if per_hour < 1 else "%d/hr" % round(per_hour) + return "%d/min" % round(hz * 60) + +def format_speed(bps): + try: + bps = float(bps or 0) + except (TypeError, ValueError): + return "-" + try: + return RNS.prettyspeed(bps) + except Exception: + return "%d bps" % int(bps) + +def interface_mode_str(mode): + modes = { + 1: "Full", # MODE_FULL + 2: "Point-to-Point", # MODE_POINT_TO_POINT + 3: "Access Point", # MODE_ACCESS_POINT + 4: "Roaming", # MODE_ROAMING + 5: "Boundary", # MODE_BOUNDARY + 6: "Gateway", # MODE_GATEWAY + } + return modes.get(mode, "Full") + +def connection_markup(glyphs, is_connected, label=None): + icon = glyphs.get("connected" if is_connected else "disconnected", "") + if label is None: + label = "Connected" if is_connected else "Disconnected" + attr = "connected_status" if is_connected else "disconnected_status" + sep = " " if icon else "" + return (attr, "%s%s%s" % (icon, sep, label)) + def _get_cols_rows(): return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() @@ -589,7 +631,7 @@ INTERFACE_FIELDS = { "type": "edit", "label": "Target Port: ", "default": "", - "placeholder": "e.g., 8080", + "placeholder": "e.g., 4242", "validation": ["required", "number"], "transform": lambda x: int(x.strip()) if x.strip() else None }, @@ -1124,7 +1166,7 @@ INTERFACE_FIELDS = { ### INTERFACE WIDGETS #### class SelectableInterfaceItem(urwid.WidgetWrap): - def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None, profiles_label=None): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None, profiles_label=None, announce_rate="N/A"): self.parent = parent self._selectable = True self.icon = icon @@ -1137,12 +1179,9 @@ class SelectableInterfaceItem(urwid.WidgetWrap): if is_enabled: enabled_txt = ("connected_status", "Enabled") else: - enabled_txt = ("disconnected_status", "Disabled") + enabled_txt = ("disabled_status", "Disabled") - if is_connected: - connected_txt = ("connected_status", "Connected") - else: - connected_txt = ("disconnected_status", "Disconnected") + connected_txt = connection_markup(parent.g, is_connected) self.selection_txt = urwid.Text(" ") self.title_widget = urwid.Text(("interface_title", f"{icon} {name}")) @@ -1154,39 +1193,50 @@ class SelectableInterfaceItem(urwid.WidgetWrap): self.tx_widget = urwid.Text(("value", format_bytes(tx))) self.rx_widget = urwid.Text(("value", format_bytes(rx))) + self.announce_widget = urwid.Text(("value", announce_rate)) self.status_widget = urwid.Text(enabled_txt) self.connection_widget = urwid.Text(connected_txt) - rows = [ - urwid.Columns([ + if is_enabled: + status_row = urwid.Columns([ (10, urwid.Text(("key", "Status: "))), (10, self.status_widget), (3, urwid.Text(" | ")), self.connection_widget, - ]), + ]) + else: + status_row = urwid.Columns([ + (10, urwid.Text(("key", "Status: "))), + self.status_widget, + ]) + + rows = [ + status_row, urwid.Columns([ (10, urwid.Text(("key", "Type:"))), urwid.Text(("value", iface_type)), ]), - - urwid.Divider("-"), - - urwid.Columns([ - (10, urwid.Text(("key", "TX:"))), - (15, self.tx_widget), - (10, urwid.Text(("key", "RX:"))), - self.rx_widget, - ]), ] if profiles_label is not None: - rows.insert(2, urwid.Columns([ + rows.append(urwid.Columns([ (10, urwid.Text(("key", "Profiles:"))), urwid.Text(("value", profiles_label)), ])) + if is_enabled: + rows.append(urwid.Divider("-")) + rows.append(urwid.Columns([ + (5, urwid.Text(("key", "TX:"))), + (14, self.tx_widget), + (5, urwid.Text(("key", "RX:"))), + (14, self.rx_widget), + (12, urwid.Text(("key", "Announces:"))), + self.announce_widget, + ])) + pile_contents = [title_content] + rows pile = urwid.Pile(pile_contents) @@ -1209,7 +1259,7 @@ class SelectableInterfaceItem(urwid.WidgetWrap): if self.is_enabled: self.status_widget.set_text(("connected_status", "Enabled")) else: - self.status_widget.set_text(("disconnected_status", "Disabled")) + self.status_widget.set_text(("disabled_status", "Disabled")) def selectable(self): return True @@ -1242,9 +1292,17 @@ class SelectableInterfaceItem(urwid.WidgetWrap): return None return key - def update_stats(self, tx, rx): + def update_stats(self, tx, rx, announce_rate=None): self.tx_widget.set_text(("value", format_bytes(tx))) self.rx_widget.set_text(("value", format_bytes(rx))) + if announce_rate is not None: + self.announce_widget.set_text(("value", announce_rate)) + + def update_connection(self, is_connected): + if is_connected == self.is_connected: + return + self.is_connected = is_connected + self.connection_widget.set_text(connection_markup(self.parent.g, is_connected)) class InterfaceOptionItem(urwid.WidgetWrap): def __init__(self, parent_display, label, value, on_select=None): @@ -1421,6 +1479,15 @@ class InterfaceFiller(urwid.WidgetWrap): if key == "ctrl t": self.app.ui.main_display.sub_displays.interface_display.toggle_view_mode() return None + if key == "ctrl s": + self.app.ui.main_display.sub_displays.interface_display.save_current_profile() + return None + if key == "ctrl f": + self.app.ui.main_display.sub_displays.interface_display.cycle_filter() + return None + if key == "ctrl o": + self.app.ui.main_display.sub_displays.interface_display.toggle_focused_interface() + return None if key == "ctrl a": # add interface self.app.ui.main_display.sub_displays.interface_display.add_interface() @@ -2015,6 +2082,7 @@ class AddInterfaceView(urwid.WidgetWrap): self.parent.interface_items.append(new_item) self.parent._rebuild_list() + self.parent.mark_restart_pending() self.show_message(f"Interface {name} added. Restart NomadNet to start using this interface") @@ -2200,8 +2268,10 @@ class EditInterfaceView(AddInterfaceView): updated_config = { "type": interface_type, - "interface_enabled": True + "interface_enabled": self.interface_config.get("interface_enabled", True), } + if "enabled" in self.interface_config: + updated_config["enabled"] = self.interface_config["enabled"] for field_key, field in self.fields.items(): if field_key not in ["name", "custom_parameters", "type", "subinterfaces"]: @@ -2261,6 +2331,7 @@ class EditInterfaceView(AddInterfaceView): break self.parent._rebuild_list() + self.parent.mark_restart_pending() self.show_message(f"Interface {new_name} updated. Restart NomadNet for these changes to take effect") except Exception as e: @@ -2330,42 +2401,41 @@ class ShowInterface(urwid.WidgetWrap): footer = urwid.Pile(footer_content) # status widgets - self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status", + self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disabled_status", "Enabled" if self.is_enabled else "Disabled")) - self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status", + self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disabled_status", self.parent.g['selected'] if self.is_enabled else self.parent.g[ 'unselected'])) - self.connection_text = urwid.Text(("connected_status" if self.is_connected else "disconnected_status", - "Connected" if self.is_connected else "Disconnected")) + self.connection_text = urwid.Text(connection_markup(self.parent.g, self.is_connected)) + + if self.is_enabled: + status_columns = urwid.Columns([ + (10, urwid.Text(("key", "Status:"))), + (4, self.status_indicator), + (8, self.status_text), + (3, urwid.Text(" | ")), + self.connection_text, + ]) + else: + status_columns = urwid.Columns([ + (10, urwid.Text(("key", "Status:"))), + (4, self.status_indicator), + self.status_text, + ]) self.info_rows = [ urwid.Columns([ (10, urwid.Text(("key", "Type:"))), urwid.Text(("value", f"{_get_interface_icon(self.parent.glyphset, iface_type)} {iface_type}")), ]), - urwid.Columns([ - (10, urwid.Text(("key", "Status:"))), - (4, self.status_indicator), - (8, self.status_text), - (3, urwid.Text(" | ")), - self.connection_text, - ]), + status_columns, urwid.Divider("-") ] - self.tx_text = urwid.Text(("value", format_bytes(self.tx))) - self.rx_text = urwid.Text(("value", format_bytes(self.rx))) - - self.stat_row = urwid.Columns([ - (10, urwid.Text(("key", "TX:"))), - (15, self.tx_text), - (10, urwid.Text(("key", "RX:"))), - self.rx_text, - ]) - - self.info_rows.append(self.stat_row) + for row in self._build_statistics(): + self.info_rows.append(row) self.info_rows.append(urwid.Divider("-")) self.bandwidth_chart = InterfaceBandwidthChart(history_length=self.history_length, glyphset=self.parent.glyphset) @@ -2403,7 +2473,7 @@ class ShowInterface(urwid.WidgetWrap): ]) self.disconnected_message = urwid.Filler( - urwid.Text(("disconnected_status", + urwid.Text(("disabled_status", "Charts not available - Interface is not connected"), align="center"), valign="top" @@ -2539,20 +2609,160 @@ class ShowInterface(urwid.WidgetWrap): super().__init__(self.content_box) + def _stat_rows_spec(self, stats): + # Ordered (row_id, label) pairs that apply to this interface + spec = [("mode", "Mode:")] + if stats.get("bitrate") is not None: + spec.append(("bitrate", "Bitrate:")) + spec.append(("tx", "TX:")) + spec.append(("rx", "RX:")) + if "rxs" in stats and "txs" in stats: + spec.append(("speed", "Speed:")) + if stats.get("incoming_announce_frequency") is not None: + spec.append(("announce", "Announces:")) + if stats.get("incoming_pr_frequency") is not None: + spec.append(("pathreq", "Path Reqs:")) + if stats.get("clients") is not None: + spec.append(("clients", "Clients:")) + if stats.get("peers") is not None: + spec.append(("peers", "Peers:")) + if stats.get("ifac_netname") is not None or stats.get("ifac_signature") is not None: + spec.append(("ifac", "Network:")) + if stats.get("announce_queue") is not None: + spec.append(("queued", "Queued:")) + if "held_announces" in stats: + spec.append(("held", "Held:")) + return spec + + def _radio_rows_spec(self, stats): + spec = [] + if "channel_load_short" in stats and "channel_load_long" in stats: + spec.append(("chload", "Ch. Load:")) + if "airtime_short" in stats and "airtime_long" in stats: + spec.append(("airtime", "Airtime:")) + if "noise_floor" in stats: + spec.append(("noise", "Noise Fl.:")) + if stats.get("battery_percent") is not None: + spec.append(("battery", "Battery:")) + if "cpu_load" in stats or "cpu_temp" in stats: + spec.append(("cpu", "CPU:")) + if "mem_load" in stats: + spec.append(("mem", "Memory:")) + return spec + + def _fmt_pct(self, v): + try: + v = float(v) + except (TypeError, ValueError): + return "0" + if v <= 1.0: + v *= 100.0 + return "%d" % int(round(v)) + + def _fmt_pct_pair(self, short_v, long_v): + return "%s%% (15s), %s%% (1h)" % (self._fmt_pct(short_v), self._fmt_pct(long_v)) + + def _format_stat(self, row_id, stats): + if row_id == "mode": + return interface_mode_str(stats.get("mode")) + if row_id == "bitrate": + return format_speed(stats.get("bitrate")) + if row_id == "tx": + return format_bytes(stats.get("txb", 0)) + if row_id == "rx": + return format_bytes(stats.get("rxb", 0)) + if row_id == "speed": + return "TX %s RX %s" % (format_speed(stats.get("txs", 0)), format_speed(stats.get("rxs", 0))) + if row_id == "announce": + return "In %s Out %s" % (format_announce_rate(stats.get("incoming_announce_frequency")), + format_announce_rate(stats.get("outgoing_announce_frequency"))) + if row_id == "pathreq": + return "In %s Out %s" % (format_announce_rate(stats.get("incoming_pr_frequency")), + format_announce_rate(stats.get("outgoing_pr_frequency"))) + if row_id == "clients": + return str(stats.get("clients")) + if row_id == "peers": + return "%s reachable" % stats.get("peers") + if row_id == "ifac": + netname = stats.get("ifac_netname") + size = stats.get("ifac_size") + bits = ("%d-bit IFAC" % (size * 8)) if size else "IFAC" + return ("%s (%s)" % (netname, bits)) if netname else bits + if row_id == "queued": + n = stats.get("announce_queue") or 0 + return "%d announce%s" % (n, "" if n == 1 else "s") + if row_id == "held": + n = stats.get("held_announces") or 0 + return "%d announce%s" % (n, "" if n == 1 else "s") + if row_id == "chload": + return self._fmt_pct_pair(stats.get("channel_load_short"), stats.get("channel_load_long")) + if row_id == "airtime": + return self._fmt_pct_pair(stats.get("airtime_short"), stats.get("airtime_long")) + if row_id == "noise": + nf = stats.get("noise_floor") + return ("%s dBm" % nf) if nf is not None else "Unknown" + if row_id == "battery": + try: + return "%d%% (%s)" % (int(stats.get("battery_percent")), stats.get("battery_state", "Unknown")) + except (TypeError, ValueError): + return "Unknown" + if row_id == "cpu": + load = stats.get("cpu_load") + temp = stats.get("cpu_temp") + parts = [] + if load is not None: + parts.append("%s%% load" % load) + if temp is not None: + parts.append("%s°C" % temp) + return ", ".join(parts) if parts else "Unknown" + if row_id == "mem": + m = stats.get("mem_load") + return ("%s%%" % m) if m is not None else "Unknown" + return "" + + def _build_statistics(self): + # Builds the live statistics section and records the dynamic widgets in + # self.stat_w so they can be refreshed in place each poll tick. + self.stat_w = {} + + def stat_row(row_id, label): + w = urwid.Text(("value", self._format_stat(row_id, self.stats))) + self.stat_w[row_id] = w + return urwid.Columns([(12, urwid.Text(("key", label))), w]) + + rows = [urwid.Text(("interface_title", "Statistics"), align="left")] + for row_id, label in self._stat_rows_spec(self.stats): + rows.append(stat_row(row_id, label)) + + radio = self._radio_rows_spec(self.stats) + if radio: + rows.append(urwid.Divider("-")) + rows.append(urwid.Text(("interface_title", "Radio"), align="left")) + for row_id, label in radio: + rows.append(stat_row(row_id, label)) + + return rows + + def _refresh_stats(self, stats): + for row_id, w in self.stat_w.items(): + try: + w.set_text(("value", self._format_stat(row_id, stats))) + except Exception: + pass + def update_status_display(self): if self.is_enabled: self.status_indicator.set_text(("connected_status", self.parent.g['selected'])) self.status_text.set_text(("connected_status", "Enabled")) else: - self.status_indicator.set_text(("disconnected_status", self.parent.g['unselected'])) - self.status_text.set_text(("disconnected_status", "Disabled")) + self.status_indicator.set_text(("disabled_status", self.parent.g['unselected'])) + self.status_text.set_text(("disabled_status", "Disabled")) def update_connection_display(self, is_connected): old_connection_state = self.is_connected self.is_connected = is_connected - self.connection_text.set_text(("connected_status" if self.is_connected else "disconnected_status", - "Connected" if self.is_connected else "Disconnected")) + self.connection_text.set_text(connection_markup(self.parent.g, self.is_connected)) if old_connection_state != self.is_connected: body_pile = self.frame.body.body[0].original_widget @@ -2568,11 +2778,8 @@ class ShowInterface(urwid.WidgetWrap): if chart_index is not None: if self.is_connected: new_widget = self.horizontal_charts if self.is_horizontal else self.vertical_charts - if not self.started: - self.start() else: new_widget = self.disconnected_box - self.started = False body_pile.contents[chart_index] = (new_widget, body_pile.options()) @@ -2600,7 +2807,7 @@ class ShowInterface(urwid.WidgetWrap): if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None: self.parent.app.ui.loop.draw_screen() - self.show_restart_required_message() + self.parent.mark_restart_pending() except Exception as e: self.show_error_message(f"Error updating interface: {str(e)}") @@ -2770,65 +2977,70 @@ class ShowInterface(urwid.WidgetWrap): break def start(self): - if not self.started and self.is_connected: + # Poll while the detail view is open, regardless of connection state, so + # that the connection indicator and live stats keep refreshing and a + # dropped link can be seen to come back up. + if not self.started: self.started = True self.parent.app.ui.loop.set_alarm_in(1, self.update_bandwidth_charts) + def _show_disconnect_overlay(self): + # Uses its own overlay object so it never collides with the interface + # list's disconnect overlay handling while both poll loops are running. + if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay: + return + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text("Waiting to Reconnect...", align="center"), + ]) + dialog_box = urwid.LineBox(urwid.Filler(dialog_text)) + self._disconnect_overlay = urwid.Overlay( + dialog_box, self, align='center', width=35, valign='middle', height=4) + self.parent.widget = self._disconnect_overlay + self.parent.app.ui.main_display.update_active_sub_display() + def update_bandwidth_charts(self, loop, user_data): if not self.started: return try: - interface_stats = self.parent.app.interface_stats - stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} - stats = stats_lookup.get(self.iface_name, {}) + # The interface list's poll loop owns the actual stats fetch; when the + # shared RNS instance drops it flags _rns_disconnected, which we surface + # here too instead of silently charting stale data. + if getattr(self.parent, "_rns_disconnected", False): + self._show_disconnect_overlay() + else: + if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay: + self.parent.widget = self + self.parent.app.ui.main_display.update_active_sub_display() - tx = stats.get("txb", self.tx) - rx = stats.get("rxb", self.rx) + interface_stats = self.parent.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + stats = stats_lookup.get(self.iface_name, {}) - new_connection_status = stats.get("status", False) - if new_connection_status != self.is_connected: - self.update_connection_display(new_connection_status) + tx = stats.get("txb", self.tx) + rx = stats.get("rxb", self.rx) - if not self.is_connected: - return + new_connection_status = stats.get("status", False) + if new_connection_status != self.is_connected: + self.update_connection_display(new_connection_status) - self.tx_text.set_text(("value", format_bytes(tx))) - self.rx_text.set_text(("value", format_bytes(rx))) + self._refresh_stats(stats) - self.bandwidth_chart.update(rx, tx) + if self.is_connected: + self.bandwidth_chart.update(rx, tx) - rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) + rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) - self.rx_chart_text.set_text(rx_chart) - self.tx_chart_text.set_text(tx_chart) - self.rx_peak_text.set_text(f"Peak: {peak_rx}") - self.tx_peak_text.set_text(f"Peak: {peak_tx}") + self.rx_chart_text.set_text(rx_chart) + self.tx_chart_text.set_text(tx_chart) + self.rx_peak_text.set_text(f"Peak: {peak_rx}") + self.tx_peak_text.set_text(f"Peak: {peak_tx}") - self.tx = tx - self.rx = rx + self.tx = tx + self.rx = rx except Exception as e: - if not hasattr(self.parent, - 'disconnect_overlay') or self.parent.widget is not self.parent.disconnect_overlay: - dialog_text = urwid.Pile([ - urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), - urwid.Text("Waiting to Reconnect...", align="center") - ]) - dialog_content = urwid.Filler(dialog_text) - dialog_box = urwid.LineBox(dialog_content) - - self.parent.disconnect_overlay = urwid.Overlay( - dialog_box, - self, - align='center', - width=35, - valign='middle', - height=4 - ) - - self.parent.widget = self.parent.disconnect_overlay - self.parent.app.ui.main_display.update_active_sub_display() - self.started = False + self._show_disconnect_overlay() finally: if self.started: loop.set_alarm_in(1, self.update_bandwidth_charts) @@ -2842,21 +3054,27 @@ class ShowInterface(urwid.WidgetWrap): self.parent.switch_to_edit_interface(self.iface_name) class InterfaceTile(urwid.WidgetWrap): - def __init__(self, parent, name, is_connected, is_enabled, iface_type, icon): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, icon, announce_rate="0"): self.parent = parent self.name = name self.icon = icon self.g = parent.g - self.dot_style = "connected_status" if is_enabled else "disconnected_status" + self.dot_style = "connected_status" if is_enabled else "disabled_status" self.dot = self.g['selected'] if is_enabled else self.g['unselected'] self.title_widget = urwid.Text("", wrap="clip") typ = urwid.Text(("value", iface_type), wrap="clip") - status = urwid.Text([ - (self.dot_style, "Enabled" if is_enabled else "Disabled"), - ("value", f" {self.g['sep_dot']} "), - ("connected_status" if is_connected else "disconnected_status", "Conn." if is_connected else "Disc."), - ], wrap="clip") - inner = urwid.Pile([self.title_widget, typ, status]) + if is_enabled: + status = urwid.Text([ + (self.dot_style, "Enabled"), + ("value", f" {self.g['sep_dot']} "), + connection_markup(self.g, is_connected, "Conn." if is_connected else "Disc."), + ], wrap="clip") + else: + status = urwid.Text([(self.dot_style, "Disabled")], wrap="clip") + inner_items = [self.title_widget, typ, status] + if is_enabled: + inner_items.append(urwid.Text([("key", "Announces "), ("value", announce_rate)], wrap="clip")) + inner = urwid.Pile(inner_items) self.box = urwid.LineBox( urwid.Padding(inner, left=1, right=1), tlcorner="╭", trcorner="╮", blcorner="╰", brcorner="╯", @@ -2987,11 +3205,42 @@ class ProfilesView(urwid.WidgetWrap): def select(self, pid): if pid == "__default__": - self.profiles.select_default() + if self.profiles.default_members is None: + return + count = len(self.profiles.default_members) + title = "Switch to Default" + msg = "Restore your saved manual setup?\nThis enables %d interface%s and disables the rest." % (count, "" if count == 1 else "s") else: - self.profiles.select_profile(pid) - self.refresh(focus_pid=pid) - self.status.set_text(("connected_status", "Restart NomadNet for changes to take effect")) + p = self.profiles.get(pid) + if p is None: + return + count = self.profiles.member_count(p) + title = "Switch Profile" + msg = "Switch to profile '%s'?\nThis enables its %d interface%s and disables the rest." % (p["name"] or "(unnamed)", count, "" if count == 1 else "s") + + def yes(_b): + self.dismiss_dialog() + if pid == "__default__": + self.profiles.select_default() + else: + self.profiles.select_profile(pid) + self.parent.mark_restart_pending() + self.refresh(focus_pid=pid) + self.status.set_text(("connected_status", "Restart NomadNet for changes to take effect")) + + def no(_b): + self.dismiss_dialog() + + pile = urwid.Pile([ + urwid.Text(msg, align="center"), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title), height=10) def _overlay(self, dialog, height=8, width=50): frame = self.app.ui.main_display.frame @@ -3013,6 +3262,20 @@ class ProfilesView(urwid.WidgetWrap): self._prompt("New Profile", edit, ok) + def save_current(self): + n = len(self.profiles.enabled_set()) + edit = ReadlineEdit(caption="Name (%d enabled): " % n) + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + pid = self.profiles.save_current_as_profile(val) + self.refresh(focus_pid=pid) + self.status.set_text(("connected_status", "Saved current setup (%d interface%s) as a profile" % (n, "" if n == 1 else "s"))) + + self._prompt("Save Current as Profile", edit, ok) + def rename_profile(self): w = self._focused() if w is None or w.pid is None: @@ -3075,6 +3338,8 @@ class ProfilesView(urwid.WidgetWrap): self.parent.show_interfaces(); return None if key == "ctrl a": self.new_profile(); return None + if key == "ctrl s": + self.save_current(); return None if key == "ctrl e": self.rename_profile(); return None if key == "ctrl x": @@ -3099,6 +3364,11 @@ class InterfaceDisplay: self.active_tab = "interfaces" self.iface_view_mode = "rows" + self.iface_filter = "all" + self.total_interfaces = 0 + self.restart_pending = False + self._notice_shown = False + self._rns_disconnected = False self.profiles_view = None self._build_interface_items() @@ -3106,11 +3376,12 @@ class InterfaceDisplay: self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents()) self.list_box = urwid.ListBox(self.list_walker) - self.body_rows = max(3, self.list_rows - 3) + self.body_rows = max(3, self.list_rows - 4) self.box_adapter = urwid.BoxAdapter(self.list_box, self.body_rows) self.tabs_pile = urwid.Pile([ ('pack', self._make_tab_bar("interfaces")), + ('pack', self._make_filter_bar()), ('pack', urwid.Divider()), self.box_adapter, ]) @@ -3118,13 +3389,41 @@ class InterfaceDisplay: self.shortcuts_display = InterfaceDisplayShortcuts(self.app) self.widget = self.interfaces_display + def _empty_message(self): + if self.iface_filter != "all" and self.total_interfaces > 0: + return "No %s interfaces." % self.iface_filter + return "No interfaces found. Press Ctrl + A to add a new interface " + def _interface_list_contents(self): if not self.interface_items: - return [urwid.Text(("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center")] + return [urwid.Text(("interface_title", self._empty_message()), align="center")] return list(self.interface_items) + def _make_filter_bar(self): + cols = [('weight', 1, urwid.Text(""))] + for key, label in (("all", "All"), ("enabled", "Enabled"), ("disabled", "Disabled")): + sel = self.iface_filter == key + mark = self.g['selected'] if sel else self.g['unselected'] + style = "interface_tile_focus" if sel else "interface_title" + cols.append(('pack', ClickableIcon((style, "%s %s " % (mark, label)), on_click=lambda k=key: self._set_filter(k)))) + cols.append(('weight', 1, urwid.Text(""))) + return urwid.Columns(cols, dividechars=0) + + def _set_filter(self, key): + if key == self.iface_filter: + return + self.iface_filter = key + self._build_interface_items() + self._rebuild_list() + self.app.ui.main_display.update_active_sub_display() + + def cycle_filter(self): + order = ["all", "enabled", "disabled"] + i = order.index(self.iface_filter) if self.iface_filter in order else 0 + self._set_filter(order[(i + 1) % len(order)]) + def _make_tab_bar(self, active): - iface = f" Interfaces ({len(self.interface_items)}) " + iface = f" Interfaces ({self.total_interfaces}) " prof = " Profiles " if active == "interfaces": iface_w = ClickableIcon(("interface_title_selected", "["+iface+"]"), on_click=self.show_interfaces) @@ -3141,7 +3440,7 @@ class InterfaceDisplay: ], dividechars=0) def _build_grid_listbox(self): - tiles = self.interface_tiles if self.interface_tiles else [urwid.Text("No interfaces found. Press Ctrl + A to add a new interface ", align="center")] + tiles = self.interface_tiles if self.interface_tiles else [urwid.Text(self._empty_message(), align="center")] self.iface_grid = urwid.GridFlow(tiles, cell_width=34, h_sep=2, v_sep=1, align="center") return urwid.ListBox(urwid.SimpleFocusListWalker([self.iface_grid])) @@ -3208,7 +3507,7 @@ class InterfaceDisplay: if new_rows != self.terminal_rows or new_cols != self.terminal_cols: self.terminal_cols, self.terminal_rows = new_cols, new_rows self.list_rows = self.terminal_rows - self.iface_row_offset - self.body_rows = max(3, self.list_rows - 3) + self.body_rows = max(3, self.list_rows - 4 - (1 if self.restart_pending else 0)) self.box_adapter.height = self.body_rows @@ -3217,45 +3516,65 @@ class InterfaceDisplay: if self.started: loop.set_alarm_in(5, self.check_terminal_size) + def _show_disconnect_overlay(self): + # Surfaced when the shared RNS instance (rnsd) we are attached to goes away. + if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: + return + if self.widget is not self.interfaces_display: + return + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text("Waiting to Reconnect...", align="center"), + ]) + dialog_box = urwid.LineBox(urwid.Filler(dialog_text)) + self.disconnect_overlay = urwid.Overlay( + dialog_box, + self.interfaces_display, + align='center', + width=35, + valign='middle', + height=4, + ) + self.widget = self.disconnect_overlay + self.app.ui.main_display.update_active_sub_display() + def poll_stats(self, loop, user_data): self.poll_scheduler = True try: - if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: - self.widget = self.interfaces_display - self.app.ui.main_display.update_active_sub_display() - - def job(): self.app.interface_stats = self.app.rns.get_interface_stats() - threading.Thread(target=job, daemon=True).start() - interface_stats = self.app.interface_stats - stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} - for item in self.interface_items: - # use interface name as the key - stats_for_interface = stats_lookup.get(item.name) - if stats_for_interface: - tx = stats_for_interface.get("txb", 0) - rx = stats_for_interface.get("rxb", 0) - item.update_stats(tx, rx) - except Exception as e: - if not hasattr(self, 'disconnect_overlay') or self.widget is not self.disconnect_overlay: - dialog_text = urwid.Pile([ - urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), - urwid.Text(("Waiting to Reconnect..."), align="center") - ]) - dialog_content = urwid.Filler(dialog_text) - dialog_box = urwid.LineBox(dialog_content) - - self.disconnect_overlay = urwid.Overlay( - dialog_box, - self.interfaces_display, - align='center', - width=35, - valign='middle', - height=4 - ) - - if self.widget is self.interfaces_display: - self.widget = self.disconnect_overlay + # React to the outcome of the previous (asynchronous) stats fetch. The + # fetch runs in a daemon thread so the UI never blocks on the RPC round + # trip; the thread cannot raise into this loop, so it reports failure via + # the _rns_disconnected flag instead. + if self._rns_disconnected: + self._show_disconnect_overlay() + else: + if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: + self.widget = self.interfaces_display self.app.ui.main_display.update_active_sub_display() + + interface_stats = self.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + for item in self.interface_items: + # use interface name as the key + stats_for_interface = stats_lookup.get(item.name) + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0)) + item.update_stats(tx, rx, announce_rate) + item.update_connection(stats_for_interface.get("status", False)) + else: + item.update_connection(False) + + def job(): + try: + self.app.interface_stats = self.app.rns.get_interface_stats() + self._rns_disconnected = False + except Exception: + self._rns_disconnected = True + threading.Thread(target=job, daemon=True).start() + except Exception as e: + self._show_disconnect_overlay() finally: if self.started: loop.set_alarm_in(1, self.poll_stats) @@ -3424,6 +3743,7 @@ class InterfaceDisplay: self.paste_status.set_text(("error", " Failed: " + str(e))) return + self.mark_restart_pending() self.dismiss_dialog() self.show_interfaces() @@ -3445,6 +3765,7 @@ class InterfaceDisplay: try: self.app.interface_profiles.remove_interface(interface_name) except Exception: pass + self.mark_restart_pending() self._build_interface_items() self._rebuild_list() self.dismiss_dialog() @@ -3519,6 +3840,7 @@ class InterfaceDisplay: processed_interfaces[interface_name] = interface_data + self.total_interfaces = len(processed_interfaces) self.app.interface_stats = self.app.rns.get_interface_stats() stats_lookup = {interface['short_name']: interface for interface in self.app.interface_stats['interfaces']} @@ -3527,6 +3849,11 @@ class InterfaceDisplay: for interface_name, interface_data in processed_interfaces.items(): is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + if self.iface_filter == "enabled" and not is_enabled: + continue + if self.iface_filter == "disabled" and is_enabled: + continue + iface_type = interface_data.get("type", "Unknown") icon = _get_interface_icon(self.glyphset, iface_type) @@ -3536,10 +3863,12 @@ class InterfaceDisplay: tx = stats_for_interface.get("txb", 0) rx = stats_for_interface.get("rxb", 0) is_connected = stats_for_interface["status"] + announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0)) else: tx = 0 rx = 0 is_connected = False + announce_rate = "-" profiles_label = profiles.label_for(interface_name) if profiles is not None else None @@ -3552,7 +3881,8 @@ class InterfaceDisplay: tx=tx, rx=rx, icon=icon, - profiles_label=profiles_label + profiles_label=profiles_label, + announce_rate=announce_rate ) self.interface_items.append(item) @@ -3564,12 +3894,104 @@ class InterfaceDisplay: is_enabled=is_enabled, iface_type=iface_type, icon=icon, + announce_rate=announce_rate, ) self.interface_tiles.append(tile) def _rebuild_list(self): self._set_iface_body() self.tabs_pile.contents[0] = (self._make_tab_bar("interfaces"), self.tabs_pile.options('pack')) + self.tabs_pile.contents[1] = (self._make_filter_bar(), self.tabs_pile.options('pack')) + + def mark_restart_pending(self): + self.restart_pending = True + if self._notice_shown: + return + try: + mark = "!" if self.glyphset == "plain" else "⚠" + banner = urwid.AttrMap( + urwid.Text("%s Interfaces have been updated. Restart required" % mark, align="center"), + "warning_text") + self.tabs_pile.contents.insert(2, (banner, self.tabs_pile.options('pack'))) + self._notice_shown = True + self.body_rows = max(3, self.body_rows - 1) + self.box_adapter.height = self.body_rows + except Exception: + pass + + def _focus_interface(self, name): + try: + if self.iface_view_mode == "grid": + for i, t in enumerate(self.interface_tiles): + if getattr(t, "name", None) == name: + self.iface_grid.focus_position = i + return + else: + body = self.box_adapter._original_widget.body + for i, w in enumerate(body): + if getattr(w, "name", None) == name: + self.box_adapter._original_widget.focus_position = i + return + except Exception: + pass + + def toggle_focused_interface(self): + name = self._focused_interface_name() + if name is None: + return + interfaces = self.app.rns.config['interfaces'] + if name not in interfaces: + return + iface = interfaces[name] + is_enabled = str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \ + str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + value = "false" if is_enabled else "true" + iface["interface_enabled"] = value + if "enabled" in iface: + iface["enabled"] = value + try: + self.app.rns.config.write() + except Exception: + return + self.mark_restart_pending() + self._build_interface_items() + self._rebuild_list() + self._focus_interface(name) + self.app.ui.main_display.update_active_sub_display() + + def save_current_profile(self): + profiles = getattr(self.app, "interface_profiles", None) + if profiles is None: + return + n = len(profiles.enabled_set()) + edit = ReadlineEdit(caption="Name: ") + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + profiles.save_current_as_profile(val) + self.show_interfaces() + + pile = urwid.Pile([ + urwid.Text("Save the %d currently enabled interface%s as a new profile." % (n, "" if n == 1 else "s"), align="left"), + urwid.Divider(), + edit, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=ok)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Save Current as Profile") + overlay = urwid.Overlay( + dialog, self.interfaces_display, + align="center", width=("relative", 60), + valign="middle", height=("relative", 45), min_width=44, min_height=9, + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() def open_config_editor(self): import platform @@ -3602,7 +4024,7 @@ class InterfaceDisplay: class InterfaceDisplayShortcuts: def __init__(self, app): self.app = app - self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [Enter] Show [C-t] Grid/Rows [C-w] Editor [Tab] Profiles" + self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [C-o] On/Off [Enter] Show [C-f] Filter [C-s] Save Profile [C-t] Grid/Rows [Tab] Profiles" self.current_shortcuts = self.default_shortcuts self.widget = urwid.AttrMap( urwid.Text(self.current_shortcuts), @@ -3629,5 +4051,5 @@ class InterfaceDisplayShortcuts: self.update_shortcuts(edit_shortcuts) def set_profiles_tab_shortcuts(self): - profiles_shortcuts = "[Up/Down] Navigate [Space] Select Profile [C-a] New [C-e] Rename [C-x] Delete [Tab] Interfaces" + profiles_shortcuts = "[Space] Select [C-a] New [C-s] Save Current [C-e] Rename [C-x] Delete [Tab] Interfaces" self.update_shortcuts(profiles_shortcuts) \ No newline at end of file From 0213ecbcb910140c3be2e062dedb401532c6bf98 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:45:01 -0400 Subject: [PATCH 510/592] Guide: add anchors for interface types and description of [ Interface ] profiles --- nomadnet/ui/textui/Guide.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 5ac7241..8cdb239 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -571,8 +571,32 @@ To add a new interface, press `!< Ctrl + A >`!. From here you can select which t `Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b +>Interface Profiles + +Interface profiles let you save different combinations of enabled interfaces and switch between them quickly. For example, you might keep a `!Home`! profile that uses a local AutoInterface, and a `!Travel`! profile that reaches out over a TCP gateway instead. + +Press `!< Tab >`! from the `![ Interfaces ]`! section to move between the `!Interfaces`! and `!Profiles`! tabs. + +To save your current set of enabled interfaces as a new profile, press `!< Ctrl + S >`!. To assign an interface to one or more profiles, edit it with `!< Ctrl + E >`! and use the profile checkboxes in the edit form. You can also toggle the selected interface on or off directly with `!< Ctrl + O >`!, and filter the list by All, Enabled or Disabled with `!< Ctrl + F >`!. + +`Ffff`! (!) Note:`! Switching profiles changes which interfaces are enabled, so a restart of NomadNet or your Reticulum instance is required for the change to take effect.`f`b + + >Interface Types +Jump to a specific interface type: + + `F79d`_`[AutoInterface`#autointerface]`_`f + `F79d`_`[TCPClientInterface`#tcpclientinterface]`_`f + `F79d`_`[TCPServerInterface`#tcpserverinterface]`_`f + `F79d`_`[UDPInterface`#udpinterface]`_`f + `F79d`_`[I2PInterface`#i2pinterface]`_`f + `F79d`_`[RNodeInterface`#rnodeinterface]`_`f + `F79d`_`[RNodeMultiInterface`#rnodemultiinterface]`_`f + `F79d`_`[SerialInterface`#serialinterface]`_`f + `F79d`_`[KISSInterface`#kissinterface]`_`f + `F79d`_`[PipeInterface`#pipeinterface]`_`f + >>AutoInterface The Auto Interface enables communication with other discoverable Reticulum nodes over autoconfigured IPv6 and UDP. It does not need any functional IP infrastructure like routers or DHCP servers, but will require at least some sort of switching medium between peers (a wired switch, a hub, a WiFi access point or similar). From 44594ff448825bef8447543be1542dc686a6840b Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:12:10 -0400 Subject: [PATCH 511/592] NomadNetworkApp: Add a quit dialog on Ctrl + Q and header Quit --- nomadnet/ui/TextUI.py | 2 +- nomadnet/ui/textui/Log.py | 6 +++- nomadnet/ui/textui/Main.py | 60 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 0708bd2..058228e 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -273,7 +273,7 @@ class TextUI: def unhandled_input(self, key): if key == "ctrl q": - raise urwid.ExitMainLoop + self.main_display.show_quit_dialog() elif key == "ctrl e": pass diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index bea98ef..11b5efa 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -55,9 +55,13 @@ class LogTerminal(urwid.WidgetWrap): def keypress(self, size, key): + if key == "ctrl q": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.show_quit_dialog() + return None + if key == "up": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" - + return super(LogTerminal, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index a50d8b7..12fad81 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -87,6 +87,18 @@ class MainFrame(urwid.Frame): return super(MainFrame, self).keypress(size, key) +class QuitDialogLineBox(urwid.LineBox): + def __init__(self, *args, on_cancel=None, **kwargs): + self.on_cancel = on_cancel + super().__init__(*args, **kwargs) + + def keypress(self, size, key): + if key == "esc": + if self.on_cancel is not None: + self.on_cancel() + return None + return super().keypress(size, key) + class MainDisplay(): def __init__(self, ui, app): self.ui = ui @@ -166,6 +178,54 @@ class MainDisplay(): self.menu_display.start() def quit(self, sender=None): + self.show_quit_dialog() + + def show_quit_dialog(self, sender=None): + if getattr(self, "_quit_dialog_open", False): + return + + def confirm(button=None): + self.do_quit() + + def cancel(button=None): + self._quit_dialog_open = False + self.app.ui.loop.widget = self.frame + + buttons = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirm)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=cancel)), + ]) + try: + + + buttons.focus_position = 2 # default to "No" + + + except Exception: + pass + + pile = urwid.Pile([ + urwid.Text("Are you sure you want to quit?", align="center"), + urwid.Divider(), + buttons, + ]) + + dialog = QuitDialogLineBox( + urwid.Filler(pile, urwid.TOP), + title="Quit NomadNet", + on_cancel=cancel, + ) + + self._quit_dialog_open = True + + self.app.ui.loop.widget = urwid.Overlay( + dialog, self.frame, + align="center", width=44, + valign="middle", height=8, min_width=20, + ) + + def do_quit(self, sender=None): logterm_pid = None if True or RNS.vendor.platformutils.is_android(): if self.sub_displays.log_display != None and self.sub_displays.log_display.log_term != None: From 94f5e7188a234f5aae3638ca3fa1ba92e086fc52 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:09:42 -0400 Subject: [PATCH 512/592] Add visual Config editor. Optional utilities tab with path, rncp file send utility, transport table viewer, speedtest, and identity resolver. Misc fixes --- nomadnet/NomadNetworkApp.py | 15 + nomadnet/ui/TextUI.py | 18 + nomadnet/ui/textui/Config.py | 1225 ++++++++++++- nomadnet/ui/textui/Conversations.py | 207 +-- nomadnet/ui/textui/Editor.py | 6 +- nomadnet/ui/textui/FileBrowser.py | 195 +++ nomadnet/ui/textui/Interfaces.py | 129 +- nomadnet/ui/textui/Main.py | 34 +- nomadnet/ui/textui/Transport.py | 503 ++++++ nomadnet/ui/textui/Utilities.py | 1523 +++++++++++++++++ .../additional_urwid_widgets/FormWidgets.py | 31 +- 11 files changed, 3658 insertions(+), 228 deletions(-) create mode 100644 nomadnet/ui/textui/FileBrowser.py create mode 100644 nomadnet/ui/textui/Transport.py create mode 100644 nomadnet/ui/textui/Utilities.py diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 24a75a7..6bf3be2 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -984,6 +984,13 @@ class NomadNetworkApp: if value == "web": self.uimode = nomadnet.ui.UI_WEB + if not "utilities" in self.config: + self.config["utilities"] = {} + if not "enable_utilities" in self.config["utilities"]: + self.config["utilities"]["enable_utilities"] = False + else: + self.config["utilities"]["enable_utilities"] = self.config["utilities"].as_bool("enable_utilities") + if "rrc" in self.config: for option in self.config["rrc"]: if option == "history_per_room_cap": @@ -1383,6 +1390,14 @@ sanitize_names = yes # escape sequences. clipboard_copy = no +[utilities] + +# You can enable a Utilities section in the +# menu, where you can view the path and announce +# rate tables and probe destinations. Disabled +# by default. +enable_utilities = no + [rrc] # Maximum number of messages retained per diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 058228e..50d65d1 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -128,6 +128,23 @@ THEMES = { } } +CONFIG_SECTION_COLORS = { + "logging": ("yellow", "#fd3", "brown", "#a80"), + "client": ("light cyan", "#3cd", "dark cyan", "#077"), + "textui": ("light green", "#6c5", "dark green", "#2a0"), + "utilities": ("light gray", "#bbb", "dark gray", "#555"), + "rrc": ("light magenta", "#d7f", "dark magenta", "#a2a"), + "node": ("light blue", "#5af", "dark blue", "#06c"), + "printing": ("brown", "#b86", "brown", "#852"), + "reticulum": ("light magenta", "#a6f", "dark magenta", "#63c"), +} + +for _section, (_d16, _dhi, _l16, _lhi) in CONFIG_SECTION_COLORS.items(): + THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section, _d16, "default", "default", _dhi, "default")) + THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section+"_selected", _d16+",bold", "default", "bold", _dhi+",bold", "default")) + THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section, _l16, "default", "default", _lhi, "default")) + THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section+"_selected", _l16+",bold", "default", "bold", _lhi+",bold", "default")) + GLYPHSETS = { "plain": 1, "unicode": 2, @@ -181,6 +198,7 @@ GLYPHS = { ("folder_open", "[-]", "\u25be", "\uf07c"), ("fold_open", "-", "▾", "▾"), ("fold_closed", "+", "▸", "▸"), + ("dropdown", " [v]", " ▾", " "), } class TextUI: diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py index 36d92a5..84de154 100644 --- a/nomadnet/ui/textui/Config.py +++ b/nomadnet/ui/textui/Config.py @@ -1,13 +1,705 @@ +import RNS import nomadnet import urwid import platform +from RNS.vendor.configobj import ConfigObj +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * + +CONFIG_SECTION_GLYPHS = { + ("logging", "(L)", "≡", ""), + ("client", "(C)", "✉", ""), + ("utilities", "(Y)", "⚒", ""), + ("textui", "(U)", "▤", ""), + ("rrc", "(R)", "❖", ""), + ("node", "(N)", "Ⓝ", "\U000f0002"), + ("printing", "(P)", "⎙", ""), + ("reticulum", "(Rt)", "⇄", ""), + ("editor", "(E)", "✎", ""), +} + +def _get_section_icon(glyphset, name): + glyphset_index = 1 + if glyphset == "plain": + glyphset_index = 0 + elif glyphset == "nerdfont": + glyphset_index = 2 + + for glyph_tuple in CONFIG_SECTION_GLYPHS: + if glyph_tuple[0] == name: + return glyph_tuple[glyphset_index + 1] + + return "(#)" if glyphset == "plain" else "⚙" if glyphset == "unicode" else "" + +def _get_cols_rows(): + return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() + +def section_title_attr(section_key): + return "config_title_" + section_key + +class ConfigCheckbox(FormCheckbox): + def __init__(self, config_key, on_text="Enabled", off_text="Disabled", state=False, validation_types=None, transform=None, **kwargs): + self.on_text = on_text + self.off_text = off_text + super().__init__(config_key, label="", state=state, validation_types=validation_types, transform=transform, **kwargs) + self._refresh_label(self.get_state()) + urwid.connect_signal(self, "change", self._on_change) + + def _on_change(self, widget, new_state): + self._refresh_label(new_state) + + def _refresh_label(self, state): + if state: + self.set_label(("connected_status", " " + self.on_text)) + else: + self.set_label(("inactive_text", " " + self.off_text)) + +CONFIG_SECTIONS = [ + { + "key": "logging", + "name": "Logging", + "glyph": "logging", + "description": "Log verbosity and where log output is written", + "fields": [ + { + "config_key": "loglevel", + "label": "Log level: ", + "type": "dropdown", + "options": ["0", "1", "2", "3", "4", "5", "6", "7"], + "default": "4", + "help": "0 = critical, 4 = info, 7 = extreme verbosity", + }, + { + "config_key": "destination", + "label": "Destination: ", + "type": "dropdown", + "options": ["file", "stdout"], + "default": "file", + "help": "Write log to a file or to standard output", + }, + { + "config_key": "logfile", + "label": "Log file: ", + "type": "edit", + "placeholder": "Defaults to the config directory", + "help": "Path to the log file (when destination is file)", + }, + ], + }, + { + "key": "client", + "name": "LXMF client", + "glyph": "client", + "description": "Client behaviour, announces, syncing and messages", + "fields": [ + { + "config_key": "downloads_path", + "label": "Downloads path: ", + "type": "edit", + "default": "~/Downloads", + "placeholder": "~/Downloads", + "help": "Where downloaded files are saved", + }, + { + "config_key": "attachment_save_path", + "label": "Attachment path: ", + "type": "edit", + "placeholder": "Defaults to the downloads path", + "help": "Where message attachments are saved", + }, + { + "config_key": "notify_on_new_message", + "label": "Notify on message: ", + "type": "checkbox", + "default": True, + "help": "Show a notification when a new message arrives", + }, + { + "config_key": "announce_at_start", + "label": "Announce at start: ", + "type": "checkbox", + "default": True, + "help": "Announce the local peer when starting", + }, + { + "config_key": "announce_interval", + "label": "Announce interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between peer announces (minimum 30)", + }, + { + "config_key": "try_propagation_on_send_fail", + "label": "Propagate on fail: ", + "type": "checkbox", + "default": True, + "help": "Use a propagation node if direct delivery fails", + }, + { + "config_key": "periodic_lxmf_sync", + "label": "Periodic sync: ", + "type": "checkbox", + "default": True, + "help": "Automatically sync messages from a propagation node", + }, + { + "config_key": "lxmf_sync_interval", + "label": "Sync interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between automatic syncs", + }, + { + "config_key": "lxmf_sync_limit", + "label": "Sync limit: ", + "type": "edit", + "default": "8", + "validation": ["number"], + "help": "Max messages fetched per sync (0 = no limit)", + }, + { + "config_key": "required_stamp_cost", + "label": "Required stamp cost: ", + "type": "edit", + "default": "None", + "placeholder": "None", + "help": "Required inbound stamp cost 1-255, or None to disable", + }, + { + "config_key": "accept_invalid_stamps", + "label": "Accept invalid stamps: ", + "type": "checkbox", + "default": False, + "help": "Accept messages carrying an invalid stamp", + }, + { + "config_key": "max_accepted_size", + "label": "Max message size: ", + "type": "edit", + "default": "500", + "validation": ["float"], + "help": "Maximum accepted incoming message size, in KB", + }, + { + "config_key": "compact_announce_stream", + "label": "Compact announces: ", + "type": "checkbox", + "default": True, + "help": "Show only one announce per destination", + }, + { + "config_key": "compose_in_markdown", + "label": "Compose in markdown: ", + "type": "checkbox", + "default": True, + "help": "Author messages using markdown", + }, + ], + }, + { + "key": "textui", + "name": "Text UI", + "glyph": "textui", + "description": "Appearance and behaviour of the text interface", + "note": "Switch between the light and dark theme, glyphs and other appearance options here.", + "fields": [ + { + "config_key": "intro_time", + "label": "Show intro: ", + "type": "checkbox", + "default": True, + "on_value": "1.0", + "off_value": "0", + "help": "Show the intro screen at startup", + }, + { + "config_key": "intro_text", + "label": "Intro text: ", + "type": "edit", + "default": "Nomad Network", + "placeholder": "Nomad Network", + "help": "Text shown on the intro screen", + }, + { + "config_key": "theme", + "label": "Theme: ", + "type": "dropdown", + "options": ["dark", "light"], + "default": "dark", + "help": "Colour theme", + }, + { + "config_key": "colormode", + "label": "Color mode: ", + "type": "dropdown", + "options": ["monochrome", "16", "88", "256", "24bit"], + "default": "24bit", + "help": "Colour capability of your terminal", + }, + { + "config_key": "glyphs", + "label": "Glyphs: ", + "type": "dropdown", + "options": ["plain", "unicode", "nerdfont"], + "default": "nerdfont", + "help": "Glyph set used for icons", + }, + { + "config_key": "mouse_enabled", + "label": "Mouse enabled: ", + "type": "checkbox", + "default": True, + "help": "Allow mouse interaction", + }, + { + "config_key": "editor", + "label": "Editor: ", + "type": "edit", + "default": "nano", + "placeholder": "nano", + "help": "Command used to edit text", + }, + { + "config_key": "hide_guide", + "label": "Hide guide: ", + "type": "checkbox", + "default": False, + "help": "Hide the Guide entry in the menu", + }, + { + "config_key": "sanitize_names", + "label": "Sanitize names: ", + "type": "checkbox", + "default": True, + "help": "Strip control characters from announced names", + }, + { + "config_key": "clipboard_copy", + "label": "Clipboard copy: ", + "type": "checkbox", + "default": False, + "help": "Enable copying to the system clipboard", + }, + { + "config_key": "animation_interval", + "label": "Animation interval: ", + "type": "edit", + "default": "1", + "validation": ["number"], + "help": "Interval between UI animation frames", + }, + ], + }, + { + "key": "utilities", + "name": "Utilities", + "glyph": "utilities", + "description": "Optional utility views and tools", + "note": "Adds a Utilities menu where you can view path/announce tables and probe destinations.", + "fields": [ + { + "config_key": "enable_utilities", + "label": "Enable utilities: ", + "type": "checkbox", + "default": False, + "help": "Show the Utilities section (path tables, probe) in the menu", + }, + ], + }, + { + "key": "rrc", + "name": "RRC Client Settings", + "glyph": "rrc", + "description": "Rendering and history for the rooms client", + "fields": [ + { + "config_key": "history_per_room_cap", + "label": "History cap: ", + "type": "edit", + "default": "500", + "validation": ["number"], + "help": "Messages kept in memory per room", + }, + { + "config_key": "filter_loaded_history", + "label": "Filter history: ", + "type": "checkbox", + "default": True, + "help": "Filter system messages out of loaded history", + }, + { + "config_key": "ephemeral_notices", + "label": "Ephemeral notices: ", + "type": "edit", + "default": "10", + "validation": ["float"], + "help": "Minutes to keep notices (0 keeps them)", + }, + { + "config_key": "color_mention_timestamps", + "label": "Color mention time: ", + "type": "checkbox", + "default": True, + "help": "Colour the timestamp when you are mentioned", + }, + { + "config_key": "render_markdown", + "label": "Render markdown: ", + "type": "checkbox", + "default": True, + "help": "Render markdown formatting in messages", + }, + { + "config_key": "render_micron", + "label": "Render micron: ", + "type": "checkbox", + "default": True, + "help": "Render micron markup in messages", + }, + { + "config_key": "nick_colors", + "label": "Nick colors: ", + "type": "checkbox", + "default": True, + "help": "Colour user nicknames", + }, + { + "config_key": "justify_msgs", + "label": "Justify messages: ", + "type": "checkbox", + "default": True, + "help": "Justify message text", + }, + { + "config_key": "space_msgs", + "label": "Space messages: ", + "type": "checkbox", + "default": False, + "help": "Add extra spacing between messages", + }, + { + "config_key": "show_gutters", + "label": "Show gutters: ", + "type": "checkbox", + "default": True, + "help": "Show gutters alongside messages", + }, + { + "config_key": "mention_color", + "label": "Mention color: ", + "type": "edit", + "placeholder": "6-digit hex, e.g. ff8800", + "help": "Hex colour used for mentions of your nick", + }, + { + "config_key": "nick_colors_theme", + "label": "Nick color theme: ", + "type": "list", + "default": [], + "placeholder": "6-digit hex colour", + "help": "Custom palette of hex colours used for nicknames", + }, + { + "config_key": "enable_esoterics", + "label": "Enable esoterics: ", + "type": "checkbox", + "default": False, + "help": "Enable esoteric features", + }, + ], + }, + { + "key": "node", + "name": "Node", + "glyph": "node", + "description": "Node hosting, propagation and announces", + "note": "Enable this to host a node on the network, serving pages and files to others.", + "fields": [ + { + "config_key": "enable_node", + "label": "Enable node: ", + "type": "checkbox", + "default": False, + "help": "Host a node on the network", + }, + { + "config_key": "node_name", + "label": "Node name: ", + "type": "edit", + "default": "None", + "placeholder": "None", + "help": "Display name for the node, or None", + }, + { + "config_key": "announce_at_start", + "label": "Announce at start: ", + "type": "checkbox", + "default": True, + "help": "Announce the node when starting", + }, + { + "config_key": "announce_interval", + "label": "Announce interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between node announces (minimum 1)", + }, + { + "config_key": "disable_propagation", + "label": "Disable propagation: ", + "type": "checkbox", + "default": True, + "help": "Disable the propagation node functionality", + }, + { + "config_key": "propagation_cost", + "label": "Propagation cost: ", + "type": "edit", + "default": "16", + "validation": ["number"], + "help": "Stamp cost for propagation (minimum 13)", + }, + { + "config_key": "max_transfer_size", + "label": "Max transfer size: ", + "type": "edit", + "default": "256", + "validation": ["float"], + "help": "Maximum single message size on propagation, in KB", + }, + { + "config_key": "max_sync_size", + "label": "Max sync size: ", + "type": "edit", + "default": "10240", + "validation": ["float"], + "help": "Maximum sync batch size, in KB", + }, + { + "config_key": "pages_path", + "label": "Pages path: ", + "type": "edit", + "placeholder": "Default node pages directory", + "help": "Directory of pages served by the node", + }, + { + "config_key": "page_refresh_interval", + "label": "Page refresh: ", + "type": "edit", + "default": "0", + "validation": ["number"], + "help": "Minutes between page rescans (0 = never)", + }, + { + "config_key": "files_path", + "label": "Files path: ", + "type": "edit", + "placeholder": "Default node files directory", + "help": "Directory of files served by the node", + }, + { + "config_key": "file_refresh_interval", + "label": "File refresh: ", + "type": "edit", + "default": "0", + "validation": ["number"], + "help": "Minutes between file rescans (0 = never)", + }, + { + "config_key": "prioritise_destinations", + "label": "Priority dests: ", + "type": "list", + "default": [], + "placeholder": "Destination hash", + "help": "Destination hashes given storage priority", + }, + { + "config_key": "static_peers", + "label": "Static peers: ", + "type": "list", + "default": [], + "placeholder": "Destination hash", + "help": "Propagation peers always kept connected", + }, + { + "config_key": "max_peers", + "label": "Max peers: ", + "type": "edit", + "placeholder": "No limit", + "validation": ["number"], + "help": "Maximum number of propagation peers", + }, + { + "config_key": "message_storage_limit", + "label": "Storage limit: ", + "type": "edit", + "default": "2000", + "validation": ["float"], + "help": "Maximum propagation storage, in MB", + }, + ], + }, + { + "key": "printing", + "name": "Printing", + "glyph": "printing", + "description": "Printing of received messages", + "note": "The rendered message file is appended to this command, so it runs as e.g. 'lp '.", + "fields": [ + { + "config_key": "print_messages", + "label": "Print messages: ", + "type": "checkbox", + "default": False, + "help": "Print received messages", + }, + { + "config_key": "print_command", + "label": "Print command: ", + "type": "edit", + "default": "lp", + "placeholder": "lp", + "help": ("Command to print with; the message file is appended as the last argument. Examples: " + "lp | " + "lp -d NAME (named printer, list with lpstat -p) | " + "lp -d NAME -o cpi=16 -o lpi=8 (small thermal-roll printers) | " + "lp -d NAME -o media=Custom.58x210mm (58mm thermal roll)"), + }, + { + "config_key": "print_from", + "label": "Print from: ", + "type": "edit", + "placeholder": "everywhere, trusted, or hashes", + "help": "Which senders may trigger automatic printing", + }, + { + "config_key": "message_template", + "label": "Message template: ", + "type": "edit", + "placeholder": "Path to template file", + "help": "Template file used when printing messages", + }, + ], + }, + { + "key": "reticulum", + "name": "Reticulum (global)", + "glyph": "reticulum", + "configfile": "reticulum", + "description": "Global Reticulum settings (shared instance, transport)", + "fields": [ + { + "config_key": "enable_transport", + "label": "Enable transport: ", + "type": "checkbox", + "default": False, + "help": "Route traffic for other peers (transport node)", + }, + { + "config_key": "share_instance", + "label": "Share instance: ", + "type": "checkbox", + "default": True, + "help": "Run a shared instance other local programs connect to", + }, + { + "config_key": "instance_name", + "label": "Instance name: ", + "type": "edit", + "default": "default", + "placeholder": "default", + "help": "Name used to isolate multiple shared instances", + }, + { + "config_key": "shared_instance_type", + "label": "Instance type: ", + "type": "edit", + "placeholder": "unix or tcp (blank for auto)", + "help": "Transport used for shared instance communication", + }, + { + "config_key": "shared_instance_port", + "label": "Instance port: ", + "type": "edit", + "placeholder": "37428", + "validation": ["number"], + "help": "TCP port for the shared instance", + }, + { + "config_key": "instance_control_port", + "label": "Control port: ", + "type": "edit", + "placeholder": "37429", + "validation": ["number"], + "help": "TCP control port for the shared instance", + }, + { + "config_key": "discover_interfaces", + "label": "Discover interfaces: ", + "type": "checkbox", + "default": False, + "help": "Discover interfaces advertised by transport instances", + }, + { + "config_key": "enable_remote_management", + "label": "Remote management: ", + "type": "checkbox", + "default": False, + "help": "Allow remote management of this instance", + }, + { + "config_key": "remote_management_allowed", + "label": "Remote allowed: ", + "type": "list", + "default": [], + "placeholder": "Identity hash", + "help": "Identity hashes allowed to manage this instance", + }, + { + "config_key": "respond_to_probes", + "label": "Respond to probes: ", + "type": "checkbox", + "default": False, + "help": "Answer reachability probes from other nodes", + }, + { + "config_key": "use_implicit_proof", + "label": "Implicit proof: ", + "type": "checkbox", + "default": True, + "help": "Use implicit proofs for packet delivery", + }, + { + "config_key": "panic_on_interface_error", + "label": "Panic on iface error: ", + "type": "checkbox", + "default": False, + "help": "Exit if an interface has an unrecoverable error", + }, + ], + }, +] + +_SECTION_ORDER = {"node": 0, "client": 1, "logging": 2, "textui": 3, "utilities": 4, "rrc": 5, "printing": 6, "reticulum": 7} +CONFIG_SECTIONS.sort(key=lambda s: _SECTION_ORDER.get(s["key"], 99)) + class ConfigDisplayShortcuts(): def __init__(self, app): - import urwid self.app = app + self.default_shortcuts = "[Enter] Edit Section [C-w] Text Editor [Up/Down] Navigate" + self.widget = urwid.AttrMap(urwid.Text(self.default_shortcuts), "shortcutbar") - self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") + def update_shortcuts(self, new_shortcuts): + self.widget.original_widget.set_text(new_shortcuts) + + def reset_shortcuts(self): + self.update_shortcuts(self.default_shortcuts) + + def set_section_shortcuts(self): + self.update_shortcuts("[Up/Down] Navigate Fields [Enter] Select Option [C-s] Save [C-r] Reset [Esc] Cancel") class ConfigFiller(urwid.WidgetWrap): def __init__(self, widget, app): @@ -15,41 +707,509 @@ class ConfigFiller(urwid.WidgetWrap): self.filler = urwid.Filler(widget, urwid.TOP) super().__init__(self.filler) + def keypress(self, size, key): + if key == "ctrl w": + self.app.ui.main_display.sub_displays.config_display.open_text_editor() + return None + return super(ConfigFiller, self).keypress(size, key) + +class SelectableConfigSection(urwid.WidgetWrap): + def __init__(self, parent, section_key, name, description, icon, note=None): + self.parent = parent + self.section_key = section_key + self.name = name + self.icon = icon + self._selectable = True + + self.title_attr = section_title_attr(section_key) + self.title_attr_selected = self.title_attr + "_selected" + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text((self.title_attr, f"{icon} {name}")) + + title_content = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + + description_row = urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("value", description)), + ]) + + pile_rows = [title_content, description_row] + if note: + pile_rows.append(urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("inactive_text", note)), + ])) + + pile = urwid.Pile(pile_rows) + padded_body = urwid.Padding(pile, left=2, right=2) + + box = urwid.LineBox( + padded_body, + title=None, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + super().__init__(box) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + + if focus: + self.title_widget.set_text((self.title_attr_selected, f"{self.icon} {self.name}")) + else: + self.title_widget.set_text((self.title_attr, f"{self.icon} {self.name}")) + + return super().render(size, focus=focus) def keypress(self, size, key): if key == "up": - self.app.ui.main_display.frame.focus_position = "header" - - return super(ConfigFiller, self).keypress(size, key) + listbox = self.parent.list_box + if listbox.focus_position == 0: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": + self.parent.switch_to_section(self.section_key) + return None + return key + +class ConfigActionItem(urwid.WidgetWrap): + def __init__(self, parent, label, on_select): + self.parent = parent + self.on_select = on_select + self._selectable = True + + text_widget = urwid.Text(label, align="left") + super().__init__(urwid.AttrMap(text_widget, "list_normal", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == "enter": + self.on_select() + return None + return super().keypress(size, key) + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self.on_select() + return True + return False + +class ConfigSectionView(urwid.WidgetWrap): + LABEL_WIDTH = 24 + + def __init__(self, parent, section): + self.parent = parent + self.section = section + self.section_key = section["key"] + self.fields = {} + + self.parent.shortcuts_display.set_section_shortcuts() + + self.config_path = self._resolve_config_path(section) + self.file_config = ConfigObj(self.config_path) + section_config = self.file_config.get(self.section_key, {}) + + cols, rows = _get_cols_rows() + self.content_width = min(80, max(40, cols - 6)) + + icon = _get_section_icon(self.parent.glyphset, section["glyph"]) + + field_items = [] + for field in section["fields"]: + widget = self._build_field_widget(field) + current = section_config.get(field["config_key"], field.get("default")) + self._populate_field(widget, field, current) + self.fields[field["config_key"]] = {"field": field, "widget": widget} + field_items.append(self._build_field_row(field, widget)) + + self.list_walker = urwid.SimpleFocusListWalker(field_items) + self.form_listbox = urwid.ListBox(self.list_walker) + + header = urwid.Pile([ + urwid.Text((section_title_attr(self.section_key) + "_selected", f"{icon} {section['name']}"), align=urwid.CENTER), + urwid.Divider("─"), + ]) + + save_btn = urwid.AttrMap(urwid.Button("Save", on_press=self.on_save), "button_normal", focus_map="button_focus") + reset_btn = urwid.AttrMap(urwid.Button("Reset", on_press=self.on_reset), "button_normal", focus_map="button_focus") + back_btn = urwid.AttrMap(urwid.Button("Cancel", on_press=self.on_back), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, save_btn), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, reset_btn), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, back_btn), + ]) + self._footer = urwid.Pile([ + urwid.Divider("─"), + urwid.Padding(button_row, align=urwid.CENTER, width=self.content_width), + ]) + try: + self._footer.focus_position = 1 + except Exception: + pass + + self._frame = urwid.Frame(self.form_listbox, header=header, footer=self._footer) + super().__init__(self._frame) + + def _resolve_config_path(self, section): + if section.get("configfile") == "reticulum": + try: + return self.parent.app.rns.config.filename + except Exception: + return RNS.Reticulum.configpath + return self.parent.app.configpath + + def _build_field_row(self, field, widget): + row = urwid.Columns([ + (self.LABEL_WIDTH, urwid.Text(("key", field["label"]), align=urwid.RIGHT)), + widget, + ]) + contents = [row] + help_text = self._field_help(field) + if help_text: + contents.append(urwid.Padding(urwid.Text(("inactive_text", help_text)), left=self.LABEL_WIDTH + 2)) + contents.append(urwid.Padding(widget.error_widget, left=self.LABEL_WIDTH + 2)) + contents.append(urwid.Divider()) + field_pile = urwid.Pile(contents) + return urwid.Padding(field_pile, align=urwid.CENTER, width=self.content_width) + + def _field_help(self, field): + parts = [] + if field.get("help"): + parts.append(field["help"]) + default = field.get("default", None) + if field["type"] == "checkbox": + default_text = "yes" if default else "no" + elif field["type"] == "list": + if not default: + default_text = "none" + elif isinstance(default, (list, tuple)): + default_text = ", ".join(str(item) for item in default) + else: + default_text = str(default) + else: + default_text = str(default) if default not in (None, "") else "none" + parts.append(f"(default: {default_text})") + return " ".join(parts) + + def _build_field_widget(self, field): + if field["type"] == "dropdown": + return FormDropdown( + config_key=field["config_key"], + label=field.get("label", ""), + options=field["options"], + default=str(field.get("default", field["options"][0])), + validation_types=field.get("validation", []), + ) + elif field["type"] == "checkbox": + return ConfigCheckbox( + config_key=field["config_key"], + on_text=field.get("on_text", "Enabled"), + off_text=field.get("off_text", "Disabled"), + state=bool(field.get("default", False)), + validation_types=field.get("validation", []), + ) + elif field["type"] == "list": + return FormMultiList( + config_key=field["config_key"], + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + ) + else: + return FormEdit( + config_key=field["config_key"], + caption="", + edit_text=str(field.get("default", "")), + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + ) + + def _populate_field(self, widget, field, value): + if value is None: + return + + if field["type"] == "checkbox": + if isinstance(value, bool): + state = value + else: + s = str(value).strip().lower() + if s in ("", "false", "off", "no"): + state = False + else: + try: + state = float(s) != 0 + except ValueError: + state = True + widget.set_state(state) + elif field["type"] == "dropdown": + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + elif field["type"] == "list": + if value: + widget.set_value(value if isinstance(value, list) else [value]) + else: + widget.edit_text = str(value) + + def validate_all(self): + all_valid = True + for entry in self.fields.values(): + if not entry["widget"].validate(): + all_valid = False + return all_valid + + def on_save(self, button): + if not self.validate_all(): + return + + if self.section_key not in self.file_config: + self.file_config[self.section_key] = {} + + section_config = self.file_config[self.section_key] + + for config_key, entry in self.fields.items(): + field = entry["field"] + widget = entry["widget"] + + if field["type"] == "checkbox": + on_value = field.get("on_value", "yes") + off_value = field.get("off_value", "no") + section_config[config_key] = on_value if widget.get_state() else off_value + elif field["type"] == "list": + value = widget.get_value() + if value: + section_config[config_key] = value + elif config_key in section_config: + del section_config[config_key] + else: + value = widget.get_value() + if value != "": + section_config[config_key] = value + elif config_key in section_config: + del section_config[config_key] + + try: + self.file_config.write() + self.parent.mark_restart_pending() + self.show_message(f"{self.section['name']} configuration saved.\nRestart Nomad Network for changes to take effect.") + except Exception as e: + self.show_message(f"Error saving configuration: {str(e)}", title="Error") + + def on_reset(self, button=None): + self._confirm( + f"Reset all {self.section['name']} settings to their defaults?\nYou will still need to Save to apply.", + self._do_reset, + ) + + def _do_reset(self): + for entry in self.fields.values(): + self._reset_field(entry["widget"], entry["field"]) + + def _reset_field(self, widget, field): + default = field.get("default") + if field["type"] == "checkbox": + widget.set_state(bool(default)) + elif field["type"] == "dropdown": + default_str = str(default if default is not None else widget.options[0]) + if default_str in widget.options: + widget.selected = default_str + widget.main_button.base_widget.set_text(default_str) + elif field["type"] == "list": + widget.set_value(default if isinstance(default, list) else []) + else: + widget.edit_text = "" if default is None else str(default) + widget.error_widget.set_text("") + + def on_back(self, button): + self.parent.switch_to_list() + + def dismiss_dialog(self): + self._dismiss_overlay() + + def _confirm(self, message, on_yes): + def yes(button): + self._dismiss_overlay() + on_yes() + + def no(button): + self._dismiss_overlay() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align=urwid.CENTER), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]), + parent=self, + title="Confirm", + ) + self._show_overlay(dialog, height=9, width=64) + + def _show_overlay(self, dialog, height=8, width=60): + overlay = urwid.Overlay( + dialog, + self, + align=urwid.CENTER, + width=width, + valign=urwid.MIDDLE, + height=height, + min_width=1, + min_height=1, + ) + self.parent.widget = overlay + self.parent.app.ui.main_display.update_active_sub_display() + + def _dismiss_overlay(self): + self.parent.widget = self + self.parent.app.ui.main_display.update_active_sub_display() + + def show_message(self, message, title="Notice"): + def dismiss_dialog(button): + self.parent.switch_to_list() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align=urwid.CENTER), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title=title + ) + + overlay = urwid.Overlay( + dialog, + self.parent.list_view, + align=urwid.CENTER, + width=60, + valign=urwid.MIDDLE, + height=10, + min_width=1, + min_height=1 + ) + + self.parent.widget = overlay + self.parent.app.ui.main_display.update_active_sub_display() + + def keypress(self, size, key): + if key == "ctrl s": + self.on_save(None) + return None + if key == "ctrl r": + self.on_reset(None) + return None + if key == "esc": + self.parent.switch_to_list() + return None + + result = super().keypress(size, key) + if result is None: + return None + + if result in ("down", "tab") and self._frame.focus_position != "footer": + self._frame.focus_position = "footer" + try: + self._footer.focus_position = 1 + except Exception: + pass + return None + if result == "up": + if self._frame.focus_position == "footer": + self._frame.focus_position = "body" + else: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + + return result class ConfigDisplay(): def __init__(self, app): - import urwid self.app = app + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.restart_pending = False + self.editor_term = None - def open_editor(sender): - self.editor_term = EditorTerminal(self.app, self) - self.widget = urwid.LineBox(self.editor_term) - self.app.ui.main_display.update_active_sub_display() - self.app.ui.main_display.frame.focus_position = "body" - self.editor_term.term.change_focus(True) - - pile = urwid.Pile([ - urwid.Text( - ( - "body_text", - "\nTo change the configuration, edit the config file located at:\n\n" - +self.app.configpath - +"\n\nRestart Nomad Network for changes to take effect\n", - ), - align=urwid.CENTER, - ), - urwid.Padding(urwid.Button("Open Editor", on_press=open_editor), width=15, align=urwid.CENTER), - ]) - - self.config_explainer = ConfigFiller(pile, self.app) self.shortcuts_display = ConfigDisplayShortcuts(self.app) - self.widget = self.config_explainer + + self._build_list_view() + self.widget = self.list_view + + def _build_list_view(self): + self.terminal_cols, self.terminal_rows = _get_cols_rows() + + self.section_items = [] + for section in CONFIG_SECTIONS: + icon = _get_section_icon(self.glyphset, section["glyph"]) + self.section_items.append( + SelectableConfigSection(self, section["key"], section["name"], section["description"], icon, note=section.get("note")) + ) + + editor_icon = _get_section_icon(self.glyphset, "editor") + self.editor_item = ConfigActionItem(self, f"{editor_icon} Open config file in text editor", self.open_text_editor) + + list_contents = list(self.section_items) + [urwid.Divider(), self.editor_item] + self.list_walker = urwid.SimpleFocusListWalker(list_contents) + self.list_box = urwid.ListBox(self.list_walker) + + self.list_rows = max(5, self.terminal_rows - 7) + self.box_adapter = urwid.BoxAdapter(self.list_box, self.list_rows) + + self.header_text = urwid.Text("") + self.list_divider = urwid.Divider("─") + self.list_pile = urwid.Pile([self.box_adapter]) + self._update_header() + + self.list_view = ConfigFiller(self.list_pile, self.app) + + def _update_header(self): + contents = [] + if self.restart_pending: + self.header_text.set_text(("warning_text", "Changes saved. Restart NomadNet for them to take effect")) + contents.append((self.header_text, self.list_pile.options('pack'))) + contents.append((self.list_divider, self.list_pile.options('pack'))) + contents.append((self.box_adapter, self.list_pile.options('weight', 1))) + self.list_pile.contents = contents + + def mark_restart_pending(self): + self.restart_pending = True + self._update_header() + + def switch_to_section(self, section_key): + section = next(s for s in CONFIG_SECTIONS if s["key"] == section_key) + self.section_view = ConfigSectionView(self, section) + self.widget = self.section_view + self.app.ui.main_display.update_active_sub_display() + + def switch_to_list(self): + self.shortcuts_display.reset_shortcuts() + self._update_header() + self.widget = self.list_view + self.app.ui.main_display.update_active_sub_display() + + def open_text_editor(self): + self.editor_term = EditorTerminal(self.app, self) + self.widget = urwid.LineBox(self.editor_term) + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.editor_term.term.change_focus(True) def shortcuts(self): return self.shortcuts_display @@ -72,9 +1232,7 @@ class EditorTerminal(urwid.WidgetWrap): ) def quit_term(*args, **kwargs): - self.parent.widget = self.parent.config_explainer - self.app.ui.main_display.update_active_sub_display() - self.app.ui.main_display.show_config(None) + self.parent.switch_to_list() self.app.ui.main_display.request_redraw() urwid.connect_signal(self.term, 'closed', quit_term) @@ -83,7 +1241,4 @@ class EditorTerminal(urwid.WidgetWrap): def keypress(self, size, key): - # TODO: Decide whether there should be a way to get out while editing - #if key == "up": - # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" - return super(EditorTerminal, self).keypress(size, key) \ No newline at end of file + return super(EditorTerminal, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 4d30068..bb0fe21 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -13,6 +13,7 @@ from datetime import datetime, timedelta from nomadnet.Directory import DirectoryEntry from LXMF import pn_announce_data_is_valid, PN_META_NAME from nomadnet.Conversation import ConversationMessage +from .FileBrowser import FileBrowser from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name @@ -2422,7 +2423,29 @@ class ConversationWidget(urwid.WidgetWrap): def attach_file(self): self.dialog_active = True - browser = FileBrowserDialog(self) + + def on_change(selected): + self.pending_attachments[:] = selected + self.frame.contents["footer"] = (self._build_footer(), None) + + def on_done(selected): + self.pending_attachments[:] = selected + self.frame.contents["footer"] = (self._build_footer(), None) + self.file_browser_closed() + + def on_cancel(): + self.pending_attachments.clear() + self.frame.contents["footer"] = (self._build_footer(), None) + self.file_browser_closed() + + browser = FileBrowser( + on_done=on_done, + on_cancel=on_cancel, + on_change=on_change, + title="Attach File", + multi_select=True, + selected=self.pending_attachments, + ) bottom = self.messagelist overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) self.frame.contents["body"] = (overlay, self.frame.options()) @@ -2886,188 +2909,6 @@ def _save_attachment_to_disk(filename, data): return save_path -class FileBrowserEntry(urwid.WidgetWrap): - signals = ["click"] - - def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False): - self.full_path = full_path - self.name = name - self.is_dir = is_dir - self.is_parent = is_parent - self.selected = selected - g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs - if is_parent: - display = g["arrow_l"]+" .." - elif is_dir: - display = g["arrow_r"]+" "+name+"/" - elif selected: - display = g["check"]+" "+name - else: - display = " "+name - self.text_widget = urwid.SelectableIcon(display, 0) - if is_dir or is_parent: - style = "list_trusted" - focus_style = "list_focus" - elif selected: - style = "list_trusted" - focus_style = "list_focus_trusted" - else: - style = "list_unknown" - focus_style = "list_focus" - display_widget = urwid.AttrMap(self.text_widget, style, focus_style) - super().__init__(display_widget) - - def keypress(self, size, key): - if key == "enter": - self._emit("click") - else: - return key - - def mouse_event(self, size, event, button, x, y, focus): - if button == 1 and urwid.util.is_mouse_press(event): - self._emit("click") - return True - return False - - -class FileBrowserDialog(urwid.WidgetWrap): - def __init__(self, delegate): - self.delegate = delegate - app = nomadnet.NomadNetworkApp.get_shared_instance() - self.g = app.ui.glyphs - self.current_path = os.path.expanduser("~") - - self.path_label = urwid.Text("") - self.status_label = urwid.Text("") - self.file_walker = urwid.SimpleFocusListWalker([]) - self.file_listbox = urwid.ListBox(self.file_walker) - - self.button_columns = urwid.Columns([ - (urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)), - (urwid.WEIGHT, 0.1, urwid.Text("")), - (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)), - ]) - - header_pile = urwid.Pile([ - self.path_label, - self.status_label, - urwid.Divider(self.g["divider1"]), - ]) - - footer_pile = urwid.Pile([ - urwid.Divider(self.g["divider1"]), - self.button_columns, - ]) - - self._populate() - - self.browser_frame = urwid.Frame( - self.file_listbox, - header=header_pile, - footer=footer_pile, - ) - - linebox = urwid.LineBox(self.browser_frame, title="Attach File") - super().__init__(linebox) - - def _update_status(self): - pending = self.delegate.pending_attachments - if pending: - names = [os.path.basename(p) for p in pending] - self.status_label.set_text(" "+self.g["file"]+" "+str(len(pending))+" selected: "+", ".join(names)) - else: - self.status_label.set_text(" No files selected") - - def _populate(self): - self.path_label.set_text(" "+self.current_path) - self._update_status() - - focus_pos = None - try: - focus_pos = self.file_listbox.focus_position - except Exception: - pass - - entries = [] - parent = os.path.dirname(self.current_path) - if parent != self.current_path: - entry = FileBrowserEntry("..", parent, is_parent=True) - urwid.connect_signal(entry, "click", self._entry_clicked, entry) - entries.append(entry) - - try: - items = sorted(os.listdir(self.current_path)) - except PermissionError: - entries.append(urwid.Text(("error_text", " Permission denied"))) - self.file_walker[:] = entries - return - - dirs = [] - files = [] - for item in items: - if item.startswith("."): - continue - full = os.path.join(self.current_path, item) - if os.path.isdir(full): - dirs.append((item, full)) - elif os.path.isfile(full): - files.append((item, full)) - - for name, full in dirs: - entry = FileBrowserEntry(name, full, is_dir=True) - urwid.connect_signal(entry, "click", self._entry_clicked, entry) - entries.append(entry) - - for name, full in files: - is_selected = full in self.delegate.pending_attachments - entry = FileBrowserEntry(name, full, selected=is_selected) - urwid.connect_signal(entry, "click", self._entry_clicked, entry) - entries.append(entry) - - if not dirs and not files: - entries.append(urwid.Text(("inactive_text", " (empty)"))) - - self.file_walker[:] = entries - if focus_pos is not None and focus_pos < len(entries): - self.file_listbox.set_focus(focus_pos) - elif entries: - self.file_listbox.set_focus(0) - - def _entry_clicked(self, entry_widget, user_data=None): - entry = user_data if user_data else entry_widget - if entry.is_dir or entry.is_parent: - self.current_path = entry.full_path - self._populate() - else: - if entry.full_path in self.delegate.pending_attachments: - self.delegate.pending_attachments.remove(entry.full_path) - else: - self.delegate.pending_attachments.append(entry.full_path) - self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None) - self._populate() - - def _dismiss(self, sender): - self.delegate.file_browser_closed() - - def _cancel(self, sender): - self.delegate.pending_attachments.clear() - self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None) - self.delegate.file_browser_closed() - - def keypress(self, size, key): - if key == "esc": - self.delegate.file_browser_closed() - return - result = super().keypress(size, key) - if result == "down" and self.browser_frame.focus_position == "body": - self.browser_frame.focus_position = "footer" - return - elif result == "up" and self.browser_frame.focus_position == "footer": - self.browser_frame.focus_position = "body" - return - return result - - class SyncProgressBar(urwid.ProgressBar): def get_text(self): status = nomadnet.NomadNetworkApp.get_shared_instance().get_sync_status() diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py index fd66046..a035d0d 100644 --- a/nomadnet/ui/textui/Editor.py +++ b/nomadnet/ui/textui/Editor.py @@ -400,7 +400,11 @@ class EditorColumns(urwid.Columns): if key == "esc": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" return None - return super().keypress(size, key) + result = super().keypress(size, key) + if key == "up" and result == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + return result class PageEditorShortcuts(): diff --git a/nomadnet/ui/textui/FileBrowser.py b/nomadnet/ui/textui/FileBrowser.py new file mode 100644 index 0000000..5a9863a --- /dev/null +++ b/nomadnet/ui/textui/FileBrowser.py @@ -0,0 +1,195 @@ +import os +import nomadnet +import urwid + +class FileBrowserEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False): + self.full_path = full_path + self.name = name + self.is_dir = is_dir + self.is_parent = is_parent + self.selected = selected + g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs + if is_parent: + display = g["arrow_l"]+" .." + elif is_dir: + display = g["arrow_r"]+" "+name+"/" + elif selected: + display = g["check"]+" "+name + else: + display = " "+name + self.text_widget = urwid.SelectableIcon(display, 0) + if is_dir or is_parent: + style = "list_trusted" + focus_style = "list_focus" + elif selected: + style = "list_trusted" + focus_style = "list_focus_trusted" + else: + style = "list_unknown" + focus_style = "list_focus" + display_widget = urwid.AttrMap(self.text_widget, style, focus_style) + super().__init__(display_widget) + + def keypress(self, size, key): + if key == "enter": + self._emit("click") + else: + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + +class FileBrowser(urwid.WidgetWrap): + def __init__(self, on_done=None, on_cancel=None, on_change=None, title="Select File", multi_select=True, start_path=None, selected=None): + self.on_done_cb = on_done + self.on_cancel_cb = on_cancel + self.on_change_cb = on_change + self.multi_select = multi_select + self.selected = list(selected) if selected else [] + + app = nomadnet.NomadNetworkApp.get_shared_instance() + self.g = app.ui.glyphs + self.current_path = os.path.expanduser(start_path) if start_path else os.path.expanduser("~") + + self.path_label = urwid.Text("") + self.status_label = urwid.Text("") + self.file_walker = urwid.SimpleFocusListWalker([]) + self.file_listbox = urwid.ListBox(self.file_walker) + + self.button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)), + ]) + + header_pile = urwid.Pile([ + self.path_label, + self.status_label, + urwid.Divider(self.g["divider1"]), + ]) + footer_pile = urwid.Pile([ + urwid.Divider(self.g["divider1"]), + self.button_columns, + ]) + + self._populate() + + self.browser_frame = urwid.Frame(self.file_listbox, header=header_pile, footer=footer_pile) + super().__init__(urwid.LineBox(self.browser_frame, title=title)) + + def _update_status(self): + if self.selected: + names = [os.path.basename(p) for p in self.selected] + self.status_label.set_text(" "+self.g["file"]+" "+str(len(self.selected))+" selected: "+", ".join(names)) + elif self.multi_select: + self.status_label.set_text(" No files selected") + else: + self.status_label.set_text(" Select a file") + + def _populate(self): + self.path_label.set_text(" "+self.current_path) + self._update_status() + + focus_pos = None + try: + focus_pos = self.file_listbox.focus_position + except Exception: + pass + + entries = [] + parent = os.path.dirname(self.current_path) + if parent != self.current_path: + entry = FileBrowserEntry("..", parent, is_parent=True) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + try: + items = sorted(os.listdir(self.current_path)) + except PermissionError: + entries.append(urwid.Text(("error_text", " Permission denied"))) + self.file_walker[:] = entries + return + except Exception: + entries.append(urwid.Text(("error_text", " Cannot read directory"))) + self.file_walker[:] = entries + return + + dirs = [] + files = [] + for item in items: + if item.startswith("."): + continue + full = os.path.join(self.current_path, item) + if os.path.isdir(full): + dirs.append((item, full)) + elif os.path.isfile(full): + files.append((item, full)) + + for name, full in dirs: + entry = FileBrowserEntry(name, full, is_dir=True) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + for name, full in files: + entry = FileBrowserEntry(name, full, selected=(full in self.selected)) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + if not dirs and not files: + entries.append(urwid.Text(("inactive_text", " (empty)"))) + + self.file_walker[:] = entries + if focus_pos is not None and focus_pos < len(entries): + self.file_listbox.set_focus(focus_pos) + elif entries: + self.file_listbox.set_focus(0) + + def _entry_clicked(self, entry_widget, user_data=None): + entry = user_data if user_data else entry_widget + if entry.is_dir or entry.is_parent: + self.current_path = entry.full_path + self._populate() + return + + if self.multi_select: + if entry.full_path in self.selected: + self.selected.remove(entry.full_path) + else: + self.selected.append(entry.full_path) + if self.on_change_cb is not None: + self.on_change_cb(list(self.selected)) + self._populate() + else: + self.selected = [entry.full_path] + if self.on_change_cb is not None: + self.on_change_cb(list(self.selected)) + if self.on_done_cb is not None: + self.on_done_cb(list(self.selected)) + + def _dismiss(self, sender): + if self.on_done_cb is not None: + self.on_done_cb(list(self.selected)) + + def _cancel(self, sender): + self.selected = [] + if self.on_cancel_cb is not None: + self.on_cancel_cb() + + def keypress(self, size, key): + if key == "esc": + self._cancel(None) + return None + result = super().keypress(size, key) + if result == "down" and self.browser_frame.focus_position == "body": + self.browser_frame.focus_position = "footer" + return None + elif result == "up" and self.browser_frame.focus_position == "footer": + self.browser_frame.focus_position = "body" + return None + return result diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index 6d49738..0a74333 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -1488,6 +1488,9 @@ class InterfaceFiller(urwid.WidgetWrap): if key == "ctrl o": self.app.ui.main_display.sub_displays.interface_display.toggle_focused_interface() return None + if key == "ctrl b": + self.app.ui.main_display.sub_displays.interface_display.show_bulk_actions() + return None if key == "ctrl a": # add interface self.app.ui.main_display.sub_displays.interface_display.add_interface() @@ -3401,11 +3404,12 @@ class InterfaceDisplay: def _make_filter_bar(self): cols = [('weight', 1, urwid.Text(""))] - for key, label in (("all", "All"), ("enabled", "Enabled"), ("disabled", "Disabled")): + cols.append(('pack', urwid.Text(("interface_title", "Filter by: ")))) + for key, label in (("all", "All"), ("enabled", "Enabled"), ("disabled", "Disabled"), ("connected", "Connected"), ("disconnected", "Disconnected")): sel = self.iface_filter == key mark = self.g['selected'] if sel else self.g['unselected'] style = "interface_tile_focus" if sel else "interface_title" - cols.append(('pack', ClickableIcon((style, "%s %s " % (mark, label)), on_click=lambda k=key: self._set_filter(k)))) + cols.append(('pack', ClickableIcon((style, "%s %s " % (mark, label)), on_click=lambda k=key: self._set_filter(k)))) cols.append(('weight', 1, urwid.Text(""))) return urwid.Columns(cols, dividechars=0) @@ -3418,7 +3422,7 @@ class InterfaceDisplay: self.app.ui.main_display.update_active_sub_display() def cycle_filter(self): - order = ["all", "enabled", "disabled"] + order = ["all", "enabled", "disabled", "connected", "disconnected"] i = order.index(self.iface_filter) if self.iface_filter in order else 0 self._set_filter(order[(i + 1) % len(order)]) @@ -3870,6 +3874,11 @@ class InterfaceDisplay: is_connected = False announce_rate = "-" + if self.iface_filter == "connected" and not is_connected: + continue + if self.iface_filter == "disconnected" and is_connected: + continue + profiles_label = profiles.label_for(interface_name) if profiles is not None else None item = SelectableInterfaceItem( @@ -3959,6 +3968,118 @@ class InterfaceDisplay: self._focus_interface(name) self.app.ui.main_display.update_active_sub_display() + def _iface_is_enabled(self, iface): + return str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \ + str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + def _set_iface_enabled(self, iface, enabled): + value = "true" if enabled else "false" + iface["interface_enabled"] = value + if "enabled" in iface: + iface["enabled"] = value + + def show_bulk_actions(self): + interfaces = self.app.rns.config['interfaces'] + if not interfaces: + self._bulk_notice("No interfaces configured.") + return + + dialog_widgets = [ + urwid.Text(("interface_title", "Apply an action to all interfaces"), align="left"), + urwid.Divider(), + InterfaceOptionItem(self, "Enable all interfaces", None, on_select=lambda: self._bulk_apply("enable")), + InterfaceOptionItem(self, "Disable all interfaces", None, on_select=lambda: self._bulk_apply("disable")), + InterfaceOptionItem(self, "Invert (toggle each interface)", None, on_select=lambda: self._bulk_apply("invert")), + InterfaceOptionItem(self, "Solo selected (disable all others)", None, on_select=lambda: self._bulk_apply("solo")), + ] + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)) + dialog = DialogLineBox(listbox, parent=self, title="Bulk Actions") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 50), + valign='middle', + height=('relative', 50), + min_width=20, + min_height=10, + left=2, + right=2 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def _bulk_apply(self, action): + interfaces = self.app.rns.config['interfaces'] + focused = self._focused_interface_name() + + if action == "solo" and focused is None: + self._bulk_notice("Select an interface first to solo it.") + return + + changed = 0 + for name, iface in interfaces.items(): + current = self._iface_is_enabled(iface) + if action == "enable": + target = True + elif action == "disable": + target = False + elif action == "invert": + target = not current + elif action == "solo": + target = (name == focused) + else: + target = current + + if target != current: + self._set_iface_enabled(iface, target) + changed += 1 + + try: + self.app.rns.config.write() + except Exception as e: + self._bulk_notice(f"Error applying bulk action: {str(e)}", title="Error") + return + + self.mark_restart_pending() + self._build_interface_items() + self._rebuild_list() + + verb = {"enable": "Enabled all", "disable": "Disabled all", "invert": "Inverted all", "solo": "Soloed selected"}[action] + if changed == 0: + self._bulk_notice("No interfaces needed changing.") + else: + self._bulk_notice(f"{verb} interfaces ({changed} changed).\nRestart required for changes to take effect.") + + def _bulk_notice(self, message, title="Notice"): + def dismiss_dialog(button): + self.dismiss_dialog() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + parent=self, + title=title + ) + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=60, + valign='middle', + height=8, + min_width=20, + min_height=1 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + def save_current_profile(self): profiles = getattr(self.app, "interface_profiles", None) if profiles is None: @@ -4024,7 +4145,7 @@ class InterfaceDisplay: class InterfaceDisplayShortcuts: def __init__(self, app): self.app = app - self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [C-o] On/Off [Enter] Show [C-f] Filter [C-s] Save Profile [C-t] Grid/Rows [Tab] Profiles" + self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [C-o] On/Off [C-b] Bulk [Enter] Show [C-f] Filter [C-s] Save Profile [C-t] Grid/Rows [Tab] Profiles" self.current_shortcuts = self.default_shortcuts self.widget = urwid.AttrMap( urwid.Text(self.current_shortcuts), diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 12fad81..9eb8ed1 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -6,6 +6,8 @@ from .Channels import * from .Directory import * from .Config import * from .Interfaces import * +from .Transport import * +from .Utilities import * from .Map import * from .Log import * from .Guide import * @@ -21,6 +23,7 @@ class SubDisplays(): self.directory_display = DirectoryDisplay(self.app) self.config_display = ConfigDisplay(self.app) self.interface_display = InterfaceDisplay(self.app) + self.utilities_display = UtilitiesDisplay(self.app) if self._utilities_enabled(app) else None self.map_display = MapDisplay(self.app) self.log_display = LogDisplay(self.app) self.guide_display = GuideDisplay(self.app) @@ -31,6 +34,12 @@ class SubDisplays(): else: self.active_display = self.conversations_display + def _utilities_enabled(self, app): + try: + return app.config["utilities"].as_bool("enable_utilities") + except Exception: + return False + def active(self): return self.active_display @@ -141,6 +150,13 @@ class MainDisplay(): self.update_active_sub_display() self.sub_displays.interface_display.start() + def show_utilities(self, user_data): + if self.sub_displays.utilities_display is None: + return + self.sub_displays.active_display = self.sub_displays.utilities_display + self.update_active_sub_display() + self.sub_displays.utilities_display.start() + def show_log(self, user_data): self.sub_displays.active_display = self.sub_displays.log_display self.sub_displays.log_display.show() @@ -184,6 +200,11 @@ class MainDisplay(): if getattr(self, "_quit_dialog_open", False): return + active = self.sub_displays.active_display + if active in (self.sub_displays.log_display, self.sub_displays.config_display): + self.do_quit() + return + def confirm(button=None): self.do_quit() @@ -218,7 +239,7 @@ class MainDisplay(): ) self._quit_dialog_open = True - + self.app.ui.loop.widget = urwid.Overlay( dialog, self.frame, align="center", width=44, @@ -264,6 +285,7 @@ class MenuDisplay(): button_log = (7, MenuButton("Log", on_press=handler.show_log)) button_config = (10, MenuButton("Config", on_press=handler.show_config)) button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) + button_utilities = (13, MenuButton("Utilities", on_press=handler.show_utilities)) button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) button_mynode = (11, MenuButton("My Node", on_press=handler.show_page_editor)) button_quit = (8, MenuButton("Quit", on_press=handler.quit)) @@ -272,7 +294,15 @@ class MenuDisplay(): buttons = [menu_text, button_conversations, button_network] if self.app.enable_node: buttons.append(button_mynode) - buttons += [button_channels, button_log, button_interfaces, button_config] + buttons += [button_channels, button_log, button_interfaces] + enable_utilities = False + try: + enable_utilities = self.app.config["utilities"].as_bool("enable_utilities") + except Exception: + enable_utilities = False + if enable_utilities: + buttons.append(button_utilities) + buttons.append(button_config) if not self.app.config["textui"]["hide_guide"]: buttons.append(button_guide) buttons.append(button_quit) diff --git a/nomadnet/ui/textui/Transport.py b/nomadnet/ui/textui/Transport.py new file mode 100644 index 0000000..dbc7c69 --- /dev/null +++ b/nomadnet/ui/textui/Transport.py @@ -0,0 +1,503 @@ +import RNS +import time +import nomadnet +import urwid + +def _get_cols_rows(): + return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() + +def _fmt_secs(secs): + secs = int(secs) + if secs < 0: + secs = 0 + if secs < 60: + return "%ds" % secs + if secs < 3600: + return "%dm" % (secs // 60) + if secs < 86400: + return "%dh" % (secs // 3600) + return "%dd" % (secs // 86400) + +def _rel_future(ts): + if not ts: + return "-" + remaining = ts - time.time() + if remaining <= 0: + return "expired" + return _fmt_secs(remaining) + +def _rel_past(ts): + if not ts: + return "-" + return _fmt_secs(time.time() - ts) + " ago" + +def _fmt_duration(secs): + if not secs: + return "n/a" + secs = int(secs) + if secs < 0: + secs = 0 + days = secs // 86400 + hours = (secs % 86400) // 3600 + minutes = (secs % 3600) // 60 + if days: + return "%dd %dh" % (days, hours) + if hours: + return "%dh %dm" % (hours, minutes) + if minutes: + return "%dm" % minutes + return "%ds" % secs + +def _fmt_rate(hz): + try: + hz = float(hz or 0) + except Exception: + return "0" + if hz <= 0: + return "0" + per_hour = hz * 3600 + if per_hour >= 1: + return "%.0f/h" % per_hour + return "%.1f/d" % (hz * 86400) + +def _mode_str(mode): + return {1: "Full", 2: "Point-to-Point", 3: "Access Point", 4: "Roaming", 5: "Boundary", 6: "Gateway"}.get(mode, "Full") + +class TransportRow(urwid.WidgetWrap): + def __init__(self, columns): + self._selectable = True + super().__init__(urwid.AttrMap(columns, "list_normal", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + return key + +class LazyTableWalker(urwid.ListWalker): + def __init__(self): + self.items = [] + self.factory = None + self.empty_widget = None + self.focus = 0 + + def configure(self, items, factory, empty_widget=None): + self.items = items or [] + self.factory = factory + self.empty_widget = empty_widget + if self.focus >= len(self.items): + self.focus = max(0, len(self.items) - 1) + self._modified() + + def _build(self, position): + return self.factory(self.items[position], position) + + def get_focus(self): + if not self.items: + if self.empty_widget is not None: + return (self.empty_widget, 0) + return (None, None) + if self.focus >= len(self.items): + self.focus = len(self.items) - 1 + if self.focus < 0: + self.focus = 0 + return (self._build(self.focus), self.focus) + + def set_focus(self, position): + self.focus = position + self._modified() + + def get_next(self, position): + n = position + 1 + if not self.items or n >= len(self.items): + return (None, None) + return (self._build(n), n) + + def get_prev(self, position): + p = position - 1 + if not self.items or p < 0: + return (None, None) + return (self._build(p), p) + + def positions(self, reverse=False): + count = len(self.items) + if count == 0: + return [0] if self.empty_widget is not None else [] + if reverse: + return range(count - 1, -1, -1) + return range(count) + + def __len__(self): + return len(self.items) + + def __getitem__(self, position): + if not self.items: + if position == 0 and self.empty_widget is not None: + return self.empty_widget + raise IndexError(position) + if position < 0 or position >= len(self.items): + raise IndexError(position) + return self._build(position) + +class TransportFiller(urwid.WidgetWrap): + def __init__(self, widget, display): + self.display = display + super().__init__(urwid.Filler(widget, urwid.TOP)) + + def keypress(self, size, key): + if key == "tab": + self.display.cycle_tab() + return None + if key == "ctrl r" or key == "f5": + self.display.refresh() + return None + if key == "esc": + self.display.go_back() + return None + result = super().keypress(size, key) + if key == "up" and result == "up": + self.display.go_back_to_header() + return None + return result + +class TransportDisplayShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Tab] Switch Table [Up] Filter [C-r] Refresh [Esc] Back"), + "shortcutbar" + ) + +class TransportDisplay: + POLL_INTERVAL = 5 + + HASH_W = 34 + HOPS_W = 6 + VIA_W = 20 + EXP_W = 12 + LAST_W = 16 + VIOL_W = 11 + STATUS_W = 10 + PATHS_W = 8 + HELD_W = 8 + QUEUE_W = 8 + RATE_W = 10 + MODE_W = 12 + + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.started = False + self.poll_scheduler = False + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.active_tab = "paths" + self.paths = [] + self.rates = [] + self.interfaces = [] + self.paths_per_interface = {} + self.link_count = 0 + self.transport_id = None + self.network_id = None + self.transport_uptime = None + self.held_announces = 0 + self.filter_text = "" + + self.summary_text = urwid.Text("") + self.list_walker = LazyTableWalker() + self.list_box = urwid.ListBox(self.list_walker) + + self.filter_edit = urwid.Edit("", "") + urwid.connect_signal(self.filter_edit, "change", self._on_filter_change) + self.filter_row = urwid.Columns([ + (9, urwid.Text(("key", "Filter:"))), + urwid.AttrMap(self.filter_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1) + + _cols, rows = _get_cols_rows() + self.box_adapter = urwid.BoxAdapter(self.list_box, max(3, rows - 10)) + + self.pile = urwid.Pile([ + ('pack', self.summary_text), + ('pack', self._tab_bar()), + ('pack', self.filter_row), + ('pack', self._header_row()), + ('pack', urwid.Divider("─")), + self.box_adapter, + ]) + + self.transport_view = TransportFiller(self.pile, self) + self.shortcuts_display = TransportDisplayShortcuts(self.app) + self.widget = self.transport_view + + self._refresh_data() + self._rebuild() + + def shortcuts(self): + return self.shortcuts_display + + def _tab_bar(self): + def tab(label, key): + attr = "list_focus" if self.active_tab == key else "list_normal" + return (urwid.PACK, urwid.Text((attr, " %s " % label))) + + return urwid.Columns([ + tab("Paths", "paths"), + tab("Announce Rates", "rates"), + tab("Interfaces", "interfaces"), + (urwid.WEIGHT, 1, urwid.Text("")), + ], dividechars=1) + + def _header_row(self): + if self.active_tab == "paths": + cols = [ + (self.HASH_W, urwid.Text(("key", "Destination"), wrap="clip")), + (self.HOPS_W, urwid.Text(("key", "Hops"), wrap="clip")), + (urwid.WEIGHT, 1, urwid.Text(("key", "Interface"), wrap="clip")), + (self.VIA_W, urwid.Text(("key", "Via"), wrap="clip")), + (self.EXP_W, urwid.Text(("key", "Expires"), wrap="clip")), + ] + elif self.active_tab == "interfaces": + cols = [ + (urwid.WEIGHT, 1, urwid.Text(("key", "Interface"), wrap="clip")), + (self.STATUS_W, urwid.Text(("key", "Status"), wrap="clip")), + (self.PATHS_W, urwid.Text(("key", "Paths"), wrap="clip")), + (self.HELD_W, urwid.Text(("key", "Held"), wrap="clip")), + (self.QUEUE_W, urwid.Text(("key", "Queue"), wrap="clip")), + (self.RATE_W, urwid.Text(("key", "Ann in"), wrap="clip")), + (self.MODE_W, urwid.Text(("key", "Mode"), wrap="clip")), + ] + else: + cols = [ + (self.HASH_W, urwid.Text(("key", "Destination"), wrap="clip")), + (self.LAST_W, urwid.Text(("key", "Last announce"), wrap="clip")), + (self.VIOL_W, urwid.Text(("key", "Violations"), wrap="clip")), + (urwid.WEIGHT, 1, urwid.Text(("key", "Blocked"), wrap="clip")), + ] + return urwid.Columns(cols, dividechars=1) + + def _refresh_data(self): + try: + self.paths = self.app.rns.get_path_table() or [] + except Exception: + self.paths = [] + try: + self.rates = self.app.rns.get_rate_table() or [] + except Exception: + self.rates = [] + try: + self.link_count = self.app.rns.get_link_count() + except Exception: + self.link_count = 0 + + try: + stats = self.app.rns.get_interface_stats() or {} + except Exception: + stats = {} + self.transport_id = stats.get("transport_id") + self.network_id = stats.get("network_id") + self.transport_uptime = stats.get("transport_uptime") + self.interfaces = stats.get("interfaces", []) or [] + try: + self.held_announces = sum(int(i.get("held_announces", 0) or 0) for i in self.interfaces) + except Exception: + self.held_announces = 0 + + self.paths_per_interface = {} + for entry in self.paths: + iface = entry.get("interface", "") + self.paths_per_interface[iface] = self.paths_per_interface.get(iface, 0) + 1 + + try: + self.interfaces.sort(key=lambda i: self.paths_per_interface.get(i.get("name", ""), 0), reverse=True) + except Exception: + pass + + try: + self.paths.sort(key=lambda e: (e.get("interface", ""), e.get("hops", 0))) + except Exception: + pass + try: + self.rates.sort(key=lambda e: e.get("last", 0), reverse=True) + except Exception: + pass + + def _make_path_row(self, entry, position): + dest = RNS.prettyhexrep(entry["hash"]) if entry.get("hash") else "?" + via = RNS.prettyhexrep(entry["via"]) if entry.get("via") else "?" + hops = entry.get("hops", 0) + iface = entry.get("interface", "") + expires = _rel_future(entry.get("expires", 0)) + + columns = urwid.Columns([ + (self.HASH_W, urwid.Text(("list_normal", dest), wrap="clip")), + (self.HOPS_W, urwid.Text(str(hops), wrap="clip")), + (urwid.WEIGHT, 1, urwid.Text(iface, wrap="clip")), + (self.VIA_W, urwid.Text(via, wrap="clip")), + (self.EXP_W, urwid.Text(expires, wrap="clip")), + ], dividechars=1) + return TransportRow(columns) + + def _make_rate_row(self, entry, position): + now = time.time() + dest = RNS.prettyhexrep(entry["hash"]) if entry.get("hash") else "?" + last = _rel_past(entry.get("last", 0)) + violations = str(entry.get("rate_violations", 0)) + blocked_until = entry.get("blocked_until", 0) or 0 + if blocked_until > now: + blocked = ("disconnected_status", "blocked %s" % _rel_future(blocked_until)) + else: + blocked = ("connected_status", "no") + + columns = urwid.Columns([ + (self.HASH_W, urwid.Text(("list_normal", dest), wrap="clip")), + (self.LAST_W, urwid.Text(last, wrap="clip")), + (self.VIOL_W, urwid.Text(violations, wrap="clip")), + (urwid.WEIGHT, 1, urwid.Text(blocked, wrap="clip")), + ], dividechars=1) + return TransportRow(columns) + + def _make_iface_row(self, entry, position): + name = entry.get("short_name") or entry.get("name") or "?" + paths = self.paths_per_interface.get(entry.get("name", ""), 0) + held = entry.get("held_announces", 0) or 0 + queue = entry.get("announce_queue") + queue_str = "-" if queue is None else str(queue) + rate = _fmt_rate(entry.get("incoming_announce_frequency", 0)) + mode = _mode_str(entry.get("mode")) + + if entry.get("status"): + status = ("connected_status", "up") + else: + status = ("disconnected_status", "down") + + columns = urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Text(("list_normal", name), wrap="clip")), + (self.STATUS_W, urwid.Text(status, wrap="clip")), + (self.PATHS_W, urwid.Text(str(paths), wrap="clip")), + (self.HELD_W, urwid.Text(str(held), wrap="clip")), + (self.QUEUE_W, urwid.Text(queue_str, wrap="clip")), + (self.RATE_W, urwid.Text(rate, wrap="clip")), + (self.MODE_W, urwid.Text(mode, wrap="clip")), + ], dividechars=1) + return TransportRow(columns) + + def _rebuild(self): + blocked_now = 0 + try: + now = time.time() + blocked_now = sum(1 for r in self.rates if (r.get("blocked_until", 0) or 0) > now) + except Exception: + pass + + transport_id = RNS.prettyhexrep(self.transport_id) if self.transport_id else "n/a" + network_id = RNS.prettyhexrep(self.network_id) if self.network_id else "n/a" + uptime = _fmt_duration(self.transport_uptime) + + summary = [ + ("key", "Transport ID: "), ("value", transport_id), + ("key", " Network ID: "), ("value", network_id), + ("key", " Uptime: "), ("value", uptime), "\n", + ("key", "Active links: "), ("value", str(self.link_count)), + ("key", " Known paths: "), ("value", str(len(self.paths))), + ("key", " Held announces: "), ("value", str(self.held_announces)), + ("key", " Announce sources: "), ("value", str(len(self.rates))), + ("key", " Rate-limited: "), ("value", str(blocked_now)), + ] + + if self.active_tab == "paths": + data = self._filtered(self.paths) + factory = self._make_path_row + empty_msg = "No paths in the routing table yet." + elif self.active_tab == "interfaces": + data = self._filtered(self.interfaces) + factory = self._make_iface_row + empty_msg = "No interface statistics available." + else: + data = self._filtered(self.rates) + factory = self._make_rate_row + empty_msg = "No announce rate data available." + + if self.filter_text.strip(): + summary.append(("inactive_text", " [filter: %d shown]" % len(data))) + self.summary_text.set_text(summary) + + self.pile.contents[1] = (self._tab_bar(), self.pile.options('pack')) + self.pile.contents[3] = (self._header_row(), self.pile.options('pack')) + + empty = urwid.Text(("inactive_text", empty_msg), align=urwid.CENTER) + self.list_walker.configure(data, factory, empty) + + try: + _cols, rows = _get_cols_rows() + self.box_adapter.height = max(3, rows - 10) + except Exception: + pass + + def cycle_tab(self): + order = ["paths", "rates", "interfaces"] + i = order.index(self.active_tab) if self.active_tab in order else 0 + self.active_tab = order[(i + 1) % len(order)] + self._rebuild() + + def _on_filter_change(self, widget, new_text): + self.filter_text = new_text + self._rebuild() + + def _haystack(self, entry): + parts = [] + for key in ("hash", "via"): + value = entry.get(key) + if value: + parts.append(RNS.prettyhexrep(value)) + for key in ("interface", "name", "short_name"): + value = entry.get(key) + if value: + parts.append(str(value)) + return " ".join(parts).lower() + + def _filtered(self, items): + needle = self.filter_text.strip().lower() + if not needle: + return items + return [entry for entry in items if needle in self._haystack(entry)] + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _is_visible(self): + md = self.app.ui.main_display + if self.parent is not None: + return md.sub_displays.active_display is self.parent and getattr(self.parent, "active", None) == "transport" + return md.sub_displays.active_display is self + + def refresh(self): + self._refresh_data() + self._rebuild() + + def start(self): + self.started = True + self._refresh_data() + self._rebuild() + if not self.poll_scheduler and self.app.ui.loop is not None: + self.poll_scheduler = True + self.app.ui.loop.set_alarm_in(self.POLL_INTERVAL, self._poll) + + def _poll(self, loop, user_data): + if self._is_visible(): + self._refresh_data() + self._rebuild() + try: + loop.draw_screen() + except Exception: + pass + if self.started: + loop.set_alarm_in(self.POLL_INTERVAL, self._poll) diff --git a/nomadnet/ui/textui/Utilities.py b/nomadnet/ui/textui/Utilities.py new file mode 100644 index 0000000..088f5a4 --- /dev/null +++ b/nomadnet/ui/textui/Utilities.py @@ -0,0 +1,1523 @@ +import RNS +import os +import time +import nomadnet +import urwid + +from .Transport import TransportDisplay, _get_cols_rows +from .ReadlineEdit import ReadlineEdit +from .FileBrowser import FileBrowser + +DEFAULT_PROBE_SIZE = 16 +DEFAULT_PROBE_TIMEOUT = 12 + +def _interface_enabled(iface): + try: + return str(iface.get("enabled")).lower() not in ("false", "off", "no", "0") and \ + str(iface.get("interface_enabled")).lower() not in ("false", "off", "no", "0") + except Exception: + return True + +def _has_active_interfaces(app): + try: + interfaces = app.rns.config["interfaces"] + except Exception: + return True + try: + return any(_interface_enabled(interfaces[name]) for name in interfaces) + except Exception: + return True + +class UtilityItem(urwid.WidgetWrap): + def __init__(self, parent, name, description, on_select): + self.parent = parent + self.name = name + self.on_select = on_select + self._selectable = True + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text(("interface_title", name)) + + title_row = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + description_row = urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("value", description)), + ]) + + box = urwid.LineBox( + urwid.Padding(urwid.Pile([title_row, description_row]), left=2, right=2), + title=None, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + super().__init__(box) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + attr = "interface_title_selected" if focus else "interface_title" + self.title_widget.set_text((attr, self.name)) + return super().render(size, focus=focus) + + def keypress(self, size, key): + if key == "up": + if self.parent.landing_listbox.focus_position == 0: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": + self.on_select() + return None + return key + +class UtilitiesDisplayShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Enter] Open [Up/Down] Navigate"), + "shortcutbar" + ) + +class UtilitiesDisplay: + def __init__(self, app): + self.app = app + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.active = "landing" + self.transport = None + self.probe = None + self.path = None + self.identity = None + self.send = None + self.speedtest = None + + self.shortcuts_display = UtilitiesDisplayShortcuts(self.app) + self._build_landing() + self.widget = self.landing_view + + def _build_landing(self): + items = [ + UtilityItem(self, "Path Tables", "View routing, announce rate and per-interface tables", self.open_transport), + UtilityItem(self, "Path Resolver", "Look up or drop the path to a destination", self.open_path), + UtilityItem(self, "Probe", "Send probes to a destination and measure round trip", self.open_probe), + UtilityItem(self, "Identity", "Show your addresses and look up a destination's identity", self.open_identity), + UtilityItem(self, "Send File", "Send a file to a destination over Reticulum to a rncp listener", self.open_send), + UtilityItem(self, "Speed Test", "Measure throughput to a peer also running this screen", self.open_speedtest), + ] + self.landing_listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + + _cols, rows = _get_cols_rows() + box = urwid.BoxAdapter(self.landing_listbox, max(4, rows - 6)) + + pile = urwid.Pile([ + ('pack', urwid.Text(("body_text", "Select a utility to open."), align=urwid.CENTER)), + ('pack', urwid.Divider("─")), + box, + ]) + self.landing_view = urwid.Filler(pile, urwid.TOP) + + def shortcuts(self): + if self.active == "transport" and self.transport is not None: + return self.transport.shortcuts_display + if self.active == "probe" and self.probe is not None: + return self.probe.shortcuts_display + if self.active == "path" and self.path is not None: + return self.path.shortcuts_display + if self.active == "identity" and self.identity is not None: + return self.identity.shortcuts_display + if self.active == "send" and self.send is not None: + return self.send.shortcuts_display + if self.active == "speedtest" and self.speedtest is not None: + return self.speedtest.shortcuts_display + return self.shortcuts_display + + def _leave_speedtest(self): + if self.speedtest is not None and self.active == "speedtest": + self.speedtest.stop_listening() + + def start(self): + self.show_landing() + + def show_landing(self): + self._leave_speedtest() + self.active = "landing" + self.widget = self.landing_view + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_transport(self): + if self.transport is None: + self.transport = TransportDisplay(self.app, parent=self) + self.active = "transport" + self.widget = self.transport.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.transport.start() + + def open_probe(self): + if self.probe is None: + self.probe = ProbeView(self.app, parent=self) + self.probe.update_interface_status() + self.active = "probe" + self.widget = self.probe.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_path(self): + if self.path is None: + self.path = PathView(self.app, parent=self) + self.path.update_interface_status() + self.active = "path" + self.widget = self.path.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_identity(self): + if self.identity is None: + self.identity = IdentityView(self.app, parent=self) + self.active = "identity" + self.widget = self.identity.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_send(self): + if self.send is None: + self.send = SendFileView(self.app, parent=self) + self.send.update_interface_status() + self.active = "send" + self.widget = self.send.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_speedtest(self): + if self.speedtest is None: + self.speedtest = SpeedTestView(self.app, parent=self) + self.active = "speedtest" + self.widget = self.speedtest.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.speedtest.update_interface_status() + self.speedtest.start_listening() + +class ProbeShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Probe / Activate [Esc] Back"), + "shortcutbar" + ) + +class UtilityViewFiller(urwid.WidgetWrap): + def __init__(self, body, view): + self.view = view + super().__init__(body) + + def keypress(self, size, key): + if key == "esc": + self.view.go_back() + return None + result = super().keypress(size, key) + if key == "up" and result == "up": + self.view.go_back_to_header() + return None + return result + +class ProbeView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.shortcuts_display = ProbeShortcuts(self.app) + + self._probing = False + self._concluded = False + self._remaining = 0 + self._sent = 0 + self._replies = 0 + self._cur_receipt = None + + self.name_edit = ReadlineEdit("", "rnstransport.probe") + self.hash_edit = ReadlineEdit("", "") + self.size_edit = ReadlineEdit("", str(DEFAULT_PROBE_SIZE)) + self.count_edit = ReadlineEdit("", "1") + + self.probe_button = urwid.AttrMap(urwid.Button("Probe", on_press=self.on_probe), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.probe_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + self.status_text = urwid.Text("") + + rows = [ + urwid.Text(("form_title", "Probe a destination"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + self._field_row("Destination name:", self.name_edit), + urwid.Padding(urwid.Text(("inactive_text", "Full dotted name for example rnstransport.probe")), left=22), + self._field_row("Destination hash:", self.hash_edit), + self._field_row("Payload size:", self.size_edit), + self._field_row("Probe count:", self.count_edit), + urwid.Divider(), + button_row, + urwid.Divider("─"), + urwid.Text(("key", "Output")), + ] + self.walker = urwid.SimpleFocusListWalker(rows) + self.listbox = urwid.ListBox(self.walker) + self.widget = UtilityViewFiller(self.listbox, self) + self.update_interface_status() + + def _field_row(self, label, edit): + return urwid.Columns([ + (20, urwid.Text(("key", label), align=urwid.RIGHT)), + urwid.AttrMap(edit, "list_normal", focus_map="list_focus"), + ], dividechars=1) + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.walker.append(urwid.Text((attr, message))) + try: + self.listbox.set_focus(len(self.walker) - 1) + except Exception: + pass + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def on_probe(self, button=None): + if self._probing: + return + + self.update_interface_status() + + full_name = self.name_edit.edit_text.strip() + hexhash = self.hash_edit.edit_text.strip() + + if not full_name: + self._log("Enter a destination name (e.g. rnstransport.probe)", "error_text") + return + + try: + app_name, aspects = RNS.Destination.app_and_aspects_from_name(full_name) + except Exception as e: + self._log("Invalid destination name: %s" % str(e), "error_text") + return + + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + destination_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + try: + size = int(self.size_edit.edit_text.strip() or str(DEFAULT_PROBE_SIZE)) + except ValueError: + size = DEFAULT_PROBE_SIZE + try: + count = int(self.count_edit.edit_text.strip() or "1") + except ValueError: + count = 1 + if count < 1: + count = 1 + + self._app_name = app_name + self._aspects = aspects + self._dest_hash = destination_hash + self._size = max(1, size) + self._remaining = count + self._sent = 0 + self._replies = 0 + self._probing = True + self.probe_button.original_widget.set_label("Probing…") + self._begin_probe() + + def _begin_probe(self): + dest_hash = self._dest_hash + if not RNS.Transport.has_path(dest_hash): + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._path_deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + else: + self._send_one() + + def _timeout(self): + try: + return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._send_one() + elif time.time() > self._path_deadline: + self._log("Path request timed out", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _send_one(self): + dest_hash = self._dest_hash + identity = RNS.Identity.recall(dest_hash) + if identity is None: + self._log("Could not recall identity for destination", "error_text") + self._finish() + return + + try: + request_destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self._app_name, *self._aspects) + probe = RNS.Packet(request_destination, os.urandom(self._size)) + probe.pack() + except OSError: + self._log("Probe payload of %d bytes exceeds the MTU" % self._size, "error_text") + self._finish() + return + except Exception as e: + self._log("Could not create probe: %s" % str(e), "error_text") + self._finish() + return + + self._concluded = False + receipt = probe.send() + self._cur_receipt = receipt + self._sent += 1 + self._log("Sent probe %d (%d bytes) to %s …" % (self._sent, self._size, RNS.prettyhexrep(dest_hash))) + + try: + receipt.set_delivery_callback(self._on_delivered) + except Exception: + pass + self.app.ui.loop.set_alarm_in(self._timeout(), self._on_timeout) + + def _on_delivered(self, receipt): + self._schedule(lambda: self._conclude(receipt, True)) + + def _on_timeout(self, loop, user_data): + self._conclude(self._cur_receipt, False) + + def _conclude(self, receipt, delivered): + if self._concluded: + return + self._concluded = True + + is_delivered = delivered + if not is_delivered and receipt is not None: + try: + is_delivered = receipt.status == RNS.PacketReceipt.DELIVERED + except Exception: + is_delivered = False + + if is_delivered and receipt is not None: + self._replies += 1 + try: + hops = RNS.Transport.hops_to(self._dest_hash) + except Exception: + hops = 0 + try: + rtt = receipt.get_rtt() + rtt_str = "%.0f ms" % (rtt * 1000) if rtt < 1 else "%.3f s" % rtt + except Exception: + rtt_str = "?" + hop_str = "%d hop%s" % (hops, "" if hops == 1 else "s") + self._log("Reply from %s %s over %s%s" % ( + RNS.prettyhexrep(receipt.destination.hash), rtt_str, hop_str, self._reception_stats(receipt)), + "connected_status") + else: + self._log("Probe %d timed out" % self._sent, "warning_text") + + self._next() + + def _reception_stats(self, receipt): + stats = "" + try: + proof_packet = getattr(receipt, "proof_packet", None) + if proof_packet is None: + return "" + if getattr(self.app.rns, "is_connected_to_shared_instance", False): + packet_hash = proof_packet.packet_hash + rssi = self.app.rns.get_packet_rssi(packet_hash) + snr = self.app.rns.get_packet_snr(packet_hash) + q = self.app.rns.get_packet_q(packet_hash) + else: + rssi = getattr(proof_packet, "rssi", None) + snr = getattr(proof_packet, "snr", None) + q = None + if rssi is not None: + stats += " [RSSI %s dBm]" % rssi + if snr is not None: + stats += " [SNR %s dB]" % snr + if q is not None: + stats += " [Q %s%%]" % q + except Exception: + pass + return stats + + def _next(self): + self._remaining -= 1 + if self._remaining > 0: + self._send_one() + else: + self._finish() + + def _finish(self): + if self._sent > 0: + loss = round((1 - (self._replies / self._sent)) * 100, 1) + self._log("Sent %d, received %d, packet loss %s%%" % (self._sent, self._replies, loss), "key") + self._probing = False + self.probe_button.original_widget.set_label("Probe") + self._draw() + +class PathShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Resolve / Activate [Esc] Back"), + "shortcutbar" + ) + +class PathView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.shortcuts_display = PathShortcuts(self.app) + + self._resolving = False + + self.hash_edit = ReadlineEdit("", "") + + self.resolve_button = urwid.AttrMap(urwid.Button("Resolve", on_press=self.on_resolve), "button_normal", focus_map="button_focus") + self.drop_button = urwid.AttrMap(urwid.Button("Drop path", on_press=self.on_drop), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, self.resolve_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.drop_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.back_button), + ]) + + self.status_text = urwid.Text("") + + rows = [ + urwid.Text(("form_title", "Resolve a path"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Requests the path from the network if it is not already known.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), + urwid.Text(("key", "Output")), + ] + self.walker = urwid.SimpleFocusListWalker(rows) + self.listbox = urwid.ListBox(self.walker) + self.widget = UtilityViewFiller(self.listbox, self) + self.update_interface_status() + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.walker.append(urwid.Text((attr, message))) + try: + self.listbox.set_focus(len(self.walker) - 1) + except Exception: + pass + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _validate_hash(self): + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return None + try: + return bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return None + + def _timeout(self): + try: + return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def on_resolve(self, button=None): + if self._resolving: + return + self.update_interface_status() + dest_hash = self._validate_hash() + if dest_hash is None: + return + self._dest_hash = dest_hash + if RNS.Transport.has_path(dest_hash): + self._show_path() + return + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._resolving = True + self.resolve_button.original_widget.set_label("Resolving…") + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._resolving = False + self.resolve_button.original_widget.set_label("Resolve") + self._show_path() + self._draw() + elif time.time() > self._deadline: + self._resolving = False + self.resolve_button.original_widget.set_label("Resolve") + self._log("Path not found", "warning_text") + self._draw() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _show_path(self): + dest_hash = self._dest_hash + try: + hops = RNS.Transport.hops_to(dest_hash) + next_hop_bytes = self.app.rns.get_next_hop(dest_hash) + except Exception: + next_hop_bytes = None + hops = 0 + if not next_hop_bytes: + self._log("Path data unavailable", "warning_text") + return + try: + interface = self.app.rns.get_next_hop_if_name(dest_hash) + except Exception: + interface = "?" + hop_str = "%d hop%s" % (hops, "" if hops == 1 else "s") + self._log("Path found: %s is %s away via %s on %s" % ( + RNS.prettyhexrep(dest_hash), hop_str, RNS.prettyhexrep(next_hop_bytes), interface), + "connected_status") + + def on_drop(self, button=None): + dest_hash = self._validate_hash() + if dest_hash is None: + return + try: + dropped = self.app.rns.drop_path(dest_hash) + except Exception as e: + self._log("Could not drop path: %s" % str(e), "error_text") + return + if dropped: + self._log("Dropped path to %s" % RNS.prettyhexrep(dest_hash)) + else: + self._log("No path to %s to drop" % RNS.prettyhexrep(dest_hash), "warning_text") + +class IdentityShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Look up / Activate [Esc] Back"), + "shortcutbar" + ) + +class IdentityView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = IdentityShortcuts(self.app) + self._resolving = False + + self.hash_edit = ReadlineEdit("", "") + self.lookup_button = urwid.AttrMap(urwid.Button("Look up", on_press=self.on_lookup), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.lookup_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Identity"), align=urwid.CENTER), + urwid.Divider("─"), + urwid.Text(("key", "Your addresses")), + ] + rows += self._address_rows() + rows += [ + urwid.Divider(), + urwid.Text(("key", "Look up a destination")), + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Recalls the identity, requesting it from the network if needed.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), + urwid.Text(("key", "Output")), + ] + self.walker = urwid.SimpleFocusListWalker(rows) + self.listbox = urwid.ListBox(self.walker) + self.widget = UtilityViewFiller(self.listbox, self) + + def _addr(self, obj): + try: + return RNS.prettyhexrep(obj.hash) if obj is not None else "" + except Exception: + return "" + + def _address_rows(self): + rows = [] + + def row(label, value): + return urwid.Columns([ + (18, urwid.Text(("key", label), align=urwid.RIGHT)), + urwid.Text(("value", value)), + ], dividechars=1) + + rows.append(row("Identity:", self._addr(getattr(self.app, "identity", None)))) + rows.append(row("LXMF address:", self._addr(getattr(self.app, "lxmf_destination", None)))) + node = getattr(self.app, "node", None) + if node is not None: + rows.append(row("Node:", self._addr(getattr(node, "destination", None)))) + return rows + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.walker.append(urwid.Text((attr, message))) + try: + self.listbox.set_focus(len(self.walker) - 1) + except Exception: + pass + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def on_lookup(self, button=None): + if self._resolving: + return + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + self._dest_hash = dest_hash + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + self._show_identity(identity) + return + + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._resolving = True + self.lookup_button.original_widget.set_label("Looking up…") + self._log("Identity unknown, requesting from the network …") + try: + self._deadline = time.time() + DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(dest_hash) + except Exception: + self._deadline = time.time() + DEFAULT_PROBE_TIMEOUT + self.app.ui.loop.set_alarm_in(0.25, self._await_identity) + + def _await_identity(self, loop, user_data): + identity = RNS.Identity.recall(self._dest_hash) + if identity is not None: + self._resolving = False + self.lookup_button.original_widget.set_label("Look up") + self._show_identity(identity) + self._draw() + elif time.time() > self._deadline: + self._resolving = False + self.lookup_button.original_widget.set_label("Look up") + self._log("Identity not found", "warning_text") + self._draw() + else: + loop.set_alarm_in(0.25, self._await_identity) + + def _show_identity(self, identity): + self._log("kno wn %s resolves to identity %s" % ( + RNS.prettyhexrep(self._dest_hash), RNS.prettyhexrep(identity.hash)), "connected_status") + +RNCP_APP_NAME = "rncp" + +class SendFileShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Choose / Send [Esc] Back"), + "shortcutbar" + ) + +class SendFileView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = SendFileShortcuts(self.app) + + self._sending = False + self._concluded = False + self._link = None + self._resource = None + self.file_path = None + + self.hash_edit = ReadlineEdit("", "") + self.file_label = urwid.Text(("inactive_text", "No file selected")) + self.status_text = urwid.Text("") + self.progress_text = urwid.Text("") + + self.choose_button = urwid.AttrMap(urwid.Button("Choose file…", on_press=self.choose_file), "button_normal", focus_map="button_focus") + self.send_button = urwid.AttrMap(urwid.Button("Send", on_press=self.on_send), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.send_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Send a file"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + urwid.Columns([ + (20, urwid.Text(("key", "File:"), align=urwid.RIGHT)), + urwid.AttrMap(self.choose_button, "button_normal", focus_map="button_focus"), + ], dividechars=1), + urwid.Padding(self.file_label, left=22), + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "The receiver must be running 'rncp --listen' and have your identity hash in the allow list.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), + self.progress_text, + urwid.Text(("key", "Output")), + ] + self.walker = urwid.SimpleFocusListWalker(rows) + self.listbox = urwid.ListBox(self.walker) + self.widget = UtilityViewFiller(self.listbox, self) + self.update_interface_status() + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.walker.append(urwid.Text((attr, message))) + try: + self.listbox.set_focus(len(self.walker) - 1) + except Exception: + pass + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def choose_file(self, button=None): + def on_done(selected): + if selected: + self.file_path = selected[0] + self.file_label.set_text(("value", os.path.basename(self.file_path))) + self._close_browser() + + def on_cancel(): + self._close_browser() + + browser = FileBrowser(on_done=on_done, on_cancel=on_cancel, title="Select File to Send", multi_select=False) + overlay = urwid.Overlay(browser, self.widget, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) + self.parent.widget = overlay + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def _close_browser(self): + self.parent.widget = self.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self._draw() + + def _timeout(self): + try: + return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def on_send(self, button=None): + if self._sending: + return + self.update_interface_status() + + if not self.file_path or not os.path.isfile(self.file_path): + self._log("Choose a file to send first", "error_text") + return + + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + self._dest_hash = dest_hash + self._sending = True + self._concluded = False + self._resource = None + self.send_button.original_widget.set_label("Sending…") + self.progress_text.set_text("") + + if RNS.Transport.has_path(dest_hash): + self._establish_link() + else: + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + elif time.time() > self._deadline: + self._log("Path not found", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _establish_link(self): + identity = RNS.Identity.recall(self._dest_hash) + if identity is None: + self._log("Could not recall identity for destination", "error_text") + self._finish() + return + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, RNCP_APP_NAME, "receive") + self._log("Establishing link with %s …" % RNS.prettyhexrep(self._dest_hash)) + self._link = RNS.Link(destination, established_callback=self._on_link_up, closed_callback=self._on_link_closed) + except Exception as e: + self._log("Could not establish link: %s" % str(e), "error_text") + self._finish() + return + self._link_deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.5, self._check_link) + + def _check_link(self, loop, user_data): + if not self._sending or self._resource is not None: + return + try: + active = self._link is not None and self._link.status == RNS.Link.ACTIVE + except Exception: + active = False + if active: + return + if time.time() > self._link_deadline: + self._log("Link establishment timed out", "warning_text") + try: + self._link.teardown() + except Exception: + pass + self._finish() + else: + loop.set_alarm_in(0.5, self._check_link) + + def _on_link_up(self, link): + self._schedule(lambda: self._start_resource(link)) + + def _start_resource(self, link): + if not self._sending or self._resource is not None: + return + try: + link.identify(self.app.identity) + metadata = {"name": os.path.basename(self.file_path).encode("utf-8")} + self._resource = RNS.Resource(open(self.file_path, "rb"), link, metadata=metadata, + callback=self._on_resource_done, progress_callback=self._on_progress, auto_compress=True) + self._log("Advertising %s …" % os.path.basename(self.file_path)) + except Exception as e: + self._log("Could not start transfer: %s" % str(e), "error_text") + self._finish() + + def _on_progress(self, resource): + self._schedule(lambda: self._show_progress(resource)) + + def _show_progress(self, resource): + try: + pct = int(resource.get_progress() * 100) + except Exception: + pct = 0 + self.progress_text.set_text(("body_text", "Transferring… %d%%" % pct)) + + def _on_resource_done(self, resource): + self._schedule(lambda: self._resource_done(resource)) + + def _resource_done(self, resource): + if self._concluded: + return + self._concluded = True + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if complete: + self.progress_text.set_text(("connected_status", "Transferring… 100%")) + self._log("Transfer complete", "connected_status") + else: + self._log("Transfer failed or the file was not accepted ,", "warning_text") + try: + if self._link is not None: + self._link.teardown() + except Exception: + pass + self._finish() + + def _on_link_closed(self, link): + self._schedule(self._link_closed) + + def _link_closed(self): + if self._sending and self._resource is None and not self._concluded: + self._log("Link closed before the transfer started", "warning_text") + self._finish() + + def _finish(self): + self._sending = False + self.send_button.original_widget.set_label("Send") + self._draw() + +SPEEDTEST_APP_NAME = "nomadnetwork" +SPEEDTEST_ASPECTS = ("utilities", "speedtest") +DEFAULT_SPEEDTEST_MB = 1 +MAX_SPEEDTEST_MB = 50 + +def _fmt_bitrate(bytes_per_sec): + bits = float(bytes_per_sec) * 8 + for unit in ("bps", "Kbps", "Mbps", "Gbps"): + if bits < 1000: + return "%.1f %s" % (bits, unit) + bits /= 1000 + return "%.1f Tbps" % bits + +class SpeedTestShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Start / Activate [Esc] Back"), + "shortcutbar" + ) + +class SpeedTestView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = SpeedTestShortcuts(self.app) + + self.server_destination = None + self._listening = False + self._incoming_link = None + self._recv_link = None + self._recv_started = None + self._recv_bytes = 0 + + self._sending = False + self._client_link = None + self._client_resource = None + self._send_started = None + self._send_bytes = 0 + + self.hash_edit = ReadlineEdit("", "") + self.size_edit = ReadlineEdit("", str(DEFAULT_SPEEDTEST_MB)) + self.address_text = urwid.Text(("value", "—")) + self.status_text = urwid.Text("") + self.progress_text = urwid.Text("") + + self.start_button = urwid.AttrMap(urwid.Button("Start test", on_press=self.on_start), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.start_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Speed test"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + urwid.Columns([ + (22, urwid.Text(("key", "Your speedtest hash:"), align=urwid.RIGHT)), + self.address_text, + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Both sides open this screen and enter each other's hash; then one presses Start.")), left=24), + urwid.Columns([ + (22, urwid.Text(("key", "Peer hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Columns([ + (22, urwid.Text(("key", "Test size (MB):"), align=urwid.RIGHT)), + urwid.AttrMap(self.size_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Divider(), + button_row, + urwid.Divider("─"), + self.progress_text, + urwid.Text(("key", "Output")), + ] + self.walker = urwid.SimpleFocusListWalker(rows) + self.listbox = urwid.ListBox(self.walker) + self.widget = UtilityViewFiller(self.listbox, self) + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def _update_address(self): + if self.server_destination is not None: + self.address_text.set_text(("value", RNS.prettyhexrep(self.server_destination.hash))) + else: + self.address_text.set_text(("value", "—")) + + def start_listening(self): + if self.server_destination is None: + try: + self.server_destination = RNS.Destination(self.app.identity, RNS.Destination.IN, RNS.Destination.SINGLE, SPEEDTEST_APP_NAME, *SPEEDTEST_ASPECTS) + self.server_destination.set_link_established_callback(self._on_incoming_link) + try: + self.server_destination.accepts_links(True) + except Exception: + pass + except Exception as e: + self._log("Could not start listener: %s" % str(e), "error_text") + self.server_destination = None + self._listening = True + self._update_address() + + def stop_listening(self): + self._listening = False + try: + if self._incoming_link is not None: + self._incoming_link.teardown() + except Exception: + pass + self._incoming_link = None + self._recv_link = None + try: + if self.server_destination is not None: + RNS.Transport.deregister_destination(self.server_destination) + except Exception: + pass + self.server_destination = None + self._update_address() + + def _is_active(self): + try: + md = self.app.ui.main_display + return self._listening and md.sub_displays.active_display is self.parent and getattr(self.parent, "active", None) == "speedtest" + except Exception: + return False + + def _peer_identity(self): + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + return None + try: + return RNS.Identity.recall(bytes.fromhex(hexhash)) + except Exception: + return None + + def go_back(self): + self.stop_listening() + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.walker.append(urwid.Text((attr, message))) + try: + self.listbox.set_focus(len(self.walker) - 1) + except Exception: + pass + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def _on_incoming_link(self, link): + if not self._is_active(): + try: + link.teardown() + except Exception: + pass + return + self._incoming_link = link + link.set_remote_identified_callback(self._on_remote_identified) + self.app.ui.loop.set_alarm_in(8, lambda l, u: self._incoming_timeout(link)) + + def _incoming_timeout(self, link): + if self._recv_link is link: + return + try: + link.teardown() + except Exception: + pass + + def _on_remote_identified(self, link, identity): + self._schedule(lambda: self._verify_incoming(link, identity)) + + def _verify_incoming(self, link, identity): + peer_identity = self._peer_identity() + if peer_identity is None or peer_identity.hash != identity.hash: + self._log("Rejected speed test from unauthorized peer", "warning_text") + try: + link.teardown() + except Exception: + pass + return + self._recv_link = link + self._recv_started = None + self._recv_bytes = 0 + link.set_resource_strategy(RNS.Link.ACCEPT_ALL) + link.set_resource_started_callback(self._on_recv_started) + link.set_resource_concluded_callback(self._on_recv_concluded) + self._log("Incoming speed test from %s" % RNS.prettyhexrep(identity.hash), "connected_status") + + def _on_recv_started(self, resource): + self._schedule(self._recv_started_now) + + def _recv_started_now(self): + self._recv_started = time.time() + self.progress_text.set_text(("body_text", "Receiving…")) + + def _on_recv_concluded(self, resource): + self._schedule(lambda: self._recv_concluded(resource)) + + def _recv_concluded(self, resource): + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if not complete: + self._log("Incoming transfer failed", "warning_text") + return + size = self._resource_size(resource) + elapsed = max(0.001, time.time() - (self._recv_started or time.time())) + self._log("Download: %s (%s in %.2fs)" % (_fmt_bitrate(size / elapsed), _size_str(size), elapsed), "connected_status") + self.progress_text.set_text("") + + def _resource_size(self, resource): + for getter in ("get_transfer_size", "getTransferSize"): + try: + fn = getattr(resource, getter, None) + if fn: + v = fn() + if v: + return v + except Exception: + pass + try: + return getattr(resource, "size", 0) or len(resource.data) + except Exception: + return 0 + + def on_start(self, button=None): + if self._sending: + return + self.update_interface_status() + + peer_identity = self._peer_identity() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + hexhash = self.hash_edit.edit_text.strip() + if len(hexhash) != dest_len: + self._log("Peer hash must be %d hex characters" % dest_len, "error_text") + return + try: + self._dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid peer hash", "error_text") + return + + try: + mb = float(self.size_edit.edit_text.strip() or str(DEFAULT_SPEEDTEST_MB)) + except ValueError: + mb = DEFAULT_SPEEDTEST_MB + mb = max(0.01, min(MAX_SPEEDTEST_MB, mb)) + self._send_bytes = int(mb * 1024 * 1024) + + self._sending = True + self.start_button.original_widget.set_label("Testing…") + self.progress_text.set_text("") + + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + else: + try: + RNS.Transport.request_path(self._dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(self._dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _timeout(self): + try: + return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + elif time.time() > self._deadline: + self._log("Path not found", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _establish_link(self): + identity = RNS.Identity.recall(self._dest_hash) + if identity is None: + self._log("Could not recall identity for peer", "error_text") + self._finish() + return + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, SPEEDTEST_APP_NAME, *SPEEDTEST_ASPECTS) + self._log("Establishing link with %s …" % RNS.prettyhexrep(self._dest_hash)) + self._client_link = RNS.Link(destination, established_callback=self._on_client_link_up, closed_callback=self._on_client_link_closed) + except Exception as e: + self._log("Could not establish link: %s" % str(e), "error_text") + self._finish() + return + self._link_deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.5, self._check_link) + + def _check_link(self, loop, user_data): + if not self._sending or self._client_resource is not None: + return + try: + active = self._client_link is not None and self._client_link.status == RNS.Link.ACTIVE + except Exception: + active = False + if active: + return + if time.time() > self._link_deadline: + self._log("Link establishment timed out", "warning_text") + try: + self._client_link.teardown() + except Exception: + pass + self._finish() + else: + loop.set_alarm_in(0.5, self._check_link) + + def _on_client_link_up(self, link): + self._schedule(lambda: self._client_ready(link)) + + def _client_ready(self, link): + if not self._sending: + return + try: + link.identify(self.app.identity) + except Exception: + pass + self.app.ui.loop.set_alarm_in(0.6, lambda l, u: self._send_resource(link)) + + def _send_resource(self, link): + if not self._sending or self._client_resource is not None: + return + try: + data = os.urandom(self._send_bytes) + self._send_started = time.time() + self._client_resource = RNS.Resource(data, link, callback=self._on_send_done, progress_callback=self._on_send_progress, auto_compress=False) + self._log("Sending %s test payload …" % _size_str(self._send_bytes)) + except Exception as e: + self._log("Could not start transfer: %s" % str(e), "error_text") + self._finish() + + def _on_send_progress(self, resource): + self._schedule(lambda: self._send_progress(resource)) + + def _send_progress(self, resource): + try: + pct = int(resource.get_progress() * 100) + except Exception: + pct = 0 + self.progress_text.set_text(("body_text", "Uploading… %d%%" % pct)) + + def _on_send_done(self, resource): + self._schedule(lambda: self._send_done(resource)) + + def _send_done(self, resource): + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if complete: + elapsed = max(0.001, time.time() - (self._send_started or time.time())) + self._log("Upload: %s (%s in %.2fs)" % (_fmt_bitrate(self._send_bytes / elapsed), _size_str(self._send_bytes), elapsed), "connected_status") + else: + self._log("Transfer failed or was not accepted", "warning_text") + try: + if self._client_link is not None: + self._client_link.teardown() + except Exception: + pass + self.progress_text.set_text("") + self._finish() + + def _on_client_link_closed(self, link): + self._schedule(self._client_closed) + + def _client_closed(self): + if self._sending and self._client_resource is None: + self._log("Link closed before the test started", "warning_text") + self._finish() + + def _finish(self): + self._sending = False + self.start_button.original_widget.set_label("Start test") + self._draw() + +def _size_str(num, suffix="B"): + units = ["", "K", "M", "G", "T"] + num = float(num) + for unit in units: + if abs(num) < 1024.0: + return "%.1f %s%s" % (num, unit, suffix) + num /= 1024.0 + return "%.1f P%s" % (num, suffix) diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py index 38a72df..fd69aa8 100644 --- a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -1,7 +1,14 @@ import urwid +import nomadnet from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit +def dropdown_indicator(): + try: + return nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs["dropdown"] + except Exception: + return " ▾" + class DialogLineBox(urwid.LineBox): def __init__(self, body, parent=None, title="?"): super().__init__(body, title=title) @@ -38,6 +45,13 @@ class Dropdown(urwid.WidgetWrap): self.main_button = urwid.SelectableIcon(self.main_text, 0) self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus") + self.indicator_widget = urwid.Text(dropdown_indicator()) + self.main_row = urwid.Columns([ + ('pack', self.main_button), + ('pack', self.indicator_widget), + ('weight', 1, urwid.Text("")), + ]) + self.option_widgets = [] for opt in options: icon = urwid.SelectableIcon(opt, 0) @@ -48,7 +62,7 @@ class Dropdown(urwid.WidgetWrap): self.options_listbox = urwid.ListBox(self.options_walker) self.dropdown_box = None # will be created on open_dropdown - self.pile = urwid.Pile([self.main_button]) + self.pile = urwid.Pile([self.main_row]) self.dropdown_visible = False super().__init__(self.pile) @@ -241,14 +255,25 @@ class FormMultiList(urwid.Pile, FormField): urwid.Pile.__init__(self, pile_widgets) FormField.__init__(self, config_key, transform) - def create_entry_row(self): - edit = ReadlineEdit("", "") + def create_entry_row(self, text=""): + edit = ReadlineEdit("", text) entry_row = urwid.Columns([ ('weight', 1, edit), (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), ]) return entry_row + def set_value(self, value): + self.entries = [] + if not value: + self.entries.append(self.create_entry_row()) + else: + if isinstance(value, str): + value = [value] + for item in value: + self.entries.append(self.create_entry_row(str(item))) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + def remove_entry(self, button, entry_row): if len(self.entries) > 1: self.entries.remove(entry_row) From 8dffd508c3b644b7aca4a157899de031bfe68803 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 15 Jun 2026 06:08:33 -0400 Subject: [PATCH 513/592] stream speed test files from temp file instead of os.random, progress bar for speed test utility. Ctrl + l to clear and output box for utils. --- nomadnet/ui/textui/Utilities.py | 434 +++++++++++++++++++++++--------- 1 file changed, 321 insertions(+), 113 deletions(-) diff --git a/nomadnet/ui/textui/Utilities.py b/nomadnet/ui/textui/Utilities.py index 088f5a4..549c0c7 100644 --- a/nomadnet/ui/textui/Utilities.py +++ b/nomadnet/ui/textui/Utilities.py @@ -1,12 +1,45 @@ import RNS import os import time +import tempfile +import threading import nomadnet import urwid from .Transport import TransportDisplay, _get_cols_rows from .ReadlineEdit import ReadlineEdit from .FileBrowser import FileBrowser +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import Dropdown + +class OutputLog: + def __init__(self, title="Output"): + self.walker = urwid.SimpleListWalker([]) + self.listbox = urwid.ListBox(self.walker) + self.widget = urwid.LineBox( + urwid.ScrollBar(self.listbox), + title=title, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + def log(self, message, attr="body_text"): + self.walker.append(urwid.Text((attr, message))) + try: + self.listbox.set_focus(len(self.walker) - 1) + self.listbox.set_focus_valign("bottom") + except Exception: + pass + + def clear(self): + self.walker[:] = [] + +def _utility_body(form_rows, output): + return urwid.Pile([ + ('pack', urwid.Pile(form_rows)), + ('weight', 1, output.widget), + ]) DEFAULT_PROBE_SIZE = 16 DEFAULT_PROBE_TIMEOUT = 12 @@ -209,7 +242,7 @@ class ProbeShortcuts: def __init__(self, app): self.app = app self.widget = urwid.AttrMap( - urwid.Text("[Up/Down] Navigate [Enter] Probe / Activate [Esc] Back"), + urwid.Text("[Up/Down] Navigate [Enter] Probe / Activate [C-l] Clear [Esc] Back"), "shortcutbar" ) @@ -222,7 +255,13 @@ class UtilityViewFiller(urwid.WidgetWrap): if key == "esc": self.view.go_back() return None + if key == "ctrl l": + if hasattr(self.view, "clear_output"): + self.view.clear_output() + return None result = super().keypress(size, key) + if hasattr(self.view, "after_keypress"): + self.view.after_keypress() if key == "up" and result == "up": self.view.go_back_to_header() return None @@ -270,12 +309,9 @@ class ProbeView: self._field_row("Probe count:", self.count_edit), urwid.Divider(), button_row, - urwid.Divider("─"), - urwid.Text(("key", "Output")), - ] - self.walker = urwid.SimpleFocusListWalker(rows) - self.listbox = urwid.ListBox(self.walker) - self.widget = UtilityViewFiller(self.listbox, self) + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) self.update_interface_status() def _field_row(self, label, edit): @@ -301,11 +337,10 @@ class ProbeView: pass def _log(self, message, attr="body_text"): - self.walker.append(urwid.Text((attr, message))) - try: - self.listbox.set_focus(len(self.walker) - 1) - except Exception: - pass + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() def _draw(self): try: @@ -516,7 +551,7 @@ class PathShortcuts: def __init__(self, app): self.app = app self.widget = urwid.AttrMap( - urwid.Text("[Up/Down] Navigate [Enter] Resolve / Activate [Esc] Back"), + urwid.Text("[Up/Down] Navigate [Enter] Resolve / Activate [C-l] Clear [Esc] Back"), "shortcutbar" ) @@ -557,12 +592,9 @@ class PathView: urwid.Padding(urwid.Text(("inactive_text", "Requests the path from the network if it is not already known.")), left=22), urwid.Divider(), button_row, - urwid.Divider("─"), - urwid.Text(("key", "Output")), - ] - self.walker = urwid.SimpleFocusListWalker(rows) - self.listbox = urwid.ListBox(self.walker) - self.widget = UtilityViewFiller(self.listbox, self) + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) self.update_interface_status() def update_interface_status(self): @@ -582,11 +614,10 @@ class PathView: pass def _log(self, message, attr="body_text"): - self.walker.append(urwid.Text((attr, message))) - try: - self.listbox.set_focus(len(self.walker) - 1) - except Exception: - pass + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() def _draw(self): try: @@ -685,7 +716,7 @@ class IdentityShortcuts: def __init__(self, app): self.app = app self.widget = urwid.AttrMap( - urwid.Text("[Up/Down] Navigate [Enter] Look up / Activate [Esc] Back"), + urwid.Text("[Up/Down] Navigate [Enter] Look up / Activate [C-l] Clear [Esc] Back"), "shortcutbar" ) @@ -723,12 +754,9 @@ class IdentityView: urwid.Padding(urwid.Text(("inactive_text", "Recalls the identity, requesting it from the network if needed.")), left=22), urwid.Divider(), button_row, - urwid.Divider("─"), - urwid.Text(("key", "Output")), - ] - self.walker = urwid.SimpleFocusListWalker(rows) - self.listbox = urwid.ListBox(self.walker) - self.widget = UtilityViewFiller(self.listbox, self) + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) def _addr(self, obj): try: @@ -763,11 +791,10 @@ class IdentityView: pass def _log(self, message, attr="body_text"): - self.walker.append(urwid.Text((attr, message))) - try: - self.listbox.set_focus(len(self.walker) - 1) - except Exception: - pass + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() def _draw(self): try: @@ -833,7 +860,7 @@ class SendFileShortcuts: def __init__(self, app): self.app = app self.widget = urwid.AttrMap( - urwid.Text("[Up/Down] Navigate [Enter] Choose / Send [Esc] Back"), + urwid.Text("[Up/Down] Navigate [Enter] Choose / Send [C-l] Clear [Esc] Back"), "shortcutbar" ) @@ -882,12 +909,9 @@ class SendFileView: urwid.Divider(), button_row, urwid.Divider("─"), - self.progress_text, - urwid.Text(("key", "Output")), - ] - self.walker = urwid.SimpleFocusListWalker(rows) - self.listbox = urwid.ListBox(self.walker) - self.widget = UtilityViewFiller(self.listbox, self) + self.progress_text, ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) self.update_interface_status() def update_interface_status(self): @@ -907,11 +931,10 @@ class SendFileView: pass def _log(self, message, attr="body_text"): - self.walker.append(urwid.Text((attr, message))) - try: - self.listbox.set_focus(len(self.walker) - 1) - except Exception: - pass + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() def _draw(self): try: @@ -1101,10 +1124,28 @@ class SendFileView: self.send_button.original_widget.set_label("Send") self._draw() + SPEEDTEST_APP_NAME = "nomadnetwork" SPEEDTEST_ASPECTS = ("utilities", "speedtest") -DEFAULT_SPEEDTEST_MB = 1 -MAX_SPEEDTEST_MB = 50 +SPEEDTEST_INMEM_MAX = 8 * 1024 * 1024 + +SPEEDTEST_SIZES = [ + ("2 KB", 2 * 1024), + ("8 KB", 8 * 1024), + ("32 KB", 32 * 1024), + ("128 KB", 128 * 1024), + ("512 KB", 512 * 1024), + ("1 MB", 1024 * 1024), + ("4 MB", 4 * 1024 * 1024), + ("16 MB", 16 * 1024 * 1024), + ("64 MB", 64 * 1024 * 1024), + ("256 MB", 256 * 1024 * 1024), + ("1000 MB", 1000 * 1024 * 1024), + ("2000 MB", 2000 * 1024 * 1024), +] +SPEEDTEST_SIZE_LABELS = [label for label, _ in SPEEDTEST_SIZES] +SPEEDTEST_BYTES_BY_LABEL = {label: nbytes for label, nbytes in SPEEDTEST_SIZES} +DEFAULT_SPEEDTEST_LABEL = "1 MB" def _fmt_bitrate(bytes_per_sec): bits = float(bytes_per_sec) * 8 @@ -1114,11 +1155,19 @@ def _fmt_bitrate(bytes_per_sec): bits /= 1000 return "%.1f Tbps" % bits +def _size_str(num, suffix="B"): + num = float(num) + for unit in ("", "K", "M", "G", "T"): + if abs(num) < 1024.0: + return "%.1f %s%s" % (num, unit, suffix) + num /= 1024.0 + return "%.1f P%s" % (num, suffix) + class SpeedTestShortcuts: def __init__(self, app): self.app = app self.widget = urwid.AttrMap( - urwid.Text("[Up/Down] Navigate [Enter] Start / Activate [Esc] Back"), + urwid.Text("[Up/Down] Navigate [Enter] Start / Cancel [C-l] Clear [Esc] Back"), "shortcutbar" ) @@ -1134,22 +1183,26 @@ class SpeedTestView: self._listening = False self._incoming_link = None self._recv_link = None + self._recv_resource = None self._recv_started = None - self._recv_bytes = 0 + self._recv_polling = False self._sending = False self._client_link = None self._client_resource = None self._send_started = None self._send_bytes = 0 + self._tmppath = None self.hash_edit = ReadlineEdit("", "") - self.size_edit = ReadlineEdit("", str(DEFAULT_SPEEDTEST_MB)) - self.address_text = urwid.Text(("value", "—")) + self.size_dropdown = Dropdown("", SPEEDTEST_SIZE_LABELS, DEFAULT_SPEEDTEST_LABEL) + self.address_text = urwid.Text(("value", "-")) self.status_text = urwid.Text("") - self.progress_text = urwid.Text("") + self.listen_status = urwid.Text("") + self.rate_text = urwid.Text("") + self.progress_bar = urwid.ProgressBar("progress_empty", "progress_full", current=0, done=100) - self.start_button = urwid.AttrMap(urwid.Button("Start test", on_press=self.on_start), "button_normal", focus_map="button_focus") + self.start_button = urwid.AttrMap(urwid.Button("Send test file", on_press=self.on_start), "button_normal", focus_map="button_focus") self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") button_row = urwid.Columns([ (urwid.WEIGHT, 0.45, self.start_button), @@ -1161,6 +1214,7 @@ class SpeedTestView: urwid.Text(("form_title", "Speed test"), align=urwid.CENTER), urwid.Divider("─"), self.status_text, + self.listen_status, urwid.Columns([ (22, urwid.Text(("key", "Your speedtest hash:"), align=urwid.RIGHT)), self.address_text, @@ -1171,18 +1225,20 @@ class SpeedTestView: urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), ], dividechars=1), urwid.Columns([ - (22, urwid.Text(("key", "Test size (MB):"), align=urwid.RIGHT)), - urwid.AttrMap(self.size_edit, "list_normal", focus_map="list_focus"), + (22, urwid.Text(("key", "Test size:"), align=urwid.RIGHT)), + self.size_dropdown, ], dividechars=1), urwid.Divider(), button_row, urwid.Divider("─"), - self.progress_text, - urwid.Text(("key", "Output")), + urwid.Columns([ + (22, urwid.Text(("key", "Progress:"), align=urwid.RIGHT)), + self.progress_bar, + ], dividechars=1), + urwid.Padding(self.rate_text, left=24), ] - self.walker = urwid.SimpleFocusListWalker(rows) - self.listbox = urwid.ListBox(self.walker) - self.widget = UtilityViewFiller(self.listbox, self) + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) def update_interface_status(self): if _has_active_interfaces(self.app): @@ -1190,11 +1246,58 @@ class SpeedTestView: else: self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + def _peer_hash_entered(self): + return len(self.hash_edit.edit_text.strip()) == (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + + def _update_listen_status(self): + dot = self.g.get("connected", "*") + if self._sending: + self.listen_status.set_text(("connected_status", "%s %s Sending test…" % (dot, self.g.get("arrow_u", "^")))) + elif self._recv_resource is not None or self._recv_link is not None: + self.listen_status.set_text(("connected_status", "%s %s Receiving test…" % (dot, self.g.get("arrow_d", "v")))) + elif self._listening and self.server_destination is not None: + if self._peer_hash_entered(): + self.listen_status.set_text(("connected_status", "%s Listening and ready to receive or send to from entered peer" % dot)) + else: + self.listen_status.set_text(("warning_text", "%s Listening enter the peer's hash to accept their test" % dot)) + else: + self.listen_status.set_text(("inactive_text", "Not listening")) + + def after_keypress(self): + self._update_listen_status() + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def _set_progress(self, pct): + try: + self.progress_bar.set_completion(max(0, min(100, pct))) + except Exception: + pass + def _update_address(self): if self.server_destination is not None: self.address_text.set_text(("value", RNS.prettyhexrep(self.server_destination.hash))) else: - self.address_text.set_text(("value", "—")) + self.address_text.set_text(("value", "-")) def start_listening(self): if self.server_destination is None: @@ -1210,9 +1313,11 @@ class SpeedTestView: self.server_destination = None self._listening = True self._update_address() + self._update_listen_status() def stop_listening(self): self._listening = False + self._recv_polling = False try: if self._incoming_link is not None: self._incoming_link.teardown() @@ -1220,6 +1325,7 @@ class SpeedTestView: pass self._incoming_link = None self._recv_link = None + self._recv_resource = None try: if self.server_destination is not None: RNS.Transport.deregister_destination(self.server_destination) @@ -1227,6 +1333,26 @@ class SpeedTestView: pass self.server_destination = None self._update_address() + self._update_listen_status() + + def _log_link_mtu(self, link): + try: + parts = [] + mtu = getattr(link, "mtu", None) + mdu = getattr(link, "mdu", None) + if mtu: + parts.append("MTU: %s (MDU %s)" % (RNS.prettysize(mtu), RNS.prettysize(mdu) if mdu else "?")) + rtt = getattr(link, "rtt", None) + if rtt: + if hasattr(RNS, "prettyshorttime"): + rtt_str = RNS.prettyshorttime(rtt) + else: + rtt_str = "%.0f ms" % (rtt * 1000) if rtt < 1 else "%.3f s" % rtt + parts.append("RTT: %s" % rtt_str) + if parts: + self._log("Link " + ", ".join(parts)) + except Exception: + pass def _is_active(self): try: @@ -1246,6 +1372,7 @@ class SpeedTestView: return None def go_back(self): + self.cancel(silent=True) self.stop_listening() if self.parent is not None: self.parent.show_landing() @@ -1256,27 +1383,13 @@ class SpeedTestView: except Exception: pass - def _log(self, message, attr="body_text"): - self.walker.append(urwid.Text((attr, message))) - try: - self.listbox.set_focus(len(self.walker) - 1) - except Exception: - pass - - def _draw(self): - try: - self.app.ui.loop.draw_screen() - except Exception: - pass - - def _schedule(self, fn): - try: - self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) - except Exception: + def _cleanup_temp(self): + if self._tmppath: try: - fn() + os.remove(self._tmppath) except Exception: pass + self._tmppath = None def _on_incoming_link(self, link): if not self._is_active(): @@ -1311,37 +1424,63 @@ class SpeedTestView: return self._recv_link = link self._recv_started = None - self._recv_bytes = 0 + self._recv_resource = None link.set_resource_strategy(RNS.Link.ACCEPT_ALL) link.set_resource_started_callback(self._on_recv_started) link.set_resource_concluded_callback(self._on_recv_concluded) + self._set_progress(0) self._log("Incoming speed test from %s" % RNS.prettyhexrep(identity.hash), "connected_status") + self._log_link_mtu(link) def _on_recv_started(self, resource): - self._schedule(self._recv_started_now) + self._schedule(lambda: self._recv_started_now(resource)) - def _recv_started_now(self): + def _recv_started_now(self, resource): + self._recv_resource = resource self._recv_started = time.time() - self.progress_text.set_text(("body_text", "Receiving…")) + self._recv_polling = True + self.rate_text.set_text(("body_text", "Receiving…")) + self._update_listen_status() + self.app.ui.loop.set_alarm_in(0.3, self._recv_poll) + + def _recv_poll(self, loop, user_data): + if not self._recv_polling or self._recv_resource is None: + return + try: + pct = int(self._recv_resource.get_progress() * 100) + except Exception: + pct = 0 + self._set_progress(pct) + self.rate_text.set_text(("body_text", "Downloading… %d%%" % max(0, min(100, pct)))) + self._draw() + if self._recv_polling: + loop.set_alarm_in(0.3, self._recv_poll) def _on_recv_concluded(self, resource): self._schedule(lambda: self._recv_concluded(resource)) def _recv_concluded(self, resource): + self._recv_polling = False try: complete = resource.status == RNS.Resource.COMPLETE except Exception: complete = False if not complete: self._log("Incoming transfer failed", "warning_text") + self.rate_text.set_text("") return size = self._resource_size(resource) elapsed = max(0.001, time.time() - (self._recv_started or time.time())) - self._log("Download: %s (%s in %.2fs)" % (_fmt_bitrate(size / elapsed), _size_str(size), elapsed), "connected_status") - self.progress_text.set_text("") + self._set_progress(100) + rate = _fmt_bitrate(size / elapsed) + self._log("Download: %s (%s in %.2fs)" % (rate, _size_str(size), elapsed), "connected_status") + self.rate_text.set_text(("connected_status", "Download: %s" % rate)) + self._recv_resource = None + self._recv_link = None + self._update_listen_status() def _resource_size(self, resource): - for getter in ("get_transfer_size", "getTransferSize"): + for getter in ("get_transfer_size", "get_data_size"): try: fn = getattr(resource, getter, None) if fn: @@ -1357,6 +1496,7 @@ class SpeedTestView: def on_start(self, button=None): if self._sending: + self.cancel() return self.update_interface_status() @@ -1372,16 +1512,14 @@ class SpeedTestView: self._log("Invalid peer hash", "error_text") return - try: - mb = float(self.size_edit.edit_text.strip() or str(DEFAULT_SPEEDTEST_MB)) - except ValueError: - mb = DEFAULT_SPEEDTEST_MB - mb = max(0.01, min(MAX_SPEEDTEST_MB, mb)) - self._send_bytes = int(mb * 1024 * 1024) + label = self.size_dropdown.get_value() + self._send_bytes = SPEEDTEST_BYTES_BY_LABEL.get(label, 1024 * 1024) self._sending = True - self.start_button.original_widget.set_label("Testing…") - self.progress_text.set_text("") + self.start_button.original_widget.set_label("Cancel") + self._set_progress(0) + self.rate_text.set_text("") + self._update_listen_status() if RNS.Transport.has_path(self._dest_hash): self._establish_link() @@ -1401,6 +1539,8 @@ class SpeedTestView: return DEFAULT_PROBE_TIMEOUT def _await_path(self, loop, user_data): + if not self._sending: + return if RNS.Transport.has_path(self._dest_hash): self._establish_link() elif time.time() > self._deadline: @@ -1455,18 +1595,64 @@ class SpeedTestView: link.identify(self.app.identity) except Exception: pass - self.app.ui.loop.set_alarm_in(0.6, lambda l, u: self._send_resource(link)) + self._log_link_mtu(link) + self.app.ui.loop.set_alarm_in(0.6, lambda l, u: self._prepare_and_send(link)) - def _send_resource(self, link): + def _prepare_and_send(self, link): if not self._sending or self._client_resource is not None: return + nbytes = self._send_bytes + if nbytes <= SPEEDTEST_INMEM_MAX: + self._begin_resource(link, os.urandom(nbytes), None) + return + self._log("Preparing %s payload …" % _size_str(nbytes)) + + def worker(): + path = None + try: + fd, path = tempfile.mkstemp(prefix="nn_speedtest_") + written = 0 + with os.fdopen(fd, "wb") as f: + while written < nbytes and self._sending: + chunk = min(1024 * 1024, nbytes - written) + f.write(os.urandom(chunk)) + written += chunk + except Exception as e: + if path: + try: + os.remove(path) + except Exception: + pass + self._schedule(lambda: (self._log("Could not prepare payload: %s" % str(e), "error_text"), self._finish())) + return + if not self._sending: + if path: + try: + os.remove(path) + except Exception: + pass + return + self._schedule(lambda: self._begin_resource(link, None, path)) + + threading.Thread(target=worker, daemon=True).start() + + def _begin_resource(self, link, data_bytes, tmppath): + if not self._sending: + if tmppath: + try: + os.remove(tmppath) + except Exception: + pass + return + self._tmppath = tmppath try: - data = os.urandom(self._send_bytes) + src = data_bytes if data_bytes is not None else open(tmppath, "rb") self._send_started = time.time() - self._client_resource = RNS.Resource(data, link, callback=self._on_send_done, progress_callback=self._on_send_progress, auto_compress=False) + self._client_resource = RNS.Resource(src, link, callback=self._on_send_done, progress_callback=self._on_send_progress, auto_compress=False) self._log("Sending %s test payload …" % _size_str(self._send_bytes)) except Exception as e: self._log("Could not start transfer: %s" % str(e), "error_text") + self._cleanup_temp() self._finish() def _on_send_progress(self, resource): @@ -1477,7 +1663,8 @@ class SpeedTestView: pct = int(resource.get_progress() * 100) except Exception: pct = 0 - self.progress_text.set_text(("body_text", "Uploading… %d%%" % pct)) + self._set_progress(pct) + self.rate_text.set_text(("body_text", "Uploading… %d%%" % max(0, min(100, pct)))) def _on_send_done(self, resource): self._schedule(lambda: self._send_done(resource)) @@ -1488,16 +1675,20 @@ class SpeedTestView: except Exception: complete = False if complete: + self._set_progress(100) elapsed = max(0.001, time.time() - (self._send_started or time.time())) - self._log("Upload: %s (%s in %.2fs)" % (_fmt_bitrate(self._send_bytes / elapsed), _size_str(self._send_bytes), elapsed), "connected_status") + rate = _fmt_bitrate(self._send_bytes / elapsed) + self._log("Upload: %s (%s in %.2fs)" % (rate, _size_str(self._send_bytes), elapsed), "connected_status") + self.rate_text.set_text(("connected_status", "Upload: %s" % rate)) else: self._log("Transfer failed or was not accepted", "warning_text") + self.rate_text.set_text("") try: if self._client_link is not None: self._client_link.teardown() except Exception: pass - self.progress_text.set_text("") + self._cleanup_temp() self._finish() def _on_client_link_closed(self, link): @@ -1508,16 +1699,33 @@ class SpeedTestView: self._log("Link closed before the test started", "warning_text") self._finish() - def _finish(self): + def cancel(self, silent=False): + if not self._sending: + return self._sending = False + try: + if self._client_resource is not None: + self._client_resource.cancel() + except Exception: + pass + try: + if self._client_link is not None: + self._client_link.teardown() + except Exception: + pass + self._client_resource = None + self._cleanup_temp() + if not silent: + self._log("Speed test cancelled", "warning_text") + self.rate_text.set_text("") + self._set_progress(0) self.start_button.original_widget.set_label("Start test") + self._update_listen_status() self._draw() -def _size_str(num, suffix="B"): - units = ["", "K", "M", "G", "T"] - num = float(num) - for unit in units: - if abs(num) < 1024.0: - return "%.1f %s%s" % (num, unit, suffix) - num /= 1024.0 - return "%.1f P%s" % (num, suffix) + def _finish(self): + self._sending = False + self._client_resource = None + self.start_button.original_widget.set_label("Start test") + self._update_listen_status() + self._draw() From cbc670d6b0773e398e765c0ce43994c70fcdde0f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 15 Jun 2026 12:15:24 +0200 Subject: [PATCH 514/592] Compatibility with urwid 4.0.0+ --- nomadnet/vendor/Scrollable.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index dc130d4..e40bef0 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -138,7 +138,7 @@ class Scrollable(urwid.WidgetDecoration): first_visible = True if w.selectable(): - ow.focus_item = pwi + ow.focus = pwi st = None nf = ow.get_focus() diff --git a/setup.py b/setup.py index 9460894..94c17e6 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.3.4", "lxmf>=1.0.0", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.3.5", "lxmf>=1.0.1", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From 9b7ebe13b771cd8a05c40eac596bed1dc58c9ba8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 15 Jun 2026 12:15:42 +0200 Subject: [PATCH 515/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index b7e1990..64c7d51 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.5" +__version__ = "1.2.6" From a7ec7ae9ec1d1ea81280a2eccfc32359c3ae161e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 10 Jul 2026 16:28:39 +0200 Subject: [PATCH 516/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 94c17e6..516254c 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.3.5", "lxmf>=1.0.1", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.3.8", "lxmf>=1.0.1", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From a720dfa9d8ecffab68a76a57e62846fd386d7e09 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien <956a74d715fe9cb2e9da0a19b067b414> Date: Fri, 10 Jul 2026 16:36:55 -0400 Subject: [PATCH 517/592] fix path resolution when using a shared-instance rnsd --- nomadnet/Conversation.py | 45 +++++++++++++++++------------ nomadnet/ui/textui/Conversations.py | 21 ++++++++++++-- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index c543d9c..b966093 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -15,8 +15,9 @@ class Conversation: created_callback = None aspect_filter = "lxmf.delivery" + receive_path_responses = True @staticmethod - def received_announce(destination_hash, announced_identity, app_data): + def received_announce(destination_hash, announced_identity, app_data, announce_packet_hash=None, is_path_response=False): app = nomadnet.NomadNetworkApp.get_shared_instance() if not display_name_from_app_data(app_data): @@ -32,15 +33,16 @@ class Conversation: if Conversation.created_callback != None: Conversation.created_callback() - # This reformats the new v0.5.0 announce data back to the expected format - # for nomadnets storage and other handling functions. - dn = LXMF.display_name_from_app_data(app_data) - app_data = b"" - if dn != None: app_data = dn.encode("utf-8") + if not is_path_response: + # This reformats the new v0.5.0 announce data back to the expected format + # for nomadnets storage and other handling functions. + dn = LXMF.display_name_from_app_data(app_data) + app_data = b"" + if dn != None: app_data = dn.encode("utf-8") - # Add the announce to the directory announce - # stream logger - app.directory.lxmf_announce_received(destination_hash, app_data) + # Add the announce to the directory announce + # stream logger + app.directory.lxmf_announce_received(destination_hash, app_data) else: RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) @@ -202,15 +204,9 @@ class Conversation: self.__changed_callback = None - if not RNS.Identity.recall(bytes.fromhex(self.source_hash)): + if not self.__resolve_send_destination(): RNS.Transport.request_path(bytes.fromhex(source_hash)) - self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) - - if self.source_identity: - self.source_known = True - self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") - if initiator: if not os.path.isdir(self.messages_path): os.makedirs(self.messages_path) @@ -293,8 +289,18 @@ class Conversation: def register_changed_callback(self, callback): self.__changed_callback = callback + def __resolve_send_destination(self): + if self.send_destination == None: + self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) + + if self.source_identity: + self.source_known = True + self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + + return self.send_destination != None + def send(self, content="", title="", fields=None): - if self.send_destination: + if self.__resolve_send_destination(): dest = self.send_destination source = self.app.lxmf_destination desired_method = LXMF.LXMessage.DIRECT @@ -324,11 +330,12 @@ class Conversation: return True else: - RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) + RNS.log("Destination is not known, cannot create LXMF Message. Requesting path...", RNS.LOG_VERBOSE) + RNS.Transport.request_path(bytes.fromhex(self.source_hash)) return False def paper_output(self, content="", title="", mode="print_qr"): - if self.send_destination: + if self.__resolve_send_destination(): try: dest = self.send_destination source = self.app.lxmf_destination diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index f022206..feaedc9 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -242,13 +242,24 @@ class ConversationsDisplay(): except Exception: pass - nomadnet.Conversation.created_callback = lambda: self._wake(self.update_conversation_list) + nomadnet.Conversation.created_callback = lambda: self._wake(self._conversations_changed) try: self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) except Exception: pass + def _conversations_changed(self): + self.update_conversation_list() + # If the identity keys for the displayed conversation just + # became known, enable its editor without requiring the user + # to close and reopen the conversation. + if self.currently_displayed_conversation != None: + widget = ConversationsDisplay.cached_conversation_widgets.get(self.currently_displayed_conversation) + if widget != None: + widget.check_editor_allowed() + widget._update_peer_info() + def _process_pending(self, data): while True: try: @@ -1890,6 +1901,7 @@ class ConversationWidget(urwid.WidgetWrap): self.sort_by_timestamp = False self.pending_attachments = [] self.dialog_active = False + self.editor_allowed = None self.update_message_widgets() @@ -2187,14 +2199,17 @@ class ConversationWidget(urwid.WidgetWrap): g = self.app.ui.glyphs if self.frame: allowed = nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)) + if allowed == self.editor_allowed: + return + self.editor_allowed = allowed if allowed: self.frame.contents["footer"] = (self._build_footer(), None) else: warning = urwid.AttrMap( urwid.Padding(urwid.Text( "\n"+g["info"]+"\n\nYou cannot currently message this peer, since its identity keys are not known. " - "The keys have been requested from the network and should arrive shortly, if available. " - "Close this conversation and reopen it to try again.\n\n" + "The keys have been requested from the network, and you will be able to send messages " + "as soon as they arrive.\n\n" "To query the network manually, select this conversation in the conversation list, " "press Ctrl-E, and use the query button.\n", align=urwid.CENTER, From 193311b4e585de45dd371b8b7434f4bca9cc7d9c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 11 Jul 2026 11:19:23 +0200 Subject: [PATCH 518/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 64c7d51..7f647d0 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.6" +__version__ = "1.2.7" From 936bef77c7289c3db58d5ee7bfefe793b8401225 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:42:31 -0400 Subject: [PATCH 519/592] Merge dev branch into master --- nomadnet/Conversation.py | 64 ++++- nomadnet/Directory.py | 5 +- nomadnet/ui/textui/Config.py | 61 ++--- nomadnet/ui/textui/Conversations.py | 245 ++++++++++++++++-- nomadnet/ui/textui/Editor.py | 57 +++- nomadnet/ui/textui/FileBrowser.py | 43 ++- nomadnet/ui/textui/Main.py | 5 +- nomadnet/ui/textui/Network.py | 44 ++-- nomadnet/ui/textui/Transport.py | 38 +-- nomadnet/ui/textui/Utilities.py | 107 +++++--- .../additional_urwid_widgets/FormWidgets.py | 17 +- 11 files changed, 539 insertions(+), 147 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index c543d9c..aac7a7c 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -70,6 +70,24 @@ class Conversation: ingested_path = lxmessage.write_to_directory(conversation_path) + if not originator and ingested_path is not None: + try: + rssi = getattr(lxmessage, "rssi", None) + snr = getattr(lxmessage, "snr", None) + q = getattr(lxmessage, "q", None) + if rssi is not None or snr is not None or q is not None: + index = ConversationMessage.read_index(conversation_path) + filename = os.path.basename(ingested_path) + entry = index.get(filename) or {} + if rssi is not None: entry["rssi"] = rssi + if snr is not None: entry["snr"] = snr + if q is not None: entry["q"] = q + index[filename] = entry + with open(os.path.join(conversation_path, ".index"), "wb") as index_file: + index_file.write(msgpack.packb(index)) + except Exception as e: + RNS.log("Error while storing reception stats for message: "+str(e), RNS.LOG_ERROR) + try: ConversationMessage.extract_attachments_from_lxm(lxmessage, app) except Exception as e: @@ -293,14 +311,26 @@ class Conversation: def register_changed_callback(self, callback): self.__changed_callback = callback + def ensure_send_destination(self): + if self.send_destination is None: + self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) + if self.source_identity: + self.source_known = True + self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + return self.send_destination + def send(self, content="", title="", fields=None): + self.ensure_send_destination() if self.send_destination: dest = self.send_destination source = self.app.lxmf_destination desired_method = LXMF.LXMessage.DIRECT - if self.app.directory.preferred_delivery(dest.hash) == DirectoryEntry.PROPAGATED: + preferred = self.app.directory.preferred_delivery(dest.hash) + if preferred == DirectoryEntry.PROPAGATED: if self.app.message_router.get_outbound_propagation_node() != None: desired_method = LXMF.LXMessage.PROPAGATED + elif preferred == DirectoryEntry.OPPORTUNISTIC: + desired_method = LXMF.LXMessage.OPPORTUNISTIC else: if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None: RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG) @@ -328,6 +358,7 @@ class Conversation: return False def paper_output(self, content="", title="", mode="print_qr"): + self.ensure_send_destination() if self.send_destination: try: dest = self.send_destination @@ -433,6 +464,9 @@ class ConversationMessage: self._cached_method = None self._cached_has_attachments = None self._cached_attachment_names = None + self._cached_rssi = None + self._cached_snr = None + self._cached_q = None self.sort_timestamp = os.path.getmtime(file_path) if os.path.isfile(file_path) else 0 @@ -476,6 +510,12 @@ class ConversationMessage: if self._cached_signature_validated is None: self._cached_signature_validated = self.lxm.signature_validated self._cached_method = self.lxm.method + if self._cached_rssi is None: + self._cached_rssi = getattr(self.lxm, "rssi", None) + if self._cached_snr is None: + self._cached_snr = getattr(self.lxm, "snr", None) + if self._cached_q is None: + self._cached_q = getattr(self.lxm, "q", None) if hasattr(self.lxm, "get_fields"): _fields = self.lxm.get_fields() if _fields and isinstance(_fields, dict) and LXMF.FIELD_RENDERER in _fields: @@ -596,6 +636,22 @@ class ConversationMessage: self.load() return self._cached_state + def get_method(self): + if self._cached_method is not None: + return self._cached_method + if not self.loaded: + self.load() + return self._cached_method + + def get_rssi(self): + return self._cached_rssi + + def get_snr(self): + return self._cached_snr + + def get_q(self): + return self._cached_q + def get_transport_encryption(self): if self._cached_transport_encryption is not None: return self._cached_transport_encryption @@ -957,6 +1013,9 @@ class ConversationMessage: "renderer": None if self._cached_renderer is ConversationMessage._RENDERER_UNSET else self._cached_renderer, "has_attachments": self._cached_has_attachments, "attachment_names": self._cached_attachment_names, + "rssi": self._cached_rssi, + "snr": self._cached_snr, + "q": self._cached_q, } def restore_from_index(self, entry): @@ -974,6 +1033,9 @@ class ConversationMessage: self._cached_renderer = entry.get("renderer", ConversationMessage._RENDERER_UNSET) self._cached_has_attachments = entry.get("has_attachments") self._cached_attachment_names = entry.get("attachment_names") + self._cached_rssi = entry.get("rssi") + self._cached_snr = entry.get("snr") + self._cached_q = entry.get("q") @staticmethod def read_index(conversation_path): diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index f850909..5e72148 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -412,8 +412,9 @@ class DirectoryEntry: UNKNOWN = 0x02 TRUSTED = 0xFF - DIRECT = 0x01 - PROPAGATED = 0x02 + DIRECT = 0x01 + PROPAGATED = 0x02 + OPPORTUNISTIC = 0x03 def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None, notes=None): if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py index 84de154..c67636e 100644 --- a/nomadnet/ui/textui/Config.py +++ b/nomadnet/ui/textui/Config.py @@ -165,6 +165,7 @@ CONFIG_SECTIONS = [ "type": "edit", "default": "None", "placeholder": "None", + "validation": ["stamp_cost"], "help": "Required inbound stamp cost 1-255, or None to disable", }, { @@ -571,8 +572,9 @@ CONFIG_SECTIONS = [ { "config_key": "print_from", "label": "Print from: ", - "type": "edit", - "placeholder": "everywhere, trusted, or hashes", + "type": "list", + "default": [], + "placeholder": "everywhere, trusted, or hash", "help": "Which senders may trigger automatic printing", }, { @@ -704,8 +706,7 @@ class ConfigDisplayShortcuts(): class ConfigFiller(urwid.WidgetWrap): def __init__(self, widget, app): self.app = app - self.filler = urwid.Filler(widget, urwid.TOP) - super().__init__(self.filler) + super().__init__(widget) def keypress(self, size, key): if key == "ctrl w": @@ -963,7 +964,10 @@ class ConfigSectionView(urwid.WidgetWrap): if value: widget.set_value(value if isinstance(value, list) else [value]) else: - widget.edit_text = str(value) + if isinstance(value, (list, tuple)): + widget.edit_text = ", ".join(str(item) for item in value) + else: + widget.edit_text = str(value) def validate_all(self): all_valid = True @@ -974,8 +978,14 @@ class ConfigSectionView(urwid.WidgetWrap): def on_save(self, button): if not self.validate_all(): + self.show_message("Some fields have invalid values.\nCorrect the marked fields and try again.", title="Not saved", back_to_list=False) return + try: + self.file_config.reload() + except Exception: + pass + if self.section_key not in self.file_config: self.file_config[self.section_key] = {} @@ -1004,10 +1014,15 @@ class ConfigSectionView(urwid.WidgetWrap): try: self.file_config.write() + if self.section.get("configfile") == "reticulum": + try: + self.parent.app.rns.config.reload() + except Exception: + pass self.parent.mark_restart_pending() self.show_message(f"{self.section['name']} configuration saved.\nRestart Nomad Network for changes to take effect.") except Exception as e: - self.show_message(f"Error saving configuration: {str(e)}", title="Error") + self.show_message(f"Error saving configuration: {str(e)}", title="Error", back_to_list=False) def on_reset(self, button=None): self._confirm( @@ -1081,9 +1096,12 @@ class ConfigSectionView(urwid.WidgetWrap): self.parent.widget = self self.parent.app.ui.main_display.update_active_sub_display() - def show_message(self, message, title="Notice"): - def dismiss_dialog(button): - self.parent.switch_to_list() + def show_message(self, message, title="Notice", back_to_list=True): + def dismiss_dialog(button=None): + if back_to_list: + self.parent.switch_to_list() + else: + self._dismiss_overlay() dialog = DialogLineBox( urwid.Pile([ @@ -1091,22 +1109,11 @@ class ConfigSectionView(urwid.WidgetWrap): urwid.Divider(), urwid.Button("OK", on_press=dismiss_dialog) ]), + parent=self, title=title ) - overlay = urwid.Overlay( - dialog, - self.parent.list_view, - align=urwid.CENTER, - width=60, - valign=urwid.MIDDLE, - height=10, - min_width=1, - min_height=1 - ) - - self.parent.widget = overlay - self.parent.app.ui.main_display.update_active_sub_display() + self._show_overlay(dialog, height=10, width=60) def keypress(self, size, key): if key == "ctrl s": @@ -1153,8 +1160,6 @@ class ConfigDisplay(): self.widget = self.list_view def _build_list_view(self): - self.terminal_cols, self.terminal_rows = _get_cols_rows() - self.section_items = [] for section in CONFIG_SECTIONS: icon = _get_section_icon(self.glyphset, section["glyph"]) @@ -1169,12 +1174,9 @@ class ConfigDisplay(): self.list_walker = urwid.SimpleFocusListWalker(list_contents) self.list_box = urwid.ListBox(self.list_walker) - self.list_rows = max(5, self.terminal_rows - 7) - self.box_adapter = urwid.BoxAdapter(self.list_box, self.list_rows) - self.header_text = urwid.Text("") self.list_divider = urwid.Divider("─") - self.list_pile = urwid.Pile([self.box_adapter]) + self.list_pile = urwid.Pile([('weight', 1, self.list_box)]) self._update_header() self.list_view = ConfigFiller(self.list_pile, self.app) @@ -1185,7 +1187,7 @@ class ConfigDisplay(): self.header_text.set_text(("warning_text", "Changes saved. Restart NomadNet for them to take effect")) contents.append((self.header_text, self.list_pile.options('pack'))) contents.append((self.list_divider, self.list_pile.options('pack'))) - contents.append((self.box_adapter, self.list_pile.options('weight', 1))) + contents.append((self.list_box, self.list_pile.options('weight', 1))) self.list_pile.contents = contents def mark_restart_pending(self): @@ -1200,6 +1202,7 @@ class ConfigDisplay(): def switch_to_list(self): self.shortcuts_display.reset_shortcuts() + self.editor_term = None self._update_header() self.widget = self.list_view self.app.ui.main_display.update_active_sub_display() diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index bb0fe21..516afaa 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -72,13 +72,13 @@ class ConversationDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-s] Save [Tab] ↑ Messages"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-v] Delivery [C-r] Details [C-s] Save [Tab] ↑ Messages"), "shortcutbar") class ConversationBodyShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-s] Save [C-u] Purge [C-o] Sort [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-r] Details [C-v] Delivery [C-o] Sort [C-s] Save [C-u] Purge [C-k] Cancel Send [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar") class TabButton(urwid.Button): button_left = urwid.Text("[") @@ -562,9 +562,10 @@ class ConversationsDisplay(): def delete_selected_conversation(self): self.dialog_open = True item = self.ilb.get_selected_item() - if item == None: + source_hash = getattr(item, "source_hash", None) if item is not None else None + if source_hash is None: + self.dialog_open = False return - source_hash = item.source_hash def dismiss_dialog(sender): self.dialog_open = False @@ -839,8 +840,9 @@ class ConversationsDisplay(): unknown_selected = True trusted_selected = False - direct_selected = True - propagated_selected = False + direct_selected = True + propagated_selected = False + opportunistic_selected = False pinned_initial = False notes_initial = "" @@ -862,9 +864,13 @@ class ConversationsDisplay(): unknown_selected = False trusted_selected = True - if self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) == DirectoryEntry.PROPAGATED: + preferred = self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) + if preferred == DirectoryEntry.PROPAGATED: direct_selected = False propagated_selected = True + elif preferred == DirectoryEntry.OPPORTUNISTIC: + direct_selected = False + opportunistic_selected = True pinned_initial = existing_entry.sort_rank is not None notes_initial = getattr(existing_entry, "notes", "") or "" @@ -881,8 +887,9 @@ class ConversationsDisplay(): r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) method_button_group = [] - r_direct = urwid.RadioButton(method_button_group, "Deliver directly", state=direct_selected) - r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected) + r_direct = urwid.RadioButton(method_button_group, "Deliver over direct link", state=direct_selected) + r_opportunistic = urwid.RadioButton(method_button_group, "Deliver opportunistically", state=opportunistic_selected) + r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected) def dismiss_dialog(sender): self.dialog_open = False @@ -901,6 +908,8 @@ class ConversationsDisplay(): delivery = DirectoryEntry.DIRECT if r_propagated.state == True: delivery = DirectoryEntry.PROPAGATED + elif r_opportunistic.state == True: + delivery = DirectoryEntry.OPPORTUNISTIC sort_rank = 0 if cb_pin.state else None notes_value = e_notes.get_edit_text() @@ -972,6 +981,7 @@ class ConversationsDisplay(): r_trusted, urwid.Divider(g["divider1"]), r_direct, + r_opportunistic, r_propagated, urwid.Divider(g["divider1"]), cb_pin, @@ -1037,7 +1047,7 @@ class ConversationsDisplay(): elif r_trusted.state == True: trust_level = DirectoryEntry.TRUSTED - if not source_hash in [c[0] for c in existing_conversations]: + if not source_hash_text in [c[0] for c in existing_conversations]: entry = DirectoryEntry(source_hash, display_name, trust_level) self.app.directory.remember(entry) @@ -1048,7 +1058,7 @@ class ConversationsDisplay(): if trust_level != DirectoryEntry.TRUSTED: if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED: self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED) - self.display_conversation(source_hash_text) + self.display_conversation(None, source_hash_text) self.dialog_open = False self.update_conversation_list() @@ -1595,7 +1605,7 @@ class ConversationsDisplay(): if selected_hash is not None: for idx, widget in enumerate(self.list_widgets): - if widget.source_hash == selected_hash: + if getattr(widget, "source_hash", None) == selected_hash: self.ilb.select_item(idx) break nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() @@ -1632,7 +1642,7 @@ class ConversationsDisplay(): conversation_position = None index = 0 for widget in self.list_widgets: - if widget.source_hash == source_hash: + if getattr(widget, "source_hash", None) == source_hash: conversation_position = index index += 1 @@ -1790,6 +1800,24 @@ class ListEntry(urwid.Text): self._emit('click') return True +LXM_STATE_NAMES = { + LXMF.LXMessage.GENERATING: "Generating", + LXMF.LXMessage.OUTBOUND: "Outbound", + LXMF.LXMessage.SENDING: "Sending", + LXMF.LXMessage.SENT: "Sent", + LXMF.LXMessage.DELIVERED: "Delivered", + LXMF.LXMessage.REJECTED: "Rejected", + LXMF.LXMessage.CANCELLED: "Cancelled", + LXMF.LXMessage.FAILED: "Failed", +} + +LXM_METHOD_NAMES = { + LXMF.LXMessage.OPPORTUNISTIC: "Opportunistic", + LXMF.LXMessage.DIRECT: "Direct link", + LXMF.LXMessage.PROPAGATED: "Propagation node", + LXMF.LXMessage.PAPER: "Paper", +} + class MessageEdit(ReadlineMixin, urwid.Edit): def keypress(self, size, key): if key == "ctrl d": @@ -1800,6 +1828,13 @@ class MessageEdit(ReadlineMixin, urwid.Edit): self.delegate.attach_file() elif key == "ctrl s": self.delegate.save_focused_attachments() + elif key == "ctrl v": + self.delegate.cycle_delivery_method() + elif key == "ctrl r": + self.delegate.toggle_message_details() + elif key == "ctrl k": + if not self.delegate.cancel_pending_send(): + return super(MessageEdit, self).keypress(size, key) elif key == "up": y = self.get_cursor_coords(size)[1] if y == 0: @@ -1858,6 +1893,8 @@ class ConversationFrame(urwid.Frame): return super(ConversationFrame, self).keypress(size, key) class ConversationWidget(urwid.WidgetWrap): + MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024 + def __init__(self, source_hash, delegate): self.app = nomadnet.NomadNetworkApp.get_shared_instance() g = self.app.ui.glyphs @@ -1876,6 +1913,7 @@ class ConversationWidget(urwid.WidgetWrap): self.sort_by_timestamp = False self.pending_attachments = [] self.dialog_active = False + self.show_message_details = False self.update_message_widgets() @@ -2096,9 +2134,18 @@ class ConversationWidget(urwid.WidgetWrap): else: hops_str = str(hops)+" hop" + ("s" if hops != 1 else "") + preferred = self.app.directory.preferred_delivery(source_hash_bytes) + if preferred == DirectoryEntry.PROPAGATED: + via_str = "Propagation" + elif preferred == DirectoryEntry.OPPORTUNISTIC: + via_str = "Opportunistic" + else: + via_str = "Link" + right_parts = [] if stamp_cost is not None: right_parts.append("Stamp: "+str(stamp_cost)) + right_parts.append("Via: "+via_str) right_parts.append(g["speed"]+hops_str) left = " "+display_name @@ -2174,13 +2221,14 @@ class ConversationWidget(urwid.WidgetWrap): if self.frame: allowed = nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)) if allowed: + self.conversation.ensure_send_destination() self.frame.contents["footer"] = (self._build_footer(), None) else: warning = urwid.AttrMap( urwid.Padding(urwid.Text( "\n"+g["info"]+"\n\nYou cannot currently message this peer, since its identity keys are not known. " "The keys have been requested from the network and should arrive shortly, if available. " - "Close this conversation and reopen it to try again.\n\n" + "The message editor will appear here as soon as the keys arrive.\n\n" "To query the network manually, select this conversation in the conversation list, " "press Ctrl-E, and use the query button.\n", align=urwid.CENTER, @@ -2188,6 +2236,34 @@ class ConversationWidget(urwid.WidgetWrap): "msg_header_caution", ) self.frame.contents["footer"] = (warning, None) + self._schedule_editor_recheck() + + def _schedule_editor_recheck(self): + if getattr(self, "_editor_recheck_pending", False): + return + try: + loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop + if loop is None: + return + self._editor_recheck_pending = True + loop.set_alarm_in(1.0, self._editor_recheck) + except Exception: + self._editor_recheck_pending = False + + def _editor_recheck(self, loop=None, user_data=None): + self._editor_recheck_pending = False + if not self.frame: + return + if getattr(self.delegate, "currently_displayed_conversation", None) != self.source_hash: + return + if nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)): + self.check_editor_allowed() + try: + loop.draw_screen() + except Exception: + pass + else: + self._schedule_editor_recheck() def toggle_focus_area(self): name = "" @@ -2213,6 +2289,8 @@ class ConversationWidget(urwid.WidgetWrap): elif key == "ctrl u": self.conversation.purge_failed() self.conversation_changed(None) + elif key == "ctrl k": + self.cancel_pending_send() elif key == "ctrl t": self.toggle_editor() elif key == "ctrl x": @@ -2226,9 +2304,61 @@ class ConversationWidget(urwid.WidgetWrap): self.attach_file() elif key == "ctrl s": self.save_focused_attachments() + elif key == "ctrl v": + self.cycle_delivery_method() + elif key == "ctrl r": + self.toggle_message_details() else: return key + def toggle_message_details(self): + self.show_message_details = not self.show_message_details + self.update_message_widgets(replace=True) + + def _message_is_pending(self, conv_message): + try: + src = conv_message._cached_source_hash + state = conv_message.get_state() + except Exception: + return False + is_outbound = src is not None and self.app.lxmf_destination.hash == src + return is_outbound and state is not None and state < LXMF.LXMessage.SENT + + def cancel_message(self, conv_message): + if conv_message is None: + return + try: + self.app.message_router.cancel_outbound(conv_message.get_hash()) + conv_message._cached_state = LXMF.LXMessage.FAILED + except Exception as e: + RNS.log("Could not cancel outbound message: "+str(e), RNS.LOG_ERROR) + self.conversation_changed(None) + + def cancel_pending_send(self): + for widget in reversed(self.message_widgets): + conv_message = getattr(widget, "conv_message", None) + if conv_message is not None and self._message_is_pending(conv_message): + self.cancel_message(conv_message) + return True + return False + + def cycle_delivery_method(self): + source_hash_bytes = bytes.fromhex(self.source_hash) + order = [DirectoryEntry.DIRECT, DirectoryEntry.OPPORTUNISTIC, DirectoryEntry.PROPAGATED] + current = self.app.directory.preferred_delivery(source_hash_bytes) + try: + selected = order[(order.index(current) + 1) % len(order)] + except ValueError: + selected = order[0] + entry = self.app.directory.find(source_hash_bytes) + if entry is None: + display_name = self.app.directory.display_name(source_hash_bytes) + entry = DirectoryEntry(source_hash_bytes, display_name, DirectoryEntry.UNKNOWN, preferred_delivery=selected) + else: + entry.preferred_delivery = selected + self.app.directory.remember(entry) + self._update_peer_info() + def _on_conversation_changed_from_callback(self, conversation): self.delegate._wake(lambda: self.conversation_changed(conversation)) @@ -2238,6 +2368,14 @@ class ConversationWidget(urwid.WidgetWrap): self.update_message_widgets(replace = True) def update_message_widgets(self, replace = False): + prev_pos = None + prev_count = len(self.message_widgets) if getattr(self, "message_widgets", None) else 0 + if getattr(self, "messagelist", None) is not None: + try: + prev_pos = self.messagelist.get_selected_position() + except Exception: + prev_pos = None + self.message_widgets = [] added_hashes = set() needs_index = [] @@ -2270,10 +2408,19 @@ class ConversationWidget(urwid.WidgetWrap): self.message_widgets.sort(key=lambda m: m.sort_timestamp, reverse=False) from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox - self.messagelist = IndicativeListBox(self.message_widgets, position = len(self.message_widgets)-1) + new_count = len(self.message_widgets) + if prev_pos is None or prev_count == 0 or prev_pos >= prev_count - 1: + position = max(0, new_count - 1) + else: + position = min(prev_pos, max(0, new_count - 1)) + self.messagelist = IndicativeListBox(self.message_widgets, position = position) self.messagelist.name = "messagelist" if replace: - self.frame.contents["body"] = (self.messagelist, None) + current_body = self.frame.contents["body"][0] + if self.dialog_active and isinstance(current_body, urwid.Overlay): + current_body.bottom_w = self.messagelist + else: + self.frame.contents["body"] = (self.messagelist, None) nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() @@ -2423,6 +2570,7 @@ class ConversationWidget(urwid.WidgetWrap): def attach_file(self): self.dialog_active = True + initial_attachments = list(self.pending_attachments) def on_change(selected): self.pending_attachments[:] = selected @@ -2434,7 +2582,7 @@ class ConversationWidget(urwid.WidgetWrap): self.file_browser_closed() def on_cancel(): - self.pending_attachments.clear() + self.pending_attachments[:] = initial_attachments self.frame.contents["footer"] = (self._build_footer(), None) self.file_browser_closed() @@ -2445,6 +2593,7 @@ class ConversationWidget(urwid.WidgetWrap): title="Attach File", multi_select=True, selected=self.pending_attachments, + max_file_size=ConversationWidget.MAX_ATTACHMENT_SIZE, ) bottom = self.messagelist overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) @@ -2588,6 +2737,7 @@ class LXMessageWidget(urwid.WidgetWrap): app = nomadnet.NomadNetworkApp.get_shared_instance() g = app.ui.glyphs self._conversation_widget = conversation_widget + self.conv_message = message self.timestamp = message.get_timestamp() self.sort_timestamp = message.sort_timestamp self.transfer_done = False @@ -2719,12 +2869,58 @@ class LXMessageWidget(urwid.WidgetWrap): pile_widgets = [title] + if conversation_widget is not None and getattr(conversation_widget, "show_message_details", False): + def _safe(fn): + try: return fn() + except Exception: return None + + def _detail_row(glyph, values): + return urwid.Columns([ + (3, urwid.Text(("body_text", glyph), align=urwid.RIGHT)), + urwid.Text(("inactive_text", " · ".join(values))), + ], dividechars=1) + + detail_rows = [] + + delivery = [] + if msg_method is not None: + delivery.append(LXM_METHOD_NAMES.get(msg_method, "Unknown")) + if is_outbound and msg_state is not None: + delivery.append(LXM_STATE_NAMES.get(msg_state, "Unknown")) + if delivery: + detail_rows.append(_detail_row(g.get("sent" if is_outbound else "peer", ">"), delivery)) + + signal = [] + snr = _safe(message.get_snr) + rssi = _safe(message.get_rssi) + q = _safe(message.get_q) + if snr is not None: signal.append("SNR "+str(round(snr, 1))+" dB") + if rssi is not None: signal.append("RSSI "+str(round(rssi, 1))+" dBm") + if q is not None: signal.append("Q "+str(round(q))+"%") + if signal: + detail_rows.append(_detail_row(g.get("speed", "~"), signal)) + + security = [] + sig = _safe(message.get_signature_description) + if sig: security.append(sig) + enc = _safe(message.get_transport_encryption) + if enc: security.append(str(enc)) + if security: + detail_rows.append(_detail_row(g.get("encrypted", "#"), security)) + + if msg_hash is not None: + short_hash = _safe(lambda: RNS.hexrep(msg_hash, delimit=False)) + if short_hash: + detail_rows.append(_detail_row(g.get("info", "i"), [short_hash])) + + if detail_rows: + pile_widgets.append(urwid.Padding(urwid.Pile(detail_rows), left=2, right=2)) + if is_outbound and msg_state is not None and msg_state < LXMF.LXMessage.SENT and msg_hash is not None: try: for pending in app.message_router.pending_outbound: if pending.hash == msg_hash: - if pending.representation == LXMF.LXMessage.RESOURCE: - self._live_lxm = pending + self._live_lxm = pending break except Exception: pass @@ -2738,7 +2934,16 @@ class LXMessageWidget(urwid.WidgetWrap): else: bar = "#" * filled + "-" * (bar_width - filled) self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") - pile_widgets.append(self.progress_attr) + if conversation_widget is not None: + cancel_icon = ClickableIcon(" "+g["cross"]+" Cancel ", + on_click=lambda cw=conversation_widget, cm=message: cw.cancel_message(cm)) + progress_row = urwid.Columns([ + ("weight", 1, self.progress_attr), + ("pack", urwid.AttrMap(cancel_icon, "msg_notice_caution")), + ], dividechars=1) + pile_widgets.append(progress_row) + else: + pile_widgets.append(self.progress_attr) self._start_progress_poll() if markdown: pile_widgets.append(content_pile) diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py index a035d0d..c9997a5 100644 --- a/nomadnet/ui/textui/Editor.py +++ b/nomadnet/ui/textui/Editor.py @@ -149,7 +149,10 @@ class MicronEdit(ReadlineEdit): class GutterEdit(MicronEdit): # MicronEdit with a wrap-aware line-number gutter on the left def _gutter_width(self, maxcol): - return max(3, len(str(self.edit_text.count("\n") + 1)) + 2) + gw = max(3, len(str(self.edit_text.count("\n") + 1)) + 2) + if maxcol - gw < 1: + return 0 + return gw def rows(self, size, focus=False): gw = self._gutter_width(size[0]) @@ -164,18 +167,24 @@ class GutterEdit(MicronEdit): def move_cursor_to_coords(self, size, x, y): gw = self._gutter_width(size[0]) - return super().move_cursor_to_coords((max(1, size[0] - gw),), max(0, x - gw), y) + if isinstance(x, int): + x = max(0, x - gw) + return super().move_cursor_to_coords((max(1, size[0] - gw),), x, y) def render(self, size, focus=False): maxcol = size[0] gw = self._gutter_width(maxcol) + if gw == 0: + return super().render(size, focus) tw = max(1, maxcol - gw) - + cheap = len(self.edit_text) > MAX_HIGHLIGHT_CHARS + wrap = "any" if cheap else "space" + if self.wrap != wrap: + self.set_wrap_mode(wrap) edit_canv = super().render((tw,), focus=False) h = edit_canv.rows() - cheap = len(self.edit_text) > MAX_HIGHLIGHT_CHARS rows = [] for i, line in enumerate(self.edit_text.split("\n"), 1): rows.append(str(i).rjust(gw - 1) + " ") @@ -578,11 +587,19 @@ class PageEditorDisplay(): d = os.path.dirname(path) or "." tmp = os.path.join(d, "." + os.path.basename(path) + ".tmp") try: + existing_mode = None + try: + existing_mode = os.stat(path).st_mode + except OSError: + existing_mode = None with open(tmp, "w", encoding="utf-8") as f: f.write(text) f.flush() try: os.fsync(f.fileno()) except Exception: pass + if existing_mode is not None: + try: os.chmod(tmp, existing_mode) + except Exception: pass os.replace(tmp, path) except Exception: try: os.remove(tmp) @@ -716,10 +733,13 @@ class PageEditorDisplay(): if self.current_path and self.editable: self.save_file(self.current_path) proceed() - name = os.path.basename(self.current_path) if self.current_path else "this file" + name = os.path.basename(self.current_path) if self.current_path else "this buffer" + if self.current_path and self.editable: + buttons = [("Save", save_then), ("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)] + else: + buttons = [("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)] self.show_dialog(ConfirmDialog("Unsaved changes", "Discard unsaved changes to %s?" % name, - [("Save", save_then), ("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)], - on_cancel=self.close_dialog)) + buttons, on_cancel=self.close_dialog)) def load_file(self, path): try: @@ -839,10 +859,18 @@ class PageEditorDisplay(): try: os.makedirs(os.path.dirname(target), exist_ok=True) os.rename(old, target) + if os.path.isfile(old + ".allowed"): + try: os.replace(old + ".allowed", target + ".allowed") + except Exception: pass + old_prefix = old + os.sep if self.current_path == old: self.current_path = target; self.update_title() - if old in self.expanded: - self.expanded.discard(old); self.expanded.add(target) + elif self.current_path and self.current_path.startswith(old_prefix): + self.current_path = target + self.current_path[len(old):]; self.update_title() + self.expanded = set( + target if p == old else (target + p[len(old):] if p.startswith(old_prefix) else p) + for p in self.expanded + ) self.refresh_tree(); self.save_state() self.set_status("renamed to " + os.path.basename(target)) except Exception as e: @@ -866,11 +894,16 @@ class PageEditorDisplay(): def _do_delete(self, path): self.close_dialog() try: + prefix = path + os.sep if os.path.isdir(path): - shutil.rmtree(path); self.expanded.discard(path) + shutil.rmtree(path) + self.expanded = set(p for p in self.expanded if p != path and not p.startswith(prefix)) else: os.remove(path) - if self.current_path == path: + if os.path.isfile(path + ".allowed"): + try: os.remove(path + ".allowed") + except Exception: pass + if self.current_path is not None and (self.current_path == path or self.current_path.startswith(prefix)): self.current_path = None; self.editable = False; self.is_exec = False; self.dirty = False self._loading = True; self.editor.set_edit_text(""); self._loading = False self.update_title(); self.set_preview_note("") @@ -1016,7 +1049,7 @@ class PageEditorDisplay(): self.body.original_widget = self.columns def on_edit_change(self, widget, old_text): - if self._loading or not self.editable: + if self._loading or (self.current_path is not None and not self.editable): return self.dirty = True self.set_modified(True) diff --git a/nomadnet/ui/textui/FileBrowser.py b/nomadnet/ui/textui/FileBrowser.py index 5a9863a..f80a4f6 100644 --- a/nomadnet/ui/textui/FileBrowser.py +++ b/nomadnet/ui/textui/FileBrowser.py @@ -46,11 +46,12 @@ class FileBrowserEntry(urwid.WidgetWrap): return False class FileBrowser(urwid.WidgetWrap): - def __init__(self, on_done=None, on_cancel=None, on_change=None, title="Select File", multi_select=True, start_path=None, selected=None): + def __init__(self, on_done=None, on_cancel=None, on_change=None, title="Select File", multi_select=True, start_path=None, selected=None, max_file_size=None): self.on_done_cb = on_done self.on_cancel_cb = on_cancel self.on_change_cb = on_change self.multi_select = multi_select + self.max_file_size = max_file_size self.selected = list(selected) if selected else [] app = nomadnet.NomadNetworkApp.get_shared_instance() @@ -81,7 +82,26 @@ class FileBrowser(urwid.WidgetWrap): self._populate() self.browser_frame = urwid.Frame(self.file_listbox, header=header_pile, footer=footer_pile) - super().__init__(urwid.LineBox(self.browser_frame, title=title)) + self._main_widget = urwid.LineBox(self.browser_frame, title=title) + self._notice_open = False + super().__init__(self._main_widget) + + def _show_notice(self, message, title="Notice"): + def dismiss(_b=None): + self._notice_open = False + self._w = self._main_widget + + box = urwid.LineBox(urwid.Pile([ + urwid.Text(message, align=urwid.CENTER), + urwid.Divider(), + urwid.Padding(urwid.Button("OK", on_press=dismiss), align=urwid.CENTER, width=8), + ]), title=title) + self._notice_open = True + self._w = urwid.Overlay(box, self._main_widget, align=urwid.CENTER, width=("relative", 75), valign=urwid.MIDDLE, height=urwid.PACK) + + def _dismiss_notice(self): + self._notice_open = False + self._w = self._main_widget def _update_status(self): if self.selected: @@ -157,6 +177,20 @@ class FileBrowser(urwid.WidgetWrap): self._populate() return + if self.max_file_size is not None and entry.full_path not in self.selected: + try: + file_size = os.path.getsize(entry.full_path) + except Exception: + file_size = 0 + if file_size > self.max_file_size: + limit_mb = self.max_file_size / (1024 * 1024) + size_mb = file_size / (1024 * 1024) + self._show_notice( + "%s is %.1f MB.\nAttachments are limited to %.0f MB." % (entry.name, size_mb, limit_mb), + title="File too large", + ) + return + if self.multi_select: if entry.full_path in self.selected: self.selected.remove(entry.full_path) @@ -182,6 +216,11 @@ class FileBrowser(urwid.WidgetWrap): self.on_cancel_cb() def keypress(self, size, key): + if self._notice_open: + if key in ("esc", "enter"): + self._dismiss_notice() + return None + return super().keypress(size, key) if key == "esc": self._cancel(None) return None diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 9eb8ed1..63ed0f2 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -201,7 +201,10 @@ class MainDisplay(): return active = self.sub_displays.active_display - if active in (self.sub_displays.log_display, self.sub_displays.config_display): + if active is self.sub_displays.log_display: + self.do_quit() + return + if active is self.sub_displays.config_display and self.sub_displays.config_display.editor_term is not None: self.do_quit() return diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 3818f6c..8332c73 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -164,7 +164,7 @@ class AnnounceInfo(urwid.WidgetWrap): display_name = op_str if not source_hash_text in [c[0] for c in existing_conversations]: - entry = DirectoryEntry(source_hash, display_name, trust_level) + entry = DirectoryEntry(op_hash, display_name, trust_level) self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) @@ -716,7 +716,7 @@ class KnownNodeInfo(urwid.WidgetWrap): display_name = op_str if not source_hash_text in [c[0] for c in existing_conversations]: - entry = DirectoryEntry(source_hash, display_name, trust_level) + entry = DirectoryEntry(op_hash, display_name, trust_level) self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) @@ -1532,13 +1532,19 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_pages, self.t_total_files, urwid.Divider(g["divider1"]), - urwid.Pile([ - urwid.Button("Peer Info", on_press=show_peer_info), - connect_button, - edit_button, - reset_button, - announce_button, - qr_button, + urwid.Columns([ + (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 6, connect_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 8, reset_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 7, announce_button), + ]), + urwid.Columns([ + (urwid.WEIGHT, 4, edit_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 9, qr_button), ]) ]) else: @@ -1553,13 +1559,19 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_pages, self.t_total_files, urwid.Divider(g["divider1"]), - urwid.Pile([ - urwid.Button("Peer Info", on_press=show_peer_info), - connect_button, - edit_button, - reset_button, - announce_button, - qr_button, + urwid.Columns([ + (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 6, connect_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 8, reset_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 7, announce_button), + ]), + urwid.Columns([ + (urwid.WEIGHT, 4, edit_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 9, qr_button), ]) ]) else: diff --git a/nomadnet/ui/textui/Transport.py b/nomadnet/ui/textui/Transport.py index dbc7c69..aaed7bb 100644 --- a/nomadnet/ui/textui/Transport.py +++ b/nomadnet/ui/textui/Transport.py @@ -1,11 +1,7 @@ import RNS import time -import nomadnet import urwid -def _get_cols_rows(): - return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() - def _fmt_secs(secs): secs = int(secs) if secs < 0: @@ -142,7 +138,7 @@ class LazyTableWalker(urwid.ListWalker): class TransportFiller(urwid.WidgetWrap): def __init__(self, widget, display): self.display = display - super().__init__(urwid.Filler(widget, urwid.TOP)) + super().__init__(widget) def keypress(self, size, key): if key == "tab": @@ -173,7 +169,7 @@ class TransportDisplay: HASH_W = 34 HOPS_W = 6 - VIA_W = 20 + VIA_W = 34 EXP_W = 12 LAST_W = 16 VIOL_W = 11 @@ -215,16 +211,13 @@ class TransportDisplay: urwid.AttrMap(self.filter_edit, "list_normal", focus_map="list_focus"), ], dividechars=1) - _cols, rows = _get_cols_rows() - self.box_adapter = urwid.BoxAdapter(self.list_box, max(3, rows - 10)) - self.pile = urwid.Pile([ ('pack', self.summary_text), ('pack', self._tab_bar()), ('pack', self.filter_row), ('pack', self._header_row()), ('pack', urwid.Divider("─")), - self.box_adapter, + ('weight', 1, self.list_box), ]) self.transport_view = TransportFiller(self.pile, self) @@ -429,12 +422,6 @@ class TransportDisplay: empty = urwid.Text(("inactive_text", empty_msg), align=urwid.CENTER) self.list_walker.configure(data, factory, empty) - try: - _cols, rows = _get_cols_rows() - self.box_adapter.height = max(3, rows - 10) - except Exception: - pass - def cycle_tab(self): order = ["paths", "rates", "interfaces"] i = order.index(self.active_tab) if self.active_tab in order else 0 @@ -492,12 +479,13 @@ class TransportDisplay: self.app.ui.loop.set_alarm_in(self.POLL_INTERVAL, self._poll) def _poll(self, loop, user_data): - if self._is_visible(): - self._refresh_data() - self._rebuild() - try: - loop.draw_screen() - except Exception: - pass - if self.started: - loop.set_alarm_in(self.POLL_INTERVAL, self._poll) + if not self.started or not self._is_visible(): + self.poll_scheduler = False + return + self._refresh_data() + self._rebuild() + try: + loop.draw_screen() + except Exception: + pass + loop.set_alarm_in(self.POLL_INTERVAL, self._poll) diff --git a/nomadnet/ui/textui/Utilities.py b/nomadnet/ui/textui/Utilities.py index 549c0c7..771cd0b 100644 --- a/nomadnet/ui/textui/Utilities.py +++ b/nomadnet/ui/textui/Utilities.py @@ -6,7 +6,7 @@ import threading import nomadnet import urwid -from .Transport import TransportDisplay, _get_cols_rows +from .Transport import TransportDisplay from .ReadlineEdit import ReadlineEdit from .FileBrowser import FileBrowser from nomadnet.vendor.additional_urwid_widgets.FormWidgets import Dropdown @@ -145,15 +145,11 @@ class UtilitiesDisplay: ] self.landing_listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) - _cols, rows = _get_cols_rows() - box = urwid.BoxAdapter(self.landing_listbox, max(4, rows - 6)) - - pile = urwid.Pile([ + self.landing_view = urwid.Pile([ ('pack', urwid.Text(("body_text", "Select a utility to open."), align=urwid.CENTER)), ('pack', urwid.Divider("─")), - box, + ('weight', 1, self.landing_listbox), ]) - self.landing_view = urwid.Filler(pile, urwid.TOP) def shortcuts(self): if self.active == "transport" and self.transport is not None: @@ -467,17 +463,21 @@ class ProbeView: receipt.set_delivery_callback(self._on_delivered) except Exception: pass - self.app.ui.loop.set_alarm_in(self._timeout(), self._on_timeout) + self.app.ui.loop.set_alarm_in(self._timeout(), self._on_timeout, user_data=receipt) def _on_delivered(self, receipt): self._schedule(lambda: self._conclude(receipt, True)) def _on_timeout(self, loop, user_data): - self._conclude(self._cur_receipt, False) + if user_data is not self._cur_receipt: + return + self._conclude(user_data, False) def _conclude(self, receipt, delivered): if self._concluded: return + if receipt is not self._cur_receipt: + return self._concluded = True is_delivered = delivered @@ -851,7 +851,7 @@ class IdentityView: loop.set_alarm_in(0.25, self._await_identity) def _show_identity(self, identity): - self._log("kno wn %s resolves to identity %s" % ( + self._log("%s resolves to identity %s" % ( RNS.prettyhexrep(self._dest_hash), RNS.prettyhexrep(identity.hash)), "connected_status") RNCP_APP_NAME = "rncp" @@ -1103,7 +1103,7 @@ class SendFileView: self.progress_text.set_text(("connected_status", "Transferring… 100%")) self._log("Transfer complete", "connected_status") else: - self._log("Transfer failed or the file was not accepted ,", "warning_text") + self._log("Transfer failed or the file was not accepted", "warning_text") try: if self._link is not None: self._link.teardown() @@ -1127,6 +1127,7 @@ class SendFileView: SPEEDTEST_APP_NAME = "nomadnetwork" SPEEDTEST_ASPECTS = ("utilities", "speedtest") +SPEEDTEST_FULL_NAME = ".".join((SPEEDTEST_APP_NAME,) + SPEEDTEST_ASPECTS) SPEEDTEST_INMEM_MAX = 8 * 1024 * 1024 SPEEDTEST_SIZES = [ @@ -1192,6 +1193,7 @@ class SpeedTestView: self._client_resource = None self._send_started = None self._send_bytes = 0 + self._send_seq = 0 self._tmppath = None self.hash_edit = ReadlineEdit("", "") @@ -1257,9 +1259,9 @@ class SpeedTestView: self.listen_status.set_text(("connected_status", "%s %s Receiving test…" % (dot, self.g.get("arrow_d", "v")))) elif self._listening and self.server_destination is not None: if self._peer_hash_entered(): - self.listen_status.set_text(("connected_status", "%s Listening and ready to receive or send to from entered peer" % dot)) + self.listen_status.set_text(("connected_status", "%s Listening, ready to receive from or send to the entered peer" % dot)) else: - self.listen_status.set_text(("warning_text", "%s Listening enter the peer's hash to accept their test" % dot)) + self.listen_status.set_text(("warning_text", "%s Listening, enter the peer's hash to accept their test" % dot)) else: self.listen_status.set_text(("inactive_text", "Not listening")) @@ -1361,13 +1363,13 @@ class SpeedTestView: except Exception: return False - def _peer_identity(self): + def _entered_peer_hash(self): hexhash = self.hash_edit.edit_text.strip() dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 if len(hexhash) != dest_len: return None try: - return RNS.Identity.recall(bytes.fromhex(hexhash)) + return bytes.fromhex(hexhash) except Exception: return None @@ -1414,8 +1416,12 @@ class SpeedTestView: self._schedule(lambda: self._verify_incoming(link, identity)) def _verify_incoming(self, link, identity): - peer_identity = self._peer_identity() - if peer_identity is None or peer_identity.hash != identity.hash: + expected_hash = self._entered_peer_hash() + try: + identified_hash = RNS.Destination.hash_from_name_and_identity(SPEEDTEST_FULL_NAME, identity) + except Exception: + identified_hash = None + if expected_hash is None or identified_hash is None or identified_hash != expected_hash: self._log("Rejected speed test from unauthorized peer", "warning_text") try: link.teardown() @@ -1437,15 +1443,28 @@ class SpeedTestView: def _recv_started_now(self, resource): self._recv_resource = resource - self._recv_started = time.time() - self._recv_polling = True + if self._recv_started is None: + self._recv_started = time.time() self.rate_text.set_text(("body_text", "Receiving…")) self._update_listen_status() - self.app.ui.loop.set_alarm_in(0.3, self._recv_poll) + if not self._recv_polling: + self._recv_polling = True + self.app.ui.loop.set_alarm_in(0.3, self._recv_poll) def _recv_poll(self, loop, user_data): if not self._recv_polling or self._recv_resource is None: return + failed = False + try: + if self._recv_resource.status in (RNS.Resource.FAILED, RNS.Resource.CORRUPT): + failed = True + elif self._recv_link is not None and self._recv_link.status == RNS.Link.CLOSED: + failed = True + except Exception: + pass + if failed: + self._recv_failed() + return try: pct = int(self._recv_resource.get_progress() * 100) except Exception: @@ -1459,16 +1478,28 @@ class SpeedTestView: def _on_recv_concluded(self, resource): self._schedule(lambda: self._recv_concluded(resource)) - def _recv_concluded(self, resource): + def _recv_failed(self): self._recv_polling = False + self._log("Incoming transfer failed", "warning_text") + self.rate_text.set_text("") + self._recv_resource = None + self._recv_link = None + self._update_listen_status() + + def _recv_concluded(self, resource): try: complete = resource.status == RNS.Resource.COMPLETE except Exception: complete = False if not complete: - self._log("Incoming transfer failed", "warning_text") - self.rate_text.set_text("") + self._recv_failed() return + try: + if resource.segment_index < resource.total_segments: + return + except Exception: + pass + self._recv_polling = False size = self._resource_size(resource) elapsed = max(0.001, time.time() - (self._recv_started or time.time())) self._set_progress(100) @@ -1480,7 +1511,7 @@ class SpeedTestView: self._update_listen_status() def _resource_size(self, resource): - for getter in ("get_transfer_size", "get_data_size"): + for getter in ("get_data_size", "get_transfer_size"): try: fn = getattr(resource, getter, None) if fn: @@ -1500,7 +1531,6 @@ class SpeedTestView: return self.update_interface_status() - peer_identity = self._peer_identity() dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 hexhash = self.hash_edit.edit_text.strip() if len(hexhash) != dest_len: @@ -1515,6 +1545,7 @@ class SpeedTestView: label = self.size_dropdown.get_value() self._send_bytes = SPEEDTEST_BYTES_BY_LABEL.get(label, 1024 * 1024) + self._send_seq += 1 self._sending = True self.start_button.original_widget.set_label("Cancel") self._set_progress(0) @@ -1596,14 +1627,15 @@ class SpeedTestView: except Exception: pass self._log_link_mtu(link) - self.app.ui.loop.set_alarm_in(0.6, lambda l, u: self._prepare_and_send(link)) + seq = self._send_seq + self.app.ui.loop.set_alarm_in(0.6, lambda l, u: self._prepare_and_send(link, seq)) - def _prepare_and_send(self, link): - if not self._sending or self._client_resource is not None: + def _prepare_and_send(self, link, seq): + if not self._sending or seq != self._send_seq or self._client_resource is not None: return nbytes = self._send_bytes if nbytes <= SPEEDTEST_INMEM_MAX: - self._begin_resource(link, os.urandom(nbytes), None) + self._begin_resource(link, os.urandom(nbytes), None, seq) return self._log("Preparing %s payload …" % _size_str(nbytes)) @@ -1613,7 +1645,7 @@ class SpeedTestView: fd, path = tempfile.mkstemp(prefix="nn_speedtest_") written = 0 with os.fdopen(fd, "wb") as f: - while written < nbytes and self._sending: + while written < nbytes and self._sending and seq == self._send_seq: chunk = min(1024 * 1024, nbytes - written) f.write(os.urandom(chunk)) written += chunk @@ -1625,19 +1657,19 @@ class SpeedTestView: pass self._schedule(lambda: (self._log("Could not prepare payload: %s" % str(e), "error_text"), self._finish())) return - if not self._sending: + if not self._sending or seq != self._send_seq: if path: try: os.remove(path) except Exception: pass return - self._schedule(lambda: self._begin_resource(link, None, path)) + self._schedule(lambda: self._begin_resource(link, None, path, seq)) threading.Thread(target=worker, daemon=True).start() - def _begin_resource(self, link, data_bytes, tmppath): - if not self._sending: + def _begin_resource(self, link, data_bytes, tmppath, seq): + if not self._sending or seq != self._send_seq: if tmppath: try: os.remove(tmppath) @@ -1670,6 +1702,8 @@ class SpeedTestView: self._schedule(lambda: self._send_done(resource)) def _send_done(self, resource): + if not self._sending: + return try: complete = resource.status == RNS.Resource.COMPLETE except Exception: @@ -1702,6 +1736,7 @@ class SpeedTestView: def cancel(self, silent=False): if not self._sending: return + self._send_seq += 1 self._sending = False try: if self._client_resource is not None: @@ -1719,13 +1754,13 @@ class SpeedTestView: self._log("Speed test cancelled", "warning_text") self.rate_text.set_text("") self._set_progress(0) - self.start_button.original_widget.set_label("Start test") + self.start_button.original_widget.set_label("Send test file") self._update_listen_status() self._draw() def _finish(self): self._sending = False self._client_resource = None - self.start_button.original_widget.set_label("Start test") + self.start_button.original_widget.set_label("Send test file") self._update_listen_status() self._draw() diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py index fd69aa8..9ab5dea 100644 --- a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -139,9 +139,20 @@ class FormEdit(Placeholder, FormField): self.error = "This field is required" break elif validation == "number": - if value and not value.replace('-', '').replace('.', '').isdigit(): - self.error = "This field must be a number" + try: + if value: + int(value) + except ValueError: + self.error = "This field must be a whole number" break + elif validation == "stamp_cost": + if value and value.lower() != "none": + try: + if not 1 <= int(value) <= 255: + raise ValueError + except ValueError: + self.error = "This field must be 1-255 or None" + break elif validation == "float": try: if value: @@ -172,7 +183,7 @@ class FormCheckbox(urwid.CheckBox, FormField): for validation in self.validation_types: if validation == "required": if not value: - self.error = "This field is required" + self.error = "This field is required" break self.error_widget.set_text(("error", self.error or "")) From f07a03548bacfac396a890773357528127d17776 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien <956a74d715fe9cb2e9da0a19b067b414> Date: Sat, 11 Jul 2026 13:44:18 -0400 Subject: [PATCH 520/592] RRC: parse /who and /list notices delivered via resource transfer --- nomadnet/RRC.py | 77 ++++++++++++++++++++++++++++--------------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 5393973..70afa79 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -839,6 +839,46 @@ class RRCHub: self._append_history(target_room, msg) self._clean_history() + def _process_notice_text(self, text): + # Parse hub service notices (/list and /who replies) regardless of + # whether they arrived as a packet or a resource transfer. Returns + # True when the notice was consumed silently (auto /list or /who) + # and should not be recorded to the message log. + parsed = _parse_room_list_notice(text) + if parsed is not None: + with self._lock: + self.available_rooms = parsed + silent = self._silent_list_pending > 0 + if silent: + self._silent_list_pending -= 1 + self.manager._notify_change(self) + if silent: + return True + parsed_who = _parse_who_notice(text) + if parsed_who is not None: + who_room, who_entries = parsed_who + with self._lock: + members = self.members.setdefault(who_room, set()) + for nick, hash_hex in who_entries: + try: + hash_bytes = bytes.fromhex(hash_hex) + except Exception: + continue + if nick is None: + members.add(hash_bytes) + continue + for ph in members: + if ph.startswith(hash_bytes): + self.nicks[ph] = nick + break + silent_who = who_room in self._silent_who_rooms + if silent_who: + self._silent_who_rooms.discard(who_room) + self.manager._notify_change(self) + if silent_who: + return True + return False + def get_messages(self, room, take_lock=True): if take_lock: with self._lock: buf = list(self.messages.get(room, [])) @@ -1090,39 +1130,8 @@ class RRCHub: room = env.get(K_ROOM) src = env.get(K_SRC) if isinstance(body, str): - parsed = _parse_room_list_notice(body) - if parsed is not None: - with self._lock: - self.available_rooms = parsed - silent = self._silent_list_pending > 0 - if silent: - self._silent_list_pending -= 1 - self.manager._notify_change(self) - if silent: - return - parsed_who = _parse_who_notice(body) - if parsed_who is not None: - who_room, who_entries = parsed_who - with self._lock: - members = self.members.setdefault(who_room, set()) - for nick, hash_hex in who_entries: - try: - hash_bytes = bytes.fromhex(hash_hex) - except Exception: - continue - if nick is None: - members.add(hash_bytes) - continue - for ph in members: - if ph.startswith(hash_bytes): - self.nicks[ph] = nick - break - silent_who = who_room in self._silent_who_rooms - if silent_who: - self._silent_who_rooms.discard(who_room) - self.manager._notify_change(self) - if silent_who: - return + if self._process_notice_text(body): + return room_n = room.strip().lower() if isinstance(room, str) else None if room_n is None and isinstance(body, str) and body.strip(): with self._lock: @@ -1261,6 +1270,8 @@ class RRCHub: with self._lock: self.motd = text self.manager._notify_change(self) + elif self._process_notice_text(text): + return msg = RRCMessage("notice", room, None, None, text, _now_ms()) self._record_notice(msg) except Exception as e: From 510d476c0dfd7f72dd81715f4d323eb95ea0c752 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien <956a74d715fe9cb2e9da0a19b067b414> Date: Sun, 12 Jul 2026 10:16:29 -0400 Subject: [PATCH 521/592] RRC: configurable max accepted resource transfer size --- nomadnet/NomadNetworkApp.py | 15 +++++++++++++++ nomadnet/RRC.py | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 522a77c..50b4c6d 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -155,6 +155,7 @@ class NomadNetworkApp: self.rrc_history_per_room_cap = 500 self.rrc_filter_loaded_history = True + self.rrc_max_accepted_resource_size = 256*1024 self.rrc_ephemeral_notices = 600 self.rrc_nick_colors = True self.rrc_nick_colors_theme = None @@ -994,6 +995,12 @@ class NomadNetworkApp: except Exception: value = True self.rrc_filter_loaded_history = value + if option == "max_accepted_resource_size": + try: value = self.config["rrc"].as_int(option) + except Exception: value = None + if value is not None and value >= 0: + self.rrc_max_accepted_resource_size = value*1024 + if option == "ephemeral_notices": try: value = self.config["rrc"].as_float(option) except Exception: value = 0 @@ -1397,6 +1404,14 @@ history_per_room_cap = 500 # loaded. filter_loaded_history = yes +# Maximum size in kilobytes of a resource +# transfer that will be accepted from a +# hub. Lower this on low-bandwidth links +# to automatically reject transfers that +# would take too long to receive. Set to +# 0 to reject all resource transfers. +max_accepted_resource_size = 256 + # You can choose whether notices and sys # messages persist indefinitely, or are # removed from the message history after diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 70afa79..e764b3b 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -1213,7 +1213,12 @@ class RRCHub: size = getattr(resource, "size", 0) except Exception: return False - if size > 262144: + try: + max_size = int(getattr(self.manager.app, "rrc_max_accepted_resource_size", 262144)) + except Exception: + max_size = 262144 + if max_size <= 0 or size > max_size: + self._log("rejecting resource transfer of "+str(size)+" bytes (max accepted is "+str(max_size)+" bytes)", RNS.LOG_DEBUG) return False return True From 79879c4e77ffb30ea746b1b97b74dcbe4f3bbca9 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien <956a74d715fe9cb2e9da0a19b067b414> Date: Tue, 14 Jul 2026 09:12:07 -0400 Subject: [PATCH 522/592] Conversations: make "yesterday/N days ago" respect calendar days and not just hours --- nomadnet/ui/textui/Conversations.py | 18 +++++++++--------- nomadnet/ui/textui/Network.py | 9 +++++---- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index feaedc9..bd71d3b 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -38,16 +38,16 @@ def relative_time(timestamp): elif delta < 86400: h = int(delta / 3600) return str(h)+"h ago" - elif delta < 172800: - return "yesterday" - elif delta < 604800: - d = int(delta / 86400) - return str(d)+"d ago" - elif delta < 2592000: - w = int(delta / 604800) - return str(w)+"w ago" else: - return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d") + days = (datetime.fromtimestamp(now).date() - datetime.fromtimestamp(timestamp).date()).days + if days <= 1: + return "yesterday" + elif days < 7: + return str(days)+"d ago" + elif days < 30: + return str(days // 7)+"w ago" + else: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d") def _format_size(size): diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 0332139..db3cb57 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -1939,13 +1939,14 @@ def pretty_date(time=False): from datetime import datetime now = datetime.now() if type(time) is int: - diff = now - datetime.fromtimestamp(time) + then = datetime.fromtimestamp(time) elif isinstance(time,datetime): - diff = now - time + then = time elif not time: - diff = now - now + then = now + diff = now - then second_diff = diff.seconds - day_diff = diff.days + day_diff = (now.date() - then.date()).days if day_diff < 0: return '' From 85fd18a25f7df91ef08ac4cb901ad8a686aee5ad Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 14 Jul 2026 15:47:15 +0200 Subject: [PATCH 523/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 7f647d0..5cc179e 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.7" +__version__ = "1.2.8" From c99ce2b930f3f0d649b26b449cb88e813ea26827 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 20 Jul 2026 19:17:31 +0200 Subject: [PATCH 524/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 516254c..a4a010b 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.3.8", "lxmf>=1.0.1", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.4.0", "lxmf>=1.1.0", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From a686b8f3b752f9538a82c4b922c8c25c180538f1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 24 Jul 2026 00:05:25 +0200 Subject: [PATCH 525/592] Cleaned up annoying node auto-add on peer trusted. What was I thinking? --- nomadnet/Directory.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index f850909..ea2f21e 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -195,11 +195,11 @@ class Directory: self._node_announces.insert(0, (timestamp, source_hash, app_data, "node")) self._clean_node_announces() - if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: - existing_entry = self.find(source_hash) - if not existing_entry: - node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) - self.remember(node_entry) + # if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: + # existing_entry = self.find(source_hash) + # if not existing_entry: + # node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) + # self.remember(node_entry) if hasattr(self.app.ui, "main_display"): self.app.ui.main_display.sub_displays.network_display.directory_change_callback() From 270ccf7130245c8ddde6d68b0c9c42bdc1d9d00d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 24 Jul 2026 22:12:29 +0200 Subject: [PATCH 526/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a4a010b..12e9039 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.4.0", "lxmf>=1.1.0", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.4.1", "lxmf>=1.1.0", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From 89e3eea10c60d8fe597d36d2e091d5aab86bdfb8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 26 Jul 2026 18:02:56 +0200 Subject: [PATCH 527/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 12e9039..7cd0f91 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.4.1", "lxmf>=1.1.0", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.4.2", "lxmf>=1.1.0", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From 5d0ffce51ce94957429b6e34e4957e01c714128f Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:27:47 -0400 Subject: [PATCH 528/592] Atomic writes for config fields, don't overwrite intro_time, Node editor allow fixes --- nomadnet/InterfaceProfiles.py | 3 +- nomadnet/ui/textui/Config.py | 56 +++++++++++++--------- nomadnet/ui/textui/Editor.py | 82 ++++++++++++++++++++++++++------ nomadnet/ui/textui/Interfaces.py | 15 +++--- nomadnet/util.py | 33 +++++++++++++ 5 files changed, 143 insertions(+), 46 deletions(-) diff --git a/nomadnet/InterfaceProfiles.py b/nomadnet/InterfaceProfiles.py index a6f918c..254380b 100644 --- a/nomadnet/InterfaceProfiles.py +++ b/nomadnet/InterfaceProfiles.py @@ -3,6 +3,7 @@ import threading import RNS import RNS.vendor.umsgpack as msgpack +from nomadnet.util import write_config_atomic class InterfaceProfiles: @@ -178,7 +179,7 @@ class InterfaceProfiles: interfaces[name]["interface_enabled"] = value if "enabled" in interfaces[name]: interfaces[name]["enabled"] = value - self.app.rns.config.write() + write_config_atomic(self.app.rns.config) return True except Exception as e: RNS.log("Could not apply interface profile: "+str(e), RNS.LOG_ERROR) diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py index c67636e..7a0f771 100644 --- a/nomadnet/ui/textui/Config.py +++ b/nomadnet/ui/textui/Config.py @@ -4,6 +4,7 @@ import urwid import platform from RNS.vendor.configobj import ConfigObj +from nomadnet.util import write_config_atomic from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * CONFIG_SECTION_GLYPHS = { @@ -208,12 +209,11 @@ CONFIG_SECTIONS = [ "fields": [ { "config_key": "intro_time", - "label": "Show intro: ", - "type": "checkbox", - "default": True, - "on_value": "1.0", - "off_value": "0", - "help": "Show the intro screen at startup", + "label": "Intro time: ", + "type": "edit", + "default": "1", + "validation": ["float"], + "help": "Seconds to show the intro screen at startup (0 to disable)", }, { "config_key": "intro_text", @@ -976,6 +976,21 @@ class ConfigSectionView(urwid.WidgetWrap): all_valid = False return all_valid + def _widget_write_value(self, field, widget): + if field["type"] == "checkbox": + return field.get("on_value", "yes") if widget.get_state() else field.get("off_value", "no") + return widget.get_value() + + def _default_write_value(self, field): + default = field.get("default") + if field["type"] == "checkbox": + return field.get("on_value", "yes") if bool(default) else field.get("off_value", "no") + if default is None: + return "" + if isinstance(default, (list, tuple)): + return list(default) + return str(default) + def on_save(self, button): if not self.validate_all(): self.show_message("Some fields have invalid values.\nCorrect the marked fields and try again.", title="Not saved", back_to_list=False) @@ -986,7 +1001,8 @@ class ConfigSectionView(urwid.WidgetWrap): except Exception: pass - if self.section_key not in self.file_config: + section_existed = self.section_key in self.file_config + if not section_existed: self.file_config[self.section_key] = {} section_config = self.file_config[self.section_key] @@ -994,26 +1010,20 @@ class ConfigSectionView(urwid.WidgetWrap): for config_key, entry in self.fields.items(): field = entry["field"] widget = entry["widget"] + present = config_key in section_config + value = self._widget_write_value(field, widget) - if field["type"] == "checkbox": - on_value = field.get("on_value", "yes") - off_value = field.get("off_value", "no") - section_config[config_key] = on_value if widget.get_state() else off_value - elif field["type"] == "list": - value = widget.get_value() - if value: - section_config[config_key] = value - elif config_key in section_config: - del section_config[config_key] - else: - value = widget.get_value() - if value != "": - section_config[config_key] = value - elif config_key in section_config: + if not value: + if present: del section_config[config_key] + elif present or value != self._default_write_value(field): + section_config[config_key] = value + + if not section_existed and not section_config: + del self.file_config[self.section_key] try: - self.file_config.write() + write_config_atomic(self.file_config) if self.section.get("configfile") == "reticulum": try: self.parent.app.rns.config.reload() diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py index c9997a5..42d8203 100644 --- a/nomadnet/ui/textui/Editor.py +++ b/nomadnet/ui/textui/Editor.py @@ -912,36 +912,88 @@ class PageEditorDisplay(): except Exception as e: self.set_status("delete failed: %s" % e) + def _read_allowed(self, path): + ap = path + ".allowed" + if not os.path.isfile(ap): + return "" + try: + with open(ap, encoding="utf-8") as f: + return "\n".join(ln.strip() for ln in f.read().splitlines() if ln.strip()) + except Exception: + return "" + + def _walk_pages(self, path): + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + for fn in files: + if fn.endswith(".allowed"): + continue + yield os.path.join(root, fn) + + def _folder_permissions(self, path): + bodies = {} + total = 0 + for page in self._walk_pages(path): + total += 1 + body = self._read_allowed(page) + bodies[body] = bodies.get(body, 0) + 1 + restricted = sum(count for body, count in bodies.items() if body) + distinct = [body for body in bodies if body] + common = distinct[0] if len(bodies) == 1 and distinct else None + return common, restricted, total + def permissions_dialog(self): w = self._selected_tree() if not w or not getattr(w, "path", None): self.set_status("select a file/folder for permissions"); return path = w.path is_dir = os.path.isdir(path) - content = "" - if not is_dir and os.path.isfile(path + ".allowed"): - try: content = open(path + ".allowed", encoding="utf-8").read() - except Exception: content = "" - note = ("Allowed identity hashes per line. Empty = public.\n" - + ("Applies to every page in this folder." if is_dir else "Applys to this page.")) + + if is_dir: + common, restricted, total = self._folder_permissions(path) + content = common or "" + if not total: + state = "This folder contains no pages." + elif not restricted: + state = "All %d pages here are public." % total + elif common is not None: + state = "All %d pages here share these entries." % total + else: + state = "Entries differ across pages (%d of %d restricted)." % (restricted, total) + note = ("Allowed identity hashes per line.\n" + + state + "\nSaving applies these entries to every page in this folder and its subfolders.") + else: + content = self._read_allowed(path) + note = "Allowed identity hashes per line. Empty = public.\nApplies to this page." + title = "Permissions: " + os.path.basename(path) + ("/" if is_dir else "") self.show_dialog(PermissionsDialog(title, note, content, lambda txt: self._save_permissions(path, is_dir, txt), self.close_dialog)) def _save_permissions(self, path, is_dir, text): - self.close_dialog() body = "\n".join(ln.strip() for ln in text.splitlines() if ln.strip()) + + if is_dir and not body: + restricted = self._folder_permissions(path)[1] + if restricted: + self.show_dialog(ConfirmDialog("Make public", + "This removes the access restrictions from %d page(s)\nunder %s, making them public.\n\nContinue?" + % (restricted, os.path.basename(path)), + [("Make public", lambda: self._apply_permissions(path, is_dir, body)), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + return + + self._apply_permissions(path, is_dir, body) + + def _apply_permissions(self, path, is_dir, body): + self.close_dialog() try: if is_dir: count = 0 - for root, dirs, files in os.walk(path): - dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] - for fn in files: - if fn.endswith(".allowed"): - continue - self._write_allowed(os.path.join(root, fn), body) - count += 1 - self.set_status("permissions applied to %d files" % count) + for page in self._walk_pages(path): + self._write_allowed(page, body) + count += 1 + self.set_status(("permissions applied to %d files" if body else "permissions cleared on %d files") % count) else: self._write_allowed(path, body) self.set_status("permissions saved" if body else "permissions cleared (public)") diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index 0a74333..b3914ef 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -8,6 +8,7 @@ from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * from nomadnet.vendor.AsciiChart import AsciiChart from .ReadlineEdit import ReadlineEdit from .Helpers import ClickableIcon +from nomadnet.util import write_config_atomic ### GYLPHS ### INTERFACE_GLYPHS = { @@ -2066,7 +2067,7 @@ class AddInterfaceView(urwid.WidgetWrap): try: interfaces = self.parent.app.rns.config['interfaces'] interfaces[name] = interface_config - self.parent.app.rns.config.write() + write_config_atomic(self.parent.app.rns.config) self._apply_profile_membership(name) display_type = custom_type if self.iface_type == "CustomInterface" else self.iface_type @@ -2323,7 +2324,7 @@ class EditInterfaceView(AddInterfaceView): else: interfaces[self.iface_name] = updated_config - self.parent.app.rns.config.write() + write_config_atomic(self.parent.app.rns.config) self._apply_profile_membership(new_name) display_type = interface_type @@ -2798,7 +2799,7 @@ class ShowInterface(urwid.WidgetWrap): try: interfaces = self.parent.app.rns.config['interfaces'] interfaces[self.iface_name] = self.interface_config - self.parent.app.rns.config.write() + write_config_atomic(self.parent.app.rns.config) self.update_status_display() @@ -3742,7 +3743,7 @@ class InterfaceDisplay: try: for name, conf in parsed.items(): interfaces[name] = conf - self.app.rns.config.write() + write_config_atomic(self.app.rns.config) except Exception as e: self.paste_status.set_text(("error", " Failed: " + str(e))) return @@ -3765,7 +3766,7 @@ class InterfaceDisplay: try: if interface_name in self.app.rns.config['interfaces']: del self.app.rns.config['interfaces'][interface_name] - self.app.rns.config.write() + write_config_atomic(self.app.rns.config) try: self.app.interface_profiles.remove_interface(interface_name) except Exception: pass @@ -3959,7 +3960,7 @@ class InterfaceDisplay: if "enabled" in iface: iface["enabled"] = value try: - self.app.rns.config.write() + write_config_atomic(self.app.rns.config) except Exception: return self.mark_restart_pending() @@ -4038,7 +4039,7 @@ class InterfaceDisplay: changed += 1 try: - self.app.rns.config.write() + write_config_atomic(self.app.rns.config) except Exception as e: self._bulk_notice(f"Error applying bulk action: {str(e)}", title="Error") return diff --git a/nomadnet/util.py b/nomadnet/util.py index 51aa440..a7bd62c 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -1,6 +1,39 @@ +import os import re import unicodedata + +def write_config_atomic(config): + path = getattr(config, "filename", None) + if not path: + config.write() + return + + directory = os.path.dirname(path) or "." + tmp = os.path.join(directory, "."+os.path.basename(path)+".tmp") + try: + try: + existing_mode = os.stat(path).st_mode + except OSError: + existing_mode = None + + with open(tmp, "wb") as tmp_file: + config.write(tmp_file) + tmp_file.flush() + try: os.fsync(tmp_file.fileno()) + except Exception: pass + + if existing_mode is not None: + try: os.chmod(tmp, existing_mode) + except Exception: pass + + os.replace(tmp, path) + except Exception: + try: os.remove(tmp) + except Exception: pass + raise + + invalid_rendering = ["🕵️", "☝"] # Unicode blocks to strip (symbols, dingbats, emoji ranges) From 086d58513c5aa61fe801cf7befe0e4526275401c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 19 Aug 2026 22:20:41 +0200 Subject: [PATCH 529/592] Fixed propagation node announce --- nomadnet/Node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index acf54ff..28c816f 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -217,7 +217,7 @@ class Node: self.last_announce = time.time() self.app.peer_settings["node_last_announce"] = self.last_announce self.destination.announce(app_data=self.app_data) - self.app.message_router.announce_propagation_node() + if not self.app.disable_propagation: self.app.message_router.announce_propagation_node() def __jobs(self): while self.should_run_jobs: From 0aa71bf75b0fe959e9561e30e452dfb9c7635e07 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 28 Aug 2026 14:50:41 +0200 Subject: [PATCH 530/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7cd0f91..2979e56 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.4.2", "lxmf>=1.1.0", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.5.1", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From 9efd7fef2d14e10711cdd7ffd5a7fdf219986df5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 28 Aug 2026 17:05:36 +0200 Subject: [PATCH 531/592] Fixed entirely empty page response never appearing in browser. That's a weird commit message, ain't gonna change it. --- nomadnet/ui/textui/Browser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 90eaff4..d98ee23 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1761,8 +1761,8 @@ class Browser: if self.saved_file_name == None: resp_size = self.response_size else: resp_size = self.saved_file_size - if resp_size: - stats_string = " "+self.g["page"]+size_str(resp_size) + if resp_size: stats_string = " "+self.g["page"]+size_str(resp_size) + else: stats_string = " " if self.response_transfer_size: stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str if self.response_transfer_size and self.response_time: From 17b4cc498e51850128b8b97d6ae260108c2a115a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 28 Aug 2026 17:10:18 +0200 Subject: [PATCH 532/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 5cc179e..b87fc0c 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.8" +__version__ = "1.2.9" From eb02c47e95111d7f9102db276b41ecc7178b0b29 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 2 Sep 2026 13:01:23 +0200 Subject: [PATCH 533/592] Remove unrenderable characters from UNIX domain socket names. Display AutoInterface peers correctly. --- nomadnet/ui/textui/Transport.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Transport.py b/nomadnet/ui/textui/Transport.py index aaed7bb..a358bc1 100644 --- a/nomadnet/ui/textui/Transport.py +++ b/nomadnet/ui/textui/Transport.py @@ -352,7 +352,11 @@ class TransportDisplay: return TransportRow(columns) def _make_iface_row(self, entry, position): - name = entry.get("short_name") or entry.get("name") or "?" + short_name = entry.get("short_name") + if short_name == "None": short_name = None + name = short_name or entry.get("name") or "?" + RNS.log(name) + name = name.replace("\x00", "") paths = self.paths_per_interface.get(entry.get("name", ""), 0) held = entry.get("held_announces", 0) or 0 queue = entry.get("announce_queue") From 5f258f8a8fe9f3bfcfd2a5d23fec3066ee493a44 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 2 Sep 2026 13:01:43 +0200 Subject: [PATCH 534/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index b87fc0c..67bc602 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.2.9" +__version__ = "1.3.0" From cb88ff3ed14dea2ce4d8448c794d97695a5a01b1 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 2 Sep 2026 13:01:59 +0200 Subject: [PATCH 535/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2979e56..69ad85d 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.5.1", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.5.2", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From 3eb410afdd2ef3f7b46bfd3411d4d427b4b3f5d7 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 2 Sep 2026 13:22:51 +0200 Subject: [PATCH 536/592] Don't set PN unless different from current --- nomadnet/NomadNetworkApp.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 0ca19c5..65bf17a 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -630,9 +630,10 @@ class NomadNetworkApp: if selected_node == None: RNS.log("Could not autoselect a propagation node! LXMF propagation will not be available until a trusted node announces on the network, or a propagation node is manually selected.", RNS.LOG_WARNING) else: - pn_name_str = "" - RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_DEBUG) - self.message_router.set_outbound_propagation_node(selected_node) + if self.message_router.get_outbound_propagation_node() != selected_node: + pn_name_str = "" + RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_DEBUG) + self.message_router.set_outbound_propagation_node(selected_node) def get_user_selected_propagation_node(self): if "propagation_node" in self.peer_settings: From 54491d8f2b19b98ee5eaa2ca6385b825229c3726 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 2 Sep 2026 22:48:31 +0200 Subject: [PATCH 537/592] Improved string sanitization, added dynamic timeout calculations, improved state persistence, fixed .allowed not being respected for downloadable files, by Zenith --- nomadnet/Conversation.py | 2 +- nomadnet/Node.py | 92 +++++++++++++++-------------- nomadnet/NomadNetworkApp.py | 43 +++++++++----- nomadnet/RRC.py | 18 +++--- nomadnet/ui/TextUI.py | 2 + nomadnet/ui/textui/Browser.py | 31 ++++++---- nomadnet/ui/textui/Conversations.py | 2 +- nomadnet/ui/textui/Editor.py | 12 ++-- nomadnet/ui/textui/Guide.py | 4 +- nomadnet/ui/textui/Log.py | 3 +- nomadnet/ui/textui/Main.py | 6 ++ nomadnet/ui/textui/MicronParser.py | 2 + nomadnet/ui/textui/Utilities.py | 16 ++--- nomadnet/util.py | 5 +- 14 files changed, 139 insertions(+), 99 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 5c804b6..86d26bb 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -873,7 +873,7 @@ class ConversationMessage: name = str(name) if name is not None else "" except Exception: name = "" - name = re.sub(r"[\x00-\x1f\x7f]", "", name) + name = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", name) parts = re.split(r"[/\\]", name) name = parts[-1] if parts else "" if ":" in name: diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 28c816f..622d8aa 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -9,6 +9,7 @@ import RNS.vendor.umsgpack as msgpack class Node: JOB_INTERVAL = 5 + SETTINGS_FLUSH_INTERVAL = 60 START_ANNOUNCE_DELAY = 6 def __init__(self, app): @@ -24,6 +25,8 @@ class Node: self.file_refresh_interval = self.app.file_refresh_interval self.job_interval = Node.JOB_INTERVAL self.should_run_jobs = True + self.settings_dirty = False + self.last_settings_flush = time.time() self.app_data = None self.name = self.app.node_name @@ -100,7 +103,8 @@ class Node: directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."] for file in files: - self.servedfiles.append(base_path+"/"+file) + if not file.endswith(".allowed"): + self.servedfiles.append(base_path+"/"+file) for directory in directories: self.scan_files(base_path+"/"+directory) @@ -109,51 +113,13 @@ class Node: RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) try: self.app.peer_settings["served_page_requests"] += 1 - self.app.save_peer_settings() + self.settings_dirty = True except Exception as e: RNS.log("Could not increase served page request count", RNS.LOG_ERROR) file_path = path.replace("/page", self.app.pagespath, 1) - - allowed_path = file_path+".allowed" - request_allowed = False - - if os.path.isfile(allowed_path): - allowed_list = [] - - try: - if os.access(allowed_path, os.X_OK): - allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE) - allowed_input = allowed_result.stdout - - else: - with open(allowed_path, "rb") as fh: - allowed_input = fh.read() - - allowed_hash_strs = allowed_input.splitlines() - - for hash_str in allowed_hash_strs: - if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: - try: - allowed_hash = bytes.fromhex(hash_str.decode("utf-8")) - allowed_list.append(allowed_hash) - - except Exception as e: - RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG) - RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) - - except Exception as e: - RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR) - - if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list: - request_allowed = True - else: - request_allowed = False - RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE) - - else: - request_allowed = True + request_allowed = self.request_allowed(file_path, remote_identity) try: if request_allowed: @@ -187,18 +153,52 @@ class Node: RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) return None + def request_allowed(self, file_path, remote_identity): + allowed_path = file_path+".allowed" + if not os.path.isfile(allowed_path): + return True + + allowed_list = [] + try: + if os.access(allowed_path, os.X_OK): + allowed_input = subprocess.run([allowed_path], stdout=subprocess.PIPE).stdout + else: + with open(allowed_path, "rb") as fh: + allowed_input = fh.read() + + for hash_str in allowed_input.splitlines(): + if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: + try: + allowed_list.append(bytes.fromhex(hash_str.decode("utf-8"))) + except Exception as e: + RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR) + + if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list: + return True + + RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE) + return False + # TODO: Improve file handling, this will be slow for large files def serve_file(self, path, data, request_id, remote_identity, requested_at): RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) try: self.app.peer_settings["served_file_requests"] += 1 - self.app.save_peer_settings() + self.settings_dirty = True except Exception as e: RNS.log("Could not increase served file request count", RNS.LOG_ERROR) file_path = path.replace("/file", self.app.filespath, 1) file_name = path.replace("/file/", "", 1) + if not self.request_allowed(file_path, remote_identity): + RNS.log("Request denied", RNS.LOG_VERBOSE) + return DEFAULT_NOTALLOWED.encode("utf-8") + try: RNS.log("Serving file: "+file_path, RNS.LOG_VERBOSE) return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}] @@ -236,13 +236,19 @@ class Node: self.register_files() self.last_file_refresh = time.time() + if self.settings_dirty and now > self.last_settings_flush + Node.SETTINGS_FLUSH_INTERVAL: + self.settings_dirty = False + self.last_settings_flush = now + try: self.app.save_peer_settings() + except Exception as e: RNS.log("Could not save peer settings: "+str(e), RNS.LOG_ERROR) + time.sleep(self.job_interval) def peer_connected(self, link): RNS.log("Peer connected to "+str(self.destination), RNS.LOG_VERBOSE) try: self.app.peer_settings["node_connects"] += 1 - self.app.save_peer_settings() + self.settings_dirty = True except Exception as e: RNS.log("Could not increase node connection count", RNS.LOG_ERROR) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 65bf17a..27bdaa6 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -8,6 +8,7 @@ import threading import traceback import subprocess import contextlib +import tempfile import RNS import LXMF @@ -129,6 +130,7 @@ class NomadNetworkApp: self.firstrun = False self.should_run_jobs = True self.job_interval = 5 + self.peer_settings_lock = threading.Lock() self.defer_jobs = 90 self.page_refresh_interval = 0 self.file_refresh_interval = 0 @@ -249,11 +251,13 @@ class NomadNetworkApp: RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) nomadnet.panic() - if os.path.isfile(self.peersettingspath): + self.peer_settings = None + self.peer_settings_notice = None + for path in (self.peersettingspath, self.peersettingspath+".bak"): + if self.peer_settings != None or not os.path.isfile(path): continue try: - file = open(self.peersettingspath, "rb") - self.peer_settings = msgpack.unpackb(file.read()) - file.close() + with open(path, "rb") as file: + self.peer_settings = msgpack.unpackb(file.read()) if not "node_last_announce" in self.peer_settings: self.peer_settings["node_last_announce"] = None @@ -274,17 +278,21 @@ class NomadNetworkApp: self.peer_settings["served_file_requests"] = 0 self.peer_settings["announce_interval"] = self.announce_interval + if path != self.peersettingspath: + self.peer_settings_notice = "Peer settings were restored from the last backup.\nCheck your display name and propagation node." except Exception as e: - RNS.logdest = RNS.LOG_STDOUT - RNS.log(f"Could not load local peer settings from {self.peersettingspath}", RNS.LOG_ERROR) + RNS.log(f"Could not load local peer settings from {path}", RNS.LOG_ERROR) RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR) - RNS.log(f"This likely means that the peer settings file has become corrupt.", RNS.LOG_ERROR) - RNS.log(f"You can try deleting the file at {self.peersettingspath} and restarting nomadnet.", RNS.LOG_ERROR) - nomadnet.panic() - else: + RNS.log(f"Moving the corrupt file to {path}.corrupt", RNS.LOG_ERROR) + try: os.replace(path, path+".corrupt") + except Exception: pass + self.peer_settings = None + self.peer_settings_notice = "Peer settings were corrupt and have been reset.\nCheck your display name and propagation node." + + if self.peer_settings == None: try: - RNS.log("No peer settings file found, creating new...") + RNS.log("Creating new peer settings file...") self.peer_settings = { "display_name": "Anonymous Peer", "announce_interval": self.announce_interval, @@ -650,9 +658,16 @@ class NomadNetworkApp: return self.message_router.get_outbound_propagation_node() def save_peer_settings(self): - tmp_path = f"{self.peersettingspath}.tmp" - with open(tmp_path, "wb") as file: file.write(msgpack.packb(self.peer_settings)) - os.replace(tmp_path, self.peersettingspath) + with self.peer_settings_lock: + fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.peersettingspath), prefix=".peersettings.") + try: + with os.fdopen(fd, "wb") as file: file.write(msgpack.packb(self.peer_settings)) + if os.path.isfile(self.peersettingspath): os.replace(self.peersettingspath, self.peersettingspath+".bak") + os.replace(tmp_path, self.peersettingspath) + except Exception: + try: os.unlink(tmp_path) + except Exception: pass + raise def lxmf_delivery(self, message): time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index e764b3b..ddafa60 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -8,6 +8,7 @@ from collections import deque import RNS from nomadnet.vendor import cbor +from nomadnet.util import strip_modifiers HISTORY_DIR_NAME = "rrc_history" @@ -192,7 +193,7 @@ class RRCMessage: self.room = room self.src = src self.nick = nick - self.text = text + self.text = strip_modifiers(text) self.ts = ts self.mention = False @@ -355,10 +356,11 @@ class RRCHub: def _connect_worker(self): try: - timeout_s = 20.0 + path_timeout = self.manager.app.rns.get_medium_path_timeout() + timeout_s = max(20.0, path_timeout) if not RNS.Transport.has_path(self.hub_hash): RNS.Transport.request_path(self.hub_hash) - deadline = time.monotonic() + min(5.0, timeout_s) + deadline = time.monotonic() + max(5.0, path_timeout) while time.monotonic() < deadline: if RNS.Transport.has_path(self.hub_hash): break @@ -429,7 +431,7 @@ class RRCHub: except Exception as e: self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR) attempts += 1 - self._stop_hello.wait(timeout=3.0) + self._stop_hello.wait(timeout=max(3.0, (cur_link.rtt or 0)*cur_link.traffic_timeout_factor)) if not self.welcomed and not self._stop_hello.is_set(): self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout") try: @@ -847,7 +849,7 @@ class RRCHub: parsed = _parse_room_list_notice(text) if parsed is not None: with self._lock: - self.available_rooms = parsed + self.available_rooms = {strip_modifiers(name): strip_modifiers(topic) for name, topic in parsed.items()} silent = self._silent_list_pending > 0 if silent: self._silent_list_pending -= 1 @@ -925,7 +927,7 @@ class RRCHub: if isinstance(body, dict): hub_name = body.get(B_WELCOME_HUB) if isinstance(hub_name, str): - self.hub_name = hub_name + self.hub_name = strip_modifiers(hub_name) ver = body.get(B_WELCOME_VER) if isinstance(ver, str): self.hub_version = ver @@ -1135,7 +1137,7 @@ class RRCHub: room_n = room.strip().lower() if isinstance(room, str) else None if room_n is None and isinstance(body, str) and body.strip(): with self._lock: - self.motd = body + self.motd = strip_modifiers(body) self.manager._notify_change(self) msg = RRCMessage( "notice", @@ -1273,7 +1275,7 @@ class RRCHub: return if kind == RES_KIND_MOTD: with self._lock: - self.motd = text + self.motd = strip_modifiers(text) self.manager._notify_change(self) elif self._process_notice_text(text): return diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 50d65d1..bbaa73a 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -279,6 +279,7 @@ class TextUI: self.set_colormode(colormode) self.main_display.start() + if intro_timeout <= 0 and self.app.peer_settings_notice: self.main_display.show_notice(self.app.peer_settings_notice) self.loop.run() def set_colormode(self, colormode): @@ -297,3 +298,4 @@ class TextUI: def display_main(self, loop, user_data): self.loop.widget = self.main_display.widget + if self.app.peer_settings_notice: self.main_display.show_notice(self.app.peer_settings_notice) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 51685b3..9a427bd 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -11,6 +11,7 @@ import threading from threading import Lock from .MicronParser import markup_to_attrmaps, make_style, default_state from nomadnet.Directory import DirectoryEntry +from nomadnet.Conversation import ConversationMessage from nomadnet.vendor.Scrollable import * from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name @@ -825,10 +826,9 @@ class Browser: partial["pr_throttle"] = time.time()+15 RNS.log(f"Requesting path for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) RNS.Transport.request_path(partial_destination_hash) - pr_time = time.time()+RNS.Transport.first_hop_timeout(partial_destination_hash) + pr_time = time.time()+self.path_timeout(partial_destination_hash) while not RNS.Transport.has_path(partial_destination_hash): - now = time.time() - if now > pr_time+self.timeout: return + if time.time() > pr_time: return time.sleep(0.25) for pid in self.page_partials: @@ -853,7 +853,7 @@ class Browser: partial["link"] = None partial["link"] = RNS.Link(destination, established_callback = established, closed_callback = closed) - timeout = time.time()+self.timeout + timeout = time.time()+max(self.timeout, partial["link"].establishment_timeout) while partial["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1) if partial["link"] and partial["link"].status == RNS.Link.ACTIVE and partial["request_id"] == None: @@ -1065,6 +1065,9 @@ class Browser: def set_timeout(self, timeout): self.timeout = timeout + def path_timeout(self, destination_hash): + return max(self.timeout + self.app.rns.get_first_hop_timeout(destination_hash), self.app.rns.get_medium_path_timeout()) + def download_local_file(self, path): try: file_path = self.app.filespath+path.replace("/file", "", 1) @@ -1110,10 +1113,9 @@ class Browser: self.status = Browser.PATH_REQUESTED self.update_display() - pr_time = time.time()+RNS.Transport.first_hop_timeout(self.destination_hash) + pr_time = time.time()+self.path_timeout(self.destination_hash) while not RNS.Transport.has_path(self.destination_hash): - now = time.time() - if now > pr_time+self.timeout: + if time.time() > pr_time: self.request_timeout() return @@ -1493,10 +1495,9 @@ class Browser: self.status = Browser.PATH_REQUESTED self.update_display() - pr_time = time.time()+RNS.Transport.first_hop_timeout(self.destination_hash) + pr_time = time.time()+self.path_timeout(self.destination_hash) while not RNS.Transport.has_path(self.destination_hash): - now = time.time() - if now > pr_time+self.timeout: + if time.time() > pr_time: self.request_timeout() return @@ -1738,9 +1739,13 @@ class Browser: def file_received(self, request_receipt): try: - if type(request_receipt.response) == io.BufferedReader: + if isinstance(request_receipt.response, bytes): + markup = request_receipt.response.decode("utf-8") + self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + + elif type(request_receipt.response) == io.BufferedReader: if request_receipt.metadata != None: - file_name = os.path.basename(request_receipt.metadata["name"].decode("utf-8")) + file_name = ConversationMessage.safe_attachment_name(request_receipt.metadata["name"]) file_handle = request_receipt.response file_destination = self.app.downloads_path+"/"+file_name @@ -1759,7 +1764,7 @@ class Browser: else: file_name = request_receipt.response[0] file_data = request_receipt.response[1] - file_destination_name = os.path.basename(file_name) + file_destination_name = ConversationMessage.safe_attachment_name(file_name) file_destination = self.app.downloads_path+"/"+file_destination_name counter = 0 diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index dec5fa6..6410abf 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -2809,7 +2809,7 @@ class LXMessageWidget(urwid.WidgetWrap): title_string = g["warning"]+" "+g["arrow_l"]+" "+message.get_signature_description() + "\n " + title_string if message.get_title() != "": - title_string += " | " + message.get_title() + title_string += " | " + strip_modifiers(message.get_title()) inbound_untrusted = False if not is_outbound and msg_source_hash is not None: diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py index 42d8203..b22e449 100644 --- a/nomadnet/ui/textui/Editor.py +++ b/nomadnet/ui/textui/Editor.py @@ -953,18 +953,18 @@ class PageEditorDisplay(): common, restricted, total = self._folder_permissions(path) content = common or "" if not total: - state = "This folder contains no pages." + state = "This folder contains no files." elif not restricted: - state = "All %d pages here are public." % total + state = "All %d files here are public." % total elif common is not None: - state = "All %d pages here share these entries." % total + state = "All %d files here share these entries." % total else: - state = "Entries differ across pages (%d of %d restricted)." % (restricted, total) + state = "Entries differ across files (%d of %d restricted)." % (restricted, total) note = ("Allowed identity hashes per line.\n" - + state + "\nSaving applies these entries to every page in this folder and its subfolders.") + + state + "\nSaving applies these entries to every file in this folder and its subfolders.") else: content = self._read_allowed(path) - note = "Allowed identity hashes per line. Empty = public.\nApplies to this page." + note = "Allowed identity hashes per line. Empty = public.\nApplies to this page or file." title = "Permissions: " + os.path.basename(path) + ("/" if is_dir else "") self.show_dialog(PermissionsDialog(title, note, content, diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 8cdb239..aa97121 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -498,9 +498,9 @@ By default, you can find the examples in `!~/.nomadnetwork/examples`!. If you bu >>Authenticating Users -Sometimes, you don't want everyone to be able to view certain pages or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. +Sometimes, you don't want everyone to be able to view certain pages, download certain files or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. -To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". +To enable authentication for any page or file, simply add a new file next to it with ".allowed" added to its file-name. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". The same works for files in your files directory, and the ".allowed" file itself is never served. For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this: diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index 11b5efa..c301d63 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -4,6 +4,7 @@ import itertools import mmap import urwid import nomadnet +from nomadnet.util import strip_modifiers class LogDisplayShortcuts(): @@ -68,7 +69,7 @@ class LogTerminal(urwid.WidgetWrap): class LogTail(urwid.WidgetWrap): def __init__(self, app): self.app = app - self.log_tail = urwid.Text(tail(self.app.logfilepath, 50)) + self.log_tail = urwid.Text(strip_modifiers(tail(self.app.logfilepath, 50))) self.log = urwid.Scrollable(self.log_tail) self.log.set_scrollpos(-1) self.log_scrollbar = urwid.ScrollBar(self.log) diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 63ed0f2..cf1c959 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -249,6 +249,12 @@ class MainDisplay(): valign="middle", height=8, min_width=20, ) + def show_notice(self, text): + def close(button=None): self.app.ui.loop.widget = self.frame + pile = urwid.Pile([urwid.Text(text, align="center"), urwid.Divider(), urwid.Button("OK", on_press=close)]) + dialog = QuitDialogLineBox(urwid.Filler(pile, urwid.TOP), title="Notice", on_cancel=close) + self.app.ui.loop.widget = urwid.Overlay(dialog, self.frame, align="center", width=64, valign="middle", height=9, min_width=20) + def do_quit(self, sender=None): logterm_pid = None if True or RNS.vendor.platformutils.is_android(): diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 0add998..ff946c4 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -8,6 +8,7 @@ from urwid.util import is_mouse_press from urwid.text_layout import calc_coords from .ReadlineEdit import ReadlineEdit from RNS.Utilities.rngit.util import MarkdownToMicron +from nomadnet.util import STRIP_CONTROL_RE DEFAULT_FG_DARK = "ddd" DEFAULT_FG_LIGHT = "222" @@ -101,6 +102,7 @@ def ensure_selected_styles(): else: SELECTED_STYLES = STYLES_LIGHT def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None): + markup = STRIP_CONTROL_RE.sub("", markup) global LINK_CLASS if link_class: LINK_CLASS = link_class else: LINK_CLASS = LinkableText diff --git a/nomadnet/ui/textui/Utilities.py b/nomadnet/ui/textui/Utilities.py index 771cd0b..ec0dfee 100644 --- a/nomadnet/ui/textui/Utilities.py +++ b/nomadnet/ui/textui/Utilities.py @@ -419,7 +419,7 @@ class ProbeView: def _timeout(self): try: - return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout()) except Exception: return DEFAULT_PROBE_TIMEOUT @@ -639,7 +639,7 @@ class PathView: def _timeout(self): try: - return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout()) except Exception: return DEFAULT_PROBE_TIMEOUT @@ -830,7 +830,7 @@ class IdentityView: self.lookup_button.original_widget.set_label("Looking up…") self._log("Identity unknown, requesting from the network …") try: - self._deadline = time.time() + DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(dest_hash) + self._deadline = time.time() + max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(dest_hash), self.app.rns.get_medium_path_timeout()) except Exception: self._deadline = time.time() + DEFAULT_PROBE_TIMEOUT self.app.ui.loop.set_alarm_in(0.25, self._await_identity) @@ -975,7 +975,7 @@ class SendFileView: def _timeout(self): try: - return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout()) except Exception: return DEFAULT_PROBE_TIMEOUT @@ -1040,7 +1040,7 @@ class SendFileView: self._log("Could not establish link: %s" % str(e), "error_text") self._finish() return - self._link_deadline = time.time() + self._timeout() + self._link_deadline = time.time() + max(self._timeout(), self._link.establishment_timeout) self.app.ui.loop.set_alarm_in(0.5, self._check_link) def _check_link(self, loop, user_data): @@ -1402,7 +1402,7 @@ class SpeedTestView: return self._incoming_link = link link.set_remote_identified_callback(self._on_remote_identified) - self.app.ui.loop.set_alarm_in(8, lambda l, u: self._incoming_timeout(link)) + self.app.ui.loop.set_alarm_in(max(8, self.app.rns.get_medium_path_timeout()), lambda l, u: self._incoming_timeout(link)) def _incoming_timeout(self, link): if self._recv_link is link: @@ -1565,7 +1565,7 @@ class SpeedTestView: def _timeout(self): try: - return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout()) except Exception: return DEFAULT_PROBE_TIMEOUT @@ -1594,7 +1594,7 @@ class SpeedTestView: self._log("Could not establish link: %s" % str(e), "error_text") self._finish() return - self._link_deadline = time.time() + self._timeout() + self._link_deadline = time.time() + max(self._timeout(), self._client_link.establishment_timeout) self.app.ui.loop.set_alarm_in(0.5, self._check_link) def _check_link(self, loop, user_data): diff --git a/nomadnet/util.py b/nomadnet/util.py index a7bd62c..4c3a079 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -121,8 +121,9 @@ def strip_modifiers(text): stripped = re.sub(r'[\U0001F3FB-\U0001F3FF]', '', stripped, flags=re.UNICODE) stripped = re.sub(r'[\u200D\u200C]', '', stripped) stripped = re.sub(r'\r\n?', '\n', stripped) - - return stripped.strip().replace("\x00", "") + stripped = STRIP_CONTROL_RE.sub('', stripped) + + return stripped.strip() def sanitize_name(name): if name is None: return None From 7529696b9d4d5ebf5526e1f158525f62f759d996 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 2 Sep 2026 22:48:59 +0200 Subject: [PATCH 538/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 67bc602..9c73af2 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.3.0" +__version__ = "1.3.1" From 71904f1e40071d45459b5ffc28047740703dd775 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 3 Sep 2026 15:13:15 +0200 Subject: [PATCH 539/592] Adjusted loglevel --- nomadnet/ui/textui/Browser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 9a427bd..5f92244 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -267,7 +267,7 @@ class Browser: pass # do nothing if checkbox is not check recurse_down(self.attr_maps) - RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME) # In-document anchor link (#name or empty #) if link_target.startswith("#"): @@ -917,7 +917,7 @@ class Browser: pass # do nothing if checkbox is not check recurse_down(self.attr_maps) - RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME) return request_data From c0091dbf316d245486bbc11bb4a97f9d37bce09a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 22:15:10 +0200 Subject: [PATCH 540/592] Added image display support --- nomadnet/Node.py | 40 +- nomadnet/NomadNetworkApp.py | 11 +- nomadnet/ui/TextUI.py | 9 +- nomadnet/ui/textui/Browser.py | 238 +++++++- nomadnet/ui/textui/Log.py | 2 +- nomadnet/ui/textui/Main.py | 27 +- nomadnet/ui/textui/MicronParser.py | 72 +++ nomadnet/ui/textui/images/__init__.py | 18 + nomadnet/ui/textui/images/_ctlseqs.py | 78 +++ nomadnet/ui/textui/images/_imagedata.py | 137 +++++ nomadnet/ui/textui/images/_imagestore.py | 90 +++ nomadnet/ui/textui/images/_selftest.py | 681 +++++++++++++++++++++++ nomadnet/ui/textui/images/_termlib.py | 196 +++++++ nomadnet/ui/textui/images/_webp.py | 367 ++++++++++++ nomadnet/ui/textui/images/screen.py | 210 +++++++ nomadnet/ui/textui/images/widget.py | 276 +++++++++ 16 files changed, 2427 insertions(+), 25 deletions(-) create mode 100644 nomadnet/ui/textui/images/__init__.py create mode 100644 nomadnet/ui/textui/images/_ctlseqs.py create mode 100644 nomadnet/ui/textui/images/_imagedata.py create mode 100644 nomadnet/ui/textui/images/_imagestore.py create mode 100644 nomadnet/ui/textui/images/_selftest.py create mode 100644 nomadnet/ui/textui/images/_termlib.py create mode 100644 nomadnet/ui/textui/images/_webp.py create mode 100644 nomadnet/ui/textui/images/screen.py create mode 100644 nomadnet/ui/textui/images/widget.py diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 622d8aa..714f270 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -32,6 +32,7 @@ class Node: self.register_pages() self.register_files() + self.register_media() self.destination.set_link_established_callback(self.peer_connected) @@ -73,6 +74,10 @@ class Node: response_generator = self.serve_page, allow = RNS.Destination.ALLOW_ALL) + def register_media(self): + self.destination.register_request_handler("/media", response_generator = self.serve_media, + allow = RNS.Destination.ALLOW_ALL) + def register_files(self): # TODO: Deregister previously registered files # that no longer exist. @@ -153,6 +158,39 @@ class Node: RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) return None + def serve_media(self, path, data, request_id, link_id, remote_identity, requested_at): + if not type(data) == dict: return None + if not "path" in data: return None + if not "key" in data: return None + media_path = data["path"].replace("/media/", "").lstrip("/").replace("../", "") + media_path = f"{self.app.pagespath}/{media_path}" + if not media_path.startswith(self.app.pagespath): + RNS.log(f"Invalid media request path: {media_path}", RNS.LOG_DEBUG) + return None + + RNS.log(f"Media request {RNS.prettyhexrep(request_id)} for: {media_path}", RNS.LOG_VERBOSE) + try: + self.app.peer_settings["served_media_requests"] += 1 + self.settings_dirty = True + except Exception as e: RNS.log("Could not increase served page request count", RNS.LOG_ERROR) + + request_allowed = self.request_allowed(media_path, remote_identity) + try: + if request_allowed: + file_path = media_path + file_name = os.path.basename(file_path) + RNS.log(f"Serving media: {file_path}", RNS.LOG_VERBOSE) + return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}] + + else: + RNS.log("Request denied", RNS.LOG_VERBOSE) + return None + + except Exception as e: + RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(media_path), RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + return None + def request_allowed(self, file_path, remote_identity): allowed_path = file_path+".allowed" if not os.path.isfile(allowed_path): @@ -194,7 +232,7 @@ class Node: RNS.log("Could not increase served file request count", RNS.LOG_ERROR) file_path = path.replace("/file", self.app.filespath, 1) - file_name = path.replace("/file/", "", 1) + file_name = os.path.basename(file_path) if not self.request_allowed(file_path, remote_identity): RNS.log("Request denied", RNS.LOG_VERBOSE) return DEFAULT_NOTALLOWED.encode("utf-8") diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 27bdaa6..1319e5f 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -112,6 +112,7 @@ class NomadNetworkApp: self.storagepath = self.configdir+"/storage" self.identitypath = self.configdir+"/storage/identity" self.cachepath = self.configdir+"/storage/cache" + self.imagecachepath = self.configdir+"/storage/cache/images" self.resourcepath = self.configdir+"/storage/resources" self.conversationpath = self.configdir+"/storage/conversations" self.directorypath = self.configdir+"/storage/directory" @@ -121,7 +122,6 @@ class NomadNetworkApp: self.pagespath = self.configdir+"/storage/pages" self.filespath = self.configdir+"/storage/files" - self.cachepath = self.configdir+"/storage/cache" self.examplespath = self.configdir+"/examples" self.downloads_path = os.path.expanduser("~/Downloads") @@ -176,6 +176,9 @@ class NomadNetworkApp: if not os.path.isdir(self.cachepath): os.makedirs(self.cachepath) + if not os.path.isdir(self.imagecachepath): + os.makedirs(self.imagecachepath) + if not os.path.isdir(self.resourcepath): os.makedirs(self.resourcepath) @@ -274,9 +277,15 @@ class NomadNetworkApp: if not "served_page_requests" in self.peer_settings: self.peer_settings["served_page_requests"] = 0 + if not "served_media_requests" in self.peer_settings: + self.peer_settings["served_media_requests"] = 0 + if not "served_file_requests" in self.peer_settings: self.peer_settings["served_file_requests"] = 0 + if not "quit_confirm" in self.peer_settings: + self.peer_settings["quit_confirm"] = True + self.peer_settings["announce_interval"] = self.announce_interval if path != self.peersettingspath: self.peer_settings_notice = "Peer settings were restored from the last backup.\nCheck your display name and propagation node." diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index bbaa73a..bdc13bb 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -7,6 +7,7 @@ import platform import nomadnet from nomadnet.ui import THEME_DARK, THEME_LIGHT from nomadnet.ui.textui import * +from nomadnet.ui.textui.images import ImageScreen from nomadnet import NomadNetworkApp COLORMODE_MONO = 1 @@ -245,7 +246,7 @@ class TextUI: for glyph in GLYPHS: self.glyphs[glyph[0]] = glyph[GLYPHSETS[glyphset]] - self.screen = urwid.raw_display.Screen() + self.screen = ImageScreen() self.screen.register_palette(self.palette) self.main_display = Main.MainDisplay(self, self.app) @@ -291,10 +292,8 @@ class TextUI: self.restore_palette = True def unhandled_input(self, key): - if key == "ctrl q": - self.main_display.show_quit_dialog() - elif key == "ctrl e": - pass + if key == "ctrl q": self.main_display.quit() + elif key == "ctrl e": pass def display_main(self, loop, user_data): self.loop.widget = self.main_display.widget diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 5f92244..2ed21c0 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -125,8 +125,11 @@ class Browser: self.attr_maps = [] self.page_pile = None self.page_partials = {} + self.page_images = {} self.updater_running = False + self.image_updater_running = False self.partial_updater_lock = Lock() + self.image_updater_lock = Lock() self.build_display() self.history = [] @@ -481,6 +484,7 @@ class Browser: self.page_pile = None self.page_partials = {} + self.page_images = {} self.browser_body = urwid.Filler( urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), urwid.MIDDLE, @@ -556,6 +560,7 @@ class Browser: self.display_widget.set_attr_map({None: "browser_inactive"}) self.page_pile = None self.page_partials = {} + self.page_images = {} self.browser_body = urwid.Filler( urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), urwid.MIDDLE, @@ -626,15 +631,240 @@ class Browser: self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) def update_page_display(self): + self._purge_page_images() pile = urwid.Pile(self.attr_maps) pile.automove_cursor_on_scroll = True self.page_pile = pile self.page_partials = {} + self.page_images = {} self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") self.detect_partials() + self.detect_images() self.init_folds() - # render an arbitrary markup buffer (used by the page editor preview) and + def image_cache_path(self, url): + url_hash = self.url_hash(url) + if not url_hash: return None + else: + path = f"{self.app.cachepath}/images/{url_hash}" + return path + + def resolve_image(self, url): + cachepath = self.image_cache_path(url) + if not cachepath: return None + else: + if os.path.exists(cachepath): return cachepath + else: return None + + def _purge_page_images(self): + try: + screen = getattr(getattr(self.app.ui, "loop", None), "screen", None) + if hasattr(screen, "purge_images"): screen.purge_images() + except Exception: pass + + def detect_images(self): + for w in self.attr_maps: + o = w._original_widget + if hasattr(o, "_contained_image"): o = o._contained_image + if hasattr(o, "image_url"): + resolved_image = self.resolve_image(o.image_url) + if resolved_image: o.load(resolved_image) + else: + image = { "widget": o, "id": o.image_id, "url": o.image_url, "refresh": None, "updated": None, + "update_requested": None, "request_id": None, "link": None, "failed": False, "pr_throttle": 0, + "progress_updated": None, "previous_progress": 0 } + + self.page_images[o.image_id] = image + + if len(self.page_images) > 0: self.start_image_updater() + + def start_image_updater(self): + if not self.image_updater_running: self.update_images() + + def update_images(self, loop=None, user_data=None): + def job(): + if self.image_updater_lock.locked(): return + with self.image_updater_lock: + self.image_updater_running = True + + for iid in self.page_images: + try: + image = self.page_images[iid] + if image["failed"]: continue + if not image["updated"] or (image["refresh"] != None and time.time() > image["updated"]+image["refresh"]): + image["update_requested"] = time.time() + self.__load_image(image) + while not image["updated"] and not image["failed"] and len(self.page_images) > 0: time.sleep(0.2) + + except Exception as e: RNS.log(f"Error updating page image: {e}", RNS.LOG_ERROR) + + if len(self.page_images) > 0: self.app.ui.loop.set_alarm_in(1, self.update_images) + else: self.image_updater_running = False + + threading.Thread(target=job, daemon=True).start() + + def __load_image(self, image): + if image["failed"] == True: return + w = image["widget"] + url = image["url"] + try: image_destination_hash, path = self.parse_url(image["url"]) + except Exception as e: + RNS.log(f"Could not parse image URL: {e}", RNS.LOG_ERROR) + image["failed"] = True + w.notice(f"Could not load image {url}: {e}") + return + + if image_destination_hash == self.loopback: + local_image = path.replace("/media/", "") + local_path = f"{self.app.pagespath}/{local_image}" + w.load(local_path) + image["updated"] = time.time() + return + + if not RNS.Transport.has_path(image_destination_hash): + if time.time() <= image["pr_throttle"]: return + else: + image["pr_throttle"] = time.time()+15 + RNS.log(f"Requesting path for image: {path}", RNS.LOG_DEBUG) + RNS.Transport.request_path(image_destination_hash) + pr_time = time.time()+self.path_timeout(image_destination_hash) + while not RNS.Transport.has_path(image_destination_hash): + if time.time() > pr_time: return + time.sleep(0.25) + + if not image["link"]: + if self.link and self.link.destination.hash == image_destination_hash and self.link.status == RNS.Link.ACTIVE: + image["link"] = self.link + RNS.log(f"Re-using existing page link for image {url}", RNS.LOG_EXTREME) + + if not image["link"]: + for pid in self.page_images: + other_image = self.page_images[pid] + if other_image["link"]: + existing_link = other_image["link"] + if existing_link.destination.hash == image_destination_hash and existing_link.status == RNS.Link.ACTIVE: + RNS.log(f"Re-using existing link for image {url}", RNS.LOG_EXTREME) + image["link"] = existing_link + break + + if not image["link"] or image["link"].status == RNS.Link.CLOSED: + RNS.log(f"Establishing link for image: {image_destination_hash} / {path}", RNS.LOG_DEBUG) + identity = RNS.Identity.recall(image_destination_hash) + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects) + w.notice(f"Establishing link...") + + def f_established(wd): + def established(link): + RNS.log(f"Link established for image: {path}", RNS.LOG_DEBUG) + wd.notice(f"Link established") + return established + + def f_closed(wd): + def closed(link): + RNS.log(f"Link closed for image: {path}", RNS.LOG_DEBUG) + image["link"] = None + image["failed"] = True + wd.notice(f"Link closed during transfer") + return closed + + image["link"] = RNS.Link(destination, established_callback = f_established(w), closed_callback = f_closed(w)) + timeout = time.time()+max(self.timeout, image["link"].establishment_timeout) + while image["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1) + + if image["link"] and image["link"].status == RNS.Link.ACTIVE and image["request_id"] == None: + RNS.log(f"Sending request for image: {path}", RNS.LOG_DEBUG) + receipt = image["link"].request("/media", data=self.__get_image_request_data(image), response_callback = self.image_received, + failed_callback = self.image_failed, progress_callback = self.image_progressed) + w.notice(f"Request sent") + + if receipt: image["request_id"] = receipt.request_id + else: + RNS.log(f"Image request failed", RNS.LOG_ERROR) + image["failed"] = True + w.notice(f"Request failed") + + def __get_image_request_data(self, image): + destination_hash, path = self.parse_url(image["url"]) + request_data = {"path": path, "key": None} + return request_data + + def image_failed(self, request_receipt): + RNS.log("Loading page image failed", RNS.LOG_ERROR) + for iid in self.page_images: + image = self.page_images[iid] + if image["request_id"] == request_receipt.request_id: + try: + image["updated"] = time.time() + image["request_id"] = None + image["failed"] = True + url = image["url"] + w = image["widget"] + w.notice(f"Could not load image {url}") + except Exception as e: + RNS.log(f"Error in image failed callback: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + + def image_progressed(self, request_receipt): + for iid in self.page_images: + i = self.page_images[iid] + if i["request_id"] == request_receipt.request_id: + try: + response_progress = request_receipt.progress + response_time = request_receipt.get_response_time() + response_size = request_receipt.response_size + response_transfer_size = request_receipt.response_transfer_size + + w = i["widget"] + now = time.time() + if i["progress_updated"] == None: i["progress_updated"] = now + if now > i["progress_updated"]+0.5: + td = now - i["progress_updated"] + pd = response_progress - i["previous_progress"] + bd = pd*response_transfer_size + response_speed = (bd/td)*8 + i["previous_progress"] = response_progress + i["progress_updated"] = now + stats = f"{round(response_progress*100, 2)}% ({RNS.prettysize(response_progress*response_size)} of {RNS.prettysize(response_size)}) - {RNS.prettyspeed(response_speed)}" + w.notice(stats) + + except Exception as e: RNS.trace_exception(e) + + def image_received(self, request_receipt): + if not request_receipt.response: self.image_failed(request_receipt) + for iid in self.page_images: + image = self.page_images[iid] + if image["request_id"] == request_receipt.request_id: + try: + image["updated"] = image["update_requested"] + image["request_id"] = None + w = image["widget"] + w.notice(f"Image loaded") + + except Exception as e: + RNS.log(f"Error while loading received image: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + image["failed"] = True + + try: + url = image["url"] + cachepath = self.image_cache_path(url) + if isinstance(request_receipt.response, bytes): + with open(cachepath, "wb") as f: f.write(request_receipt.response) + + elif type(request_receipt.response) == io.BufferedReader: + if request_receipt.metadata != None: + file_handle = request_receipt.response + file_destination = cachepath + shutil.move(file_handle.name, file_destination) + + resolved_path = self.resolve_image(url) + if resolved_path: w.load(resolved_path) + + except Exception as e: + RNS.log("Error while handling image response: "+str(e), RNS.LOG_ERROR) + image["failed"] = True + + # Render an arbitrary markup buffer (used by the page editor preview) and # return the content widget, with partials/folds/fields wired as usual def render_markup_buffer(self, markup): self.status = Browser.DONE @@ -1805,10 +2035,8 @@ class Browser: self.update_display() if self.link != None: - try: - self.link.teardown() - except Exception as e: - pass + try: self.link.teardown() + except Exception as e: pass else: self.status = Browser.REQUEST_FAILED self.response_progress = 0 diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index c301d63..8472809 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -57,7 +57,7 @@ class LogTerminal(urwid.WidgetWrap): def keypress(self, size, key): if key == "ctrl q": - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.show_quit_dialog() + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.quit() return None if key == "up": diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index cf1c959..e439db2 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -194,11 +194,11 @@ class MainDisplay(): self.menu_display.start() def quit(self, sender=None): - self.show_quit_dialog() + if self.app.peer_settings["quit_confirm"]: self.show_quit_dialog() + else: self.do_quit() def show_quit_dialog(self, sender=None): - if getattr(self, "_quit_dialog_open", False): - return + if getattr(self, "_quit_dialog_open", False): return active = self.sub_displays.active_display if active is self.sub_displays.log_display: @@ -208,7 +208,14 @@ class MainDisplay(): self.do_quit() return + dont_ask_again = urwid.CheckBox("Don't ask again") + def confirm(button=None): + if dont_ask_again.get_state(): + try: + self.app.peer_settings["quit_confirm"] = False + self.app.save_peer_settings() + except Exception: pass self.do_quit() def cancel(button=None): @@ -220,19 +227,15 @@ class MainDisplay(): (urwid.WEIGHT, 0.1, urwid.Text("")), (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=cancel)), ]) - try: - - - buttons.focus_position = 2 # default to "No" - - - except Exception: - pass + try: buttons.focus_position = 2 # default to "No" + except Exception: pass pile = urwid.Pile([ urwid.Text("Are you sure you want to quit?", align="center"), urwid.Divider(), buttons, + urwid.Divider(), + dont_ask_again, ]) dialog = QuitDialogLineBox( @@ -246,7 +249,7 @@ class MainDisplay(): self.app.ui.loop.widget = urwid.Overlay( dialog, self.frame, align="center", width=44, - valign="middle", height=8, min_width=20, + valign="middle", height=7, min_width=20, ) def show_notice(self, text): diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index ff946c4..331a9a2 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -9,6 +9,8 @@ from urwid.text_layout import calc_coords from .ReadlineEdit import ReadlineEdit from RNS.Utilities.rngit.util import MarkdownToMicron from nomadnet.util import STRIP_CONTROL_RE +try: from .images import ImageWidget +except Exception: ImageWidget = None DEFAULT_FG_DARK = "ddd" DEFAULT_FG_LIGHT = "222" @@ -216,6 +218,72 @@ def parse_partial(line): except Exception as e: return None +def parse_image(line, state, url_delegate): + try: + endpos = line.rfind(")") + if endpos <= 0: return None + image_data = line[:endpos] + fields = image_data.split("`") + if len(fields) < 2: return None + + alt_text = fields[0].strip() + image_url = fields[-1].strip() + properties = fields[1:-1] + + width = None + height = None + align = None + for prop in properties: + if "=" not in prop: continue + key, _, value = prop.partition("=") + key = key.strip() + value = value.strip() + if key == "w": width = value + elif key == "h": height = value + elif key == "a": align = value + + widget = None + if image_url: + try: path = url_delegate.resolve_image(image_url) + except Exception as e: + RNS.trace_exception(e) # TODO: Remove + path = None + + if ImageWidget is not None: + try: + if not ImageWidget.placeholder_glyph: ImageWidget.placeholder_glyph = f"{url_delegate.g['image']} " + + widget = ImageWidget(path, name=alt_text) + for prop_name, prop_value in ( ("width", width), ("height", height), ("align", align) ): + if prop_value is not None: + try: + if isinstance(prop_value, str) and prop_value.isdigit(): prop_value = int(prop_value) + setattr(widget, prop_name, prop_value) + except Exception as e: + RNS.trace_exception(e) # TODO: Remove + pass + + except Exception as e: + RNS.trace_exception(e) # TODO: Remove + widget = None + + if widget is None: + placeholder = alt_text if alt_text else (image_url or "image") + widget = urwid.Text(f"[{url_delegate.g['warning']} Error loading image: {placeholder}]") + + widget.image_url = image_url + widget.image_alt = alt_text + if state["depth"] == 0: return [widget] + else: + padded = urwid.Padding(widget, left=left_indent(state), right=right_indent(state)) + padded._contained_image = widget + return [padded] + + except Exception as e: + # TODO: Remove + RNS.trace_exception(e) + return None + def render_table(lines, state, url_delegate): if len(lines) < 2: return None if state["table_maxwidth"]: max_width = state["table_maxwidth"] @@ -482,6 +550,10 @@ def parse_line(line, state, url_delegate): elif line.startswith("`{"): return parse_partial(line[2:]) + # Check for images + elif line.startswith("`("): + return parse_image(line[2:], state, url_delegate) + # Check for section heading reset elif first_char == "<": state["depth"] = 0 diff --git a/nomadnet/ui/textui/images/__init__.py b/nomadnet/ui/textui/images/__init__.py new file mode 100644 index 0000000..619be17 --- /dev/null +++ b/nomadnet/ui/textui/images/__init__.py @@ -0,0 +1,18 @@ +# A minimal, dependency-free image widget for urwid, rendering images through +# the Kitty terminal graphics protocol. + +from ._termlib import get_cell_size, is_kitty_supported, reset_caches, diagnostics +from ._imagedata import ImageData, parse_image_header +from .widget import ImageWidget, ImageCanvas, UI_MARGIN +from .screen import ImageScreen + +__all__ = ( "ImageWidget", + "ImageCanvas", + "ImageScreen", + "ImageData", + "parse_image_header", + "is_kitty_supported", + "get_cell_size", + "diagnostics", + "reset_caches", + "UI_MARGIN" ) diff --git a/nomadnet/ui/textui/images/_ctlseqs.py b/nomadnet/ui/textui/images/_ctlseqs.py new file mode 100644 index 0000000..564b7d2 --- /dev/null +++ b/nomadnet/ui/textui/images/_ctlseqs.py @@ -0,0 +1,78 @@ +# Kitty Terminal Graphics Protocol control sequences +# +# Minimal set of control sequences needed for the Kitty graphics protocol +# side-channel image rendering used by the urwid image widget. + +from base64 import standard_b64encode +import re + +# Basic building blocks +APC = b"\x1b_" # Application Program Command +ST = b"\x1b\\" # String Terminator +CSI = b"\x1b[" + +# Kitty graphics: transmit-and-display with chunking support. +# ESC _ G ; ESC \ +KITTY_TRANSMISSION = b"\x1b_G%s;%s\x1b\\" + +# Delete images: d=A (all placements visible on screen), d=i (one +# placement, data kept), d=I (whole image, data freed). +KITTY_DELETE_ALL = b"\x1b_Ga=d,d=A\x1b\\" +KITTY_DELETE_PLACEMENT = b"\x1b_Ga=d,d=i,i=%d,p=%d\x1b\\" +KITTY_DELETE_IMAGE = b"\x1b_Ga=d,d=I,i=%d\x1b\\" + +# Support query (payload is base64 of 4 zero bytes; expects "i=31;OK") +KITTY_QUERY = b"\x1b_Ga=q,t=d,i=31,f=24,s=1,v=1,C=1,c=1,r=1;AAAA\x1b\\" +KITTY_QUERY_RESPONSE_RE = re.compile(b"\x1b_Gi=31;(?P[^\x1b]*)\x1b\\\\") + +# Cursor: absolute positioning, erase characters, cursor forward +CURSOR_POSITION = b"\x1b[%d;%dH" +ERASE_CHARS = b"\x1b[%dX" + +# Synchronized output (DEC 2026) - reduces flicker/tearing when redrawing +BEGIN_SYNCED_UPDATE = b"\x1b[?2026h" +END_SYNCED_UPDATE = b"\x1b[?2026l" + +# Terminal queries: cell size in pixels (XTWINOPS 16) + primary DA +CELL_SIZE_PX = b"\x1b[16t" +DA1 = b"\x1b[c" +# Response: CSI 4 ; ; t +CELL_SIZE_RESPONSE_RE = re.compile(b"\x1b\\[4;(?P\\d+);(?P\\d+)t") + +# Maximum payload (base64) per transmission chunk, as recommended by the +# protocol documentation +CHUNK_SIZE = 4096 + +# Control data for a transmit-only (a=t) with a quiet mode (q=1) +def kitty_transmit_control(image_id): return b"a=t,i=%d,f=100,t=d,q=1" % image_id + +# A placement command (a=p) for a previously transmitted image. +# Re-sending the same (image id, placement id) pair replaces the +# placement in place - used to move/resize/slice without flicker. +# y/h select the source rectangle (image pixels) when a slice of +# the image is displayed. +def kitty_placement(image_id, placement_id, cols, rows, z, y=None, h=None): + control = b"a=p,i=%d,p=%d,c=%d,r=%d,z=%d,C=1,q=1" % (image_id, placement_id, cols, rows, z) + if y is not None and h is not None: control += b",y=%d,h=%d" % (y, h) + return KITTY_TRANSMISSION % (control, b"") + +# Encodes a kitty graphics transmission, chunking the base64-encoded +# payload into ``chunk_size``-byte pieces. +def kitty_transmission(control, payload, chunk_size=CHUNK_SIZE): + encoded = standard_b64encode(payload) + chunks = [encoded[i:i + chunk_size] for i in range(0, len(encoded), chunk_size)] + if not chunks: + chunks = [b""] + + def control_for(index, n_chunks): + if n_chunks == 1: + return control + if index == 0: + # The control data appears only in the first chunk; the chunk + # marker says more chunks are coming. + return control + b",m=1" + if index == n_chunks - 1: + return b"m=0" + return b"m=1" + + return b"".join(KITTY_TRANSMISSION % (control_for(i, len(chunks)), chunk) for i, chunk in enumerate(chunks)) diff --git a/nomadnet/ui/textui/images/_imagedata.py b/nomadnet/ui/textui/images/_imagedata.py new file mode 100644 index 0000000..294f72c --- /dev/null +++ b/nomadnet/ui/textui/images/_imagedata.py @@ -0,0 +1,137 @@ +# Image file handling for the side-channel renderer +# +# The Kitty graphics protocol can decode and scale images in the terminal +# itself (data format ``f=100``), which means the widget needs no image +# decoding library at all. It only needs the *dimensions* of the image to lay +# it out correctly in the urwid canvas; those are trivially parseable from the +# file headers of the supported formats. +# +# Supported source formats: +# - WebP (VP8, VP8L and VP8X) +# - PNG +# +# The payload transmitted to the terminal is always a PNG file: PNG sources +# are passed through verbatim, and WebP sources are converted transparently +# by the multi-backend conversion chain in :mod:`._webp` (no required +# dependencies). The conversion result is cached on disk, so each unique +# WebP is converted at most once per system. + +import hashlib +import os + +MAX_PAYLOAD_BYTES = 8 * 1024 * 1024 +MAX_CONVERTED_PAYLOAD_BYTES = 16 * 1024 * 1024 +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +# Returns (width, height) from a PNG file. +def _parse_png(data): + if not data.startswith(_PNG_SIGNATURE): raise ValueError("not a PNG") + if len(data) < 24 or data[12:16] != b"IHDR": raise ValueError("PNG missing IHDR chunk") + width = int.from_bytes(data[16:20], "big") + height = int.from_bytes(data[20:24], "big") + if not (width > 0 and height > 0): raise ValueError("PNG has null dimensions") + return width, height + +# Returns (width, height) from a WebP file (VP8, VP8L or VP8X). +def _parse_webp(data): + if not data.startswith(b"RIFF") or data[8:12] != b"WEBP": raise ValueError("not a WebP") + if len(data) < 30: raise ValueError("WebP file too short") + fourcc = data[12:16] + if fourcc == b"VP8X": + # 10-byte chunk: flags(1) reserved(3) canvas-w-1(3 LE) canvas-h-1(3 LE) + width = int.from_bytes(data[24:27], "little") + 1 + height = int.from_bytes(data[27:30], "little") + 1 + elif fourcc == b"VP8 ": + # frame tag(3) start code 0x9d012a(3) width(2 LE, 14 bits) height(2 LE, 14 bits) + width = int.from_bytes(data[26:28], "little") & 0x3FFF + height = int.from_bytes(data[28:30], "little") & 0x3FFF + elif fourcc == b"VP8L": + # signature(1) then 4 bytes: w-1 (14 bits) | h-1 (14 bits) | ... + bits = int.from_bytes(data[21:25], "little") + width = (bits & 0x3FFF) + 1 + height = ((bits >> 14) & 0x3FFF) + 1 + else: raise ValueError("unsupported WebP variant %r" % fourcc) + if not (width > 0 and height > 0): raise ValueError("WebP has null dimensions") + return width, height + + +def parse_image_header(data): + if data[:8] == _PNG_SIGNATURE: + w, h = _parse_png(data) + return "PNG", w, h + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + w, h = _parse_webp(data) + return "WebP", w, h + raise ValueError("unsupported image format (only PNG and WebP)") + +# Loads an image file and exposes its PNG bytes and dimensions. +# The payload is always a PNG file, transmitted verbatim to the terminal +# (Kitty's f=100 data format). The dimensions are used only for layout. +# WebP sources are converted transparently to PNG on load. +class ImageData(object): + def __init__(self, path, max_bytes=MAX_PAYLOAD_BYTES): + self.path = path + self.error = None + self.format = None + self.source_format = None + self.conversion_backend = None + self.width = 0 + self.height = 0 + self.data = None + try: + with open(path, "rb") as f: data = f.read() + except OSError as e: + self.error = "could not read file: %s" % e + return + + if len(data) > max_bytes: + self.error = "file too large (%.1f MiB > %d MiB)" % (len(data) / (1024 * 1024), max_bytes // (1024 * 1024)) + return + + if not data: + self.error = "empty file" + return + + try: self.format, self.width, self.height = parse_image_header(data) + except ValueError as e: + self.error = str(e) + return + + self.source_format = self.format + if self.format == "WebP": + from . import _webp + png = _webp.convert_webp_to_png(data) + if png is None: + self.error = "webp conversion failed (no working backend available)" + return + if len(png) > MAX_CONVERTED_PAYLOAD_BYTES: + self.error = "converted image too large (%.1f MiB > %d MiB)" % (len(png) / (1024 * 1024), MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024)) + return + + self.data = png + self.format = "PNG" + self.conversion_backend = _webp.last_backend() + + else: self.data = data + + # Content key for de-duplication: identical bytes = same key, so + # the same image transmitted once can be placed many times. + self._key = hashlib.sha256(self.data).hexdigest() + + @property + def ok(self): return self.data is not None + + @property + def key(self): return getattr(self, "_key", None) + + @property + def size(self): return (self.width, self.height) + + def description(self): + if self.ok: + if self.source_format == "WebP": + return "WebP %dx%d -> PNG (%s)" % (self.width, self.height, self.conversion_backend or "cached") + else: + return "%s %dx%d" % (self.format, self.width, self.height) + + return self.error diff --git a/nomadnet/ui/textui/images/_imagestore.py b/nomadnet/ui/textui/images/_imagestore.py new file mode 100644 index 0000000..78935b5 --- /dev/null +++ b/nomadnet/ui/textui/images/_imagestore.py @@ -0,0 +1,90 @@ +# Registry of terminal-side image data +# +# Tracks which image payloads have been transmitted to the terminal (Kitty +# image ids), de-duplicates identical image data across widgets (one payload +# transmitted once, placed many times), and counts live widget references so +# that orphaned data can be evicted from the terminal's buffers. +# +# This module only tracks *state*; all protocol output is performed by +# ImageScreen, which reads and updates the store. +# +# Eviction: +# +# - Automatic, widgets unregister when closed/destroyed; the screen evicts +# (deletes from the terminal) any registered data with no live references. +# - Explicit, the browser can call ImageScreen.purge_images() on page +# navigation to drop everything at once. + +DEFAULT_IMAGE_ID_MAX = 2 ** 32 - 1 # Protocol limit for image ids + +class ImageStore(object): + def __init__(self): + self._entries = {} # content key -> {"image_id", "data", "refs", "transmitted"} + self._next_image_id = 1 + + ############# + # Lifecycle # + ############# + + # Registers image data and returns (key, is_new). + # If identical data is already registered, the existing entry is + # reused (de-duplication) and its reference count is incremented. + def register(self, key, data): + entry = self._entries.get(key) + if entry is None: + image_id = self._next_image_id + self._next_image_id += 1 + if image_id > DEFAULT_IMAGE_ID_MAX: raise OverflowError("image id space exhausted") + entry = { "image_id": image_id, "data": data, + "refs": 0, "transmitted": False } + self._entries[key] = entry + is_new = True + else: is_new = False + + entry["refs"] += 1 + return key, is_new + + # Decrements the reference count of registered data + def unregister(self, key): + entry = self._entries.get(key) + if entry is None: return + entry["refs"] = max(0, entry["refs"] - 1) + + ########### + # Queries # + ########### + + def entry_for(self, key): return self._entries.get(key) + + def transmitted(self): return [key for key, entry in self._entries.items() if entry["transmitted"]] + + # Returns [(key, image_id), ...] for entries with no live + # widget references that are still present in the terminal. + def evict_orphans(self): + return [ (key, entry["image_id"]) + for key, entry in self._entries.items() + if entry["refs"] == 0 and entry["transmitted"] ] + + ############ + # Mutation # + ############ + + def mark_transmitted(self, key): + entry = self._entries.get(key) + if entry is not None: + entry["transmitted"] = True + + def mark_all_untransmitted(self): + for entry in self._entries.values(): + entry["transmitted"] = False + + # Drops the entry for evicted data (frees the payload bytes) + def evict(self, key): self._entries.pop(key, None) + + # Forgets all state (used by tests) + def reset(self): + self._entries.clear() + self._next_image_id = 1 + +# Module-level singleton shared by widgets and the screen. +image_store = ImageStore() diff --git a/nomadnet/ui/textui/images/_selftest.py b/nomadnet/ui/textui/images/_selftest.py new file mode 100644 index 0000000..e1755bb --- /dev/null +++ b/nomadnet/ui/textui/images/_selftest.py @@ -0,0 +1,681 @@ +#!/usr/bin/env python3 +""" +.. Images - headless self test + +Exercises the widget + screen machinery without a terminal: image header +parsing, display sizing, shard scanning, APC construction/chunking and the +transmit/delete state machine at various scroll positions. + +Run:: + + python3 -m nomadnet.ui.textui.images._selftest [image-file] +""" + +import base64 +import io +import os +import shutil +import struct +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) + +import urwid + +from nomadnet.ui.textui.images import _ctlseqs +from nomadnet.ui.textui.images import _imagestore +from nomadnet.ui.textui.images import _termlib +from nomadnet.ui.textui.images._imagedata import ImageData, parse_image_header +from nomadnet.ui.textui.images.screen import ImageScreen +from nomadnet.ui.textui.images.widget import ImageWidget + +DEFAULT_IMAGE = os.path.expanduser("~/Scratchpad/test.png") +VIEWPORT = (80, 24) +CELL_SIZE = (1, 2) # forced fallback for deterministic layout + +PASS = 0 +FAIL = 0 + + +def check(cond, label): + global PASS, FAIL + if cond: + PASS += 1 + print(" ok - %s" % label) + else: + FAIL += 1 + print(" FAIL - %s" % label) + + +def build_pile(image_path, wrap_attrmap=False): + widgets = [] + for i in range(12): + widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % i)) # 3 rows each + img = ImageWidget(image_path) + if wrap_attrmap: + img = urwid.AttrMap(img, "body") + widgets.append(urwid.Divider(" ")) + widgets.append(img) + widgets.append(urwid.Divider(" ")) + for i in range(12): + widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % (13 + i))) + return urwid.Pile(widgets) + + +class RecordingScreen(ImageScreen): + def __init__(self): + super().__init__() + self.log = [] + + def write(self, data): + if isinstance(data, bytes): + data = data.decode("ascii") + self.log.append(data) + return data + + def flush(self): + pass + + +def extract_transmit(log): + """Returns (image_id, joined_b64_payload) of the a=t transmission, or + None if no transmission is present.""" + image_id = None + payloads = [] + for item in log: + if item.startswith("\x1b_Ga=t"): + for piece in item.split("\x1b_G")[1:]: + body = piece[:-2] # strip the trailing ST (ESC \) + head, sep, payload = body.partition(";") + if not sep: + continue + if image_id is None: + image_id = int(head.split("i=")[1].split(",")[0]) + payloads.append(payload) + if image_id is None: + return None + return image_id, "".join(payloads) + + +def extract_placements(log): + """Returns the control data of every a=p placement command.""" + return [ + item[3:-2] + for item in log + if item.startswith("\x1b_Ga=p") + ] + + +def extract_deletes(log): + """Returns the control data of every a=d delete command.""" + return [ + item[3:-2] + for item in log + if item.startswith("\x1b_Ga=d") + ] + + +def screen_probe_placement_key(widget, row, col, canvas_cols, trim_top, vis_rows): + """Computes an ImageScreen placement key without a screen instance.""" + return ImageScreen._placement_key( + None, widget, row, col, canvas_cols, trim_top, vis_rows) + + +def test_header_parse(image_path): + print("== header parsing ==") + with open(image_path, "rb") as f: + data = f.read() + fmt, w, h = parse_image_header(data) + check(fmt == "PNG", "detected PNG") + check(w == 1280 and h == 960, "dimensions %dx%d" % (w, h)) + imgdata = ImageData(image_path) + check(imgdata.ok, "ImageData loads without error") + check(imgdata.size == (w, h), "ImageData size matches") + + +def test_transmission_encoding(image_path): + print("== APC encoding/chunking ==") + small = b"\x89PNG\r\n\x1a\n" + b"x" * 100 + single = _ctlseqs.kitty_transmission(b"a=T,f=100,c=80,r=30,z=1", small) + check(single.count(b"\x1b_G") == 1, "single-chunk transmission has one APC") + check(single.startswith(b"\x1b_Ga=T,f=100,c=80,r=30,z=1;"), "control data correct") + check(single.endswith(b"\x1b\\"), "ends with ST") + payload = single.split(b";", 1)[1][:-2] + check(base64.b64decode(payload) == small, "payload round-trips to original file") + + with open(image_path, "rb") as f: + data = f.read() + chunks = _ctlseqs.kitty_transmission(b"a=T,f=100", data, chunk_size=16) + apcs = chunks.split(b"\x1b\\")[:-1] + check(len(apcs) > 1, "multi-chunk transmission splits into %d APCs" % len(apcs)) + first, last = apcs[0], apcs[-1] + heads = [a.split(b";", 1)[0][3:] for a in apcs] + check(b",m=1;" in first, "first chunk carries control + m=1") + check(last.startswith(b"\x1b_Gm=0;"), "last chunk carries m=0") + check(sum(b"a=T" in h for h in heads) == 1, "control data appears only once") + check(all(h == b"m=1" or h == b"m=0" for h in heads[1:]), "continuation chunks carry only the chunk marker") + joined_b64 = b"".join(a.split(b";", 1)[1] for a in apcs) + check(base64.b64decode(joined_b64) == data, "multi-chunk payload reassembles") + + +_TINY_WEBP = base64.b64decode( + "UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuUAAA=") + + +def _webp_fixture(): + """Returns (webp_bytes, (width, height)): a 4x4 WebP generated with + Pillow when available (more representative), else a 1x1 constant.""" + try: + from PIL import Image + if _webp_module_available(): + image = Image.new("RGBA", (4, 4)) + for y in range(4): + for x in range(4): + image.putpixel((x, y), (x * 60, y * 60, x * y * 10, 255)) + buf = io.BytesIO() + image.save(buf, format="WEBP") + data = buf.getvalue() + if parse_image_header(data)[0] == "WebP": + return data, (4, 4) + except Exception: + pass + return _TINY_WEBP, (1, 1) + + +def _webp_module_available(): + try: + from PIL import features + return bool(features.check("webp")) + except Exception: + return False + + +def test_webp_conversion(): + print("== webp conversion ==") + from nomadnet.ui.textui.images import _webp + from nomadnet.ui.textui.images._imagedata import MAX_CONVERTED_PAYLOAD_BYTES + + fmt, w, h = parse_image_header(_TINY_WEBP) + check((fmt, w, h) == ("WebP", 1, 1), "embedded fixture is a 1x1 WebP") + + fixture, dims = _webp_fixture() + check(parse_image_header(fixture)[1:] == dims, + "webp fixture has expected dimensions") + + cache_tmp = tempfile.mkdtemp(prefix="imgw-test-cache-") + prev_cache_env = os.environ.get("NOMADNET_IMAGE_CACHE") + os.environ["NOMADNET_IMAGE_CACHE"] = cache_tmp + try: + # Each available backend must convert the fixture to a valid PNG + # with matching dimensions. Backends are invoked directly, since + # the on-disk cache would otherwise mask all but the first one. + _webp.reset() + present = [name for name, avail in _webp.available_backends() if avail] + converted = 0 + for name in present: + png = _webp._BACKENDS[name](fixture, dims) + ok = (png is not None and png.startswith(b"\x89PNG") + and _webp._png_dims(png) == dims) + check(ok, "%s backend converts to a valid PNG" % name) + if ok: + converted += 1 + check(converted >= 1, "at least one backend converts webp (got %d)" % converted) + + # Repeat conversion is served from the on-disk cache: the backend + # table is disabled and the conversion still succeeds. + _webp.reset() + png1 = _webp.convert_webp_to_png(fixture) + check(png1 is not None, "first conversion succeeds") + orig_backends = dict(_webp._BACKENDS) + _webp._BACKENDS = {name: (lambda *a, **k: None) for name in _webp.BACKENDS} + png2 = _webp.convert_webp_to_png(fixture) + check(png1 == png2 and _webp.stats()["cache_hits"] >= 1, + "repeat conversion served from the on-disk cache") + _webp._BACKENDS = orig_backends + + # ImageData-level transparency: a webp file loads as a PNG. + _webp.reset() + fd, webp_path = tempfile.mkstemp(suffix=".webp") + with os.fdopen(fd, "wb") as f: + f.write(fixture) + d = ImageData(webp_path) + check(d.ok and d.format == "PNG" and d.size == dims, + "ImageData converts webp transparently to PNG") + check(d.data.startswith(b"\x89PNG\r\n\x1a\n") and d.key is not None, + "converted payload is a keyed PNG") + check("-> PNG" in d.description(), "description notes the conversion") + os.unlink(webp_path) + + # Converted-payload cap. + _webp.reset() + orig_convert = _webp.convert_webp_to_png + _webp.convert_webp_to_png = lambda data: b"x" * (MAX_CONVERTED_PAYLOAD_BYTES + 1) + fd, webp_path = tempfile.mkstemp(suffix=".webp") + with os.fdopen(fd, "wb") as f: + f.write(fixture) + d = ImageData(webp_path) + check(not d.ok and "converted image too large" in d.error, + "converted payload capped at %d MiB" + % (MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024))) + os.unlink(webp_path) + _webp.convert_webp_to_png = orig_convert + + # Undecodable webp (valid header/dimensions, garbage bitstream). + _webp.reset() + junk = (b"RIFF" + struct.pack(" (10, 20)") + check(_termlib._cell_size_from_winsize([24, 80, 960, 480]) == (12, 20), + "correct winsize -> (12, 20)") + check(_termlib._cell_size_from_winsize([24, 80, 0, 0]) is None, + "zero pixel fields -> None") + check(_termlib._cell_size_from_winsize([40, 100, 800, 1000]) == (8, 25), + "portrait window cell keeps valid aspect") + check(_termlib._cell_size_from_winsize([30, 80, 1200, 240]) is None, + "implausible aspect (broken reporting) -> None") + check(_termlib._cell_size_from_winsize([0, 0, 0, 0]) is None, + "empty winsize -> None") + + +def test_widget_layout(image_path): + print("== widget layout ==") + _termlib.kitty_supported = True + _termlib.cell_size = CELL_SIZE + # Force a deterministic terminal size for the height-percent cases. + _orig_terminal_size = _termlib.get_terminal_size + _termlib.get_terminal_size = lambda: (80, 24) + _orig_ui_margin = None + from nomadnet.ui.textui.images import widget as _widget_mod + _orig_ui_margin = _widget_mod.UI_MARGIN + _widget_mod.UI_MARGIN = 8 + try: + imgdata = ImageData(image_path) + w = ImageWidget(image_path) + iw, ih = imgdata.size + expected_rows = max(1, round(80 * CELL_SIZE[0] * ih / (CELL_SIZE[1] * iw))) + rows = w.rows((80,)) + check(rows == expected_rows, + "default: rows((80,)) == %d, got %d" % (expected_rows, rows)) + canvas = w.render((80,)) + check(isinstance(canvas, urwid.SolidCanvas), "render returns a solid canvas") + check(canvas.rows() == rows and canvas.cols() == 80, "canvas sized 80x%d" % rows) + check(w._ti_rows == rows and w._ti_cols == 80, "widget stores display size") + w.close() + + # width as absolute columns (aspect preserved); the canvas fills the + # allocated width while the image rect is the resolved size. + w = ImageWidget(image_path, width=40) + c, r = w._display_size(80) + check((c, r) == (40, 15), "width=40 -> (40, 15), got (%d, %d)" % (c, r)) + canvas = w.render((80,)) + check((canvas.cols(), canvas.rows()) == (80, 15), + "width=40 canvas fills the row (80, 15), got (%d, %d)" + % (canvas.cols(), canvas.rows())) + check((w._ti_cols, w._ti_rows) == (40, 15), + "width=40 image rect stored (40, 15)") + w.close() + + # width as percent of the layout width + w = ImageWidget(image_path, width="50%") + c, r = w._display_size(80) + check((c, r) == (40, 15), "width=50%% -> (40, 15), got (%d, %d)" % (c, r)) + w.close() + + # height as absolute rows (width derived, aspect preserved) + w = ImageWidget(image_path, height=20) + c, r = w._display_size(80) + check(r == 20 and c == 53, "height=20 -> (53, 20), got (%d, %d)" % (c, r)) + w.close() + + # height as percent of the visible page height + # page = terminal lines (24) - UI_MARGIN (8) = 16; 50% -> 8 rows + w = ImageWidget(image_path, height="50%") + c, r = w._display_size(80) + check(r == 8 and c == 21, "height=50%% -> (21, 8), got (%d, %d)" % (c, r)) + w.close() + + # both given: stretch to the exact rectangle + w = ImageWidget(image_path, width=40, height=30) + c, r = w._display_size(80) + check((c, r) == (40, 30), "width=40,height=30 stretches -> (40, 30), got (%d, %d)" % (c, r)) + w.close() + + # stretching is clamped uniformly when overflowing the space + w = ImageWidget(image_path, width=400, height=200) + c, r = w._display_size(80) + check((c, r) == (80, 40), "overflowing stretch clamped -> (80, 40), got (%d, %d)" % (c, r)) + w.close() + + # BOX layout: the canvas fills the box; the image rect is clamped + # and re-derived (width from the clamped height). + w = ImageWidget(image_path, name="box") + canvas = w.render((80, 10)) + check((canvas.cols(), canvas.rows()) == (80, 10), + "BOX render((80,10)) canvas fills the box, got (%d, %d)" + % (canvas.cols(), canvas.rows())) + check((w._ti_cols, w._ti_rows) == (27, 10), + "BOX image rect clamped -> (27, 10), got (%d, %d)" + % (w._ti_cols, w._ti_rows)) + w.close() + + # invalid specifications + def raises(ctor, want): + try: + ctor() + except want: + return True + except Exception: + return False + return False + check(raises(lambda: ImageWidget(image_path, width="xx"), ValueError), + "width='xx' raises ValueError") + check(raises(lambda: ImageWidget(image_path, height=0), ValueError), + "height=0 raises ValueError") + check(raises(lambda: ImageWidget(image_path, width=1.5), TypeError), + "width=1.5 raises TypeError") + check(raises(lambda: ImageWidget(image_path, height="50"), ValueError), + "height='50' raises ValueError") + + # settable properties re-resolve and invalidate + w = ImageWidget(image_path) + w.width = "50%" + c, r = w._display_size(80) + check((c, r) == (40, 15), "set width=50%% property -> (40, 15)") + w.height = 10 + c, r = w._display_size(80) + check((c, r) == (40, 10), "width+height set -> stretch (40, 10), got (%d, %d)" % (c, r)) + w.width = None + w.height = 10 + c, r = w._display_size(80) + check((c, r) == (27, 10), "height-only after reset -> (27, 10), got (%d, %d)" % (c, r)) + w.close() + + # alignment: placement column of a narrowed image inside its canvas + w = ImageWidget(image_path, width=40) # default center + w.render((80,)) # sets _ti_cols/_ti_rows + check(w._ti_align == "|", "default alignment is center") + key = screen_probe_placement_key(w, 3, 0, 80, 0, 15) + check(key == (3, 20, 40, 15, None, None), + "centered placement col -> %s" % (key,)) + w.align = "left" + key = screen_probe_placement_key(w, 3, 0, 80, 0, 15) + check(key == (3, 0, 40, 15, None, None), "left placement col -> 0") + w.align = "right" + key = screen_probe_placement_key(w, 3, 0, 80, 0, 15) + check(key == (3, 40, 40, 15, None, None), "right placement col -> 40") + check(raises(lambda: ImageWidget(image_path, align="top"), ValueError), + "align='top' raises ValueError") + w.close() + finally: + _termlib.get_terminal_size = _orig_terminal_size + if _orig_ui_margin is not None: + _widget_mod.UI_MARGIN = _orig_ui_margin + + +def find_image_widget(pile): + for widget, _opts in pile.contents: + child = widget + if isinstance(child, urwid.AttrMap): + child = child.original_widget + if isinstance(child, ImageWidget): + return child + return None + + +def run_screen_test(image_path, wrap_attrmap, label): + print("== screen state machine (%s) ==" % label) + _termlib.kitty_supported = True + _termlib.cell_size = CELL_SIZE + _imagestore.image_store.reset() + + pile = build_pile(image_path, wrap_attrmap=wrap_attrmap) + scrollable = urwid.Scrollable(pile, force_forward_keypress=True) + screen = RecordingScreen() + + img_widget = find_image_widget(pile) + check(img_widget is not None, "image widget found in pile") + z = img_widget._ti_z + entry = _imagestore.image_store.entry_for(img_widget._ti_key) + check(entry is not None and entry["refs"] == 1, "widget registered with image store") + image_id = entry["image_id"] + b64 = base64.b64encode(img_widget._ti_data.data).decode("ascii") + + def entries_at(offset): + scrollable.set_scrollpos(offset) + canvas = scrollable.render(VIEWPORT) + return screen._ti_scan(canvas) + + # Build a timeline of image visibility across all scroll offsets. + # state = (trim_top, row, visible_rows) + timeline = [] + max_offset = 200 + for off in range(max_offset + 1): + entries = entries_at(off) + if not entries: + timeline.append((off, None)) + continue + widget, row, col, canvas_cols, trim_top, vis_rows = entries[0] + timeline.append((off, (trim_top, row, vis_rows))) + image_rows = img_widget._ti_rows + visible = [t for t in timeline if t[1] is not None] + check(len(visible) > 0, "image is visible at some scroll offset") + if not visible: + return + + def first_offset(pred): + for off, state in timeline: + if state is not None and pred(state): + return off + return None + + off_enter = first_offset(lambda s: s[0] == 0) # trim_top == 0 + off_clip = first_offset(lambda s: s[0] > 0) # top-clipped + check(off_enter is not None, "found offset with image fully entered") + check(off_clip is not None, "found offset with image top-clipped") + if off_enter is None or off_clip is None: + return + # State at the entry offset: (trim_top, row, visible_rows) + off_enter_vis_rows = timeline[off_enter][1][2] + + last_visible_off = visible[-1][0] + off_away = last_visible_off + 1 if last_visible_off < max_offset else None + check(off_away is not None, "found offset past the image") + + def update(offset): + screen.log = [] + scrollable.set_scrollpos(offset) + canvas = scrollable.render(VIEWPORT) + screen._ti_update(canvas) + return list(screen.log) + + # 1. before the image is visible: nothing + log = update(0) + check(not any("a=t" in l or "a=p" in l for l in log), + "nothing written while out of view") + check(not screen._ti_images, "nothing tracked while out of view") + + # 2. image entered (bottom-clipped): transmit once, place the visible + # slice (y=0 crop) at the bottom. + log = update(off_enter) + trans = extract_transmit(log) + placements = extract_placements(log) + check(trans is not None, "image data transmitted on entry") + if trans: + trans_id, pb64 = trans + check(trans_id == image_id, "transmit uses the store's image id") + check(pb64 == b64, "transmitted payload is the original file") + check(len(placements) == 1, "one placement on entry, got %d" % len(placements)) + if placements: + ctrl = placements[0] + check("i=%d" % image_id in ctrl and "p=%d" % z in ctrl, + "placement references image id and placement id") + check("c=80" in ctrl and "r=%d" % off_enter_vis_rows in ctrl, + "placement has display size") + check("y=0," in ctrl and "h=" in ctrl, "entry slice uses a y=0 crop") + check("z=%d" % z in ctrl and "C=1" in ctrl and "q=1" in ctrl, + "placement has z-index, C=1 and quiet mode") + check(len(screen._ti_images) == 1 and + screen._ti_images[id(img_widget)]["drawn"] is not None, + "image tracked as drawn") + check(entry["transmitted"], "store entry marked transmitted") + + # 3. move while visible: in-place placement update, no re-transmit, + # no delete. + log = update(off_enter + 1) + check(extract_transmit(log) is None, "position change does not re-transmit") + check(len(extract_placements(log)) == 1, "position change re-places in place") + check(not extract_deletes(log), "position change does not delete") + + # 4. top-clipped: placement with a y>0 crop. + log = update(off_clip) + placements = extract_placements(log) + check(len(placements) == 1, "top-clip places a slice, got %d" % len(placements)) + if placements: + ctrl = placements[0] + y_px = int(ctrl.split("y=")[1].split(",")[0].split(";")[0]) + check(",y=" in ctrl and "h=" in ctrl and y_px > 0, + "top-clipped placement has y>0 crop (y=%d)" % y_px) + + # 5. scrolled past: placement deleted, image data kept. + log = update(off_away) + deletes = extract_deletes(log) + check(deletes == ["a=d,d=i,i=%d,p=%d" % (image_id, z)], + "scroll-away deletes the placement only: %s" % deletes) + check(extract_transmit(log) is None, "scroll-away does not transmit") + check(id(img_widget) not in screen._ti_images, "widget untracked after scroll-away") + + # 6. scroll back: re-placed from the kept data, no re-transmission. + log = update(off_enter) + check(extract_transmit(log) is None, "scroll-back does not re-transmit") + check(len(extract_placements(log)) == 1, "scroll-back re-places the image") + + # 7. still visible, unchanged position: nothing written. + log = update(off_enter) + check(not extract_placements(log) and not extract_deletes(log), + "unchanged position writes nothing") + + # 8. widget closed -> orphaned data evicted from the terminal. + img_widget.close() + log = update(off_away) + deletes = extract_deletes(log) + check("a=d,d=I,i=%d" % image_id in deletes, + "orphaned image data evicted with d=I") + check(_imagestore.image_store.entry_for(img_widget._ti_key) is None, + "evicted entry dropped from the store") + + +def test_eviction_and_purge(image_path): + print("== eviction and purge ==") + _termlib.kitty_supported = True + _termlib.cell_size = CELL_SIZE + _imagestore.image_store.reset() + + # Shared source: two widgets with the same file share one store entry. + w1 = ImageWidget(image_path) + w2 = ImageWidget(image_path) + entry = _imagestore.image_store.entry_for(w1._ti_key) + check(entry["refs"] == 2, "identical sources share one registry entry (refs=%d)" + % entry["refs"]) + check(w2._ti_key == w1._ti_key, "identical sources have the same content key") + + w1.close() + check(entry["refs"] == 1, "closing one widget decrements refcount") + check(entry["transmitted"] is False, "no data transmitted yet") + + # Place w2 via a screen update and verify orphan eviction after close. + pile = urwid.Pile([urwid.Text("x"), urwid.Divider(" "), w2, + urwid.Divider(" "), urwid.Text("y")]) + scrollable = urwid.Scrollable(pile, force_forward_keypress=True) + screen = RecordingScreen() + image_id = entry["image_id"] + + def update(offset): + screen.log = [] + scrollable.set_scrollpos(offset) + screen._ti_update(scrollable.render(VIEWPORT)) + return list(screen.log) + + log = update(2) + check(extract_transmit(log) is not None, "image transmitted when widget visible") + check(entry["transmitted"], "entry marked transmitted") + + w2.close() + log = update(0) + deletes = extract_deletes(log) + check("a=d,d=I,i=%d" % image_id in deletes, "last reference closed -> d=I eviction") + check(_imagestore.image_store.entry_for(w2._ti_key) is None, + "entry dropped after eviction") + + # Purge: after transmitting, purge_images() deletes everything and the + # next redraw re-transmits for still-visible widgets. + _imagestore.image_store.reset() + w3 = ImageWidget(image_path) + pile = urwid.Pile([urwid.Text("x"), urwid.Divider(" "), w3, + urwid.Divider(" "), urwid.Text("y")]) + scrollable = urwid.Scrollable(pile, force_forward_keypress=True) + screen2 = RecordingScreen() + + def update2(offset): + screen2.log = [] + scrollable.set_scrollpos(offset) + screen2._ti_update(scrollable.render(VIEWPORT)) + return list(screen2.log) + + log = update2(2) + check(extract_transmit(log) is not None, "purge-test: image transmitted") + screen2.log = [] + screen2.purge_images() + check(any("a=d,d=A" in l for l in screen2.log), "purge deletes all (d=A)") + check(_imagestore.image_store.transmitted() == [], "purge marks all untransmitted") + + log = update2(2) + check(extract_transmit(log) is not None, "visible image re-transmitted after purge") + check(len(extract_placements(log)) == 1, "visible image re-placed after purge") + w3.close() + + +def main(): + image_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_IMAGE + print("Using image: %s" % image_path) + _termlib.reset_caches() + print("ambient cell size (env): %s" % (_termlib._query_cell_size(),)) + + test_header_parse(image_path) + test_transmission_encoding(image_path) + test_webp_conversion() + test_winsize_parsing() + test_widget_layout(image_path) + run_screen_test(image_path, wrap_attrmap=False, label="bare widget") + run_screen_test(image_path, wrap_attrmap=True, label="attrmap-wrapped") + test_eviction_and_purge(image_path) + + print("\n%d passed, %d failed" % (PASS, FAIL)) + return 1 if FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nomadnet/ui/textui/images/_termlib.py b/nomadnet/ui/textui/images/_termlib.py new file mode 100644 index 0000000..abe7575 --- /dev/null +++ b/nomadnet/ui/textui/images/_termlib.py @@ -0,0 +1,196 @@ +# Terminal queries for capability detection and layout metrics. +# +# All values are resolved once and cached; subsequent calls are instant. +# Queries should be performed *before* starting the urwid main loop, because +# they read directly from the terminal and would otherwise race with urwid's +# own input handling. If no terminal is available, or the queries time out, +# sensible fallbacks are used. + +import os +import select +import shutil +import sys +import termios +import time + +from . import _ctlseqs + +QUERY_TIMEOUT = 0.3 + +kitty_supported = None +cell_size = None + +# Last raw terminal query response (bytes), for diagnostics. +last_query_response = bytearray() +last_kitty_response = bytearray() + +# Last TIOCGWINSZ buffer (rows, cols, xpixel, ypixel), for diagnostics. +winsize = [0, 0, 0, 0] + +# Number of times a query attempt has been made; used to avoid repeated +# slow queries in a broken environment. +_query_failed = False + +def _open_tty(): + # Opens the active terminal for reading and writing. + for stream in ("out", "in", "err"): + try: return os.open(os.ttyname(getattr(sys, "__std%s__" % stream).fileno()), os.O_RDWR) + except (OSError, AttributeError): continue + try: return os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + except OSError: return -1 + + +def query_tty(request, timeout=QUERY_TIMEOUT): + # Sends a query to the terminal and returns the raw response. + global _query_failed + if _query_failed: return b"" + + fd = _open_tty() + if fd == -1: + _query_failed = True + return b"" + + # Never block indefinitely, even on a broken pseudo-terminal, + # select has a deadline and reads are non-blocking. + try: os.set_blocking(fd, False) + except OSError: pass + + old_attr = termios.tcgetattr(fd) + new_attr = termios.tcgetattr(fd) + # Raw-ish mode; disable canonical mode and echo. In canonical mode the + # terminal buffers input until a newline, which terminal query responses + # (for example. kitty's "i=31;OK") never contain. The read would block until + # timeout and the query would always "fail". + new_attr[3] &= ~(termios.ECHO | termios.ICANON) + new_attr[6][termios.VMIN] = 0 + new_attr[6][termios.VTIME] = 0 + try: + # TCSANOW (not TCSAFLUSH): TCSAFLUSH drains the output queue first, + # which can block indefinitely on some pseudo-terminals. + termios.tcsetattr(fd, termios.TCSANOW, new_attr) + try: os.write(fd, request) + except (BlockingIOError, OSError): return b"" + response = bytearray() + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: break + try: ready, _, _ = select.select([fd], [], [], remaining) + except (OSError, ValueError): break + if not ready: break + try: chunk = os.read(fd, 4096) + except (BlockingIOError, OSError): continue + if not chunk: break + response += chunk + if response.endswith(b"\x1b\\") or response.endswith(b"c"): break + last_query_response[:] = response + return bytes(response) + + finally: + try: termios.tcsetattr(fd, termios.TCSANOW, old_attr) + except termios.error: pass + finally: os.close(fd) + +def is_kitty_supported(): + global kitty_supported + if kitty_supported is None: + response = query_tty(_ctlseqs.KITTY_QUERY) + last_kitty_response[:] = response + kitty_supported = bool(response and _ctlseqs.KITTY_QUERY_RESPONSE_RE.search(response)) + return kitty_supported + +def force_kitty_supported(status): + # Forces the support status (used by tests). + global kitty_supported + kitty_supported = bool(status) + +def _cell_size_from_winsize(buf): + # Interprets a TIOCGWINSZ buffer as (cell_width_px, cell_height_px). + # The struct layout is (rows, cols, xpixel, ypixel). Returns None if the + # terminal does not report pixel dimensions, or if the result is + # implausible (e.g. several times wider than tall - a sign of broken + # winsize reporting). + rows, cols, xpix, ypix = buf + if not (cols and rows and xpix and ypix): return None + cell_w, cell_h = max(1, xpix // cols), max(1, ypix // rows) + if not (0.1 <= cell_w / cell_h <= 1.5): + # Real terminal cells should be as wide as, or narrower than, + # they are tall; anything far outside that range is bogus. + return None + return (cell_w, cell_h) + +def _query_cell_size(): + # Determines the current cell size in pixels, or None if unknown. + fd = _open_tty() + if fd != -1: + # First, try the TIOCGWINSZ pixel fields (fast, no query) + import array + import fcntl + try: + buf = array.array("H", [0, 0, 0, 0]) + fcntl.ioctl(fd, termios.TIOCGWINSZ, buf) + winsize[:] = buf + cell = _cell_size_from_winsize(buf) + if cell is not None: return cell + + except OSError: pass + finally: os.close(fd) + + # Fall back to XTWINOPS 16 (cell size in pixels); DA1 is appended to + # speed up the response (terminals treat queries as FIFO). + response = query_tty(_ctlseqs.CELL_SIZE_PX + _ctlseqs.DA1) + if response: + match = _ctlseqs.CELL_SIZE_RESPONSE_RE.search(response) + if match: return (int(match.group("w")), int(match.group("h"))) + return None + +def get_cell_size(): + # Returns the terminal cell size in pixels as (width, height), or None + # if it could not be determined. + global cell_size + if cell_size is None: cell_size = _query_cell_size() + return cell_size + + +def get_terminal_size(): + # Returns the active terminal size as (columns, lines). + # Uses the tty directly when available (correct even with redirected + # output), falling back to shutil.get_terminal_size. + + fd = _open_tty() + if fd != -1: + try: + size = os.get_terminal_size(fd) + return (size.columns, size.lines) + except OSError: pass + finally: os.close(fd) + size = shutil.get_terminal_size(fallback=(80, 24)) + return (size.columns, size.lines) + +def reset_caches(): + # Clears all cached capability/metric values (used by tests). + global kitty_supported, cell_size, _query_failed + kitty_supported = None + cell_size = None + _query_failed = False + last_query_response[:] = b"" + last_kitty_response[:] = b"" + winsize[:] = (0, 0, 0, 0) + +def diagnostics(): + # Returns a list of diagnostic strings about the terminal environment, + # for display/debugging. + import os + lines = [ "TERM=%r" % os.environ.get("TERM"), + "TERM_PROGRAM=%r TERM_PROGRAM_VERSION=%r" % (os.environ.get("TERM_PROGRAM"), os.environ.get("TERM_PROGRAM_VERSION")), + "kitty graphics protocol supported: %s" % is_kitty_supported(), + "kitty query response: %r" % bytes(last_kitty_response), + "cell size (px): %s" % (get_cell_size(),), + "cell size query response: %r" % bytes(last_query_response), + "winsize (rows, cols, xpix, ypix): %r" % (tuple(winsize),) ] + try: + from . import _webp + lines.append("webp conversion: %s" % _webp.describe()) + except Exception: pass + + return lines diff --git a/nomadnet/ui/textui/images/_webp.py b/nomadnet/ui/textui/images/_webp.py new file mode 100644 index 0000000..e47743c --- /dev/null +++ b/nomadnet/ui/textui/images/_webp.py @@ -0,0 +1,367 @@ +# Multi-backend WebP decoding +# +# Kitty's graphics protocol decodes PNG natively but not WebP, so WebP +# sources are converted transparently at the ImageData layer. This module +# provides a conversion chain with that *should* work almost anywhere with +# zero extra dependencies: +# +# - Pillow/PIL, if installed with WebP support +# - libwebp via ctypes, decoding to RGBA, paired with a pure-Python PNG +# encoder. The system libwebp is present on most Linux/macOS systems +# as a transitive dependency of common graphics stacks and browsers +# - sips - the macOS built-in image tool +# - dwebp - from the system PATH (the "official" WebP tool) +# - ImageMagick / GraphicsMagick (magick, convert, gm convert) +# - ffmpeg / avconv +# - gdk-pixbuf-thumbnailer (GTK/GNOME systems) +# +# Every backend's output is validated (it must be a valid PNG with the same +# dimensions as the source WebP), so missing tools, or tools built without +# WebP support, are simply skipped. The first backend that produces a valid +# conversion is remembered and preferred for subsequent conversions, but the +# preference is replaced whenever a different backend wins for a particular +# image. +# +# Environment overrides: +# +# NOMADNET_IMAGE_BACKEND force a specific backend (strict) +# NOMADNET_IMAGE_CACHE override the conversion cache directory + +import ctypes +import ctypes.util +import hashlib +import io +import os +import shutil +import struct +import subprocess +import tempfile +import zlib + +MAX_CACHE_BYTES = 192 * 1024 * 1024 +MAX_CACHE_FILES = 512 +CONVERSION_TIMEOUT = 5 + +# libwebp soname candidates, in order of preference. +_LIBWEBP_NAMES = [ "libwebp.so.7", + "libwebp.so.6", + "libwebp.so.5", + "libwebp.so", + "libwebp.dylib", + "libwebp-7.dll", + "libwebp.dll" ] + +# The built-in backend chain, in preference order. +BACKENDS = ( "pil", + "libwebp-ctypes", + "sips", + "dwebp", + "magick", + "convert", + "gm", + "ffmpeg", + "avconv", + "gdk-pixbuf" ) + +_ENV_BACKEND = os.environ.get("NOMADNET_IMAGE_BACKEND") +_winner = None # name of the currently preferred backend +_last_backend = None # backend used for the most recent conversion +_convert_count = 0 +_cache_hits = 0 + +_libwebp_cache = {"lib": None, "probed": False} + +############################### +# PNG validation and encoding # +############################### + +# Returns (width, height) if png is a valid PNG, else None +def _png_dims(png): + if not png.startswith(b"\x89PNG\r\n\x1a\n") or len(png) < 24: + return None + if png[12:16] != b"IHDR": + return None + width = int.from_bytes(png[16:20], "big") + height = int.from_bytes(png[20:24], "big") + if not (width > 0 and height > 0): + return None + return (width, height) + + +# Encodes RGBA pixel data as a PNG (8-bit RGBA, no filtering) +def _encode_png_rgba(rgba, width, height): + def chunk(tag, payload): + return (struct.pack(">I", len(payload)) + tag + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + stride = width * 4 + raw = bytearray(height * (stride + 1)) + pos = 0 + for row in range(height): + raw[pos] = 0 # filter type: none + pos += 1 + start = row * stride + raw[pos:pos + stride] = rgba[start:start + stride] + pos += stride + + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + chunk(b"IEND", b"")) + + +#################### +# Conversion cache # +#################### + +def cache_dir(): + base = os.environ.get("NOMADNET_IMAGE_CACHE") + if not base: + base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache") + base = os.path.join(base, "nomadnet", "image-cache") + return base + +def _cache_path(source_data): + return os.path.join(cache_dir(), hashlib.sha256(source_data).hexdigest() + ".png") + +def _cache_get(source_data): + try: + with open(_cache_path(source_data), "rb") as f: png = f.read() + if _png_dims(png) is not None: return png + except OSError: pass + return None + +def _cache_put(source_data, png): + try: + directory = cache_dir() + os.makedirs(directory, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory) + try: + with os.fdopen(fd, "wb") as f: f.write(png) + os.replace(tmp_path, _cache_path(source_data)) + except OSError: + try: os.unlink(tmp_path) + except OSError: pass + _prune_cache() + except OSError: pass + +# Evicts least-recently-used entries beyond the size/file limits +def _prune_cache(): + try: + entries = [] + for name in os.listdir(cache_dir()): + path = os.path.join(cache_dir(), name) + try: + st = os.stat(path) + if st.st_size > 0: + entries.append((st.st_mtime, st.st_size, path)) + except OSError: pass + + total = sum(e[1] for e in entries) + entries.sort() + while len(entries) > MAX_CACHE_FILES or total > MAX_CACHE_BYTES: + _, size, path = entries.pop(0) + total -= size + try: os.unlink(path) + except OSError: pass + except OSError: pass + +####################### +# Individual backends # +####################### + +# Runs a conversion CLI over temp files +def _run_cli(argv, data): + if shutil.which(argv[0]) is None: return None + try: + with tempfile.TemporaryDirectory(prefix="nomadnet-img-") as td: + src = os.path.join(td, "source.webp") + dst = os.path.join(td, "output.png") + with open(src, "wb") as f: f.write(data) + args = [a.replace("{src}", src).replace("{dst}", dst) for a in argv] + subprocess.run( + args, + timeout=CONVERSION_TIMEOUT, + # stdin must be closed: some tools (e.g. ffmpeg) block + # on inherited stdin otherwise. + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + with open(dst, "rb") as f: return f.read() + except (OSError, subprocess.TimeoutExpired, ValueError): return None + +def _backend_pil(data, dims): + try: + from PIL import Image + from PIL import features + if not features.check("webp"): return None + except ImportError: return None + try: + image = Image.open(io.BytesIO(data)) + image.load() + buf = io.BytesIO() + image.save(buf, format="PNG") + return buf.getvalue() + except Exception: return None + +# Loads the system libwebp via ctypes, caching the result. +def _get_libwebp(): + if _libwebp_cache["probed"]: return _libwebp_cache["lib"] + _libwebp_cache["probed"] = True + lib = None + names = list(_LIBWEBP_NAMES) + found = ctypes.util.find_library("webp") + if found and found not in names: names.insert(0, found) + for name in names: + try: + lib = ctypes.CDLL(name) + break + except OSError: continue + + if lib is not None: + lib.WebPGetInfo.restype = ctypes.c_int + lib.WebPGetInfo.argtypes = [ ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int) ] + + lib.WebPDecodeRGBA.restype = ctypes.c_void_p + lib.WebPDecodeRGBA.argtypes = [ ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int) ] + lib.WebPFree.restype = None + lib.WebPFree.argtypes = [ctypes.c_void_p] + + _libwebp_cache["lib"] = lib + return lib + +def _backend_ctypes(data, dims): + lib = _get_libwebp() + if lib is None: return None + try: + buf = ctypes.create_string_buffer(data, len(data)) + width = ctypes.c_int(0) + height = ctypes.c_int(0) + if not lib.WebPGetInfo(buf, len(data), ctypes.byref(width), ctypes.byref(height)): return None + if width.value <= 0 or height.value <= 0: return None + ptr = lib.WebPDecodeRGBA(buf, len(data), ctypes.byref(width), ctypes.byref(height)) + if not ptr: return None + try: rgba = ctypes.string_at(ptr, width.value * height.value * 4) + finally: lib.WebPFree(ptr) + return _encode_png_rgba(rgba, width.value, height.value) + except Exception: return None + +def _backend_gdkpixbuf(data, dims): + if dims is None: return None + width, height = dims + return _run_cli(["gdk-pixbuf-thumbnailer", "-s", "%dx%d" % (width, height), "{src}", "{dst}"], data) + +def _backend_sips(data, dims): return _run_cli(["sips", "-s", "format", "png", "{src}", "--out", "{dst}"], data) +def _backend_dwebp(data, dims): return _run_cli(["dwebp", "{src}", "-o", "{dst}"], data) +def _backend_magick(data, dims): return _run_cli(["magick", "{src}", "{dst}"], data) +def _backend_convert(data, dims): return _run_cli(["convert", "{src}", "{dst}"], data) +def _backend_gm(data, dims): return _run_cli(["gm", "convert", "{src}", "{dst}"], data) +def _backend_ffmpeg(data, dims): return _run_cli(["ffmpeg", "-y", "-loglevel", "error", "-i", "{src}", "-frames:v", "1", "{dst}"], data) +def _backend_avconv(data, dims): return _run_cli(["avconv", "-y", "-loglevel", "error", "-i", "{src}", "-frames:v", "1", "{dst}"], data) + +_BACKENDS = { "pil": _backend_pil, + "libwebp-ctypes": _backend_ctypes, + "sips": _backend_sips, + "dwebp": _backend_dwebp, + "magick": _backend_magick, + "convert": _backend_convert, + "gm": _backend_gm, + "ffmpeg": _backend_ffmpeg, + "avconv": _backend_avconv, + "gdk-pixbuf": _backend_gdkpixbuf } + +##################### +# Conversion driver # +##################### + +def _webp_dims(data): + from ._imagedata import parse_image_header + try: fmt, width, height = parse_image_header(data) + except ValueError: return None + if fmt != "WebP": return None + return (width, height) + +def _backend_order(): + if _ENV_BACKEND: return [_ENV_BACKEND] if _ENV_BACKEND in BACKENDS else [] + order = list(BACKENDS) + if _winner in order: + order.remove(_winner) + order.insert(0, _winner) + return order + +# Converts WebP data to PNG bytes, or None on failure +def convert_webp_to_png(data): + global _cache_hits + png = _cache_get(data) + if png is not None: + _cache_hits += 1 + return png + return _convert_uncached(data) + +def _convert_uncached(data): + global _convert_count, _winner, _last_backend + dims = _webp_dims(data) + if dims is None: return None + for name in _backend_order(): + try: png = _BACKENDS[name](data, dims) + except Exception: png = None + if png is not None and _png_dims(png) == dims: + _convert_count += 1 + _last_backend = name + if not _ENV_BACKEND: _winner = name + _cache_put(data, png) + return png + + return None + +################################## +# Introspection and test support # +################################## + +# Returns [(name, available), ...] for the built-in chain +def available_backends(): + out = [] + for name in BACKENDS: + if name == "pil": + try: + from PIL import features + out.append((name, bool(features.check("webp")))) + except Exception: out.append((name, False)) + elif name == "libwebp-ctypes": out.append((name, _get_libwebp() is not None)) + elif name == "gdk-pixbuf": out.append((name, shutil.which("gdk-pixbuf-thumbnailer") is not None)) + else: out.append((name, shutil.which(name) is not None)) + + return out + +# A diagnostic string describing the conversion setup +def describe(): + if _ENV_BACKEND: return "backend forced via NOMADNET_IMAGE_BACKEND: %s" % _ENV_BACKEND + present = [name for name, avail in available_backends() if avail] + if not present: return "no conversion backend available (WebP images will not render)" + return "backend(s) available: %s; preferred: %s" % (", ".join(present), _last_backend or _winner or "none yet") + +# Usage statistics for diagnostics +def stats(): + return { "backend": _last_backend, "conversions": _convert_count, + "cache_hits": _cache_hits, "cache_dir": cache_dir() } + +def last_backend(): return _last_backend + +# Forces a specific backend name (testing/debugging); None to clear. +def force_backend(name): + global _ENV_BACKEND + _ENV_BACKEND = name if name in BACKENDS else None + +# Clears dynamic state (winner, counters); used by tests. +def reset(): + global _winner, _last_backend, _convert_count, _cache_hits + _winner = None + _last_backend = None + _convert_count = 0 + _cache_hits = 0 + _libwebp_cache["lib"] = None + _libwebp_cache["probed"] = False diff --git a/nomadnet/ui/textui/images/screen.py b/nomadnet/ui/textui/images/screen.py new file mode 100644 index 0000000..c7a9f59 --- /dev/null +++ b/nomadnet/ui/textui/images/screen.py @@ -0,0 +1,210 @@ +import urwid + +from . import _ctlseqs +from ._imagestore import image_store +from ._termlib import is_kitty_supported +from .widget import ImageWidget + +# A raw display screen subclass with Kitty TGP side-channel support +class ImageScreen(urwid.raw_display.Screen): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ti_images = {} + + ########## + # Output # + ########## + + def write(self, data): + if isinstance(data, bytes): data = data.decode("ascii") + return super().write(data) + + ############# + # Lifecycle # + ############# + + def clear(self): + # Clears all images and forces a full repaint + if is_kitty_supported(): + self.write(_ctlseqs.KITTY_DELETE_ALL) + self._ti_images = {} + image_store.mark_all_untransmitted() + return super().clear() + + def _start(self, *args, **kwargs): + ret = super()._start(*args, **kwargs) + if is_kitty_supported(): + # Clear any images left over from a previous run/instance. + self.write(_ctlseqs.KITTY_DELETE_ALL) + self._ti_images = {} + image_store.mark_all_untransmitted() + return ret + + def _stop(self): + self.purge_images() + try: return super()._stop() + finally: self._ti_images = {} + + def purge_images(self): + # Purges all image data from the terminal buffer. + if not is_kitty_supported(): return + if self._ti_images or image_store.transmitted(): + self.write(_ctlseqs.KITTY_DELETE_ALL) + self._ti_images = {} + image_store.mark_all_untransmitted() + + ########### + # Drawing # + ########### + + def draw_screen(self, size, canvas): + # Draws the canvas, then updates on-screen images within a + # synchronized-update region to prevent flicker. + self.write(_ctlseqs.BEGIN_SYNCED_UPDATE) + try: + super().draw_screen(size, canvas) + self._ti_update(canvas) + finally: + self.write(_ctlseqs.END_SYNCED_UPDATE) + self.flush() + + def _ti_update(self, canvas): + # Places/updates/deletes image placements to match the current + # canvas, and evicts orphaned image data. + if not is_kitty_supported(): return + + entries = self._ti_scan(canvas) + current = {} + for widget, row, col, canvas_cols, trim_top, vis_rows in entries: + current[id(widget)] = (widget, row, col, canvas_cols, trim_top, vis_rows) + + # For widgets that scrolled completely out of view, remove their + # placement only. The image data is kept in the terminal so that + # scrolling back just re-places it. + for wid, info in list(self._ti_images.items()): + if wid in current: continue + self._ti_unplace(info["widget"]) + del self._ti_images[wid] + + # For visible widgets, place/update the visible slice in place. + for wid, (widget, row, col, canvas_cols, trim_top, vis_rows) in current.items(): + info = self._ti_images.setdefault(wid, {"widget": widget, "drawn": None}) + if vis_rows <= 0 or trim_top >= widget._ti_rows: + if info["drawn"] is not None: + self._ti_unplace(widget) + info["drawn"] = None + continue + + key = self._placement_key(widget, row, col, canvas_cols, trim_top, vis_rows) + if key is None: + if info["drawn"] is not None: + self._ti_unplace(widget) + info["drawn"] = None + continue + + if info["drawn"] == key: continue + self._ti_place(widget, key) + info["drawn"] = key + + # Evict image data that no live widget references any more + for key, image_id in image_store.evict_orphans(): + self.write(_ctlseqs.KITTY_DELETE_IMAGE % image_id) + image_store.evict(key) + + ############# + # Internals # + ############# + + def _ti_scan(self, canvas): + # Walks the canvas shards and returns a list of + # (widget, row, col, canvas_cols, trim_top, visible_rows) + # for every visible image canvas. + found = [] + if not isinstance(canvas, urwid.CompositeCanvas): return found + + def process_shard_tails(): + nonlocal col + while col in shard_tails: + *trim, cols, rows, canv = shard_tails[col] + if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv) + else: del shard_tails[col] + col += cols + + shard_tails = {} + row = 0 + for n_rows, cviews in canvas.shards: + col = 0 + for cview in cviews: + process_shard_tails() + *trim, cols, rows, _, canv = cview + widget = self._ti_widget_of(canv) + if widget is not None: found.append((widget, row, col, cols, trim[1], rows)) + if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv) + col += cols + + process_shard_tails() + row += n_rows + + return found + + @staticmethod + def _ti_widget_of(canv): + # Returns the ImageWidget for a leaf canvas, or None + widget_info = getattr(canv, "_widget_info", None) + if not widget_info: return None + widget = widget_info[0] + if isinstance(widget, ImageWidget) and widget._ti_data.ok and not widget._ti_closed: return widget + return None + + def _placement_key(self, widget, row, col, canvas_cols, trim_top, vis_rows): + # Compute placement parameters for the visible slice of + # an image, including the alignment offset. + + cols = widget._ti_cols + rows = widget._ti_rows + if not (cols and rows): return None + place_col = col + if cols < canvas_cols: + align = widget._ti_align + spare = canvas_cols - cols + if align == ">": place_col += spare + elif align == "|": place_col += spare // 2 + + vis_rows = max(1, min(vis_rows, rows)) + y_px = h_px = None + if not (trim_top == 0 and vis_rows == rows): + # Crop to the visible slice of the image, in image pixels + ih = widget._ti_data.height + y_px = min(ih - 1, round(trim_top * ih / rows)) + h_px = max(1, min(ih - y_px, round(vis_rows * ih / rows))) + + return (row, place_col, cols, vis_rows, y_px, h_px) + + def _ti_place(self, widget, key): + # Place (or in-place update) the visible slice of an image + row, place_col, cols, vis_rows, y_px, h_px = key + entry = image_store.entry_for(widget._ti_key) + if entry is None: + # This should not happen, but if the image data was evicted + # while the image was still alive, re-register it so the + # data can be re-transmitted to the terminal buffer. + image_store.register(widget._ti_key, widget._ti_data.data) + entry = image_store.entry_for(widget._ti_key) + image_id = entry["image_id"] + + if not entry["transmitted"]: + payload = widget._ti_data.data + b64_len = (len(payload) * 4 + 2) // 3 + n_chunks = max(1, (b64_len + _ctlseqs.CHUNK_SIZE - 1) // _ctlseqs.CHUNK_SIZE) + self.write(_ctlseqs.kitty_transmission(_ctlseqs.kitty_transmit_control(image_id), payload)) + entry["transmitted"] = True + + self.write(_ctlseqs.CURSOR_POSITION % (row + 1, place_col + 1)) + self.write(_ctlseqs.kitty_placement(image_id, widget._ti_z, cols, vis_rows, widget._ti_z, y=y_px, h=h_px)) + + def _ti_unplace(self, widget): + # Delete a widget's placement, but keep the + # image data in the terminal buffer. + entry = image_store.entry_for(widget._ti_key) + if entry is None: return + self.write(_ctlseqs.KITTY_DELETE_PLACEMENT % (entry["image_id"], widget._ti_z)) diff --git a/nomadnet/ui/textui/images/widget.py b/nomadnet/ui/textui/images/widget.py new file mode 100644 index 0000000..23cdaa0 --- /dev/null +++ b/nomadnet/ui/textui/images/widget.py @@ -0,0 +1,276 @@ +# An urwid widget that occupies the space an image should fill in a layout, +# without embedding any escape sequences in its canvas. The actual terminal +# graphics are transmitted by the ImageScreen class which tracks the widget's +# on-screen position after every draw. +# +# This separation keeps the canvas trivially simple (urwid sees only blank +# rows) and lets the screen handle the hard parts; positioning, deletion when +# scrolled away, terminal resize and redraws - all of which are driven by +# urwid's own shard bookkeeping rather than by fragile escape-sequence +# manipulation inside canvas rows. +# +# When the Kitty graphics protocol is unavailable (or the image cannot be +# loaded), the widget renders a simple text placeholder instead. + +import os +import re +import urwid + +from . import _termlib +from ._imagestore import image_store + +DEFAULT_CELL_SIZE = (1, 2) # Fallback, assume a 1:2 cell aspect ratio +MAX_Z_INDEX = 2 ** 31 - 1 +UI_MARGIN = 8 +_SIZE_SPEC_RE = re.compile(r"(\d+(?:\.\d+)?)%") +_ALIGNS = { "left": "<", "<": "<", + "center": "|", "|": "|", + "right": ">", ">": ">" } + +def _check_align(value): + if value not in _ALIGNS: raise ValueError("invalid alignment %r (expected 'left', 'center' or 'right')" % (value,)) + return _ALIGNS[value] + + +def _check_size_spec(value, name): + if value is None: return None + + if isinstance(value, int): + if value <= 0: raise ValueError("%s must be a positive integer, got %r" % (name, value)) + return value + + if isinstance(value, str): + match = _SIZE_SPEC_RE.fullmatch(value) + if not match or float(match.group(1)) <= 0: + raise ValueError("invalid %s specification %r (expected a positive integer or a percent string like '50%%')" % (name, value)) + return value + + raise TypeError("invalid type for %s (got %s)" % (name, type(value).__name__)) + + +class ImageWidget(urwid.Widget): + # A widget that displays an image via the Kitty graphics protocol. + # The widget is a plain FLOW/BOX widget; its canvas is blank. The + # ImageScreen class performs the actual drawing over the Kitty + # TGP side-channel. + + placeholder_glyph = "" + + _sizing = frozenset((urwid.FLOW, urwid.BOX)) + ignore_focus = True + + # Z-index allocation. Each widget instance gets a unique z-index so that + # the screen can delete exactly one image when it scrolls away or moves. + _ti_next_z = 1 + _ti_free_z = set() + + def __init__(self, path, name=None, width=None, height=None, align="center"): + if path and not isinstance(path, (str, os.PathLike)): raise TypeError("path must be a string or path-like object") + self.path = path + self._notice = None + self._ti_path = None + self._ti_data = None + self._ti_width = _check_size_spec(width, "width") + self._ti_height = _check_size_spec(height, "height") + self._ti_align = _check_align(align) + self._ti_name = name if name is not None else "" + self._ti_z = self._ti_alloc_z() + self.image_id = self._ti_z + + # Display size (cols, rows) from the most recent render. + # Used by the screen to place the image. + self._ti_cols = None + self._ti_rows = None + self._ti_closed = False + self._ti_key = None + super().__init__() + if self.path: self.load() + + def load(self, path=None): + if path: self.path = path + if self.path: + from ._imagedata import ImageData + self._ti_path = os.path.abspath(os.fspath(self.path)) + self._ti_data = ImageData(self._ti_path) + + if self._ti_data.ok: + # Register with the image store (de-duplicates identical data + # and allocates the terminal-side image id on first use). + self._ti_key = self._ti_data.key + image_store.register(self._ti_key, self._ti_data.data) + self._invalidate() + + def notice(self, msg=""): + if not msg: self._notice = None + else: self._notice = str(msg) + self._invalidate() + + def __del__(self): + try: type(self)._ti_free_z.add(self._ti_z) + except Exception: pass + try: self.close() + except Exception: pass + + def close(self): + # Releases the widget's reference to its image data. + # The data is evicted from the terminal by the screen as soon as no + # live widget references it any more. Safe to call multiple times and + # from __del__. + try: + if not self._ti_closed: + self._ti_closed = True + if self._ti_key is not None: image_store.unregister(self._ti_key) + except Exception: pass + + @classmethod + def _ti_alloc_z(cls): + if cls._ti_free_z: return cls._ti_free_z.pop() + z = cls._ti_next_z + if z >= MAX_Z_INDEX: raise RuntimeError("Too many image widgets") + cls._ti_next_z += 1 + return z + + ########## + # Sizing # + ########## + + width = property( + lambda self: self._ti_width, + doc="""Display width specification: None (full width), a positive + int (columns) or a percent string ("NN%") of the available layout + width.""", + ) + + @width.setter + def width(self, value): + self._ti_width = _check_size_spec(value, "width") + self._invalidate() + + height = property( + lambda self: self._ti_height, + doc="""Display height specification: None (derived, aspect + preserving), a positive int (rows) or a percent string ("NN%") of + the visible page height.""", + ) + + @height.setter + def height(self, value): + self._ti_height = _check_size_spec(value, "height") + self._invalidate() + + align = property( + lambda self: self._ti_align, + doc="""Horizontal alignment of the image within its allocated + space: ``"<"`` (left), ``"|"`` (center) or ``">"`` (right).""", + ) + + @align.setter + def align(self, value): + self._ti_align = _check_align(value) + self._invalidate() + + def _short(self): return "img#%x" % (id(self) & 0xFFFFF) + + def _display_size(self, maxcol, maxrow=None): + if not self._ti_data.ok: return (min(maxcol, 1), 1) + iw, ih = self._ti_data.size + cell_w, cell_h = _termlib.get_cell_size() or DEFAULT_CELL_SIZE + w_spec, h_spec = self._ti_width, self._ti_height + + def _pct(value): return float(value[:-1]) / 100.0 + + def _raw_width(): + if w_spec is None: return float(maxcol) + if isinstance(w_spec, int): return float(w_spec) + return maxcol * _pct(w_spec) + + def _raw_height(): + if h_spec is None: return None + if isinstance(h_spec, int): return float(h_spec) + columns, lines = _termlib.get_terminal_size() + page = max(1, lines - UI_MARGIN) + return page * _pct(h_spec) + + def _height_for_width(cols): return cols * cell_w * ih / (cell_h * iw) + def _width_for_height(rows): return rows * cell_h * iw / (cell_w * ih) + + if w_spec is not None and h_spec is not None: + # Stretch mode: Target rectangle, uniformly scaled down only if + # it would overflow the available space. + target_w = max(1.0, _raw_width()) + target_h = max(1.0, _raw_height()) + scale = min(1.0, maxcol / target_w) + if maxrow is not None: scale = min(scale, maxrow / target_h) + cols = max(1, round(target_w * scale)) + rows = max(1, round(target_h * scale)) + elif w_spec is not None: + # Width given: Height derived, aspect preserving. + cols = max(1, min(maxcol, round(_raw_width()))) + rows = max(1, round(_height_for_width(cols))) + if maxrow is not None and rows > maxrow: + rows = maxrow + cols = max(1, min(maxcol, round(_width_for_height(rows)))) + elif h_spec is not None: + # Height given: Width derived, aspect preserving. + rows = max(1, round(_raw_height())) + cols = max(1, min(maxcol, round(_width_for_height(rows)))) + if maxrow is not None and rows > maxrow: + rows = maxrow + cols = max(1, min(maxcol, round(_width_for_height(rows)))) + else: + # Default: Full width, height derived. + cols = maxcol + rows = max(1, round(_height_for_width(cols))) + if maxrow is not None and rows > maxrow: + rows = maxrow + cols = max(1, min(maxcol, round(_width_for_height(rows)))) + + return (cols, rows) + + def rows(self, size, focus=False): + maxcol = size[0] + if not (self._ti_data and self._ti_data.ok and _termlib.is_kitty_supported()): + return len(self._placeholder_lines()) + n = self._display_size(maxcol)[1] + return n + + def render(self, size, focus=False): + if len(size) == 2: + maxcol, maxrow = size + is_box = True + else: + maxcol, maxrow = size[0], None + is_box = False + + if not (self._ti_data and self._ti_data.ok and _termlib.is_kitty_supported()): + return self._placeholder_canvas(maxcol, maxrow, focus) + + cols, rows = self._display_size(maxcol, maxrow) + self._ti_cols, self._ti_rows = cols, rows + # urwid requires canvases to fill the allocated space exactly + # (width == maxcol for FLOW, (maxcol, maxrow) for BOX). The image + # itself is placed by the screen at its resolved size inside the + # canvas, left-aligned; the rest of the canvas stays blank. + if is_box: return ImageCanvas(maxcol, maxrow) + else: return ImageCanvas(maxcol, rows) + + ######################### + # Placeholder rendering # + ######################### + + def _placeholder_lines(self): + return [f"{self.placeholder_glyph}{self._notice or self._ti_name}"] + + def _placeholder_canvas(self, maxcol, maxrow, focus): + align = {"<": "left", "|": "center", ">": "right"}.get(self._ti_align, "center") + text = urwid.Text("\n".join(self._placeholder_lines()), align=align) + if maxrow is not None: return urwid.Filler(text).render((maxcol, maxrow), focus) + else: return text.render((maxcol,), focus) + + +class ImageCanvas(urwid.SolidCanvas): + # A blank canvas marking the space occupied by an image. + # Urwid sets _widget_info on this canvas to the owning + # ImageWidget. ImageScreen finds image canvases through + # that reference during its shard scan. + def __init__(self, cols, rows): super().__init__(" ", cols, rows) From 4fecb706d89a2b57a1d9adab4757f091ec1e832c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 22:16:14 +0200 Subject: [PATCH 541/592] Cleanup --- nomadnet/ui/textui/MicronParser.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 331a9a2..47564f4 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -308,9 +308,7 @@ def render_table(lines, state, url_delegate): return widgets if widgets else None class FormColumns(urwid.Columns): - MIN_FIELD_WIDTH = 4 - @staticmethod def _natural_width(widget, maxcol, focused): @@ -354,14 +352,8 @@ class FormColumns(urwid.Columns): return super().column_widths(size, focus) if available < self.MIN_FIELD_WIDTH * len(field_idxs): - return super().column_widths(size, focus) - - - - - overflow = requested - available slack = {i: max(0, nat[i] - self.MIN_FIELD_WIDTH) for i in field_idxs} total_slack = sum(slack.values()) From 3028301fb13626a6fd53cffb5ba36f87263db82e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 22:24:06 +0200 Subject: [PATCH 542/592] Signal denied on image requests --- nomadnet/Node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 714f270..081abff 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -184,7 +184,7 @@ class Node: else: RNS.log("Request denied", RNS.LOG_VERBOSE) - return None + return False except Exception as e: RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(media_path), RNS.LOG_ERROR) From 7bd3a3a8882d950876d4e48633193403903e4f8b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 22:33:03 +0200 Subject: [PATCH 543/592] Enforce webp for remote images --- nomadnet/ui/textui/Browser.py | 4 ++-- nomadnet/ui/textui/images/_imagedata.py | 19 ++++++++++++------- nomadnet/ui/textui/images/widget.py | 7 +++++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 2ed21c0..04e39e1 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -668,7 +668,7 @@ class Browser: if hasattr(o, "_contained_image"): o = o._contained_image if hasattr(o, "image_url"): resolved_image = self.resolve_image(o.image_url) - if resolved_image: o.load(resolved_image) + if resolved_image: o.load(resolved_image, remote_source=True) else: image = { "widget": o, "id": o.image_id, "url": o.image_url, "refresh": None, "updated": None, "update_requested": None, "request_id": None, "link": None, "failed": False, "pr_throttle": 0, @@ -858,7 +858,7 @@ class Browser: shutil.move(file_handle.name, file_destination) resolved_path = self.resolve_image(url) - if resolved_path: w.load(resolved_path) + if resolved_path: w.load(resolved_path, remote_source=True) except Exception as e: RNS.log("Error while handling image response: "+str(e), RNS.LOG_ERROR) diff --git a/nomadnet/ui/textui/images/_imagedata.py b/nomadnet/ui/textui/images/_imagedata.py index 294f72c..d037ee9 100644 --- a/nomadnet/ui/textui/images/_imagedata.py +++ b/nomadnet/ui/textui/images/_imagedata.py @@ -69,7 +69,7 @@ def parse_image_header(data): # (Kitty's f=100 data format). The dimensions are used only for layout. # WebP sources are converted transparently to PNG on load. class ImageData(object): - def __init__(self, path, max_bytes=MAX_PAYLOAD_BYTES): + def __init__(self, path, max_bytes=MAX_PAYLOAD_BYTES, remote_source=False): self.path = path self.error = None self.format = None @@ -81,15 +81,15 @@ class ImageData(object): try: with open(path, "rb") as f: data = f.read() except OSError as e: - self.error = "could not read file: %s" % e + self.error = "Could not read file: %s" % e return if len(data) > max_bytes: - self.error = "file too large (%.1f MiB > %d MiB)" % (len(data) / (1024 * 1024), max_bytes // (1024 * 1024)) + self.error = "File too large (%.1f MiB > %d MiB)" % (len(data) / (1024 * 1024), max_bytes // (1024 * 1024)) return if not data: - self.error = "empty file" + self.error = "Empty file" return try: self.format, self.width, self.height = parse_image_header(data) @@ -102,17 +102,22 @@ class ImageData(object): from . import _webp png = _webp.convert_webp_to_png(data) if png is None: - self.error = "webp conversion failed (no working backend available)" + self.error = "WebP conversion failed (no working backend available)" return if len(png) > MAX_CONVERTED_PAYLOAD_BYTES: - self.error = "converted image too large (%.1f MiB > %d MiB)" % (len(png) / (1024 * 1024), MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024)) + self.error = "Converted image too large (%.1f MiB > %d MiB)" % (len(png) / (1024 * 1024), MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024)) return self.data = png self.format = "PNG" self.conversion_backend = _webp.last_backend() - else: self.data = data + else: + if remote_source: + self.error = "Invalid image format, remote images must be WebP" + return + else: + self.data = data # Content key for de-duplication: identical bytes = same key, so # the same image transmitted once can be placed many times. diff --git a/nomadnet/ui/textui/images/widget.py b/nomadnet/ui/textui/images/widget.py index 23cdaa0..9c26f14 100644 --- a/nomadnet/ui/textui/images/widget.py +++ b/nomadnet/ui/textui/images/widget.py @@ -86,12 +86,12 @@ class ImageWidget(urwid.Widget): super().__init__() if self.path: self.load() - def load(self, path=None): + def load(self, path=None, remote_source=False): if path: self.path = path if self.path: from ._imagedata import ImageData self._ti_path = os.path.abspath(os.fspath(self.path)) - self._ti_data = ImageData(self._ti_path) + self._ti_data = ImageData(self._ti_path, remote_source=remote_source) if self._ti_data.ok: # Register with the image store (de-duplicates identical data @@ -99,6 +99,9 @@ class ImageWidget(urwid.Widget): self._ti_key = self._ti_data.key image_store.register(self._ti_key, self._ti_data.data) self._invalidate() + else: + error_msg = self._ti_data.error or "Unknown error" + self.notice(f"Error: {error_msg}") def notice(self, msg=""): if not msg: self._notice = None From ff73e325024becd2c63187faef82cf4c558ef38e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 22:53:55 +0200 Subject: [PATCH 544/592] Images guide entry stub --- nomadnet/ui/textui/Guide.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index aa97121..d245d26 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -91,6 +91,7 @@ class GuideColumns(urwid.Columns): class GuideLinkDelegate: def __init__(self, app, reader=None): self.app = app + self.g = app.ui.glyphs self.reader = reader self.last_keypress = 0 @@ -100,6 +101,17 @@ class GuideLinkDelegate: def micron_released_focus(self): self.reader.focus_topics() + def resolve_image(self, url): + RNS.log(f"RESOLVE {url}") + if url.endswith("demo.webp"): + import os + import nomadnet.ui.textui.images as images_module + path = f"{os.path.dirname(images_module.__file__)}/demo.webp" + RNS.log(path) + return path + + return None + def handle_link(self, target, fields=None): if not target: return @@ -1450,6 +1462,7 @@ With micron you can easily create structured documents and pages with formatting `F79d`_`[Links`#links]`_`f `F79d`_`[Anchors`#anchors]`_`f `F79d`_`[Tables`#tables]`_`f + `F79d`_`[Images`#images]`_`f `F79d`_`[Fields & Requests`#fields-requests]`_`f `F79d`_`[Comments`#comments]`_`f `F79d`_`[Partials`#partials]`_`f @@ -1776,6 +1789,20 @@ The above markup produces the following table: | Orange | Ask, nicely | 3 | `t +>Images + +You can include images. + +`Faaa +`= +`(The RNS logo`w=60%`a=center`:/media/demo.webp) +`= +`` + +If your terminal supports image rendering, the image will be displayed as specified. If not, a text placeholder is shown instead: + +`(The RNS logo`w=60%`a=center`:/media/demo.webp) + >Fields & Requests Nomad Network lets you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. From 2250f2a71cb3cdb3a7fb07d8b94377c7144727ac Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 22:58:09 +0200 Subject: [PATCH 545/592] Added guide demo image --- nomadnet/ui/textui/images/demo.webp | Bin 0 -> 12328 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 nomadnet/ui/textui/images/demo.webp diff --git a/nomadnet/ui/textui/images/demo.webp b/nomadnet/ui/textui/images/demo.webp new file mode 100644 index 0000000000000000000000000000000000000000..82b04ab217f0ecd07593f09630968ebee7aaa6e0 GIT binary patch literal 12328 zcmZ{~Wl$YW)Ga&*ch_JCm*51qgS!R`l3+oCy9Iam;O-DC1b2daaM$4O&iQ!W`&Hfh z#KAhBbis(b04zWZ-~glmkCBO!y_l-1;(yWqce|YfK>vBBS^mrQ ze~;QyijFE=Tm4A{MCP2Ng- zzyojuECF-C0&oVH-&mKo>|ft%vp4?h+hGlO{5St^x;OjZ^Xg>A#rZ!J2u&OS;Mbtg zD_Q_ROay?JI4JZf4+?$B2LRX=0O+#)-+H@T0N}fM*Q8yxtzw7yzWD z0|4DyJf^;wqN=PqE&yN?!`qPXAKn|h2AyR~NJ8lWmnGG78lX)M1`xG7bHp$KCvcEl zfi0GKFnpJmeUPv@K$9w76>^d4U!`85cR9G8XYF@uUS|e*o@bidP4I|;)XsPV_b+Z& zP#<5xt7IWfeY%(j#%qB?P>lnB&-l_FywCMzinCVt&j^z(FvdRtgvKj@Zm%s)Uwnm$ zUZZUyJRVkD!Zi$2yiulOc{<*%Dws$Esa=J72Q-;3jun6;Ej-l^x$Dm>)Kf{z^XJuE zo}-)F{M73IZ)B(KO|7l1PUGVuDtvpsObNVIog0Z|O)RIU8X0-%C}r-P8W}2X6_~8k zo2&NB^%_8SD7AdqQInoB7Qt|?dx=jrtWA;O~LLDUHo zqhZnGu?XH#V#VZjV_6uYVH8s_`pwjuazJolDL*7Y0`zB-4c74W|3=(0#6DU~hjJMr z@$~n06DgE~;o}4nnGw<{h~joj>xrNzA*D%{$w?cWn^9mJlA55V?O@U*EOC z#5TFyUntyEmwS_`(kJvm5OIwa9bMd<&D|Uc8{LBPC^pwlA3m>dWRv@tC8jB--7-kzO{K`~)JHL+F%@>F>^IVjWa|_1?K6`b>5dG8Kx;;=`*VVuL3+ z!Y>?84~IH@te9WQayEyAwuYI?;&LUKP;z4^A-N_AJbR+6A6GD1`x~Kw)1u*so(nj_ z@j-FVLJ_+Y@7~*P)_Ec+R3A{0P-T2EUd7AkQS>c8k!ngTy4|X^gGy zagCxmCP9|` z^aajYQ-&c)iUZNSAn}sK)3c6YE;8ekwazKupB}aWvXX^(+XiqbvnED~zGv{V$=vOQ)A>)M|b6a&WJPX1I^Of81qq${)VD3gPp1 z+Ny-o0GJOV9}26Pemyd+WplJ|_nsPaw3nA-Z8s7ny>1K>ZPPp7S^aoo$}RO-@hdGy z<>N8&?o2)^_e_=qahsu#U4M@9%34}Fe0pcvcB>gC>ZvZUVeVT~UJiUv_A76{&BJ}& zu{_pJkjJ}*eTUi?Gi@LxGox+8;0e({j}ik3W`|%rDA-khEiWeu?aTGK4?Rc{RECQ% z)+Z1;138br__%xMM8oT}2aY$}!pcU;)IPTE=ZPpadjp_)O@_M(p4LNa$+)ur7KiUZ;6fSb04{lQi zt3wDC2VM_@&9?c}n}kqQaY`Ot4I=*t8-55-fK6hyX~Ge>+ag)0BM6Ky(qw?YE^$5= z=4~k&Q!GbGF|DD=w$votZqTi>VGmHqQ~h;qkRnC@rLBE&v=XH7Lp@JFF0!NmIx1h{ zACAY`Z5VqwJ#g}EMc%4T68tL&r6{fiM&PO8k2#mcEEDOfA)^2f*((oWCV^OYJYy2` z%TAw5vq2*l?T$G9h)x_~?leV*IVwB@A5(Bbk+#5#?Ep(exUuQ+4vYaa85G^eKUYq3 zGYoH^#rrnVRd@}9S`&-dtr?3@YOga7wV+fBKStVrZ7V~?9EPtHf-l)`5Qb_3v+~4< zHCz5JT6}2BQp600@5TnNRQF>rH8JxfJO_66IU5$}Ya6XX#e@TA)BYGUP>Ujy2S|O+ z9-2fQSDlAvFkykO6g)%rU(G2K4=#U)dOftv!MI!ERay>16$A1A<=lY_E8`2F6s+0D zpg>@EL6GeE6 zMh3yE!LW>~P2*ql!FH-rnw1K~9W(NtRYP0*N0swOE}CkZfBcgu41XO5dg;u!_=N@1 z{(FxbnBzR*G(jby0M5E9?q|as6K<44fYlX(cFipKTAK+Tn8MLKa3x+{pL(47k&!~c z9D7Q!>vUN$$Py*ASK-s!S}~&s&NLEYTW2b1w=*}}^X1Rz8?&va5* z&PEzEMoY@hEGl}aSN4e{XK#qZ*(T$!7W6s}iBQrJ8eySj4nsslq}^l7%T=0#RCfCw z$p@8Pnh;7_mfe`*p$;M?a7LriK2#;~#Ve72(r6)W8&e5aIl2(mH3YcjP!r8;o{pE| zWl3HAxUlT7L&R6M#h;zrIzc0 zH3&wgIO5E+o`U@TZZ2=3T`m&j%cJQBTYY1%SD{J2D1`JeWOe{r<85I}d_DQfDAC#f zceZ>wFtg{k@MUAA=I+MsSFf#exPj^hc56q!*_lr-o;##Gor~E5&z$~iLJR3a^ltQM z&B7j9#D%$Z*;;+itV%+6HWFh4J-t^5whenG0tf&8;Br+fB#vX+WD-w|^aNcE&j0#g z70pB8rO4A z-{U;bY&%X$ywOabtfbxHvNsoeAZ}$lTEcIt{;AV`W6t1Bkv8`1c~$u9tg0wD_(0t$ z^{jyI$3f3eCB3@k=hUY(4UFuTu@zE_>Yn>% z&5&se2RdTl-Z%>os!S)ty=}O=+>blVBUDE=kr?jHmN=B2#({1Pc0x!M%~V_?5$vZN ztx&pM5effCaqK>a_F+PkC~EmHU3@_U4NueJXn|hWi3DA|gv|sF7u0swoGu>M-E6sb zZ^Wy+PEb0|)e zsrekcxNkMfXz7%Pj9BtR?a|K@IfuFIQ`>01fG~#_D5Jx_QJO3aWBfqdxR}oShoq(q zH@6HLx`;qXvhla|yn_wIyHsU*V+36In+o4_3@kw8OgyK*-;JDFhA$bZX~%vAI< z(C+Vz$WsCh1V5J#2ZHK9@11VjXyU4mPluuvEyG;&(}tvFb!klQZOuyJAPnfD_mxU{ z8nLl4NR_PVy$GXCT5NV{{&Vw$^0vMJjM4^3Fged3R!>rH2~gB(dO5;rleYf6{;H^4 z{)JUnT>cjEStrcn%*DXwMie4H0;52hqLCbA|C739@o+4>Hy_-0k4{t5^woecu>qH_ zY8a-%-dbG~VbS-V!r(ZU5SwPslck8cNXqX%XRX8Q#BD*`x@0auGk7~u9BlR!$?V1= zd^Zf_mFnVtQdhv*;$0|kI#EGrXU#4D(Z?sB&@w#nT%dgO0Z~p<^Tkn7+@298hMCIl z2Nl&7znXk$|d7Rh3_9YqSK%AT~EW#yilSY5Or{g{M8ANSQow_;!Y zs`8HNa&?@ZO9awSG`rtAH|qqarb%fulV>m|nO)#2IZONIA9KDOO0K(XF>7nG+~vPz zfYDcmM33d%>e{XQ|Hx@%D34$&2Q#|`QOkZjM7Mn|&~hPvDMFEh4fys$Bp^iG`1L@K zRnuUPtxRl2y9{n40aO)&gT49+@^ZzI{K5|b zg1;?HMXHwC;opyA!V*+pWL7~xjTa;ybC~c}0MQ6yH`kWQGkV{FuG!3;iiePUz$KQ& zOce9N;gW$j>%5Q;~wRwM86gV3GTa8)D+ z?wm^WS*midT)VTN^Px&PUdfF8XA^lfvewN@#w*?Xmiq^`X9MKqmo=X;Yy;y4M87fH z`8aHymllO5S}k{P-~2j}o4|orbb*~JVS6IQkZ5VOiXRhaDLNN+KKb*yx;I(XZT_|% z{{p#9mZMfW*7qV7+b5nsKedy!wqGUQ9kzDmO`!7_3K^Fl4y^gOU7(K~?~<%MQivQ- z67s*gk_(KC)(RWOVu^L0>Ze^9Gyoq;zlR-^*eWns$YetHH)!6?`-nQC{q_~Kz zqr`v^RchW)IQ=@H{j!_*Ih^=w>CDvBHARdl*~baTk-b|@hb2g4pbT2gK;})SMYvb`JM`IOkXxeB{r zI~E9pL}4u3Jy3e2K@L?(+4B9lHodM>pACJ&4_6&id_RRK=<48;0#`a!0-f0TxnFT83~&J>f$| zLI0oPJE7-BW!fLIL*X{K(hgcqqVv4R*(ZAq4_(0r&hl_lUxGEH%2F2EcHBv%*C^TU zD9s&7DQPwIT?5d`g{M}Z><$sNj^n?)3S+Fae8@@%=RSr_*=u;5-8?-H$O_(9$2=%@ z){?@j=BC($BEzV+CHDXi}yc}jD<7l z?QfZEDrQKGKRb1T)gYK8M z0Se!FDDDKWT+59l!B6O1b;^$@$ ze4%(b70BEEhQNq40w3Lp1uPGr2azO|H9sBAdhm5~c3)ViD#gsErO9bJzm|i8 zYkQP*VkHQS6CtmLqHQO+p9_;O34^mWTHMMtut>uZPRR}%w25FH6?n2>>sm*N_2>BpD74!JF>k9!{AR&MueEkUQ8-979 zH*V)^D98f5QEUi*w(qqH}=HTqIGsS1u_uUijZRfdf9dy;#!RPC1?E}Yw=rHD%D6%}hDt#V!H75dV6JMnB@!gl_e;2i~3I|V&AK6XA-pXRhs ztV18Jsa~M36E%oeohQ%}(ciwYFL94*FLy7{d%G*&U$5h$Y_9`Q!e_$AFE648ozPcU zU)tBe*U+cx)6NG9DD?Jq$@j|3@TK;a_r(f2Xa=4;a%ow$ZPcrrGtk;w#{Uoshu_8? z&hYpC*++PJ1#R+816{K;roZXJX4t@~=J=VMLAJKatx`6#9*I-ecXqHN{`p6!^RBJr zSb*b$Y}({5)Ou64xVOLwDO9kzWx0(LA;W)sGLNE=eEj8l4P#P)=V$Mg9BTN~G_uu= zMZIup5_#)yy?sqhcz^GJ?tV7%P&z$h6zu1{SXsnPn0t6RQz_Z0>%NBZzK3c1ewGJ0 zb=HB>gT*Xi;8xc?;BhbtGabROo9=ewzqCT_R$X|v1*Hja^nj$b$5q+REI=Y-SQ<)s-G4d-f*eP1-4pQUUaT@>;v{p zcE;~jg-u=}!V-geHg89{P^Q}cT>iuy`lJa9KOG*&{Sh9$iyU6n|6a1^(fNXuQD&H+ zeg7A&`DPS7zEs5}Tw1UXNN7YsxBr`c-9j^b_RQLM4pZ7TD7m=O5BE%q8uTT@T(FH}8qxfW+xt_DAKS0b zHdl0${#Hrc9*Y1nyw|hFV)WTW=*V|t{TT5??UE;Fa1AB7K};!gy(gb6Sf>X3kyK+H*~s}AgES+XHloTuL?QoZOyt>G(!%2-X zK1W=W>OR@xdYedS-2zV{Xx!g|j^KL*KgzryVCZOxW|LyoCe0UQYlluGY9>Z7H$sWn zmi(yGGKnZj3j{^7`nZ#yvNSU1J|aPkk%FxwRQ&Eso|-{x2L0|rOn(K#&5KGi*u8$lkVFI&QC`^C)}-4l6b<{T-Hd7-;7+CCS65W@t3&H;D4`Yq)r%xcK`7IS4@Tadv+zGRAhEk){Lw00=X{x;azZJ zsM&p%W;86686nWS#*#Y8z9@}`iXiPD(pfzRCfH30K8pD_SII7xweb6-!B;&}fCssw4$&|2IS?m4WMsCHF+{P{)H6>cK&+@!yq;rNKYd6kkEfuSX z6%FbpeE&9Vs-`Ah?rsNMg1r*e*}Ay&xU!0c4RoAr;g*oP*(!2tC;P~QqB<*8RPj!l zmVc2s)k~BSGk;0CPw#O1Gyq@jX$#INnfPdg{TYk*P&fAnslYUmsDTc>^T(w`-55$M zn1a!-?$o4SGOM$>CExBT4rkm)2&~pQ0C~QU{O&DMcG`XyP%o4!i4!N6DQ(+6Nq&5u%Vyvi5Rh z8^ZSJjFChfe8R`e(1IXk??kt>R)^*sp1-oN3u>@Wa-EbSA3P!a_?U;!fKR&YVer>( znl<&$wSnU&+cDtUKV$?7f2e#eVbBKs7dz!R2LX1D=6Woztwwnt{(~}_8udgv-cH}|9hnfpphM3HxRvT!Dxxa~$tXKN zC^!OL&Yk8JZ6CJC{rlhL=OO%W7!B+~VK09sumsjyd4YJRhSZNn!e|7s<#=$o-zuFg zyeTqU`(mH>I8x(vvb?;pOGuHB~Z0Ix$kle=9oO z$l>|&__%=0jVx6+rN%(iTd)$A;gv&6Xh{KCR&I2Y6wJ265)N=vX#SVCNX@6wsoh*H zd$PS?+hCF`G7kTDKKgYL?l(#1hHzZ_NVmucb1FT2KxU zT;*@_*suO==q+eIB9_kY3h?rT<*UktEEX6&ZFHO6ez32VBoFVaZ^2Ev+sVxeK4&L3 zWhza~59?u6Z@7BuN)y}ksCkI9KH<_9x8PE#NZ|d-t{?|9P4W&uzMl?hN_V3TGDsu8 zaUZ*qcNz;7N93&d#?|y5eeGMD5k+e$)yReVZ;QL_z+FPS9Chnrc)7ZZxD)oDoc(?s znWrn54fN}B6S~$tiG4;oa#5-8-65+E|1k9fte1w!I&_QS7o(mA&~<}9(znfjo0%9{B}pD>{Zmo zzf$p?r_W#ofJI>&>!*UREvq}N`~8)xV|3g1f2X*c#MN5H)-QHZ1it4#YwU=r9}M^R z(jJztjawt!8#2ir|5p<@0|QHnC=}Z@MaE`0sbEk=g2QNE5Zm9oFLoo_bna|SZbXCF zP+x5*P3LE?f}*7znd^ick<(zhjEbn7wX_E+m3f41qubait!Ind{g!4Z%jlN#@S$=u zVf=f=z9Y%*=nhe;gs7Yvlh-YnV|Efg#CgDHrp5y&wl{xKT8y|vK!0~ID(6qOV7)xL z8h=NHJn7FCboAws4H~PdP(3H7%_bIUm`Yv75H5?sHvQFa@7st36k;UeL5o;k$GbYk zQmbRW%!ltFc&48M;*JrZgvWS}S|vQolRrdAh8nZ3I3czeVbr8%d&`gMQ4&CayTnE; zgzS{+VTAS>G1`4?Qd6oAlv-aVJ{U|b$-O-mamMZEWOLsLr()=LIPWvvabEG0Gupz% z@(f+!F(9J#fm&4p9@67)uaS&DFKzfz<~C@%FJ>1$Zh9}!Ti`}ykssHX93jv5S^3>z z@NY~z3kI~YBAs+c)yS96vKB+lNlkkbb=tM5cyS|q-|UBz^;6hK37ewdHp+$hiG}`H zc8XpwplY^~5$Ms)j~0HQ9a9l-AYCFnpeZJ4- zAzs_Z^DN~FQjWhOTW9f*gW;b?D7kUht)J6>&sJ^6>hqJV+LguVG#MjaGzlGua!nTc z7Jk$2d@(Cq<10e?%19^3(a(_|g_zOS&4UGQV5N(z3te?^>FW50Gns6tO40%+LdM2f zx+b@aovV!l$uZ|bm~*wHBvrEfVH^4HNI$NsRBbNKk8*6z_{+1J21DdJ)_YW3$<4rl zqIPJ-t*y-+R`&CXYCxyNPcHanj%oPH>Z)mkumL0RE z!!&Gr7}Of;wjqQWl!8gCp638dY|o`w?4xZb0$CpCJ;CkU25y-Ci@Zj)iRG7lo~P); z>!V{aW>@(y@s8im@xkLYuFy)vbSS&HEBlj&NEde~58B0dbR1KedgP|Mo_Xy_!CO`Y z)>vg-7HM(6G(`ti$un*t-&@;nFqm}}P6ZiKiVubMc*~V!(qM41w#ECMQqq73CX7-Q zY3Rg#XI%j9ns9|nR$Pp(cKJ*1oFWVx`-dax^XRp6RMKT|N0=*Y7ksmyoT{fupjtcE9@`Rjm0{}t%Pe%e8mO?vGI?&oXB zfyV3Ol4V^Bh=PC~Xr0J#EVXI0(g;q0Wy<!d-M@`Xub2~cFxX-fEq)qmkIp1qeP7dV=Bxq4Y$PTh3#@Z~eL<%7f`Z>%h*Oh** zjQpU)bK>}}Cqd>$e|7vYbwmdZ!?>b=+L1WpkpsA-+)Yf-*D^+jv(@KddaQ|a(RgTc#5vI|FD zVSPh?8!T`7^PHA{vvk5g7a1?p8`SA{2fJ$Ydp$}Qsg!wV4<@(vPD@T6OT`mooymtCL1TZ##;h9RXCB)3w#F%mD)g#SBMI!^BJ8?J&F%WG0*lpOuV*Ocqf z1*noIHLVcf+}>Eq$>@^lY^LElHsR zy0Oq@af|HDX{lMn#lQ59llCO~Q2p-#7uo-+>h0+yqUP1fU}GVNr`CsAMgREHlJE>f zckq`_40=hf@6SjsF_%Dd24}V%##9-#tg@GMUAqoF?8I3)nn5Zn!|yG_h`L?DM37*e zo4XygsbdD1{s%#nx>$qk!EweDV5bp7LHqg)5e7V~iHJ-LOFZu|;CC(D7|v=?ibMxX z``#*U&-chgw&#;fe&NA!ah|yS+En@8$&L)U-^uaV%mdbdKu z?BA#HI`)pFzaN;QStjs0P;ZzZW52Z*SGILOM=B8il`@Y}`cMghyjxI{Ofec8Wa&*# zy(txw+1k$2wq$#`x5Iv9Up$(A3LRJoS9%vJ4I0Dru+OV*GGeUw+egQoqgXkH-toaH64YPUKp z9Kqus^{Yl*qWST}H`Vwj6o&8!Lw z_3J-+zZ;guG~vRTB1ZF-4rX(;uhUFOPIL|E8?@XC&j*P7(k zC3gKDRnEs|1b1h!MS_pe!3xus%F8$ID?0jqskz|bKjg0gs~Se3B*!jKB-IoHR#??0 z0ka*A-~Ba&b{dZ>4@MM5O#0#L^<$I=Wg`$cbgA616dtt=8?OI0T?4;7PbrRtd*xDA z8g(_CVUhx8^qeL;S#Jw+l=j3{KEvcO5_(Z#H#t+;-zN9?5pPo(Bb&E^&pBa&m zkM zNe!q;QZ}1~NgT-*DatmjIRRaTuJQ8Ig&?@ewsu?78DLwv2kE3DzPAWxXq`-Kf5vlR z?+{RkOq89ka&S5AP)=h_rrH0p(E?r+ap&5{uf4K!TYOhEpB*UrFgW5KBLy0s8468* z9gOS^G@1;&i!x6jzd&iG{P_oR2HP1MR z_IbRfJh0xnV$I2s*|A+@%a6G33T+?rDQYv}F{oupVSl+tdNJ(-w@H)vOr-PTV)qKD z^*Xz3o9jiu)M&&TshwX|ubhh~k*}qC>q|mi0fINwwU~(^UDceh5!1M)ZL{*xuzr%9 zw0CHYNT2AWv6fKO>)M8QfW+kNS*mQ>a@=W6&AQMpZ8>%g`&C`vF4xC_EF}I?M>6^| zg)afeIuUL94=3ft8rojpS6^YYAf)`4SGTOz)J$wHvdj9xWFr0;&i&Iuf_$t2;lnHF zZ9YROuX#6Z-H)UfWb;oJwq-~1^U#v_Q9aglq)Ep`L2e2h45sS3(L>%+F;y8*iGTPv zXWt?xcCvkVyf}-1Q!@qfT7nd24oQ!mq{zh=o4>yoOd|px54zCw!<4-gVD;j*3okZ<&bzZYNE=XuLBo~5nQerQZ2WkTi#l%&s zE+6cP6)aynnLBb#P#dBK`Tr8l`AK23zScFfS@n^JIuvfgO`f|Hs+|y9+JW*PK1L4R zl`7GHIO;afwLhBe8+4@Nun6c7TH}Spy!>vH#&Z<-n(c^{_uKtjm>e3^tqgAVZ}+Nm zHbY^G)kQUQj42_!Hc>@_c{Yvj+WF>vM9MMDxh6^a^MT_q zPZchJ>`PG^!(jW_zaUC}ib}5UOgLqDJ13<0&m^_5mgJeWf4Mtz1J(3ls>?H#VQiv* z*aw5LG-I~mGKs9!dFInYujW12(!QB^yjD`v$@_k9P*qC&U?=e6mVQ_mKeP~Rmzyuy z8?d3EF<5=N{pZh!c^eu!1DbTgNezBM0uG*~8Hb5&(2uFOt#0I>^6{=&$CQs6E1#?H zx$|dd^!pU1bl6v2+|7>zn_a_prvn9!7)^B|Yfvsuk~E39iT^Nleg<2_@(XULu(xzA zH!^?u8cejgsuGLa1fQKW!~=+f?i(*H$^Bkn^cm-t8L2ghc`G~!pzF z1Mq0A9MDjL)Rp8n$`V9F{8x;dCq5sy!20~630w+YRhyYJ&Z+ldW~NcF-Zj?d*Ab}GmFAb?2y&w!T8e*i;D$oHk%6)LR+xDzq8#kO{XmCVD0H+n4G9->);gmR7U!QMt{0DA#Bvut0_F98Sf- z47G3wnIJVPW%T5Mb%wa=6tk<=Z6_Sd8Y5ymOz&UyOZlsK2i1-@+dyV|DR#C~$Nj$@ z2hs#d-|>CC^6VfJJGMbAGsL6)hfd}&qj;W5Ty(wTi`qYEnw%E)b#^jgz44tr+_Q2F ztY(dDc=7F=Tec68J9~)q$liYla2x{!t(h>D2ko_yzCSEYw2Gh|S#ORJarY3Zwu7`B#=1Tqe|Eb*w!})-B>i}wofO~tFN5$`X)KcgHnlxV zZcUDMrpelAlE-1+85TXiA~`z$%6cYCz};@sQu)QTeTu0mIC$}ZM*e&9tv%}W>Sf<#1*!3PH&NS|7#B#T@MCXQT?v{fNant)O_ zp#+~KDAbIfxqNQiEeaid22V^&8Gsmj(rCO=`-D1N-^lD^!iCL#`-l7g1BZhl AJOBUy literal 0 HcmV?d00001 From 815ff99e123f117dc7312c824cfdf4ab47101c3d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 23:13:30 +0200 Subject: [PATCH 546/592] Add native resolution width/height specifier --- nomadnet/ui/textui/images/_selftest.py | 39 +++++++++++++++++++++++++- nomadnet/ui/textui/images/widget.py | 14 +++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/images/_selftest.py b/nomadnet/ui/textui/images/_selftest.py index e1755bb..ce132c4 100644 --- a/nomadnet/ui/textui/images/_selftest.py +++ b/nomadnet/ui/textui/images/_selftest.py @@ -255,7 +255,7 @@ def test_webp_conversion(): with os.fdopen(fd, "wb") as f: f.write(fixture) d = ImageData(webp_path) - check(not d.ok and "converted image too large" in d.error, + check(not d.ok and "Converted image too large" in d.error, "converted payload capped at %d MiB" % (MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024))) os.unlink(webp_path) @@ -414,6 +414,43 @@ def test_widget_layout(image_path): check((c, r) == (27, 10), "height-only after reset -> (27, 10), got (%d, %d)" % (c, r)) w.close() + # native resolution specification ("n"): display at the image's own + # pixel size, quantized to the terminal cell grid. Cell = (1, 2), + # small fixture 64x32 -> native 64 cols, 16 rows. + from nomadnet.ui.textui.images import _webp + small_png = _webp._encode_png_rgba(bytes(64 * 32 * 4), 64, 32) + fd, small_path = tempfile.mkstemp(suffix=".png") + with os.fdopen(fd, "wb") as f: + f.write(small_png) + try: + wn = ImageWidget(small_path, width="n") + wn.render((80,)) # sets _ti_cols/_ti_rows + check((wn._ti_cols, wn._ti_rows) == (64, 16), + "width='n' displays native size (64, 16), got (%d, %d)" + % (wn._ti_cols, wn._ti_rows)) + wn.close() + hn = ImageWidget(small_path, height="n") + hn.render((80,)) + check((hn._ti_cols, hn._ti_rows) == (64, 16), + "height='n' displays native size (64, 16), got (%d, %d)" + % (hn._ti_cols, hn._ti_rows)) + hn.close() + bn = ImageWidget(small_path, width="n", height="n") + bn.render((80,)) + check((bn._ti_cols, bn._ti_rows) == (64, 16), + "width='n' height='n' displays native size (64, 16), got (%d, %d)" + % (bn._ti_cols, bn._ti_rows)) + bn.close() + big = ImageWidget(image_path, width="n") # 1280x960, overflows + c, r = big._display_size(80) + check((c, r) == (80, 30), + "native of a large image clamps to available width (80, 30), got (%d, %d)" % (c, r)) + big.close() + check(raises(lambda: ImageWidget(image_path, width="x"), ValueError), + "width='x' raises ValueError") + finally: + os.unlink(small_path) + # alignment: placement column of a narrowed image inside its canvas w = ImageWidget(image_path, width=40) # default center w.render((80,)) # sets _ti_cols/_ti_rows diff --git a/nomadnet/ui/textui/images/widget.py b/nomadnet/ui/textui/images/widget.py index 9c26f14..30dd3d0 100644 --- a/nomadnet/ui/textui/images/widget.py +++ b/nomadnet/ui/textui/images/widget.py @@ -33,6 +33,7 @@ def _check_align(value): def _check_size_spec(value, name): + # None = auto, int = absolute cells/rows, "NN%" = percent, "n" = native resolution if value is None: return None if isinstance(value, int): @@ -40,9 +41,10 @@ def _check_size_spec(value, name): return value if isinstance(value, str): + if value.lower() == "n": return "n" match = _SIZE_SPEC_RE.fullmatch(value) if not match or float(match.group(1)) <= 0: - raise ValueError("invalid %s specification %r (expected a positive integer or a percent string like '50%%')" % (name, value)) + raise ValueError("invalid %s specification %r (expected a positive integer, a percent string like '50%%' or 'n' for native resolution)" % (name, value)) return value raise TypeError("invalid type for %s (got %s)" % (name, type(value).__name__)) @@ -140,8 +142,8 @@ class ImageWidget(urwid.Widget): width = property( lambda self: self._ti_width, doc="""Display width specification: None (full width), a positive - int (columns) or a percent string ("NN%") of the available layout - width.""", + int (columns), a percent string ("NN%") of the available layout + width, or "n" for native resolution.""", ) @width.setter @@ -152,8 +154,8 @@ class ImageWidget(urwid.Widget): height = property( lambda self: self._ti_height, doc="""Display height specification: None (derived, aspect - preserving), a positive int (rows) or a percent string ("NN%") of - the visible page height.""", + preserving), a positive int (rows), a percent string ("NN%") of + the visible page height, or "n" for native resolution.""", ) @height.setter @@ -185,11 +187,13 @@ class ImageWidget(urwid.Widget): def _raw_width(): if w_spec is None: return float(maxcol) if isinstance(w_spec, int): return float(w_spec) + if w_spec == "n": return iw / cell_w return maxcol * _pct(w_spec) def _raw_height(): if h_spec is None: return None if isinstance(h_spec, int): return float(h_spec) + if h_spec == "n": return ih / cell_h columns, lines = _termlib.get_terminal_size() page = max(1, lines - UI_MARGIN) return page * _pct(h_spec) From 235658c3753d13a85bd87020c183f7a9aebc751d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 23:31:17 +0200 Subject: [PATCH 547/592] Added images to markup guide --- nomadnet/ui/textui/Guide.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index d245d26..480f7d5 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -102,7 +102,6 @@ class GuideLinkDelegate: self.reader.focus_topics() def resolve_image(self, url): - RNS.log(f"RESOLVE {url}") if url.endswith("demo.webp"): import os import nomadnet.ui.textui.images as images_module @@ -184,7 +183,7 @@ class TopicList(urwid.WidgetWrap): GuideEntry(self.app, self, guide_display, "Hosting a Node"), GuideEntry(self.app, self, guide_display, "Configuration Options"), GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"), - GuideEntry(self.app, self, guide_display, "Markup"), + GuideEntry(self.app, self, guide_display, "Micron Markup"), self.first_run_entry, GuideEntry(self.app, self, guide_display, "Network Configuration"), GuideEntry(self.app, self, guide_display, "Display Test"), @@ -1791,17 +1790,29 @@ The above markup produces the following table: >Images -You can include images. +You can include images in pages by using the image tag. All images must specify at least an alt-text and a URL, and can optionally specify rendering options such as width, height and alignment. The alt-text is required so a meaningful placeholder can be displayed for clients that do not support image rendering. + +Width (`Faaaw=`f) and height (`Faaah=`f) can be specified either in terminal columns/rows, in percentages, or using the `Faaan`f specifier, which will render the image as close to its native resolution as possible + +Alignment (`Faaaa=`f) can be either `Faaaleft`f (`Faaal`f), `Faaaright`f (`Faaar`f) or `Faaacenter`f (`Faaac`f). `Faaa `= -`(The RNS logo`w=60%`a=center`:/media/demo.webp) +`(The RNS logo`w=n`a=c`:/media/demo.webp) `= `` +Images are block-level elements, and must be placed independently on their own line. + If your terminal supports image rendering, the image will be displayed as specified. If not, a text placeholder is shown instead: -`(The RNS logo`w=60%`a=center`:/media/demo.webp) +`(The RNS logo`w=n`a=c`:/media/demo.webp) + +If you see the image above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm and others. + +It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either. + +`!Important!`! For the sake of bandwidth efficiency, images sent over the network `!must`! be in WebP format. Nomadnet will reject and ignore all other image formats. You can easily convert images to WebP with command-line utilities such as `Faaaconvert`f and similar. The WebP format supports both efficient compression and lossless alpha masks, so it is suitable for anything you can achieve with PNG, JPEG or other formats, but much lighter on bandwidth. >Fields & Requests @@ -2017,7 +2028,7 @@ TOPICS = { "Hosting a Node": TOPIC_HOSTING, "Configuration Options": TOPIC_CONFIG, "Keyboard Shortcuts": TOPIC_SHORTCUTS, - "Markup": TOPIC_MARKUP, + "Micron Markup": TOPIC_MARKUP, "Display Test": TOPIC_DISPLAYTEST, "Network Configuration": TOPIC_NETWORKS, "Credits & Licenses": TOPIC_LICENSES, From 0cd8de0c2e56eb77371145f9c19000d56ee015ce Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 23:31:29 +0200 Subject: [PATCH 548/592] Added image alignment shorthands --- nomadnet/ui/textui/MicronParser.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 47564f4..209af64 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -242,6 +242,10 @@ def parse_image(line, state, url_delegate): elif key == "h": height = value elif key == "a": align = value + if align == "c": align = "center" + if align == "l": align = "left" + if align == "r": align = "right" + widget = None if image_url: try: path = url_delegate.resolve_image(image_url) From 38165804ae40a0e50b210cf167c6e72f3686b093 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 5 Sep 2026 23:41:50 +0200 Subject: [PATCH 549/592] Guard image functionality on TGP support status --- nomadnet/ui/textui/Browser.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 04e39e1..7c355cc 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -15,6 +15,7 @@ from nomadnet.Conversation import ConversationMessage from nomadnet.vendor.Scrollable import * from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name +from nomadnet.ui.textui.images import _termlib from .Helpers import ClickableIcon, osc52_copy from .ReadlineEdit import ReadlineMixin, ReadlineEdit @@ -130,6 +131,7 @@ class Browser: self.image_updater_running = False self.partial_updater_lock = Lock() self.image_updater_lock = Lock() + self.image_rendering_supported = _termlib.is_kitty_supported() self.build_display() self.history = [] @@ -147,6 +149,9 @@ class Browser: self.clean_cache() + if self.image_rendering_supported: RNS.log(f"In-browser image rendering supported", RNS.LOG_DEBUG) + else: RNS.log(f"In-browser image rendering is not supported", RNS.LOG_DEBUG) + def current_url(self): if self.destination_hash == None: return "" @@ -657,18 +662,20 @@ class Browser: else: return None def _purge_page_images(self): + if not self.image_rendering_supported: return try: screen = getattr(getattr(self.app.ui, "loop", None), "screen", None) if hasattr(screen, "purge_images"): screen.purge_images() except Exception: pass def detect_images(self): + if not self.image_rendering_supported: return for w in self.attr_maps: o = w._original_widget if hasattr(o, "_contained_image"): o = o._contained_image if hasattr(o, "image_url"): resolved_image = self.resolve_image(o.image_url) - if resolved_image: o.load(resolved_image, remote_source=True) + if resolved_image and self.image_rendering_supported: o.load(resolved_image, remote_source=True) else: image = { "widget": o, "id": o.image_id, "url": o.image_url, "refresh": None, "updated": None, "update_requested": None, "request_id": None, "link": None, "failed": False, "pr_throttle": 0, @@ -679,9 +686,11 @@ class Browser: if len(self.page_images) > 0: self.start_image_updater() def start_image_updater(self): + if not self.image_rendering_supported: return if not self.image_updater_running: self.update_images() def update_images(self, loop=None, user_data=None): + if not self.image_rendering_supported: return def job(): if self.image_updater_lock.locked(): return with self.image_updater_lock: @@ -717,7 +726,7 @@ class Browser: if image_destination_hash == self.loopback: local_image = path.replace("/media/", "") local_path = f"{self.app.pagespath}/{local_image}" - w.load(local_path) + if self.image_rendering_supported: w.load(local_path) image["updated"] = time.time() return @@ -858,7 +867,7 @@ class Browser: shutil.move(file_handle.name, file_destination) resolved_path = self.resolve_image(url) - if resolved_path: w.load(resolved_path, remote_source=True) + if resolved_path and self.image_rendering_supported: w.load(resolved_path, remote_source=True) except Exception as e: RNS.log("Error while handling image response: "+str(e), RNS.LOG_ERROR) From 4544335324889ab5c583c1b1c014573bd24a025e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 00:51:12 +0200 Subject: [PATCH 550/592] Cleanup --- nomadnet/Node.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 081abff..914516a 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -162,11 +162,18 @@ class Node: if not type(data) == dict: return None if not "path" in data: return None if not "key" in data: return None - media_path = data["path"].replace("/media/", "").lstrip("/").replace("../", "") - media_path = f"{self.app.pagespath}/{media_path}" - if not media_path.startswith(self.app.pagespath): + + jail = os.path.normpath(self.app.pagespath) + media_path = os.path.join(self.app.pagespath, data["path"].replace("/media/", "").lstrip("/")) + media_path = os.path.normpath(media_path) + + if not media_path.startswith(jail+os.sep): RNS.log(f"Invalid media request path: {media_path}", RNS.LOG_DEBUG) - return None + return False + + if len(media_path) > 512: + RNS.log(f"Invalid media request path length: {len(media_path)}", RNS.LOG_DEBUG) + return False RNS.log(f"Media request {RNS.prettyhexrep(request_id)} for: {media_path}", RNS.LOG_VERBOSE) try: @@ -189,7 +196,7 @@ class Node: except Exception as e: RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(media_path), RNS.LOG_ERROR) RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) - return None + return False def request_allowed(self, file_path, remote_identity): allowed_path = file_path+".allowed" From 3c732874f6d1536530ac8f41511bd31c855852e3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 01:29:08 +0200 Subject: [PATCH 551/592] Added image rendering to the display test --- nomadnet/ui/textui/Guide.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 480f7d5..721a66e 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1392,6 +1392,15 @@ Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0 `cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions. `` + +>>Image Rendering Test + +`(The RNS logo`w=n`a=c`:/media/demo.webp) + +If you see the RNS logo above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm and others. + +It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either. + ''' From 37fff0bc76a77a64d551d8500f9dcb6c7a95ae07 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 01:37:50 +0200 Subject: [PATCH 552/592] Added demo image to package data --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 69ad85d..8179141 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ if "--getversion" in sys.argv: package_data = { "": [ "examples/messageboard/*", + "ui/textui/images/demo.webp", ] } From 05b968ecf6b6bb28f2e5a22037b5b8188ff8b14d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 12:20:51 +0200 Subject: [PATCH 553/592] Split network shortcuts into list/browser --- nomadnet/ui/textui/Network.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 3525740..9f65266 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -18,10 +18,19 @@ class NetworkDisplayShortcuts(): self.app = app g = app.ui.glyphs - shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL " - if app.config["textui"]["clipboard_copy"]: - shortcut_text += "[C-y] Copy " + shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-g] Fullscreen" + self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") + +class BrowserShortcuts(): + def __init__(self, app, browser): + self.app = app + self.browser = browser + g = app.ui.glyphs + + shortcut_text = "[C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL " + if app.config["textui"]["clipboard_copy"]: shortcut_text += "[C-y] Copy " shortcut_text += "[C-g] Fullscreen [C-s / C-b] Save Node" + if browser.image_rendering_supported: shortcut_text += " [C-i] Load Images" self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") class DialogLineBox(urwid.LineBox): @@ -1722,7 +1731,9 @@ class NetworkDisplay(): dividechars=0, focus_column=0 ) - self.shortcuts_display = NetworkDisplayShortcuts(self.app) + self.list_shortcuts = NetworkDisplayShortcuts(self.app) + self.browser_shortcuts = BrowserShortcuts(self.app, self.browser) + self.shortcuts_display = self.list_shortcuts self.widget = self.columns def toggle_list(self): @@ -1799,6 +1810,12 @@ class NetworkDisplay(): self.network_stats_display.start() def shortcuts(self): + try: focus_path = self.columns.get_focus_path() + except Exception: return self.list_shortcuts + try: + if focus_path[0] == 0: return self.list_shortcuts + if focus_path[0] == 1: return self.browser_shortcuts + except: pass return self.shortcuts_display def directory_change_callback(self): From 6451d7fa7f8f70276c7b79be4eaf9e5048a45c94 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 12:29:52 +0200 Subject: [PATCH 554/592] Manual image loading action --- nomadnet/ui/textui/Browser.py | 11 ++++++++++- nomadnet/ui/textui/Network.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 7c355cc..1b57133 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -31,6 +31,8 @@ class BrowserFrame(urwid.Frame): self.delegate.reload() elif key == "ctrl u": self.delegate.url_dialog() + elif key == "ctrl l": + self.delegate.load_images() elif key == "ctrl s": self.delegate.save_node_dialog() elif key == "ctrl b": @@ -644,7 +646,7 @@ class Browser: self.page_images = {} self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") self.detect_partials() - self.detect_images() + if self.should_load_images(): self.detect_images() self.init_folds() def image_cache_path(self, url): @@ -668,6 +670,13 @@ class Browser: if hasattr(screen, "purge_images"): screen.purge_images() except Exception: pass + def should_load_images(self): + return False + + def load_images(self): + if not self.image_rendering_supported: return + else: self.detect_images() + def detect_images(self): if not self.image_rendering_supported: return for w in self.attr_maps: diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 9f65266..24c315c 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -30,7 +30,7 @@ class BrowserShortcuts(): shortcut_text = "[C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL " if app.config["textui"]["clipboard_copy"]: shortcut_text += "[C-y] Copy " shortcut_text += "[C-g] Fullscreen [C-s / C-b] Save Node" - if browser.image_rendering_supported: shortcut_text += " [C-i] Load Images" + if browser.image_rendering_supported: shortcut_text += " [C-l] Load Images" self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") class DialogLineBox(urwid.LineBox): From 0db7d4bdaa14a56ac437d72c8fc447c443c62b67 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 13:37:35 +0200 Subject: [PATCH 555/592] Added image loading config. Added auto-load check based on RTT and EDR. --- nomadnet/NomadNetworkApp.py | 23 +++++++++++++++++++++++ nomadnet/ui/TextUI.py | 5 +++++ nomadnet/ui/textui/Browser.py | 28 +++++++++++++++++++++++++++- nomadnet/ui/textui/Config.py | 8 ++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 1319e5f..edc6833 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -994,6 +994,20 @@ class NomadNetworkApp: else: raise ValueError("The selected Text UI color mode is invalid") + if not "image_loading" in self.config["textui"]: + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO + else: + if self.config["textui"]["image_loading"].lower() == "never": + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_NEVER + elif self.config["textui"]["image_loading"].lower() == "manual": + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_MANUAL + elif self.config["textui"]["image_loading"].lower() == "auto": + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO + elif self.config["textui"]["image_loading"].lower() == "always": + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_ALWAYS + else: + raise ValueError("The selected image loading mode is invalid") + if not "theme" in self.config["textui"]: self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_DARK else: @@ -1400,6 +1414,15 @@ colormode = 24bit # glyphs = unicode glyphs = nerdfont +# If your terminal supports image output, +# the page browser can display images and +# graphics on pages. You can specify how, +# or if at all, images are loaded. Valid +# options are "auto", "manual", "never" +# and "always". + +image_loading = auto + # You can specify whether mouse events # should be considered as input to the # application. On by default. diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index bdc13bb..413972d 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -16,6 +16,11 @@ COLORMODE_88 = 88 COLORMODE_256 = 256 COLORMODE_TRUE = 2**24 +IMGLOAD_NEVER = 0x00 +IMGLOAD_MANUAL = 0x01 +IMGLOAD_AUTO = 0x02 +IMGLOAD_ALWAYS = 0x03 + THEMES = { THEME_DARK: { "urwid_theme": [ diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 1b57133..7487d48 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -19,6 +19,11 @@ from nomadnet.ui.textui.images import _termlib from .Helpers import ClickableIcon, osc52_copy from .ReadlineEdit import ReadlineMixin, ReadlineEdit +IMGLOAD_NEVER = 0x00 +IMGLOAD_MANUAL = 0x01 +IMGLOAD_AUTO = 0x02 +IMGLOAD_ALWAYS = 0x03 + class BrowserFrame(urwid.Frame): def keypress(self, size, key): if key == "ctrl w": @@ -133,7 +138,8 @@ class Browser: self.image_updater_running = False self.partial_updater_lock = Lock() self.image_updater_lock = Lock() - self.image_rendering_supported = _termlib.is_kitty_supported() + self.image_mode = self.app.config["textui"]["image_loading"] + self.image_rendering_supported = _termlib.is_kitty_supported() and self.image_mode != IMGLOAD_NEVER self.build_display() self.history = [] @@ -671,10 +677,26 @@ class Browser: except Exception: pass def should_load_images(self): + if self.image_mode == IMGLOAD_NEVER: return False + elif self.image_mode == IMGLOAD_MANUAL: return False + elif self.image_mode == IMGLOAD_ALWAYS: return True + elif self.image_mode == IMGLOAD_AUTO: + if self.destination_hash == self.loopback: return True + rtt_limit = 1.5; edr_limit = 10000 + if self.link: + rtt = self.link.rtt + edr = self.link.get_expected_rate() or self.last_response_speed() + rtt_ok = rtt and rtt < rtt_limit + edr_ok = edr and edr > edr_limit + RNS.log(f"Checking image load conditions, RTT is {RNS.prettyshorttime(rtt) if rtt else 'unknown'}, EDR is {RNS.prettyspeed(edr) if edr else 'unknown'}", RNS.LOG_DEBUG) + if rtt_ok and edr_ok: return True + else: return False + return False def load_images(self): if not self.image_rendering_supported: return + elif self.image_mode == IMGLOAD_NEVER: return else: self.detect_images() def detect_images(self): @@ -2107,6 +2129,10 @@ class Browser: self.update_display() + def last_response_speed(self): + if self.response_transfer_size and self.response_time: + return (self.response_transfer_size/max(self.response_time, 0.0001))*8 + else: return None def status_text(self): if self.status == Browser.DONE and self.response_transfer_size != None: diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py index 7a0f771..8c54f08 100644 --- a/nomadnet/ui/textui/Config.py +++ b/nomadnet/ui/textui/Config.py @@ -239,6 +239,14 @@ CONFIG_SECTIONS = [ "default": "24bit", "help": "Colour capability of your terminal", }, + { + "config_key": "image_loading", + "label": "Image Loading: ", + "type": "dropdown", + "options": ["never", "manual", "auto", "always"], + "default": "auto", + "help": "How, or if at all, to load page images", + }, { "config_key": "glyphs", "label": "Glyphs: ", From b0d42c9c0560ea9baf52b34d27fa82f9ea8e3c25 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 13:42:37 +0200 Subject: [PATCH 556/592] Added image_loading to configuration options guide --- nomadnet/ui/textui/Guide.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 721a66e..21ccf87 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1182,6 +1182,17 @@ Tells the program what color palette is supported by the terminal. Most terminal `!24bit`! Most new terminals support this full-color mode < +>>> +`!image_loading = auto`! +>>>> +Specifies how, if at all, images on pages should be loaded. Available options: +>>>>> +`!never`! Never load any images +`!manual`! Only load images when manually requseted with Ctrl-L +`!auto`! Load images automatically if link conditions are sufficient +`!always`! Always load images, regardless of link RTT and EDR +< + >>> `!theme = dark`! >>>> From 4d4e7073384d540f3d93eedc237e3e511483d122 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 17:42:28 +0200 Subject: [PATCH 557/592] Fixed missing None check --- nomadnet/ui/textui/Browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 7487d48..b41fc65 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -787,7 +787,7 @@ class Browser: image["link"] = existing_link break - if not image["link"] or image["link"].status == RNS.Link.CLOSED: + if not image["link"] or (image["link"] and image["link"].status) == RNS.Link.CLOSED: RNS.log(f"Establishing link for image: {image_destination_hash} / {path}", RNS.LOG_DEBUG) identity = RNS.Identity.recall(image_destination_hash) destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects) From f62603da1d42922d7b986ec74b1c77ba80eb7069 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 17:46:32 +0200 Subject: [PATCH 558/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 9c73af2..3e8d9f9 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.3.1" +__version__ = "1.4.0" From 10664102e86bdb8460fdafb3311f509e4dbb49ec Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 18:23:22 +0200 Subject: [PATCH 559/592] Added reload images action --- nomadnet/ui/textui/Browser.py | 20 +++++++++++++++----- nomadnet/ui/textui/Network.py | 4 ++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index b41fc65..b15eef7 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -38,6 +38,8 @@ class BrowserFrame(urwid.Frame): self.delegate.url_dialog() elif key == "ctrl l": self.delegate.load_images() + elif key == "ctrl x": + self.delegate.load_images(force_reload=True) elif key == "ctrl s": self.delegate.save_node_dialog() elif key == "ctrl b": @@ -694,19 +696,26 @@ class Browser: return False - def load_images(self): + def load_images(self, force_reload=False): if not self.image_rendering_supported: return elif self.image_mode == IMGLOAD_NEVER: return - else: self.detect_images() + else: self.detect_images(force_reload=force_reload) - def detect_images(self): + def detect_images(self, force_reload=False): if not self.image_rendering_supported: return for w in self.attr_maps: o = w._original_widget if hasattr(o, "_contained_image"): o = o._contained_image if hasattr(o, "image_url"): resolved_image = self.resolve_image(o.image_url) - if resolved_image and self.image_rendering_supported: o.load(resolved_image, remote_source=True) + if resolved_image and force_reload: + RNS.log(f"Clearing image cache for {o.image_url} ({resolved_image})", RNS.LOG_DEBUG) + try: os.unlink(resolved_image) + except Exception as e: RNS.log(f"Could not clear image cache for {o.image_url}: {e}", RNS.LOG_ERROR) + resolved_image = None + + if force_reload: continue + elif resolved_image and self.image_rendering_supported: o.load(resolved_image, remote_source=True) else: image = { "widget": o, "id": o.image_id, "url": o.image_url, "refresh": None, "updated": None, "update_requested": None, "request_id": None, "link": None, "failed": False, "pr_throttle": 0, @@ -714,7 +723,8 @@ class Browser: self.page_images[o.image_id] = image - if len(self.page_images) > 0: self.start_image_updater() + if force_reload: self.reload() + elif len(self.page_images) > 0: self.start_image_updater() def start_image_updater(self): if not self.image_rendering_supported: return diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 24c315c..11c4e54 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -29,8 +29,8 @@ class BrowserShortcuts(): shortcut_text = "[C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL " if app.config["textui"]["clipboard_copy"]: shortcut_text += "[C-y] Copy " - shortcut_text += "[C-g] Fullscreen [C-s / C-b] Save Node" - if browser.image_rendering_supported: shortcut_text += " [C-l] Load Images" + shortcut_text += "[C-g] Fullscreen [C-s/b] Save Node" + if browser.image_rendering_supported: shortcut_text += " [C-l] Load Images [C-x] Reload Images" self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") class DialogLineBox(urwid.LineBox): From 9caa24b5e8629595cc2910c5e1b41fba52fc2fb3 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 19:18:40 +0200 Subject: [PATCH 560/592] Updated guide --- nomadnet/ui/textui/Guide.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 21ccf87..a204272 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1408,7 +1408,7 @@ Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0 `(The RNS logo`w=n`a=c`:/media/demo.webp) -If you see the RNS logo above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm and others. +If you see the RNS logo above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, iTerm2 and others. It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either. @@ -1828,7 +1828,7 @@ If your terminal supports image rendering, the image will be displayed as specif `(The RNS logo`w=n`a=c`:/media/demo.webp) -If you see the image above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm and others. +If you see the image above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, iTerm2 and others. It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either. From 3a5fd73ee0d9b7bd49e87369c810ea27104808fc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 6 Sep 2026 20:28:47 +0200 Subject: [PATCH 561/592] Fixed typo --- nomadnet/ui/textui/images/_webp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/ui/textui/images/_webp.py b/nomadnet/ui/textui/images/_webp.py index e47743c..5eff74c 100644 --- a/nomadnet/ui/textui/images/_webp.py +++ b/nomadnet/ui/textui/images/_webp.py @@ -2,7 +2,7 @@ # # Kitty's graphics protocol decodes PNG natively but not WebP, so WebP # sources are converted transparently at the ImageData layer. This module -# provides a conversion chain with that *should* work almost anywhere with +# provides a conversion chain that *should* work almost anywhere with # zero extra dependencies: # # - Pillow/PIL, if installed with WebP support From 774c08905f74025487f1630bc56a9ddd8e3d203a Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 8 Sep 2026 13:19:32 +0200 Subject: [PATCH 562/592] Fixed /media request handler allowing serving of .allowed files --- nomadnet/Node.py | 11 +++++++++-- nomadnet/ui/textui/images/_imagedata.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 914516a..008ae13 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -158,6 +158,7 @@ class Node: RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) return None + MEDIA_EXTS = [".webp"] def serve_media(self, path, data, request_id, link_id, remote_identity, requested_at): if not type(data) == dict: return None if not "path" in data: return None @@ -166,6 +167,12 @@ class Node: jail = os.path.normpath(self.app.pagespath) media_path = os.path.join(self.app.pagespath, data["path"].replace("/media/", "").lstrip("/")) media_path = os.path.normpath(media_path) + base_name = os.path.basename(media_path) + base_ext = os.path.splitext(base_name)[-1] + + if not base_ext.lower() in self.MEDIA_EXTS: + RNS.log(f"Invalid media request type: {media_path}, must be in {self.MEDIA_EXTS}", RNS.LOG_DEBUG) + return False if not media_path.startswith(jail+os.sep): RNS.log(f"Invalid media request path: {media_path}", RNS.LOG_DEBUG) @@ -199,9 +206,9 @@ class Node: return False def request_allowed(self, file_path, remote_identity): + if file_path.lower().endswith(".allowed"): return False allowed_path = file_path+".allowed" - if not os.path.isfile(allowed_path): - return True + if not os.path.isfile(allowed_path): return True allowed_list = [] try: diff --git a/nomadnet/ui/textui/images/_imagedata.py b/nomadnet/ui/textui/images/_imagedata.py index d037ee9..a78045a 100644 --- a/nomadnet/ui/textui/images/_imagedata.py +++ b/nomadnet/ui/textui/images/_imagedata.py @@ -62,7 +62,7 @@ def parse_image_header(data): if data[:4] == b"RIFF" and data[8:12] == b"WEBP": w, h = _parse_webp(data) return "WebP", w, h - raise ValueError("unsupported image format (only PNG and WebP)") + raise ValueError("unsupported image format") # Loads an image file and exposes its PNG bytes and dimensions. # The payload is always a PNG file, transmitted verbatim to the terminal From 7a7cd8fe23bfdf3866bac4f15047695522bd092b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 8 Sep 2026 13:34:57 +0200 Subject: [PATCH 563/592] Always reset _trim_top when canvas fits to avoid stale positioning for click calculation --- nomadnet/vendor/Scrollable.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index e40bef0..c3010b9 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -83,6 +83,10 @@ class Scrollable(urwid.WidgetDecoration): if canv_cols <= maxcol and canv_rows <= maxrow: # Canvas is small enough to fit without trimming + if self._trim_top != 0 or self._scroll_action is not None: + # Reset potentially stale _trim_top + self._trim_top = 0 + self._scroll_action = None return canv self._adjust_trim_top(canv, size) From 23de5ca93661d03308bd2704c63cb1b0272a812f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Tue, 8 Sep 2026 13:35:21 +0200 Subject: [PATCH 564/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 3e8d9f9..bf25615 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.4.0" +__version__ = "1.4.1" From 1d99e0dca6fa08bff0d4ee628d003ba4a34ced05 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 9 Sep 2026 19:32:41 +0200 Subject: [PATCH 565/592] Optimized page rendering and scroll performance (quite a bit, to say the least), by Zenith --- nomadnet/ui/textui/Browser.py | 185 ++++++++++++++++++++++------- nomadnet/ui/textui/MicronParser.py | 43 ++++--- nomadnet/vendor/Scrollable.py | 33 +++-- 3 files changed, 188 insertions(+), 73 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index b15eef7..c9c0c22 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -3,6 +3,7 @@ import LXMF import io import os import time +import bisect import urwid import shutil import nomadnet @@ -52,11 +53,11 @@ class BrowserFrame(urwid.Frame): elif self.focus_position == "body": if key == "down" or key == "up": try: - if hasattr(self.delegate, "page_pile") and self.delegate.page_pile: + if hasattr(self.delegate, "page_list") and self.delegate.page_list: def df(loop, user_data): st = None - if self.delegate.page_pile: - nf = self.delegate.page_pile.focus + if self.delegate.page_list: + nf = self.delegate.page_list.focus if hasattr(nf, "key_timeout"): st = nf elif hasattr(nf, "original_widget"): @@ -80,6 +81,109 @@ class BrowserFrame(urwid.Frame): else: return super(BrowserFrame, self).keypress(size, key) +class PageList(urwid.ListBox): + def __init__(self, body): + super().__init__(body) + self._page_size = (0, 0) + self._page_focus = None + self._page_layouts = {} + + def invalidate_layout(self): + self._page_layouts = {} + self._invalidate() + + def _layout(self, maxcol): + if maxcol not in self._page_layouts: + layout_started = time.time() + offsets = [0] + for widget in self.body: + offsets.append(offsets[-1] + widget.rows((maxcol,), True)) + self._page_layouts[maxcol] = offsets + # RNS.log(f" rebuilt {len(self.body)} widgets, {offsets[-1]} rows, width {maxcol}, {(time.time()-layout_started)*1000:.1f}ms", RNS.LOG_DEBUG) + return self._page_layouts[maxcol] + + def render(self, size, focus=False): + self._page_size = size + if self.body and self.focus is not self._page_focus: + self._page_focus = self.focus + widget = self.focus + while widget is not None: + widget._invalidate() + widget = getattr(widget, "original_widget", None) + return super().render(size, focus) + + def rows_max(self, size=None, focus=False): + size = size or self._page_size + return self._layout(size[0])[-1] + + def get_scrollpos(self, size=None, focus=False): + size = size or self._page_size + if not self.body or not size[1]: return 0 + + middle, top, bottom = self.calculate_visible(size, focus) + if middle is None: return 0 + return self._layout(size[0])[self.top_position(size)] + top.trim + + def set_scrollpos(self, position): + size = self._page_size + maxcol, maxrow = size + if not self.body or not maxrow: return + + offsets = self._layout(maxcol) + position = max(0, min(int(position), max(0, offsets[-1] - maxrow))) + pos = bisect.bisect_left(offsets, position, 0, len(self.body)) + if pos == len(self.body) or offsets[pos] - position >= maxrow: pos -= 1 + self.change_focus(size, pos, offsets[pos] - position) + + def top_position(self, size=None): + size = size or self._page_size + if not self.body or not size[1]: return 0 + middle, top, bottom = self.calculate_visible(size, True) + if middle is None: return 0 + return top.fill[-1].position if top.fill else middle.focus_pos + + def scroll(self, rows): + self.set_scrollpos(self.get_scrollpos() + rows) + + def focused_widget_name(self): + widget = self.focus + while getattr(widget, "original_widget", None) is not None: widget = widget.original_widget + return type(widget).__name__ + + def keypress(self, size, key): + self._page_size = size + key_started = time.time() + focus_before = self.focus_position if self.body else None + scroll_before = self.get_scrollpos(size, True) + unhandled_key = self.handle_key(size, key) + + # RNS.log(f" {key}: {unhandled_key} focus {focus_before} -> {self.focus_position if self.body else None} ({self.focused_widget_name()}) scrollpos {scroll_before}->{self.get_scrollpos(size, True)}, {(time.time()-key_started) * 1000:.1f}ms", RNS.LOG_DEBUG) + return unhandled_key + + def handle_key(self, size, key): + if key in ("page up", "page down", "home", "end"): + focus_widget = self.focus + if focus_widget is not None and focus_widget.selectable(): + key = focus_widget.keypress((size[0],), key) + if key is None: + self.make_cursor_visible(size) + return None + + if key == "page down": self.scroll(size[1] - 1) + elif key == "page up": self.scroll(1 - size[1]) + elif key == "home": self.set_scrollpos(0) + elif key == "end": self.set_scrollpos(self.rows_max(size)) + return None + + unhandled_key = super().keypress(size, key) + if key in ("up", "down"): return None + return unhandled_key + + def mouse_event(self, size, event, button, col, row, focus): + self._page_size = size + if button in (4, 5): return False + return super().mouse_event(size, event, button, col, row, focus) + class Browser: DEFAULT_PATH = "/page/index.mu" DEFAULT_TIMEOUT = 10 @@ -133,7 +237,8 @@ class Browser: self.link_target = None self.frame = None self.attr_maps = [] - self.page_pile = None + self.page_list = None + self.page_rows = [] self.page_partials = {} self.page_images = {} self.updater_running = False @@ -344,8 +449,6 @@ class Browser: anchors = getattr(self.attr_maps, "anchors", None) or {} header_rows = getattr(self.attr_maps, "header_rows", None) or [] - cols = self._content_cols() - target_idx = None if name: target_idx = anchors.get(name) @@ -356,11 +459,11 @@ class Browser: else: current = 0 try: - current = self.browser_body.original_widget.original_widget.get_scrollpos() + current = self.page_rows[self.page_list.top_position()] except Exception: current = 0 for hr in header_rows: - if self._rows_above(hr, cols) > current: + if hr > current: target_idx = hr break if target_idx is None: @@ -368,12 +471,9 @@ class Browser: self.reveal_index(int(target_idx)) - row_offset = self._rows_above(int(target_idx), cols) - try: - scrollable = self.browser_body.original_widget.original_widget - scrollable.anchor_cursor_update = True - scrollable.set_scrollpos(row_offset) + self.page_list.set_focus(self.page_rows.index(int(target_idx))) + self.page_list.set_focus_valign("top") except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) @@ -391,18 +491,6 @@ class Browser: return max(40, cols) - def _rows_above(self, index, cols): - if index <= 0 or not self.attr_maps: return 0 - - hidden = self.hidden_indices() - total = 0 - for i in range(min(index, len(self.attr_maps))): - if i in hidden: continue - try: total += self.attr_maps[i].rows((cols,)) - except Exception: total += 1 - - return total - def handle_lxmf_link(self, link_target): try: def san(name): @@ -497,7 +585,8 @@ class Browser: self.browser_header = urwid.Text("") self.browser_footer = urwid.Text("") - self.page_pile = None + self.page_list = None + self.page_rows = [] self.page_partials = {} self.page_images = {} self.browser_body = urwid.Filler( @@ -573,7 +662,8 @@ class Browser: def update_display(self): if self.status == Browser.DISCONECTED: self.display_widget.set_attr_map({None: "browser_inactive"}) - self.page_pile = None + self.page_list = None + self.page_rows = [] self.page_partials = {} self.page_images = {} self.browser_body = urwid.Filler( @@ -647,16 +737,18 @@ class Browser: def update_page_display(self): self._purge_page_images() - pile = urwid.Pile(self.attr_maps) - pile.automove_cursor_on_scroll = True - self.page_pile = pile + self.page_rows = list(range(len(self.attr_maps))) + self.page_list = PageList(urwid.SimpleFocusListWalker(list(self.attr_maps))) self.page_partials = {} self.page_images = {} - self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") + self.browser_body = urwid.AttrMap(ScrollBar(self.page_list, thumb_char="\u2503", trough_char=" "), "scrollbar") self.detect_partials() if self.should_load_images(): self.detect_images() self.init_folds() + def refresh_page_layout(self): + if self.page_list is not None: self.page_list.invalidate_layout() + def image_cache_path(self, url): url_hash = self.url_hash(url) if not url_hash: return None @@ -723,6 +815,7 @@ class Browser: self.page_images[o.image_id] = image + self.refresh_page_layout() if force_reload: self.reload() elif len(self.page_images) > 0: self.start_image_updater() @@ -767,7 +860,9 @@ class Browser: if image_destination_hash == self.loopback: local_image = path.replace("/media/", "") local_path = f"{self.app.pagespath}/{local_image}" - if self.image_rendering_supported: w.load(local_path) + if self.image_rendering_supported: + w.load(local_path) + self.refresh_page_layout() image["updated"] = time.time() return @@ -908,7 +1003,9 @@ class Browser: shutil.move(file_handle.name, file_destination) resolved_path = self.resolve_image(url) - if resolved_path and self.image_rendering_supported: w.load(resolved_path, remote_source=True) + if resolved_path and self.image_rendering_supported: + w.load(resolved_path, remote_source=True) + self.refresh_page_layout() except Exception as e: RNS.log("Error while handling image response: "+str(e), RNS.LOG_ERROR) @@ -979,15 +1076,14 @@ class Browser: return hidden def rebuild_visible(self): - pile = getattr(self, "page_pile", None) - if pile is None: return + page = getattr(self, "page_list", None) + if page is None: return hidden = self.hidden_indices() - opts = pile.options() - pile.contents = [(w, opts) for i, w in enumerate(self.attr_maps) if i not in hidden] - try: - if pile.focus_position >= len(pile.contents): - pile.focus_position = max(0, len(pile.contents)-1) - except Exception: pass + self.page_rows = [i for i in range(len(self.attr_maps)) if i not in hidden] + focus = page.focus_position if page.body else 0 + page.body[:] = [self.attr_maps[i] for i in self.page_rows] + page.invalidate_layout() + if page.body: page.set_focus(min(focus, len(page.body)-1)) def fold_changed(self, heading_widget): if getattr(heading_widget, "collapsed", False): @@ -996,9 +1092,9 @@ class Browser: self.collapsed_headings.discard(heading_widget) self.rebuild_visible() try: - for pos, (w, _) in enumerate(self.page_pile.contents): + for pos, w in enumerate(self.page_list.body): if getattr(w, "_original_widget", None) is heading_widget: - self.page_pile.focus_position = pos + self.page_list.set_focus(pos) break except Exception: pass @@ -1067,6 +1163,7 @@ class Browser: url = partial["url"] pile = partial["pile"] pile.contents = [(urwid.Text(f"Could not load partial {url}: The resource transfer failed"), pile.options())] + self.refresh_page_layout() except Exception as e: RNS.log(f"Error in partial failed callback: {e}", RNS.LOG_ERROR) RNS.trace_exception(e) @@ -1085,6 +1182,7 @@ class Browser: partial["attr_maps"] = markup_to_attrmaps(strip_modifiers(partial["content"]), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) pile = partial["pile"] pile.contents = [(e, pile.options()) for e in partial["attr_maps"]] + self.refresh_page_layout() except Exception as e: RNS.trace_exception(e) @@ -1098,6 +1196,7 @@ class Browser: pile = partial["pile"] url = partial["url"] pile.contents = [(urwid.Text(f"Could not load partial {url}: {e}"), pile.options())] + self.refresh_page_layout() return if partial_destination_hash != self.loopback and not RNS.Transport.has_path(partial_destination_hash): diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 209af64..ae0e5e1 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -1159,6 +1159,8 @@ class LinkableText(urwid.Text): super().__init__(text, align=align) self.delegate = delegate self._cursor_position = 0 + self._cursor_promised = None + self._cursor_promised_at = 0 self.key_timeout = 2 self.in_columns = False if self.delegate != None: @@ -1265,28 +1267,35 @@ class LinkableText(urwid.Text): def kt_event(self, loop, user_data): self._invalidate() + def cursor_visible(self): + return self.delegate == None or time.time() < self.delegate.last_keypress+self.key_timeout + def render(self, size, focus=False): - now = time.time() c = super().render(size, focus) - - if focus and (self.delegate == None or now < self.delegate.last_keypress+self.key_timeout): - c = urwid.CompositeCanvas(c) - c.cursor = self.get_cursor_coords(size) - if self.delegate != None: - self.peek_link() - + if focus: + if time.time() < self._cursor_promised_at+0.5: + coords = self._cursor_promised + else: + coords = self.get_cursor_coords(size) + if coords != None: + c = urwid.CompositeCanvas(c) + c.cursor = coords + if self.delegate != None: + self.peek_link() return c def get_cursor_coords(self, size): - if self._cursor_position > len(self.text): - return None - - (maxcol,) = size - trans = self.get_line_translation(maxcol) - x, y = calc_coords(self.text, trans, self._cursor_position) - if maxcol <= x: - return None - return x, y + self._cursor_promised_at = time.time() + self._cursor_promised = None + if not self.cursor_visible() or self._cursor_position > len(self.text): + return None + (maxcol,) = size + trans = self.get_line_translation(maxcol) + x, y = calc_coords(self.text, trans, self._cursor_position) + if maxcol <= x: + return None + self._cursor_promised = (x, y) + return x, y def mouse_event(self, size, event, button, x, y, focus): try: diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index c3010b9..a682641 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -9,6 +9,7 @@ # GNU General Public License for more details # http://www.gnu.org/licenses/gpl-3.0.txt +import RNS import time import urwid from urwid.widget import BOX, FIXED, FLOW @@ -371,6 +372,11 @@ class ScrollBar(urwid.WidgetDecoration): self._sevt_interval = 0 self._sevt_start = 0 self._sfreq_deque = deque(maxlen=5) + + self._debug_hz = 0 + self._debug_hz_smoothed = 0 + self._debug_scroll_ms = 0 + self._debug_scroll_started = 0 def render(self, size, focus=False): maxcol, maxrow = size @@ -390,6 +396,12 @@ class ScrollBar(urwid.WidgetDecoration): ow_rows_max = ow_base.rows_max(ow_size, focus) ow_canv = ow.render(ow_size, focus) + + if self._debug_scroll_started: + total_ms = (time.time() - self._debug_scroll_started) * 1000 + # RNS.log(f" accel={self._accel} , hz={RNS.prettyfrequency(self._debug_hz)} smoothed={RNS.prettyfrequency(self._debug_hz_smoothed)} scroll={self._debug_scroll_ms:.1f}ms total={total_ms:.1f}ms", RNS.LOG_DEBUG) + self._debug_scroll_started = 0 + self._original_widget_size = ow_size pos = ow_base.get_scrollpos(ow_size, focus) @@ -427,10 +439,6 @@ class ScrollBar(urwid.WidgetDecoration): combinelist = [(ow_canv, None, True, ow_size[0]), (sb_canv, None, False, sb_width)] - - - - self._sb_visible = True self._sb_thumb_height = thumb_height self._sb_ow_rows_max = ow_rows_max @@ -531,23 +539,22 @@ class ScrollBar(urwid.WidgetDecoration): sevt_freq = 1/self._sevt_interval self._sfreq_deque.append(sevt_freq) freq_smth = sum(self._sfreq_deque)/len(self._sfreq_deque) + self._debug_hz = sevt_freq + self._debug_hz_smoothed = freq_smth if len(self._sfreq_deque) >= self._sfreq_deque.maxlen: if freq_smth > 25.0: self._accel = max(self._accel, 3) elif freq_smth > 12.0: self._accel = max(self._accel, 2) else: self._accel = max(self._accel, 1) - # import RNS; RNS.log(f"a={self._accel} s={RNS.prettyfrequency(freq_smth)} f={RNS.prettyfrequency(sevt_freq)}", RNS.LOG_CRITICAL) - step = self._accel - if button == 4: # scroll wheel up + if button in (4, 5): + scroll_started = time.time() pos = ow.get_scrollpos(ow_size) - newpos = pos - step - if newpos < 0: newpos = 0 + if button == 4: newpos = max(0, pos - step) + else: newpos = pos + step ow.set_scrollpos(newpos) - return True - elif button == 5: # scroll wheel down - pos = ow.get_scrollpos(ow_size) - ow.set_scrollpos(pos + step) + self._debug_scroll_started = scroll_started + self._debug_scroll_ms = (time.time() - scroll_started) * 1000 return True return False From d2de625e5f36ee0922180bd540b3df83b50fbeb2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 14:47:20 +0200 Subject: [PATCH 566/592] Wrap browser keypress handler to avoid crash on intermittent listbox invalid offset --- nomadnet/ui/textui/Browser.py | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index c9c0c22..edfa939 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -161,24 +161,30 @@ class PageList(urwid.ListBox): return unhandled_key def handle_key(self, size, key): - if key in ("page up", "page down", "home", "end"): - focus_widget = self.focus - if focus_widget is not None and focus_widget.selectable(): - key = focus_widget.keypress((size[0],), key) - if key is None: - self.make_cursor_visible(size) - return None + try: + if key in ("page up", "page down", "home", "end"): + focus_widget = self.focus + if focus_widget is not None and focus_widget.selectable(): + key = focus_widget.keypress((size[0],), key) + if key is None: + self.make_cursor_visible(size) + return None - if key == "page down": self.scroll(size[1] - 1) - elif key == "page up": self.scroll(1 - size[1]) - elif key == "home": self.set_scrollpos(0) - elif key == "end": self.set_scrollpos(self.rows_max(size)) + if key == "page down": self.scroll(size[1] - 1) + elif key == "page up": self.scroll(1 - size[1]) + elif key == "home": self.set_scrollpos(0) + elif key == "end": self.set_scrollpos(self.rows_max(size)) + return None + + unhandled_key = super().keypress(size, key) + if key in ("up", "down"): return None + return unhandled_key + + except Exception as e: + RNS.log(f"Error while handling browser keypress: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) return None - unhandled_key = super().keypress(size, key) - if key in ("up", "down"): return None - return unhandled_key - def mouse_event(self, size, event, button, col, row, focus): self._page_size = size if button in (4, 5): return False From c0bcc1c2ebb3534ba6f462fa0180017b51306206 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 15:39:47 +0200 Subject: [PATCH 567/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8179141..16ad895 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.5.2", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.5.3", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From 240e391a99be16ffa13b956de85371b006d03c20 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 16:31:04 +0200 Subject: [PATCH 568/592] Conv cache property --- nomadnet/NomadNetworkApp.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index edc6833..128f495 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -113,6 +113,7 @@ class NomadNetworkApp: self.identitypath = self.configdir+"/storage/identity" self.cachepath = self.configdir+"/storage/cache" self.imagecachepath = self.configdir+"/storage/cache/images" + self.convcachepath = self.configdir+"/storage/cache/images/converted" self.resourcepath = self.configdir+"/storage/resources" self.conversationpath = self.configdir+"/storage/conversations" self.directorypath = self.configdir+"/storage/directory" @@ -179,6 +180,9 @@ class NomadNetworkApp: if not os.path.isdir(self.imagecachepath): os.makedirs(self.imagecachepath) + if not os.path.isdir(self.convcachepath): + os.makedirs(self.convcachepath) + if not os.path.isdir(self.resourcepath): os.makedirs(self.resourcepath) From 8d4daed97880aa21ba552d9bce64891743879278 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 17:27:09 +0200 Subject: [PATCH 569/592] Auto-convert --- nomadnet/Node.py | 85 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 008ae13..31d4a26 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -4,9 +4,13 @@ import sys import RNS import time import threading +import hashlib +import shutil import subprocess import RNS.vendor.umsgpack as msgpack +from RNS.Utilities.rngit.media import convert_file_to_webp + class Node: JOB_INTERVAL = 5 SETTINGS_FLUSH_INTERVAL = 60 @@ -26,6 +30,7 @@ class Node: self.job_interval = Node.JOB_INTERVAL self.should_run_jobs = True self.settings_dirty = False + self.conversion_cache_lock = threading.Lock() self.last_settings_flush = time.time() self.app_data = None self.name = self.app.node_name @@ -158,7 +163,12 @@ class Node: RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) return None - MEDIA_EXTS = [".webp"] + NATIVE_MEDIA_EXTS = [".webp"] + MEDIA_EXTS = [".webp", ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] + CONVERSION_QUALITY = 85 + CONVERSION_MAX_DIMENSION = 1200 + CONVERSION_CACHE_MAX_FILES = 256 + CONVERSION_CACHE_MAX_BYTES = 192*1024*1024 def serve_media(self, path, data, request_id, link_id, remote_identity, requested_at): if not type(data) == dict: return None if not "path" in data: return None @@ -191,8 +201,17 @@ class Node: request_allowed = self.request_allowed(media_path, remote_identity) try: if request_allowed: - file_path = media_path - file_name = os.path.basename(file_path) + if not base_ext.lower() in self.NATIVE_MEDIA_EXTS: + converted_path = self.convert_media_to_webp(media_path) + if converted_path is False: + RNS.log(f"Could not convert {media_path} to WebP", RNS.LOG_DEBUG) + return False + file_path = converted_path + file_name = os.path.splitext(base_name)[0]+".webp" + else: + file_path = media_path + file_name = base_name + RNS.log(f"Serving media: {file_path}", RNS.LOG_VERBOSE) return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}] @@ -205,6 +224,65 @@ class Node: RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) return False + def convert_media_to_webp(self, media_path, quality = CONVERSION_QUALITY, max_dimension = CONVERSION_MAX_DIMENSION): + try: + hasher = hashlib.sha256() + with open(media_path, "rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): hasher.update(chunk) + source_hash = hasher.hexdigest() + except Exception as e: + RNS.log(f"Could not read media source for conversion: {media_path}", RNS.LOG_WARNING) + return False + + if quality == None: quality = Node.CONVERSION_QUALITY + if max_dimension == None: max_dimension = Node.CONVERSION_MAX_DIMENSION + + cache_name = f"{source_hash}.q{quality}.d{max_dimension if max_dimension is not None else 0}.webp" + cache_path = os.path.join(self.app.convcachepath, cache_name) + return self.__cached_conversion(media_path, cache_path, quality, max_dimension) + + def __cached_conversion(self, media_path, cache_path, quality, max_dimension): + with self.conversion_cache_lock: + if os.path.isfile(cache_path): + RNS.log(f"Using cached media conversion for: {media_path}", RNS.LOG_VERBOSE) + try: os.utime(cache_path, None) + except Exception: pass + return cache_path + + try: + os.makedirs(self.app.convcachepath, exist_ok=True) + converted_path = convert_file_to_webp(media_path, quality=quality, max_dimension=max_dimension) + if converted_path is False: return False + + shutil.move(converted_path, cache_path) + self._prune_conversion_cache() + RNS.log(f"Converted media: {media_path} -> {cache_path}", RNS.LOG_VERBOSE) + return cache_path + + except Exception as e: + RNS.log(f"Error during media conversion of {media_path}: {e}", RNS.LOG_WARNING) + return False + + def _prune_conversion_cache(self): + try: + entries = [] + for name in os.listdir(self.app.convcachepath): + path = os.path.join(self.app.convcachepath, name) + try: + st = os.stat(path) + if st.st_size > 0: + entries.append((st.st_mtime, st.st_size, path)) + except OSError: pass + + total = sum(e[1] for e in entries) + entries.sort() + while len(entries) > Node.CONVERSION_CACHE_MAX_FILES or total > Node.CONVERSION_CACHE_MAX_BYTES: + _, size, path = entries.pop(0) + total -= size + try: os.unlink(path) + except OSError: pass + except OSError: pass + def request_allowed(self, file_path, remote_identity): if file_path.lower().endswith(".allowed"): return False allowed_path = file_path+".allowed" @@ -235,7 +313,6 @@ class Node: RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE) return False - # TODO: Improve file handling, this will be slow for large files def serve_file(self, path, data, request_id, remote_identity, requested_at): RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) try: From 0b3f96e06a13547af6d7e446c580165df2bb68d4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 18:16:39 +0200 Subject: [PATCH 570/592] Use local cache dir --- nomadnet/NomadNetworkApp.py | 6 +++++- nomadnet/ui/textui/images/_webp.py | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 128f495..e9852e5 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -113,7 +113,8 @@ class NomadNetworkApp: self.identitypath = self.configdir+"/storage/identity" self.cachepath = self.configdir+"/storage/cache" self.imagecachepath = self.configdir+"/storage/cache/images" - self.convcachepath = self.configdir+"/storage/cache/images/converted" + self.convcachepath = self.configdir+"/storage/cache/images/converted_node" + self.dispcachepath = self.configdir+"/storage/cache/images/converted_disp" self.resourcepath = self.configdir+"/storage/resources" self.conversationpath = self.configdir+"/storage/conversations" self.directorypath = self.configdir+"/storage/directory" @@ -183,6 +184,9 @@ class NomadNetworkApp: if not os.path.isdir(self.convcachepath): os.makedirs(self.convcachepath) + if not os.path.isdir(self.dispcachepath): + os.makedirs(self.dispcachepath) + if not os.path.isdir(self.resourcepath): os.makedirs(self.resourcepath) diff --git a/nomadnet/ui/textui/images/_webp.py b/nomadnet/ui/textui/images/_webp.py index 5eff74c..9238216 100644 --- a/nomadnet/ui/textui/images/_webp.py +++ b/nomadnet/ui/textui/images/_webp.py @@ -25,7 +25,6 @@ # Environment overrides: # # NOMADNET_IMAGE_BACKEND force a specific backend (strict) -# NOMADNET_IMAGE_CACHE override the conversion cache directory import ctypes import ctypes.util @@ -115,12 +114,13 @@ def _encode_png_rgba(rgba, width, height): # Conversion cache # #################### +CONV_CACHE_BASE = None def cache_dir(): - base = os.environ.get("NOMADNET_IMAGE_CACHE") - if not base: - base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache") - base = os.path.join(base, "nomadnet", "image-cache") - return base + global CONV_CACHE_BASE + if not CONV_CACHE_BASE: + from nomadnet.NomadNetworkApp import NomadNetworkApp + CONV_CACHE_BASE = NomadNetworkApp._shared_instance.dispcachepath + return CONV_CACHE_BASE def _cache_path(source_data): return os.path.join(cache_dir(), hashlib.sha256(source_data).hexdigest() + ".png") From 5d6419610fb94d1a45794cad2cbfa3b3e02b547f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 18:30:14 +0200 Subject: [PATCH 571/592] Always key local image cache on full dest+path URL --- nomadnet/ui/textui/Browser.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index edfa939..dc857ba 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -274,27 +274,22 @@ class Browser: else: RNS.log(f"In-browser image rendering is not supported", RNS.LOG_DEBUG) def current_url(self): - if self.destination_hash == None: - return "" + if self.destination_hash == None: return "" path = "" if self.path == None else self.path url = RNS.hexrep(self.destination_hash, delimit=False)+":"+path if isinstance(self.request_data, dict) and self.request_data: parts = [] for k, v in self.request_data.items(): - if not isinstance(k, str): - continue - if not k.startswith("var_"): - continue + if not isinstance(k, str): continue + if not k.startswith("var_"): continue parts.append(k[4:]+"="+str(v)) - if parts: - url += "`" + "|".join(parts) + if parts: url += "`" + "|".join(parts) return url def url_hash(self, url): - if url == None: - return None + if url == None: return None else: url = url.encode("utf-8") return RNS.hexrep(RNS.Identity.full_hash(url), delimit=False) @@ -756,6 +751,8 @@ class Browser: if self.page_list is not None: self.page_list.invalidate_layout() def image_cache_path(self, url): + destination_hash, path = self.parse_url(url) + url = f"{RNS.hexrep(destination_hash, delimit=False)}:{path}" url_hash = self.url_hash(url) if not url_hash: return None else: From abb1d3b9234197ee3f5894b0b4fa540b4f84c08e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 18:48:08 +0200 Subject: [PATCH 572/592] Evict browser image cache entries according to limit by mtime --- nomadnet/NomadNetworkApp.py | 2 +- nomadnet/ui/textui/Browser.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index e9852e5..725bdf9 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -112,7 +112,7 @@ class NomadNetworkApp: self.storagepath = self.configdir+"/storage" self.identitypath = self.configdir+"/storage/identity" self.cachepath = self.configdir+"/storage/cache" - self.imagecachepath = self.configdir+"/storage/cache/images" + self.imagecachepath = self.configdir+"/storage/cache/images/browser" self.convcachepath = self.configdir+"/storage/cache/images/converted_node" self.dispcachepath = self.configdir+"/storage/cache/images/converted_disp" self.resourcepath = self.configdir+"/storage/resources" diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index dc857ba..e843dee 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -251,6 +251,7 @@ class Browser: self.image_updater_running = False self.partial_updater_lock = Lock() self.image_updater_lock = Lock() + self.image_cache_lock = Lock() self.image_mode = self.app.config["textui"]["image_loading"] self.image_rendering_supported = _termlib.is_kitty_supported() and self.image_mode != IMGLOAD_NEVER self.build_display() @@ -756,7 +757,7 @@ class Browser: url_hash = self.url_hash(url) if not url_hash: return None else: - path = f"{self.app.cachepath}/images/{url_hash}" + path = f"{self.app.imagecachepath}/{url_hash}" return path def resolve_image(self, url): @@ -1004,6 +1005,7 @@ class Browser: file_handle = request_receipt.response file_destination = cachepath shutil.move(file_handle.name, file_destination) + threading.Thread(target=self.clean_image_cache, daemon=True).start() resolved_path = self.resolve_image(url) if resolved_path and self.image_rendering_supported: @@ -1014,6 +1016,33 @@ class Browser: RNS.log("Error while handling image response: "+str(e), RNS.LOG_ERROR) image["failed"] = True + IMAGE_CACHE_MAX_FILES = 256 + IMAGE_CACHE_MAX_BYTES = 192*1024*1024 + def clean_image_cache(self): + if self.image_cache_lock.locked(): return + with self.image_cache_lock: + try: + entries = [] + for name in os.listdir(self.app.imagecachepath): + path = os.path.join(self.app.imagecachepath, name) + try: + st = os.stat(path) + if st.st_size > 0: entries.append((st.st_mtime, st.st_size, path)) + except OSError as exc: RNS.log(f"Could not get cached file information for {path}: {e}", RNS.LOG_DEBUG) + + cleaned_b = 0; cleaned_c = 0 + total = sum(e[1] for e in entries) + entries.sort() + while len(entries) > self.IMAGE_CACHE_MAX_FILES or total > self.IMAGE_CACHE_MAX_BYTES: + _, size, path = entries.pop(0) + total -= size + cleaned_b += size + cleaned_c += 1 + try: os.unlink(path) + except OSError as exc: RNS.log(f"Could not remove cached file {path}: {exc}") + if cleaned_b or cleaned_c: RNS.log(f"Cleaned {RNS.prettysize(cleaned_b)} / {cleaned_c} image{'s' if cleaned_c != 1 else ''} from cache", RNS.LOG_DEBUG) + except Exception as exc: RNS.log(f"Error while cleaning browser image cache: {exc}", RNS.LOG_ERROR) + # Render an arbitrary markup buffer (used by the page editor preview) and # return the content widget, with partials/folds/fields wired as usual def render_markup_buffer(self, markup): From 379bf9318e0613a7fd1b7a1dfab9e008c84c12d5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 19:14:51 +0200 Subject: [PATCH 573/592] Add local image preview and auto-conversion to node editor and local loopback browsing --- nomadnet/ui/textui/Browser.py | 7 +++++++ nomadnet/ui/textui/Editor.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index e843dee..019f175 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -864,6 +864,13 @@ class Browser: if image_destination_hash == self.loopback: local_image = path.replace("/media/", "") local_path = f"{self.app.pagespath}/{local_image}" + + if self.app.node: + image_ext = os.path.splitext(local_path)[-1] + if not image_ext in self.app.node.NATIVE_MEDIA_EXTS: + RNS.log(f"Non-native media type for {local_path}, attempting auto-conversion", RNS.LOG_DEBUG) + local_path = self.app.node.convert_media_to_webp(local_path) + if self.image_rendering_supported: w.load(local_path) self.refresh_page_layout() diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py index b22e449..2952311 100644 --- a/nomadnet/ui/textui/Editor.py +++ b/nomadnet/ui/textui/Editor.py @@ -445,6 +445,8 @@ class PageEditorDisplay(): self.register_styles() self.preview_browser = Browser(self.app, "nomadnetwork", "node", delegate=None) + self.preview_browser.loopback = self.app.node.destination.hash + self.preview_browser.destination_hash = self.app.node.destination.hash self.preview_browser.handle_link = self._preview_handle_link self.preview_browser.marked_link = lambda *a, **k: None self.preview_browser._content_cols = self._preview_cols From 7fbd5390ec2818d2996e2cbbfd439d2997d3ec2f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 19:52:14 +0200 Subject: [PATCH 574/592] Fixed missing quit_confirm at init --- nomadnet/NomadNetworkApp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 725bdf9..b32a4e2 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -316,6 +316,7 @@ class NomadNetworkApp: "last_announce": None, "node_last_announce": None, "propagation_node": None, + "quit_confirm": True, "last_lxmf_sync": 0, "node_connects": 0, "served_page_requests": 0, From 73380d2c46c0e746d5a17534987655c869a00cf5 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 10 Sep 2026 19:52:22 +0200 Subject: [PATCH 575/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index bf25615..daa50c7 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.4.1" +__version__ = "1.4.2" From 652a6256ef0bc74dae477463893625fb8254d7d4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 11 Sep 2026 16:10:42 +0200 Subject: [PATCH 576/592] Hacks to fix broken rrcd /who and room user pre-seed behaviour on low-MTU/low-bandwidth links, instead of completely falling apart --- nomadnet/RRC.py | 147 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 134 insertions(+), 13 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index ddafa60..5ca4bfe 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -118,7 +118,7 @@ def _msg_id(): # greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)" _WHO_ENTRY_RE = re.compile( r"(?:^|,\s)" - r"(?:(?P[0-9a-fA-F]{32})|(?P.+?)\s\((?P[0-9a-fA-F]{12})\))" + r"(?:(?P[0-9a-fA-F]{64})|(?P.+?)\s\((?P[0-9a-fA-F]{12})\))" r"(?=,\s|$)" ) @@ -170,6 +170,16 @@ def _parse_room_list_notice(text): return rooms +# On low-MTU links the hub may deliver a /list reply as a sequence of +# chunked NOTICE packets (one line per packet) instead of a single notice +# or a resource transfer. The chunks carry no continuation marker, but each +# line has a recognisable shape. Lines matching it, arriving within a short +# window after a /list header, are treated as continuation fragments and +# merged into the room list instead of being recorded as chat messages. +_LIST_FRAGMENT_RE = re.compile(r"^ (?P\S+?)(?: - (?P.*))?$") +LIST_FRAGMENT_WINDOW = 60.0 + + def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None): env = { K_V: RRC_VERSION, @@ -235,6 +245,11 @@ class RRCHub: self.mention_rooms = set() self.members = {} self.nicks = {} + # Display-only member entries derived from 12-hex identity prefixes + # in /who replies. Populated when the full identity hash of a nicked + # member is not yet known, and promoted to regular members as soon + # as the full hash is learned from room traffic or join fanouts. + self.prefix_members = {} self.auto_reconnect = False self.auto_list = False @@ -259,6 +274,7 @@ class RRCHub: self.available_rooms = {} self._silent_list_pending = 0 self._silent_who_rooms = set() + self._list_fragment_until = 0.0 self.nick_override = None self._pending_joins = set() @@ -290,6 +306,7 @@ class RRCHub: self.unread_rooms.discard(r) self.mention_rooms.discard(r) self.members.pop(r, None) + self.prefix_members.pop(r, None) self._delete_history(r) self.manager.save() self.manager._notify_change(self) @@ -306,7 +323,23 @@ class RRCHub: def get_members(self, room): with self._lock: - return list(self.members.get(room, set())) + return list(self.members.get(room, set()) | self.prefix_members.get(room, set())) + + def _upgrade_prefix_member(self, room, full_hash): + # Once a full identity hash is learned from room traffic or join + # fanouts, promote any display-only prefix member that matches it, + # so the entry becomes a full, addressable member. The prefix + # entry's nickname is carried over unless the promoting source + # provides one. + pset = self.prefix_members.get(room) + if pset: + for p in list(pset): + if full_hash.startswith(p): + pset.discard(p) + nick = self.nicks.pop(p, None) + if nick and full_hash not in self.nicks: + self.nicks[full_hash] = nick + break def display_name_for(self, peer): if not isinstance(peer, (bytes, bytearray)): @@ -467,6 +500,7 @@ class RRCHub: self.welcomed = False self.motd = None self.members.clear() + self.prefix_members.clear() self._resource_expectations.clear() self._pending_joins.clear() self._pending_parts.clear() @@ -844,23 +878,50 @@ class RRCHub: def _process_notice_text(self, text): # Parse hub service notices (/list and /who replies) regardless of # whether they arrived as a packet or a resource transfer. Returns - # True when the notice was consumed silently (auto /list or /who) - # and should not be recorded to the message log. + # True when the notice was consumed silently (auto /list or /who, + # or a /list continuation fragment) and should not be recorded to + # the message log. parsed = _parse_room_list_notice(text) if parsed is not None: with self._lock: self.available_rooms = {strip_modifiers(name): strip_modifiers(topic) for name, topic in parsed.items()} + # The first line of a /list reply opens a short window in + # which continuation fragments (chunked NOTICE packets on + # low-MTU links) are absorbed into the room list. + self._list_fragment_until = time.monotonic()+LIST_FRAGMENT_WINDOW silent = self._silent_list_pending > 0 if silent: self._silent_list_pending -= 1 self.manager._notify_change(self) - if silent: + if silent: return True + with self._lock: + list_window_active = time.monotonic() < self._list_fragment_until + if list_window_active: + fm = _LIST_FRAGMENT_RE.match(text) + if fm is not None: + with self._lock: + frag_name = fm.group("name").strip().lower() + frag_topic = fm.group("topic") + if frag_topic: + frag_topic = strip_modifiers(frag_topic.strip()) or None + else: + frag_topic = None + self.available_rooms[frag_name] = frag_topic + self._list_fragment_until = time.monotonic()+LIST_FRAGMENT_WINDOW + self.manager._notify_change(self) return True parsed_who = _parse_who_notice(text) if parsed_who is not None: who_room, who_entries = parsed_who with self._lock: members = self.members.setdefault(who_room, set()) + # Treat /who replies as fresh roster snapshots: replace any + # display-only prefix members from earlier replies before + # applying this one. + prefix_members = self.prefix_members.setdefault(who_room, set()) + for p in list(prefix_members): + self.nicks.pop(p, None) + prefix_members.clear() for nick, hash_hex in who_entries: try: hash_bytes = bytes.fromhex(hash_hex) @@ -868,11 +929,23 @@ class RRCHub: continue if nick is None: members.add(hash_bytes) + self._upgrade_prefix_member(who_room, hash_bytes) continue + known = None for ph in members: if ph.startswith(hash_bytes): - self.nicks[ph] = nick + known = ph break + if known is not None: + self.nicks[known] = nick + else: + # A /who line only carries a 12-hex identity prefix + # for nicked members. If the full hash is not yet + # known, keep the prefix as a display-only member. + # It is promoted to a full member as soon as the + # hash is learned from room traffic or fanouts. + prefix_members.add(hash_bytes) + self.nicks[hash_bytes] = nick silent_who = who_room in self._silent_who_rooms if silent_who: self._silent_who_rooms.discard(who_room) @@ -987,8 +1060,10 @@ class RRCHub: members = self.members.setdefault(r, set()) for h in body_hashes: members.add(h) + self._upgrade_prefix_member(r, h) if own_hash is not None: members.add(own_hash) + self._upgrade_prefix_member(r, own_hash) # rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it # so display_name_for() can render the nick instead of a hash prefix. @@ -1043,12 +1118,19 @@ class RRCHub: self.nicks[ph] = parter_nick members = self.members.get(r) + pset = self.prefix_members.get(r) if members is not None: for h in body_hashes: members.discard(h) + if pset: + for p in list(pset): + if h.startswith(p): + pset.discard(p) + self.nicks.pop(p, None) if self_part: self.rooms.discard(r) self.members.pop(r, None) + self.prefix_members.pop(r, None) if self_part: self.manager.save() @@ -1075,7 +1157,9 @@ class RRCHub: with self._lock: self.nicks[bytes(src)] = nick if isinstance(room, str) and room: - self.members.setdefault(room.strip().lower(), set()).add(bytes(src)) + rn = room.strip().lower() + self.members.setdefault(rn, set()).add(bytes(src)) + self._upgrade_prefix_member(rn, bytes(src)) if isinstance(body, str): msg = RRCMessage( "msg", @@ -1108,7 +1192,9 @@ class RRCHub: with self._lock: self.nicks[bytes(src)] = nick if isinstance(room, str) and room: - self.members.setdefault(room.strip().lower(), set()).add(bytes(src)) + rn = room.strip().lower() + self.members.setdefault(rn, set()).add(bytes(src)) + self._upgrade_prefix_member(rn, bytes(src)) if isinstance(body, str): msg = RRCMessage( "action", @@ -1132,12 +1218,47 @@ class RRCHub: room = env.get(K_ROOM) src = env.get(K_SRC) if isinstance(body, str): - if self._process_notice_text(body): - return + #################### HACK ZONE ##################### + # This is the kind of crazy mess that happens when + # an LLM designs a chat protocol, and the client + # maintainer is too open-minded, decides to go with + # it, and now is responsible for the fundamental + # architectural chaos. + # + # Yes, I love RRC, but trying to figure out an + # elegant way to solve the underlying issues that + # make this kind of mess necessary revealed the + # following answer: There is none. + # + # There are so many bugs, weird cases, architectural + # mismatches, impossible situations and general + # weirdness here, that I am just not going to go and + # attempt to sort it all out. Sorry, I don't have the + # time, so a hack like this is what it will be. This + # makes things actually work semi-expectedly, even + # over low-bandwidth links and such, so hacky fix it + # is. Until we have something better, this will do + # just fine. On a related note, I was considering + # taking up maintenance of rrcd, now that it seems to + # not really be worked on or updated anymore, but + # looking at the entire system, I think it's better + # left as it; it works fine, albeit with a few quirks + # for it's purpose. My future work in chat-related + # protocols on Reticulum will be focused on a better + # architecture, incorporating everything we learned + # from RRC, and makeing it Reticulum-native from the + # start, instead of mirroring IRC too closely. + # - Mark + was_parsed = self._process_notice_text(body) room_n = room.strip().lower() if isinstance(room, str) else None if room_n is None and isinstance(body, str) and body.strip(): - with self._lock: - self.motd = strip_modifiers(body) + probably_motd = True + if was_parsed: probably_motd = False + if body.lstrip().lower().startswith("registered public rooms:"): probably_motd = False; was_parsed = True + if body.lstrip().lower().startswith("members in "): probably_motd = False; was_parsed = True + if probably_motd: + with self._lock: self.motd = strip_modifiers(body) + was_parsed = False self.manager._notify_change(self) msg = RRCMessage( "notice", @@ -1147,7 +1268,7 @@ class RRCHub: body, _now_ms(), ) - self._record_notice(msg) + if not was_parsed: self._record_notice(msg) return if t == T_ERROR: From 7606b32418500bd434beb6665739794dfe38f2a4 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 11 Sep 2026 17:08:50 +0200 Subject: [PATCH 577/592] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index daa50c7..aa56ed4 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.4.2" +__version__ = "1.4.3" diff --git a/setup.py b/setup.py index 16ad895..4770e3c 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.5.3", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.5.4", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From 24c61665e9a835d403e12c4df71c3e3f795545d8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 11 Sep 2026 20:05:17 +0200 Subject: [PATCH 578/592] Fixed PageList crashes --- nomadnet/ui/textui/Browser.py | 61 ++++++++++++++++++++++++++++++++--- nomadnet/vendor/Scrollable.py | 14 ++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 019f175..ad38f7a 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -93,14 +93,55 @@ class PageList(urwid.ListBox): self._invalidate() def _layout(self, maxcol): - if maxcol not in self._page_layouts: + offsets = self._page_layouts.get(maxcol) + if offsets is None or len(offsets) != len(self.body) + 1: layout_started = time.time() offsets = [0] for widget in self.body: offsets.append(offsets[-1] + widget.rows((maxcol,), True)) self._page_layouts[maxcol] = offsets # RNS.log(f" rebuilt {len(self.body)} widgets, {offsets[-1]} rows, width {maxcol}, {(time.time()-layout_started)*1000:.1f}ms", RNS.LOG_DEBUG) - return self._page_layouts[maxcol] + return offsets + + def calculate_visible(self, size, focus=False): + visible = super().calculate_visible(size, focus) + if visible is not None and self.body: + self._sync_layout(size[0], visible) + return visible + + def _sync_layout(self, maxcol, visible): + # Validate the cached layout against the live geometry of the + # currently visible widgets, and delta-update the cached offsets when a + # visible widget has changed height since the layout was built without + # rescanning the entire document on every render. + + if not self.body or not self._page_layouts: return + + offsets = self._page_layouts.get(maxcol) + if offsets is None or len(offsets) != len(self.body) + 1: return + + def sync_position(pos, live_rows): + if pos < 0 or pos >= len(self.body) or live_rows < 0: return + cached_rows = offsets[pos + 1] - offsets[pos] + if cached_rows == live_rows: return + + delta = live_rows - cached_rows + for i in range(pos + 1, len(offsets)): offsets[i] += delta + + # Other cached widths may also be affected by this change. Sync + # them using the changed widget's live rows at their widths. + for width, other in list(self._page_layouts.items()): + if width == maxcol or len(other) != len(offsets): continue + other_cached = other[pos + 1] - other[pos] + other_live = self.body[pos].rows((width,), True) + if other_cached == other_live: continue + other_delta = other_live - other_cached + for i in range(pos + 1, len(other)): other[i] += other_delta + + middle = visible.middle + sync_position(middle.focus_pos, middle.focus_rows) + for item in visible.top.fill: sync_position(item.position, item.rows) + for item in visible.bottom.fill: sync_position(item.position, item.rows) def render(self, size, focus=False): self._page_size = size @@ -128,12 +169,24 @@ class PageList(urwid.ListBox): size = self._page_size maxcol, maxrow = size if not self.body or not maxrow: return - + offsets = self._layout(maxcol) position = max(0, min(int(position), max(0, offsets[-1] - maxrow))) pos = bisect.bisect_left(offsets, position, 0, len(self.body)) if pos == len(self.body) or offsets[pos] - position >= maxrow: pos -= 1 - self.change_focus(size, pos, offsets[pos] - position) + + # Guard against the cached layout being inconsistent + # with the live widget geometry. Without it, change_focus + # can be called with an offset that the live target widget + # cannot satisfy, raising ListBoxError. + pos = max(0, min(pos, len(self.body) - 1)) + offset_inset = offsets[pos] - position + if offset_inset > maxrow - 1: offset_inset = maxrow - 1 + elif offset_inset < 0: + tgt_rows = max(1, self.body[pos].rows((maxcol,), True)) + if offset_inset < 1 - tgt_rows: offset_inset = 1 - tgt_rows + + self.change_focus(size, pos, offset_inset) def top_position(self, size=None): size = size or self._page_size diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index a682641..45ea781 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -407,6 +407,13 @@ class ScrollBar(urwid.WidgetDecoration): pos = ow_base.get_scrollpos(ow_size, focus) posmax = ow_rows_max - maxrow + # Clamp position to valid scroll range. The position + # reported by the underlying widget can transiently + # fall outside [0, posmax] if its layout state is + # inconsistent. + if pos < 0: pos = 0 + elif pos > posmax: pos = posmax + # Thumb shrinks/grows according to the ratio of # / thumb_weight = min(1, maxrow / max(1, ow_rows_max)) @@ -418,9 +425,12 @@ class ScrollBar(urwid.WidgetDecoration): if top_height == 0 and top_weight > 0: top_height = 1 - # Bottom part is remaining space + # Bottom part is remaining space. Guard against + # negative values so the scrollbar canvas can + # never be taller than maxrow. bottom_height = maxrow - thumb_height - top_height - assert thumb_height + top_height + bottom_height == maxrow + if bottom_height < 0: bottom_height = 0 + assert thumb_height + top_height + bottom_height <= maxrow # Create scrollbar canvas # Creating SolidCanvases of correct height may result in "cviews do not From e1e8ab83800fec0df358796b44d5fcec4b2cd12b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Fri, 11 Sep 2026 20:16:13 +0200 Subject: [PATCH 579/592] Hello typo my old friend --- nomadnet/RRC.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 5ca4bfe..f5cde89 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -1246,7 +1246,7 @@ class RRCHub: # for it's purpose. My future work in chat-related # protocols on Reticulum will be focused on a better # architecture, incorporating everything we learned - # from RRC, and makeing it Reticulum-native from the + # from RRC, and making it Reticulum-native from the # start, instead of mirroring IRC too closely. # - Mark was_parsed = self._process_notice_text(body) From abdfc62b17458481e0983b11f45000a99fa4a58c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Sep 2026 20:46:29 +0200 Subject: [PATCH 580/592] Updated readme --- README.md | 67 +++++++------------------------------------------------ README.mu | 3 ++- 2 files changed, 10 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 5d53d01..0b614f9 100755 --- a/README.md +++ b/README.md @@ -12,15 +12,17 @@ Nomad Network is build on [LXMF](https://github.com/markqvist/LXMF) and [Reticul Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over *extremely* low-bandwidth medium, and has been succesfully used over 300bps radio links. -If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android and macOS. +If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android, Windows and macOS. ## Notable Features - Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports. - Zero-configuration, minimal-infrastructure mesh communication - Distributed and encrypted message store holds messages for offline users - Connectable nodes that can host pages and files + - Image rendering support in terminals that support it - Node-side generated pages with PHP, Python, bash or others - Built-in text-based browser for interacting with contents on nodes + - Built-in RRC client for live, many-to-many chat - An easy to use and bandwidth efficient markup language for writing pages - Page caching in browser @@ -76,10 +78,10 @@ The first time the program is running, you will be presented with the **Guide se To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). -If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: +If you want to try Nomad Network without building your own physical network, you can connect to the [distributed backbone](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: - - `abb3ebcd03cb2388a838e70c001291f9` Dublin Hub Testnet Node - - `ea6a715f814bdc37e56f80c34da6ad51` Frankfurt Hub Testnet Node + - `9ce92808be498e9e05590ff27cbfdfe4` The rns.recipes forum + - `a4a5e861626ce97c9aa544d9ecdf6d22` rmap.world To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. @@ -88,42 +90,12 @@ You can install Nomad Network on Android using Termux, but there's a few more co For a native Android application with a graphical user interface, have a look at [Sideband](https://github.com/markqvist/Sideband). -### Docker Images - -Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the very latest commit) or the version number (eg `0.2.0`) for a specific release. - -```sh -$ docker pull ghcr.io/markqvist/nomadnet:master - -# Run nomadnet interactively in a container -$ docker run -it ghcr.io/markqvist/nomadnet:master --textui - -# Run nomadnet as a daemon, using config stored on the host machine in specified -# directories, and connect the containers network to the host network (which will -# allow the default AutoInterface to automatically peer with other discovered -# Reticulum instances). -$ docker run -d \ - -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ - -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ - --network host - ghcr.io/markqvist/nomadnet:master - -# You can also keep the network of the container isolated from the host, but you -# will need to manually configure one or more Reticulum interfaces to reach other -# nodes in a network, by editing the Reticulum configuration file. -$ docker run -d \ - -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ - -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ - ghcr.io/markqvist/nomadnet:master - -# Send daemon log output to console instead of file -$ docker run -i ghcr.io/markqvist/nomadnet:master --daemon --console -``` ## Tools & Extensions Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist: +- [Retipedia](https://github.com/RFnexus/Retipedia) - [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum)) - [NomadForecast](https://github.com/faragher/NomadForecast) - [micron-blog](https://github.com/randogoth/micron-blog) @@ -137,9 +109,6 @@ Nomad Network is a very flexible and extensible platform, and a variety of commu - [POPR](https://github.com/faragher/POPR) - [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools) -## Help & Discussion - -For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). ## Support Nomad Network You can help support the continued development of open, free and private communications systems by donating via one of the following channels: @@ -161,28 +130,8 @@ You can help support the continued development of open, free and private communi - Ko-Fi: https://ko-fi.com/markqvist -## Development Roadmap - -- New major features - - Network-wide propagated bulletins and discussion threads - - Collaborative maps and geospatial information sharing -- Minor improvements and fixes - - Link status (RSSI and SNR) in conversation or conv list - - Ctrl-M shorcut for jumping to menu - - Share node with other users / send node info to user - - Fix internal editor failing on some OSes with no "editor" alias - - Possibly add a required-width header - - Improve browser handling of remote link close - - Better navigation handling when requests fail (also because of closed links) - - Retry failed messages mechanism - - Re-arrange buttons to be more consistent - - Term compatibility notice in readme - - Selected icon in conversation list - - Possibly a Search Local Nodes function - - Possibly add via entry in node info box, next to distance - ## Caveat Emptor -Nomad Network is beta software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch. +Nomad Network is beta software, and should be considered as such. Use at your own risk and responsibility. ## Screenshots diff --git a/README.mu b/README.mu index 332b95a..2c95bf1 100644 --- a/README.mu +++ b/README.mu @@ -16,6 +16,7 @@ If you'd rather want to use an LXMF client with a graphical user interface, you • Zero-configuration, minimal-infrastructure mesh communication • Distributed and encrypted message store holds messages for offline users • Connectable nodes that can host pages and files + • Image rendering support in terminals that support it • Node-side generated pages with PHP, Python, bash or others • Built-in text-based browser for interacting with contents on nodes • Built-in RRC client for live, many-to-many chat @@ -119,4 +120,4 @@ For this to be possible, I need your help. Please support the continued developm >> Caveat Emptor -Nomad Network is experimental software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it `!has not`! been externally security audited, and there could very well be privacy-breaking bugs. Use at your own risk and responsibility. +Nomad Network is experimental software, and should be considered as such. Use at your own risk and responsibility. From 700104d3deb83dc19f223df5a13ef6d2c9463470 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Sep 2026 20:56:30 +0200 Subject: [PATCH 581/592] Updated readme --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 0b614f9..0b3df88 100755 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # Nomad Network - Communicate Freely +> [!WARNING] +> Several hastily launched, incorrect, and in most cases entirely LLM-generated fakes of Nomad Network and associated tools are currently being circulated and marketed. Most of these "projects" violate the open source GPLv3 license that Nomad Network was published under, but claim independent ownership and license grants. Such claims or grants are **not** legally valid, and **not** recognized by the Nomad Network authors and copyright holders. +> +> Any claimed assertion of copyright or grant of license that such projects are making are [void grants](https://reticulum.network/manual/brandolinis.html#void-grants-legal-foundations-of-machine-generated-code). Both the violators themselves, and **all** downstream projects using the infringing derivatives, are **directly** and **personally** liable for the legal consequences. +> +> For more detailed information, see the relevant sections of the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the manual. For software and projects recognized by the Reticulum community, see the [Programs Using Reticulum](https://reticulum.network/manual/software.html) chapter of the manual. +> +> Do **not** use these "projects", they are a hazard to the entire ecosystem we have been carefully building over the last ten years. + *This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.* Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. From f286853b2b963c2b45832c4e8f497401ec42aca8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Sep 2026 20:59:00 +0200 Subject: [PATCH 582/592] Updated readme --- README.md | 4 +++- docs/screenshots/1.png | Bin 80551 -> 137969 bytes docs/screenshots/6.png | Bin 0 -> 80551 bytes 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/screenshots/6.png diff --git a/README.md b/README.md index 0b3df88..3e537a0 100755 --- a/README.md +++ b/README.md @@ -152,4 +152,6 @@ Nomad Network is beta software, and should be considered as such. Use at your ow ![Screenshot 4](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/4.png) -![Screenshot 5](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/5.png) \ No newline at end of file +![Screenshot 5](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/5.png) + +![Screenshot 6](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/6.png) diff --git a/docs/screenshots/1.png b/docs/screenshots/1.png index ee38e564345d52480f8170cfc66ab6cbe503c9ac..3df866efa216cefd81d77e07c9491ea73c7c8f6d 100644 GIT binary patch literal 137969 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%0AA~PZa&&{cR))9tQ82cjgUM_AuFAdbXyRVw|L{Ag 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
J{oiUEx6?P9G{m_rQ1$K!zT` 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

SmxT+l22gu^i| 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


J{oiUEx6?P9G{m_rQ1$K!zT` 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
J{oiUEx6?P9G{m_rQ1$K!zT` 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

SmxT+l22gu^i| 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 Date: Sat, 26 Sep 2026 21:02:50 +0200 Subject: [PATCH 585/592] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 865c762..8bf2322 100755 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > > Any claimed assertion of copyright or grant of license that such projects are making are [void grants](https://reticulum.network/manual/brandolinis.html#void-grants-legal-foundations-of-machine-generated-code). Both the violators themselves, and **all** downstream projects using the infringing derivatives, are **directly** and **personally** liable for the legal consequences. > -> For more detailed information, see the relevant sections of the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the manual. For software and projects recognized by the Reticulum community, see the [Programs Using Reticulum](https://reticulum.network/manual/software.html) chapter of the manual. +> For more detailed information, see the relevant sections of the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the Reticulum Manual. For software and projects recognized by the Reticulum community, see the [Programs Using Reticulum](https://reticulum.network/manual/software.html) chapter. > > Do **not** use these "projects", they are a hazard to the entire ecosystem we have been carefully building over the last ten years. From 64b750c28bcf83253253e0c6060d13c85b2fa67f Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Sep 2026 21:07:23 +0200 Subject: [PATCH 586/592] Updated readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 8bf2322..05ca42a 100755 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ Nomad Network does not need any connections to the public internet to work. In f If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android, Windows and macOS. +Other Nomad Network clients include: + +- [MeshChatX](https://meshchatx.com/) +- [Columba (mobile)](https://github.com/torlando-tech/columba) +- [Ren Browser](https://github.com/Quad4-Software/Ren-Browser) + ## Notable Features - Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports. - Zero-configuration, minimal-infrastructure mesh communication From cfe23af68dfea725f4d88d95ffe9a4ef0e188941 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 26 Sep 2026 21:07:54 +0200 Subject: [PATCH 587/592] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 05ca42a..b306b86 100755 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. -![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) +![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1a.png) Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. From 9d242710903889f4da93e57957810aa4c324dd7e Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 27 Sep 2026 14:30:47 +0200 Subject: [PATCH 588/592] Fixed invalid divider chars crashing page rendering --- nomadnet/ui/textui/MicronParser.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index ae0e5e1..d4ac77d 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -603,14 +603,14 @@ def parse_line(line, state, url_delegate): if len(line) == 2: divider_char = line[1] # Control characters don't make sense here and otherwise crash nomadnet - if ord(divider_char) < 32: - divider_char = "\u2500" - else: - divider_char = "\u2500" - if state["depth"] == 0: - return [urwid.Divider(divider_char)] - else: - return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))] + if ord(divider_char) < 32: divider_char = "\u2500" + # Check if the divider char is renderable + try: urwid.Divider(divider_char).render((30,)) + except Exception as e: divider_char = "\u2500" + else: divider_char = "\u2500" + + if state["depth"] == 0: return [urwid.Divider(divider_char)] + else: return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))] output = make_output(state, line, url_delegate, pre_escape) From e5d7de45376625b1416de5a361831e57360e4c99 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 27 Sep 2026 14:32:15 +0200 Subject: [PATCH 589/592] Updated version --- nomadnet/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index aa56ed4..c0f285b 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.4.3" +__version__ = "1.4.4" From 725c5a1b420b105e638fbab7c4bd3cf3088ea02b Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 27 Sep 2026 14:52:39 +0200 Subject: [PATCH 590/592] Soft-fail and block missing termios on Windows --- nomadnet/ui/textui/images/_termlib.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/images/_termlib.py b/nomadnet/ui/textui/images/_termlib.py index abe7575..a974e84 100644 --- a/nomadnet/ui/textui/images/_termlib.py +++ b/nomadnet/ui/textui/images/_termlib.py @@ -10,9 +10,11 @@ import os import select import shutil import sys -import termios import time +try: import termios +except: termios = None + from . import _ctlseqs QUERY_TIMEOUT = 0.3 @@ -43,7 +45,7 @@ def _open_tty(): def query_tty(request, timeout=QUERY_TIMEOUT): # Sends a query to the terminal and returns the raw response. global _query_failed - if _query_failed: return b"" + if _query_failed or not termios: return b"" fd = _open_tty() if fd == -1: @@ -121,6 +123,7 @@ def _cell_size_from_winsize(buf): def _query_cell_size(): # Determines the current cell size in pixels, or None if unknown. + if not termios: return None fd = _open_tty() if fd != -1: # First, try the TIOCGWINSZ pixel fields (fast, no query) From 84620f847199fe8b8ea6d607d6431818877a4c27 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Mon, 28 Sep 2026 19:13:30 +0200 Subject: [PATCH 591/592] Updated versions --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4770e3c..45721bd 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.5.4", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"], + install_requires=["rns>=1.5.5", "lxmf>=1.2.0", "urwid>=3.0.5", "qrcode"], python_requires=">=3.8", ) From b2f852c9e0efc68d89d9185939f42ec0c2fee125 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 30 Sep 2026 15:08:55 +0200 Subject: [PATCH 592/592] Updated logging --- nomadnet/Directory.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 65ab3a7..8727b7d 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -6,7 +6,7 @@ import nomadnet import threading import RNS.vendor.umsgpack as msgpack -from LXMF import pn_announce_data_is_valid +from LXMF import pn_announce_data_is_valid, PN_META_IMPL_NAME, PN_META_VERSION from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name @@ -21,7 +21,10 @@ class PNAnnounceHandler: data = msgpack.unpackb(app_data) if data[2] == True: - RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + metadata = data[6] + if not PN_META_IMPL_NAME in metadata or not PN_META_VERSION in metadata: impl_str = "" + else: impl_str = f" running {metadata[PN_META_IMPL_NAME]} {metadata[PN_META_VERSION]}" + RNS.log(f"Received active propagation node announce from {RNS.prettyhexrep(destination_hash)}{impl_str}", RNS.LOG_DEBUG) associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity)

SmxT+l22gu^i| z3!N02X^Bj!+S80{<(0hfIisVyy9MsUqyzNyoY4S9w{U zFGciLGF)sCEOC|s=j8=XeIzCP1H^#FI2We!ShHPvCXZO91x^1Cs8=mY{7$m7Kkl`s zMbK0HdTiQzEb70Sz$^to!WKJEI$OuSihubF7p??4)&Q=e;bL+>Sd-(2!!>u>I!5W$ zw1$-R^bYEq1RjP_j2uh&U_VN)p zb59#pW>Qrc%yUOItW{*8@j2pC2SaLiemQ#*>OgkggL_P&ge# zre~S=sXE%47?;9tUnCtjj+$NBynY>nLdzraAe|NT9Ww&kPdLAPboGLVz3J4y}g zHsGCkndDsl9H@O}fclYF7_+Mv!aSqprFR5cKi_K~DYqGaY&HSI-ez z5ecknCi0e-0ggCbvxQ>$iJO{~e^-B-3)s#s15qX&)Q5)Ih4Zcy|Ke@?>+9%olDs*j#Rb@%l-%V#u|E)S>$LHSPk=_H#yb`V2Y2+lB>$@-bR1{|az0Lue7hQWoK>r-l z%X8rK7p}3g3zI(&{GQ(bx$ch&$BqWrKD3|$(4zv(M*;6MIeF|ySP5J9hitLbK1+vw z7f(e_dFcY>2R7hj8i-~05~mn;y}hPDEP zWzQlwYLnHiS5Q(jw+-()$o~YeVGhPh&vRc=a|Ng^+LxdwD%uV2A|ZJ~hCkEP;~W4U zX}tp9qs*N{LkEL;=RZ9ziGu)_YJWzx_X)ECsm&-q5+Zqro6dkPjS{ z(XmCnk|afE^YHPB1TI`0qdv_34ija6er?I#`td#rdic;y8s7tSch4ds8pg-txBdEE zyu3%4z8S2#uB{b;7jL_n!XQl!@hFY@>fWk(1YCwZw94hHk?3Xh| zNa~)mY)LmXH5(xqg>m>&=i!bs_)-s_gH>O@(SZ@r*3dWxfOgnW2E?s78-2}GgR221 z0*@X&f>@w?Xy_&cTWpvSFrXWsyhDtgtDi!EH{%Mvo;2#7L)ZjMYvyl>Aq>3zUjlAO z<7vTjihSgweN3QTp8-1n-_^*k^P z&|rds@L1qOk~h@bfPN(eJ{^?84Y2o^n1tB@e2v)Z!G)s=G4s`EV|AaXodZ2>T~_=9)t*vxFhcs2Ie*Pf42C_R zB9hjT{<18d4~_+0eG`hZ`^U>*{~qd+8y_d#AHlm7wrf6M$Sq2S3YJ^$I=ohJ@QYX1 zPQ~@>|K(RE9d%&G)HASLg?m;#qJHCsG*naNw<;2H4XXwRP6hq1nS?u5|E8mfz?hj? zhZPPEIjiZF=YTkkA8)F=f3G7$3|h$uhank+|bYj zEdlUpI=8NzfC0v6j5g_qk2*D3Cf~VD}u|4@QxAjv7rVjavW=PnanydaCTOR)ubLwUVDijp_WtN)<)4{Pk3qJ&j(`#;8F{#j9 zI8B9tPvjh>I5h3Wi(lddOgrM7Hm-d+sxmERy?!7)BXiyFO~WxIbMrU>a;)I+2HlHS zFWbWBbXjB{d~99Ty<&gp-o3YAqSv+|=@#3AK`ffQ(_)Zi6*Q|(c9J7i#Af_YDs^l( z*U-Wva_Kfb|UcPgcePd;kl%cVG$xc=#qF zq-Nwu3}@y1Sm3g4dYZqRG6)cRVK^JWcJGS1Zgoqbu}E`Z<(gb1DV`TO_mp3#>M_8< z0P3TB-|~F<^nB~H$R+ue1w6jBXo`QZxFJ9e3gvd$UxeU6y$Om5etQ?{Z?dhUh(PZIS{+Zjw#Mhmj-UT_ zpa+TKjuG=maQu^nCg0S0U^5sqY@Ogs-B{nQ+}Y|KrpSy#%C_&8p_TRv>-6J1f`U&t z2l+;!tkx;_-SZ+Jh)ch~g|58lG z<)kV{JiV+wCvuIe@g3)vFC74Kzjf!%v8z^h1%W*Z&diT{g&s!MhZlk~BCjzAdE(2U zO>hIst+K_k-oR#>99&ITwZZLHyo)1?zb zHvkPg_gja*p?l4vlf<5_tXzS@$@^TzyYvWeL`_Z29$QC8(H@WX1vscs+Mv65xs>nf zyf)_v z)>4IV4qcju=^@Ot#$?gv6G|Q_al1m9T!pN=vhhs}{uKyR5^nMTa;W& ztD4Qg*H6#^fS~@nPJd6&g4;s=ix{zoDrds)dwja641zHa-xR)Y+PiOYCN4JB$gk~f zW+^igiTp8f@Rs4?cEl{j4be`aAhPpnMk(k#j-+-K|0FU?tu@+nP*tMDwBAPZI2cwS zU*4B>QWe4KVspPv@z$X0H6F25--Q9!=otEFxr^;Gw(|y=&UmkWtNAv0tQ>StwU_z6 znaRIK)R3ph1222KpU20~*u{U9J=11pPTwRjjQ-NLQToamae21Rhu*MzZKKGV)vJ%j*b}QXld23+P{E z?PV&OUU;7wuU*Q)<7ef}$2-t&mG?JD%eox0fWwes5{mqge^849>!8}sM}NFVtC4OU zQJoSRpG8J=4S3i~zE0R2sd7>J@@0efUFqV8>a91lpchg`H;As)jm2=w4CvMF?w!Nm z^k`2MK@5~1PpH8#KxJc`)GoS1S)3z!k_b6vWOt06Qwhm3V3$r*tZNWs$w$vPY@;JL z=C8?Dph~kW-W6<&k-ztb9{KZ8ZvM-f%#lLV2%3Rr7hqO3r}w5?*Hudi(DrJ4hw*g# zm`V6rg?N#k7q*9U_WqT4-PU&{#RKu*_9oASK!xHM=K5prn#Z>S%_O0qlWS6vC@i)3 zFq=)nZGWj*q>xM;Jzxm0@LHXIIyB~fEd9X#WTTURTx%V z;}W7mBkV9l1-=%^-*YkD9&V+(jgL5dgh7;9CO+V@pq#|5JH`%Nj%=@I zw-dvg7y<(~_9QqBYcG25-DuCNsjkFXdP(f01OE}NF0$g4pbkN6L|KY7OtG-3KxeGnVV#XSi41sJB z9N8^>eeLgGYW!*g0+`Qm)#3W%!Kp&Z2gke6GYK#1X+hcIsI0>LWfZ-M8^p?-svX=` z#{y9zrwYIhq=G7x`Aj8*BUT)7j>e?{uiQf`S3X*R2LXdU>7zEoia<1s`aCo#;Heuo z*~esQ8p(mN`*}boHMErOb7z9-f`)O+l0r!M-PqV6`WIqiI>#jtS(^r|0OEmj%XG?T z-niKEh>C}8ui?X6zid^oc1+}kKY)Y=YL~QO#16NnROSkq;FZ}c?-La+wnE7pO68Cy zX(^O}8J_*)WyeG}#20^+m<~RJ$*7UzR|Cz23Pnx`c|yX{7^OhKFLi+CEj}?W?zbCX zvm-D~k(^JlST4THmpkSf2VvxlHqQpqHEkH5=kD1YA%JfBzU)G^mR_fjTx@N5e*Lv} zY9x{dq67|J)@bDA#x7kZXm&#p=cTDXgKVomlZ!P_u8VnvBd{q7{wDDm;%q2?tqX_H zMO~rKJ__sh_a0+IRKsB+XeI3<-C&1%MJ3YrrkiN=J1gtpIcjF;)FI- z>MuF|!~$@*!U{k5@noX8?MB`GLKsz#fRb}}rl8Ow#-J(z2tUCCm#;nr#>4l&ii@8z zHy_t(ZG_+#69xo=jS+&sO7MnG$1%T4d-u^u!PeH%V@am-RneL#5W4^lUj~MMeuJ_6 zS9g1JscuQ=_W$3C8zRKU6BlW^^QHaEc}2xYhk^ukJ3Cf@?Lu{+6Y|)h67#d#bMA2T zTSnAvqF=wR^>)ETLOLQ~0L_U8948`0GtRQ9Tj*em8uJ{Lrn`Cq;soA_m<{W!E6T*_TLmV zE-dPZOioe+la zph=k~?`j(Yd#!!hv)S9-D(E8|$ z33FoW!NqapEoW!v=b@20j*i8@9eZ@kzeCD6BZH%N3~`u7Ep2<_+4C^<;=sCPBdlxH z^@^v-E~ST#9JvFx0<8Htxw(#HAQYVLFn%C#;R}TzCLw6bz`y|4)f1Tl6kgUFOXfxS zh(#G>ZVhT6?HsC~xo-?W5CIod0PVL zxu%P~7BzHl=oxRd`jIhqVb8%Izd#Tq9>Y74$4S z*s@^^&;C;sfA}Dsg~Spcol)Yq4NB#1(NXB;X3DrD#jXD&X?dt6V=Djb1daJ$8o#;K z$2mHhVAPol>q0nN zp5&5KY|5*&cR$W=`6Ojd$+@1_W|MRl`&)qE?jd%Gcj&x{6~`UgD@U~H#LJ3{J%FX< zy>iGE85x;u*s*GAAth_^C7>4aua|xL9GP->s8H+^C<;hU1n&KYD|RCu%rmK>{ebp0 z|LN@i$QJxZGzmWRA90~^5-hh{g=k-|2?k1 zpUS^kzy7D^@^=L@=6-dr zM;zy6^Eu=wUl+X<9=tsG_j}WQjXi5s-}xnm560l~pcux6cHC&lTDEQfaNP)P|MH#e zlZ0+6p@6S_PRdLQ-6&^-g=rcPCgqotGlseTMhG|l`U*!L1s8C3e$2*QCabUmq9EBS>-0mzN(pd-LZT(23Wm9A+s5ET38^mVw}T>thS@dmY2L-Ha%ivl*mcyKqdDJf|{6)>wLTHe)`VS3Z%x2LA&C08g`_kCn@8AE!%xCrPqPm7?^`CZ+_Ni}(cCiwX6pcW|Me&{9 zGvaRbhd}-W$Y0d}zIBAbxxlVFbDpS=62mk&>zga6n^t+f3=0bbBxP@nzqi6xSyM#A zKUZ&*!L0COb|Pv_>_YF_S7p*gaq-`yi(yDE&!pt0)99!K%n3Ys`t*Z-fm!AJ^DyGv z8TaGM#VNq_7-(2y)NsAwtb$)GjN_d;(Ro|nMA6hdfLQZ4(;R%p)ts((`&9d8yQ|80 zF7eI0cCMM&C8V z{RCwn76SnORs1XU==0p%;5qDHa`p4xTpTKi;Q_j9dsuhot=<3xIb6+au*fQBiwF)k zVV^365`q3lb|^?mH;jS3W@}nb_Tk0r(d#Irf+7FIQ>R!g3K6nGLJ+uCcJmjs<-hcA`SSTU%V z@8bK*3d}Zf-4Gph!sJBTT#}?q_U$7-ul$XtJ@HZ68MEZP=4LHmB&G?VWNIWh3g~2^ z;&OWg%SPS`K7I`=gqZXfi4D)n_cndNSA*d-m@}i znT(Rs5@agL9XlnCTW+9T3M`3tU~;hqhtt1#^SNH`rXjz6o(M)0mWhga4^wKu`pJTx z2jDTvp0uO@8g_S~u>q5}Z?jSZ8x8*Qh;Z3&?%KO@N9LE$TZ72nTtSqglZI$(i(-Bj zg<@{$R@0Y1{;mYSEP4T@7Z~`+8M6rNg+Fh$*6{6Z74{S)g(c6hEKa{PW9XUad2XMRxTsT`yN|Q~ps0dqb4wQ)Nex+Ov)v zTIqk*d4>i3^(x~iG6eKO6PX1zui+G^i6U&rKiW6^#-Wh-mQ~-xrA^ibx@dZgRN^_O zp<+Xl;>v50s@nnN26|8r6mj9>%d(x$WpjwNRXDrj=NP%|^5txm;-af$3d**|sFl`s-};`#y_*DI^jLdZP@=V|bD0~u1 zJKtRoBer49L@!KecbW97JJg`=I9zHPH(C5Mn|c4|GFrSSL9$(zl+=SE^&`iQ^=z(M zf^*rc9-nv1?dJ21rBR!y zU{&bLr%%_;XrbOwt5+&z1Tp^A;HoBPVN^AMiL|WmYgi#-c*b6}{{GOBcOz*6Jj3ZE@p@s9>| znjhg+1O8k;N(D9tNe!%Ak5;Gj)ZTW;!?`#3a|OJGBIqTWa@klw$yneFlUkQDe>s^i=e?C>ZhkZqt9zI~d zeP?|9DT{;~X!#Kn&3oS}(|h8XvtVjEBg2OE=1r#&6XMr>-XGvXK<6%wXKH2!|Lv`^ zA7xA5Lt18ZC35H~<6>45m;dki`T2?DA$+0~GPLkn?{2`*T%jtLP88>=eRpPrs2Ez@ zrbDW7V<=~8cJ_T}jIcz!LU-c1Fl2I0QIU>js#nGJN}kccRtZ_)Aw0#x@~f(B`R$dO=gVW7d-m*c^Y+#UYIz+UUE-)W!9GQ5gB_`Hhkg{u6z4}O(BR!;Q&aO9 zmBc-RB%f+=C=4B*Bm_JonVRbVtE9FDu5TQMl&}3FN8W3@pc*zjC zTbd|iS-Dh6@X9mCn)&)}ofWrzLrXT3lP!&keF`fkhB(8JyLv$8UAYpS6K-fNWg@2w zvqC9xg2rI8SIyN*O{0$62^-I2&%u-qan8p4J**PAB1voYB4_|^RhRbIlx#c-tlU`| z?%~E%!N~tzko5w}gFMqJEn&=BX+?4k3F$CcN$urF>fHYk2=40Q#fvv!fTKrhysbB{ zY>Y=+TYGV=Miq?J`<8hYyKbFo;)dKd37gtkoUIFM3cSzWT`)Zo8xxZ@L)j@REH8B) z%>?b%_x>iuUQ1&IfV96TEUXQu!{!)!$*DZ@&W&i88oBNXk62rNHT@vNLIFr@w+;`h zU%%ckRN`M+TqB*7cFik6;T#t0*wQ)gSumSnq>b!^`kqiBo9P} zb3dQo8UDr9)$~Ryh_FE)M*?Te!%vT_(rl55Ns^DE6DOEHAQJAMN1kw#~ zuM*5q&w;~xdv3+!Sxf5GcZ+Kh~0m%Mp~=WV`3TfuRO=Fv=*H}v)8TW=N4 zY2UJ&E5VD(&6LyJL-Z-uLABM@Rpu=fVeF=GLzB0@vvWQ;?Q@Kb+CZg^$M`SDrOV1O zL3G{=H$#)hW@Tli?K&8CEGX{rsM+~6!))cWRlV`rC@Ye6DRsOv1oJ*WvfjW%)74N_HAB1K7No4B#nBPv9oI_IYkT7e);^_?ECd~ z4;t$#^pY#7irt0%4RL>NTh6||tOvuZ!~8-*T1!hyLui{Qc6N3UY)Z?qHqBluS~F_0 z%quPy2YI~|Nedx02-2g|VUKA}*tHUEUHk`is1a|0>^a)5otxWus6Q3wppLkybK_x< zHFm8USZHV~0}~}QTL6u-L{Y0E$PIWw621@c2s~;ai{$t1(N64(kJ^fgnEBqEto*9& zwF`C=jYWYIl6gkNu*OIQbJDnX7R;edE-d6~E0Ikq_>P1(5L@TXC0u{(*G!T%l=H@Z z0K;oXTIv&veqVy*>J|5GIdE3q_C;5Bx97L3rMqNu8>!sB)MR<%V}{~l1!}-;SJRZb z!H&(tjynpv>?(J>9oXSCP~fa#K^RG)}qD8=$-7U6k{&ki zavIOi7D67M@pk?R;J^jlpIeIB)(4|7JN-c)msZ?~TF z%%#e?^n|m<+f7f!_YS@Uy2f0X=2bTDNDx-5Bu?je67QnLA2h!)N_kxxVCG-F*5ZgD zj7BlrF*QbhWzv?&sy1ze_W25Y)w%BeBkvQ=EhtG8b|s^W8i7_NfI=yr%x8 z^Y79aIOWsvE|uNY)F?$CWizu(hV(>7h4BR$Cg|QT~XmO`C zX@&K#`w|rq_IZSEBS5d;28KYD2o`%vvdZ`G_bv+y_ZLlN^|=>L`4yMd+6wpBLeB=?QuoKdY>`1 z+r~W5MytwaJ9|8))a=hK=LF?LKiZZn<@|fgMg42baT?~Pe*Qd!-KezqAuS^Gg5Ie)h$= z=yXqfUsAA2v^c5Cce?+$NZ^P8yxr^!3aMxD277QF{}%53;+3!FK(2KFPtEqWzii-cIW&b%GxdH-HWTFKZFoo6tzlHu_w}s+K%afA zsNFoycr?4GvHkAq=zWPd=8RM9a{0Tv0VOWuW4DIthbERshflGaR{Mlr4RL&o*N@-xdF&>Zhk4YRqV%0y zhTcMa?dvC5U00bsb>@`aV9^xKzx zR^(bmNc?-_G&bcL z>f86_R-(*Y%q=_ozEHO9jzAz3eaD90SWhpGMtonT&&bOueg9>AOpIlrsh(awi11ZF z^D13klj>_@B6|IdsW?b+yoK4iV-J{5B^M9mTQ9EadyhB`JxFxR7|PT%PEQhyUeGb6 zvPn6Y-WV@;MLTC(Qa6%a+CL3#JLXT|+S5G|r$91na@IpQ5mf>JF!yphHR+JB{qrYp50o8|EbXrBpk@;;2l`VnfVs#p>9DV)ZtxUeR z|9rPmDJRmsABk`zldTQ)lmr;eT+COp(AC2OacC&4_NuN2YFc--frFJ(9(cr zw+^&xIgyirG)C9P`;zXS5}wtQ>~hx4FQJnVp(dHLBu?246Ta(}Xt#Xw_%(k2`K;_a z{o18(CvHY-r3%FI`1<&x zaMQAsdH1r+KR$?idwVNKdKMK$@$>VmfBKY}HB#k?|B#+uM+-gf1@BzOsCuhqzGQ4~ zhb8Pcb|Gt{0!8#)P2YL6OX%CfLG|0t=Cl7ly?u97lWDiFGxLppGovzN0R;gIhzN*M zq-cna0!E~F5Ta6~BTWPhV1rSbfb=dkp?9gVg7lh%j?!B|N`fRLx%-v*&Un^cXRZ6k zSvP;U77|k4_j#VZ_pj_N(K~XTYA*p=IE>E4i_~&=lc4y@2gutyV(fy;_2UPyg0;Gk zMhw*BY9Jqwah>@0u9?a!d~$JQC#h!16FM-T*L!Uyjbu+gP->b-X5%bh+=_kHor5p* z^qK#^?%m|y_m0xP=J)1XIal8Jfkz`6kV7Z{T6^qB`}p|muXRHeiQ4QN6~_F;pmoNs zs@Sg0X!D*R?QcA=c}nMMGltE!F`$3Z*@@UJSv;_WWJeG5ekU$dr9xRK!h|rZ^>X=-v>e~| z{V|pOl<%mRm}x{W(N8dF&6g!!N!2&w{IuO@yA?CVnzKt`G=6NeSpQNewjr$0>_1un zDUTszX<0+sz9`wB_0<*1{5Tgch0=B}pbx`cHj~FagSP?rutV|ACx1oH#k``T(*VaL z-QD*j-{eo9r1sk>et6h1>hYEvT7O;|s1#LNVuTF3uS{Szm`t&HQ)=L$V23&mAIa1A ztUIUT-fB5#E|P1sS46NL4~b_;f7{ez&7v0M2Wvf=L1l8EpDS@(llSb|L&{X+F_WIx zLEx2rw4MQCm;>qgycvPxZF>c0;)X7U_*Tl!hf>au*l#kDQ%{#Fp|6jaRbmU3ey zfd;*67NC;^i&wU6A51kpKG*JrqSb84{;b`-za6)`KosEiN)~WjdtFy)l7XR9G{I{K z*L9L=HQcjG&M`)7C%tW*T_5sFhp_k}F`_$lV^NriE|d)wM4!S`-(d*3nefROnt71K zSMOeO7_>GSzq$F<#r3XIdHvhI)ebqL(_iWd*X;R1`0T5QXDr%FAyRg1803q#3!2JK zd=(azlQV>#5Zu#tW-!G<#;w79m0s2~-kWwwl^^X}dolfhoWqb1hF8`uLrPkj)>!Z_ z$HDqb;FHy2(l1_BllHiLHXypwJiByrdh|tXECNUm-(8iRS_>$1*2RrD4pz2&_@L}L z?3M6ARvbey$Hjf9fPy%k(cTfTIEuJ)VAO^lgwmnm!2_iUA3P|nTGy`|3X5dVd@vk2 zo9*}Yb0d;}gO$B|hU%D~m6L)Trnv#vmvmSZwM%n7@yw_BjiZS)=eFfR2D6R4qCzW= zu^e5hgC<>*(0zvet(?k9poOjAXDRa4YcDY?U%qs*SV$W!fshufHRTs1kJY8rn|FgLqUvD(^6TR&I@gcXUwp^th(aA z^rw7@S71($R;81RH?z&qV@50D;lm&IA2^^l&8rF}Vwp4cVYNq44__fM4QUDf6$tLIE49oH-r2P?!SyAFmpAua4Hx zGEgab5omizAA5WReQmWD$q5A!VPUqr2*46VWI($d)aG+rRK(EGUhny&FyXkl+ixGc z%#GC5SQ8m9p*A|PRsKU4F+D7fR?gcz(qSJ0{d)+|iL`ogz+T4Y(*=MV;P|@Q-rf#f z_%A<78S`TfhsnB5-1%@>b-txz#1JwsQ2b9$k~H@3--Ye@MD;|Ipcm@?g9q`2N`6@A zd#(e+w4syBTw*|D@@js3`Lw34-N$9M?tl=Y{Yu7fYj0OOM2{S~2=6pdW?MSdYG$K- z3P7C75!XnS&;zGbRd;|uzxmBYlcG;Mf?O+1YJ7u!{q@(0r6uRZ$IP3b_rL@+StKLY z>Bi3n5&B(GwTL zVj!JN5d5A5bT~+~=$~_lep0fK#Txz{0cs$94{3tI&4FC=f*tia2+Xz6jc8cXpdDl9 z%T|IcUzu%>^{zwm7r0P&D%neTKhp`h$_B#!(-Q41BZ9VX+qO^FDKAwH?c$Zuig5pQ zVOaMZtIRU<*IzYY-tOh$aSe4`u^mYg6kFisc)We*Od-gqp-b9azaJ`l63sEN;L47P zh)>g0I+LH|(nc!fm5FLS7RFYeC+Ei`nDWu)NV>pGdNZjU(m`OZrm^>o;XX$O1_q9e zRTENEFKD7z9g62xglC@;QMqAg_GJVE>cvMgX!E&%Tq0ompz}0u(4ZMwlDHBN9TD=O z84NPd>qP#;8OEDqPi^)!SD+H14l5YHE+7#8S(W(HjD z;2H~sgtEJ(u$5f9p(fru@D5phUaK)Pb?ay?(B2@Q>UM_!z>Q>GO>}y9cR(Qz^lB<- zOu2V;u);AWEUc2THk;qv{A=Cjii5rZ8K=}Yw=9y}P0k;L#gTpS(e~!9CX8g=CP8Qi zfa?c?2JhGFm(n+F?dSqTA=dg63c_Xr42dv!kY~ig)|BS%DKM)ko1$R%^Ydo{N&*{j zcC~}pIk@C&CvH~?xDy6>10f7B%~6RCCYOC--1L+y$)QdlxBLZNF=?qDn|Dk#w3pYz z(wS+(^m5GaC|eUB#w|TEDWBufH!vV*E{)9f_sCS=`21&!Xpzw15=Yk9&G)P&hdg&c zz`@rQ4VCRJRCL3qi3yk_RN%o5dM2f=N{b0xi~`deSfNrsv>5Qw57#YEarBIga-j0C z{g~R2GB}sW{~4MkbawI)AJ4D0)~Qj5_mgP}2ip zqqxH)3XBx75i-_8W${fB@xJf7`WG92#owpWDXH7~x+V|G-|T#~p3qcsLHO`ZH+ z$Ag5(-!VqRCe3R1i+;X1$IN!t=lLGE@n!uRVNSE^*J7W&((q?Ws! zJ)Zc~<+3);?}xJUHJ(&5GKm5DyHvfBpPy)o8~~~s=~IS)SKK@NrK{Ig=7TXg4;Wi) zZ~iX(e{?*F76?Tr`j39cpSp({k)_0~X?=RGJMCg1)iS^rOnXAGWiw5=Fxc_2x{Y&4^j$Qn3&;Cx9 zl=J65Vu5WD-)WyxfBjDTbmhBoasDU2n@M;0>37;E!tdXIr2pMF@pt<;8Kcp~SIS8j zv%V&0{e#V|cL8f8`}+r=PrR>5 zZFk8Rg{fI0OJ75S{9z2v<#H#MO3l1@y=m}qjRV=wpWgty0H2X@InLPFBAO=RgaT%ez03au>1taFAS9me;p(PjDLJSYe(o6 zMGy9U`bG@HgK8b*YG|%x28V4Aa2+yC8b_36Q-ScIa7x>27r-Y3dS{?`oU1KqJ+fKs zr1(n5_Ohk^{`v|sF@K@o?}mTjsp>l=FOQyUdiU-UG<5`BCJ7?(e50~h$Oo70^j@M6 zRBoM*m#4*m_=C95*qDxrmtI%3I8$?3)uVF_AGuFJ;JBfoAxHsE2n*}4PD#pOD^);* z=ukJp7gSS|knF`c69psXmx(Aiwp}g$*xDB|{%sG^?V6gJfYtyAB@ohv{Q?37r;;pa zD+`{%${k2|l7DkU;6e(5=pr+OwiEUIvUZ=DCN|_{2F_$8#l)u z7tal5S3>%O0Q%h}QZVTquNKo@%`6yPYe-m{yP@-}>OO!_5N7Na-hfU?oB!=Q2n3!3 z?M?Bg>TDtccJUFAD9Ob`w z?Az7$KYqjWlMPAIzE$&p=R9v_u63K(yodb|U&>6hBMrdffl4A+r zxn-73Qf;P1?-Qp#cc~;h4w6qe)_|zXcCuNYVY}SX+=3QuDjQ`k)~f?$1GLFQYa+Y8 zYLh95v-LIW$>dSj3H)*g5QiJB1gn9GVvr5E_eis#qy)P^)%6|4;64< zUYl1>%^A-P*HDc2%e%U`be9`q3zAV|lR(0hlt=+#*3{Ofekoul*%0fO?!i~lk86h) zGfx%(U{BlHI*VK|m>N5C0!ZiE?an!v$Of3=b?NC-CT&d2Iibh7`tw!L=a}k>X!e>e z-M^*b9Lx-prTAPzxLq&~n|vMDvEbfX%e)VUAEZMHq>%aqeX2W8>^1=)=JFp^fwQV+ zG`I9-s^!6`B|UTVg4Wh+64>Ia;BGHIVLE)X%BMqsU-3l-^jU6dX+d5Vc-R^x_JV29 z3MornAlH}0Y^F4-g5{BwH-F+O{D$E_|_ty%+j zDvBmv_~q$SyZOZenCWeHW>pG9*nd7EBlY!#4-M~6fDTH}?RCM$~SG##qI!rtM5q`)5vZleIbX8>+ zc>=Q+fqpg7Odarhp614XXzJ2VQhZ{&_VFfy)N5#j-d#f3h?8$2>j88&N{*lYqC0NV zQQ_EBQm$W=(361eVv33bS})I51yTT_1bQ#0< z$4Q8y0fOH^jc1Awax4O+3I~C6G5@OE=$)uPA3iF#>DtqVebst&$wnpgRNcLZ?Ynq$ zpcz-4nZ-U03wbjZ&%S+ek5ts(zswkrR=6W`{|XwpopS4s&3z|#@wc^x3z?nAy=(~OPhTEy=shS8mH-OHt)A43 zy{36pGtRvpOcNJTV>PDkL%Rj~)PVfawl;`{Jd)pShY=k0$OxZZ2hSVjQu6Z4Loex8 zF9+D`8ynkRixvU`>_DW04(iXt;xdbaT0|pfQB{UnBRI^hd+kK($;-ekAA|5?@RKGcw(6fDjK%1u+vKTa3XG1+bn~vl< z#?;_Ix{8VP043whU}aHJ-}&m96}4=m{qiMrc8`$*-p?n|i4zo)Z!lV4hp2E+%&^86 z;o+hX)Smg$6w}&MjL~s$SGBTASR_hl$DB$kw`w7q zTuj$~x5IAe)tQym)kq#wf-#9Y_Q{`ha)vi`dc^z0gKwD(!=j|OkvJWzC(Cnb)w zm*y+v^okxG+N`r(Vl|TIhv5t!ZBIgK*y9-9PC0WRgrih;3K9glPw9FoKl#jmJUxI* z@5Tjx`r=@A#xdIl2TcC@=GM$%8;l0+SJ{UVIz2rf9fyb8Y?o2hY7uugmje}SgfMPI zY({PEc@R&CzN#ZC)S0NLWL5PTMEIJll_Hv|$w{;H7l3kXLkP~ErG&}(@yzx!f6N`) zdju;eNgXLQJawuaSWV&wpLi3XMJI^iIw11_XNhC$S-!LGe_g+x zh{%-Mi)o${w?P|yKVGotG6n9+RNOg`Y{Jlno(GT0RS&^eLMRif$w}_nUzl7J1rH-NXxozIwW^6@ zZ}W3zO+Gm1zA+gS|40RR?yi+6qHSmD4|5||?01n2IqIUaqmYxeQ&HMZXsxmQG-^6!&{Ia+XW5|bpj z6b`@M=N>QbQVWEzbGTs}rGpaNo53w%?mZw<;IqC!FmML)ss?3ukDDmqg0|4J$90;V$3su&TIpz;fK9L9&xAdz5uK#y^cvwDs})S z6?!0Ktu_XCL0wpeV`?~#+Qu8?oPuGTvvpiV8Rx*jpJzh{ow{ebj+H}GK~}L?vC1qt zh%UQPI!GB{YJN*u2+kY<0Y~?aBsrL*eg) zOGPE*D(_~iyA<1|Ij-{_dqIebCe=khbjgzA;prKp2JuMd(;mB&S3fjR$BSkYa?Ot& zn=P7rIsV~8W^-$^&gI5Xa6#pfif@N?e%dyCzsCb$Ov?JIU*}SS`fp7sm@tB9oG%Ag zdZe+t16|m?DHx51k_gGl)lS6~B-LQ$sr!^wnnVvWET&KG55@NEig*Og6LLP@2?|$Ts^{RX z+h1Mj>gt*stPEq&sp+=5BKAuoIPX}^nc&z%aHk?zi-`ggsb(}u$ zIz!DgLtX7~Jns5+_0d_C=@0=?AWia(-Rv@S%uMG0Ni*y|<$yyVBI8n^M*agW1JV!k zRH~Im?$i`nB|^{W-pNkO-0to@m~J|Mjh%-z%NQEI>g(K6EU*K|>dJcMLQa{gZ#_P; zFe5ayvV?&D(d;_kNZt?Zk!mWLZ<0#QR1S)^>3@a|QMQ{U%U(t7Yk>C?3s7!1jvVVL zo9exdZP#^h$b;0ugOsPX29(}TVa-AY-a}Hd$IJ6@aDOumGWlZ%R)F>8`o5c z#8aqgKHTm8{O;NOfPRGST$#dXyWI4~Js*5+?a*RscsXtK5PjiX4ob52<~4Fr+9P%Q zr&Q^mTaX+Rv@4`}F;Z_<$n5$Bvx}KCIlIA~rl+D3K+YSsY^~@t>CoJ&f1)$)ay?H9 z;F`?_8h#E;f7E`@&P3mZdj&|60N5#pPqXeK;N^&@F6#L`LT!FYNhx>&3IVgMh<&YX zrb{yOYzqckNoIC-E)<(N`Vr~@e+Q3ixqP%ZjyY}WMbAA*JS2ic10 zr;?Sl5kKa!W2Iz+U zmb3)OtA&*>8UeZ=Oa%BotipH-&m<8%Q9il)!W3pBEmxGQRi^8+cBXHh|nBDJ^0W9!f zQ194$j|K=KgfO6e=R;jZcsHS*f5$@=-TUdyPrHO|NA2?QILDjLEIECq z!4B2!yJv29okCKdJ9m5lDw+vPist9+Z44{vi_V}^9|@6ACK;KaO{D;vk199QPFwOb zB6QRzW>37>>Be~RTLWlewFWCG_3d*O_N>8{fz^vr`MHL=)3N_XC{`ipIp4Bm@3Vvk zthkQ(_3O;$>a_=8Ab@-cT2Le6zkyxRjSy~f+mp^AdmAlbZX0eGL-zi!yL2~9o>$iO zRlx3p;#^!@;DJT(T}U+;uI)btx!RMRx>Mi$w2yy(<_^JEYNB}j-!%=C)wZ)GliZVz z<#$=!9!q3>NOimTD@R`f7!E-VsiDEWizMNRRcPK(azjN?d& z{!fhc9{7{Ii3v-Q^mL+1+-7VXVB@s%L8 zt82{*o={!dzU|XS^wF-a&&-Yyl~`k@kgGS1j$$dT)z_AAUo(|`EiFY*cb4*@2pA}H zF}&bU1a$-C$}X&;J9y>(XzS@k7Go-*s(P(ENEic=_3Bl{Jh@SJXGyBfXqL^WC{OSi z&Gdu3yy6&GS)~2aS!xk0zpa$txcP4J61k+3oexM4kEC${aBC1sDWfU(FC&iWsi~~p z{BMy24#v2N36o@Y8J8n5rPV7vDlQJc`S8c`)ipiP-XH`p*3a~|c7?Dp(f1J?9a&90 z!Ch~Af`440uXMs(@)z;ZSxZxrXRRqMWJXK|KC;8`I^|S^Mo9H+*D>?(@FXEjOLSrH zRDR91C)Cs9B|#n^u8Abchr1yExRxFAv|&5K+~m2(o(#y^sSVnZG8M= zicEN|noa!CjH9lPSD8O`JT2`w(%<((^SXUL*$&+i)8n*s8|{-nU6V4cookRU6t%b` zGMW7Z#zoaw6k#fG$3g0Qn?o&WX5OlCo7RZ9Di>S0GbqzYFawvd%vl|s8z>KY8FoK2 zn5DWv3*nQi;39DI@XW(d3q{}aYD@L&X$7MviW*6JHAZ?t)K!qYI=G=X)B?$+Y|Qb`RPDg$-#;jPQSC~M>h@oZ|PlVnR)|G zM)yx&%pqa*I5p@c3H;Du)LH9S2(2k8MYmOM=C%0Sx3{S4wUK(YL;j)ko{QVIZOTB_ zD<1UWWeA_x*}=c z%HMs>i0|I#)2Z*i=Kt!&z^oV%ed|a@|8;r&cW}Y5wf{U0V+fAo&?6j#RuzIiK0Yly zrPk9iaL~XBVTmr#&y5C|K=b8oIlI?Ha++-Z25bDKv)^|?Q2@=jZ=ubr-SH6CTQ2vW z2Y@O5yjKb99?WmxRdBV8`}a1|1NOgU4^sDTz2Tjy7xE>>C6-Tx@L=E!;9XZ!S0@ql za~B%dc@Nn46{WH6-)>K|&0%eLGiI}8Z@k_Xa!|U#=;zHmz1x1b$dcvgiyJm6`i%?B zVTbcuFS&@VdYXP*J^nz@A@DiPz@a@*>6{WOfFc0>A_k44A&1w8$ufZb2K(vUgud`| zEkdF5#Sq0 zRZk(xQSkU^9p}}5Lf?3_U(8^JwLU7xJ=m6l#zN=ShOu9znZ9y--<&Pm{F#PMV>68~ z#3Js&L4i3D~AFG@R0VSyO2v1BxZfVk39PnyrLM z*%b)GVD6fRq}zF?s;u|#gV{n5McXDh6jpn))b}57`us_~9{?=Sv9lv2(8pXzLLJG1}=b6%XWd=?$EM zK6U?bY~R}!3scAU?VB0puqy$&%m4$NLaP!?XZfmp{5Juwi9cAAl75IW9tbXI-G8gx z1W5)^KJ*!IFCk4p3eD?!E1j`1So_f`H&p;I28t~L3$9nagzo*Q%`fj7PVeA3b-feD zTf2XH{R7gYLs8KL=1LisTDJwKm6+r!daYzAaW>t6!E3TBKPe*vY7}j_?Tx6&n>>={ zy*mW&r&hU5N1wam&EE9F=)iP&L@rmiKH|fB{$7#p=K82#;V*lS&phEV*Mb*aYZ6JZ z;jmeDBtjH4UL=7Eg83|_)r?a@6y)cpYOT-Z77~0}3}j8#8;E$Wi-5v})FMgDQx4+Bd#z5ed8 zSm@J^0QB**l#5;${WPeIgoT9xo9#$`Jl9{EJ%U>|OH%Z>3UZRUp6sx9qp@D*+~P*5 zaD(hY|FL^gXEyKsL1}B7kyekv*?r(fk^m*gLgt%fPm2m1q3yKbz8jt z`1&j9K|g~h&1-?y%xRcCI9ECGc=djD+%)rZ07p#JUpb@y&i+(R+^Ecmm=-K7y|<8wOu z&8L!j#+=wLenmYvPwYVQ2rWX>VCpsbfVJmTE7Gyk2zDGPM?2m9^V^?M>qBmn?2x#7 zr4*u6YoaD%N7U7o^`Se!p@gFMS%6CB6)3&S?{BzT+>06=ots!>XWZ^c2Fj)rJZqC* z&hsm}UjfBYPcL3K7iB9Hb^M4>)eujsgO5@1ttaua_F1sNk<-eg=*9!Yf#?JtDfZ@u zhGTE*Dl}wG2d*8e7fRao2t?Qjv)Z#C+%28PM!u@SUG0Wki?#yxHn^p{a!!ems{jlF z=Sgr{&bCrkT0grgFE3BLElye$l#Iw{g-&Je6CeKwJWxsM$A6O{L z$VP`7WWO0{7akQQj4xAvp&v&-?Y(A0o9$7B%EF}FE((EC;D#dS6I_WHNo+->c4uog0J{X}Eq2el4!#%pkk1VKCN9 za4Xl;dpxU|^V_e#3RSVC*fDu<$nc)N;Sc^$yFAxB^aW!eFC6mTHH@_-$Qj6SHzjCknK(FHGLOwq>Mz7^Sz7AeP zkJTx4pS=EQ;lqb7Jlej?)w{yY)zux20FT9?{B85UfLcy~Bd9g4BFE>*!2^#6auQx@_t*hPHnu09WlAocDPDCA~s^efI^DxOOkecSW z!G?;9j6BvdVKUV;gkj}Mgy@q& zWC%;B&Gj}KlevBm@C@*NcL8HborIn=QF@B$Z%CB!RBY!4z~Mxu~a3wMYBYQWZ7!>XNl+{KeAe&WP) z$dYxtU)Hh(*R8YAGJ|No;dz0r>v$bSEExH_4BT&}=wit%p+Q7ucGdZ2bW4s>3bD@O z?0!fCqQP76A~A76=C!>ApS;A$ldimaB{-JpS&UwxISTT~p8DEQ=!fXCQC{TOIg=JB z;HqFW0fQH_Ave#^NK%|#ZWN?E`HiQ{Wn2YGCcUZUlwN|q*m%ke+&hz+yZOhj#ePZ0 z60s12pvOc+2Bda;c9}39Pk**V&AN~nlKVpiB6ljpjv6^UbG#rADTCIyiT?0t{7V8O z5@33e z=a4P}0CCU1eexT8+!t9INpru8RB<+k+lHwv#&=C3xXn}{KcmVV5qMc;yh18P^y{QGJS?{r@B5SSE9X4+s{tTd?b8s3d z%X4W2VfaN-?vH8t5~-ifTa_fw*7>ZHA)kE_6O%pO7zQ2xI(-YQs+ixdRjF~Qbwnc5 zXPlXuRXxh?LCVCZ(cz(CrFU=N3Sppdb34HiWeR$3UCp|q%ht?G z(yY?|b~RlD{>bx(#XdPPVFP(1qs`ej{o}_g@cpf`mc4p@s$mS&YvA4uZ4Vn-183{x5W(EfUgyu2Ztt{s>)Cpe1bdd6Zn7{K# zJ=|k3kkv+u8^45LBMMguvRx8v?dyy0x{GdJYKxauE4|%8hEi+a-o3}*#Q+f(13_|% zAG-=}D-yn+AGw6MM`7I|ijyTGr+$lpx)W%G0aL|eaAM>@B?b%qdd*$+6dXOSMVKIj z_{Spvz&-<=T@Tm|zgwWKfBB*XZbI%;*K-h#6r_33ZLAA|9!Qxr1N)j0LmZ^gv^jkw^@lZGS@{MMxmk$6q}Dj;ayYO; z4k>is6il7FHRxvr_k@0M4|0CQ;>;Z&jgYnc_&c=jN~nf7TZQwlfmWNZN^#qF?3jNl zlHAGXT$=_#2Dwb5_g6zsnb+E+gqTDQJ9BL5Ec5VtTPcJ%l8kQwD*iPu668y8)H&z` zEOTNCqH9Km5Lj^Z;f-}oOyVJF-1o;HX;3po)!^N%pw~IlcmhhS?rM*DAq?khBl^(x zXOLnKm9$>-0<%K8svu6Uuy*BH@l_D7>_ys^f}YA0at%E&A*+NQ!b1&Pl^7TK{CO%Q z)Hd)wu)qx=H9W47n8Ra!?+sL6U_|PT5LR3Jz=K6>@73Nk0@%(;<@SaU4SCY`h0>3v&QGtz;aJ= zA+``kGBcOEGu6!6@8dB@9Sgw7U>Jyc3&W48MnAov4Be6qq^(;!YrR$p4$;5+R?TQ? zMUr~yUQD_pTyndH;Dd7j^<3ir545)k&>kqR-TLlVL-nJ7RyU(WoIB{<)FLF};Y6Ze z+`sjDk*pc={Bgo(w?r Date: Sat, 26 Sep 2026 21:00:46 +0200 Subject: [PATCH 584/592] Updated readme --- docs/screenshots/1a.png | Bin 0 -> 137969 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/1a.png diff --git a/docs/screenshots/1a.png b/docs/screenshots/1a.png new file mode 100644 index 0000000000000000000000000000000000000000..3df866efa216cefd81d77e07c9491ea73c7c8f6d GIT binary patch literal 137969 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%0AA~PZa&&{cR))9tQ82cjgUM_AuFAdbXyRVw|L{Ag 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 Date: Sat, 26 Sep 2026 21:00:24 +0200 Subject: [PATCH 583/592] Updated readme --- README.md | 2 +- docs/screenshots/1.png | Bin 137969 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 docs/screenshots/1.png diff --git a/README.md b/README.md index 3e537a0..865c762 100755 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Nomad Network is beta software, and should be considered as such. Use at your ow ## Screenshots -![Screenshot 1](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) +![Screenshot 1](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1a.png) ![Screenshot 2](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/2.png) diff --git a/docs/screenshots/1.png b/docs/screenshots/1.png deleted file mode 100644 index 3df866efa216cefd81d77e07c9491ea73c7c8f6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 137969 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%0AA~PZa&&{cR))9tQ82cjgUM_AuFAdbXyRVw|L{Ag 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