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 ""))