Merge dev branch into master

This commit is contained in:
zenith
2026-07-12 17:42:31 -04:00
parent 8dffd508c3
commit 936bef77c7
11 changed files with 539 additions and 147 deletions
+63 -1
View File
@@ -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):
+3 -2
View File
@@ -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:
+32 -29
View File
@@ -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()
+225 -20
View File
@@ -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)
+45 -12
View File
@@ -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)
+41 -2
View File
@@ -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
+4 -1
View File
@@ -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
+28 -16
View File
@@ -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:
+13 -25
View File
@@ -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)
+71 -36
View File
@@ -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()
+14 -3
View File
@@ -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 ""))