Merged RRC support from Zenith and neutral

This commit is contained in:
Mark Qvist committed 2026-05-17 02:04:37 +02:00
commit bffb9e47dc
12 files changed
+3628 -58

No files matched your search

+49 -15
View File
@@ -1,4 +1,5 @@
import os
import re
import RNS
import LXMF
import shutil
@@ -459,22 +460,25 @@ class ConversationMessage:
if found_in_fields:
names = []
file_atts = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, [])
for att in file_atts:
for idx, att in enumerate(file_atts):
if isinstance(att, list) and len(att) >= 2:
size = len(att[1]) if isinstance(att[1], bytes) else 0
names.append(("file", str(att[0]), size))
safe = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx))
names.append(("file", safe, size))
if LXMF.FIELD_IMAGE in fields:
fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE])
if data:
size = len(data)
ext = ConversationMessage._ext_from_media_format(fmt, data)
names.append(("file", "image"+ext, size))
safe = ConversationMessage.safe_attachment_name("image"+ext, fallback="image")
names.append(("file", safe, size))
if LXMF.FIELD_AUDIO in fields:
fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO])
if data:
size = len(data)
ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True)
names.append(("file", "audio"+ext, size))
safe = ConversationMessage.safe_attachment_name("audio"+ext, fallback="audio")
names.append(("file", safe, size))
self._cached_has_attachments = True
self._cached_attachment_names = names
@@ -674,11 +678,18 @@ class ConversationMessage:
try:
with open(manifest_path, "rb") as f:
manifest = msgpack.unpackb(f.read(), raw=False)
for f in manifest["files"]:
f["name"] = os.path.basename(f["name"]).encode("utf-8").decode("utf-8")
safe_files = []
for idx, entry in enumerate(manifest.get("files", [])):
if not isinstance(entry, dict):
continue
entry["name"] = ConversationMessage.safe_attachment_name(entry.get("name"), fallback="attachment_"+str(idx))
stored = entry.get("stored_name")
if not isinstance(stored, str) or not re.fullmatch(r"file_\d+", stored):
continue
safe_files.append(entry)
manifest["files"] = safe_files
return manifest
except Exception as e:
RNS.log(f"Error loading attachment manifest: {e}")
return None
@@ -714,12 +725,12 @@ class ConversationMessage:
for idx, att in enumerate(file_attachments):
try:
if isinstance(att, list) and len(att) >= 2:
filename = os.path.basename(str(att[0])).encode("utf-8").decode("utf-8")
filename = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx))
data = att[1] if isinstance(att[1], bytes) else b""
stored_name = "file_"+str(idx)
with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data)
manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)})
except Exception as e:
RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR)
continue
@@ -728,7 +739,7 @@ class ConversationMessage:
fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE])
if data:
ext = ConversationMessage._ext_from_media_format(fmt, data)
filename = "image" + ext
filename = ConversationMessage.safe_attachment_name("image" + ext, fallback="image")
stored_name = "file_"+str(len(manifest["files"]))
with open(os.path.join(att_dir, stored_name), "wb") as f:
f.write(data)
@@ -738,7 +749,7 @@ class ConversationMessage:
fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO])
if data:
ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True)
filename = "audio" + ext
filename = ConversationMessage.safe_attachment_name("audio" + ext, fallback="audio")
stored_name = "file_"+str(len(manifest["files"]))
with open(os.path.join(att_dir, stored_name), "wb") as f:
f.write(data)
@@ -747,6 +758,29 @@ class ConversationMessage:
with open(os.path.join(att_dir, "manifest"), "wb") as f:
f.write(msgpack.packb(manifest))
@staticmethod
def safe_attachment_name(name, fallback="attachment"):
try:
if isinstance(name, bytes):
name = name.decode("utf-8", errors="replace")
elif not isinstance(name, str):
name = str(name) if name is not None else ""
except Exception:
name = ""
name = re.sub(r"[\x00-\x1f\x7f]", "", name)
parts = re.split(r"[/\\]", name)
name = parts[-1] if parts else ""
if ":" in name:
name = name.split(":")[-1]
name = name.lstrip(".")
if not name or name in (".", ".."):
return fallback
if len(name) > 200:
base, ext = os.path.splitext(name)
ext = ext[:16]
name = base[:200 - len(ext)] + ext
return name
@staticmethod
def _unpack_media_field(field_data):
"""Normalize FIELD_IMAGE/FIELD_AUDIO which can be raw bytes or [format, bytes].
@@ -789,10 +823,10 @@ class ConversationMessage:
@staticmethod
def _ext_from_media_format(fmt, data, is_audio=False):
"""Derive file extension from format identifier and data.
fmt can be a string ('webp'), an integer (LXMF audio mode), or None."""
if isinstance(fmt, str) and len(fmt) > 0:
return "." + fmt.lower().strip(".")
safe = re.sub(r"[^A-Za-z0-9]", "", fmt).lower()[:8]
if safe:
return "." + safe
if isinstance(fmt, int) and is_audio:
if fmt >= 16 and fmt <= 25:
return ".ogg"
+73 -21
View File
@@ -41,6 +41,13 @@ class NomadNetworkApp:
RNS.log("Saving directory...", RNS.LOG_VERBOSE)
self.directory.save_to_disk()
if hasattr(self, "rrc") and self.rrc is not None:
try:
self.rrc.save()
self.rrc.shutdown()
except Exception:
pass
if hasattr(self.ui, "restore_ixon"):
if self.ui.restore_ixon:
try:
@@ -79,6 +86,8 @@ class NomadNetworkApp:
self.uimode = None
self.announce_interval = 6*60*60
if configdir == None:
self.configdir = NomadNetworkApp.configdir
else:
@@ -139,10 +148,12 @@ class NomadNetworkApp:
self.lxmf_sync_interval = 360*60
self.lxmf_sync_limit = 8
self.compact_stream = False
self.required_stamp_cost = None
self.accept_invalid_stamps = False
self.rrc_history_per_room_cap = 500
if not os.path.isdir(self.storagepath):
os.makedirs(self.storagepath)
@@ -193,11 +204,11 @@ class NomadNetworkApp:
import shutil
examplespath = os.path.join(os.path.dirname(__file__), "examples")
shutil.copytree(examplespath, self.examplespath, ignore=shutil.ignore_patterns("__pycache__"))
except Exception as e:
RNS.log("Could not copy examples into the "+self.examplespath+" directory.", RNS.LOG_ERROR)
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.log("Could not load config file, creating default configuration file...")
self.createDefaultConfig()
self.firstrun = True
@@ -249,6 +260,8 @@ class NomadNetworkApp:
if not "served_file_requests" in self.peer_settings:
self.peer_settings["served_file_requests"] = 0
self.peer_settings["announce_interval"] = self.announce_interval
except Exception as e:
RNS.logdest = RNS.LOG_STDOUT
RNS.log(f"Could not load local peer settings from {self.peersettingspath}", RNS.LOG_ERROR)
@@ -261,7 +274,7 @@ class NomadNetworkApp:
RNS.log("No peer settings file found, creating new...")
self.peer_settings = {
"display_name": "Anonymous Peer",
"announce_interval": None,
"announce_interval": self.announce_interval,
"last_announce": None,
"node_last_announce": None,
"propagation_node": None,
@@ -301,6 +314,10 @@ class NomadNetworkApp:
self.directory = nomadnet.Directory(self)
from nomadnet.RRC import RRCManager
self.rrc = RRCManager(self)
self.rrc.load()
static_peers = []
for static_peer in self.static_peers:
try:
@@ -366,7 +383,7 @@ class NomadNetworkApp:
if not self.disable_propagation:
RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash))
self.node = nomadnet.Node(self)
else:
self.node = None
@@ -431,11 +448,14 @@ class NomadNetworkApp:
RNS.log("Starting job scheduler now", RNS.LOG_DEBUG)
while self.should_run_jobs:
now = time.time()
if now > self.peer_settings["last_lxmf_sync"] + self.lxmf_sync_interval:
RNS.log("Initiating automatic LXMF sync", RNS.LOG_VERBOSE)
self.request_lxmf_sync(limit=self.lxmf_sync_limit)
if now > self.peer_settings["last_announce"] + self.peer_settings["announce_interval"]:
self.announce_now()
time.sleep(self.job_interval)
def set_display_name(self, display_name):
@@ -519,6 +539,7 @@ class NomadNetworkApp:
self.message_router.cancel_propagation_node_requests()
def announce_now(self):
RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE)
self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost)
self.lxmf_destination.display_name = self.peer_settings["display_name"]
self.message_router.announce(self.lxmf_destination.hash)
@@ -561,7 +582,7 @@ class NomadNetworkApp:
self.peer_settings["propagation_node"] = node_hash
self.save_peer_settings()
self.autoselect_propagation_node()
def get_default_propagation_node(self):
return self.message_router.get_outbound_propagation_node()
@@ -593,7 +614,7 @@ class NomadNetworkApp:
if self.print_messages:
if self.print_all_messages:
return True
else:
source_hash_text = RNS.hexrep(message.source_hash, delimit=False)
@@ -636,7 +657,7 @@ class NomadNetworkApp:
received = time.time()
g = self.ui.glyphs
m_rtime = datetime.fromtimestamp(message.timestamp)
stime = m_rtime.strftime(self.time_format)
@@ -703,7 +724,7 @@ class NomadNetworkApp:
def createDefaultConfig(self):
self.config = ConfigObj(__default_nomadnet_config__)
self.config.filename = self.configpath
if not os.path.isdir(self.configdir):
os.makedirs(self.configdir)
self.config.write()
@@ -749,6 +770,11 @@ class NomadNetworkApp:
value = self.config["client"].as_bool(option)
self.peer_announce_at_start = value
if option == "announce_interval":
value = self.config["client"].as_int(option)
if value < 30: value = 30
self.announce_interval = value*60
if option == "try_propagation_on_send_fail":
value = self.config["client"].as_bool(option)
self.try_propagation_on_fail = value
@@ -764,7 +790,7 @@ class NomadNetworkApp:
self.lxmf_sync_interval = value
if option == "lxmf_sync_limit":
value = self.config["client"].as_int(option)
value = self.config["client"].as_int(option)
if value > 0:
self.lxmf_sync_limit = value
@@ -790,7 +816,7 @@ class NomadNetworkApp:
self.accept_invalid_stamps = value
if option == "max_accepted_size":
value = self.config["client"].as_float(option)
value = self.config["client"].as_float(option)
if value > 0:
self.lxmf_max_incoming_size = value
@@ -880,6 +906,16 @@ class NomadNetworkApp:
if value == "web":
self.uimode = nomadnet.ui.UI_WEB
if "rrc" in self.config:
for option in self.config["rrc"]:
if option == "history_per_room_cap":
try:
value = self.config["rrc"].as_int(option)
except Exception:
value = None
if value is not None and value >= 0:
self.rrc_history_per_room_cap = value
if "node" in self.config:
if not "enable_node" in self.config["node"]:
self.enable_node = False
@@ -936,10 +972,10 @@ class NomadNetworkApp:
value = self.config["node"].as_int("propagation_cost")
if value < 13: value = 13
self.node_propagation_cost = value
if "pages_path" in self.config["node"]:
self.pagespath = self.config["node"]["pages_path"]
if not "page_refresh_interval" in self.config["node"]:
self.page_refresh_interval = 0
else:
@@ -947,11 +983,11 @@ class NomadNetworkApp:
if value < 0:
value = 0
self.page_refresh_interval = value
if "files_path" in self.config["node"]:
self.filespath = self.config["node"]["files_path"]
if not "file_refresh_interval" in self.config["node"]:
self.file_refresh_interval = 0
else:
@@ -959,7 +995,7 @@ class NomadNetworkApp:
if value < 0:
value = 0
self.file_refresh_interval = value
if "prioritise_destinations" in self.config["node"]:
self.prioritised_lxmf_destinations = self.config["node"].as_list("prioritise_destinations")
@@ -970,7 +1006,7 @@ class NomadNetworkApp:
self.static_peers = self.config["node"].as_list("static_peers")
else:
self.static_peers = []
if not "max_peers" in self.config["node"]:
self.max_peers = None
else:
@@ -1010,13 +1046,13 @@ class NomadNetworkApp:
self.print_all_messages = True
if self.config["printing"]["print_from"].lower() == "trusted":
self.print_all_messages = False
self.print_trusted_messages = True
if len(self.config["printing"]["print_from"]) == (RNS.Identity.TRUNCATED_HASHLENGTH//8)*2:
self.allowed_message_print_destinations.append(self.config["printing"]["print_from"])
if type(self.config["printing"]["print_from"]) == list:
self.allowed_message_print_destinations = self.config["printing"].as_list("print_from")
for allowed_entry in self.allowed_message_print_destinations:
@@ -1036,7 +1072,7 @@ class NomadNetworkApp:
template_file.write(__printing_template_msg__.encode("utf-8"))
self.printing_template_msg = __printing_template_msg__
@staticmethod
def get_shared_instance():
if NomadNetworkApp._shared_instance != None:
@@ -1084,6 +1120,10 @@ notify_on_new_message = yes
# to let other peers reach it immediately.
announce_at_start = yes
# Automatic announce interval in minutes for
# your LXMF address. 6 hours by default.
announce_interval = 360
# By default, the client will try to deliver a
# message via the LXMF propagation network, if
# a direct delivery to the recipient is not
@@ -1188,6 +1228,18 @@ hide_guide = no
# announces.
sanitize_names = yes
[rrc]
# Maximum number of messages retained per
# room in the in-memory scrollback buffer,
# and the number of messages restored from
# on-disk history at startup. The on-disk
# log itself is appended to indefinitely;
# this cap only controls how much backlog
# is visible. Set to 0 to keep every message
# in memory (full history).
history_per_room_cap = 500
[node]
# Whether to enable node hosting
+1362
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.0.4"
__version__ = "1.1.0"
+15 -1
View File
@@ -59,6 +59,13 @@ THEMES = {
("placeholder", "dark gray", "default", "default", "dark gray", "default"),
("placeholder_text", "dark gray", "default", "default", "dark gray", "default"),
("error", "light red,blink", "default", "blink", "#f44,blink", "default"),
("irc_ts", "dark gray", "default", "default", "#888", "default"),
("irc_nick_self", "light green", "default", "default", "#6c5", "default"),
("irc_nick_peer", "light cyan", "default", "default", "#3cd", "default"),
("irc_notice", "yellow", "default", "default", "#fd3", "default"),
("irc_error", "light red", "default", "default", "#f55", "default"),
("irc_system", "dark gray", "default", "default", "#888", "default"),
("irc_mention", "light red,bold", "default", "bold", "#fb4,bold", "default"),
],
},
@@ -103,7 +110,14 @@ THEMES = {
("placeholder", "light gray", "default", "default", "#999", "default"),
("placeholder_text", "light gray", "default", "default", "#999", "default"),
("error", "dark red,blink", "default", "blink", "#a22,blink", "default"),
],
("irc_ts", "dark gray", "default", "default", "#888", "default"),
("irc_nick_self", "dark green", "default", "default", "#3a0", "default"),
("irc_nick_peer", "dark cyan", "default", "default", "#077", "default"),
("irc_notice", "brown", "default", "default", "#a70", "default"),
("irc_error", "dark red", "default", "default", "#a22", "default"),
("irc_system", "dark gray", "default", "default", "#888", "default"),
("irc_mention", "dark red,bold", "default", "bold", "#c50,bold", "default"),
],
}
}
+54
View File
@@ -192,6 +192,8 @@ class Browser:
return "nomadnetwork.node"
elif destination_type == "lxmf":
return "lxmf.delivery"
elif destination_type == "rrc":
return "rrc.hub.session"
else:
return destination_type
@@ -250,6 +252,11 @@ class Browser:
recurse_down(self.attr_maps)
RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG)
# rrc://<hex>[:<dest_name>]/<room> URL form
if link_target.startswith("rrc://"):
self.handle_rrc_link(link_target[6:])
return
components = link_target.split("@")
destination_type = None
@@ -280,6 +287,10 @@ class Browser:
RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG)
self.handle_lxmf_link(link_target)
elif destination_type == "rrc.hub.session":
RNS.log("Passing RRC link to handler", RNS.LOG_DEBUG)
self.handle_rrc_link(link_target)
elif destination_type == "partial":
if partial_ids != None and len(partial_ids) > 0: self.handle_partial_updates(partial_ids)
@@ -331,6 +342,49 @@ class Browser:
self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
def handle_rrc_link(self, link_target):
try:
if not isinstance(link_target, str):
raise ValueError("invalid RRC link payload")
rest = link_target.strip()
if rest.startswith("/"):
rest = rest[1:]
hub_part, _, room = rest.partition("/")
hex_part, _, dest = hub_part.partition(":")
hex_part = hex_part.strip()
dest = dest.strip() or None
try:
hub_hash = bytes.fromhex(hex_part)
except Exception:
raise ValueError("invalid hub hash")
expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
if len(hub_hash) != expected_len:
raise ValueError("hub hash must be "+str(expected_len)+" bytes")
room = room.strip().lstrip("#").strip()
room_norm = room.lower() if room else None
existing = self.app.rrc.find_hub(hub_hash, dest_name=dest)
self.app.ui.main_display.show_channels(None)
channels = self.app.ui.main_display.sub_displays.channels_display
if existing is not None:
channels.update_list()
if room_norm:
channels._select_room(None, (existing, room_norm))
else:
channels._select_hub(None, existing)
return
channels.confirm_new_hub_dialog(hub_hash, dest, room_norm)
except Exception as e:
RNS.log("Could not open RRC link: "+str(e), RNS.LOG_ERROR)
self.browser_footer = urwid.Text("Could not open RRC link: "+str(e))
self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
def micron_released_focus(self):
if self.delegate != None:
self.delegate.focus_lists()
File diff suppressed because it is too large. Load diff
+60 -17
View File
@@ -1,4 +1,5 @@
import RNS
import collections
import os
import shutil
import time
@@ -133,7 +134,40 @@ class ConversationsDisplay():
self.shortcuts_display = self.list_shortcuts
self.widget = self.columns_widget
nomadnet.Conversation.created_callback = self.update_conversation_list
self._pending_actions = collections.deque()
self._wake_fd = None
try:
self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending)
except Exception:
pass
nomadnet.Conversation.created_callback = lambda: self._wake(self.update_conversation_list)
def _process_pending(self, data):
while True:
try:
action = self._pending_actions.popleft()
except IndexError:
break
try:
action()
except Exception as e:
RNS.log("Conversations UI action failed: "+str(e), RNS.LOG_ERROR)
return True
def _wake(self, action):
self._pending_actions.append(action)
if self._wake_fd is not None:
try:
os.write(self._wake_fd, b".")
return
except Exception:
pass
try:
self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None))
except Exception:
pass
def focus_change_event(self):
if not self.dialog_open:
@@ -1024,7 +1058,7 @@ class ConversationWidget(urwid.WidgetWrap):
self.update_message_widgets()
self.conversation.register_changed_callback(self.conversation_changed)
self.conversation.register_changed_callback(self._on_conversation_changed_from_callback)
#title_editor = MessageEdit(caption="\u270E", edit_text="", multiline=False)
title_editor = MessageEdit(caption="", edit_text="", multiline=False)
@@ -1243,6 +1277,13 @@ class ConversationWidget(urwid.WidgetWrap):
else:
return super(ConversationWidget, self).keypress(size, key)
def _on_conversation_changed_from_callback(self, conversation):
delegate = getattr(self, "delegate", None)
if delegate is not None and hasattr(delegate, "_wake"):
delegate._wake(lambda: self.conversation_changed(conversation))
else:
self.conversation_changed(conversation)
def conversation_changed(self, conversation):
if hasattr(self, "peer_info_widget"):
self._update_peer_info()
@@ -1798,32 +1839,34 @@ class ClickableAttachment(urwid.Text):
self.set_text(" "+g["cross"]+" Save failed: "+str(e))
def _copy_attachment_to_dest(filename, src_path):
def _resolve_attachment_save_path(filename):
app = nomadnet.NomadNetworkApp.get_shared_instance()
save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path
if not os.path.isdir(save_dir):
os.makedirs(save_dir)
save_path = os.path.join(save_dir, filename)
safe_name = ConversationMessage.safe_attachment_name(filename)
base_dir = os.path.realpath(save_dir) + os.sep
candidate = os.path.realpath(os.path.join(save_dir, safe_name))
if not (candidate + os.sep).startswith(base_dir):
raise OSError(13, os.strerror(13))
counter = 0
base, ext = os.path.splitext(filename)
while os.path.isfile(save_path):
base, ext = os.path.splitext(safe_name)
while os.path.isfile(candidate):
counter += 1
save_path = os.path.join(save_dir, base+"_"+str(counter)+ext)
candidate = os.path.realpath(os.path.join(save_dir, base+"_"+str(counter)+ext))
if not (candidate + os.sep).startswith(base_dir):
raise OSError(13, os.strerror(13))
return candidate
def _copy_attachment_to_dest(filename, src_path):
save_path = _resolve_attachment_save_path(filename)
shutil.copy2(src_path, save_path)
return save_path
def _save_attachment_to_disk(filename, data):
app = nomadnet.NomadNetworkApp.get_shared_instance()
save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path
if not os.path.isdir(save_dir):
os.makedirs(save_dir)
save_path = os.path.join(save_dir, filename)
counter = 0
base, ext = os.path.splitext(filename)
while os.path.isfile(save_path):
counter += 1
save_path = os.path.join(save_dir, base+"_"+str(counter)+ext)
save_path = _resolve_attachment_save_path(filename)
with open(save_path, "wb") as f:
f.write(data)
return save_path
+16
View File
@@ -717,6 +717,12 @@ Sets whether to output a notification character (bell or flash) to the terminal
Determines whether your LXMF address is automatically announced when the program starts. Must be a boolean value.
<
>>>
`!announce_interval = 360`!
>>>>
Determines how often, in minutes, your LXMF address is announced on the network. Defaults to 6 hours.
<
>>>
`!try_propagation_on_send_fail = yes`!
>>>>
@@ -765,6 +771,16 @@ The maximum accepted unpacked size for messages received directly from other pee
With this option enabled, Nomad Network will only display one entry in the announce stream per destination. Older announces are culled when a new one arrives.
<
>> RRC Section
This section hold configuration directives related to the RRC client behaviour. It is delimited by the `![rrc]`! header in the configuration file. Available directives, along with their default values, are as follows:
>>>
`!history_per_room_cap = 500`!
>>>>
Maximum number of messages retained per room in the in-memory scrollback buffer, and the number of messages restored from on-disk history at startup. The on-disk log itself is appended to indefinitely; this cap only controls how much backlog is visible. Set to 0 to keep every message in memory.
<
>> Text UI Section
This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows:
+10 -2
View File
@@ -2,6 +2,7 @@ import RNS
from .Network import *
from .Conversations import *
from .Channels import *
from .Directory import *
from .Config import *
from .Interfaces import *
@@ -15,6 +16,7 @@ class SubDisplays():
self.app = app
self.network_display = NetworkDisplay(self.app)
self.conversations_display = ConversationsDisplay(self.app)
self.channels_display = ChannelsDisplay(self.app)
self.directory_display = DirectoryDisplay(self.app)
self.config_display = ConfigDisplay(self.app)
self.interface_display = InterfaceDisplay(self.app)
@@ -103,6 +105,11 @@ class MainDisplay():
self.sub_displays.active_display = self.sub_displays.conversations_display
self.update_active_sub_display()
def show_channels(self, user_data):
self.sub_displays.active_display = self.sub_displays.channels_display
self.update_active_sub_display()
self.sub_displays.channels_display.start()
def show_directory(self, user_data):
self.sub_displays.active_display = self.sub_displays.directory_display
self.update_active_sub_display()
@@ -181,6 +188,7 @@ class MenuDisplay():
menu_text = (urwid.PACK, self.menu_indicator)
button_network = (11, MenuButton("Network", on_press=handler.show_network))
button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations))
button_channels = (12, MenuButton("Channels", on_press=handler.show_channels))
button_directory = (13, MenuButton("Directory", on_press=handler.show_directory))
button_map = (7, MenuButton("Map", on_press=handler.show_map))
button_log = (7, MenuButton("Log", on_press=handler.show_log))
@@ -191,9 +199,9 @@ class MenuDisplay():
# buttons = [menu_text, button_conversations, button_node, button_directory, button_map]
if self.app.config["textui"]["hide_guide"]:
buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_quit]
buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_quit]
else:
buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_guide, button_quit]
buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_guide, button_quit]
columns = MenuColumns(buttons, dividechars=1)
columns.handler = handler
+430
View File
@@ -0,0 +1,430 @@
# https://github.com/brianolson/cbor_py
# Copyright 2014-2015 Brian Olson
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import re
import struct
from io import BytesIO
CBOR_TYPE_MASK = 0xE0 # top 3 bits
CBOR_INFO_BITS = 0x1F # low 5 bits
CBOR_UINT = 0x00
CBOR_NEGINT = 0x20
CBOR_BYTES = 0x40
CBOR_TEXT = 0x60
CBOR_ARRAY = 0x80
CBOR_MAP = 0xA0
CBOR_TAG = 0xC0
CBOR_7 = 0xE0 # float and other types
CBOR_UINT8_FOLLOWS = 24 # 0x18
CBOR_UINT16_FOLLOWS = 25 # 0x19
CBOR_UINT32_FOLLOWS = 26 # 0x1a
CBOR_UINT64_FOLLOWS = 27 # 0x1b
CBOR_VAR_FOLLOWS = 31 # 0x1f
CBOR_BREAK = 0xFF
CBOR_FALSE = (CBOR_7 | 20)
CBOR_TRUE = (CBOR_7 | 21)
CBOR_NULL = (CBOR_7 | 22)
CBOR_UNDEFINED = (CBOR_7 | 23) # js 'undefined' value
CBOR_FLOAT16 = (CBOR_7 | 25)
CBOR_FLOAT32 = (CBOR_7 | 26)
CBOR_FLOAT64 = (CBOR_7 | 27)
CBOR_TAG_DATE_STRING = 0 # RFC3339
CBOR_TAG_DATE_ARRAY = 1 # any number, seconds since 1970-01-01T00:00:00 UTC
CBOR_TAG_BIGNUM = 2 # big-endian byte string follows
CBOR_TAG_NEGBIGNUM = 3 # big-endian byte string follows
CBOR_TAG_DECIMAL = 4
CBOR_TAG_BIGFLOAT = 5
CBOR_TAG_BASE64URL = 21
CBOR_TAG_BASE64 = 22
CBOR_TAG_BASE16 = 23
CBOR_TAG_CBOR = 24
CBOR_TAG_URI = 32
CBOR_TAG_REGEX = 35
CBOR_TAG_MIME = 36
CBOR_TAG_CBOR_FILEHEADER = 55799 # 0xd9d9f7
_CBOR_TAG_BIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_BIGNUM)
_CBOR_TAG_NEGBIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_NEGBIGNUM)
def _dumps_bignum_to_bytearray(val):
out = []
while val > 0:
out.insert(0, val & 0x0ff)
val = val >> 8
return bytes(out)
def dumps_int(val):
"return bytes representing int val in CBOR"
if val >= 0:
if val <= 23:
return struct.pack('B', val)
if val <= 0x0ff:
return struct.pack('BB', CBOR_UINT8_FOLLOWS, val)
if val <= 0x0ffff:
return struct.pack('!BH', CBOR_UINT16_FOLLOWS, val)
if val <= 0x0ffffffff:
return struct.pack('!BI', CBOR_UINT32_FOLLOWS, val)
if val <= 0x0ffffffffffffffff:
return struct.pack('!BQ', CBOR_UINT64_FOLLOWS, val)
outb = _dumps_bignum_to_bytearray(val)
return _CBOR_TAG_BIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb
val = -1 - val
return _encode_type_num(CBOR_NEGINT, val)
def dumps_float(val):
return struct.pack("!Bd", CBOR_FLOAT64, val)
def _encode_type_num(cbor_type, val):
"""For some CBOR primary type [0..7] and an auxiliary unsigned number,
return CBOR encoded bytes."""
assert val >= 0
if val <= 23:
return struct.pack('B', cbor_type | val)
if val <= 0x0ff:
return struct.pack('BB', cbor_type | CBOR_UINT8_FOLLOWS, val)
if val <= 0x0ffff:
return struct.pack('!BH', cbor_type | CBOR_UINT16_FOLLOWS, val)
if val <= 0x0ffffffff:
return struct.pack('!BI', cbor_type | CBOR_UINT32_FOLLOWS, val)
if (((cbor_type == CBOR_NEGINT) and (val <= 0x07fffffffffffffff)) or
((cbor_type != CBOR_NEGINT) and (val <= 0x0ffffffffffffffff))):
return struct.pack('!BQ', cbor_type | CBOR_UINT64_FOLLOWS, val)
if cbor_type != CBOR_NEGINT:
raise Exception("value too big for CBOR unsigned number: {0!r}".format(val))
outb = _dumps_bignum_to_bytearray(val)
return _CBOR_TAG_NEGBIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb
def dumps_string(val, is_text=None, is_bytes=None):
if isinstance(val, str):
val = val.encode('utf8')
is_text = True
is_bytes = False
if (is_bytes) or not (is_text == True):
return _encode_type_num(CBOR_BYTES, len(val)) + val
return _encode_type_num(CBOR_TEXT, len(val)) + val
def dumps_array(arr, sort_keys=False):
head = _encode_type_num(CBOR_ARRAY, len(arr))
parts = [dumps(x, sort_keys=sort_keys) for x in arr]
return head + b''.join(parts)
def dumps_dict(d, sort_keys=False):
head = _encode_type_num(CBOR_MAP, len(d))
parts = [head]
if sort_keys:
for k in sorted(d.keys()):
v = d[k]
parts.append(dumps(k, sort_keys=sort_keys))
parts.append(dumps(v, sort_keys=sort_keys))
else:
for k, v in d.items():
parts.append(dumps(k, sort_keys=sort_keys))
parts.append(dumps(v, sort_keys=sort_keys))
return b''.join(parts)
def dumps_bool(b):
if b:
return struct.pack('B', CBOR_TRUE)
return struct.pack('B', CBOR_FALSE)
def dumps_tag(t, sort_keys=False):
return _encode_type_num(CBOR_TAG, t.tag) + dumps(t.value, sort_keys=sort_keys)
def dumps(ob, sort_keys=False):
if ob is None:
return struct.pack('B', CBOR_NULL)
if isinstance(ob, bool):
return dumps_bool(ob)
if isinstance(ob, (str, bytes, bytearray)):
if isinstance(ob, bytearray):
ob = bytes(ob)
return dumps_string(ob)
if isinstance(ob, (list, tuple)):
return dumps_array(ob, sort_keys=sort_keys)
if isinstance(ob, dict):
return dumps_dict(ob, sort_keys=sort_keys)
if isinstance(ob, float):
return dumps_float(ob)
if isinstance(ob, int):
return dumps_int(ob)
if isinstance(ob, Tag):
return dumps_tag(ob, sort_keys=sort_keys)
raise Exception("don't know how to cbor serialize object of type %s" % type(ob))
def dump(obj, fp, sort_keys=False):
"""obj: Python object to serialize. fp: file-like object capable of .write(bytes)."""
blob = dumps(obj, sort_keys=sort_keys)
fp.write(blob)
class Tag(object):
def __init__(self, tag=None, value=None):
self.tag = tag
self.value = value
def __repr__(self):
return "Tag({0!r}, {1!r})".format(self.tag, self.value)
def __eq__(self, other):
if not isinstance(other, Tag):
return False
return (self.tag == other.tag) and (self.value == other.value)
def __hash__(self):
return hash((self.tag, self.value)) if not isinstance(self.value, (list, dict, bytearray)) else id(self)
def loads(data):
"""Parse CBOR bytes and return Python objects."""
if data is None:
raise ValueError("got None for buffer to decode in loads")
if isinstance(data, (bytes, bytearray, memoryview)):
fp = BytesIO(bytes(data))
else:
fp = data
return _loads(fp)[0]
def load(fp):
"""Parse and return object from fp, a file-like object supporting .read(n)."""
return _loads(fp)[0]
_MAX_DEPTH = 100
def _tag_aux(fp, tb):
bytes_read = 1
tag = tb & CBOR_TYPE_MASK
tag_aux = tb & CBOR_INFO_BITS
if tag_aux <= 23:
aux = tag_aux
elif tag_aux == CBOR_UINT8_FOLLOWS:
data = fp.read(1)
aux = struct.unpack_from("!B", data, 0)[0]
bytes_read += 1
elif tag_aux == CBOR_UINT16_FOLLOWS:
data = fp.read(2)
aux = struct.unpack_from("!H", data, 0)[0]
bytes_read += 2
elif tag_aux == CBOR_UINT32_FOLLOWS:
data = fp.read(4)
aux = struct.unpack_from("!I", data, 0)[0]
bytes_read += 4
elif tag_aux == CBOR_UINT64_FOLLOWS:
data = fp.read(8)
aux = struct.unpack_from("!Q", data, 0)[0]
bytes_read += 8
else:
assert tag_aux == CBOR_VAR_FOLLOWS, "bogus tag {0:02x}".format(tb)
aux = None
return tag, tag_aux, aux, bytes_read
def _read_byte(fp):
tb = fp.read(1)
if len(tb) == 0:
raise EOFError()
return tb[0]
def _loads_var_array(fp, limit, depth, returntags, bytes_read):
ob = []
tb = _read_byte(fp)
while tb != CBOR_BREAK:
(subob, sub_len) = _loads_tb(fp, tb, limit, depth, returntags)
bytes_read += 1 + sub_len
ob.append(subob)
tb = _read_byte(fp)
return (ob, bytes_read + 1)
def _loads_var_map(fp, limit, depth, returntags, bytes_read):
ob = {}
tb = _read_byte(fp)
while tb != CBOR_BREAK:
(subk, sub_len) = _loads_tb(fp, tb, limit, depth, returntags)
bytes_read += 1 + sub_len
(subv, sub_len) = _loads(fp, limit, depth, returntags)
bytes_read += sub_len
ob[subk] = subv
tb = _read_byte(fp)
return (ob, bytes_read + 1)
def _loads_array(fp, limit, depth, returntags, aux, bytes_read):
ob = []
for _ in range(aux):
subob, subpos = _loads(fp, limit, depth, returntags)
bytes_read += subpos
ob.append(subob)
return ob, bytes_read
def _loads_map(fp, limit, depth, returntags, aux, bytes_read):
ob = {}
for _ in range(aux):
subk, subpos = _loads(fp, limit, depth, returntags)
bytes_read += subpos
subv, subpos = _loads(fp, limit, depth, returntags)
bytes_read += subpos
ob[subk] = subv
return ob, bytes_read
def _loads(fp, limit=None, depth=0, returntags=False):
"return (object, bytes read)"
if depth > _MAX_DEPTH:
raise Exception("hit CBOR loads recursion depth limit")
tb = _read_byte(fp)
return _loads_tb(fp, tb, limit, depth, returntags)
def _loads_tb(fp, tb, limit=None, depth=0, returntags=False):
# Some special cases of CBOR_7 best handled by special struct.unpack logic here
if tb == CBOR_FLOAT16:
data = fp.read(2)
hibyte, lowbyte = struct.unpack_from("BB", data, 0)
exp = (hibyte >> 2) & 0x1F
mant = ((hibyte & 0x03) << 8) | lowbyte
if exp == 0:
val = mant * (2.0 ** -24)
elif exp == 31:
val = float('Inf') if mant == 0 else float('NaN')
else:
val = (mant + 1024.0) * (2 ** (exp - 25))
if hibyte & 0x80:
val = -1.0 * val
return (val, 3)
elif tb == CBOR_FLOAT32:
data = fp.read(4)
pf = struct.unpack_from("!f", data, 0)
return (pf[0], 5)
elif tb == CBOR_FLOAT64:
data = fp.read(8)
pf = struct.unpack_from("!d", data, 0)
return (pf[0], 9)
tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb)
if tag == CBOR_UINT:
return (aux, bytes_read)
elif tag == CBOR_NEGINT:
return (-1 - aux, bytes_read)
elif tag == CBOR_BYTES:
ob, subpos = loads_bytes(fp, aux)
return (ob, bytes_read + subpos)
elif tag == CBOR_TEXT:
raw, subpos = loads_bytes(fp, aux, btag=CBOR_TEXT)
ob = raw.decode('utf8')
return (ob, bytes_read + subpos)
elif tag == CBOR_ARRAY:
if aux is None:
return _loads_var_array(fp, limit, depth, returntags, bytes_read)
return _loads_array(fp, limit, depth, returntags, aux, bytes_read)
elif tag == CBOR_MAP:
if aux is None:
return _loads_var_map(fp, limit, depth, returntags, bytes_read)
return _loads_map(fp, limit, depth, returntags, aux, bytes_read)
elif tag == CBOR_TAG:
ob, subpos = _loads(fp, limit, depth + 1, returntags)
bytes_read += subpos
if returntags:
ob = Tag(aux, ob)
else:
ob = tagify(ob, aux)
return ob, bytes_read
elif tag == CBOR_7:
if tb == CBOR_TRUE:
return (True, bytes_read)
if tb == CBOR_FALSE:
return (False, bytes_read)
if tb == CBOR_NULL:
return (None, bytes_read)
if tb == CBOR_UNDEFINED:
return (None, bytes_read)
raise ValueError("unknown cbor tag 7 byte: {:02x}".format(tb))
def loads_bytes(fp, aux, btag=CBOR_BYTES):
if aux is not None:
ob = fp.read(aux)
return (ob, aux)
chunklist = []
total_bytes_read = 0
while True:
tb = fp.read(1)[0]
if tb == CBOR_BREAK:
total_bytes_read += 1
break
tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb)
assert tag == btag, 'variable length value contains unexpected component'
ob = fp.read(aux)
chunklist.append(ob)
total_bytes_read += bytes_read + aux
return (b''.join(chunklist), total_bytes_read)
def _bytes_to_biguint(bs):
out = 0
for ch in bs:
out = out << 8
out = out | ch
return out
def tagify(ob, aux):
if aux == CBOR_TAG_DATE_STRING:
# RFC3339 date string parsing not implemented; return as Tag.
return Tag(aux, ob)
if aux == CBOR_TAG_DATE_ARRAY:
return datetime.datetime.fromtimestamp(ob, tz=datetime.timezone.utc)
if aux == CBOR_TAG_BIGNUM:
return _bytes_to_biguint(ob)
if aux == CBOR_TAG_NEGBIGNUM:
return -1 - _bytes_to_biguint(ob)
if aux == CBOR_TAG_REGEX:
return re.compile(ob)
return Tag(aux, ob)
def encode(obj, sort_keys=False):
return dumps(obj, sort_keys=sort_keys)
def decode(data):
return loads(data)
+1 -1
View File
@@ -31,6 +31,6 @@ setuptools.setup(
entry_points= {
'console_scripts': ['nomadnet=nomadnet.nomadnet:main']
},
install_requires=["rns>=1.2.5", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"],
install_requires=["rns>=1.2.7", "lxmf>=0.9.8", "urwid>=2.6.16", "qrcode"],
python_requires=">=3.8",
)