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 01/83] 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 02/83] 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 03/83] 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 04/83] 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 05/83] 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 06/83] 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 07/83] 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 08/83] 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 09/83] 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 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 10/83] 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 11/83] 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 12/83] 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 13/83] 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 14/83] 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 15/83] 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 16/83] 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 17/83] 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 18/83] 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 19/83] 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 20/83] 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 21/83] 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 22/83] 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 23/83] 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 24/83] 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 25/83] 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 26/83] 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 27/83] 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 28/83] 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 29/83] 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 30/83] 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 31/83] 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 32/83] 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 33/83] 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 34/83] 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 35/83] 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 36/83] 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 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 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 37/83] 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 38/83] 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 39/83] 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 40/83] 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 41/83] 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 42/83] 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 43/83] 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 44/83] 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 45/83] 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 46/83] 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 47/83] 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 48/83] 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 49/83] 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 50/83] 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 51/83] 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 52/83] 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 53/83] 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 54/83] 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 55/83] 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 56/83] 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 57/83] 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 58/83] 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 59/83] 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 60/83] 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 61/83] 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 62/83] 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 63/83] 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 64/83] 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 65/83] 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 66/83] 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 67/83] 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 68/83] 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 69/83] 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 70/83] 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 71/83] 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 72/83] 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 73/83] 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` zCyHF~b6Zr#-G;H{=+TEMShwhy7#icS@87KzJmEV>LuXfrP39knqiU$UNgT%1V|a51 z;W60|ZBqQ8*g3=LohJ*7TiG}lnCYaZ$^iJ=(%F8)bepdSWAy=Jl7eB%^{Vmp)6DVK z$kMFIJfG)9lvMgLsLE_)UBn+ZrZSZ76`%1twvx!|mWo~nSUohc~S zT)lR!W&T0n%KrEEMwZmT(q+1C(Yi22SD?hpD=TYxdR9Dpb`a#shHA!2s2jh_M2n>I z=#|yYf3s!TuD4$rw;7S1*=VJ19o$~>l?vc?%0-Pq*~t2kyB2qMHtk_0kN20JR*-7) zL{kxo{Y2(!^s7fD5@YR4o5b#cWwMoH^&P-ghutPVx~>jotxu_ec1c#jjggzn=Js01 znFYe^soXv!CzKPEN_|&Eo7Mq2B)`=~I=W&&l4L6?@l46@_nefyYTAB{z7BrnC|Ow> zboIB2dC;?RZZr6=9r}ZnUY(4fgFc^FSv%_-BHu$@YyZ4=T@ID^J~28p5m|Qd(DQT9 zQNHtIO;aSAuW9idH5#plRMPnU`+OaslWW?pun>rDAq!vp!(aRRgOSpc-ZQrki9pJI z`0%dh_w5jHI;e+yK^-*t54WMc=%wNp8AwX=aDMZgJ#%Ov79fXZLwMp7`XLePT{f&zEaWM>39}A&l{Zkr zf^qQk=D;QAj1*bVNAC64*LF?T`}OzsCNGb;%|#AsinffxO|SxL1dv2#LJdcU^MZ+q z;(ar-bO47}CC%vc+_vYI0yd^mBrqN=zcW8Szc&(qQ~_=Zy2+$%^}RiA?I25&?wTKh6c92uC8~$ zO-oG+*1CWH4Wq{rBAuM9{&xq0Co~x$1z~pCt|>zfbT|1^0=Nx$$5HJLE&v`9Q*FJRQDs$zeLEaF~dwMWru{0!=LrO|HuX>{@m_dqL z2l%CTz$8PAD|De{vw!e@XRBN%6?se6?M}y|99R%kqy-fQad$Z=#sGYr1UED6b0(tm z*QXz3nY{Flo;nAZ0u31;guDAoz$45!(@6VytMg;nMTiOl9vhEgJO)8Ae_KoJh_?WJlT0v+%K&~l*-1+Z zzspmE8>0g7BuF{ftx7~!Rkg>C4;Q*KE-lrSt6vF@2B|}ZJa0-vLVO8xb}Swlm6fg9 zqgw={G(u%X1-9DH_uW|K_SZ-LU4X8c6yxT)Wsw|NE)$zlTT)WJPMs2YKszJL*@K`E z#8Gv)@e=G7>!Q8R16vzw`B=s*NMD5>oso18B_8XQv z)|Q74?S;GqxpEu#Rf*K}bPgf6F!EscC?I=RQQiaEeK~}X?)!vtj?O{31wMLHyi;MD zWC*}v_NXT17J$BTpaOw?>+%?f|7u${XjLN7+LDD0!V}f#1gqHKDs7>bZn^Pz#QH-d zM?$n*WSf+s4<)urj#*5KJl_--7rO42@Wc-|2} zE4wp2qf}5`?6v3I{NX6qm6^vh;m+*>t*i(cnWK~aK#eyB#H0BrInLu6>=e)$EYOjLjWX;;*64mNC?F|8Hj37%k_HHhub(nQN~0N`SA;%U}!iQzXUS7 zR~d@$mqB5`{#4RGk7^VC2R+`2pygzoUUvTN=W9YLpv-kp_SYXM-rSk^M^vAp`uG2M zNu7C`tP-6NnTcO1R~XbQ{ublXfp0{PzULX=(=opB>jA>bI~UK2%iPv0;*Jr}2Z#i$ zGrDmMl|}QYtA+ZhYKZv(Du9As1-SV6fhlmX-n<6(|$0uF?OYu&_0T6Qp(!9pSC8Klp2HAJIql265G||vftTbcR z8JF!ZG6bI307*szn?gg|{XUcJ*s{7(*abaNU4;KJr~pR7JrqDSxW&DLZm|z@T@J<$ zI-jMB>_3Ua^|Bf4_@MaXiQJTJd~`}uay$;XZk?JML=NLDAd{fqFCg%s(l)t+zxbl$ zud)S&cqsW(i-|JaV%7q5RQ+*^41C|UtPh}@=QwdQ6p{&6L^vBJtGHMZ?0Oxhh4=;G zzRb~c=6%F8i-y)9dFsA@{~m#g&VsYbW+Wf2J^KDnDXrMp`OG}K`)X>%012Kudv;2F z_H_fb)gz@6$dSPdksCI1OpHUEgjd?ZgM{LTQ<~`;hj%>yoY&CTuZAK!*?$D&eYgzx z4Gj!bCqET_Vz%Sq(f8>;BXjowoKhbuDn#LEW2 zLKR?-#&i~r%i@7`r+-Z|=66^gL9d=YLxiPFHN8&Y)B9*L!q~KW_^=1G4t4DXJ@2f^ z2UdJng`1H1s;};w`Dd|*>fQ*4U1pPIB#RF*%?dE)4wc-z>FGPgrKO86qYc!cuF5PS$^9V6EDj&> z&+biZ8;P;c-Q3>Pa8yka6{{>V9{d`WLFZpl;VIiCNXhE@iwhtZR$_xD7XSea-D2-I zL?ANMy5z!+Q!zU2CknC4!p-KIC>)TVZpr@@pb7i;_Os{C(8RjCw|CX;ktRyH^)x^1 z<$K?zNA$!2&67%?JRXSu8r1 zbmI8DqAm@BcuC*9u$0@gG{pqY0M^^%;~n{*U8U(>z8F5J%cdMDno1XfhY~9Y^Cs2U zw_>lcbE(1RJjKMWsm6EfpE6qqD@K3fDj%PIn7Z@Y$4h3`E-r3#;eBQYJKrrV9v$av z?id`zu>lF|ZH3n!i7rfCLt}AAf{jD*#H?X9j34N8tAo6isHpX)Bxr;EIx-^iW~SFm z58X8#VW1}FUad*=O1RL5%7XT+na5`Aur@ZilhQpuyxwBt?xAAR#H zlMw59F3==0vl=>~OG&sex;cys|=n)d{n?>g`eyzT}mgeSfSW;72=^T$-ICqXbqjerfG-0jo@9#BHzKw&A zyUy;fG~YP&DMF*qoX{AuHQv}BmJ;*L;nkU6Pp?x;5PHfChNGz|EX|h1mfMMk8kbby z3Y7s~vAa}%*wb>nl<{Lhz($8<=GwA}#dgCh3a1tq70HEdI2O`dkOLQ=SK3q=51vs0 zO8GNmY;uAYkAkP%l4*M#gAlJ@7s(YA|0<|b2n%^To_-U;>op50TAExK1ZNgREbYAf zy}zPaY({DnnqJ!+I8{ljwt4Q4Ki;*pWIp7YoPE6zXUWROI-f?D3*FtR6_u45=&ldT zV%tA-ciulrO=Z+nQi{s0bS(#|vA&++%y;i@%E-zzgWu6x$!{xHYe{vtKxc3v>4X^v zvxEjiLc;4H|Im<-QlRqFdtDszDRK!ZD7}ntoc#?)$7+kq6=JI$;*MZ zH$5}A#vmuh=J=^os(RMh@D9axsLc)ce}>Y@%>?SebZV4ZeW0`Dk-t6tU_UsrQxm(>e-l59uKn>FF8ZbP8{<%;R`Q z-$4;I2mKLIxy|U@A{IU7wVePL+a4z3PCT#Gm$o93cFdd5BohT5H~Gc+d1!Ze3q^J8 zcp6uxow;KRsc0jGxTa^8CM9)WJLS!U5uzu8{cx~znW6gQvNXkJHDKC7F?Ne33E zhPtN3R2r3r+KBnzXuh!WM|B2q5ScHtt(m+Ck*~R_Nf#Q8!3aAHg{pOQV!OsK{B`H_ z$@c&+1IR(P>B-IIX8qE%*urtaLRG0E|AV)W4?pJFeBmJXrq8ANzQqWL7-^qhQR@p6TlOn5!}ID z&vre+2OmIR_@b)1%DKOw6{RTPAO)iW2RaLT9a?$Nuu`3u zcM2?q??t5?A}^;2ahZv)hk%S})%`4h-`BeU8rsPK0n~VH(D}MDey501|LsWEaF;Q6_)b0IG56N&-;>I0Dg5Yd+qSxg-U9p;2N#At-e5B`Yq!2?>&)VYZ z>%Iv0hNdQRPRK#3?%@4;6#?t+LG607WNJctqKMA!ZumK z9kj;95ghI7p+$jt0trc5HoPJ>RF++7lT7DWrE@Dx2&u z>c^U~uldt{`ao{=-f=jWGK-5f(v_pH0{;XgR~|SIc_S7Xo}Y&Z{@R~5ePZHfanVWf zP39}6@hlnHRi6Q41qg&4Y%0U}JsfKw_jcY{J1y%gZA_A&e5H%jdTe4M7Y4ziZ@k;$ zD}!DN>ur#Xfjy~WJVgsP4|XXYEqL~DNxcV>y$~y;>pOq`L_}z48Ys(=%je`{D{Y;< z`StHqi$Yyq4c`L%Ujy+Y5Dj(kBiF$ znaCu-ilu3tFd+c}z!55`eh*RDW%v7GFp^cbw@-(fuYk{k++IGrS@LS&ck)f1BzyCG z2`?M|u^8GTq+Aw?932bzUE~u5eLhxq>ZD{Jiwy(c%V7C}!!KG}IU?dj4R*`V7OmC| z;qQj2hJ~{r@FzN}36r8?Hpt4{mBOdBHErIcIFk}&ndk2L*TKQ6($dMG;tGt(BJ=JE zG*5E(-*YYrbABb7$KXRNHJuv%ZAAF{l$1LHLPCp?T4?q9SW`sLOQ$poPzQ(;aH=qV z2i7H;upV0ODG!VjI-cKm4ZRQmN5AvV*JH*5AFYQ=l^v4n**f;WSe-Z_4#r6ciOQQtan9>B7O99N5r`{(W4wzTtA)tWf?nA_Ry&z-)|L)T`0 zA7K&6OwbFB4dq6_+83Jsc5)j(c;0l+?*tT^;TixSq367vov=^sZ-<8ue)b8{ME>AH z*6mKjAEU?WGn!|M<95^&*!rk%B>j>pR@iY+RTW`f`9WraH*z*|s0GFod6y;{YFfRkbnVA@ffU}H!cukZm)DB+paNXcsN(h1XREG#L??3*y#ibN{1H1 z$aT@){GrX#l3~+t5m7@&CUiYRTCZ6?E`D&H=8uW1iz|3DsPZV`W9F6bKKjI3C;-(> z7dN;0W~o{uC|+d|cI{(rkcBe}tyvnnx>?XT0}Ebk*n2oa4=}onvTwUDgR1dVi=WF2 z&oeP$A`I?dR{_G9s82;7yAR9g`u0URP1DzaEA{pCBq^@i@tG=IByQ(%v~5v=R#8~W zp_lb|Ux(tK@ZE&BbH{JN9RgymD=XWk`#(}jcTAlMl(|_l3NnY;^}{;&1=xj!wGsa7 znReot^|DIl#8io33E3;NkFSBnaZQbHSPJyK0LS@5mG?yHld(_t2u-R^g`0IsA^i}> zxxt-TIQ4B#?a&7Kx=TjWeI}z8HB=v@f$S)Qr0S?$$jN40S_uoVuN0h2J8w)6zwjGb zxPE4;QG_ZEKyGr^i1;xJMQ5z7^SYFjBu0uJ8+F2Vb81X3=9tijZ-bhFs{D_A>)A@BQhewqW7q7 z{E_>qq5YV?`7_3frT)b)LH52vWo+D&T;DlgK#M8KTJb@-0oyk5=EkDLYDQGe`3YL zOdj47<}8K29fxabN}fi{&JqtbCR7)`+CSup?y+43Ff9D?Wox_sD_6c`K`aIWo4it> z#-pU=oY|*GBt{$V5%htIP51lplj34lM#4viy&k?p+%R3VE2(11+20oBijmT4M);Fs zs*-EetUkXMn^Iy=Eo3sDXNLi{kh2{sP#lvW0^U#KwN2cUfBrd94*0lUbsw6ER#C$EZ{w3xH?Mt0Pj?bP%GB zTQNO7jU0f}-~)oMaq`PeHOfHD^X)V9FArzEtggjXdHd5Ii+Wt+Q{i=#hU@furBBIoABYNJ2xGLQG_P0n zUFl-vEh2QjcvW|4x_C-nsEnZb#%%bLzGoqH8amlO-%zfFx9{o1p5H^A(rDZPUlcY zUirq3s}jDRG=;5g_D$$kZkPg8xu-vXxm%Ndy2VzVbL|l~>S4S|zW%RIx*fkDBXbHz zxKTi)CtqQnZ2E7CGSN6GDwcfO_;z4mgz5xkI7?(iqP}NtrkkM3txtDA-(q3neKNTz zLQgN7GxP!wfHSPmp&v0?4uNxo(BjwABaTQYY960sD5F*Uu?tjmWP%3l_T^S>MmZ6$ z{aqxL;hw+(;t50SV`GxT6=eSR;lqcHzE~2p8+s2`tuwQV{HBN-Xi410l-d~vi$e?} zKB~)nG=+m=F`tH|u6`AKQckLSn7##B!|ra7&nS8f>#)5m>q$D<&lJ41Z%WyZa*qqe zVaCSBHr8qPrF4HRUDuZ~C3q)Qr6}?!0td`>MU-w$0FHF6`$q4s%se*V-+2sd$mJ1I z^KXW2$B&=vDuzB4D68544~oeKZN+)Rv<*jvBZ|0Gt>algL3O@p@9)KW^m#~~(LH)t z)c3?-`JaAhCKOy_-@y-cg^ZHi4h!bl@8Kmg4;>2%ooDZbXIfo!u1L89&63BdPH0Kj z*C~7~gRUjDTeog)Fd5x!?11HZP10hdp85Lq`>nZpbK3HaD@h`~=jfNJ>>_A?#+LS& zs6}T^gi2_M(2AI6bZtHdrzi`8%mD@sGrBpJ(mE`5imzKD;=0rK+6*7=#2J9VJ|0HOd}*;tbj*l?vBqRx8I z2URkp7J307F5e|aH9xOIrXnI|&oS8OrrDP9#Y-Zd9w1hL)(tsPHg4j!A(~IH$~V0p zB2IoeHnOxhis(SIopmZm2wuLM_3LoGzVkax;~gM!!F#Cya%@|%ei8T$96zs4(7rM- zkOSF6bcAL`b+tYjRRC2smie2LD*D21!N7gh(C6DB(wb@vs&k02BZS86i{YZw4?Qw_ zPz?dlN2Sr4pbu)_UKO*MtiU85NB|3JC|!6oli41(Zap0?MGNj=j{i)g$uXTTO<6b7%*?Q<0pmP@oADmZMTPq~eULID3Tk0W2^YPQe<~N4Pl`i@ zKPx9k(ck|VVeboI6V)}}8PfUPZj4BI7y0BB6tbB^V?f37z@mnle)3njfM?I19ohG# zZT~wWIq8!^4Fk)OHM_ywy7uqMl_6GE0Nloa1l|wmn!59cZ`yiTO3K#(K`w0r=2ols z^?ec=Wn{Cmt+%37bhcOU4~o_vHGBzFgUp&GBPprJ;+^^pLu4WB!L3rQ@veqVQx10C zTk8%E3-kGK2bY0cbyh|y>F$7O3^}Cm**Z9=#lVn|`60Wn#HP}D{|=Zq z{%?S7nM+;%+Jr-Uyb4%%e+@}+Q8!RY4(qv*$}%G8Cv>A%5-KN}Rps(0sN2-Mz2iTB zzTFSSF@ePatNE}7C6ndGBp+y;j3mpNMLlj^Y;D8((i`LLhDu~*{DL{)(yw*}!*S`7 z0d&TYN5YFv$Z9?^U}j3H;O1+cmE4fZPM&gK(#cXOXDn>b67u$(?-Z)q4!!+MIwC9# zZnv~yDZw45WGCc)%;(7RnAc4J?xE8xcdY)~W_nS!9su$H&ka}BevMfU-S>(yz&ka5PagRDx|_QxFW2Gd zx$HQ$a6T;MM>iN5@I~;YW9E}iVCLrXr}hSQ7XydZjMm}yHvg*gLs8MayeeE2?++&C z6Xi%ioR=J+dS>)FW!%RJw=5=PO6BuFM&=b=IKN4O4eDdT#&PMN?Pnj2Gb&V^(GqU7 z;S>}!P9YM+2;O7N5rUE`<>f<=3jjMYV`Jm^dDPba%mJzPTy1f~r?;MFX5?z=WG2kd@!mfQv=sI$SLC4%OpX)qjA}whW#i?w>j0NRp;|*; zJ`8AzedFWr&$20561?8_cMq9x{(LLX)8tw{&F6t-JP_R^jc|1^kN=0U`7QYG|D&Mj3i!7SO2P8uIY0mHCeQzhj{i>vz<*%(%l7}&Apid_`8CA< z7h&T64;Oi0{vTIY*tFF>n%ux57K!a8H6;r&O@^z~r=PujtY&k<=RwJqJfepquPU>N zkyQBpx6$R6R?MOo8egI|JYJ38!g?{<%l`mE$-gqOeWp&u5WH~yW)twXvI3ibE_lRF z4LWK;T?_VBE;I&_xm9+xg%4J3ce-{?dq0DYENJ>}QXpi;pyjgSvoCfL zJ8mM7rJ(5~7M`9D+c-##(cXjFH8#Axc7 z*J=riZo@)#8@|cCF^+kthhFFIE3_FGPy3J;D&G`k)?vPEh8yPq%&q}T;l1@~gnLlw zU!jpZ9ZY7d4$gx5q~*nUe?K@7K;v{m^LoWEEM<4Auo)NNTY^!$3t{BqS(XlS5gOSo_$_3?z6zfnvcs|bY~PAb3bHZkpVjUAhUl@M~60R(EHPrvN*g~}_?{4XUw#Os} zje7S|J~lP|gN8;9K7)&k3jj^)(3)20JwwgC zY@GDDe6>cbxw94QEMlOF1>(|c4b+BU$>-UUn)9Er@OHvhwwnWyngGkVwJD}COwX0;bY>+kPW7Hh*C}zjzbe?ku-wVSD~>0*w_2} z`*Qd0Jzj}dNLZ#gb9O3L(B>Mr)BSej$fM#1>swna;^Oh3hq~!UA`~m&za0qOUn#A9 z)b}Cu#fuk4@EtfD*=w_^N)P(R^oITXj;wz%Bz}2$_KL7!UUW3|p(9_en{}j$W22?Y zim1uzEI+?O?Q(sErpz(92`sFXAcSm>_SwE+HE<6^KN@NcJpw=w(%6am{rmTv4~l4* z44JM3GEv=h#r+vANVUIa3k;_NS2;M0XIeTzQteS&OgEU8+4pN`ZA^Zap%>S?V_^Z_ z6%JqpbMTG$_2B80JD`)HUF>=2Ftgvpyv6G09NkheQixbBveK!%8s2;$4}H@`kWwvVS873n1%Bg(!V0B`Ejzx@3lcD>_#Mb&On-)KVtjs_)sI>is` zqpLO+7TrA`G&eJT`Ic{NZsoL`e3WVbsndl)@IBTk@fIQa`nJXJCcwUZb&~coCA#7n zvUabe)#E?%8yoGtt7l}99^ZAoPkjf|#2E>HBk!MlR`tU?&$o?yp+L& zNr7HNV{n~N$u#}CoUeT6J8epOI@xl<-2AGRo`VSVPl|69l6Vu3)P>f|CWI_5EWmc6 z`13O7i>Xw$-%qZwg7wf7Xp}VT`P~S7xdSf>oOuE!lLzf5CT>yOmR{!bhEX|yzu_6q z7R)R;p7vIy5lNK_+~cOb zt6Y~wxof?-HM1ferW$Xojt!C(iNHP%R)=){ablom}3&uu;8=?o<>)hP*r>l~i1Oo-EqU=ezW~v{hx)1w2uXLhx<- z?zehNWUT)&2hgLT0J?qx*4|QznZnQzpPg82wOsfYltF0(E~8@R%oi4x_@2X(_74s| z@~)_;&-d~K#?CP5)VqSrmfam^;!bVtBixb>D3iLz@*j+Eojd?K!%qiEn@3>QD8N@W zJVD|wIQ^d0l9t&`+vAt4=@?85ALWS|7!bITSO2qO4WIg=c!j;UXli!}sADHiB)^+D zC@wBeBkKG+hm@|J^>7ZeY{17MYk22K zk6|2Sv25Y@|MMv}+6Pum=?UK_kH%iNjmW0~l^5@&<(dBXX&B4Ji|jl+NBc zjCWy{O^j=RdgVimv8=h_tW5`BUq(2jI*jXPNTAr^ zRFk`~VCjp;OKr}iG4KltgugK|yZ~)hO_Ce{vV=k*>p7Fumcp!oL-nRxu!zWs<3@`( z2^gP)xG&nE8=0kPXTPhZp)YOpPU66SRK+f%tqHb`Dz2`A)cVe+fvWqx?&%@&uu?NM ztv$rWicq{QjURsdRi94KyBXN{aJ)=RqDU{!b7-AD%h3MUY1|hGb7$?UB|3+QJL_k{ z!ouW~>m^ef*dO+R$=vCmZ zek>SmaAQQfVYXdYFPOA7cQz%31r$}tPSw%Q4&XNN&)4r;^{EStyS>Jo`XzzJO~ERL zhRR-*L7?si9IF^1J4vZB5?iC%sH*PZ*XnITjIxyeEfW(~h(=)2?KIbQDbaV2HqjS% zI2s2nvQCoR*RCBg>d)-&DA+j579YYOsZ$k3+D6wt>P=GwrbHL$%}Gl$zkDeJDrN_x zOGukpSy%}srh;}C5{*g zs*73w@i0qa64sZT%We~{m`h^M)~k6FZ3uLKRx|~*GU`jq%n6-x?@K_DXtexyp_~>` zjv1c<@uHW|W*=PurKV_Ow;a8iE^QV}*N#75kc>nRXs@wf05y5)6aR@0jw`WB^ye=` zUf+Jb7}QYxNYLSuVUEd4 zXrKQ5Hldq-y8g-QXU!_Gn|fu=ICV{K!>>~z#13uN%VRt9(%botySEw3R!No;(^N1lx-wvMHB<-#LZPEI6N zuKvl!_7=!7!2S4;kuh8M^f{;A&S@sD2M@(%YSM_yS_hgSj*+2Bzd0a zzVEB~< zsdXTGP74?1cq1X4W0i0_1=Xoj%&*FOSP|V5PNPrra)&?m8VJBXbsi|T;!7%B5NH0A znElzaQbY-#vN(W*bZWi;CA@%;kTf^%*5BSwM*y~qT+^K zs&7EYm>?}A6!)t2jBFHXVX#%OtYYcRcl<M!L)ODkEj3vpPbjiB!)ilCP0^3-HF9W*|7pJ{ez2SD*hLjrGUVSVI>QpRR0eJ z6MNds3sNcMZwz8&Mg?TeQawSHtNOaK^1gUe-(d9EY?YL0lQ-61ER0!jIvz{m{x(Mb z^eF{bkRQ$^_Xmo)z~|%dPpFU4_I&Oj2E)6-;hd&HSgaCIk^~glifAj6wc{nNDHJu$ z$eD&SCZtP=N>l`8r8!2(%DQ4FP@L^AA%^qkRsQF^>gtNobWWcpiiJf*S+0BwU|^R9 zR&W?YOlW(pnDXPD?eXL?9$R&XbXw|qR#iW#H6jVr(f9r;C>W8P>=^II?L8smb(Ke3o^oj4!DF=3 zq?sgXi5|Rh5xZA%E~=pH;sC)Dm!dVotEef;#P$b)7JXG%wjP{Ah+>iRkbiD_bc zy|X)SM|~Y?<4yFGe_dgwu*iiT@eTAgE09|?0fI|=`^$y<&R@e6Q z$dTyNQUd~6BiIy@QRMY=&~)%!%g(~x?6Up(<{HQ#76D6v0P_uP?U_o4f=_+U6WKNt zE61g{HKtc>D5|0b_54kcBvePZy!KT}4E;-1<;2VU@>kNL9T(@9vRIQOoz6>1g#an) z^F90SZ_Z9!&oy=7dLSFKXWzk<$rx?I5F3H6e8PYHyPT8>?zPB)l!Ml^{9-I%W?y^} zt1jQlN}bX~^dP#D-N&6p(UAowPmnSTT;#ABJngPsL$gd)UG}fjHql*PuX+J5C=%UkG5@oSiko!qGiF1V6y@ggmD)|lGg|f09$WUcivHg< zLb=6OcWcR{E8uToM}MwS+6`@8cr@(1RZ0-46~ooc2FN?~HT3lMdhcj10=*L8y2^?E z-nx2ba{^p4@=>4PxyxPLATN*JfL-KITAU~yMt%#(6Qu+Z$!B zVtM50Dgi~^59tRyjyWOA!EgvsXfZ{{l(TTEVknBTjxC>zHk98XosnyQ=&pL#GX;&~ zs;1_n6QxB7f#OU*ZIkKTHh);wcZ0ndSxW6b+kj=@GU*QT5}@@0^XkX)Oo3V@ zue)}3csa$d24KAnAh+l-rvr|Ymi*u5UJHHx+=9G)9IULE`*bmbO_fdi5LB=NUW06k z3WJiw)zv(Q0-8nzq$M$&K)iZz0sW9YSdk9tJI{4TPcQn#i>rS2@6gp{m3S^R5G6O7 zqXHh!q;82VS8tsJuG_4lqG%+UaKKWRE_G+;D5RTi-=8q8tQVinNoi|Ls)B*u!ExZ($nl+GrY;Dd(R;^Afk?G%5Rvrl|!AtJBW$3%+ zLuPiAWP9`|>)_S)jVL)1(wms&v(Fu64wl4wVlW&NNiWu)lSQ`F(?*TA;TN+P<-GR3 zNJzLxO-QXbruse_CvVSmfO515VD8=C6&-v?rC9Wg&giSA3A41+@x`cUD-iq9apHR~ z3JSVkh@ey4^M>T) z*Ki`bx^;cm3R2>kLH^-!tE+C+5xX*0kvF*hEs*e1J&*nbzJz{m}3%I7Ea}uwD1pPgv`bm!xiG|}(4DCTN}jY*0^QT&UoKp^@*+jf zTLvfMj+}j8g)4}pjMpjg^6)$c0=;R$&3c1Tek-e#WmHpC{EN6am78uwg&)SR{AW#F z`CT83)V;4l)fD>Toh4mH7v(Yd$fWX(A&1v;-a;-lV%TmFlSwkgnjW@54`LC6oymjU zv~oN|o0d?7WS4P^-S*qzl#VwKWKB)AS&IHF6sLl37CA%!9^_8@&Bm;{F+>g9E#y>5 z*=w;Gor8=W^b1LRS(%wITiV=$>#HgzZj>@=yka|;4;4T*x`KB3ozcezly|FaaFEF;VbOb^eLzXlqp|)(IhV_l+q$Q!CtoYn{EtawdGwMO3c($!o?Z8gEI9>C# zwYAUI=tBSHbMb3VavS&1gC;GtWQxZXHlS*m4A}jiL>m^1G zPtw*D=qZ6<&G)Zwm3er1!Df%(3IYkw3KQ&z$6*bL#0{RDG~5oi*s`kF>R%F-=eu6z zt0rjGNvFXA+H`m0Ftsb+>sZAvhIJm`))2O&^&Oz$MBVKtRIhZg33>LL`5%XEC}gDq zvyzSH&%g<#%QY%V)HGgDuS<5antHV`P#ANs?y1|{LiXeX#?SU8MP8;e*NmHj?*4v8 zeQ9%Nm_8YAuYG(?j-I=cMVPR$;kM=FqhE|w3VwMpkzto+X z)<$b^cdtHrjmko+1D978&}x8CvEd(MXGe_QuKiNf*w_(l#4t*Pkgwl#sgV%+{0QCN zy;@4I2aKE4wO794DD*+#01`xf&&OQQd;kra-IXyppGllHTgR*%^e3STE7~1na**VP z?v_1%BzT8BXCWC_X?Y=Vq=H%wXvXynIAS&A-FdLGBmHgl%E(HyB~gOksB7`Z7vJli zzD#7-27?2k{W-`YfKyb~rYzSG-8VSaMt+;Nq==RETUg~AsPPv@`tE9LB#0&mV+@)? zxP-+d#Tu|-la&zY)4}XUN6{FC>|UDStCWiSxk6-hsD{sKum|72_UPKv#MGmQ4B8D7Xvo-qr_fDe4z+A2= z_KA}G8V^pv26a0Q2JCHJTg*|r+`R9p)DJCI-7Kj7wXs#)8p6t9q`6iUq-nhdhU`-lRuHM*2el*G(3EXjd z9I`<{pQ*$k8|w#_Q9C45B{CI{MOI>!NK52_?oI4Whp|EVH%(?CpBUfe^+2xQ{wgDF z`8@=imwdZtR9h_2gPJnpyAuJDg;lY*RGBC1>*6q3Z&>9VtezqVP{ci;jU-@`P!J_0 zHS(~AP);jr)Te$e_qmX!G7#_P22Gi%M>|UoFNxtteYte?^i<5ugrGsUFjkY*DzTtM z4Un)HpOlr588f5=SGn+ti^oDyL}-U8pu&zH=4y_yOTTz@c!SJL2bj5oGZPex)h^sX zmA5cp5d*0HQV@K2Ey^@EMzDnfp}0UUp)7Mpc8ndsi4{wU6&({G8us$F^oy337BfW7 z&?p`+B(_>@N3e#jN%_pu^2FR+C4H;rc*W3Ec^dTUp=*EWup+@RV|mOYrSMt(qYZ@I z*-C@x=5v=Wg~rC7g0b%v0zpDAsmLw{!59UE;}z9?*=I~A14dpZQ1%m6b?@H2?&DLg z?&$GAG7!w4D^O^OOaz3NVJN$>qjnM#e6IM{Z0{2*=zq(%y4zs1lUobsHodqw7N!h8 z+7Xx!GH5_EB=N_6{~s-%7RYaU0MD^PIGz0$xD|m z-O<;tTFCOqr&8vYD2~%)+Y1x~f_&)MF%Qb$CG$N}@gKyN?$MU?$9;MzVAop3o2t0F zo&YsosO!Ic@#0xX$dkJ{ihyWzci7z*u)fMSIX&%0(3H}F;tdpSqT}M8XJzq00$oGV zoAW0qS%aa`Nc1^J-Psv`xbp2BWY{nnap98PuYrMq^4pt*^?Rp&Vgb;N-5OhKtq~eY zlBa|2q7c^aOZ<0goKw$xI6&&wpd#Ewp8!WVO;bd^BjRL0fxCX$g>r*2z&Y5`Z#?LXx={6Qj+9 zAL4FXwOL4NJz>THlb)XsU4=t7IhT<)-9Kj!AXyj9MMgW<+eF24y9(kB+HK-hoa zz?p}m67If>O3SQC(b31Q zLLSbjU^|X1{P1DB7>V-mMoJO_InY+`*u9E^Ji1MXIAI3_@S(O0q!w5nH{ng zAQAfk6sjKiqt1O*1jT7C>h77TDL&}T)PMW575nNHq1BxHG%gN}4kXu6$#P}^tGtj! z+|bgphNd=@7+`F+8LrXE(RrwPYva=sNQTUO7dey{FDY%W;tVStS;69e3Z|z;3=lZ1 z4oqmTELBHEwEIqKM-@8sI|E%Ktq%k$JJ{V`7ylY$WeUThs9(=UMN7Bq62t_1E%!ziHX(CY|P>KJmwDihfz%{7r%poDF! zcQ<#J;%qZ1pwy4{Oy)IIGXaIm92oO`YU$_J8C;{n_``UT%V zg#_NqjSKu%=`ihv8}%--Za*8iZcbi4OJ2hw+j_!;eq_W`Mx9Rn2=KxSj{gA4*uCGN zRXCBM?}z2WReVKFsFG0Ka^@47KRYZKL#=qmdC1AqXM_@E4+JqPn%KTSF_)13NR4hm z>EYLs9}XAzhADze@FXOjA>(E#?tagues*?bP{b;Q$jP=$jk9&(bKNwaWymzEhNSr3iJoBD&n=o^ioJmHF##{%I}x|)0+y=^f`MJV-G^c0W;e< z5e-^M#Kr;8ze#D53c!oPWyu!LE|gHK>sF(g8t<2l`u7Wh%KOS^?6|nzzXgwyN!zAB zn%@={h5})jrHB(m&e+a5wRAni8!zVPP1pH}5p7bex3SBZ&#K=Rx@z1VoUY{YFs0=%J2l(MpN z`EsgpN)sf8D^TxCY2r8?&aPo*CP|@CXm;LaS&>cOZVS`_q`VNm51U4z>9#?_kQn;IZKj^VIHSO;;pN0YgZwa zquU8D1f5N0VZ&>hPQl-g{j-3e8W*+g^rIPcO=Zx+pNEH&V5)y(L7_Gk5faf}84Jj$ zj~{Qfw6tDQG;_}NY6Xb$DuHmoV&$9s*|TTCJ{6$B`wnfDr+xhT%Somm(E81)tb7V% z%WH1#P=vG|ed^RWt+nMVlb(?QLXp>~w?eKfFW%GdqE%^3DXCccA5Kp+-| z%V5ai5J(CSU58%_aHJAr4aF|RBqnM&IdK3@)E-(P=27TjD4SpU_ojDCoh=JZ13w#| zpa_0>VX!I+&>(WXv*K$6^d9Mt)He=K4mH@s#@o1|g zg)bhTngbfcF+s&Wv0@h@0Z{}{t^?3A^NO>w$&lr;vF3UCDgm1Dr=jt;r&oeVx1WB? zo%qVVxwYMj5SMc2PMqXVvL?C+KE$eeE(U`_l5{JF8h1`_@W-#eR?9177r4C1&JKk_vW;9&eZ3DIus_XdoZYi9P=PNF z<^Blu5L*`)?@J1Ot%GL&GD>dF#H#w+o|+0rnT0%Mgaj^UWLG4ds0)csNC<4K?|gR8 z7s{CKzzz^UC_H0inu6Cz@=Ah!ogR?Gs#kQrOnU;UIb=k1;X*78vwz%vYE%Vu1Mw5p z$sSLSb7e@Y=Bi~Pw5!)Ok!1ZJap|^zS@bj;^I?uUQ0uk?`3G*JK@(oUe~%Cy?SN3 zoG=NXLhJ)*45sDP5U6`@8TozQlIG`5_!4sg@D?4Pf6 zVwzsvEfIo9NQ>$(xcTsK3}3{#bBd9|l|B;$nj9?l9jRix`5jN7-$e&w`o|

SmxT+l22gu^i| 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 Date: Sat, 26 Sep 2026 21:02:50 +0200 Subject: [PATCH 76/83] 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 77/83] 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 78/83] 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 79/83] 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 80/83] 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 81/83] 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 82/83] 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 83/83] 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| 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 Date: Sat, 26 Sep 2026 21:00:46 +0200 Subject: [PATCH 75/83] 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 74/83] 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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