Compare commits

..

No commits in common. "master" and "1.2.7" have entirely different histories.

41 changed files with 918 additions and 10808 deletions

View file

@ -1,19 +1,10 @@
# Nomad Network - Communicate Freely # Nomad Network - Communicate Freely
> [!WARNING]
> Several hastily launched, incorrect, and in most cases entirely LLM-generated fakes of Nomad Network and associated tools are currently being circulated and marketed. Most of these "projects" violate the open source GPLv3 license that Nomad Network was published under, but claim independent ownership and license grants. Such claims or grants are **not** legally valid, and **not** recognized by the Nomad Network authors and copyright holders.
>
> Any claimed assertion of copyright or grant of license that such projects are making are [void grants](https://reticulum.network/manual/brandolinis.html#void-grants-legal-foundations-of-machine-generated-code). Both the violators themselves, and **all** downstream projects using the infringing derivatives, are **directly** and **personally** liable for the legal consequences.
>
> For more detailed information, see the relevant sections of the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the Reticulum Manual. For software and projects recognized by the Reticulum community, see the [Programs Using Reticulum](https://reticulum.network/manual/software.html) chapter.
>
> Do **not** use these "projects", they are a hazard to the entire ecosystem we have been carefully building over the last ten years.
*This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.* *This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.*
Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy.
![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1a.png) ![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png)
Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers.
@ -21,23 +12,15 @@ Nomad Network is build on [LXMF](https://github.com/markqvist/LXMF) and [Reticul
Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over *extremely* low-bandwidth medium, and has been succesfully used over 300bps radio links. Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over *extremely* low-bandwidth medium, and has been succesfully used over 300bps radio links.
If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android, Windows and macOS. If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android and macOS.
Other Nomad Network clients include:
- [MeshChatX](https://meshchatx.com/)
- [Columba (mobile)](https://github.com/torlando-tech/columba)
- [Ren Browser](https://github.com/Quad4-Software/Ren-Browser)
## Notable Features ## Notable Features
- Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports. - Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports.
- Zero-configuration, minimal-infrastructure mesh communication - Zero-configuration, minimal-infrastructure mesh communication
- Distributed and encrypted message store holds messages for offline users - Distributed and encrypted message store holds messages for offline users
- Connectable nodes that can host pages and files - Connectable nodes that can host pages and files
- Image rendering support in terminals that support it
- Node-side generated pages with PHP, Python, bash or others - Node-side generated pages with PHP, Python, bash or others
- Built-in text-based browser for interacting with contents on nodes - Built-in text-based browser for interacting with contents on nodes
- Built-in RRC client for live, many-to-many chat
- An easy to use and bandwidth efficient markup language for writing pages - An easy to use and bandwidth efficient markup language for writing pages
- Page caching in browser - Page caching in browser
@ -93,10 +76,10 @@ The first time the program is running, you will be presented with the **Guide se
To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/).
If you want to try Nomad Network without building your own physical network, you can connect to the [distributed backbone](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here:
- `9ce92808be498e9e05590ff27cbfdfe4` The rns.recipes forum - `abb3ebcd03cb2388a838e70c001291f9` Dublin Hub Testnet Node
- `a4a5e861626ce97c9aa544d9ecdf6d22` rmap.world - `ea6a715f814bdc37e56f80c34da6ad51` Frankfurt Hub Testnet Node
To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node.
@ -105,12 +88,42 @@ You can install Nomad Network on Android using Termux, but there's a few more co
For a native Android application with a graphical user interface, have a look at [Sideband](https://github.com/markqvist/Sideband). For a native Android application with a graphical user interface, have a look at [Sideband](https://github.com/markqvist/Sideband).
### Docker Images
Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the very latest commit) or the version number (eg `0.2.0`) for a specific release.
```sh
$ docker pull ghcr.io/markqvist/nomadnet:master
# Run nomadnet interactively in a container
$ docker run -it ghcr.io/markqvist/nomadnet:master --textui
# Run nomadnet as a daemon, using config stored on the host machine in specified
# directories, and connect the containers network to the host network (which will
# allow the default AutoInterface to automatically peer with other discovered
# Reticulum instances).
$ docker run -d \
-v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \
-v /local/path/reticulumconfigdir/:/root/.reticulum/ \
--network host
ghcr.io/markqvist/nomadnet:master
# You can also keep the network of the container isolated from the host, but you
# will need to manually configure one or more Reticulum interfaces to reach other
# nodes in a network, by editing the Reticulum configuration file.
$ docker run -d \
-v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \
-v /local/path/reticulumconfigdir/:/root/.reticulum/ \
ghcr.io/markqvist/nomadnet:master
# Send daemon log output to console instead of file
$ docker run -i ghcr.io/markqvist/nomadnet:master --daemon --console
```
## Tools & Extensions ## Tools & Extensions
Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist: Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist:
- [Retipedia](https://github.com/RFnexus/Retipedia)
- [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum)) - [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum))
- [NomadForecast](https://github.com/faragher/NomadForecast) - [NomadForecast](https://github.com/faragher/NomadForecast)
- [micron-blog](https://github.com/randogoth/micron-blog) - [micron-blog](https://github.com/randogoth/micron-blog)
@ -124,6 +137,9 @@ Nomad Network is a very flexible and extensible platform, and a variety of commu
- [POPR](https://github.com/faragher/POPR) - [POPR](https://github.com/faragher/POPR)
- [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools) - [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools)
## Help & Discussion
For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network).
## Support Nomad Network ## Support Nomad Network
You can help support the continued development of open, free and private communications systems by donating via one of the following channels: You can help support the continued development of open, free and private communications systems by donating via one of the following channels:
@ -145,12 +161,32 @@ You can help support the continued development of open, free and private communi
- Ko-Fi: https://ko-fi.com/markqvist - Ko-Fi: https://ko-fi.com/markqvist
## Development Roadmap
- New major features
- Network-wide propagated bulletins and discussion threads
- Collaborative maps and geospatial information sharing
- Minor improvements and fixes
- Link status (RSSI and SNR) in conversation or conv list
- Ctrl-M shorcut for jumping to menu
- Share node with other users / send node info to user
- Fix internal editor failing on some OSes with no "editor" alias
- Possibly add a required-width header
- Improve browser handling of remote link close
- Better navigation handling when requests fail (also because of closed links)
- Retry failed messages mechanism
- Re-arrange buttons to be more consistent
- Term compatibility notice in readme
- Selected icon in conversation list
- Possibly a Search Local Nodes function
- Possibly add via entry in node info box, next to distance
## Caveat Emptor ## Caveat Emptor
Nomad Network is beta software, and should be considered as such. Use at your own risk and responsibility. Nomad Network is beta software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch.
## Screenshots ## Screenshots
![Screenshot 1](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1a.png) ![Screenshot 1](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png)
![Screenshot 2](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/2.png) ![Screenshot 2](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/2.png)
@ -158,6 +194,4 @@ Nomad Network is beta software, and should be considered as such. Use at your ow
![Screenshot 4](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/4.png) ![Screenshot 4](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/4.png)
![Screenshot 5](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/5.png) ![Screenshot 5](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/5.png)
![Screenshot 6](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/6.png)

View file

@ -16,7 +16,6 @@ If you'd rather want to use an LXMF client with a graphical user interface, you
• Zero-configuration, minimal-infrastructure mesh communication • Zero-configuration, minimal-infrastructure mesh communication
• Distributed and encrypted message store holds messages for offline users • Distributed and encrypted message store holds messages for offline users
• Connectable nodes that can host pages and files • Connectable nodes that can host pages and files
• Image rendering support in terminals that support it
• Node-side generated pages with PHP, Python, bash or others • Node-side generated pages with PHP, Python, bash or others
• Built-in text-based browser for interacting with contents on nodes • Built-in text-based browser for interacting with contents on nodes
• Built-in RRC client for live, many-to-many chat • Built-in RRC client for live, many-to-many chat
@ -120,4 +119,4 @@ For this to be possible, I need your help. Please support the continued developm
>> Caveat Emptor >> Caveat Emptor
Nomad Network is experimental software, and should be considered as such. Use at your own risk and responsibility. Nomad Network is experimental software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it `!has not`! been externally security audited, and there could very well be privacy-breaking bugs. Use at your own risk and responsibility.

View file

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

View file

@ -72,24 +72,6 @@ class Conversation:
ingested_path = lxmessage.write_to_directory(conversation_path) 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: try:
ConversationMessage.extract_attachments_from_lxm(lxmessage, app) ConversationMessage.extract_attachments_from_lxm(lxmessage, app)
except Exception as e: except Exception as e:
@ -222,7 +204,7 @@ class Conversation:
self.__changed_callback = None self.__changed_callback = None
if not self.ensure_send_destination(): if not self.__resolve_send_destination():
RNS.Transport.request_path(bytes.fromhex(source_hash)) RNS.Transport.request_path(bytes.fromhex(source_hash))
if initiator: if initiator:
@ -307,7 +289,7 @@ class Conversation:
def register_changed_callback(self, callback): def register_changed_callback(self, callback):
self.__changed_callback = callback self.__changed_callback = callback
def ensure_send_destination(self): def __resolve_send_destination(self):
if self.send_destination == None: if self.send_destination == None:
self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash))
@ -318,16 +300,13 @@ class Conversation:
return self.send_destination != None return self.send_destination != None
def send(self, content="", title="", fields=None): def send(self, content="", title="", fields=None):
if self.ensure_send_destination(): if self.__resolve_send_destination():
dest = self.send_destination dest = self.send_destination
source = self.app.lxmf_destination source = self.app.lxmf_destination
desired_method = LXMF.LXMessage.DIRECT desired_method = LXMF.LXMessage.DIRECT
preferred = self.app.directory.preferred_delivery(dest.hash) if self.app.directory.preferred_delivery(dest.hash) == DirectoryEntry.PROPAGATED:
if preferred == DirectoryEntry.PROPAGATED:
if self.app.message_router.get_outbound_propagation_node() != None: if self.app.message_router.get_outbound_propagation_node() != None:
desired_method = LXMF.LXMessage.PROPAGATED desired_method = LXMF.LXMessage.PROPAGATED
elif preferred == DirectoryEntry.OPPORTUNISTIC:
desired_method = LXMF.LXMessage.OPPORTUNISTIC
else: else:
if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None: 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) RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG)
@ -356,7 +335,7 @@ class Conversation:
return False return False
def paper_output(self, content="", title="", mode="print_qr"): def paper_output(self, content="", title="", mode="print_qr"):
if self.ensure_send_destination(): if self.__resolve_send_destination():
try: try:
dest = self.send_destination dest = self.send_destination
source = self.app.lxmf_destination source = self.app.lxmf_destination
@ -461,9 +440,6 @@ class ConversationMessage:
self._cached_method = None self._cached_method = None
self._cached_has_attachments = None self._cached_has_attachments = None
self._cached_attachment_names = 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 self.sort_timestamp = os.path.getmtime(file_path) if os.path.isfile(file_path) else 0
@ -507,12 +483,6 @@ class ConversationMessage:
if self._cached_signature_validated is None: if self._cached_signature_validated is None:
self._cached_signature_validated = self.lxm.signature_validated self._cached_signature_validated = self.lxm.signature_validated
self._cached_method = self.lxm.method 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"): if hasattr(self.lxm, "get_fields"):
_fields = self.lxm.get_fields() _fields = self.lxm.get_fields()
if _fields and isinstance(_fields, dict) and LXMF.FIELD_RENDERER in _fields: if _fields and isinstance(_fields, dict) and LXMF.FIELD_RENDERER in _fields:
@ -633,22 +603,6 @@ class ConversationMessage:
self.load() self.load()
return self._cached_state 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): def get_transport_encryption(self):
if self._cached_transport_encryption is not None: if self._cached_transport_encryption is not None:
return self._cached_transport_encryption return self._cached_transport_encryption
@ -873,7 +827,7 @@ class ConversationMessage:
name = str(name) if name is not None else "" name = str(name) if name is not None else ""
except Exception: except Exception:
name = "" name = ""
name = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", name) name = re.sub(r"[\x00-\x1f\x7f]", "", name)
parts = re.split(r"[/\\]", name) parts = re.split(r"[/\\]", name)
name = parts[-1] if parts else "" name = parts[-1] if parts else ""
if ":" in name: if ":" in name:
@ -1010,9 +964,6 @@ class ConversationMessage:
"renderer": None if self._cached_renderer is ConversationMessage._RENDERER_UNSET else self._cached_renderer, "renderer": None if self._cached_renderer is ConversationMessage._RENDERER_UNSET else self._cached_renderer,
"has_attachments": self._cached_has_attachments, "has_attachments": self._cached_has_attachments,
"attachment_names": self._cached_attachment_names, "attachment_names": self._cached_attachment_names,
"rssi": self._cached_rssi,
"snr": self._cached_snr,
"q": self._cached_q,
} }
def restore_from_index(self, entry): def restore_from_index(self, entry):
@ -1030,9 +981,6 @@ class ConversationMessage:
self._cached_renderer = entry.get("renderer", ConversationMessage._RENDERER_UNSET) self._cached_renderer = entry.get("renderer", ConversationMessage._RENDERER_UNSET)
self._cached_has_attachments = entry.get("has_attachments") self._cached_has_attachments = entry.get("has_attachments")
self._cached_attachment_names = entry.get("attachment_names") 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 @staticmethod
def read_index(conversation_path): def read_index(conversation_path):

View file

@ -6,7 +6,7 @@ import nomadnet
import threading import threading
import RNS.vendor.umsgpack as msgpack import RNS.vendor.umsgpack as msgpack
from LXMF import pn_announce_data_is_valid, PN_META_IMPL_NAME, PN_META_VERSION from LXMF import pn_announce_data_is_valid
from nomadnet.util import strip_modifiers from nomadnet.util import strip_modifiers
from nomadnet.util import sanitize_name from nomadnet.util import sanitize_name
@ -21,10 +21,7 @@ class PNAnnounceHandler:
data = msgpack.unpackb(app_data) data = msgpack.unpackb(app_data)
if data[2] == True: if data[2] == True:
metadata = data[6] RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG)
if not PN_META_IMPL_NAME in metadata or not PN_META_VERSION in metadata: impl_str = ""
else: impl_str = f" running {metadata[PN_META_IMPL_NAME]} {metadata[PN_META_VERSION]}"
RNS.log(f"Received active propagation node announce from {RNS.prettyhexrep(destination_hash)}{impl_str}", RNS.LOG_DEBUG)
associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity)
associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity) associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity)
@ -198,11 +195,11 @@ class Directory:
self._node_announces.insert(0, (timestamp, source_hash, app_data, "node")) self._node_announces.insert(0, (timestamp, source_hash, app_data, "node"))
self._clean_node_announces() self._clean_node_announces()
# if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED:
# existing_entry = self.find(source_hash) existing_entry = self.find(source_hash)
# if not existing_entry: if not existing_entry:
# node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True)
# self.remember(node_entry) self.remember(node_entry)
if hasattr(self.app.ui, "main_display"): if hasattr(self.app.ui, "main_display"):
self.app.ui.main_display.sub_displays.network_display.directory_change_callback() self.app.ui.main_display.sub_displays.network_display.directory_change_callback()
@ -415,9 +412,8 @@ class DirectoryEntry:
UNKNOWN = 0x02 UNKNOWN = 0x02
TRUSTED = 0xFF TRUSTED = 0xFF
DIRECT = 0x01 DIRECT = 0x01
PROPAGATED = 0x02 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): 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: if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8:

View file

@ -1,240 +0,0 @@
import os
import threading
import RNS
import RNS.vendor.umsgpack as msgpack
from nomadnet.util import write_config_atomic
class InterfaceProfiles:
VERSION = 1
def __init__(self, app):
self.app = app
self.path = os.path.join(app.storagepath, "interface_profiles.msgpack")
self._lock = threading.Lock()
self.profiles = []
self.load()
def load(self):
self.profiles = []
self.default_members = None
try:
if not os.path.isfile(self.path):
return
with open(self.path, "rb") as f:
data = msgpack.unpackb(f.read())
if not isinstance(data, dict):
return
clean = []
seen = set()
for p in data.get("profiles") or []:
if not isinstance(p, dict):
continue
pid = str(p.get("id") or "")
if not pid or pid in seen:
continue
members = [str(m) for m in (p.get("members") or []) if isinstance(m, (str, bytes))]
clean.append({"id": pid, "name": str(p.get("name") or ""), "members": members})
seen.add(pid)
self.profiles = clean
dm = data.get("default_members")
self.default_members = [str(m) for m in dm] if isinstance(dm, list) else None
except Exception as e:
RNS.log("Could not load interface profiles: "+str(e), RNS.LOG_ERROR)
self.profiles = []
self.default_members = None
def save(self):
with self._lock:
tmp = self.path + ".tmp"
try:
payload = {
"version": InterfaceProfiles.VERSION,
"profiles": [{"id": p["id"], "name": p["name"], "members": list(p["members"])} for p in self.profiles],
"default_members": self.default_members,
}
with open(tmp, "wb") as f:
f.write(msgpack.packb(payload))
f.flush()
try: os.fsync(f.fileno())
except Exception: pass
os.replace(tmp, self.path)
except Exception as e:
RNS.log("Could not save interface profiles: "+str(e), RNS.LOG_ERROR)
try: os.remove(tmp)
except Exception: pass
def _interfaces(self):
try:
return self.app.rns.config["interfaces"]
except Exception:
return {}
def interface_names(self):
try:
return list(self._interfaces().keys())
except Exception:
return []
def _is_enabled(self, iface):
return str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \
str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0')
def enabled_set(self):
interfaces = self._interfaces()
result = set()
for name in interfaces:
try:
if self._is_enabled(interfaces[name]):
result.add(name)
except Exception:
pass
return result
def get(self, pid):
for p in self.profiles:
if p["id"] == pid:
return p
return None
def members_existing(self, p):
existing = set(self.interface_names())
return set(m for m in p["members"] if m in existing)
def active_profile_id(self):
enabled = self.enabled_set()
for p in self.profiles:
if self.members_existing(p) == enabled:
return p["id"]
return None
def profiles_for(self, iface_name):
return [p for p in self.profiles if iface_name in p["members"]]
def label_for(self, iface_name):
names = [p["name"] or p["id"] for p in self.profiles_for(iface_name)]
return ", ".join(names) if names else None
def member_count(self, p):
return len(self.members_existing(p))
def _new_id(self):
while True:
pid = os.urandom(4).hex()
if not self.get(pid):
return pid
def create(self, name):
pid = self._new_id()
self.profiles.append({"id": pid, "name": name or "Profile", "members": []})
self.save()
return pid
def save_current_as_profile(self, name):
pid = self.create(name)
self.set_members(pid, sorted(self.enabled_set()))
return pid
def rename(self, pid, name):
p = self.get(pid)
if p is not None and name:
p["name"] = name
self.save()
def delete(self, pid):
self.profiles = [p for p in self.profiles if p["id"] != pid]
self.save()
def set_members(self, pid, members):
p = self.get(pid)
if p is not None:
valid = set(self.interface_names())
seen = set()
ordered = []
for m in members:
if m in valid and m not in seen:
ordered.append(m)
seen.add(m)
p["members"] = ordered
self.save()
def set_interface_profiles(self, iface_name, profile_ids):
pset = set(profile_ids)
for p in self.profiles:
if p["id"] in pset:
if iface_name not in p["members"]:
p["members"].append(iface_name)
elif iface_name in p["members"]:
p["members"] = [m for m in p["members"] if m != iface_name]
self.save()
def _apply_members(self, members):
existing = set(self.interface_names())
members = set(m for m in members if m in existing)
interfaces = self._interfaces()
try:
for name in interfaces:
value = "true" if name in members else "false"
interfaces[name]["interface_enabled"] = value
if "enabled" in interfaces[name]:
interfaces[name]["enabled"] = value
write_config_atomic(self.app.rns.config)
return True
except Exception as e:
RNS.log("Could not apply interface profile: "+str(e), RNS.LOG_ERROR)
return False
def select_profile(self, pid):
p = self.get(pid)
if p is None:
return False
return self._apply_members(self.members_existing(p))
def select_default(self):
if self.default_members is None:
return False
return self._apply_members(set(self.default_members))
def update_default_if_custom(self):
# Remember the live enabled-set as "Default" whenever it matches no named
# profile -- i.e. it's the user's manual setup. Only relevant once profiles exist.
if not self.profiles:
return
enabled = self.enabled_set()
matches_named = any(self.members_existing(p) == enabled for p in self.profiles)
if matches_named and self.default_members is not None:
return
new_default = sorted(enabled)
if self.default_members != new_default:
self.default_members = new_default
self.save()
def remove_interface(self, iface_name):
changed = False
for p in self.profiles:
if iface_name in p["members"]:
p["members"] = [m for m in p["members"] if m != iface_name]
changed = True
if changed:
self.save()
def rename_interface(self, old_name, new_name):
changed = False
for p in self.profiles:
if old_name in p["members"]:
p["members"] = [new_name if m == old_name else m for m in p["members"]]
changed = True
if changed:
self.save()
def prune(self):
valid = set(self.interface_names())
changed = False
for p in self.profiles:
pruned = [m for m in p["members"] if m in valid]
if len(pruned) != len(p["members"]):
p["members"] = pruned
changed = True
if changed:
self.save()

View file

@ -4,16 +4,11 @@ import sys
import RNS import RNS
import time import time
import threading import threading
import hashlib
import shutil
import subprocess import subprocess
import RNS.vendor.umsgpack as msgpack import RNS.vendor.umsgpack as msgpack
from RNS.Utilities.rngit.media import convert_file_to_webp
class Node: class Node:
JOB_INTERVAL = 5 JOB_INTERVAL = 5
SETTINGS_FLUSH_INTERVAL = 60
START_ANNOUNCE_DELAY = 6 START_ANNOUNCE_DELAY = 6
def __init__(self, app): def __init__(self, app):
@ -29,15 +24,11 @@ class Node:
self.file_refresh_interval = self.app.file_refresh_interval self.file_refresh_interval = self.app.file_refresh_interval
self.job_interval = Node.JOB_INTERVAL self.job_interval = Node.JOB_INTERVAL
self.should_run_jobs = True self.should_run_jobs = True
self.settings_dirty = False
self.conversion_cache_lock = threading.Lock()
self.last_settings_flush = time.time()
self.app_data = None self.app_data = None
self.name = self.app.node_name self.name = self.app.node_name
self.register_pages() self.register_pages()
self.register_files() self.register_files()
self.register_media()
self.destination.set_link_established_callback(self.peer_connected) self.destination.set_link_established_callback(self.peer_connected)
@ -79,10 +70,6 @@ class Node:
response_generator = self.serve_page, response_generator = self.serve_page,
allow = RNS.Destination.ALLOW_ALL) allow = RNS.Destination.ALLOW_ALL)
def register_media(self):
self.destination.register_request_handler("/media", response_generator = self.serve_media,
allow = RNS.Destination.ALLOW_ALL)
def register_files(self): def register_files(self):
# TODO: Deregister previously registered files # TODO: Deregister previously registered files
# that no longer exist. # that no longer exist.
@ -113,8 +100,7 @@ class Node:
directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."] directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."]
for file in files: for file in files:
if not file.endswith(".allowed"): self.servedfiles.append(base_path+"/"+file)
self.servedfiles.append(base_path+"/"+file)
for directory in directories: for directory in directories:
self.scan_files(base_path+"/"+directory) self.scan_files(base_path+"/"+directory)
@ -123,13 +109,51 @@ class Node:
RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE)
try: try:
self.app.peer_settings["served_page_requests"] += 1 self.app.peer_settings["served_page_requests"] += 1
self.settings_dirty = True self.app.save_peer_settings()
except Exception as e: except Exception as e:
RNS.log("Could not increase served page request count", RNS.LOG_ERROR) RNS.log("Could not increase served page request count", RNS.LOG_ERROR)
file_path = path.replace("/page", self.app.pagespath, 1) file_path = path.replace("/page", self.app.pagespath, 1)
request_allowed = self.request_allowed(file_path, remote_identity)
allowed_path = file_path+".allowed"
request_allowed = False
if os.path.isfile(allowed_path):
allowed_list = []
try:
if os.access(allowed_path, os.X_OK):
allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE)
allowed_input = allowed_result.stdout
else:
with open(allowed_path, "rb") as fh:
allowed_input = fh.read()
allowed_hash_strs = allowed_input.splitlines()
for hash_str in allowed_hash_strs:
if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2:
try:
allowed_hash = bytes.fromhex(hash_str.decode("utf-8"))
allowed_list.append(allowed_hash)
except Exception as e:
RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG)
RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG)
except Exception as e:
RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR)
if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list:
request_allowed = True
else:
request_allowed = False
RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE)
else:
request_allowed = True
try: try:
if request_allowed: if request_allowed:
@ -163,171 +187,18 @@ class Node:
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
return None return None
NATIVE_MEDIA_EXTS = [".webp"] # TODO: Improve file handling, this will be slow for large files
MEDIA_EXTS = [".webp", ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"]
CONVERSION_QUALITY = 85
CONVERSION_MAX_DIMENSION = 1200
CONVERSION_CACHE_MAX_FILES = 256
CONVERSION_CACHE_MAX_BYTES = 192*1024*1024
def serve_media(self, path, data, request_id, link_id, remote_identity, requested_at):
if not type(data) == dict: return None
if not "path" in data: return None
if not "key" in data: return None
jail = os.path.normpath(self.app.pagespath)
media_path = os.path.join(self.app.pagespath, data["path"].replace("/media/", "").lstrip("/"))
media_path = os.path.normpath(media_path)
base_name = os.path.basename(media_path)
base_ext = os.path.splitext(base_name)[-1]
if not base_ext.lower() in self.MEDIA_EXTS:
RNS.log(f"Invalid media request type: {media_path}, must be in {self.MEDIA_EXTS}", RNS.LOG_DEBUG)
return False
if not media_path.startswith(jail+os.sep):
RNS.log(f"Invalid media request path: {media_path}", RNS.LOG_DEBUG)
return False
if len(media_path) > 512:
RNS.log(f"Invalid media request path length: {len(media_path)}", RNS.LOG_DEBUG)
return False
RNS.log(f"Media request {RNS.prettyhexrep(request_id)} for: {media_path}", RNS.LOG_VERBOSE)
try:
self.app.peer_settings["served_media_requests"] += 1
self.settings_dirty = True
except Exception as e: RNS.log("Could not increase served page request count", RNS.LOG_ERROR)
request_allowed = self.request_allowed(media_path, remote_identity)
try:
if request_allowed:
if not base_ext.lower() in self.NATIVE_MEDIA_EXTS:
converted_path = self.convert_media_to_webp(media_path)
if converted_path is False:
RNS.log(f"Could not convert {media_path} to WebP", RNS.LOG_DEBUG)
return False
file_path = converted_path
file_name = os.path.splitext(base_name)[0]+".webp"
else:
file_path = media_path
file_name = base_name
RNS.log(f"Serving media: {file_path}", RNS.LOG_VERBOSE)
return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}]
else:
RNS.log("Request denied", RNS.LOG_VERBOSE)
return False
except Exception as e:
RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(media_path), RNS.LOG_ERROR)
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
return False
def convert_media_to_webp(self, media_path, quality = CONVERSION_QUALITY, max_dimension = CONVERSION_MAX_DIMENSION):
try:
hasher = hashlib.sha256()
with open(media_path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""): hasher.update(chunk)
source_hash = hasher.hexdigest()
except Exception as e:
RNS.log(f"Could not read media source for conversion: {media_path}", RNS.LOG_WARNING)
return False
if quality == None: quality = Node.CONVERSION_QUALITY
if max_dimension == None: max_dimension = Node.CONVERSION_MAX_DIMENSION
cache_name = f"{source_hash}.q{quality}.d{max_dimension if max_dimension is not None else 0}.webp"
cache_path = os.path.join(self.app.convcachepath, cache_name)
return self.__cached_conversion(media_path, cache_path, quality, max_dimension)
def __cached_conversion(self, media_path, cache_path, quality, max_dimension):
with self.conversion_cache_lock:
if os.path.isfile(cache_path):
RNS.log(f"Using cached media conversion for: {media_path}", RNS.LOG_VERBOSE)
try: os.utime(cache_path, None)
except Exception: pass
return cache_path
try:
os.makedirs(self.app.convcachepath, exist_ok=True)
converted_path = convert_file_to_webp(media_path, quality=quality, max_dimension=max_dimension)
if converted_path is False: return False
shutil.move(converted_path, cache_path)
self._prune_conversion_cache()
RNS.log(f"Converted media: {media_path} -> {cache_path}", RNS.LOG_VERBOSE)
return cache_path
except Exception as e:
RNS.log(f"Error during media conversion of {media_path}: {e}", RNS.LOG_WARNING)
return False
def _prune_conversion_cache(self):
try:
entries = []
for name in os.listdir(self.app.convcachepath):
path = os.path.join(self.app.convcachepath, name)
try:
st = os.stat(path)
if st.st_size > 0:
entries.append((st.st_mtime, st.st_size, path))
except OSError: pass
total = sum(e[1] for e in entries)
entries.sort()
while len(entries) > Node.CONVERSION_CACHE_MAX_FILES or total > Node.CONVERSION_CACHE_MAX_BYTES:
_, size, path = entries.pop(0)
total -= size
try: os.unlink(path)
except OSError: pass
except OSError: pass
def request_allowed(self, file_path, remote_identity):
if file_path.lower().endswith(".allowed"): return False
allowed_path = file_path+".allowed"
if not os.path.isfile(allowed_path): return True
allowed_list = []
try:
if os.access(allowed_path, os.X_OK):
allowed_input = subprocess.run([allowed_path], stdout=subprocess.PIPE).stdout
else:
with open(allowed_path, "rb") as fh:
allowed_input = fh.read()
for hash_str in allowed_input.splitlines():
if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2:
try:
allowed_list.append(bytes.fromhex(hash_str.decode("utf-8")))
except Exception as e:
RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG)
RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG)
except Exception as e:
RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR)
if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list:
return True
RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE)
return False
def serve_file(self, path, data, request_id, remote_identity, requested_at): def serve_file(self, path, data, request_id, remote_identity, requested_at):
RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE)
try: try:
self.app.peer_settings["served_file_requests"] += 1 self.app.peer_settings["served_file_requests"] += 1
self.settings_dirty = True self.app.save_peer_settings()
except Exception as e: except Exception as e:
RNS.log("Could not increase served file request count", RNS.LOG_ERROR) RNS.log("Could not increase served file request count", RNS.LOG_ERROR)
file_path = path.replace("/file", self.app.filespath, 1) file_path = path.replace("/file", self.app.filespath, 1)
file_name = os.path.basename(file_path) file_name = path.replace("/file/", "", 1)
if not self.request_allowed(file_path, remote_identity):
RNS.log("Request denied", RNS.LOG_VERBOSE)
return DEFAULT_NOTALLOWED.encode("utf-8")
try: try:
RNS.log("Serving file: "+file_path, RNS.LOG_VERBOSE) RNS.log("Serving file: "+file_path, RNS.LOG_VERBOSE)
return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}] return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}]
@ -346,7 +217,7 @@ class Node:
self.last_announce = time.time() self.last_announce = time.time()
self.app.peer_settings["node_last_announce"] = self.last_announce self.app.peer_settings["node_last_announce"] = self.last_announce
self.destination.announce(app_data=self.app_data) self.destination.announce(app_data=self.app_data)
if not self.app.disable_propagation: self.app.message_router.announce_propagation_node() self.app.message_router.announce_propagation_node()
def __jobs(self): def __jobs(self):
while self.should_run_jobs: while self.should_run_jobs:
@ -365,19 +236,13 @@ class Node:
self.register_files() self.register_files()
self.last_file_refresh = time.time() self.last_file_refresh = time.time()
if self.settings_dirty and now > self.last_settings_flush + Node.SETTINGS_FLUSH_INTERVAL:
self.settings_dirty = False
self.last_settings_flush = now
try: self.app.save_peer_settings()
except Exception as e: RNS.log("Could not save peer settings: "+str(e), RNS.LOG_ERROR)
time.sleep(self.job_interval) time.sleep(self.job_interval)
def peer_connected(self, link): def peer_connected(self, link):
RNS.log("Peer connected to "+str(self.destination), RNS.LOG_VERBOSE) RNS.log("Peer connected to "+str(self.destination), RNS.LOG_VERBOSE)
try: try:
self.app.peer_settings["node_connects"] += 1 self.app.peer_settings["node_connects"] += 1
self.settings_dirty = True self.app.save_peer_settings()
except Exception as e: except Exception as e:
RNS.log("Could not increase node connection count", RNS.LOG_ERROR) RNS.log("Could not increase node connection count", RNS.LOG_ERROR)

View file

@ -8,7 +8,6 @@ import threading
import traceback import traceback
import subprocess import subprocess
import contextlib import contextlib
import tempfile
import RNS import RNS
import LXMF import LXMF
@ -112,9 +111,6 @@ class NomadNetworkApp:
self.storagepath = self.configdir+"/storage" self.storagepath = self.configdir+"/storage"
self.identitypath = self.configdir+"/storage/identity" self.identitypath = self.configdir+"/storage/identity"
self.cachepath = self.configdir+"/storage/cache" self.cachepath = self.configdir+"/storage/cache"
self.imagecachepath = self.configdir+"/storage/cache/images/browser"
self.convcachepath = self.configdir+"/storage/cache/images/converted_node"
self.dispcachepath = self.configdir+"/storage/cache/images/converted_disp"
self.resourcepath = self.configdir+"/storage/resources" self.resourcepath = self.configdir+"/storage/resources"
self.conversationpath = self.configdir+"/storage/conversations" self.conversationpath = self.configdir+"/storage/conversations"
self.directorypath = self.configdir+"/storage/directory" self.directorypath = self.configdir+"/storage/directory"
@ -124,6 +120,7 @@ class NomadNetworkApp:
self.pagespath = self.configdir+"/storage/pages" self.pagespath = self.configdir+"/storage/pages"
self.filespath = self.configdir+"/storage/files" self.filespath = self.configdir+"/storage/files"
self.cachepath = self.configdir+"/storage/cache"
self.examplespath = self.configdir+"/examples" self.examplespath = self.configdir+"/examples"
self.downloads_path = os.path.expanduser("~/Downloads") self.downloads_path = os.path.expanduser("~/Downloads")
@ -132,7 +129,6 @@ class NomadNetworkApp:
self.firstrun = False self.firstrun = False
self.should_run_jobs = True self.should_run_jobs = True
self.job_interval = 5 self.job_interval = 5
self.peer_settings_lock = threading.Lock()
self.defer_jobs = 90 self.defer_jobs = 90
self.page_refresh_interval = 0 self.page_refresh_interval = 0
self.file_refresh_interval = 0 self.file_refresh_interval = 0
@ -159,7 +155,6 @@ class NomadNetworkApp:
self.rrc_history_per_room_cap = 500 self.rrc_history_per_room_cap = 500
self.rrc_filter_loaded_history = True self.rrc_filter_loaded_history = True
self.rrc_max_accepted_resource_size = 256*1024
self.rrc_ephemeral_notices = 600 self.rrc_ephemeral_notices = 600
self.rrc_nick_colors = True self.rrc_nick_colors = True
self.rrc_nick_colors_theme = None self.rrc_nick_colors_theme = None
@ -178,15 +173,6 @@ class NomadNetworkApp:
if not os.path.isdir(self.cachepath): if not os.path.isdir(self.cachepath):
os.makedirs(self.cachepath) os.makedirs(self.cachepath)
if not os.path.isdir(self.imagecachepath):
os.makedirs(self.imagecachepath)
if not os.path.isdir(self.convcachepath):
os.makedirs(self.convcachepath)
if not os.path.isdir(self.dispcachepath):
os.makedirs(self.dispcachepath)
if not os.path.isdir(self.resourcepath): if not os.path.isdir(self.resourcepath):
os.makedirs(self.resourcepath) os.makedirs(self.resourcepath)
@ -262,13 +248,11 @@ class NomadNetworkApp:
RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR)
nomadnet.panic() nomadnet.panic()
self.peer_settings = None if os.path.isfile(self.peersettingspath):
self.peer_settings_notice = None
for path in (self.peersettingspath, self.peersettingspath+".bak"):
if self.peer_settings != None or not os.path.isfile(path): continue
try: try:
with open(path, "rb") as file: file = open(self.peersettingspath, "rb")
self.peer_settings = msgpack.unpackb(file.read()) self.peer_settings = msgpack.unpackb(file.read())
file.close()
if not "node_last_announce" in self.peer_settings: if not "node_last_announce" in self.peer_settings:
self.peer_settings["node_last_announce"] = None self.peer_settings["node_last_announce"] = None
@ -285,38 +269,27 @@ class NomadNetworkApp:
if not "served_page_requests" in self.peer_settings: if not "served_page_requests" in self.peer_settings:
self.peer_settings["served_page_requests"] = 0 self.peer_settings["served_page_requests"] = 0
if not "served_media_requests" in self.peer_settings:
self.peer_settings["served_media_requests"] = 0
if not "served_file_requests" in self.peer_settings: if not "served_file_requests" in self.peer_settings:
self.peer_settings["served_file_requests"] = 0 self.peer_settings["served_file_requests"] = 0
if not "quit_confirm" in self.peer_settings:
self.peer_settings["quit_confirm"] = True
self.peer_settings["announce_interval"] = self.announce_interval self.peer_settings["announce_interval"] = self.announce_interval
if path != self.peersettingspath:
self.peer_settings_notice = "Peer settings were restored from the last backup.\nCheck your display name and propagation node."
except Exception as e: except Exception as e:
RNS.log(f"Could not load local peer settings from {path}", RNS.LOG_ERROR) RNS.logdest = RNS.LOG_STDOUT
RNS.log(f"Could not load local peer settings from {self.peersettingspath}", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR) RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
RNS.log(f"Moving the corrupt file to {path}.corrupt", RNS.LOG_ERROR) RNS.log(f"This likely means that the peer settings file has become corrupt.", RNS.LOG_ERROR)
try: os.replace(path, path+".corrupt") RNS.log(f"You can try deleting the file at {self.peersettingspath} and restarting nomadnet.", RNS.LOG_ERROR)
except Exception: pass nomadnet.panic()
self.peer_settings = None else:
self.peer_settings_notice = "Peer settings were corrupt and have been reset.\nCheck your display name and propagation node."
if self.peer_settings == None:
try: try:
RNS.log("Creating new peer settings file...") RNS.log("No peer settings file found, creating new...")
self.peer_settings = { self.peer_settings = {
"display_name": "Anonymous Peer", "display_name": "Anonymous Peer",
"announce_interval": self.announce_interval, "announce_interval": self.announce_interval,
"last_announce": None, "last_announce": None,
"node_last_announce": None, "node_last_announce": None,
"propagation_node": None, "propagation_node": None,
"quit_confirm": True,
"last_lxmf_sync": 0, "last_lxmf_sync": 0,
"node_connects": 0, "node_connects": 0,
"served_page_requests": 0, "served_page_requests": 0,
@ -357,9 +330,6 @@ class NomadNetworkApp:
self.rrc = RRCManager(self) self.rrc = RRCManager(self)
self.rrc.load() self.rrc.load()
from nomadnet.InterfaceProfiles import InterfaceProfiles
self.interface_profiles = InterfaceProfiles(self)
static_peers = [] static_peers = []
for static_peer in self.static_peers: for static_peer in self.static_peers:
try: try:
@ -656,10 +626,9 @@ class NomadNetworkApp:
if selected_node == None: if selected_node == None:
RNS.log("Could not autoselect a propagation node! LXMF propagation will not be available until a trusted node announces on the network, or a propagation node is manually selected.", RNS.LOG_WARNING) RNS.log("Could not autoselect a propagation node! LXMF propagation will not be available until a trusted node announces on the network, or a propagation node is manually selected.", RNS.LOG_WARNING)
else: else:
if self.message_router.get_outbound_propagation_node() != selected_node: pn_name_str = ""
pn_name_str = "" RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_DEBUG)
RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_DEBUG) self.message_router.set_outbound_propagation_node(selected_node)
self.message_router.set_outbound_propagation_node(selected_node)
def get_user_selected_propagation_node(self): def get_user_selected_propagation_node(self):
if "propagation_node" in self.peer_settings: if "propagation_node" in self.peer_settings:
@ -676,16 +645,9 @@ class NomadNetworkApp:
return self.message_router.get_outbound_propagation_node() return self.message_router.get_outbound_propagation_node()
def save_peer_settings(self): def save_peer_settings(self):
with self.peer_settings_lock: tmp_path = f"{self.peersettingspath}.tmp"
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.peersettingspath), prefix=".peersettings.") with open(tmp_path, "wb") as file: file.write(msgpack.packb(self.peer_settings))
try: os.replace(tmp_path, self.peersettingspath)
with os.fdopen(fd, "wb") as file: file.write(msgpack.packb(self.peer_settings))
if os.path.isfile(self.peersettingspath): os.replace(self.peersettingspath, self.peersettingspath+".bak")
os.replace(tmp_path, self.peersettingspath)
except Exception:
try: os.unlink(tmp_path)
except Exception: pass
raise
def lxmf_delivery(self, message): def lxmf_delivery(self, message):
time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp))
@ -1003,20 +965,6 @@ class NomadNetworkApp:
else: else:
raise ValueError("The selected Text UI color mode is invalid") raise ValueError("The selected Text UI color mode is invalid")
if not "image_loading" in self.config["textui"]:
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO
else:
if self.config["textui"]["image_loading"].lower() == "never":
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_NEVER
elif self.config["textui"]["image_loading"].lower() == "manual":
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_MANUAL
elif self.config["textui"]["image_loading"].lower() == "auto":
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO
elif self.config["textui"]["image_loading"].lower() == "always":
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_ALWAYS
else:
raise ValueError("The selected image loading mode is invalid")
if not "theme" in self.config["textui"]: if not "theme" in self.config["textui"]:
self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_DARK self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_DARK
else: else:
@ -1033,13 +981,6 @@ class NomadNetworkApp:
if value == "web": if value == "web":
self.uimode = nomadnet.ui.UI_WEB self.uimode = nomadnet.ui.UI_WEB
if not "utilities" in self.config:
self.config["utilities"] = {}
if not "enable_utilities" in self.config["utilities"]:
self.config["utilities"]["enable_utilities"] = False
else:
self.config["utilities"]["enable_utilities"] = self.config["utilities"].as_bool("enable_utilities")
if "rrc" in self.config: if "rrc" in self.config:
for option in self.config["rrc"]: for option in self.config["rrc"]:
if option == "history_per_room_cap": if option == "history_per_room_cap":
@ -1053,12 +994,6 @@ class NomadNetworkApp:
except Exception: value = True except Exception: value = True
self.rrc_filter_loaded_history = value self.rrc_filter_loaded_history = value
if option == "max_accepted_resource_size":
try: value = self.config["rrc"].as_int(option)
except Exception: value = None
if value is not None and value >= 0:
self.rrc_max_accepted_resource_size = value*1024
if option == "ephemeral_notices": if option == "ephemeral_notices":
try: value = self.config["rrc"].as_float(option) try: value = self.config["rrc"].as_float(option)
except Exception: value = 0 except Exception: value = 0
@ -1423,15 +1358,6 @@ colormode = 24bit
# glyphs = unicode # glyphs = unicode
glyphs = nerdfont glyphs = nerdfont
# If your terminal supports image output,
# the page browser can display images and
# graphics on pages. You can specify how,
# or if at all, images are loaded. Valid
# options are "auto", "manual", "never"
# and "always".
image_loading = auto
# You can specify whether mouse events # You can specify whether mouse events
# should be considered as input to the # should be considered as input to the
# application. On by default. # application. On by default.
@ -1454,14 +1380,6 @@ sanitize_names = yes
# escape sequences. # escape sequences.
clipboard_copy = no clipboard_copy = no
[utilities]
# You can enable a Utilities section in the
# menu, where you can view the path and announce
# rate tables and probe destinations. Disabled
# by default.
enable_utilities = no
[rrc] [rrc]
# Maximum number of messages retained per # Maximum number of messages retained per
@ -1479,14 +1397,6 @@ history_per_room_cap = 500
# loaded. # loaded.
filter_loaded_history = yes filter_loaded_history = yes
# Maximum size in kilobytes of a resource
# transfer that will be accepted from a
# hub. Lower this on low-bandwidth links
# to automatically reject transfers that
# would take too long to receive. Set to
# 0 to reject all resource transfers.
max_accepted_resource_size = 256
# You can choose whether notices and sys # You can choose whether notices and sys
# messages persist indefinitely, or are # messages persist indefinitely, or are
# removed from the message history after # removed from the message history after

View file

@ -8,7 +8,6 @@ from collections import deque
import RNS import RNS
from nomadnet.vendor import cbor from nomadnet.vendor import cbor
from nomadnet.util import strip_modifiers
HISTORY_DIR_NAME = "rrc_history" HISTORY_DIR_NAME = "rrc_history"
@ -118,7 +117,7 @@ def _msg_id():
# greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)" # greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)"
_WHO_ENTRY_RE = re.compile( _WHO_ENTRY_RE = re.compile(
r"(?:^|,\s)" r"(?:^|,\s)"
r"(?:(?P<bh>[0-9a-fA-F]{64})|(?P<nick>.+?)\s\((?P<np>[0-9a-fA-F]{12})\))" r"(?:(?P<bh>[0-9a-fA-F]{32})|(?P<nick>.+?)\s\((?P<np>[0-9a-fA-F]{12})\))"
r"(?=,\s|$)" r"(?=,\s|$)"
) )
@ -170,16 +169,6 @@ def _parse_room_list_notice(text):
return rooms return rooms
# On low-MTU links the hub may deliver a /list reply as a sequence of
# chunked NOTICE packets (one line per packet) instead of a single notice
# or a resource transfer. The chunks carry no continuation marker, but each
# line has a recognisable shape. Lines matching it, arriving within a short
# window after a /list header, are treated as continuation fragments and
# merged into the room list instead of being recorded as chat messages.
_LIST_FRAGMENT_RE = re.compile(r"^ (?P<name>\S+?)(?: - (?P<topic>.*))?$")
LIST_FRAGMENT_WINDOW = 60.0
def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None): def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None):
env = { env = {
K_V: RRC_VERSION, K_V: RRC_VERSION,
@ -203,7 +192,7 @@ class RRCMessage:
self.room = room self.room = room
self.src = src self.src = src
self.nick = nick self.nick = nick
self.text = strip_modifiers(text) self.text = text
self.ts = ts self.ts = ts
self.mention = False self.mention = False
@ -245,11 +234,6 @@ class RRCHub:
self.mention_rooms = set() self.mention_rooms = set()
self.members = {} self.members = {}
self.nicks = {} self.nicks = {}
# Display-only member entries derived from 12-hex identity prefixes
# in /who replies. Populated when the full identity hash of a nicked
# member is not yet known, and promoted to regular members as soon
# as the full hash is learned from room traffic or join fanouts.
self.prefix_members = {}
self.auto_reconnect = False self.auto_reconnect = False
self.auto_list = False self.auto_list = False
@ -274,7 +258,6 @@ class RRCHub:
self.available_rooms = {} self.available_rooms = {}
self._silent_list_pending = 0 self._silent_list_pending = 0
self._silent_who_rooms = set() self._silent_who_rooms = set()
self._list_fragment_until = 0.0
self.nick_override = None self.nick_override = None
self._pending_joins = set() self._pending_joins = set()
@ -306,7 +289,6 @@ class RRCHub:
self.unread_rooms.discard(r) self.unread_rooms.discard(r)
self.mention_rooms.discard(r) self.mention_rooms.discard(r)
self.members.pop(r, None) self.members.pop(r, None)
self.prefix_members.pop(r, None)
self._delete_history(r) self._delete_history(r)
self.manager.save() self.manager.save()
self.manager._notify_change(self) self.manager._notify_change(self)
@ -323,23 +305,7 @@ class RRCHub:
def get_members(self, room): def get_members(self, room):
with self._lock: with self._lock:
return list(self.members.get(room, set()) | self.prefix_members.get(room, set())) return list(self.members.get(room, set()))
def _upgrade_prefix_member(self, room, full_hash):
# Once a full identity hash is learned from room traffic or join
# fanouts, promote any display-only prefix member that matches it,
# so the entry becomes a full, addressable member. The prefix
# entry's nickname is carried over unless the promoting source
# provides one.
pset = self.prefix_members.get(room)
if pset:
for p in list(pset):
if full_hash.startswith(p):
pset.discard(p)
nick = self.nicks.pop(p, None)
if nick and full_hash not in self.nicks:
self.nicks[full_hash] = nick
break
def display_name_for(self, peer): def display_name_for(self, peer):
if not isinstance(peer, (bytes, bytearray)): if not isinstance(peer, (bytes, bytearray)):
@ -389,11 +355,10 @@ class RRCHub:
def _connect_worker(self): def _connect_worker(self):
try: try:
path_timeout = self.manager.app.rns.get_medium_path_timeout() timeout_s = 20.0
timeout_s = max(20.0, path_timeout)
if not RNS.Transport.has_path(self.hub_hash): if not RNS.Transport.has_path(self.hub_hash):
RNS.Transport.request_path(self.hub_hash) RNS.Transport.request_path(self.hub_hash)
deadline = time.monotonic() + max(5.0, path_timeout) deadline = time.monotonic() + min(5.0, timeout_s)
while time.monotonic() < deadline: while time.monotonic() < deadline:
if RNS.Transport.has_path(self.hub_hash): if RNS.Transport.has_path(self.hub_hash):
break break
@ -464,7 +429,7 @@ class RRCHub:
except Exception as e: except Exception as e:
self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR) self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR)
attempts += 1 attempts += 1
self._stop_hello.wait(timeout=max(3.0, (cur_link.rtt or 0)*cur_link.traffic_timeout_factor)) self._stop_hello.wait(timeout=3.0)
if not self.welcomed and not self._stop_hello.is_set(): if not self.welcomed and not self._stop_hello.is_set():
self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout") self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout")
try: try:
@ -500,7 +465,6 @@ class RRCHub:
self.welcomed = False self.welcomed = False
self.motd = None self.motd = None
self.members.clear() self.members.clear()
self.prefix_members.clear()
self._resource_expectations.clear() self._resource_expectations.clear()
self._pending_joins.clear() self._pending_joins.clear()
self._pending_parts.clear() self._pending_parts.clear()
@ -875,85 +839,6 @@ class RRCHub:
self._append_history(target_room, msg) self._append_history(target_room, msg)
self._clean_history() self._clean_history()
def _process_notice_text(self, text):
# Parse hub service notices (/list and /who replies) regardless of
# whether they arrived as a packet or a resource transfer. Returns
# True when the notice was consumed silently (auto /list or /who,
# or a /list continuation fragment) and should not be recorded to
# the message log.
parsed = _parse_room_list_notice(text)
if parsed is not None:
with self._lock:
self.available_rooms = {strip_modifiers(name): strip_modifiers(topic) for name, topic in parsed.items()}
# The first line of a /list reply opens a short window in
# which continuation fragments (chunked NOTICE packets on
# low-MTU links) are absorbed into the room list.
self._list_fragment_until = time.monotonic()+LIST_FRAGMENT_WINDOW
silent = self._silent_list_pending > 0
if silent:
self._silent_list_pending -= 1
self.manager._notify_change(self)
if silent: return True
with self._lock:
list_window_active = time.monotonic() < self._list_fragment_until
if list_window_active:
fm = _LIST_FRAGMENT_RE.match(text)
if fm is not None:
with self._lock:
frag_name = fm.group("name").strip().lower()
frag_topic = fm.group("topic")
if frag_topic:
frag_topic = strip_modifiers(frag_topic.strip()) or None
else:
frag_topic = None
self.available_rooms[frag_name] = frag_topic
self._list_fragment_until = time.monotonic()+LIST_FRAGMENT_WINDOW
self.manager._notify_change(self)
return True
parsed_who = _parse_who_notice(text)
if parsed_who is not None:
who_room, who_entries = parsed_who
with self._lock:
members = self.members.setdefault(who_room, set())
# Treat /who replies as fresh roster snapshots: replace any
# display-only prefix members from earlier replies before
# applying this one.
prefix_members = self.prefix_members.setdefault(who_room, set())
for p in list(prefix_members):
self.nicks.pop(p, None)
prefix_members.clear()
for nick, hash_hex in who_entries:
try:
hash_bytes = bytes.fromhex(hash_hex)
except Exception:
continue
if nick is None:
members.add(hash_bytes)
self._upgrade_prefix_member(who_room, hash_bytes)
continue
known = None
for ph in members:
if ph.startswith(hash_bytes):
known = ph
break
if known is not None:
self.nicks[known] = nick
else:
# A /who line only carries a 12-hex identity prefix
# for nicked members. If the full hash is not yet
# known, keep the prefix as a display-only member.
# It is promoted to a full member as soon as the
# hash is learned from room traffic or fanouts.
prefix_members.add(hash_bytes)
self.nicks[hash_bytes] = nick
silent_who = who_room in self._silent_who_rooms
if silent_who:
self._silent_who_rooms.discard(who_room)
self.manager._notify_change(self)
if silent_who:
return True
return False
def get_messages(self, room, take_lock=True): def get_messages(self, room, take_lock=True):
if take_lock: if take_lock:
with self._lock: buf = list(self.messages.get(room, [])) with self._lock: buf = list(self.messages.get(room, []))
@ -1000,7 +885,7 @@ class RRCHub:
if isinstance(body, dict): if isinstance(body, dict):
hub_name = body.get(B_WELCOME_HUB) hub_name = body.get(B_WELCOME_HUB)
if isinstance(hub_name, str): if isinstance(hub_name, str):
self.hub_name = strip_modifiers(hub_name) self.hub_name = hub_name
ver = body.get(B_WELCOME_VER) ver = body.get(B_WELCOME_VER)
if isinstance(ver, str): if isinstance(ver, str):
self.hub_version = ver self.hub_version = ver
@ -1060,10 +945,8 @@ class RRCHub:
members = self.members.setdefault(r, set()) members = self.members.setdefault(r, set())
for h in body_hashes: for h in body_hashes:
members.add(h) members.add(h)
self._upgrade_prefix_member(r, h)
if own_hash is not None: if own_hash is not None:
members.add(own_hash) members.add(own_hash)
self._upgrade_prefix_member(r, own_hash)
# rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it # rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it
# so display_name_for() can render the nick instead of a hash prefix. # so display_name_for() can render the nick instead of a hash prefix.
@ -1118,19 +1001,12 @@ class RRCHub:
self.nicks[ph] = parter_nick self.nicks[ph] = parter_nick
members = self.members.get(r) members = self.members.get(r)
pset = self.prefix_members.get(r)
if members is not None: if members is not None:
for h in body_hashes: for h in body_hashes:
members.discard(h) members.discard(h)
if pset:
for p in list(pset):
if h.startswith(p):
pset.discard(p)
self.nicks.pop(p, None)
if self_part: if self_part:
self.rooms.discard(r) self.rooms.discard(r)
self.members.pop(r, None) self.members.pop(r, None)
self.prefix_members.pop(r, None)
if self_part: if self_part:
self.manager.save() self.manager.save()
@ -1157,9 +1033,7 @@ class RRCHub:
with self._lock: with self._lock:
self.nicks[bytes(src)] = nick self.nicks[bytes(src)] = nick
if isinstance(room, str) and room: if isinstance(room, str) and room:
rn = room.strip().lower() self.members.setdefault(room.strip().lower(), set()).add(bytes(src))
self.members.setdefault(rn, set()).add(bytes(src))
self._upgrade_prefix_member(rn, bytes(src))
if isinstance(body, str): if isinstance(body, str):
msg = RRCMessage( msg = RRCMessage(
"msg", "msg",
@ -1192,9 +1066,7 @@ class RRCHub:
with self._lock: with self._lock:
self.nicks[bytes(src)] = nick self.nicks[bytes(src)] = nick
if isinstance(room, str) and room: if isinstance(room, str) and room:
rn = room.strip().lower() self.members.setdefault(room.strip().lower(), set()).add(bytes(src))
self.members.setdefault(rn, set()).add(bytes(src))
self._upgrade_prefix_member(rn, bytes(src))
if isinstance(body, str): if isinstance(body, str):
msg = RRCMessage( msg = RRCMessage(
"action", "action",
@ -1218,47 +1090,43 @@ class RRCHub:
room = env.get(K_ROOM) room = env.get(K_ROOM)
src = env.get(K_SRC) src = env.get(K_SRC)
if isinstance(body, str): if isinstance(body, str):
#################### HACK ZONE ##################### parsed = _parse_room_list_notice(body)
# This is the kind of crazy mess that happens when if parsed is not None:
# an LLM designs a chat protocol, and the client with self._lock:
# maintainer is too open-minded, decides to go with self.available_rooms = parsed
# it, and now is responsible for the fundamental silent = self._silent_list_pending > 0
# architectural chaos. if silent:
# self._silent_list_pending -= 1
# Yes, I love RRC, but trying to figure out an self.manager._notify_change(self)
# elegant way to solve the underlying issues that if silent:
# make this kind of mess necessary revealed the return
# following answer: There is none. parsed_who = _parse_who_notice(body)
# if parsed_who is not None:
# There are so many bugs, weird cases, architectural who_room, who_entries = parsed_who
# mismatches, impossible situations and general with self._lock:
# weirdness here, that I am just not going to go and members = self.members.setdefault(who_room, set())
# attempt to sort it all out. Sorry, I don't have the for nick, hash_hex in who_entries:
# time, so a hack like this is what it will be. This try:
# makes things actually work semi-expectedly, even hash_bytes = bytes.fromhex(hash_hex)
# over low-bandwidth links and such, so hacky fix it except Exception:
# is. Until we have something better, this will do continue
# just fine. On a related note, I was considering if nick is None:
# taking up maintenance of rrcd, now that it seems to members.add(hash_bytes)
# not really be worked on or updated anymore, but continue
# looking at the entire system, I think it's better for ph in members:
# left as it; it works fine, albeit with a few quirks if ph.startswith(hash_bytes):
# for it's purpose. My future work in chat-related self.nicks[ph] = nick
# protocols on Reticulum will be focused on a better break
# architecture, incorporating everything we learned silent_who = who_room in self._silent_who_rooms
# from RRC, and making it Reticulum-native from the if silent_who:
# start, instead of mirroring IRC too closely. self._silent_who_rooms.discard(who_room)
# - Mark self.manager._notify_change(self)
was_parsed = self._process_notice_text(body) if silent_who:
return
room_n = room.strip().lower() if isinstance(room, str) else None room_n = room.strip().lower() if isinstance(room, str) else None
if room_n is None and isinstance(body, str) and body.strip(): if room_n is None and isinstance(body, str) and body.strip():
probably_motd = True with self._lock:
if was_parsed: probably_motd = False self.motd = body
if body.lstrip().lower().startswith("registered public rooms:"): probably_motd = False; was_parsed = True
if body.lstrip().lower().startswith("members in "): probably_motd = False; was_parsed = True
if probably_motd:
with self._lock: self.motd = strip_modifiers(body)
was_parsed = False
self.manager._notify_change(self) self.manager._notify_change(self)
msg = RRCMessage( msg = RRCMessage(
"notice", "notice",
@ -1268,7 +1136,7 @@ class RRCHub:
body, body,
_now_ms(), _now_ms(),
) )
if not was_parsed: self._record_notice(msg) self._record_notice(msg)
return return
if t == T_ERROR: if t == T_ERROR:
@ -1336,12 +1204,7 @@ class RRCHub:
size = getattr(resource, "size", 0) size = getattr(resource, "size", 0)
except Exception: except Exception:
return False return False
try: if size > 262144:
max_size = int(getattr(self.manager.app, "rrc_max_accepted_resource_size", 262144))
except Exception:
max_size = 262144
if max_size <= 0 or size > max_size:
self._log("rejecting resource transfer of "+str(size)+" bytes (max accepted is "+str(max_size)+" bytes)", RNS.LOG_DEBUG)
return False return False
return True return True
@ -1396,10 +1259,8 @@ class RRCHub:
return return
if kind == RES_KIND_MOTD: if kind == RES_KIND_MOTD:
with self._lock: with self._lock:
self.motd = strip_modifiers(text) self.motd = text
self.manager._notify_change(self) self.manager._notify_change(self)
elif self._process_notice_text(text):
return
msg = RRCMessage("notice", room, None, None, text, _now_ms()) msg = RRCMessage("notice", room, None, None, text, _now_ms())
self._record_notice(msg) self._record_notice(msg)
except Exception as e: except Exception as e:

View file

@ -1 +1 @@
__version__ = "1.4.4" __version__ = "1.2.7"

View file

@ -25,8 +25,6 @@ An masked field : `B444`<!|password`Value of Field>`b
An small field : `B444`<8|small`test>`b, and some more text. An small field : `B444`<8|small`test>`b, and some more text.
A multi-row field : `B444`<40x4|notes`>`b
Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b
The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!.

View file

@ -7,7 +7,6 @@ import platform
import nomadnet import nomadnet
from nomadnet.ui import THEME_DARK, THEME_LIGHT from nomadnet.ui import THEME_DARK, THEME_LIGHT
from nomadnet.ui.textui import * from nomadnet.ui.textui import *
from nomadnet.ui.textui.images import ImageScreen
from nomadnet import NomadNetworkApp from nomadnet import NomadNetworkApp
COLORMODE_MONO = 1 COLORMODE_MONO = 1
@ -16,11 +15,6 @@ COLORMODE_88 = 88
COLORMODE_256 = 256 COLORMODE_256 = 256
COLORMODE_TRUE = 2**24 COLORMODE_TRUE = 2**24
IMGLOAD_NEVER = 0x00
IMGLOAD_MANUAL = 0x01
IMGLOAD_AUTO = 0x02
IMGLOAD_ALWAYS = 0x03
THEMES = { THEMES = {
THEME_DARK: { THEME_DARK: {
"urwid_theme": [ "urwid_theme": [
@ -61,10 +55,8 @@ THEMES = {
("progress_empty", "light gray", "default", "default", "#ddd", "default"), ("progress_empty", "light gray", "default", "default", "#ddd", "default"),
("interface_title", "", "", "default", "", ""), ("interface_title", "", "", "default", "", ""),
("interface_title_selected", "bold", "", "bold", "", ""), ("interface_title_selected", "bold", "", "bold", "", ""),
("interface_tile_focus", "light blue,bold", "default", "bold", "#5af,bold", "default"),
("connected_status", "dark green", "default", "default", "dark green", "default"), ("connected_status", "dark green", "default", "default", "dark green", "default"),
("disconnected_status", "dark red", "default", "default", "dark red", "default"), ("disconnected_status", "dark red", "default", "default", "dark red", "default"),
("disabled_status", "dark red", "default", "default", "dark red", "default"),
("placeholder", "dark gray", "default", "default", "dark gray", "default"), ("placeholder", "dark gray", "default", "default", "dark gray", "default"),
("placeholder_text", "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"), ("error", "light red,blink", "default", "blink", "#f44,blink", "default"),
@ -116,10 +108,8 @@ THEMES = {
("progress_empty", "dark gray", "default", "default", "#ddd", "default"), ("progress_empty", "dark gray", "default", "default", "#ddd", "default"),
("interface_title", "dark gray", "default", "default", "#444", "default"), ("interface_title", "dark gray", "default", "default", "#444", "default"),
("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"), ("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"),
("interface_tile_focus", "dark blue,bold", "default", "bold", "#06c,bold", "default"),
("connected_status", "dark green", "default", "default", "#4a0", "default"), ("connected_status", "dark green", "default", "default", "#4a0", "default"),
("disconnected_status", "dark red", "default", "default", "#a22", "default"), ("disconnected_status", "dark red", "default", "default", "#a22", "default"),
("disabled_status", "dark red", "default", "default", "#a22", "default"),
("placeholder", "light gray", "default", "default", "#999", "default"), ("placeholder", "light gray", "default", "default", "#999", "default"),
("placeholder_text", "light gray", "default", "default", "#999", "default"), ("placeholder_text", "light gray", "default", "default", "#999", "default"),
("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"),
@ -134,23 +124,6 @@ THEMES = {
} }
} }
CONFIG_SECTION_COLORS = {
"logging": ("yellow", "#fd3", "brown", "#a80"),
"client": ("light cyan", "#3cd", "dark cyan", "#077"),
"textui": ("light green", "#6c5", "dark green", "#2a0"),
"utilities": ("light gray", "#bbb", "dark gray", "#555"),
"rrc": ("light magenta", "#d7f", "dark magenta", "#a2a"),
"node": ("light blue", "#5af", "dark blue", "#06c"),
"printing": ("brown", "#b86", "brown", "#852"),
"reticulum": ("light magenta", "#a6f", "dark magenta", "#63c"),
}
for _section, (_d16, _dhi, _l16, _lhi) in CONFIG_SECTION_COLORS.items():
THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section, _d16, "default", "default", _dhi, "default"))
THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section+"_selected", _d16+",bold", "default", "bold", _dhi+",bold", "default"))
THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section, _l16, "default", "default", _lhi, "default"))
THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section+"_selected", _l16+",bold", "default", "bold", _lhi+",bold", "default"))
GLYPHSETS = { GLYPHSETS = {
"plain": 1, "plain": 1,
"unicode": 2, "unicode": 2,
@ -191,20 +164,11 @@ GLYPHS = {
("qrcode", "QR", "\u25a4", "\uf029"), ("qrcode", "QR", "\u25a4", "\uf029"),
("selected", "[*] ", "\u25CF", "\u25CF"), ("selected", "[*] ", "\u25CF", "\u25CF"),
("unselected", "[ ] ", "\u25CB", "\u25CB"), ("unselected", "[ ] ", "\u25CB", "\u25CB"),
("focus_arrow", ">", "\u25B8", "\u25B8"),
("sep_dot", "|", "\u00B7", "\u00B7"),
("connected", "+", "\u2713", "\U000f0201"),
("disconnected", "x", "\u2717", "\U000f0202"),
("file", "[F]", "\u25a4", "\uf15b"), ("file", "[F]", "\u25a4", "\uf15b"),
("image", "[I]", "\u25a3", "\uf1c5"), ("image", "[I]", "\u25a3", "\uf1c5"),
("audio", "[~]", "\u266b", "\uf1c7"), ("audio", "[~]", "\u266b", "\uf1c7"),
("pin", "*", "\u2605", "\uf08d"), ("pin", "*", "\u2605", "\uf08d"),
("copy", "[C]", "\u29c9", "\uf0c5"), ("copy", "[C]", "\u29c9", "\uf0c5"),
("folder", "[+]", "\u25b8", "\uf07b"),
("folder_open", "[-]", "\u25be", "\uf07c"),
("fold_open", "-", "▾", "▾"),
("fold_closed", "+", "▸", "▸"),
("dropdown", " [v]", " ▾", ""),
} }
class TextUI: class TextUI:
@ -251,7 +215,7 @@ class TextUI:
for glyph in GLYPHS: for glyph in GLYPHS:
self.glyphs[glyph[0]] = glyph[GLYPHSETS[glyphset]] self.glyphs[glyph[0]] = glyph[GLYPHSETS[glyphset]]
self.screen = ImageScreen() self.screen = urwid.raw_display.Screen()
self.screen.register_palette(self.palette) self.screen.register_palette(self.palette)
self.main_display = Main.MainDisplay(self, self.app) self.main_display = Main.MainDisplay(self, self.app)
@ -285,7 +249,6 @@ class TextUI:
self.set_colormode(colormode) self.set_colormode(colormode)
self.main_display.start() self.main_display.start()
if intro_timeout <= 0 and self.app.peer_settings_notice: self.main_display.show_notice(self.app.peer_settings_notice)
self.loop.run() self.loop.run()
def set_colormode(self, colormode): def set_colormode(self, colormode):
@ -297,9 +260,10 @@ class TextUI:
self.restore_palette = True self.restore_palette = True
def unhandled_input(self, key): def unhandled_input(self, key):
if key == "ctrl q": self.main_display.quit() if key == "ctrl q":
elif key == "ctrl e": pass raise urwid.ExitMainLoop
elif key == "ctrl e":
pass
def display_main(self, loop, user_data): def display_main(self, loop, user_data):
self.loop.widget = self.main_display.widget self.loop.widget = self.main_display.widget
if self.app.peer_settings_notice: self.main_display.show_notice(self.app.peer_settings_notice)

View file

@ -3,7 +3,6 @@ import LXMF
import io import io
import os import os
import time import time
import bisect
import urwid import urwid
import shutil import shutil
import nomadnet import nomadnet
@ -12,19 +11,12 @@ import threading
from threading import Lock from threading import Lock
from .MicronParser import markup_to_attrmaps, make_style, default_state from .MicronParser import markup_to_attrmaps, make_style, default_state
from nomadnet.Directory import DirectoryEntry from nomadnet.Directory import DirectoryEntry
from nomadnet.Conversation import ConversationMessage
from nomadnet.vendor.Scrollable import * from nomadnet.vendor.Scrollable import *
from nomadnet.util import strip_modifiers from nomadnet.util import strip_modifiers
from nomadnet.util import sanitize_name from nomadnet.util import sanitize_name
from nomadnet.ui.textui.images import _termlib
from .Helpers import ClickableIcon, osc52_copy from .Helpers import ClickableIcon, osc52_copy
from .ReadlineEdit import ReadlineMixin, ReadlineEdit from .ReadlineEdit import ReadlineMixin, ReadlineEdit
IMGLOAD_NEVER = 0x00
IMGLOAD_MANUAL = 0x01
IMGLOAD_AUTO = 0x02
IMGLOAD_ALWAYS = 0x03
class BrowserFrame(urwid.Frame): class BrowserFrame(urwid.Frame):
def keypress(self, size, key): def keypress(self, size, key):
if key == "ctrl w": if key == "ctrl w":
@ -37,10 +29,6 @@ class BrowserFrame(urwid.Frame):
self.delegate.reload() self.delegate.reload()
elif key == "ctrl u": elif key == "ctrl u":
self.delegate.url_dialog() self.delegate.url_dialog()
elif key == "ctrl l":
self.delegate.load_images()
elif key == "ctrl x":
self.delegate.load_images(force_reload=True)
elif key == "ctrl s": elif key == "ctrl s":
self.delegate.save_node_dialog() self.delegate.save_node_dialog()
elif key == "ctrl b": elif key == "ctrl b":
@ -53,11 +41,11 @@ class BrowserFrame(urwid.Frame):
elif self.focus_position == "body": elif self.focus_position == "body":
if key == "down" or key == "up": if key == "down" or key == "up":
try: try:
if hasattr(self.delegate, "page_list") and self.delegate.page_list: if hasattr(self.delegate, "page_pile") and self.delegate.page_pile:
def df(loop, user_data): def df(loop, user_data):
st = None st = None
if self.delegate.page_list: if self.delegate.page_pile:
nf = self.delegate.page_list.focus nf = self.delegate.page_pile.focus
if hasattr(nf, "key_timeout"): if hasattr(nf, "key_timeout"):
st = nf st = nf
elif hasattr(nf, "original_widget"): elif hasattr(nf, "original_widget"):
@ -81,168 +69,6 @@ class BrowserFrame(urwid.Frame):
else: else:
return super(BrowserFrame, self).keypress(size, key) return super(BrowserFrame, self).keypress(size, key)
class PageList(urwid.ListBox):
def __init__(self, body):
super().__init__(body)
self._page_size = (0, 0)
self._page_focus = None
self._page_layouts = {}
def invalidate_layout(self):
self._page_layouts = {}
self._invalidate()
def _layout(self, maxcol):
offsets = self._page_layouts.get(maxcol)
if offsets is None or len(offsets) != len(self.body) + 1:
layout_started = time.time()
offsets = [0]
for widget in self.body:
offsets.append(offsets[-1] + widget.rows((maxcol,), True))
self._page_layouts[maxcol] = offsets
# RNS.log(f" rebuilt {len(self.body)} widgets, {offsets[-1]} rows, width {maxcol}, {(time.time()-layout_started)*1000:.1f}ms", RNS.LOG_DEBUG)
return offsets
def calculate_visible(self, size, focus=False):
visible = super().calculate_visible(size, focus)
if visible is not None and self.body:
self._sync_layout(size[0], visible)
return visible
def _sync_layout(self, maxcol, visible):
# Validate the cached layout against the live geometry of the
# currently visible widgets, and delta-update the cached offsets when a
# visible widget has changed height since the layout was built without
# rescanning the entire document on every render.
if not self.body or not self._page_layouts: return
offsets = self._page_layouts.get(maxcol)
if offsets is None or len(offsets) != len(self.body) + 1: return
def sync_position(pos, live_rows):
if pos < 0 or pos >= len(self.body) or live_rows < 0: return
cached_rows = offsets[pos + 1] - offsets[pos]
if cached_rows == live_rows: return
delta = live_rows - cached_rows
for i in range(pos + 1, len(offsets)): offsets[i] += delta
# Other cached widths may also be affected by this change. Sync
# them using the changed widget's live rows at their widths.
for width, other in list(self._page_layouts.items()):
if width == maxcol or len(other) != len(offsets): continue
other_cached = other[pos + 1] - other[pos]
other_live = self.body[pos].rows((width,), True)
if other_cached == other_live: continue
other_delta = other_live - other_cached
for i in range(pos + 1, len(other)): other[i] += other_delta
middle = visible.middle
sync_position(middle.focus_pos, middle.focus_rows)
for item in visible.top.fill: sync_position(item.position, item.rows)
for item in visible.bottom.fill: sync_position(item.position, item.rows)
def render(self, size, focus=False):
self._page_size = size
if self.body and self.focus is not self._page_focus:
self._page_focus = self.focus
widget = self.focus
while widget is not None:
widget._invalidate()
widget = getattr(widget, "original_widget", None)
return super().render(size, focus)
def rows_max(self, size=None, focus=False):
size = size or self._page_size
return self._layout(size[0])[-1]
def get_scrollpos(self, size=None, focus=False):
size = size or self._page_size
if not self.body or not size[1]: return 0
middle, top, bottom = self.calculate_visible(size, focus)
if middle is None: return 0
return self._layout(size[0])[self.top_position(size)] + top.trim
def set_scrollpos(self, position):
size = self._page_size
maxcol, maxrow = size
if not self.body or not maxrow: return
offsets = self._layout(maxcol)
position = max(0, min(int(position), max(0, offsets[-1] - maxrow)))
pos = bisect.bisect_left(offsets, position, 0, len(self.body))
if pos == len(self.body) or offsets[pos] - position >= maxrow: pos -= 1
# Guard against the cached layout being inconsistent
# with the live widget geometry. Without it, change_focus
# can be called with an offset that the live target widget
# cannot satisfy, raising ListBoxError.
pos = max(0, min(pos, len(self.body) - 1))
offset_inset = offsets[pos] - position
if offset_inset > maxrow - 1: offset_inset = maxrow - 1
elif offset_inset < 0:
tgt_rows = max(1, self.body[pos].rows((maxcol,), True))
if offset_inset < 1 - tgt_rows: offset_inset = 1 - tgt_rows
self.change_focus(size, pos, offset_inset)
def top_position(self, size=None):
size = size or self._page_size
if not self.body or not size[1]: return 0
middle, top, bottom = self.calculate_visible(size, True)
if middle is None: return 0
return top.fill[-1].position if top.fill else middle.focus_pos
def scroll(self, rows):
self.set_scrollpos(self.get_scrollpos() + rows)
def focused_widget_name(self):
widget = self.focus
while getattr(widget, "original_widget", None) is not None: widget = widget.original_widget
return type(widget).__name__
def keypress(self, size, key):
self._page_size = size
key_started = time.time()
focus_before = self.focus_position if self.body else None
scroll_before = self.get_scrollpos(size, True)
unhandled_key = self.handle_key(size, key)
# RNS.log(f" {key}: {unhandled_key} focus {focus_before} -> {self.focus_position if self.body else None} ({self.focused_widget_name()}) scrollpos {scroll_before}->{self.get_scrollpos(size, True)}, {(time.time()-key_started) * 1000:.1f}ms", RNS.LOG_DEBUG)
return unhandled_key
def handle_key(self, size, key):
try:
if key in ("page up", "page down", "home", "end"):
focus_widget = self.focus
if focus_widget is not None and focus_widget.selectable():
key = focus_widget.keypress((size[0],), key)
if key is None:
self.make_cursor_visible(size)
return None
if key == "page down": self.scroll(size[1] - 1)
elif key == "page up": self.scroll(1 - size[1])
elif key == "home": self.set_scrollpos(0)
elif key == "end": self.set_scrollpos(self.rows_max(size))
return None
unhandled_key = super().keypress(size, key)
if key in ("up", "down"): return None
return unhandled_key
except Exception as e:
RNS.log(f"Error while handling browser keypress: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
return None
def mouse_event(self, size, event, button, col, row, focus):
self._page_size = size
if button in (4, 5): return False
return super().mouse_event(size, event, button, col, row, focus)
class Browser: class Browser:
DEFAULT_PATH = "/page/index.mu" DEFAULT_PATH = "/page/index.mu"
DEFAULT_TIMEOUT = 10 DEFAULT_TIMEOUT = 10
@ -296,17 +122,10 @@ class Browser:
self.link_target = None self.link_target = None
self.frame = None self.frame = None
self.attr_maps = [] self.attr_maps = []
self.page_list = None self.page_pile = None
self.page_rows = []
self.page_partials = {} self.page_partials = {}
self.page_images = {}
self.updater_running = False self.updater_running = False
self.image_updater_running = False
self.partial_updater_lock = Lock() self.partial_updater_lock = Lock()
self.image_updater_lock = Lock()
self.image_cache_lock = Lock()
self.image_mode = self.app.config["textui"]["image_loading"]
self.image_rendering_supported = _termlib.is_kitty_supported() and self.image_mode != IMGLOAD_NEVER
self.build_display() self.build_display()
self.history = [] self.history = []
@ -324,26 +143,28 @@ class Browser:
self.clean_cache() self.clean_cache()
if self.image_rendering_supported: RNS.log(f"In-browser image rendering supported", RNS.LOG_DEBUG)
else: RNS.log(f"In-browser image rendering is not supported", RNS.LOG_DEBUG)
def current_url(self): def current_url(self):
if self.destination_hash == None: return "" if self.destination_hash == None:
return ""
path = "" if self.path == None else self.path path = "" if self.path == None else self.path
url = RNS.hexrep(self.destination_hash, delimit=False)+":"+path url = RNS.hexrep(self.destination_hash, delimit=False)+":"+path
if isinstance(self.request_data, dict) and self.request_data: if isinstance(self.request_data, dict) and self.request_data:
parts = [] parts = []
for k, v in self.request_data.items(): for k, v in self.request_data.items():
if not isinstance(k, str): continue if not isinstance(k, str):
if not k.startswith("var_"): continue continue
if not k.startswith("var_"):
continue
parts.append(k[4:]+"="+str(v)) parts.append(k[4:]+"="+str(v))
if parts: url += "`" + "|".join(parts) if parts:
url += "`" + "|".join(parts)
return url return url
def url_hash(self, url): def url_hash(self, url):
if url == None: return None if url == None:
return None
else: else:
url = url.encode("utf-8") url = url.encode("utf-8")
return RNS.hexrep(RNS.Identity.full_hash(url), delimit=False) return RNS.hexrep(RNS.Identity.full_hash(url), delimit=False)
@ -445,7 +266,7 @@ class Browser:
pass # do nothing if checkbox is not check pass # do nothing if checkbox is not check
recurse_down(self.attr_maps) recurse_down(self.attr_maps)
RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME) RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG)
# In-document anchor link (#name or empty #) # In-document anchor link (#name or empty #)
if link_target.startswith("#"): if link_target.startswith("#"):
@ -504,6 +325,8 @@ class Browser:
anchors = getattr(self.attr_maps, "anchors", None) or {} anchors = getattr(self.attr_maps, "anchors", None) or {}
header_rows = getattr(self.attr_maps, "header_rows", None) or [] header_rows = getattr(self.attr_maps, "header_rows", None) or []
cols = self._content_cols()
target_idx = None target_idx = None
if name: if name:
target_idx = anchors.get(name) target_idx = anchors.get(name)
@ -514,21 +337,22 @@ class Browser:
else: else:
current = 0 current = 0
try: try:
current = self.page_rows[self.page_list.top_position()] current = self.browser_body.original_widget.original_widget.get_scrollpos()
except Exception: except Exception:
current = 0 current = 0
for hr in header_rows: for hr in header_rows:
if hr > current: if self._rows_above(hr, cols) > current:
target_idx = hr target_idx = hr
break break
if target_idx is None: if target_idx is None:
return return
self.reveal_index(int(target_idx)) row_offset = self._rows_above(int(target_idx), cols)
try: try:
self.page_list.set_focus(self.page_rows.index(int(target_idx))) scrollable = self.browser_body.original_widget.original_widget
self.page_list.set_focus_valign("top") scrollable.anchor_cursor_update = True
scrollable.set_scrollpos(row_offset)
except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR)
@ -539,13 +363,23 @@ class Browser:
browser_cols = screen_cols-lw-2 browser_cols = screen_cols-lw-2
cols = browser_cols cols = browser_cols
except Exception as e: except Exception:
RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING) RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING)
RNS.trace_exception(e) RNS.trace_exception(e)
cols = 100 cols = 100
return max(40, cols) return max(40, cols)
def _rows_above(self, index, cols):
if index <= 0 or not self.attr_maps: return 0
total = 0
for i in range(min(index, len(self.attr_maps))):
try: total += self.attr_maps[i].rows((cols,))
except Exception: total += 1
return total
def handle_lxmf_link(self, link_target): def handle_lxmf_link(self, link_target):
try: try:
def san(name): def san(name):
@ -640,10 +474,8 @@ class Browser:
self.browser_header = urwid.Text("") self.browser_header = urwid.Text("")
self.browser_footer = urwid.Text("") self.browser_footer = urwid.Text("")
self.page_list = None self.page_pile = None
self.page_rows = []
self.page_partials = {} self.page_partials = {}
self.page_images = {}
self.browser_body = urwid.Filler( self.browser_body = urwid.Filler(
urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER),
urwid.MIDDLE, urwid.MIDDLE,
@ -717,10 +549,8 @@ class Browser:
def update_display(self): def update_display(self):
if self.status == Browser.DISCONECTED: if self.status == Browser.DISCONECTED:
self.display_widget.set_attr_map({None: "browser_inactive"}) self.display_widget.set_attr_map({None: "browser_inactive"})
self.page_list = None self.page_pile = None
self.page_rows = []
self.page_partials = {} self.page_partials = {}
self.page_images = {}
self.browser_body = urwid.Filler( self.browser_body = urwid.Filler(
urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER),
urwid.MIDDLE, urwid.MIDDLE,
@ -791,415 +621,12 @@ class Browser:
self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
def update_page_display(self): def update_page_display(self):
self._purge_page_images() pile = urwid.Pile(self.attr_maps)
self.page_rows = list(range(len(self.attr_maps))) pile.automove_cursor_on_scroll = True
self.page_list = PageList(urwid.SimpleFocusListWalker(list(self.attr_maps))) self.page_pile = pile
self.page_partials = {} self.page_partials = {}
self.page_images = {} self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar")
self.browser_body = urwid.AttrMap(ScrollBar(self.page_list, thumb_char="\u2503", trough_char=" "), "scrollbar")
self.detect_partials() self.detect_partials()
if self.should_load_images(): self.detect_images()
self.init_folds()
def refresh_page_layout(self):
if self.page_list is not None: self.page_list.invalidate_layout()
def image_cache_path(self, url):
destination_hash, path = self.parse_url(url)
url = f"{RNS.hexrep(destination_hash, delimit=False)}:{path}"
url_hash = self.url_hash(url)
if not url_hash: return None
else:
path = f"{self.app.imagecachepath}/{url_hash}"
return path
def resolve_image(self, url):
cachepath = self.image_cache_path(url)
if not cachepath: return None
else:
if os.path.exists(cachepath): return cachepath
else: return None
def _purge_page_images(self):
if not self.image_rendering_supported: return
try:
screen = getattr(getattr(self.app.ui, "loop", None), "screen", None)
if hasattr(screen, "purge_images"): screen.purge_images()
except Exception: pass
def should_load_images(self):
if self.image_mode == IMGLOAD_NEVER: return False
elif self.image_mode == IMGLOAD_MANUAL: return False
elif self.image_mode == IMGLOAD_ALWAYS: return True
elif self.image_mode == IMGLOAD_AUTO:
if self.destination_hash == self.loopback: return True
rtt_limit = 1.5; edr_limit = 10000
if self.link:
rtt = self.link.rtt
edr = self.link.get_expected_rate() or self.last_response_speed()
rtt_ok = rtt and rtt < rtt_limit
edr_ok = edr and edr > edr_limit
RNS.log(f"Checking image load conditions, RTT is {RNS.prettyshorttime(rtt) if rtt else 'unknown'}, EDR is {RNS.prettyspeed(edr) if edr else 'unknown'}", RNS.LOG_DEBUG)
if rtt_ok and edr_ok: return True
else: return False
return False
def load_images(self, force_reload=False):
if not self.image_rendering_supported: return
elif self.image_mode == IMGLOAD_NEVER: return
else: self.detect_images(force_reload=force_reload)
def detect_images(self, force_reload=False):
if not self.image_rendering_supported: return
for w in self.attr_maps:
o = w._original_widget
if hasattr(o, "_contained_image"): o = o._contained_image
if hasattr(o, "image_url"):
resolved_image = self.resolve_image(o.image_url)
if resolved_image and force_reload:
RNS.log(f"Clearing image cache for {o.image_url} ({resolved_image})", RNS.LOG_DEBUG)
try: os.unlink(resolved_image)
except Exception as e: RNS.log(f"Could not clear image cache for {o.image_url}: {e}", RNS.LOG_ERROR)
resolved_image = None
if force_reload: continue
elif resolved_image and self.image_rendering_supported: o.load(resolved_image, remote_source=True)
else:
image = { "widget": o, "id": o.image_id, "url": o.image_url, "refresh": None, "updated": None,
"update_requested": None, "request_id": None, "link": None, "failed": False, "pr_throttle": 0,
"progress_updated": None, "previous_progress": 0 }
self.page_images[o.image_id] = image
self.refresh_page_layout()
if force_reload: self.reload()
elif len(self.page_images) > 0: self.start_image_updater()
def start_image_updater(self):
if not self.image_rendering_supported: return
if not self.image_updater_running: self.update_images()
def update_images(self, loop=None, user_data=None):
if not self.image_rendering_supported: return
def job():
if self.image_updater_lock.locked(): return
with self.image_updater_lock:
self.image_updater_running = True
for iid in self.page_images:
try:
image = self.page_images[iid]
if image["failed"]: continue
if not image["updated"] or (image["refresh"] != None and time.time() > image["updated"]+image["refresh"]):
image["update_requested"] = time.time()
self.__load_image(image)
while not image["updated"] and not image["failed"] and len(self.page_images) > 0: time.sleep(0.2)
except Exception as e: RNS.log(f"Error updating page image: {e}", RNS.LOG_ERROR)
if len(self.page_images) > 0: self.app.ui.loop.set_alarm_in(1, self.update_images)
else: self.image_updater_running = False
threading.Thread(target=job, daemon=True).start()
def __load_image(self, image):
if image["failed"] == True: return
w = image["widget"]
url = image["url"]
try: image_destination_hash, path = self.parse_url(image["url"])
except Exception as e:
RNS.log(f"Could not parse image URL: {e}", RNS.LOG_ERROR)
image["failed"] = True
w.notice(f"Could not load image {url}: {e}")
return
if image_destination_hash == self.loopback:
local_image = path.replace("/media/", "")
local_path = f"{self.app.pagespath}/{local_image}"
if self.app.node:
image_ext = os.path.splitext(local_path)[-1]
if not image_ext in self.app.node.NATIVE_MEDIA_EXTS:
RNS.log(f"Non-native media type for {local_path}, attempting auto-conversion", RNS.LOG_DEBUG)
local_path = self.app.node.convert_media_to_webp(local_path)
if self.image_rendering_supported:
w.load(local_path)
self.refresh_page_layout()
image["updated"] = time.time()
return
if not RNS.Transport.has_path(image_destination_hash):
if time.time() <= image["pr_throttle"]: return
else:
image["pr_throttle"] = time.time()+15
RNS.log(f"Requesting path for image: {path}", RNS.LOG_DEBUG)
RNS.Transport.request_path(image_destination_hash)
pr_time = time.time()+self.path_timeout(image_destination_hash)
while not RNS.Transport.has_path(image_destination_hash):
if time.time() > pr_time: return
time.sleep(0.25)
if not image["link"]:
if self.link and self.link.destination.hash == image_destination_hash and self.link.status == RNS.Link.ACTIVE:
image["link"] = self.link
RNS.log(f"Re-using existing page link for image {url}", RNS.LOG_EXTREME)
if not image["link"]:
for pid in self.page_images:
other_image = self.page_images[pid]
if other_image["link"]:
existing_link = other_image["link"]
if existing_link.destination.hash == image_destination_hash and existing_link.status == RNS.Link.ACTIVE:
RNS.log(f"Re-using existing link for image {url}", RNS.LOG_EXTREME)
image["link"] = existing_link
break
if not image["link"] or (image["link"] and image["link"].status) == RNS.Link.CLOSED:
RNS.log(f"Establishing link for image: {image_destination_hash} / {path}", RNS.LOG_DEBUG)
identity = RNS.Identity.recall(image_destination_hash)
destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects)
w.notice(f"Establishing link...")
def f_established(wd):
def established(link):
RNS.log(f"Link established for image: {path}", RNS.LOG_DEBUG)
wd.notice(f"Link established")
return established
def f_closed(wd):
def closed(link):
RNS.log(f"Link closed for image: {path}", RNS.LOG_DEBUG)
image["link"] = None
image["failed"] = True
wd.notice(f"Link closed during transfer")
return closed
image["link"] = RNS.Link(destination, established_callback = f_established(w), closed_callback = f_closed(w))
timeout = time.time()+max(self.timeout, image["link"].establishment_timeout)
while image["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1)
if image["link"] and image["link"].status == RNS.Link.ACTIVE and image["request_id"] == None:
RNS.log(f"Sending request for image: {path}", RNS.LOG_DEBUG)
receipt = image["link"].request("/media", data=self.__get_image_request_data(image), response_callback = self.image_received,
failed_callback = self.image_failed, progress_callback = self.image_progressed)
w.notice(f"Request sent")
if receipt: image["request_id"] = receipt.request_id
else:
RNS.log(f"Image request failed", RNS.LOG_ERROR)
image["failed"] = True
w.notice(f"Request failed")
def __get_image_request_data(self, image):
destination_hash, path = self.parse_url(image["url"])
request_data = {"path": path, "key": None}
return request_data
def image_failed(self, request_receipt):
RNS.log("Loading page image failed", RNS.LOG_ERROR)
for iid in self.page_images:
image = self.page_images[iid]
if image["request_id"] == request_receipt.request_id:
try:
image["updated"] = time.time()
image["request_id"] = None
image["failed"] = True
url = image["url"]
w = image["widget"]
w.notice(f"Could not load image {url}")
except Exception as e:
RNS.log(f"Error in image failed callback: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
def image_progressed(self, request_receipt):
for iid in self.page_images:
i = self.page_images[iid]
if i["request_id"] == request_receipt.request_id:
try:
response_progress = request_receipt.progress
response_time = request_receipt.get_response_time()
response_size = request_receipt.response_size
response_transfer_size = request_receipt.response_transfer_size
w = i["widget"]
now = time.time()
if i["progress_updated"] == None: i["progress_updated"] = now
if now > i["progress_updated"]+0.5:
td = now - i["progress_updated"]
pd = response_progress - i["previous_progress"]
bd = pd*response_transfer_size
response_speed = (bd/td)*8
i["previous_progress"] = response_progress
i["progress_updated"] = now
stats = f"{round(response_progress*100, 2)}% ({RNS.prettysize(response_progress*response_size)} of {RNS.prettysize(response_size)}) - {RNS.prettyspeed(response_speed)}"
w.notice(stats)
except Exception as e: RNS.trace_exception(e)
def image_received(self, request_receipt):
if not request_receipt.response: self.image_failed(request_receipt)
for iid in self.page_images:
image = self.page_images[iid]
if image["request_id"] == request_receipt.request_id:
try:
image["updated"] = image["update_requested"]
image["request_id"] = None
w = image["widget"]
w.notice(f"Image loaded")
except Exception as e:
RNS.log(f"Error while loading received image: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
image["failed"] = True
try:
url = image["url"]
cachepath = self.image_cache_path(url)
if isinstance(request_receipt.response, bytes):
with open(cachepath, "wb") as f: f.write(request_receipt.response)
elif type(request_receipt.response) == io.BufferedReader:
if request_receipt.metadata != None:
file_handle = request_receipt.response
file_destination = cachepath
shutil.move(file_handle.name, file_destination)
threading.Thread(target=self.clean_image_cache, daemon=True).start()
resolved_path = self.resolve_image(url)
if resolved_path and self.image_rendering_supported:
w.load(resolved_path, remote_source=True)
self.refresh_page_layout()
except Exception as e:
RNS.log("Error while handling image response: "+str(e), RNS.LOG_ERROR)
image["failed"] = True
IMAGE_CACHE_MAX_FILES = 256
IMAGE_CACHE_MAX_BYTES = 192*1024*1024
def clean_image_cache(self):
if self.image_cache_lock.locked(): return
with self.image_cache_lock:
try:
entries = []
for name in os.listdir(self.app.imagecachepath):
path = os.path.join(self.app.imagecachepath, name)
try:
st = os.stat(path)
if st.st_size > 0: entries.append((st.st_mtime, st.st_size, path))
except OSError as exc: RNS.log(f"Could not get cached file information for {path}: {e}", RNS.LOG_DEBUG)
cleaned_b = 0; cleaned_c = 0
total = sum(e[1] for e in entries)
entries.sort()
while len(entries) > self.IMAGE_CACHE_MAX_FILES or total > self.IMAGE_CACHE_MAX_BYTES:
_, size, path = entries.pop(0)
total -= size
cleaned_b += size
cleaned_c += 1
try: os.unlink(path)
except OSError as exc: RNS.log(f"Could not remove cached file {path}: {exc}")
if cleaned_b or cleaned_c: RNS.log(f"Cleaned {RNS.prettysize(cleaned_b)} / {cleaned_c} image{'s' if cleaned_c != 1 else ''} from cache", RNS.LOG_DEBUG)
except Exception as exc: RNS.log(f"Error while cleaning browser image cache: {exc}", RNS.LOG_ERROR)
# Render an arbitrary markup buffer (used by the page editor preview) and
# return the content widget, with partials/folds/fields wired as usual
def render_markup_buffer(self, markup):
self.status = Browser.DONE
self.markup = markup
self.page_background_color = None
self.page_foreground_color = None
for tag, attr in (("#!bg=", "page_background_color"), ("#!fg=", "page_foreground_color")):
pos = markup.find(tag)
if pos >= 0:
endpos = markup.find("\n", pos)
if endpos < 0: endpos = len(markup)
if endpos-(pos+len(tag)) in (3, 6):
setattr(self, attr, markup[pos+len(tag):endpos])
try:
self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
except Exception as e:
self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render preview: {e}"), "browser_inactive")]
self.update_page_display()
return self.browser_body
def init_folds(self):
self.fold_sections = {}
self.collapsed_headings = set()
attrmaps = self.attr_maps
header_levels = getattr(attrmaps, "header_levels", None) or {}
row_levels = getattr(attrmaps, "row_levels", None) or {}
collapsibles = getattr(attrmaps, "collapsibles", None) or {}
if not collapsibles:
return
n = len(attrmaps)
for idx in sorted(collapsibles.keys()):
widget = self.collapsible_widget_at(idx)
if widget is None: continue
depth = header_levels.get(idx, 1)
end = n
for i in range(idx+1, n):
if row_levels.get(i, depth) < depth or \
(i in header_levels and header_levels.get(i, 1) <= depth):
end = i; break
self.fold_sections[widget] = (idx+1, end)
if collapsibles[idx]:
self.collapsed_headings.add(widget)
try: widget.set_collapsed(True)
except Exception: pass
if self.collapsed_headings:
self.rebuild_visible()
def collapsible_widget_at(self, idx):
try:
o = self.attr_maps[idx]._original_widget
if getattr(o, "is_collapsible_heading", False): return o
except Exception: pass
return None
def hidden_indices(self):
hidden = set()
for widget in getattr(self, "collapsed_headings", ()):
rng = getattr(self, "fold_sections", {}).get(widget)
if rng: hidden.update(range(rng[0], rng[1]))
return hidden
def rebuild_visible(self):
page = getattr(self, "page_list", None)
if page is None: return
hidden = self.hidden_indices()
self.page_rows = [i for i in range(len(self.attr_maps)) if i not in hidden]
focus = page.focus_position if page.body else 0
page.body[:] = [self.attr_maps[i] for i in self.page_rows]
page.invalidate_layout()
if page.body: page.set_focus(min(focus, len(page.body)-1))
def fold_changed(self, heading_widget):
if getattr(heading_widget, "collapsed", False):
self.collapsed_headings.add(heading_widget)
else:
self.collapsed_headings.discard(heading_widget)
self.rebuild_visible()
try:
for pos, w in enumerate(self.page_list.body):
if getattr(w, "_original_widget", None) is heading_widget:
self.page_list.set_focus(pos)
break
except Exception: pass
def reveal_index(self, idx):
changed = False
for widget in list(getattr(self, "collapsed_headings", ())):
rng = getattr(self, "fold_sections", {}).get(widget)
if rng and rng[0] <= idx < rng[1]:
self.collapsed_headings.discard(widget)
try: widget.set_collapsed(False)
except Exception: pass
changed = True
if changed: self.rebuild_visible()
def parse_url(self, url): def parse_url(self, url):
path = None path = None
@ -1255,7 +682,6 @@ class Browser:
url = partial["url"] url = partial["url"]
pile = partial["pile"] pile = partial["pile"]
pile.contents = [(urwid.Text(f"Could not load partial {url}: The resource transfer failed"), pile.options())] pile.contents = [(urwid.Text(f"Could not load partial {url}: The resource transfer failed"), pile.options())]
self.refresh_page_layout()
except Exception as e: except Exception as e:
RNS.log(f"Error in partial failed callback: {e}", RNS.LOG_ERROR) RNS.log(f"Error in partial failed callback: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e) RNS.trace_exception(e)
@ -1274,7 +700,6 @@ class Browser:
partial["attr_maps"] = markup_to_attrmaps(strip_modifiers(partial["content"]), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) partial["attr_maps"] = markup_to_attrmaps(strip_modifiers(partial["content"]), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
pile = partial["pile"] pile = partial["pile"]
pile.contents = [(e, pile.options()) for e in partial["attr_maps"]] pile.contents = [(e, pile.options()) for e in partial["attr_maps"]]
self.refresh_page_layout()
except Exception as e: except Exception as e:
RNS.trace_exception(e) RNS.trace_exception(e)
@ -1288,7 +713,6 @@ class Browser:
pile = partial["pile"] pile = partial["pile"]
url = partial["url"] url = partial["url"]
pile.contents = [(urwid.Text(f"Could not load partial {url}: {e}"), pile.options())] pile.contents = [(urwid.Text(f"Could not load partial {url}: {e}"), pile.options())]
self.refresh_page_layout()
return return
if partial_destination_hash != self.loopback and not RNS.Transport.has_path(partial_destination_hash): if partial_destination_hash != self.loopback and not RNS.Transport.has_path(partial_destination_hash):
@ -1297,9 +721,10 @@ class Browser:
partial["pr_throttle"] = time.time()+15 partial["pr_throttle"] = time.time()+15
RNS.log(f"Requesting path for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) RNS.log(f"Requesting path for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME)
RNS.Transport.request_path(partial_destination_hash) RNS.Transport.request_path(partial_destination_hash)
pr_time = time.time()+self.path_timeout(partial_destination_hash) pr_time = time.time()+RNS.Transport.first_hop_timeout(partial_destination_hash)
while not RNS.Transport.has_path(partial_destination_hash): while not RNS.Transport.has_path(partial_destination_hash):
if time.time() > pr_time: return now = time.time()
if now > pr_time+self.timeout: return
time.sleep(0.25) time.sleep(0.25)
for pid in self.page_partials: for pid in self.page_partials:
@ -1324,7 +749,7 @@ class Browser:
partial["link"] = None partial["link"] = None
partial["link"] = RNS.Link(destination, established_callback = established, closed_callback = closed) partial["link"] = RNS.Link(destination, established_callback = established, closed_callback = closed)
timeout = time.time()+max(self.timeout, partial["link"].establishment_timeout) timeout = time.time()+self.timeout
while partial["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1) while partial["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1)
if partial["link"] and partial["link"].status == RNS.Link.ACTIVE and partial["request_id"] == None: if partial["link"] and partial["link"].status == RNS.Link.ACTIVE and partial["request_id"] == None:
@ -1388,7 +813,7 @@ class Browser:
pass # do nothing if checkbox is not check pass # do nothing if checkbox is not check
recurse_down(self.attr_maps) recurse_down(self.attr_maps)
RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME) RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG)
return request_data return request_data
@ -1536,9 +961,6 @@ class Browser:
def set_timeout(self, timeout): def set_timeout(self, timeout):
self.timeout = timeout self.timeout = timeout
def path_timeout(self, destination_hash):
return max(self.timeout + self.app.rns.get_first_hop_timeout(destination_hash), self.app.rns.get_medium_path_timeout())
def download_local_file(self, path): def download_local_file(self, path):
try: try:
file_path = self.app.filespath+path.replace("/file", "", 1) file_path = self.app.filespath+path.replace("/file", "", 1)
@ -1584,9 +1006,10 @@ class Browser:
self.status = Browser.PATH_REQUESTED self.status = Browser.PATH_REQUESTED
self.update_display() self.update_display()
pr_time = time.time()+self.path_timeout(self.destination_hash) pr_time = time.time()+RNS.Transport.first_hop_timeout(self.destination_hash)
while not RNS.Transport.has_path(self.destination_hash): while not RNS.Transport.has_path(self.destination_hash):
if time.time() > pr_time: now = time.time()
if now > pr_time+self.timeout:
self.request_timeout() self.request_timeout()
return return
@ -1966,9 +1389,10 @@ class Browser:
self.status = Browser.PATH_REQUESTED self.status = Browser.PATH_REQUESTED
self.update_display() self.update_display()
pr_time = time.time()+self.path_timeout(self.destination_hash) pr_time = time.time()+RNS.Transport.first_hop_timeout(self.destination_hash)
while not RNS.Transport.has_path(self.destination_hash): while not RNS.Transport.has_path(self.destination_hash):
if time.time() > pr_time: now = time.time()
if now > pr_time+self.timeout:
self.request_timeout() self.request_timeout()
return return
@ -2210,13 +1634,9 @@ class Browser:
def file_received(self, request_receipt): def file_received(self, request_receipt):
try: try:
if isinstance(request_receipt.response, bytes): if type(request_receipt.response) == io.BufferedReader:
markup = request_receipt.response.decode("utf-8")
self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
elif type(request_receipt.response) == io.BufferedReader:
if request_receipt.metadata != None: if request_receipt.metadata != None:
file_name = ConversationMessage.safe_attachment_name(request_receipt.metadata["name"]) file_name = os.path.basename(request_receipt.metadata["name"].decode("utf-8"))
file_handle = request_receipt.response file_handle = request_receipt.response
file_destination = self.app.downloads_path+"/"+file_name file_destination = self.app.downloads_path+"/"+file_name
@ -2235,7 +1655,7 @@ class Browser:
else: else:
file_name = request_receipt.response[0] file_name = request_receipt.response[0]
file_data = request_receipt.response[1] file_data = request_receipt.response[1]
file_destination_name = ConversationMessage.safe_attachment_name(file_name) file_destination_name = os.path.basename(file_name)
file_destination = self.app.downloads_path+"/"+file_destination_name file_destination = self.app.downloads_path+"/"+file_destination_name
counter = 0 counter = 0
@ -2276,8 +1696,10 @@ class Browser:
self.update_display() self.update_display()
if self.link != None: if self.link != None:
try: self.link.teardown() try:
except Exception as e: pass self.link.teardown()
except Exception as e:
pass
else: else:
self.status = Browser.REQUEST_FAILED self.status = Browser.REQUEST_FAILED
self.response_progress = 0 self.response_progress = 0
@ -2330,10 +1752,6 @@ class Browser:
self.update_display() self.update_display()
def last_response_speed(self):
if self.response_transfer_size and self.response_time:
return (self.response_transfer_size/max(self.response_time, 0.0001))*8
else: return None
def status_text(self): def status_text(self):
if self.status == Browser.DONE and self.response_transfer_size != None: if self.status == Browser.DONE and self.response_transfer_size != None:
@ -2343,8 +1761,8 @@ class Browser:
if self.saved_file_name == None: resp_size = self.response_size if self.saved_file_name == None: resp_size = self.response_size
else: resp_size = self.saved_file_size else: resp_size = self.saved_file_size
if resp_size: stats_string = " "+self.g["page"]+size_str(resp_size) if resp_size:
else: stats_string = " " stats_string = " "+self.g["page"]+size_str(resp_size)
if self.response_transfer_size: if self.response_transfer_size:
stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str
if self.response_transfer_size and self.response_time: if self.response_transfer_size and self.response_time:

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,6 @@ from datetime import datetime, timedelta
from nomadnet.Directory import DirectoryEntry from nomadnet.Directory import DirectoryEntry
from LXMF import pn_announce_data_is_valid, PN_META_NAME from LXMF import pn_announce_data_is_valid, PN_META_NAME
from nomadnet.Conversation import ConversationMessage from nomadnet.Conversation import ConversationMessage
from .FileBrowser import FileBrowser
from nomadnet.util import strip_modifiers from nomadnet.util import strip_modifiers
from nomadnet.util import sanitize_name from nomadnet.util import sanitize_name
@ -21,7 +20,7 @@ from nomadnet.util import sanitize_name
from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.util import MarkdownToMicron
from RNS.Utilities.rngit.highlight import SyntaxHighlighter from RNS.Utilities.rngit.highlight import SyntaxHighlighter
from .MicronParser import markup_to_attrmaps from .MicronParser import markup_to_attrmaps
from .Helpers import ClickableIcon, osc52_copy, qr_ascii from .Helpers import ClickableIcon, osc52_copy
from .ReadlineEdit import ReadlineMixin, ReadlineEdit from .ReadlineEdit import ReadlineMixin, ReadlineEdit
from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags
from nomadnet.ui import THEME_DARK, THEME_LIGHT from nomadnet.ui import THEME_DARK, THEME_LIGHT
@ -39,16 +38,16 @@ def relative_time(timestamp):
elif delta < 86400: elif delta < 86400:
h = int(delta / 3600) h = int(delta / 3600)
return str(h)+"h ago" return str(h)+"h ago"
elif delta < 172800:
return "yesterday"
elif delta < 604800:
d = int(delta / 86400)
return str(d)+"d ago"
elif delta < 2592000:
w = int(delta / 604800)
return str(w)+"w ago"
else: else:
days = (datetime.fromtimestamp(now).date() - datetime.fromtimestamp(timestamp).date()).days return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
if days <= 1:
return "yesterday"
elif days < 7:
return str(days)+"d ago"
elif days < 30:
return str(days // 7)+"w ago"
else:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
def _format_size(size): def _format_size(size):
@ -72,13 +71,13 @@ class ConversationDisplayShortcuts():
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
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") self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-s] Save [Tab] ↑ Messages"), "shortcutbar")
class ConversationBodyShortcuts(): class ConversationBodyShortcuts():
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
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") self.widget = urwid.AttrMap(urwid.Text("[C-s] Save [C-u] Purge [C-o] Sort [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar")
class TabButton(urwid.Button): class TabButton(urwid.Button):
button_left = urwid.Text("[") button_left = urwid.Text("[")
@ -573,10 +572,9 @@ class ConversationsDisplay():
def delete_selected_conversation(self): def delete_selected_conversation(self):
self.dialog_open = True self.dialog_open = True
item = self.ilb.get_selected_item() item = self.ilb.get_selected_item()
source_hash = getattr(item, "source_hash", None) if item is not None else None if item == None:
if source_hash is None:
self.dialog_open = False
return return
source_hash = item.source_hash
def dismiss_dialog(sender): def dismiss_dialog(sender):
self.dialog_open = False self.dialog_open = False
@ -652,7 +650,22 @@ class ConversationsDisplay():
self.show_qr_dialog(addr, title=display) self.show_qr_dialog(addr, title=display)
def show_qr_dialog(self, data, title=None): def show_qr_dialog(self, data, title=None):
qr_text = qr_ascii(data) qr_text = None
try:
import qrcode
try:
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1)
qr.add_data(data)
qr.make()
import io
buf = io.StringIO()
qr.print_ascii(out=buf, invert=False)
qr_text = buf.getvalue().rstrip("\n")
except Exception as e:
RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR)
qr_text = None
except Exception:
qr_text = None
def dismiss(_b): def dismiss(_b):
self._restore_listbox_pane() self._restore_listbox_pane()
@ -851,9 +864,8 @@ class ConversationsDisplay():
unknown_selected = True unknown_selected = True
trusted_selected = False trusted_selected = False
direct_selected = True direct_selected = True
propagated_selected = False propagated_selected = False
opportunistic_selected = False
pinned_initial = False pinned_initial = False
notes_initial = "" notes_initial = ""
@ -875,13 +887,9 @@ class ConversationsDisplay():
unknown_selected = False unknown_selected = False
trusted_selected = True trusted_selected = True
preferred = self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) if self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) == DirectoryEntry.PROPAGATED:
if preferred == DirectoryEntry.PROPAGATED:
direct_selected = False direct_selected = False
propagated_selected = True propagated_selected = True
elif preferred == DirectoryEntry.OPPORTUNISTIC:
direct_selected = False
opportunistic_selected = True
pinned_initial = existing_entry.sort_rank is not None pinned_initial = existing_entry.sort_rank is not None
notes_initial = getattr(existing_entry, "notes", "") or "" notes_initial = getattr(existing_entry, "notes", "") or ""
@ -898,9 +906,8 @@ class ConversationsDisplay():
r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected)
method_button_group = [] method_button_group = []
r_direct = urwid.RadioButton(method_button_group, "Deliver over direct link", state=direct_selected) r_direct = urwid.RadioButton(method_button_group, "Deliver directly", 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)
r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected)
def dismiss_dialog(sender): def dismiss_dialog(sender):
self.dialog_open = False self.dialog_open = False
@ -919,8 +926,6 @@ class ConversationsDisplay():
delivery = DirectoryEntry.DIRECT delivery = DirectoryEntry.DIRECT
if r_propagated.state == True: if r_propagated.state == True:
delivery = DirectoryEntry.PROPAGATED delivery = DirectoryEntry.PROPAGATED
elif r_opportunistic.state == True:
delivery = DirectoryEntry.OPPORTUNISTIC
sort_rank = 0 if cb_pin.state else None sort_rank = 0 if cb_pin.state else None
notes_value = e_notes.get_edit_text() notes_value = e_notes.get_edit_text()
@ -992,7 +997,6 @@ class ConversationsDisplay():
r_trusted, r_trusted,
urwid.Divider(g["divider1"]), urwid.Divider(g["divider1"]),
r_direct, r_direct,
r_opportunistic,
r_propagated, r_propagated,
urwid.Divider(g["divider1"]), urwid.Divider(g["divider1"]),
cb_pin, cb_pin,
@ -1058,7 +1062,7 @@ class ConversationsDisplay():
elif r_trusted.state == True: elif r_trusted.state == True:
trust_level = DirectoryEntry.TRUSTED trust_level = DirectoryEntry.TRUSTED
if not source_hash_text in [c[0] for c in existing_conversations]: if not source_hash in [c[0] for c in existing_conversations]:
entry = DirectoryEntry(source_hash, display_name, trust_level) entry = DirectoryEntry(source_hash, display_name, trust_level)
self.app.directory.remember(entry) self.app.directory.remember(entry)
@ -1069,7 +1073,7 @@ class ConversationsDisplay():
if trust_level != DirectoryEntry.TRUSTED: if trust_level != DirectoryEntry.TRUSTED:
if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED: if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED:
self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED) self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED)
self.display_conversation(None, source_hash_text) self.display_conversation(source_hash_text)
self.dialog_open = False self.dialog_open = False
self.update_conversation_list() self.update_conversation_list()
@ -1616,7 +1620,7 @@ class ConversationsDisplay():
if selected_hash is not None: if selected_hash is not None:
for idx, widget in enumerate(self.list_widgets): for idx, widget in enumerate(self.list_widgets):
if getattr(widget, "source_hash", None) == selected_hash: if widget.source_hash == selected_hash:
self.ilb.select_item(idx) self.ilb.select_item(idx)
break break
nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen()
@ -1653,7 +1657,7 @@ class ConversationsDisplay():
conversation_position = None conversation_position = None
index = 0 index = 0
for widget in self.list_widgets: for widget in self.list_widgets:
if getattr(widget, "source_hash", None) == source_hash: if widget.source_hash == source_hash:
conversation_position = index conversation_position = index
index += 1 index += 1
@ -1811,24 +1815,6 @@ class ListEntry(urwid.Text):
self._emit('click') self._emit('click')
return True 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): class MessageEdit(ReadlineMixin, urwid.Edit):
def keypress(self, size, key): def keypress(self, size, key):
if key == "ctrl d": if key == "ctrl d":
@ -1839,13 +1825,6 @@ class MessageEdit(ReadlineMixin, urwid.Edit):
self.delegate.attach_file() self.delegate.attach_file()
elif key == "ctrl s": elif key == "ctrl s":
self.delegate.save_focused_attachments() 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": elif key == "up":
y = self.get_cursor_coords(size)[1] y = self.get_cursor_coords(size)[1]
if y == 0: if y == 0:
@ -1904,8 +1883,6 @@ class ConversationFrame(urwid.Frame):
return super(ConversationFrame, self).keypress(size, key) return super(ConversationFrame, self).keypress(size, key)
class ConversationWidget(urwid.WidgetWrap): class ConversationWidget(urwid.WidgetWrap):
MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024
def __init__(self, source_hash, delegate): def __init__(self, source_hash, delegate):
self.app = nomadnet.NomadNetworkApp.get_shared_instance() self.app = nomadnet.NomadNetworkApp.get_shared_instance()
g = self.app.ui.glyphs g = self.app.ui.glyphs
@ -1925,7 +1902,6 @@ class ConversationWidget(urwid.WidgetWrap):
self.pending_attachments = [] self.pending_attachments = []
self.dialog_active = False self.dialog_active = False
self.editor_allowed = None self.editor_allowed = None
self.show_message_details = False
self.update_message_widgets() self.update_message_widgets()
@ -2146,18 +2122,9 @@ class ConversationWidget(urwid.WidgetWrap):
else: else:
hops_str = str(hops)+" hop" + ("s" if hops != 1 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 = [] right_parts = []
if stamp_cost is not None: if stamp_cost is not None:
right_parts.append("Stamp: "+str(stamp_cost)) right_parts.append("Stamp: "+str(stamp_cost))
right_parts.append("Via: "+via_str)
right_parts.append(g["speed"]+hops_str) right_parts.append(g["speed"]+hops_str)
left = " "+display_name left = " "+display_name
@ -2236,7 +2203,6 @@ class ConversationWidget(urwid.WidgetWrap):
return return
self.editor_allowed = allowed self.editor_allowed = allowed
if allowed: if allowed:
self.conversation.ensure_send_destination()
self.frame.contents["footer"] = (self._build_footer(), None) self.frame.contents["footer"] = (self._build_footer(), None)
else: else:
warning = urwid.AttrMap( warning = urwid.AttrMap(
@ -2251,34 +2217,6 @@ class ConversationWidget(urwid.WidgetWrap):
"msg_header_caution", "msg_header_caution",
) )
self.frame.contents["footer"] = (warning, None) 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): def toggle_focus_area(self):
name = "" name = ""
@ -2304,8 +2242,6 @@ class ConversationWidget(urwid.WidgetWrap):
elif key == "ctrl u": elif key == "ctrl u":
self.conversation.purge_failed() self.conversation.purge_failed()
self.conversation_changed(None) self.conversation_changed(None)
elif key == "ctrl k":
self.cancel_pending_send()
elif key == "ctrl t": elif key == "ctrl t":
self.toggle_editor() self.toggle_editor()
elif key == "ctrl x": elif key == "ctrl x":
@ -2319,61 +2255,9 @@ class ConversationWidget(urwid.WidgetWrap):
self.attach_file() self.attach_file()
elif key == "ctrl s": elif key == "ctrl s":
self.save_focused_attachments() self.save_focused_attachments()
elif key == "ctrl v":
self.cycle_delivery_method()
elif key == "ctrl r":
self.toggle_message_details()
else: else:
return key 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): def _on_conversation_changed_from_callback(self, conversation):
self.delegate._wake(lambda: self.conversation_changed(conversation)) self.delegate._wake(lambda: self.conversation_changed(conversation))
@ -2383,14 +2267,6 @@ class ConversationWidget(urwid.WidgetWrap):
self.update_message_widgets(replace = True) self.update_message_widgets(replace = True)
def update_message_widgets(self, replace = False): 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 = [] self.message_widgets = []
added_hashes = set() added_hashes = set()
needs_index = [] needs_index = []
@ -2423,19 +2299,10 @@ class ConversationWidget(urwid.WidgetWrap):
self.message_widgets.sort(key=lambda m: m.sort_timestamp, reverse=False) self.message_widgets.sort(key=lambda m: m.sort_timestamp, reverse=False)
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox
new_count = len(self.message_widgets) self.messagelist = IndicativeListBox(self.message_widgets, position = len(self.message_widgets)-1)
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" self.messagelist.name = "messagelist"
if replace: if replace:
current_body = self.frame.contents["body"][0] self.frame.contents["body"] = (self.messagelist, None)
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() nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen()
@ -2585,31 +2452,7 @@ class ConversationWidget(urwid.WidgetWrap):
def attach_file(self): def attach_file(self):
self.dialog_active = True self.dialog_active = True
initial_attachments = list(self.pending_attachments) browser = FileBrowserDialog(self)
def on_change(selected):
self.pending_attachments[:] = selected
self.frame.contents["footer"] = (self._build_footer(), None)
def on_done(selected):
self.pending_attachments[:] = selected
self.frame.contents["footer"] = (self._build_footer(), None)
self.file_browser_closed()
def on_cancel():
self.pending_attachments[:] = initial_attachments
self.frame.contents["footer"] = (self._build_footer(), None)
self.file_browser_closed()
browser = FileBrowser(
on_done=on_done,
on_cancel=on_cancel,
on_change=on_change,
title="Attach File",
multi_select=True,
selected=self.pending_attachments,
max_file_size=ConversationWidget.MAX_ATTACHMENT_SIZE,
)
bottom = self.messagelist bottom = self.messagelist
overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2)
self.frame.contents["body"] = (overlay, self.frame.options()) self.frame.contents["body"] = (overlay, self.frame.options())
@ -2752,7 +2595,6 @@ class LXMessageWidget(urwid.WidgetWrap):
app = nomadnet.NomadNetworkApp.get_shared_instance() app = nomadnet.NomadNetworkApp.get_shared_instance()
g = app.ui.glyphs g = app.ui.glyphs
self._conversation_widget = conversation_widget self._conversation_widget = conversation_widget
self.conv_message = message
self.timestamp = message.get_timestamp() self.timestamp = message.get_timestamp()
self.sort_timestamp = message.sort_timestamp self.sort_timestamp = message.sort_timestamp
self.transfer_done = False self.transfer_done = False
@ -2809,7 +2651,7 @@ class LXMessageWidget(urwid.WidgetWrap):
title_string = g["warning"]+" "+g["arrow_l"]+" "+message.get_signature_description() + "\n " + title_string title_string = g["warning"]+" "+g["arrow_l"]+" "+message.get_signature_description() + "\n " + title_string
if message.get_title() != "": if message.get_title() != "":
title_string += " | " + strip_modifiers(message.get_title()) title_string += " | " + message.get_title()
inbound_untrusted = False inbound_untrusted = False
if not is_outbound and msg_source_hash is not None: if not is_outbound and msg_source_hash is not None:
@ -2884,58 +2726,12 @@ class LXMessageWidget(urwid.WidgetWrap):
pile_widgets = [title] 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: if is_outbound and msg_state is not None and msg_state < LXMF.LXMessage.SENT and msg_hash is not None:
try: try:
for pending in app.message_router.pending_outbound: for pending in app.message_router.pending_outbound:
if pending.hash == msg_hash: if pending.hash == msg_hash:
self._live_lxm = pending if pending.representation == LXMF.LXMessage.RESOURCE:
self._live_lxm = pending
break break
except Exception: except Exception:
pass pass
@ -2949,16 +2745,7 @@ class LXMessageWidget(urwid.WidgetWrap):
else: else:
bar = "#" * filled + "-" * (bar_width - filled) bar = "#" * filled + "-" * (bar_width - filled)
self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%")
if conversation_widget is not None: pile_widgets.append(self.progress_attr)
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() self._start_progress_poll()
if markdown: pile_widgets.append(content_pile) if markdown: pile_widgets.append(content_pile)
@ -3129,6 +2916,188 @@ def _save_attachment_to_disk(filename, data):
return save_path return save_path
class FileBrowserEntry(urwid.WidgetWrap):
signals = ["click"]
def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False):
self.full_path = full_path
self.name = name
self.is_dir = is_dir
self.is_parent = is_parent
self.selected = selected
g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs
if is_parent:
display = g["arrow_l"]+" .."
elif is_dir:
display = g["arrow_r"]+" "+name+"/"
elif selected:
display = g["check"]+" "+name
else:
display = " "+name
self.text_widget = urwid.SelectableIcon(display, 0)
if is_dir or is_parent:
style = "list_trusted"
focus_style = "list_focus"
elif selected:
style = "list_trusted"
focus_style = "list_focus_trusted"
else:
style = "list_unknown"
focus_style = "list_focus"
display_widget = urwid.AttrMap(self.text_widget, style, focus_style)
super().__init__(display_widget)
def keypress(self, size, key):
if key == "enter":
self._emit("click")
else:
return key
def mouse_event(self, size, event, button, x, y, focus):
if button == 1 and urwid.util.is_mouse_press(event):
self._emit("click")
return True
return False
class FileBrowserDialog(urwid.WidgetWrap):
def __init__(self, delegate):
self.delegate = delegate
app = nomadnet.NomadNetworkApp.get_shared_instance()
self.g = app.ui.glyphs
self.current_path = os.path.expanduser("~")
self.path_label = urwid.Text("")
self.status_label = urwid.Text("")
self.file_walker = urwid.SimpleFocusListWalker([])
self.file_listbox = urwid.ListBox(self.file_walker)
self.button_columns = urwid.Columns([
(urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)),
(urwid.WEIGHT, 0.1, urwid.Text("")),
(urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)),
])
header_pile = urwid.Pile([
self.path_label,
self.status_label,
urwid.Divider(self.g["divider1"]),
])
footer_pile = urwid.Pile([
urwid.Divider(self.g["divider1"]),
self.button_columns,
])
self._populate()
self.browser_frame = urwid.Frame(
self.file_listbox,
header=header_pile,
footer=footer_pile,
)
linebox = urwid.LineBox(self.browser_frame, title="Attach File")
super().__init__(linebox)
def _update_status(self):
pending = self.delegate.pending_attachments
if pending:
names = [os.path.basename(p) for p in pending]
self.status_label.set_text(" "+self.g["file"]+" "+str(len(pending))+" selected: "+", ".join(names))
else:
self.status_label.set_text(" No files selected")
def _populate(self):
self.path_label.set_text(" "+self.current_path)
self._update_status()
focus_pos = None
try:
focus_pos = self.file_listbox.focus_position
except Exception:
pass
entries = []
parent = os.path.dirname(self.current_path)
if parent != self.current_path:
entry = FileBrowserEntry("..", parent, is_parent=True)
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
entries.append(entry)
try:
items = sorted(os.listdir(self.current_path))
except PermissionError:
entries.append(urwid.Text(("error_text", " Permission denied")))
self.file_walker[:] = entries
return
dirs = []
files = []
for item in items:
if item.startswith("."):
continue
full = os.path.join(self.current_path, item)
if os.path.isdir(full):
dirs.append((item, full))
elif os.path.isfile(full):
files.append((item, full))
for name, full in dirs:
entry = FileBrowserEntry(name, full, is_dir=True)
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
entries.append(entry)
for name, full in files:
is_selected = full in self.delegate.pending_attachments
entry = FileBrowserEntry(name, full, selected=is_selected)
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
entries.append(entry)
if not dirs and not files:
entries.append(urwid.Text(("inactive_text", " (empty)")))
self.file_walker[:] = entries
if focus_pos is not None and focus_pos < len(entries):
self.file_listbox.set_focus(focus_pos)
elif entries:
self.file_listbox.set_focus(0)
def _entry_clicked(self, entry_widget, user_data=None):
entry = user_data if user_data else entry_widget
if entry.is_dir or entry.is_parent:
self.current_path = entry.full_path
self._populate()
else:
if entry.full_path in self.delegate.pending_attachments:
self.delegate.pending_attachments.remove(entry.full_path)
else:
self.delegate.pending_attachments.append(entry.full_path)
self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None)
self._populate()
def _dismiss(self, sender):
self.delegate.file_browser_closed()
def _cancel(self, sender):
self.delegate.pending_attachments.clear()
self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None)
self.delegate.file_browser_closed()
def keypress(self, size, key):
if key == "esc":
self.delegate.file_browser_closed()
return
result = super().keypress(size, key)
if result == "down" and self.browser_frame.focus_position == "body":
self.browser_frame.focus_position = "footer"
return
elif result == "up" and self.browser_frame.focus_position == "footer":
self.browser_frame.focus_position = "body"
return
return result
class SyncProgressBar(urwid.ProgressBar): class SyncProgressBar(urwid.ProgressBar):
def get_text(self): def get_text(self):
status = nomadnet.NomadNetworkApp.get_shared_instance().get_sync_status() status = nomadnet.NomadNetworkApp.get_shared_instance().get_sync_status()

File diff suppressed because it is too large Load diff

View file

@ -1,234 +0,0 @@
import os
import nomadnet
import urwid
class FileBrowserEntry(urwid.WidgetWrap):
signals = ["click"]
def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False):
self.full_path = full_path
self.name = name
self.is_dir = is_dir
self.is_parent = is_parent
self.selected = selected
g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs
if is_parent:
display = g["arrow_l"]+" .."
elif is_dir:
display = g["arrow_r"]+" "+name+"/"
elif selected:
display = g["check"]+" "+name
else:
display = " "+name
self.text_widget = urwid.SelectableIcon(display, 0)
if is_dir or is_parent:
style = "list_trusted"
focus_style = "list_focus"
elif selected:
style = "list_trusted"
focus_style = "list_focus_trusted"
else:
style = "list_unknown"
focus_style = "list_focus"
display_widget = urwid.AttrMap(self.text_widget, style, focus_style)
super().__init__(display_widget)
def keypress(self, size, key):
if key == "enter":
self._emit("click")
else:
return key
def mouse_event(self, size, event, button, x, y, focus):
if button == 1 and urwid.util.is_mouse_press(event):
self._emit("click")
return True
return False
class FileBrowser(urwid.WidgetWrap):
def __init__(self, on_done=None, on_cancel=None, on_change=None, title="Select File", multi_select=True, start_path=None, selected=None, 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()
self.g = app.ui.glyphs
self.current_path = os.path.expanduser(start_path) if start_path else os.path.expanduser("~")
self.path_label = urwid.Text("")
self.status_label = urwid.Text("")
self.file_walker = urwid.SimpleFocusListWalker([])
self.file_listbox = urwid.ListBox(self.file_walker)
self.button_columns = urwid.Columns([
(urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)),
(urwid.WEIGHT, 0.1, urwid.Text("")),
(urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)),
])
header_pile = urwid.Pile([
self.path_label,
self.status_label,
urwid.Divider(self.g["divider1"]),
])
footer_pile = urwid.Pile([
urwid.Divider(self.g["divider1"]),
self.button_columns,
])
self._populate()
self.browser_frame = urwid.Frame(self.file_listbox, header=header_pile, footer=footer_pile)
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:
names = [os.path.basename(p) for p in self.selected]
self.status_label.set_text(" "+self.g["file"]+" "+str(len(self.selected))+" selected: "+", ".join(names))
elif self.multi_select:
self.status_label.set_text(" No files selected")
else:
self.status_label.set_text(" Select a file")
def _populate(self):
self.path_label.set_text(" "+self.current_path)
self._update_status()
focus_pos = None
try:
focus_pos = self.file_listbox.focus_position
except Exception:
pass
entries = []
parent = os.path.dirname(self.current_path)
if parent != self.current_path:
entry = FileBrowserEntry("..", parent, is_parent=True)
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
entries.append(entry)
try:
items = sorted(os.listdir(self.current_path))
except PermissionError:
entries.append(urwid.Text(("error_text", " Permission denied")))
self.file_walker[:] = entries
return
except Exception:
entries.append(urwid.Text(("error_text", " Cannot read directory")))
self.file_walker[:] = entries
return
dirs = []
files = []
for item in items:
if item.startswith("."):
continue
full = os.path.join(self.current_path, item)
if os.path.isdir(full):
dirs.append((item, full))
elif os.path.isfile(full):
files.append((item, full))
for name, full in dirs:
entry = FileBrowserEntry(name, full, is_dir=True)
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
entries.append(entry)
for name, full in files:
entry = FileBrowserEntry(name, full, selected=(full in self.selected))
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
entries.append(entry)
if not dirs and not files:
entries.append(urwid.Text(("inactive_text", " (empty)")))
self.file_walker[:] = entries
if focus_pos is not None and focus_pos < len(entries):
self.file_listbox.set_focus(focus_pos)
elif entries:
self.file_listbox.set_focus(0)
def _entry_clicked(self, entry_widget, user_data=None):
entry = user_data if user_data else entry_widget
if entry.is_dir or entry.is_parent:
self.current_path = entry.full_path
self._populate()
return
if self.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)
else:
self.selected.append(entry.full_path)
if self.on_change_cb is not None:
self.on_change_cb(list(self.selected))
self._populate()
else:
self.selected = [entry.full_path]
if self.on_change_cb is not None:
self.on_change_cb(list(self.selected))
if self.on_done_cb is not None:
self.on_done_cb(list(self.selected))
def _dismiss(self, sender):
if self.on_done_cb is not None:
self.on_done_cb(list(self.selected))
def _cancel(self, sender):
self.selected = []
if self.on_cancel_cb is not None:
self.on_cancel_cb()
def keypress(self, size, key):
if 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
result = super().keypress(size, key)
if result == "down" and self.browser_frame.focus_position == "body":
self.browser_frame.focus_position = "footer"
return None
elif result == "up" and self.browser_frame.focus_position == "footer":
self.browser_frame.focus_position = "body"
return None
return result

View file

@ -91,7 +91,6 @@ class GuideColumns(urwid.Columns):
class GuideLinkDelegate: class GuideLinkDelegate:
def __init__(self, app, reader=None): def __init__(self, app, reader=None):
self.app = app self.app = app
self.g = app.ui.glyphs
self.reader = reader self.reader = reader
self.last_keypress = 0 self.last_keypress = 0
@ -101,16 +100,6 @@ class GuideLinkDelegate:
def micron_released_focus(self): def micron_released_focus(self):
self.reader.focus_topics() self.reader.focus_topics()
def resolve_image(self, url):
if url.endswith("demo.webp"):
import os
import nomadnet.ui.textui.images as images_module
path = f"{os.path.dirname(images_module.__file__)}/demo.webp"
RNS.log(path)
return path
return None
def handle_link(self, target, fields=None): def handle_link(self, target, fields=None):
if not target: if not target:
return return
@ -183,7 +172,7 @@ class TopicList(urwid.WidgetWrap):
GuideEntry(self.app, self, guide_display, "Hosting a Node"), GuideEntry(self.app, self, guide_display, "Hosting a Node"),
GuideEntry(self.app, self, guide_display, "Configuration Options"), GuideEntry(self.app, self, guide_display, "Configuration Options"),
GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"), GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"),
GuideEntry(self.app, self, guide_display, "Micron Markup"), GuideEntry(self.app, self, guide_display, "Markup"),
self.first_run_entry, self.first_run_entry,
GuideEntry(self.app, self, guide_display, "Network Configuration"), GuideEntry(self.app, self, guide_display, "Network Configuration"),
GuideEntry(self.app, self, guide_display, "Display Test"), GuideEntry(self.app, self, guide_display, "Display Test"),
@ -509,9 +498,9 @@ By default, you can find the examples in `!~/.nomadnetwork/examples`!. If you bu
>>Authenticating Users >>Authenticating Users
Sometimes, you don't want everyone to be able to view certain pages, download certain files or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. Sometimes, you don't want everyone to be able to view certain pages or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests.
To enable authentication for any page or file, simply add a new file next to it with ".allowed" added to its file-name. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". The same works for files in your files directory, and the ".allowed" file itself is never served. To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed".
For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this: For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this:
@ -582,32 +571,8 @@ To add a new interface, press `!< Ctrl + A >`!. From here you can select which t
`Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b `Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b
>Interface Profiles
Interface profiles let you save different combinations of enabled interfaces and switch between them quickly. For example, you might keep a `!Home`! profile that uses a local AutoInterface, and a `!Travel`! profile that reaches out over a TCP gateway instead.
Press `!< Tab >`! from the `![ Interfaces ]`! section to move between the `!Interfaces`! and `!Profiles`! tabs.
To save your current set of enabled interfaces as a new profile, press `!< Ctrl + S >`!. To assign an interface to one or more profiles, edit it with `!< Ctrl + E >`! and use the profile checkboxes in the edit form. You can also toggle the selected interface on or off directly with `!< Ctrl + O >`!, and filter the list by All, Enabled or Disabled with `!< Ctrl + F >`!.
`Ffff`! (!) Note:`! Switching profiles changes which interfaces are enabled, so a restart of NomadNet or your Reticulum instance is required for the change to take effect.`f`b
>Interface Types >Interface Types
Jump to a specific interface type:
`F79d`_`[AutoInterface`#autointerface]`_`f
`F79d`_`[TCPClientInterface`#tcpclientinterface]`_`f
`F79d`_`[TCPServerInterface`#tcpserverinterface]`_`f
`F79d`_`[UDPInterface`#udpinterface]`_`f
`F79d`_`[I2PInterface`#i2pinterface]`_`f
`F79d`_`[RNodeInterface`#rnodeinterface]`_`f
`F79d`_`[RNodeMultiInterface`#rnodemultiinterface]`_`f
`F79d`_`[SerialInterface`#serialinterface]`_`f
`F79d`_`[KISSInterface`#kissinterface]`_`f
`F79d`_`[PipeInterface`#pipeinterface]`_`f
>>AutoInterface >>AutoInterface
The Auto Interface enables communication with other discoverable Reticulum nodes over autoconfigured IPv6 and UDP. It does not need any functional IP infrastructure like routers or DHCP servers, but will require at least some sort of switching medium between peers (a wired switch, a hub, a WiFi access point or similar). The Auto Interface enables communication with other discoverable Reticulum nodes over autoconfigured IPv6 and UDP. It does not need any functional IP infrastructure like routers or DHCP servers, but will require at least some sort of switching medium between peers (a wired switch, a hub, a WiFi access point or similar).
@ -1182,17 +1147,6 @@ Tells the program what color palette is supported by the terminal. Most terminal
`!24bit`! Most new terminals support this full-color mode `!24bit`! Most new terminals support this full-color mode
< <
>>>
`!image_loading = auto`!
>>>>
Specifies how, if at all, images on pages should be loaded. Available options:
>>>>>
`!never`! Never load any images
`!manual`! Only load images when manually requseted with Ctrl-L
`!auto`! Load images automatically if link conditions are sufficient
`!always`! Always load images, regardless of link RTT and EDR
<
>>> >>>
`!theme = dark`! `!theme = dark`!
>>>> >>>>
@ -1403,15 +1357,6 @@ Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0
`cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions. `cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions.
`` ``
>>Image Rendering Test
`(The RNS logo`w=n`a=c`:/media/demo.webp)
If you see the RNS logo above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, iTerm2 and others.
It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either.
''' '''
@ -1475,13 +1420,11 @@ With micron you can easily create structured documents and pages with formatting
`F79d`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f `F79d`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f
`F79d`_`[Micron Tags`#micron-tags]`_`f `F79d`_`[Micron Tags`#micron-tags]`_`f
`F79d`_`[High Level Stuff`#high-level-stuff]`_`f `F79d`_`[High Level Stuff`#high-level-stuff]`_`f
`F79d`_`[Collapsible Sections`#collapsible-sections]`_`f
`F79d`_`[Colors`#colors]`_`f `F79d`_`[Colors`#colors]`_`f
`F79d`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f `F79d`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f
`F79d`_`[Links`#links]`_`f `F79d`_`[Links`#links]`_`f
`F79d`_`[Anchors`#anchors]`_`f `F79d`_`[Anchors`#anchors]`_`f
`F79d`_`[Tables`#tables]`_`f `F79d`_`[Tables`#tables]`_`f
`F79d`_`[Images`#images]`_`f
`F79d`_`[Fields & Requests`#fields-requests]`_`f `F79d`_`[Fields & Requests`#fields-requests]`_`f
`F79d`_`[Comments`#comments]`_`f `F79d`_`[Comments`#comments]`_`f
`F79d`_`[Partials`#partials]`_`f `F79d`_`[Partials`#partials]`_`f
@ -1644,32 +1587,6 @@ A sub sub section. We could continue, but you get the point.
Wait! It's worth noting that we can also create sections without headings. They look like this. Wait! It's worth noting that we can also create sections without headings. They look like this.
`` ``
>Collapsible Sections
Any section heading can be made collapsible, turning it into an accordion the reader can fold away and reveal on demand. Prefix a heading with \\`+ to make it foldable and open by default, or with \\`- to make it foldable but start collapsed:
`Faaa
`=
`+>Open by default
This text can be folded away by selecting the heading.
`->Hidden until expanded
This starts collapsed, and appears when the reader expands it.
`=
``
A collapsible heading folds everything beneath it, up to the next heading of the same or a higher level, exactly like a normal section. It can therefore contain anything: text, tables, input fields, sub-sections, and even live partials.
To fold or unfold a section, move onto its heading and press Enter or Space, or click it with the mouse. Open sections are marked with ▾ and collapsed ones with ▸.
You can choose your own indicators for a page by placing a #!fold directive near the top, giving the open glyph followed by the collapsed glyph:
`Faaa
`=
#!fold [-] [+]
`=
``
>Colors >Colors
@ -1808,32 +1725,6 @@ The above markup produces the following table:
| Orange | Ask, nicely | 3 | | Orange | Ask, nicely | 3 |
`t `t
>Images
You can include images in pages by using the image tag. All images must specify at least an alt-text and a URL, and can optionally specify rendering options such as width, height and alignment. The alt-text is required so a meaningful placeholder can be displayed for clients that do not support image rendering.
Width (`Faaaw=`f) and height (`Faaah=`f) can be specified either in terminal columns/rows, in percentages, or using the `Faaan`f specifier, which will render the image as close to its native resolution as possible
Alignment (`Faaaa=`f) can be either `Faaaleft`f (`Faaal`f), `Faaaright`f (`Faaar`f) or `Faaacenter`f (`Faaac`f).
`Faaa
`=
`(The RNS logo`w=n`a=c`:/media/demo.webp)
`=
``
Images are block-level elements, and must be placed independently on their own line.
If your terminal supports image rendering, the image will be displayed as specified. If not, a text placeholder is shown instead:
`(The RNS logo`w=n`a=c`:/media/demo.webp)
If you see the image above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, iTerm2 and others.
It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either.
`!Important!`! For the sake of bandwidth efficiency, images sent over the network `!must`! be in WebP format. Nomadnet will reject and ignore all other image formats. You can easily convert images to WebP with command-line utilities such as `Faaaconvert`f and similar. The WebP format supports both efficient compression and lossless alpha masks, so it is suitable for anything you can achieve with PNG, JPEG or other formats, but much lighter on bandwidth.
>Fields & Requests >Fields & Requests
Nomad Network lets you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. Nomad Network lets you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables.
@ -1890,14 +1781,6 @@ A sized input field: `B444`<16|with_size`>`b
`= `=
`` ``
You can also reserve more than one row for a field by appending `!xROWS`! to the width, which is useful for longer, multi-line input. This creates a field that is 40 columns wide and 5 rows tall:
`Faaa
`=
A multi-row input field: `B444`<40x5|notes`>`b
`=
``
It is possible to mask fields, for example for use with passwords and similar: It is possible to mask fields, for example for use with passwords and similar:
`Faaa `Faaa
@ -1926,8 +1809,6 @@ A sized input field: `B444`<16|with_size`>`B333
A masked input field: `B444`<!|masked_demo`hidden text>`B333 A masked input field: `B444`<!|masked_demo`hidden text>`B333
A multi-row input field: `B444`<40x5|notes`>`B333
Full control: `B444`<!32|all_options`hidden text>`B333 Full control: `B444`<!32|all_options`hidden text>`B333
`b `b
@ -2048,7 +1929,7 @@ TOPICS = {
"Hosting a Node": TOPIC_HOSTING, "Hosting a Node": TOPIC_HOSTING,
"Configuration Options": TOPIC_CONFIG, "Configuration Options": TOPIC_CONFIG,
"Keyboard Shortcuts": TOPIC_SHORTCUTS, "Keyboard Shortcuts": TOPIC_SHORTCUTS,
"Micron Markup": TOPIC_MARKUP, "Markup": TOPIC_MARKUP,
"Display Test": TOPIC_DISPLAYTEST, "Display Test": TOPIC_DISPLAYTEST,
"Network Configuration": TOPIC_NETWORKS, "Network Configuration": TOPIC_NETWORKS,
"Credits & Licenses": TOPIC_LICENSES, "Credits & Licenses": TOPIC_LICENSES,

View file

@ -17,21 +17,6 @@ def osc52_copy(text):
return False return False
def qr_ascii(data):
try:
import qrcode
import io
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1)
qr.add_data(data)
qr.make()
buf = io.StringIO()
qr.print_ascii(out=buf, invert=False)
return buf.getvalue().rstrip("\n")
except Exception as e:
RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR)
return None
class ClickableIcon(urwid.Text): class ClickableIcon(urwid.Text):
_selectable = False _selectable = False

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,6 @@ import itertools
import mmap import mmap
import urwid import urwid
import nomadnet import nomadnet
from nomadnet.util import strip_modifiers
class LogDisplayShortcuts(): class LogDisplayShortcuts():
@ -56,20 +55,16 @@ class LogTerminal(urwid.WidgetWrap):
def keypress(self, size, key): def keypress(self, size, key):
if key == "ctrl q":
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.quit()
return None
if key == "up": if key == "up":
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
return super(LogTerminal, self).keypress(size, key) return super(LogTerminal, self).keypress(size, key)
class LogTail(urwid.WidgetWrap): class LogTail(urwid.WidgetWrap):
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.log_tail = urwid.Text(strip_modifiers(tail(self.app.logfilepath, 50))) self.log_tail = urwid.Text(tail(self.app.logfilepath, 50))
self.log = urwid.Scrollable(self.log_tail) self.log = urwid.Scrollable(self.log_tail)
self.log.set_scrollpos(-1) self.log.set_scrollpos(-1)
self.log_scrollbar = urwid.ScrollBar(self.log) self.log_scrollbar = urwid.ScrollBar(self.log)

View file

@ -6,12 +6,9 @@ from .Channels import *
from .Directory import * from .Directory import *
from .Config import * from .Config import *
from .Interfaces import * from .Interfaces import *
from .Transport import *
from .Utilities import *
from .Map import * from .Map import *
from .Log import * from .Log import *
from .Guide import * from .Guide import *
from .Editor import *
import urwid import urwid
class SubDisplays(): class SubDisplays():
@ -23,23 +20,15 @@ class SubDisplays():
self.directory_display = DirectoryDisplay(self.app) self.directory_display = DirectoryDisplay(self.app)
self.config_display = ConfigDisplay(self.app) self.config_display = ConfigDisplay(self.app)
self.interface_display = InterfaceDisplay(self.app) self.interface_display = InterfaceDisplay(self.app)
self.utilities_display = UtilitiesDisplay(self.app) if self._utilities_enabled(app) else None
self.map_display = MapDisplay(self.app) self.map_display = MapDisplay(self.app)
self.log_display = LogDisplay(self.app) self.log_display = LogDisplay(self.app)
self.guide_display = GuideDisplay(self.app) self.guide_display = GuideDisplay(self.app)
self.page_editor_display = PageEditorDisplay(self.app) if app.enable_node else None
if app.firstrun: if app.firstrun:
self.active_display = self.guide_display self.active_display = self.guide_display
else: else:
self.active_display = self.conversations_display self.active_display = self.conversations_display
def _utilities_enabled(self, app):
try:
return app.config["utilities"].as_bool("enable_utilities")
except Exception:
return False
def active(self): def active(self):
return self.active_display return self.active_display
@ -96,18 +85,6 @@ class MainFrame(urwid.Frame):
return super(MainFrame, self).keypress(size, key) return super(MainFrame, self).keypress(size, key)
class QuitDialogLineBox(urwid.LineBox):
def __init__(self, *args, on_cancel=None, **kwargs):
self.on_cancel = on_cancel
super().__init__(*args, **kwargs)
def keypress(self, size, key):
if key == "esc":
if self.on_cancel is not None:
self.on_cancel()
return None
return super().keypress(size, key)
class MainDisplay(): class MainDisplay():
def __init__(self, ui, app): def __init__(self, ui, app):
self.ui = ui self.ui = ui
@ -150,13 +127,6 @@ class MainDisplay():
self.update_active_sub_display() self.update_active_sub_display()
self.sub_displays.interface_display.start() self.sub_displays.interface_display.start()
def show_utilities(self, user_data):
if self.sub_displays.utilities_display is None:
return
self.sub_displays.active_display = self.sub_displays.utilities_display
self.update_active_sub_display()
self.sub_displays.utilities_display.start()
def show_log(self, user_data): def show_log(self, user_data):
self.sub_displays.active_display = self.sub_displays.log_display self.sub_displays.active_display = self.sub_displays.log_display
self.sub_displays.log_display.show() self.sub_displays.log_display.show()
@ -166,14 +136,6 @@ class MainDisplay():
self.sub_displays.active_display = self.sub_displays.guide_display self.sub_displays.active_display = self.sub_displays.guide_display
self.update_active_sub_display() self.update_active_sub_display()
def show_page_editor(self, user_data):
if not self.app.enable_node:
return
self.sub_displays.active_display = self.sub_displays.page_editor_display
self.update_active_sub_display()
self.sub_displays.page_editor_display.start()
self.frame.focus_position = "body"
def update_active_sub_display(self): def update_active_sub_display(self):
self.frame.contents["body"] = (self.sub_displays.active().widget, None) self.frame.contents["body"] = (self.sub_displays.active().widget, None)
self.update_active_shortcuts() self.update_active_shortcuts()
@ -194,71 +156,6 @@ class MainDisplay():
self.menu_display.start() self.menu_display.start()
def quit(self, sender=None): def quit(self, sender=None):
if self.app.peer_settings["quit_confirm"]: self.show_quit_dialog()
else: self.do_quit()
def show_quit_dialog(self, sender=None):
if getattr(self, "_quit_dialog_open", False): return
active = self.sub_displays.active_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
dont_ask_again = urwid.CheckBox("Don't ask again")
def confirm(button=None):
if dont_ask_again.get_state():
try:
self.app.peer_settings["quit_confirm"] = False
self.app.save_peer_settings()
except Exception: pass
self.do_quit()
def cancel(button=None):
self._quit_dialog_open = False
self.app.ui.loop.widget = self.frame
buttons = urwid.Columns([
(urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirm)),
(urwid.WEIGHT, 0.1, urwid.Text("")),
(urwid.WEIGHT, 0.45, urwid.Button("No", on_press=cancel)),
])
try: buttons.focus_position = 2 # default to "No"
except Exception: pass
pile = urwid.Pile([
urwid.Text("Are you sure you want to quit?", align="center"),
urwid.Divider(),
buttons,
urwid.Divider(),
dont_ask_again,
])
dialog = QuitDialogLineBox(
urwid.Filler(pile, urwid.TOP),
title="Quit NomadNet",
on_cancel=cancel,
)
self._quit_dialog_open = True
self.app.ui.loop.widget = urwid.Overlay(
dialog, self.frame,
align="center", width=44,
valign="middle", height=7, min_width=20,
)
def show_notice(self, text):
def close(button=None): self.app.ui.loop.widget = self.frame
pile = urwid.Pile([urwid.Text(text, align="center"), urwid.Divider(), urwid.Button("OK", on_press=close)])
dialog = QuitDialogLineBox(urwid.Filler(pile, urwid.TOP), title="Notice", on_cancel=close)
self.app.ui.loop.widget = urwid.Overlay(dialog, self.frame, align="center", width=64, valign="middle", height=9, min_width=20)
def do_quit(self, sender=None):
logterm_pid = None logterm_pid = None
if True or RNS.vendor.platformutils.is_android(): if True or RNS.vendor.platformutils.is_android():
if self.sub_displays.log_display != None and self.sub_displays.log_display.log_term != None: if self.sub_displays.log_display != None and self.sub_displays.log_display.log_term != None:
@ -297,27 +194,14 @@ class MenuDisplay():
button_log = (7, MenuButton("Log", on_press=handler.show_log)) button_log = (7, MenuButton("Log", on_press=handler.show_log))
button_config = (10, MenuButton("Config", on_press=handler.show_config)) button_config = (10, MenuButton("Config", on_press=handler.show_config))
button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces))
button_utilities = (13, MenuButton("Utilities", on_press=handler.show_utilities))
button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) button_guide = (9, MenuButton("Guide", on_press=handler.show_guide))
button_mynode = (11, MenuButton("My Node", on_press=handler.show_page_editor))
button_quit = (8, MenuButton("Quit", on_press=handler.quit)) button_quit = (8, MenuButton("Quit", on_press=handler.quit))
# buttons = [menu_text, button_conversations, button_node, button_directory, button_map] # buttons = [menu_text, button_conversations, button_node, button_directory, button_map]
buttons = [menu_text, button_conversations, button_network] if self.app.config["textui"]["hide_guide"]:
if self.app.enable_node: buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_quit]
buttons.append(button_mynode) else:
buttons += [button_channels, button_log, button_interfaces] buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_guide, button_quit]
enable_utilities = False
try:
enable_utilities = self.app.config["utilities"].as_bool("enable_utilities")
except Exception:
enable_utilities = False
if enable_utilities:
buttons.append(button_utilities)
buttons.append(button_config)
if not self.app.config["textui"]["hide_guide"]:
buttons.append(button_guide)
buttons.append(button_quit)
columns = MenuColumns(buttons, dividechars=1) columns = MenuColumns(buttons, dividechars=1)
columns.handler = handler columns.handler = handler

View file

@ -8,9 +8,6 @@ from urwid.util import is_mouse_press
from urwid.text_layout import calc_coords from urwid.text_layout import calc_coords
from .ReadlineEdit import ReadlineEdit from .ReadlineEdit import ReadlineEdit
from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.util import MarkdownToMicron
from nomadnet.util import STRIP_CONTROL_RE
try: from .images import ImageWidget
except Exception: ImageWidget = None
DEFAULT_FG_DARK = "ddd" DEFAULT_FG_DARK = "ddd"
DEFAULT_FG_LIGHT = "222" DEFAULT_FG_LIGHT = "222"
@ -39,15 +36,6 @@ SECTION_INDENT = 2
INDENT_RIGHT = 1 INDENT_RIGHT = 1
MAX_TABLE_WIDTH = 100 MAX_TABLE_WIDTH = 100
DEFAULT_FOLD_GLYPHS = ("▾", "▸")
def default_fold_glyphs():
try:
g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs
return (g.get("fold_open", DEFAULT_FOLD_GLYPHS[0]), g.get("fold_closed", DEFAULT_FOLD_GLYPHS[1]))
except Exception:
return DEFAULT_FOLD_GLYPHS
def default_state(fg=None, bg=None): def default_state(fg=None, bg=None):
global SELECTED_STYLES global SELECTED_STYLES
ensure_selected_styles() ensure_selected_styles()
@ -104,7 +92,6 @@ def ensure_selected_styles():
else: SELECTED_STYLES = STYLES_LIGHT else: SELECTED_STYLES = STYLES_LIGHT
def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None): def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None):
markup = STRIP_CONTROL_RE.sub("", markup)
global LINK_CLASS global LINK_CLASS
if link_class: LINK_CLASS = link_class if link_class: LINK_CLASS = link_class
else: LINK_CLASS = LinkableText else: LINK_CLASS = LinkableText
@ -145,26 +132,15 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None
if state.get("_header_pending"): if state.get("_header_pending"):
state["header_rows"].append(row_index) state["header_rows"].append(row_index)
state.setdefault("header_levels", {})[row_index] = state["depth"]
cp = state.get("_collapsible_pending")
if cp is not None:
state.setdefault("collapsibles", {})[row_index] = bool(cp["collapsed"])
state["_collapsible_pending"] = None
state["_header_pending"] = False state["_header_pending"] = False
row_levels = state.setdefault("row_levels", {})
for display_widget in display_widgets: for display_widget in display_widgets:
row_levels[len(attrmaps)] = state["depth"]
attrmap = urwid.AttrMap(display_widget, make_style(state)) attrmap = urwid.AttrMap(display_widget, make_style(state))
attrmaps.append(attrmap) attrmaps.append(attrmap)
try: try:
setattr(attrmaps, "anchors", anchors) setattr(attrmaps, "anchors", anchors)
setattr(attrmaps, "header_rows", list(state.get("header_rows", []))) setattr(attrmaps, "header_rows", list(state.get("header_rows", [])))
setattr(attrmaps, "header_levels", dict(state.get("header_levels", {})))
setattr(attrmaps, "row_levels", dict(state.get("row_levels", {})))
setattr(attrmaps, "collapsibles", dict(state.get("collapsibles", {})))
setattr(attrmaps, "fold_glyphs", state.get("fold_glyphs") or default_fold_glyphs())
except Exception: except Exception:
pass pass
@ -218,76 +194,6 @@ def parse_partial(line):
except Exception as e: return None except Exception as e: return None
def parse_image(line, state, url_delegate):
try:
endpos = line.rfind(")")
if endpos <= 0: return None
image_data = line[:endpos]
fields = image_data.split("`")
if len(fields) < 2: return None
alt_text = fields[0].strip()
image_url = fields[-1].strip()
properties = fields[1:-1]
width = None
height = None
align = None
for prop in properties:
if "=" not in prop: continue
key, _, value = prop.partition("=")
key = key.strip()
value = value.strip()
if key == "w": width = value
elif key == "h": height = value
elif key == "a": align = value
if align == "c": align = "center"
if align == "l": align = "left"
if align == "r": align = "right"
widget = None
if image_url:
try: path = url_delegate.resolve_image(image_url)
except Exception as e:
RNS.trace_exception(e) # TODO: Remove
path = None
if ImageWidget is not None:
try:
if not ImageWidget.placeholder_glyph: ImageWidget.placeholder_glyph = f"{url_delegate.g['image']} "
widget = ImageWidget(path, name=alt_text)
for prop_name, prop_value in ( ("width", width), ("height", height), ("align", align) ):
if prop_value is not None:
try:
if isinstance(prop_value, str) and prop_value.isdigit(): prop_value = int(prop_value)
setattr(widget, prop_name, prop_value)
except Exception as e:
RNS.trace_exception(e) # TODO: Remove
pass
except Exception as e:
RNS.trace_exception(e) # TODO: Remove
widget = None
if widget is None:
placeholder = alt_text if alt_text else (image_url or "image")
widget = urwid.Text(f"[{url_delegate.g['warning']} Error loading image: {placeholder}]")
widget.image_url = image_url
widget.image_alt = alt_text
if state["depth"] == 0: return [widget]
else:
padded = urwid.Padding(widget, left=left_indent(state), right=right_indent(state))
padded._contained_image = widget
return [padded]
except Exception as e:
# TODO: Remove
RNS.trace_exception(e)
return None
def render_table(lines, state, url_delegate): def render_table(lines, state, url_delegate):
if len(lines) < 2: return None if len(lines) < 2: return None
if state["table_maxwidth"]: max_width = state["table_maxwidth"] if state["table_maxwidth"]: max_width = state["table_maxwidth"]
@ -311,168 +217,6 @@ def render_table(lines, state, url_delegate):
return widgets if widgets else None return widgets if widgets else None
class FormColumns(urwid.Columns):
MIN_FIELD_WIDTH = 4
@staticmethod
def _natural_width(widget, maxcol, focused):
# mirror the width urwid.Columns itself computes for a PACK column
if not isinstance(widget, urwid.Widget):
return 0
sizing = widget.sizing()
if sizing & frozenset((urwid.FIXED, urwid.FLOW)):
width = 0
if urwid.FIXED in sizing:
width = widget.pack((), focused)[0]
if urwid.FLOW in sizing and (not width or width > maxcol):
width = widget.pack((maxcol,), focused)[0]
return width
return widget.pack((maxcol,), focused)[0]
def column_widths(self, size, focus=False):
contents = self.contents
field_idxs = [i for i, (w, (t, n, b)) in enumerate(contents) if t == urwid.GIVEN]
if not field_idxs:
return super().column_widths(size, focus)
maxcol = size[0]
nat = []
for i, (w, (t, width, b)) in enumerate(contents):
if t == urwid.GIVEN:
nat.append(width)
elif t == urwid.PACK:
nat.append(self._natural_width(w, maxcol, focus and i == self.focus_position))
else:
return super().column_widths(size, focus)
dividers = self.dividechars * max(0, len(nat) - 1)
field_set = set(field_idxs)
fixed = sum(w for i, w in enumerate(nat) if i not in field_set)
requested = sum(nat[i] for i in field_idxs)
available = maxcol - dividers - fixed
if available >= requested:
return super().column_widths(size, focus)
if available < self.MIN_FIELD_WIDTH * len(field_idxs):
return super().column_widths(size, focus)
overflow = requested - available
slack = {i: max(0, nat[i] - self.MIN_FIELD_WIDTH) for i in field_idxs}
total_slack = sum(slack.values())
if total_slack < overflow:
return super().column_widths(size, focus)
widths = list(nat)
removed = {i: min(slack[i], overflow * slack[i] // total_slack) for i in field_idxs}
shortfall = overflow - sum(removed.values())
# Hand out any rounding remainder one column at a time.
pending = [i for i in field_idxs if removed[i] < slack[i]]
k = 0
while shortfall > 0 and pending:
i = pending[k % len(pending)]
removed[i] += 1
shortfall -= 1
if removed[i] >= slack[i]:
pending = [j for j in pending if removed[j] < slack[j]]
k = 0
else:
k += 1
for i in field_idxs:
widths[i] = nat[i] - removed[i]
return widths
class MicronField(ReadlineEdit):
def __init__(self, *args, min_rows=1, **kwargs):
try: self._min_rows = max(1, int(min_rows))
except (TypeError, ValueError): self._min_rows = 1
super().__init__(*args, **kwargs)
def rows(self, size, focus=False):
return max(self._min_rows, super().rows(size, focus))
def render(self, size, focus=False):
canv = super().render(size, focus)
if canv.rows() < self._min_rows:
canv = urwid.CompositeCanvas(canv)
canv.pad_trim_top_bottom(0, self._min_rows - canv.rows())
return canv
class CollapsibleHeading(urwid.WidgetWrap):
_selectable = True
ignore_focus = False
def __init__(self, segments, align, heading_style, depth,
collapsed=False, glyphs=None, delegate=None):
self._segments = list(segments)
self.align = align
self.heading_style = heading_style
self.depth = depth
self.collapsed = bool(collapsed)
self.glyphs = glyphs if glyphs is not None else default_fold_glyphs()
self.delegate = delegate
self.is_collapsible_heading = True
super().__init__(self._build())
def selectable(self):
return True
def _glyph(self):
return self.glyphs[1] if self.collapsed else self.glyphs[0]
def _glyph_col(self):
if self._segments and isinstance(self._segments[0], str):
return len(self._segments[0])
return 0
def _build(self):
segs = list(self._segments)
marker = self._glyph() + " "
if segs and isinstance(segs[0], str):
segs.insert(1, marker)
else:
segs.insert(0, marker)
return urwid.AttrMap(urwid.Text(segs, align=self.align), self.heading_style)
def set_collapsed(self, collapsed):
collapsed = bool(collapsed)
if collapsed != self.collapsed:
self.collapsed = collapsed
self._w = self._build()
self._invalidate()
def toggle(self):
self.collapsed = not self.collapsed
self._w = self._build()
self._invalidate()
if self.delegate is not None and hasattr(self.delegate, "fold_changed"):
self.delegate.fold_changed(self)
def keypress(self, size, key):
if self._command_map[key] == urwid.ACTIVATE or key == " ":
self.toggle()
return None
return key
def mouse_event(self, size, event, button, x, y, focus):
if button == 1 and is_mouse_press(event):
self.toggle()
return True
return False
def render(self, size, focus=False):
canv = super().render(size, focus)
if focus:
canv = urwid.CompositeCanvas(canv)
gx = self._glyph_col()
if self.align == "left" and gx < size[0]:
canv.cursor = (gx, 0)
return canv
def parse_line(line, state, url_delegate): def parse_line(line, state, url_delegate):
pre_escape = False pre_escape = False
if len(line) > 0: if len(line) > 0:
@ -485,14 +229,6 @@ def parse_line(line, state, url_delegate):
# Only parse content if not in literal state # Only parse content if not in literal state
if not state["literal"]: if not state["literal"]:
collapsible_heading = False
collapsed_initial = False
if (line.startswith("`+") or line.startswith("`-")) and line[2:3] == ">":
collapsible_heading = True
collapsed_initial = line[1] == "-"
line = line[2:]
first_char = line[0]
# Apply markup sanitization # Apply markup sanitization
if first_char == ">" and "`<" in line: if first_char == ">" and "`<" in line:
# Remove heading status from lines containing fields # Remove heading status from lines containing fields
@ -506,10 +242,6 @@ def parse_line(line, state, url_delegate):
# Check for comments # Check for comments
elif first_char == "#": elif first_char == "#":
parts = line.split()
if parts and parts[0] == "#!fold":
if len(parts) >= 3: state["fold_glyphs"] = (parts[1], parts[2])
elif len(parts) == 2: state["fold_glyphs"] = (parts[1], parts[1])
return None return None
# Check for tables # Check for tables
@ -546,10 +278,6 @@ def parse_line(line, state, url_delegate):
elif line.startswith("`{"): elif line.startswith("`{"):
return parse_partial(line[2:]) return parse_partial(line[2:])
# Check for images
elif line.startswith("`("):
return parse_image(line[2:], state, url_delegate)
# Check for section heading reset # Check for section heading reset
elif first_char == "<": elif first_char == "<":
state["depth"] = 0 state["depth"] = 0
@ -587,11 +315,6 @@ def parse_line(line, state, url_delegate):
heading_style = first_style heading_style = first_style
output.insert(0, " "*left_indent(state)) output.insert(0, " "*left_indent(state))
if collapsible_heading:
state["_collapsible_pending"] = {"collapsed": collapsed_initial}
glyphs = state.get("fold_glyphs") or default_fold_glyphs()
return [CollapsibleHeading(output, state["align"], heading_style,
state["depth"], collapsed_initial, glyphs, url_delegate)]
return [urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)] return [urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)]
else: else:
return None return None
@ -603,14 +326,14 @@ def parse_line(line, state, url_delegate):
if len(line) == 2: if len(line) == 2:
divider_char = line[1] divider_char = line[1]
# Control characters don't make sense here and otherwise crash nomadnet # Control characters don't make sense here and otherwise crash nomadnet
if ord(divider_char) < 32: divider_char = "\u2500" if ord(divider_char) < 32:
# Check if the divider char is renderable divider_char = "\u2500"
try: urwid.Divider(divider_char).render((30,)) else:
except Exception as e: divider_char = "\u2500" divider_char = "\u2500"
else: divider_char = "\u2500" if state["depth"] == 0:
return [urwid.Divider(divider_char)]
if state["depth"] == 0: return [urwid.Divider(divider_char)] else:
else: return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))] return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))]
output = make_output(state, line, url_delegate, pre_escape) output = make_output(state, line, url_delegate, pre_escape)
@ -637,9 +360,8 @@ def parse_line(line, state, url_delegate):
fd = o["data"] fd = o["data"]
fn = o["name"] fn = o["name"]
fs = o["style"] fs = o["style"]
frows = o.get("rows", 1)
fmask = "*" if o["masked"] else None fmask = "*" if o["masked"] else None
f = MicronField(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask, min_rows=frows) f = ReadlineEdit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask)
f.field_name = fn f.field_name = fn
fa = urwid.AttrMap(f, fs) fa = urwid.AttrMap(f, fs)
widgets.append((fw, fa)) widgets.append((fw, fa))
@ -674,7 +396,7 @@ def parse_line(line, state, url_delegate):
columns_widget = FormColumns(widgets, dividechars=0) columns_widget = urwid.Columns(widgets, dividechars=0)
text_widget = columns_widget text_widget = columns_widget
# text_widget = urwid.Text("<"+output+">", align=state["align"]) # text_widget = urwid.Text("<"+output+">", align=state["align"])
@ -957,12 +679,11 @@ def make_output(state, line, url_delegate, pre_escape=False):
field_content = line[field_start:backtick_pos] field_content = line[field_start:backtick_pos]
field_masked = False field_masked = False
field_width = 24 field_width = 24
field_rows = 1
field_type = "field" field_type = "field"
field_name = field_content field_name = field_content
field_value = "" field_value = ""
field_data = "" field_data = ""
field_prechecked = False field_prechecked = False
# check if field_content contains '|' # check if field_content contains '|'
if '|' in field_content: if '|' in field_content:
@ -983,16 +704,10 @@ def make_output(state, line, url_delegate, pre_escape=False):
# Handle field width # Handle field width
if len(field_flags) > 0: if len(field_flags) > 0:
width_flag, _, rows_flag = field_flags.partition("x")
try: try:
field_width = min(int(width_flag), 256) field_width = min(int(field_flags), 256)
except ValueError: except ValueError:
pass # Ignore invalid width pass # Ignore invalid width
if rows_flag:
try:
field_rows = max(1, min(int(rows_flag), 256))
except ValueError:
pass
# Check for value and pre-checked flag # Check for value and pre-checked flag
if len(f_components) > 2: if len(f_components) > 2:
@ -1036,7 +751,6 @@ def make_output(state, line, url_delegate, pre_escape=False):
"type": "field", "type": "field",
"name": field_name, "name": field_name,
"width": field_width, "width": field_width,
"rows": field_rows,
"masked": field_masked, "masked": field_masked,
"data": field_data, "data": field_data,
"style": make_style(state) "style": make_style(state)
@ -1159,8 +873,6 @@ class LinkableText(urwid.Text):
super().__init__(text, align=align) super().__init__(text, align=align)
self.delegate = delegate self.delegate = delegate
self._cursor_position = 0 self._cursor_position = 0
self._cursor_promised = None
self._cursor_promised_at = 0
self.key_timeout = 2 self.key_timeout = 2
self.in_columns = False self.in_columns = False
if self.delegate != None: if self.delegate != None:
@ -1267,35 +979,28 @@ class LinkableText(urwid.Text):
def kt_event(self, loop, user_data): def kt_event(self, loop, user_data):
self._invalidate() self._invalidate()
def cursor_visible(self):
return self.delegate == None or time.time() < self.delegate.last_keypress+self.key_timeout
def render(self, size, focus=False): def render(self, size, focus=False):
now = time.time()
c = super().render(size, focus) c = super().render(size, focus)
if focus:
if time.time() < self._cursor_promised_at+0.5: if focus and (self.delegate == None or now < self.delegate.last_keypress+self.key_timeout):
coords = self._cursor_promised c = urwid.CompositeCanvas(c)
else: c.cursor = self.get_cursor_coords(size)
coords = self.get_cursor_coords(size) if self.delegate != None:
if coords != None: self.peek_link()
c = urwid.CompositeCanvas(c)
c.cursor = coords
if self.delegate != None:
self.peek_link()
return c return c
def get_cursor_coords(self, size): def get_cursor_coords(self, size):
self._cursor_promised_at = time.time() if self._cursor_position > len(self.text):
self._cursor_promised = None return None
if not self.cursor_visible() or self._cursor_position > len(self.text):
return None (maxcol,) = size
(maxcol,) = size trans = self.get_line_translation(maxcol)
trans = self.get_line_translation(maxcol) x, y = calc_coords(self.text, trans, self._cursor_position)
x, y = calc_coords(self.text, trans, self._cursor_position) if maxcol <= x:
if maxcol <= x: return None
return None return x, y
self._cursor_promised = (x, y)
return x, y
def mouse_event(self, size, event, button, x, y, focus): def mouse_event(self, size, event, button, x, y, focus):
try: try:

View file

@ -11,42 +11,22 @@ from nomadnet.util import sanitize_name
from .Browser import Browser from .Browser import Browser
from .ReadlineEdit import ReadlineEdit from .ReadlineEdit import ReadlineEdit
from .Helpers import qr_ascii
class NetworkDisplayShortcuts(): class NetworkDisplayShortcuts():
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
g = app.ui.glyphs g = app.ui.glyphs
shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-g] Fullscreen" shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL "
self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") if app.config["textui"]["clipboard_copy"]:
shortcut_text += "[C-y] Copy "
class BrowserShortcuts(): shortcut_text += "[C-g] Fullscreen [C-s / C-b] Save Node"
def __init__(self, app, browser):
self.app = app
self.browser = browser
g = app.ui.glyphs
shortcut_text = "[C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL "
if app.config["textui"]["clipboard_copy"]: shortcut_text += "[C-y] Copy "
shortcut_text += "[C-g] Fullscreen [C-s/b] Save Node"
if browser.image_rendering_supported: shortcut_text += " [C-l] Load Images [C-x] Reload Images"
self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar")
class DialogLineBox(urwid.LineBox): class DialogLineBox(urwid.LineBox):
def keypress(self, size, key): def keypress(self, size, key):
if key == "esc": if key == "esc":
delegate = getattr(self, "delegate", None) self.delegate.update_conversation_list()
if delegate is not None and hasattr(delegate, "update_conversation_list"):
delegate.update_conversation_list()
elif delegate is not None and getattr(delegate, "parent", None) is not None:
try:
delegate.dialog_open = False
options = delegate.parent.left_pile.options(height_type=urwid.PACK, height_amount=None)
delegate.parent.left_pile.contents[1] = (type(delegate)(delegate.app, delegate.parent), options)
except Exception:
pass
return None
else: else:
return super(DialogLineBox, self).keypress(size, key) return super(DialogLineBox, self).keypress(size, key)
@ -102,7 +82,7 @@ class AnnounceInfo(urwid.WidgetWrap):
is_node = False is_node = False
is_pn = False is_pn = False
if info_type == "node" or info_type == True: if info_type == "node" or info_type == True:
type_string = "NomadNet Node " + g["node"] type_string = "Nomad Network Node " + g["node"]
is_node = True is_node = True
elif info_type == "pn": elif info_type == "pn":
type_string = "LXMF Propagation Node " + g["sent"] type_string = "LXMF Propagation Node " + g["sent"]
@ -173,7 +153,7 @@ class AnnounceInfo(urwid.WidgetWrap):
display_name = op_str display_name = op_str
if not source_hash_text in [c[0] for c in existing_conversations]: if not source_hash_text in [c[0] for c in existing_conversations]:
entry = DirectoryEntry(op_hash, display_name, trust_level) entry = DirectoryEntry(source_hash, display_name, trust_level)
self.app.directory.remember(entry) self.app.directory.remember(entry)
new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True)
@ -654,7 +634,7 @@ class KnownNodeInfo(urwid.WidgetWrap):
lxmf_addr_str = "No associated Propagation Node known" lxmf_addr_str = "No associated Propagation Node known"
type_string = "NomadNet Node " + g["node"] type_string = "Nomad Network Node " + g["node"]
if trust_level == DirectoryEntry.UNTRUSTED: if trust_level == DirectoryEntry.UNTRUSTED:
trust_str = "Untrusted" trust_str = "Untrusted"
@ -725,7 +705,7 @@ class KnownNodeInfo(urwid.WidgetWrap):
display_name = op_str display_name = op_str
if not source_hash_text in [c[0] for c in existing_conversations]: if not source_hash_text in [c[0] for c in existing_conversations]:
entry = DirectoryEntry(op_hash, display_name, trust_level) entry = DirectoryEntry(source_hash, display_name, trust_level)
self.app.directory.remember(entry) self.app.directory.remember(entry)
new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True)
@ -1351,27 +1331,22 @@ class LocalPeer(urwid.WidgetWrap):
announce_button = urwid.Button("Announce Now", on_press=announce_query) announce_button = urwid.Button("Announce Now", on_press=announce_query)
peer_widgets = [ self.display_widget = urwid.Pile(
t_id, [
i_id, t_id,
e_name, i_id,
urwid.Divider(g["divider1"]), e_name,
self.t_last_announce, urwid.Divider(g["divider1"]),
announce_button, self.t_last_announce,
urwid.Divider(g["divider1"]), announce_button,
urwid.Columns([ urwid.Divider(g["divider1"]),
(urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), urwid.Columns([
(urwid.WEIGHT, 0.1, urwid.Text("")), (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)),
(urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), (urwid.WEIGHT, 0.1, urwid.Text("")),
]), (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)),
] ]),
]
if self.app.enable_node: )
peer_widgets.append(
urwid.AttrMap(urwid.Text(" "+g["node"]+" Node Online", align=urwid.LEFT), "shortcutbar")
)
self.display_widget = urwid.Pile(peer_widgets)
super().__init__(urwid.LineBox(self.display_widget, title="Local Peer Info")) super().__init__(urwid.LineBox(self.display_widget, title="Local Peer Info"))
@ -1445,37 +1420,6 @@ class NodeInfo(urwid.WidgetWrap):
def connect_query(sender): def connect_query(sender):
self.parent.browser.retrieve_url(RNS.hexrep(self.app.node.destination.hash, delimit=False)) self.parent.browser.retrieve_url(RNS.hexrep(self.app.node.destination.hash, delimit=False))
def edit_query(sender):
self.app.ui.main_display.show_page_editor(None)
def show_qr_query(sender):
def dismiss_dialog(sender):
self.dialog_open = False
options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None)
self.parent.left_pile.contents[1] = (NodeInfo(self.app, self.parent), options)
dest_addr = RNS.hexrep(self.app.node.destination.hash, delimit=False)
qr_text = qr_ascii(dest_addr)
rows = [urwid.Text("")]
if qr_text is not None:
rows.append(urwid.Text(qr_text, align=urwid.CENTER))
else:
rows.append(urwid.Text("Node destination address:", align=urwid.CENTER))
rows += [
urwid.Text(""),
urwid.Text("< "+dest_addr+" >", align=urwid.CENTER),
urwid.Text(""),
urwid.Button("Close", on_press=dismiss_dialog),
]
dialog = DialogLineBox(urwid.Pile(rows), title="Node Destination")
dialog.delegate = self
self.dialog_open = True
options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None)
self.parent.left_pile.contents[1] = (dialog, options)
if NodeInfo.announce_timer == None: if NodeInfo.announce_timer == None:
self.t_last_announce = NodeAnnounceTime(self.app) self.t_last_announce = NodeAnnounceTime(self.app)
NodeInfo.announce_timer = self.t_last_announce NodeInfo.announce_timer = self.t_last_announce
@ -1523,9 +1467,7 @@ class NodeInfo(urwid.WidgetWrap):
announce_button = urwid.Button("Announce", on_press=announce_query) announce_button = urwid.Button("Announce", on_press=announce_query)
connect_button = urwid.Button("Browse", on_press=connect_query) connect_button = urwid.Button("Browse", on_press=connect_query)
edit_button = urwid.Button("Edit", on_press=edit_query)
reset_button = urwid.Button("Rst Stats", on_press=stats_query) reset_button = urwid.Button("Rst Stats", on_press=stats_query)
qr_button = urwid.Button("Show Destination", on_press=show_qr_query)
if not self.app.disable_propagation: if not self.app.disable_propagation:
pile = urwid.Pile([ pile = urwid.Pile([
@ -1549,11 +1491,6 @@ class NodeInfo(urwid.WidgetWrap):
(urwid.WEIGHT, 8, reset_button), (urwid.WEIGHT, 8, reset_button),
(urwid.WEIGHT, 0.5, urwid.Text("")), (urwid.WEIGHT, 0.5, urwid.Text("")),
(urwid.WEIGHT, 7, announce_button), (urwid.WEIGHT, 7, announce_button),
]),
urwid.Columns([
(urwid.WEIGHT, 4, edit_button),
(urwid.WEIGHT, 0.5, urwid.Text("")),
(urwid.WEIGHT, 9, qr_button),
]) ])
]) ])
else: else:
@ -1576,11 +1513,6 @@ class NodeInfo(urwid.WidgetWrap):
(urwid.WEIGHT, 8, reset_button), (urwid.WEIGHT, 8, reset_button),
(urwid.WEIGHT, 0.5, urwid.Text("")), (urwid.WEIGHT, 0.5, urwid.Text("")),
(urwid.WEIGHT, 7, announce_button), (urwid.WEIGHT, 7, announce_button),
]),
urwid.Columns([
(urwid.WEIGHT, 4, edit_button),
(urwid.WEIGHT, 0.5, urwid.Text("")),
(urwid.WEIGHT, 9, qr_button),
]) ])
]) ])
else: else:
@ -1708,11 +1640,10 @@ class NetworkDisplay():
self.known_nodes_display.delegate = self self.known_nodes_display.delegate = self
self.list_display = 1 self.list_display = 1
default_info_display = self.node_info_display if self.app.enable_node else self.local_peer_display
self.left_pile = NetworkLeftPile([ self.left_pile = NetworkLeftPile([
(urwid.WEIGHT, 1, self.known_nodes_display), (urwid.WEIGHT, 1, self.known_nodes_display),
# (urwid.PACK, self.network_stats_display), # (urwid.PACK, self.network_stats_display),
(urwid.PACK, default_info_display), (urwid.PACK, self.local_peer_display),
]) ])
self.left_pile.parent = self self.left_pile.parent = self
@ -1731,9 +1662,7 @@ class NetworkDisplay():
dividechars=0, focus_column=0 dividechars=0, focus_column=0
) )
self.list_shortcuts = NetworkDisplayShortcuts(self.app) self.shortcuts_display = NetworkDisplayShortcuts(self.app)
self.browser_shortcuts = BrowserShortcuts(self.app, self.browser)
self.shortcuts_display = self.list_shortcuts
self.widget = self.columns self.widget = self.columns
def toggle_list(self): def toggle_list(self):
@ -1810,12 +1739,6 @@ class NetworkDisplay():
self.network_stats_display.start() self.network_stats_display.start()
def shortcuts(self): def shortcuts(self):
try: focus_path = self.columns.get_focus_path()
except Exception: return self.list_shortcuts
try:
if focus_path[0] == 0: return self.list_shortcuts
if focus_path[0] == 1: return self.browser_shortcuts
except: pass
return self.shortcuts_display return self.shortcuts_display
def directory_change_callback(self): def directory_change_callback(self):
@ -2016,14 +1939,13 @@ def pretty_date(time=False):
from datetime import datetime from datetime import datetime
now = datetime.now() now = datetime.now()
if type(time) is int: if type(time) is int:
then = datetime.fromtimestamp(time) diff = now - datetime.fromtimestamp(time)
elif isinstance(time,datetime): elif isinstance(time,datetime):
then = time diff = now - time
elif not time: elif not time:
then = now diff = now - now
diff = now - then
second_diff = diff.seconds second_diff = diff.seconds
day_diff = (now.date() - then.date()).days day_diff = diff.days
if day_diff < 0: if day_diff < 0:
return '' return ''

View file

@ -1,495 +0,0 @@
import RNS
import time
import urwid
def _fmt_secs(secs):
secs = int(secs)
if secs < 0:
secs = 0
if secs < 60:
return "%ds" % secs
if secs < 3600:
return "%dm" % (secs // 60)
if secs < 86400:
return "%dh" % (secs // 3600)
return "%dd" % (secs // 86400)
def _rel_future(ts):
if not ts:
return "-"
remaining = ts - time.time()
if remaining <= 0:
return "expired"
return _fmt_secs(remaining)
def _rel_past(ts):
if not ts:
return "-"
return _fmt_secs(time.time() - ts) + " ago"
def _fmt_duration(secs):
if not secs:
return "n/a"
secs = int(secs)
if secs < 0:
secs = 0
days = secs // 86400
hours = (secs % 86400) // 3600
minutes = (secs % 3600) // 60
if days:
return "%dd %dh" % (days, hours)
if hours:
return "%dh %dm" % (hours, minutes)
if minutes:
return "%dm" % minutes
return "%ds" % secs
def _fmt_rate(hz):
try:
hz = float(hz or 0)
except Exception:
return "0"
if hz <= 0:
return "0"
per_hour = hz * 3600
if per_hour >= 1:
return "%.0f/h" % per_hour
return "%.1f/d" % (hz * 86400)
def _mode_str(mode):
return {1: "Full", 2: "Point-to-Point", 3: "Access Point", 4: "Roaming", 5: "Boundary", 6: "Gateway"}.get(mode, "Full")
class TransportRow(urwid.WidgetWrap):
def __init__(self, columns):
self._selectable = True
super().__init__(urwid.AttrMap(columns, "list_normal", focus_map="list_focus"))
def selectable(self):
return True
def keypress(self, size, key):
return key
class LazyTableWalker(urwid.ListWalker):
def __init__(self):
self.items = []
self.factory = None
self.empty_widget = None
self.focus = 0
def configure(self, items, factory, empty_widget=None):
self.items = items or []
self.factory = factory
self.empty_widget = empty_widget
if self.focus >= len(self.items):
self.focus = max(0, len(self.items) - 1)
self._modified()
def _build(self, position):
return self.factory(self.items[position], position)
def get_focus(self):
if not self.items:
if self.empty_widget is not None:
return (self.empty_widget, 0)
return (None, None)
if self.focus >= len(self.items):
self.focus = len(self.items) - 1
if self.focus < 0:
self.focus = 0
return (self._build(self.focus), self.focus)
def set_focus(self, position):
self.focus = position
self._modified()
def get_next(self, position):
n = position + 1
if not self.items or n >= len(self.items):
return (None, None)
return (self._build(n), n)
def get_prev(self, position):
p = position - 1
if not self.items or p < 0:
return (None, None)
return (self._build(p), p)
def positions(self, reverse=False):
count = len(self.items)
if count == 0:
return [0] if self.empty_widget is not None else []
if reverse:
return range(count - 1, -1, -1)
return range(count)
def __len__(self):
return len(self.items)
def __getitem__(self, position):
if not self.items:
if position == 0 and self.empty_widget is not None:
return self.empty_widget
raise IndexError(position)
if position < 0 or position >= len(self.items):
raise IndexError(position)
return self._build(position)
class TransportFiller(urwid.WidgetWrap):
def __init__(self, widget, display):
self.display = display
super().__init__(widget)
def keypress(self, size, key):
if key == "tab":
self.display.cycle_tab()
return None
if key == "ctrl r" or key == "f5":
self.display.refresh()
return None
if key == "esc":
self.display.go_back()
return None
result = super().keypress(size, key)
if key == "up" and result == "up":
self.display.go_back_to_header()
return None
return result
class TransportDisplayShortcuts:
def __init__(self, app):
self.app = app
self.widget = urwid.AttrMap(
urwid.Text("[Tab] Switch Table [Up] Filter [C-r] Refresh [Esc] Back"),
"shortcutbar"
)
class TransportDisplay:
POLL_INTERVAL = 5
HASH_W = 34
HOPS_W = 6
VIA_W = 34
EXP_W = 12
LAST_W = 16
VIOL_W = 11
STATUS_W = 10
PATHS_W = 8
HELD_W = 8
QUEUE_W = 8
RATE_W = 10
MODE_W = 12
def __init__(self, app, parent=None):
self.app = app
self.parent = parent
self.started = False
self.poll_scheduler = False
self.glyphset = self.app.config["textui"]["glyphs"]
self.g = self.app.ui.glyphs
self.active_tab = "paths"
self.paths = []
self.rates = []
self.interfaces = []
self.paths_per_interface = {}
self.link_count = 0
self.transport_id = None
self.network_id = None
self.transport_uptime = None
self.held_announces = 0
self.filter_text = ""
self.summary_text = urwid.Text("")
self.list_walker = LazyTableWalker()
self.list_box = urwid.ListBox(self.list_walker)
self.filter_edit = urwid.Edit("", "")
urwid.connect_signal(self.filter_edit, "change", self._on_filter_change)
self.filter_row = urwid.Columns([
(9, urwid.Text(("key", "Filter:"))),
urwid.AttrMap(self.filter_edit, "list_normal", focus_map="list_focus"),
], dividechars=1)
self.pile = urwid.Pile([
('pack', self.summary_text),
('pack', self._tab_bar()),
('pack', self.filter_row),
('pack', self._header_row()),
('pack', urwid.Divider("─")),
('weight', 1, self.list_box),
])
self.transport_view = TransportFiller(self.pile, self)
self.shortcuts_display = TransportDisplayShortcuts(self.app)
self.widget = self.transport_view
self._refresh_data()
self._rebuild()
def shortcuts(self):
return self.shortcuts_display
def _tab_bar(self):
def tab(label, key):
attr = "list_focus" if self.active_tab == key else "list_normal"
return (urwid.PACK, urwid.Text((attr, " %s " % label)))
return urwid.Columns([
tab("Paths", "paths"),
tab("Announce Rates", "rates"),
tab("Interfaces", "interfaces"),
(urwid.WEIGHT, 1, urwid.Text("")),
], dividechars=1)
def _header_row(self):
if self.active_tab == "paths":
cols = [
(self.HASH_W, urwid.Text(("key", "Destination"), wrap="clip")),
(self.HOPS_W, urwid.Text(("key", "Hops"), wrap="clip")),
(urwid.WEIGHT, 1, urwid.Text(("key", "Interface"), wrap="clip")),
(self.VIA_W, urwid.Text(("key", "Via"), wrap="clip")),
(self.EXP_W, urwid.Text(("key", "Expires"), wrap="clip")),
]
elif self.active_tab == "interfaces":
cols = [
(urwid.WEIGHT, 1, urwid.Text(("key", "Interface"), wrap="clip")),
(self.STATUS_W, urwid.Text(("key", "Status"), wrap="clip")),
(self.PATHS_W, urwid.Text(("key", "Paths"), wrap="clip")),
(self.HELD_W, urwid.Text(("key", "Held"), wrap="clip")),
(self.QUEUE_W, urwid.Text(("key", "Queue"), wrap="clip")),
(self.RATE_W, urwid.Text(("key", "Ann in"), wrap="clip")),
(self.MODE_W, urwid.Text(("key", "Mode"), wrap="clip")),
]
else:
cols = [
(self.HASH_W, urwid.Text(("key", "Destination"), wrap="clip")),
(self.LAST_W, urwid.Text(("key", "Last announce"), wrap="clip")),
(self.VIOL_W, urwid.Text(("key", "Violations"), wrap="clip")),
(urwid.WEIGHT, 1, urwid.Text(("key", "Blocked"), wrap="clip")),
]
return urwid.Columns(cols, dividechars=1)
def _refresh_data(self):
try:
self.paths = self.app.rns.get_path_table() or []
except Exception:
self.paths = []
try:
self.rates = self.app.rns.get_rate_table() or []
except Exception:
self.rates = []
try:
self.link_count = self.app.rns.get_link_count()
except Exception:
self.link_count = 0
try:
stats = self.app.rns.get_interface_stats() or {}
except Exception:
stats = {}
self.transport_id = stats.get("transport_id")
self.network_id = stats.get("network_id")
self.transport_uptime = stats.get("transport_uptime")
self.interfaces = stats.get("interfaces", []) or []
try:
self.held_announces = sum(int(i.get("held_announces", 0) or 0) for i in self.interfaces)
except Exception:
self.held_announces = 0
self.paths_per_interface = {}
for entry in self.paths:
iface = entry.get("interface", "")
self.paths_per_interface[iface] = self.paths_per_interface.get(iface, 0) + 1
try:
self.interfaces.sort(key=lambda i: self.paths_per_interface.get(i.get("name", ""), 0), reverse=True)
except Exception:
pass
try:
self.paths.sort(key=lambda e: (e.get("interface", ""), e.get("hops", 0)))
except Exception:
pass
try:
self.rates.sort(key=lambda e: e.get("last", 0), reverse=True)
except Exception:
pass
def _make_path_row(self, entry, position):
dest = RNS.prettyhexrep(entry["hash"]) if entry.get("hash") else "?"
via = RNS.prettyhexrep(entry["via"]) if entry.get("via") else "?"
hops = entry.get("hops", 0)
iface = entry.get("interface", "")
expires = _rel_future(entry.get("expires", 0))
columns = urwid.Columns([
(self.HASH_W, urwid.Text(("list_normal", dest), wrap="clip")),
(self.HOPS_W, urwid.Text(str(hops), wrap="clip")),
(urwid.WEIGHT, 1, urwid.Text(iface, wrap="clip")),
(self.VIA_W, urwid.Text(via, wrap="clip")),
(self.EXP_W, urwid.Text(expires, wrap="clip")),
], dividechars=1)
return TransportRow(columns)
def _make_rate_row(self, entry, position):
now = time.time()
dest = RNS.prettyhexrep(entry["hash"]) if entry.get("hash") else "?"
last = _rel_past(entry.get("last", 0))
violations = str(entry.get("rate_violations", 0))
blocked_until = entry.get("blocked_until", 0) or 0
if blocked_until > now:
blocked = ("disconnected_status", "blocked %s" % _rel_future(blocked_until))
else:
blocked = ("connected_status", "no")
columns = urwid.Columns([
(self.HASH_W, urwid.Text(("list_normal", dest), wrap="clip")),
(self.LAST_W, urwid.Text(last, wrap="clip")),
(self.VIOL_W, urwid.Text(violations, wrap="clip")),
(urwid.WEIGHT, 1, urwid.Text(blocked, wrap="clip")),
], dividechars=1)
return TransportRow(columns)
def _make_iface_row(self, entry, position):
short_name = entry.get("short_name")
if short_name == "None": short_name = None
name = short_name or entry.get("name") or "?"
RNS.log(name)
name = name.replace("\x00", "")
paths = self.paths_per_interface.get(entry.get("name", ""), 0)
held = entry.get("held_announces", 0) or 0
queue = entry.get("announce_queue")
queue_str = "-" if queue is None else str(queue)
rate = _fmt_rate(entry.get("incoming_announce_frequency", 0))
mode = _mode_str(entry.get("mode"))
if entry.get("status"):
status = ("connected_status", "up")
else:
status = ("disconnected_status", "down")
columns = urwid.Columns([
(urwid.WEIGHT, 1, urwid.Text(("list_normal", name), wrap="clip")),
(self.STATUS_W, urwid.Text(status, wrap="clip")),
(self.PATHS_W, urwid.Text(str(paths), wrap="clip")),
(self.HELD_W, urwid.Text(str(held), wrap="clip")),
(self.QUEUE_W, urwid.Text(queue_str, wrap="clip")),
(self.RATE_W, urwid.Text(rate, wrap="clip")),
(self.MODE_W, urwid.Text(mode, wrap="clip")),
], dividechars=1)
return TransportRow(columns)
def _rebuild(self):
blocked_now = 0
try:
now = time.time()
blocked_now = sum(1 for r in self.rates if (r.get("blocked_until", 0) or 0) > now)
except Exception:
pass
transport_id = RNS.prettyhexrep(self.transport_id) if self.transport_id else "n/a"
network_id = RNS.prettyhexrep(self.network_id) if self.network_id else "n/a"
uptime = _fmt_duration(self.transport_uptime)
summary = [
("key", "Transport ID: "), ("value", transport_id),
("key", " Network ID: "), ("value", network_id),
("key", " Uptime: "), ("value", uptime), "\n",
("key", "Active links: "), ("value", str(self.link_count)),
("key", " Known paths: "), ("value", str(len(self.paths))),
("key", " Held announces: "), ("value", str(self.held_announces)),
("key", " Announce sources: "), ("value", str(len(self.rates))),
("key", " Rate-limited: "), ("value", str(blocked_now)),
]
if self.active_tab == "paths":
data = self._filtered(self.paths)
factory = self._make_path_row
empty_msg = "No paths in the routing table yet."
elif self.active_tab == "interfaces":
data = self._filtered(self.interfaces)
factory = self._make_iface_row
empty_msg = "No interface statistics available."
else:
data = self._filtered(self.rates)
factory = self._make_rate_row
empty_msg = "No announce rate data available."
if self.filter_text.strip():
summary.append(("inactive_text", " [filter: %d shown]" % len(data)))
self.summary_text.set_text(summary)
self.pile.contents[1] = (self._tab_bar(), self.pile.options('pack'))
self.pile.contents[3] = (self._header_row(), self.pile.options('pack'))
empty = urwid.Text(("inactive_text", empty_msg), align=urwid.CENTER)
self.list_walker.configure(data, factory, empty)
def cycle_tab(self):
order = ["paths", "rates", "interfaces"]
i = order.index(self.active_tab) if self.active_tab in order else 0
self.active_tab = order[(i + 1) % len(order)]
self._rebuild()
def _on_filter_change(self, widget, new_text):
self.filter_text = new_text
self._rebuild()
def _haystack(self, entry):
parts = []
for key in ("hash", "via"):
value = entry.get(key)
if value:
parts.append(RNS.prettyhexrep(value))
for key in ("interface", "name", "short_name"):
value = entry.get(key)
if value:
parts.append(str(value))
return " ".join(parts).lower()
def _filtered(self, items):
needle = self.filter_text.strip().lower()
if not needle:
return items
return [entry for entry in items if needle in self._haystack(entry)]
def go_back(self):
if self.parent is not None:
self.parent.show_landing()
def go_back_to_header(self):
try:
self.app.ui.main_display.frame.focus_position = "header"
except Exception:
pass
def _is_visible(self):
md = self.app.ui.main_display
if self.parent is not None:
return md.sub_displays.active_display is self.parent and getattr(self.parent, "active", None) == "transport"
return md.sub_displays.active_display is self
def refresh(self):
self._refresh_data()
self._rebuild()
def start(self):
self.started = True
self._refresh_data()
self._rebuild()
if not self.poll_scheduler and self.app.ui.loop is not None:
self.poll_scheduler = True
self.app.ui.loop.set_alarm_in(self.POLL_INTERVAL, self._poll)
def _poll(self, loop, user_data):
if 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)

File diff suppressed because it is too large Load diff

View file

@ -1,18 +0,0 @@
# A minimal, dependency-free image widget for urwid, rendering images through
# the Kitty terminal graphics protocol.
from ._termlib import get_cell_size, is_kitty_supported, reset_caches, diagnostics
from ._imagedata import ImageData, parse_image_header
from .widget import ImageWidget, ImageCanvas, UI_MARGIN
from .screen import ImageScreen
__all__ = ( "ImageWidget",
"ImageCanvas",
"ImageScreen",
"ImageData",
"parse_image_header",
"is_kitty_supported",
"get_cell_size",
"diagnostics",
"reset_caches",
"UI_MARGIN" )

View file

@ -1,78 +0,0 @@
# Kitty Terminal Graphics Protocol control sequences
#
# Minimal set of control sequences needed for the Kitty graphics protocol
# side-channel image rendering used by the urwid image widget.
from base64 import standard_b64encode
import re
# Basic building blocks
APC = b"\x1b_" # Application Program Command
ST = b"\x1b\\" # String Terminator
CSI = b"\x1b["
# Kitty graphics: transmit-and-display with chunking support.
# ESC _ G <control-data> ; <base64 payload> ESC \
KITTY_TRANSMISSION = b"\x1b_G%s;%s\x1b\\"
# Delete images: d=A (all placements visible on screen), d=i (one
# placement, data kept), d=I (whole image, data freed).
KITTY_DELETE_ALL = b"\x1b_Ga=d,d=A\x1b\\"
KITTY_DELETE_PLACEMENT = b"\x1b_Ga=d,d=i,i=%d,p=%d\x1b\\"
KITTY_DELETE_IMAGE = b"\x1b_Ga=d,d=I,i=%d\x1b\\"
# Support query (payload is base64 of 4 zero bytes; expects "i=31;OK")
KITTY_QUERY = b"\x1b_Ga=q,t=d,i=31,f=24,s=1,v=1,C=1,c=1,r=1;AAAA\x1b\\"
KITTY_QUERY_RESPONSE_RE = re.compile(b"\x1b_Gi=31;(?P<message>[^\x1b]*)\x1b\\\\")
# Cursor: absolute positioning, erase characters, cursor forward
CURSOR_POSITION = b"\x1b[%d;%dH"
ERASE_CHARS = b"\x1b[%dX"
# Synchronized output (DEC 2026) - reduces flicker/tearing when redrawing
BEGIN_SYNCED_UPDATE = b"\x1b[?2026h"
END_SYNCED_UPDATE = b"\x1b[?2026l"
# Terminal queries: cell size in pixels (XTWINOPS 16) + primary DA
CELL_SIZE_PX = b"\x1b[16t"
DA1 = b"\x1b[c"
# Response: CSI 4 ; <height-px> ; <width-px> t
CELL_SIZE_RESPONSE_RE = re.compile(b"\x1b\\[4;(?P<h>\\d+);(?P<w>\\d+)t")
# Maximum payload (base64) per transmission chunk, as recommended by the
# protocol documentation
CHUNK_SIZE = 4096
# Control data for a transmit-only (a=t) with a quiet mode (q=1)
def kitty_transmit_control(image_id): return b"a=t,i=%d,f=100,t=d,q=1" % image_id
# A placement command (a=p) for a previously transmitted image.
# Re-sending the same (image id, placement id) pair replaces the
# placement in place - used to move/resize/slice without flicker.
# y/h select the source rectangle (image pixels) when a slice of
# the image is displayed.
def kitty_placement(image_id, placement_id, cols, rows, z, y=None, h=None):
control = b"a=p,i=%d,p=%d,c=%d,r=%d,z=%d,C=1,q=1" % (image_id, placement_id, cols, rows, z)
if y is not None and h is not None: control += b",y=%d,h=%d" % (y, h)
return KITTY_TRANSMISSION % (control, b"")
# Encodes a kitty graphics transmission, chunking the base64-encoded
# payload into ``chunk_size``-byte pieces.
def kitty_transmission(control, payload, chunk_size=CHUNK_SIZE):
encoded = standard_b64encode(payload)
chunks = [encoded[i:i + chunk_size] for i in range(0, len(encoded), chunk_size)]
if not chunks:
chunks = [b""]
def control_for(index, n_chunks):
if n_chunks == 1:
return control
if index == 0:
# The control data appears only in the first chunk; the chunk
# marker says more chunks are coming.
return control + b",m=1"
if index == n_chunks - 1:
return b"m=0"
return b"m=1"
return b"".join(KITTY_TRANSMISSION % (control_for(i, len(chunks)), chunk) for i, chunk in enumerate(chunks))

View file

@ -1,142 +0,0 @@
# Image file handling for the side-channel renderer
#
# The Kitty graphics protocol can decode and scale images in the terminal
# itself (data format ``f=100``), which means the widget needs no image
# decoding library at all. It only needs the *dimensions* of the image to lay
# it out correctly in the urwid canvas; those are trivially parseable from the
# file headers of the supported formats.
#
# Supported source formats:
# - WebP (VP8, VP8L and VP8X)
# - PNG
#
# The payload transmitted to the terminal is always a PNG file: PNG sources
# are passed through verbatim, and WebP sources are converted transparently
# by the multi-backend conversion chain in :mod:`._webp` (no required
# dependencies). The conversion result is cached on disk, so each unique
# WebP is converted at most once per system.
import hashlib
import os
MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
MAX_CONVERTED_PAYLOAD_BYTES = 16 * 1024 * 1024
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
# Returns (width, height) from a PNG file.
def _parse_png(data):
if not data.startswith(_PNG_SIGNATURE): raise ValueError("not a PNG")
if len(data) < 24 or data[12:16] != b"IHDR": raise ValueError("PNG missing IHDR chunk")
width = int.from_bytes(data[16:20], "big")
height = int.from_bytes(data[20:24], "big")
if not (width > 0 and height > 0): raise ValueError("PNG has null dimensions")
return width, height
# Returns (width, height) from a WebP file (VP8, VP8L or VP8X).
def _parse_webp(data):
if not data.startswith(b"RIFF") or data[8:12] != b"WEBP": raise ValueError("not a WebP")
if len(data) < 30: raise ValueError("WebP file too short")
fourcc = data[12:16]
if fourcc == b"VP8X":
# 10-byte chunk: flags(1) reserved(3) canvas-w-1(3 LE) canvas-h-1(3 LE)
width = int.from_bytes(data[24:27], "little") + 1
height = int.from_bytes(data[27:30], "little") + 1
elif fourcc == b"VP8 ":
# frame tag(3) start code 0x9d012a(3) width(2 LE, 14 bits) height(2 LE, 14 bits)
width = int.from_bytes(data[26:28], "little") & 0x3FFF
height = int.from_bytes(data[28:30], "little") & 0x3FFF
elif fourcc == b"VP8L":
# signature(1) then 4 bytes: w-1 (14 bits) | h-1 (14 bits) | ...
bits = int.from_bytes(data[21:25], "little")
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
else: raise ValueError("unsupported WebP variant %r" % fourcc)
if not (width > 0 and height > 0): raise ValueError("WebP has null dimensions")
return width, height
def parse_image_header(data):
if data[:8] == _PNG_SIGNATURE:
w, h = _parse_png(data)
return "PNG", w, h
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
w, h = _parse_webp(data)
return "WebP", w, h
raise ValueError("unsupported image format")
# Loads an image file and exposes its PNG bytes and dimensions.
# The payload is always a PNG file, transmitted verbatim to the terminal
# (Kitty's f=100 data format). The dimensions are used only for layout.
# WebP sources are converted transparently to PNG on load.
class ImageData(object):
def __init__(self, path, max_bytes=MAX_PAYLOAD_BYTES, remote_source=False):
self.path = path
self.error = None
self.format = None
self.source_format = None
self.conversion_backend = None
self.width = 0
self.height = 0
self.data = None
try:
with open(path, "rb") as f: data = f.read()
except OSError as e:
self.error = "Could not read file: %s" % e
return
if len(data) > max_bytes:
self.error = "File too large (%.1f MiB > %d MiB)" % (len(data) / (1024 * 1024), max_bytes // (1024 * 1024))
return
if not data:
self.error = "Empty file"
return
try: self.format, self.width, self.height = parse_image_header(data)
except ValueError as e:
self.error = str(e)
return
self.source_format = self.format
if self.format == "WebP":
from . import _webp
png = _webp.convert_webp_to_png(data)
if png is None:
self.error = "WebP conversion failed (no working backend available)"
return
if len(png) > MAX_CONVERTED_PAYLOAD_BYTES:
self.error = "Converted image too large (%.1f MiB > %d MiB)" % (len(png) / (1024 * 1024), MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024))
return
self.data = png
self.format = "PNG"
self.conversion_backend = _webp.last_backend()
else:
if remote_source:
self.error = "Invalid image format, remote images must be WebP"
return
else:
self.data = data
# Content key for de-duplication: identical bytes = same key, so
# the same image transmitted once can be placed many times.
self._key = hashlib.sha256(self.data).hexdigest()
@property
def ok(self): return self.data is not None
@property
def key(self): return getattr(self, "_key", None)
@property
def size(self): return (self.width, self.height)
def description(self):
if self.ok:
if self.source_format == "WebP":
return "WebP %dx%d -> PNG (%s)" % (self.width, self.height, self.conversion_backend or "cached")
else:
return "%s %dx%d" % (self.format, self.width, self.height)
return self.error

View file

@ -1,90 +0,0 @@
# Registry of terminal-side image data
#
# Tracks which image payloads have been transmitted to the terminal (Kitty
# image ids), de-duplicates identical image data across widgets (one payload
# transmitted once, placed many times), and counts live widget references so
# that orphaned data can be evicted from the terminal's buffers.
#
# This module only tracks *state*; all protocol output is performed by
# ImageScreen, which reads and updates the store.
#
# Eviction:
#
# - Automatic, widgets unregister when closed/destroyed; the screen evicts
# (deletes from the terminal) any registered data with no live references.
# - Explicit, the browser can call ImageScreen.purge_images() on page
# navigation to drop everything at once.
DEFAULT_IMAGE_ID_MAX = 2 ** 32 - 1 # Protocol limit for image ids
class ImageStore(object):
def __init__(self):
self._entries = {} # content key -> {"image_id", "data", "refs", "transmitted"}
self._next_image_id = 1
#############
# Lifecycle #
#############
# Registers image data and returns (key, is_new).
# If identical data is already registered, the existing entry is
# reused (de-duplication) and its reference count is incremented.
def register(self, key, data):
entry = self._entries.get(key)
if entry is None:
image_id = self._next_image_id
self._next_image_id += 1
if image_id > DEFAULT_IMAGE_ID_MAX: raise OverflowError("image id space exhausted")
entry = { "image_id": image_id, "data": data,
"refs": 0, "transmitted": False }
self._entries[key] = entry
is_new = True
else: is_new = False
entry["refs"] += 1
return key, is_new
# Decrements the reference count of registered data
def unregister(self, key):
entry = self._entries.get(key)
if entry is None: return
entry["refs"] = max(0, entry["refs"] - 1)
###########
# Queries #
###########
def entry_for(self, key): return self._entries.get(key)
def transmitted(self): return [key for key, entry in self._entries.items() if entry["transmitted"]]
# Returns [(key, image_id), ...] for entries with no live
# widget references that are still present in the terminal.
def evict_orphans(self):
return [ (key, entry["image_id"])
for key, entry in self._entries.items()
if entry["refs"] == 0 and entry["transmitted"] ]
############
# Mutation #
############
def mark_transmitted(self, key):
entry = self._entries.get(key)
if entry is not None:
entry["transmitted"] = True
def mark_all_untransmitted(self):
for entry in self._entries.values():
entry["transmitted"] = False
# Drops the entry for evicted data (frees the payload bytes)
def evict(self, key): self._entries.pop(key, None)
# Forgets all state (used by tests)
def reset(self):
self._entries.clear()
self._next_image_id = 1
# Module-level singleton shared by widgets and the screen.
image_store = ImageStore()

View file

@ -1,718 +0,0 @@
#!/usr/bin/env python3
"""
.. Images - headless self test
Exercises the widget + screen machinery without a terminal: image header
parsing, display sizing, shard scanning, APC construction/chunking and the
transmit/delete state machine at various scroll positions.
Run::
python3 -m nomadnet.ui.textui.images._selftest [image-file]
"""
import base64
import io
import os
import shutil
import struct
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
import urwid
from nomadnet.ui.textui.images import _ctlseqs
from nomadnet.ui.textui.images import _imagestore
from nomadnet.ui.textui.images import _termlib
from nomadnet.ui.textui.images._imagedata import ImageData, parse_image_header
from nomadnet.ui.textui.images.screen import ImageScreen
from nomadnet.ui.textui.images.widget import ImageWidget
DEFAULT_IMAGE = os.path.expanduser("~/Scratchpad/test.png")
VIEWPORT = (80, 24)
CELL_SIZE = (1, 2) # forced fallback for deterministic layout
PASS = 0
FAIL = 0
def check(cond, label):
global PASS, FAIL
if cond:
PASS += 1
print(" ok - %s" % label)
else:
FAIL += 1
print(" FAIL - %s" % label)
def build_pile(image_path, wrap_attrmap=False):
widgets = []
for i in range(12):
widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % i)) # 3 rows each
img = ImageWidget(image_path)
if wrap_attrmap:
img = urwid.AttrMap(img, "body")
widgets.append(urwid.Divider(" "))
widgets.append(img)
widgets.append(urwid.Divider(" "))
for i in range(12):
widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % (13 + i)))
return urwid.Pile(widgets)
class RecordingScreen(ImageScreen):
def __init__(self):
super().__init__()
self.log = []
def write(self, data):
if isinstance(data, bytes):
data = data.decode("ascii")
self.log.append(data)
return data
def flush(self):
pass
def extract_transmit(log):
"""Returns (image_id, joined_b64_payload) of the a=t transmission, or
None if no transmission is present."""
image_id = None
payloads = []
for item in log:
if item.startswith("\x1b_Ga=t"):
for piece in item.split("\x1b_G")[1:]:
body = piece[:-2] # strip the trailing ST (ESC \)
head, sep, payload = body.partition(";")
if not sep:
continue
if image_id is None:
image_id = int(head.split("i=")[1].split(",")[0])
payloads.append(payload)
if image_id is None:
return None
return image_id, "".join(payloads)
def extract_placements(log):
"""Returns the control data of every a=p placement command."""
return [
item[3:-2]
for item in log
if item.startswith("\x1b_Ga=p")
]
def extract_deletes(log):
"""Returns the control data of every a=d delete command."""
return [
item[3:-2]
for item in log
if item.startswith("\x1b_Ga=d")
]
def screen_probe_placement_key(widget, row, col, canvas_cols, trim_top, vis_rows):
"""Computes an ImageScreen placement key without a screen instance."""
return ImageScreen._placement_key(
None, widget, row, col, canvas_cols, trim_top, vis_rows)
def test_header_parse(image_path):
print("== header parsing ==")
with open(image_path, "rb") as f:
data = f.read()
fmt, w, h = parse_image_header(data)
check(fmt == "PNG", "detected PNG")
check(w == 1280 and h == 960, "dimensions %dx%d" % (w, h))
imgdata = ImageData(image_path)
check(imgdata.ok, "ImageData loads without error")
check(imgdata.size == (w, h), "ImageData size matches")
def test_transmission_encoding(image_path):
print("== APC encoding/chunking ==")
small = b"\x89PNG\r\n\x1a\n" + b"x" * 100
single = _ctlseqs.kitty_transmission(b"a=T,f=100,c=80,r=30,z=1", small)
check(single.count(b"\x1b_G") == 1, "single-chunk transmission has one APC")
check(single.startswith(b"\x1b_Ga=T,f=100,c=80,r=30,z=1;"), "control data correct")
check(single.endswith(b"\x1b\\"), "ends with ST")
payload = single.split(b";", 1)[1][:-2]
check(base64.b64decode(payload) == small, "payload round-trips to original file")
with open(image_path, "rb") as f:
data = f.read()
chunks = _ctlseqs.kitty_transmission(b"a=T,f=100", data, chunk_size=16)
apcs = chunks.split(b"\x1b\\")[:-1]
check(len(apcs) > 1, "multi-chunk transmission splits into %d APCs" % len(apcs))
first, last = apcs[0], apcs[-1]
heads = [a.split(b";", 1)[0][3:] for a in apcs]
check(b",m=1;" in first, "first chunk carries control + m=1")
check(last.startswith(b"\x1b_Gm=0;"), "last chunk carries m=0")
check(sum(b"a=T" in h for h in heads) == 1, "control data appears only once")
check(all(h == b"m=1" or h == b"m=0" for h in heads[1:]), "continuation chunks carry only the chunk marker")
joined_b64 = b"".join(a.split(b";", 1)[1] for a in apcs)
check(base64.b64decode(joined_b64) == data, "multi-chunk payload reassembles")
_TINY_WEBP = base64.b64decode(
"UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuUAAA=")
def _webp_fixture():
"""Returns (webp_bytes, (width, height)): a 4x4 WebP generated with
Pillow when available (more representative), else a 1x1 constant."""
try:
from PIL import Image
if _webp_module_available():
image = Image.new("RGBA", (4, 4))
for y in range(4):
for x in range(4):
image.putpixel((x, y), (x * 60, y * 60, x * y * 10, 255))
buf = io.BytesIO()
image.save(buf, format="WEBP")
data = buf.getvalue()
if parse_image_header(data)[0] == "WebP":
return data, (4, 4)
except Exception:
pass
return _TINY_WEBP, (1, 1)
def _webp_module_available():
try:
from PIL import features
return bool(features.check("webp"))
except Exception:
return False
def test_webp_conversion():
print("== webp conversion ==")
from nomadnet.ui.textui.images import _webp
from nomadnet.ui.textui.images._imagedata import MAX_CONVERTED_PAYLOAD_BYTES
fmt, w, h = parse_image_header(_TINY_WEBP)
check((fmt, w, h) == ("WebP", 1, 1), "embedded fixture is a 1x1 WebP")
fixture, dims = _webp_fixture()
check(parse_image_header(fixture)[1:] == dims,
"webp fixture has expected dimensions")
cache_tmp = tempfile.mkdtemp(prefix="imgw-test-cache-")
prev_cache_env = os.environ.get("NOMADNET_IMAGE_CACHE")
os.environ["NOMADNET_IMAGE_CACHE"] = cache_tmp
try:
# Each available backend must convert the fixture to a valid PNG
# with matching dimensions. Backends are invoked directly, since
# the on-disk cache would otherwise mask all but the first one.
_webp.reset()
present = [name for name, avail in _webp.available_backends() if avail]
converted = 0
for name in present:
png = _webp._BACKENDS[name](fixture, dims)
ok = (png is not None and png.startswith(b"\x89PNG")
and _webp._png_dims(png) == dims)
check(ok, "%s backend converts to a valid PNG" % name)
if ok:
converted += 1
check(converted >= 1, "at least one backend converts webp (got %d)" % converted)
# Repeat conversion is served from the on-disk cache: the backend
# table is disabled and the conversion still succeeds.
_webp.reset()
png1 = _webp.convert_webp_to_png(fixture)
check(png1 is not None, "first conversion succeeds")
orig_backends = dict(_webp._BACKENDS)
_webp._BACKENDS = {name: (lambda *a, **k: None) for name in _webp.BACKENDS}
png2 = _webp.convert_webp_to_png(fixture)
check(png1 == png2 and _webp.stats()["cache_hits"] >= 1,
"repeat conversion served from the on-disk cache")
_webp._BACKENDS = orig_backends
# ImageData-level transparency: a webp file loads as a PNG.
_webp.reset()
fd, webp_path = tempfile.mkstemp(suffix=".webp")
with os.fdopen(fd, "wb") as f:
f.write(fixture)
d = ImageData(webp_path)
check(d.ok and d.format == "PNG" and d.size == dims,
"ImageData converts webp transparently to PNG")
check(d.data.startswith(b"\x89PNG\r\n\x1a\n") and d.key is not None,
"converted payload is a keyed PNG")
check("-> PNG" in d.description(), "description notes the conversion")
os.unlink(webp_path)
# Converted-payload cap.
_webp.reset()
orig_convert = _webp.convert_webp_to_png
_webp.convert_webp_to_png = lambda data: b"x" * (MAX_CONVERTED_PAYLOAD_BYTES + 1)
fd, webp_path = tempfile.mkstemp(suffix=".webp")
with os.fdopen(fd, "wb") as f:
f.write(fixture)
d = ImageData(webp_path)
check(not d.ok and "Converted image too large" in d.error,
"converted payload capped at %d MiB"
% (MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024)))
os.unlink(webp_path)
_webp.convert_webp_to_png = orig_convert
# Undecodable webp (valid header/dimensions, garbage bitstream).
_webp.reset()
junk = (b"RIFF" + struct.pack("<I", 30) + b"WEBP" + b"VP8 "
+ b"\x00" * 10 + b"\x10\x00\x10\x00" + b"\xff" * 12)
check(parse_image_header(junk)[1:] == (16, 16),
"junk fixture parses as a 16x16 WebP")
fd, webp_path = tempfile.mkstemp(suffix=".webp")
with os.fdopen(fd, "wb") as f:
f.write(junk)
d = ImageData(webp_path)
check(not d.ok and "conversion failed" in d.error,
"undecodable webp results in the error state")
os.unlink(webp_path)
finally:
if prev_cache_env is not None:
os.environ["NOMADNET_IMAGE_CACHE"] = prev_cache_env
else:
os.environ.pop("NOMADNET_IMAGE_CACHE", None)
shutil.rmtree(cache_tmp, ignore_errors=True)
_webp.reset()
def test_winsize_parsing():
print("== TIOCGWINSZ parsing ==")
from nomadnet.ui.textui.images import _termlib
# struct order is (rows, cols, xpixel, ypixel)
check(_termlib._cell_size_from_winsize([40, 100, 1000, 800]) == (10, 20),
"correct winsize -> (10, 20)")
check(_termlib._cell_size_from_winsize([24, 80, 960, 480]) == (12, 20),
"correct winsize -> (12, 20)")
check(_termlib._cell_size_from_winsize([24, 80, 0, 0]) is None,
"zero pixel fields -> None")
check(_termlib._cell_size_from_winsize([40, 100, 800, 1000]) == (8, 25),
"portrait window cell keeps valid aspect")
check(_termlib._cell_size_from_winsize([30, 80, 1200, 240]) is None,
"implausible aspect (broken reporting) -> None")
check(_termlib._cell_size_from_winsize([0, 0, 0, 0]) is None,
"empty winsize -> None")
def test_widget_layout(image_path):
print("== widget layout ==")
_termlib.kitty_supported = True
_termlib.cell_size = CELL_SIZE
# Force a deterministic terminal size for the height-percent cases.
_orig_terminal_size = _termlib.get_terminal_size
_termlib.get_terminal_size = lambda: (80, 24)
_orig_ui_margin = None
from nomadnet.ui.textui.images import widget as _widget_mod
_orig_ui_margin = _widget_mod.UI_MARGIN
_widget_mod.UI_MARGIN = 8
try:
imgdata = ImageData(image_path)
w = ImageWidget(image_path)
iw, ih = imgdata.size
expected_rows = max(1, round(80 * CELL_SIZE[0] * ih / (CELL_SIZE[1] * iw)))
rows = w.rows((80,))
check(rows == expected_rows,
"default: rows((80,)) == %d, got %d" % (expected_rows, rows))
canvas = w.render((80,))
check(isinstance(canvas, urwid.SolidCanvas), "render returns a solid canvas")
check(canvas.rows() == rows and canvas.cols() == 80, "canvas sized 80x%d" % rows)
check(w._ti_rows == rows and w._ti_cols == 80, "widget stores display size")
w.close()
# width as absolute columns (aspect preserved); the canvas fills the
# allocated width while the image rect is the resolved size.
w = ImageWidget(image_path, width=40)
c, r = w._display_size(80)
check((c, r) == (40, 15), "width=40 -> (40, 15), got (%d, %d)" % (c, r))
canvas = w.render((80,))
check((canvas.cols(), canvas.rows()) == (80, 15),
"width=40 canvas fills the row (80, 15), got (%d, %d)"
% (canvas.cols(), canvas.rows()))
check((w._ti_cols, w._ti_rows) == (40, 15),
"width=40 image rect stored (40, 15)")
w.close()
# width as percent of the layout width
w = ImageWidget(image_path, width="50%")
c, r = w._display_size(80)
check((c, r) == (40, 15), "width=50%% -> (40, 15), got (%d, %d)" % (c, r))
w.close()
# height as absolute rows (width derived, aspect preserved)
w = ImageWidget(image_path, height=20)
c, r = w._display_size(80)
check(r == 20 and c == 53, "height=20 -> (53, 20), got (%d, %d)" % (c, r))
w.close()
# height as percent of the visible page height
# page = terminal lines (24) - UI_MARGIN (8) = 16; 50% -> 8 rows
w = ImageWidget(image_path, height="50%")
c, r = w._display_size(80)
check(r == 8 and c == 21, "height=50%% -> (21, 8), got (%d, %d)" % (c, r))
w.close()
# both given: stretch to the exact rectangle
w = ImageWidget(image_path, width=40, height=30)
c, r = w._display_size(80)
check((c, r) == (40, 30), "width=40,height=30 stretches -> (40, 30), got (%d, %d)" % (c, r))
w.close()
# stretching is clamped uniformly when overflowing the space
w = ImageWidget(image_path, width=400, height=200)
c, r = w._display_size(80)
check((c, r) == (80, 40), "overflowing stretch clamped -> (80, 40), got (%d, %d)" % (c, r))
w.close()
# BOX layout: the canvas fills the box; the image rect is clamped
# and re-derived (width from the clamped height).
w = ImageWidget(image_path, name="box")
canvas = w.render((80, 10))
check((canvas.cols(), canvas.rows()) == (80, 10),
"BOX render((80,10)) canvas fills the box, got (%d, %d)"
% (canvas.cols(), canvas.rows()))
check((w._ti_cols, w._ti_rows) == (27, 10),
"BOX image rect clamped -> (27, 10), got (%d, %d)"
% (w._ti_cols, w._ti_rows))
w.close()
# invalid specifications
def raises(ctor, want):
try:
ctor()
except want:
return True
except Exception:
return False
return False
check(raises(lambda: ImageWidget(image_path, width="xx"), ValueError),
"width='xx' raises ValueError")
check(raises(lambda: ImageWidget(image_path, height=0), ValueError),
"height=0 raises ValueError")
check(raises(lambda: ImageWidget(image_path, width=1.5), TypeError),
"width=1.5 raises TypeError")
check(raises(lambda: ImageWidget(image_path, height="50"), ValueError),
"height='50' raises ValueError")
# settable properties re-resolve and invalidate
w = ImageWidget(image_path)
w.width = "50%"
c, r = w._display_size(80)
check((c, r) == (40, 15), "set width=50%% property -> (40, 15)")
w.height = 10
c, r = w._display_size(80)
check((c, r) == (40, 10), "width+height set -> stretch (40, 10), got (%d, %d)" % (c, r))
w.width = None
w.height = 10
c, r = w._display_size(80)
check((c, r) == (27, 10), "height-only after reset -> (27, 10), got (%d, %d)" % (c, r))
w.close()
# native resolution specification ("n"): display at the image's own
# pixel size, quantized to the terminal cell grid. Cell = (1, 2),
# small fixture 64x32 -> native 64 cols, 16 rows.
from nomadnet.ui.textui.images import _webp
small_png = _webp._encode_png_rgba(bytes(64 * 32 * 4), 64, 32)
fd, small_path = tempfile.mkstemp(suffix=".png")
with os.fdopen(fd, "wb") as f:
f.write(small_png)
try:
wn = ImageWidget(small_path, width="n")
wn.render((80,)) # sets _ti_cols/_ti_rows
check((wn._ti_cols, wn._ti_rows) == (64, 16),
"width='n' displays native size (64, 16), got (%d, %d)"
% (wn._ti_cols, wn._ti_rows))
wn.close()
hn = ImageWidget(small_path, height="n")
hn.render((80,))
check((hn._ti_cols, hn._ti_rows) == (64, 16),
"height='n' displays native size (64, 16), got (%d, %d)"
% (hn._ti_cols, hn._ti_rows))
hn.close()
bn = ImageWidget(small_path, width="n", height="n")
bn.render((80,))
check((bn._ti_cols, bn._ti_rows) == (64, 16),
"width='n' height='n' displays native size (64, 16), got (%d, %d)"
% (bn._ti_cols, bn._ti_rows))
bn.close()
big = ImageWidget(image_path, width="n") # 1280x960, overflows
c, r = big._display_size(80)
check((c, r) == (80, 30),
"native of a large image clamps to available width (80, 30), got (%d, %d)" % (c, r))
big.close()
check(raises(lambda: ImageWidget(image_path, width="x"), ValueError),
"width='x' raises ValueError")
finally:
os.unlink(small_path)
# alignment: placement column of a narrowed image inside its canvas
w = ImageWidget(image_path, width=40) # default center
w.render((80,)) # sets _ti_cols/_ti_rows
check(w._ti_align == "|", "default alignment is center")
key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
check(key == (3, 20, 40, 15, None, None),
"centered placement col -> %s" % (key,))
w.align = "left"
key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
check(key == (3, 0, 40, 15, None, None), "left placement col -> 0")
w.align = "right"
key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
check(key == (3, 40, 40, 15, None, None), "right placement col -> 40")
check(raises(lambda: ImageWidget(image_path, align="top"), ValueError),
"align='top' raises ValueError")
w.close()
finally:
_termlib.get_terminal_size = _orig_terminal_size
if _orig_ui_margin is not None:
_widget_mod.UI_MARGIN = _orig_ui_margin
def find_image_widget(pile):
for widget, _opts in pile.contents:
child = widget
if isinstance(child, urwid.AttrMap):
child = child.original_widget
if isinstance(child, ImageWidget):
return child
return None
def run_screen_test(image_path, wrap_attrmap, label):
print("== screen state machine (%s) ==" % label)
_termlib.kitty_supported = True
_termlib.cell_size = CELL_SIZE
_imagestore.image_store.reset()
pile = build_pile(image_path, wrap_attrmap=wrap_attrmap)
scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
screen = RecordingScreen()
img_widget = find_image_widget(pile)
check(img_widget is not None, "image widget found in pile")
z = img_widget._ti_z
entry = _imagestore.image_store.entry_for(img_widget._ti_key)
check(entry is not None and entry["refs"] == 1, "widget registered with image store")
image_id = entry["image_id"]
b64 = base64.b64encode(img_widget._ti_data.data).decode("ascii")
def entries_at(offset):
scrollable.set_scrollpos(offset)
canvas = scrollable.render(VIEWPORT)
return screen._ti_scan(canvas)
# Build a timeline of image visibility across all scroll offsets.
# state = (trim_top, row, visible_rows)
timeline = []
max_offset = 200
for off in range(max_offset + 1):
entries = entries_at(off)
if not entries:
timeline.append((off, None))
continue
widget, row, col, canvas_cols, trim_top, vis_rows = entries[0]
timeline.append((off, (trim_top, row, vis_rows)))
image_rows = img_widget._ti_rows
visible = [t for t in timeline if t[1] is not None]
check(len(visible) > 0, "image is visible at some scroll offset")
if not visible:
return
def first_offset(pred):
for off, state in timeline:
if state is not None and pred(state):
return off
return None
off_enter = first_offset(lambda s: s[0] == 0) # trim_top == 0
off_clip = first_offset(lambda s: s[0] > 0) # top-clipped
check(off_enter is not None, "found offset with image fully entered")
check(off_clip is not None, "found offset with image top-clipped")
if off_enter is None or off_clip is None:
return
# State at the entry offset: (trim_top, row, visible_rows)
off_enter_vis_rows = timeline[off_enter][1][2]
last_visible_off = visible[-1][0]
off_away = last_visible_off + 1 if last_visible_off < max_offset else None
check(off_away is not None, "found offset past the image")
def update(offset):
screen.log = []
scrollable.set_scrollpos(offset)
canvas = scrollable.render(VIEWPORT)
screen._ti_update(canvas)
return list(screen.log)
# 1. before the image is visible: nothing
log = update(0)
check(not any("a=t" in l or "a=p" in l for l in log),
"nothing written while out of view")
check(not screen._ti_images, "nothing tracked while out of view")
# 2. image entered (bottom-clipped): transmit once, place the visible
# slice (y=0 crop) at the bottom.
log = update(off_enter)
trans = extract_transmit(log)
placements = extract_placements(log)
check(trans is not None, "image data transmitted on entry")
if trans:
trans_id, pb64 = trans
check(trans_id == image_id, "transmit uses the store's image id")
check(pb64 == b64, "transmitted payload is the original file")
check(len(placements) == 1, "one placement on entry, got %d" % len(placements))
if placements:
ctrl = placements[0]
check("i=%d" % image_id in ctrl and "p=%d" % z in ctrl,
"placement references image id and placement id")
check("c=80" in ctrl and "r=%d" % off_enter_vis_rows in ctrl,
"placement has display size")
check("y=0," in ctrl and "h=" in ctrl, "entry slice uses a y=0 crop")
check("z=%d" % z in ctrl and "C=1" in ctrl and "q=1" in ctrl,
"placement has z-index, C=1 and quiet mode")
check(len(screen._ti_images) == 1 and
screen._ti_images[id(img_widget)]["drawn"] is not None,
"image tracked as drawn")
check(entry["transmitted"], "store entry marked transmitted")
# 3. move while visible: in-place placement update, no re-transmit,
# no delete.
log = update(off_enter + 1)
check(extract_transmit(log) is None, "position change does not re-transmit")
check(len(extract_placements(log)) == 1, "position change re-places in place")
check(not extract_deletes(log), "position change does not delete")
# 4. top-clipped: placement with a y>0 crop.
log = update(off_clip)
placements = extract_placements(log)
check(len(placements) == 1, "top-clip places a slice, got %d" % len(placements))
if placements:
ctrl = placements[0]
y_px = int(ctrl.split("y=")[1].split(",")[0].split(";")[0])
check(",y=" in ctrl and "h=" in ctrl and y_px > 0,
"top-clipped placement has y>0 crop (y=%d)" % y_px)
# 5. scrolled past: placement deleted, image data kept.
log = update(off_away)
deletes = extract_deletes(log)
check(deletes == ["a=d,d=i,i=%d,p=%d" % (image_id, z)],
"scroll-away deletes the placement only: %s" % deletes)
check(extract_transmit(log) is None, "scroll-away does not transmit")
check(id(img_widget) not in screen._ti_images, "widget untracked after scroll-away")
# 6. scroll back: re-placed from the kept data, no re-transmission.
log = update(off_enter)
check(extract_transmit(log) is None, "scroll-back does not re-transmit")
check(len(extract_placements(log)) == 1, "scroll-back re-places the image")
# 7. still visible, unchanged position: nothing written.
log = update(off_enter)
check(not extract_placements(log) and not extract_deletes(log),
"unchanged position writes nothing")
# 8. widget closed -> orphaned data evicted from the terminal.
img_widget.close()
log = update(off_away)
deletes = extract_deletes(log)
check("a=d,d=I,i=%d" % image_id in deletes,
"orphaned image data evicted with d=I")
check(_imagestore.image_store.entry_for(img_widget._ti_key) is None,
"evicted entry dropped from the store")
def test_eviction_and_purge(image_path):
print("== eviction and purge ==")
_termlib.kitty_supported = True
_termlib.cell_size = CELL_SIZE
_imagestore.image_store.reset()
# Shared source: two widgets with the same file share one store entry.
w1 = ImageWidget(image_path)
w2 = ImageWidget(image_path)
entry = _imagestore.image_store.entry_for(w1._ti_key)
check(entry["refs"] == 2, "identical sources share one registry entry (refs=%d)"
% entry["refs"])
check(w2._ti_key == w1._ti_key, "identical sources have the same content key")
w1.close()
check(entry["refs"] == 1, "closing one widget decrements refcount")
check(entry["transmitted"] is False, "no data transmitted yet")
# Place w2 via a screen update and verify orphan eviction after close.
pile = urwid.Pile([urwid.Text("x"), urwid.Divider(" "), w2,
urwid.Divider(" "), urwid.Text("y")])
scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
screen = RecordingScreen()
image_id = entry["image_id"]
def update(offset):
screen.log = []
scrollable.set_scrollpos(offset)
screen._ti_update(scrollable.render(VIEWPORT))
return list(screen.log)
log = update(2)
check(extract_transmit(log) is not None, "image transmitted when widget visible")
check(entry["transmitted"], "entry marked transmitted")
w2.close()
log = update(0)
deletes = extract_deletes(log)
check("a=d,d=I,i=%d" % image_id in deletes, "last reference closed -> d=I eviction")
check(_imagestore.image_store.entry_for(w2._ti_key) is None,
"entry dropped after eviction")
# Purge: after transmitting, purge_images() deletes everything and the
# next redraw re-transmits for still-visible widgets.
_imagestore.image_store.reset()
w3 = ImageWidget(image_path)
pile = urwid.Pile([urwid.Text("x"), urwid.Divider(" "), w3,
urwid.Divider(" "), urwid.Text("y")])
scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
screen2 = RecordingScreen()
def update2(offset):
screen2.log = []
scrollable.set_scrollpos(offset)
screen2._ti_update(scrollable.render(VIEWPORT))
return list(screen2.log)
log = update2(2)
check(extract_transmit(log) is not None, "purge-test: image transmitted")
screen2.log = []
screen2.purge_images()
check(any("a=d,d=A" in l for l in screen2.log), "purge deletes all (d=A)")
check(_imagestore.image_store.transmitted() == [], "purge marks all untransmitted")
log = update2(2)
check(extract_transmit(log) is not None, "visible image re-transmitted after purge")
check(len(extract_placements(log)) == 1, "visible image re-placed after purge")
w3.close()
def main():
image_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_IMAGE
print("Using image: %s" % image_path)
_termlib.reset_caches()
print("ambient cell size (env): %s" % (_termlib._query_cell_size(),))
test_header_parse(image_path)
test_transmission_encoding(image_path)
test_webp_conversion()
test_winsize_parsing()
test_widget_layout(image_path)
run_screen_test(image_path, wrap_attrmap=False, label="bare widget")
run_screen_test(image_path, wrap_attrmap=True, label="attrmap-wrapped")
test_eviction_and_purge(image_path)
print("\n%d passed, %d failed" % (PASS, FAIL))
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,199 +0,0 @@
# Terminal queries for capability detection and layout metrics.
#
# All values are resolved once and cached; subsequent calls are instant.
# Queries should be performed *before* starting the urwid main loop, because
# they read directly from the terminal and would otherwise race with urwid's
# own input handling. If no terminal is available, or the queries time out,
# sensible fallbacks are used.
import os
import select
import shutil
import sys
import time
try: import termios
except: termios = None
from . import _ctlseqs
QUERY_TIMEOUT = 0.3
kitty_supported = None
cell_size = None
# Last raw terminal query response (bytes), for diagnostics.
last_query_response = bytearray()
last_kitty_response = bytearray()
# Last TIOCGWINSZ buffer (rows, cols, xpixel, ypixel), for diagnostics.
winsize = [0, 0, 0, 0]
# Number of times a query attempt has been made; used to avoid repeated
# slow queries in a broken environment.
_query_failed = False
def _open_tty():
# Opens the active terminal for reading and writing.
for stream in ("out", "in", "err"):
try: return os.open(os.ttyname(getattr(sys, "__std%s__" % stream).fileno()), os.O_RDWR)
except (OSError, AttributeError): continue
try: return os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
except OSError: return -1
def query_tty(request, timeout=QUERY_TIMEOUT):
# Sends a query to the terminal and returns the raw response.
global _query_failed
if _query_failed or not termios: return b""
fd = _open_tty()
if fd == -1:
_query_failed = True
return b""
# Never block indefinitely, even on a broken pseudo-terminal,
# select has a deadline and reads are non-blocking.
try: os.set_blocking(fd, False)
except OSError: pass
old_attr = termios.tcgetattr(fd)
new_attr = termios.tcgetattr(fd)
# Raw-ish mode; disable canonical mode and echo. In canonical mode the
# terminal buffers input until a newline, which terminal query responses
# (for example. kitty's "i=31;OK") never contain. The read would block until
# timeout and the query would always "fail".
new_attr[3] &= ~(termios.ECHO | termios.ICANON)
new_attr[6][termios.VMIN] = 0
new_attr[6][termios.VTIME] = 0
try:
# TCSANOW (not TCSAFLUSH): TCSAFLUSH drains the output queue first,
# which can block indefinitely on some pseudo-terminals.
termios.tcsetattr(fd, termios.TCSANOW, new_attr)
try: os.write(fd, request)
except (BlockingIOError, OSError): return b""
response = bytearray()
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0: break
try: ready, _, _ = select.select([fd], [], [], remaining)
except (OSError, ValueError): break
if not ready: break
try: chunk = os.read(fd, 4096)
except (BlockingIOError, OSError): continue
if not chunk: break
response += chunk
if response.endswith(b"\x1b\\") or response.endswith(b"c"): break
last_query_response[:] = response
return bytes(response)
finally:
try: termios.tcsetattr(fd, termios.TCSANOW, old_attr)
except termios.error: pass
finally: os.close(fd)
def is_kitty_supported():
global kitty_supported
if kitty_supported is None:
response = query_tty(_ctlseqs.KITTY_QUERY)
last_kitty_response[:] = response
kitty_supported = bool(response and _ctlseqs.KITTY_QUERY_RESPONSE_RE.search(response))
return kitty_supported
def force_kitty_supported(status):
# Forces the support status (used by tests).
global kitty_supported
kitty_supported = bool(status)
def _cell_size_from_winsize(buf):
# Interprets a TIOCGWINSZ buffer as (cell_width_px, cell_height_px).
# The struct layout is (rows, cols, xpixel, ypixel). Returns None if the
# terminal does not report pixel dimensions, or if the result is
# implausible (e.g. several times wider than tall - a sign of broken
# winsize reporting).
rows, cols, xpix, ypix = buf
if not (cols and rows and xpix and ypix): return None
cell_w, cell_h = max(1, xpix // cols), max(1, ypix // rows)
if not (0.1 <= cell_w / cell_h <= 1.5):
# Real terminal cells should be as wide as, or narrower than,
# they are tall; anything far outside that range is bogus.
return None
return (cell_w, cell_h)
def _query_cell_size():
# Determines the current cell size in pixels, or None if unknown.
if not termios: return None
fd = _open_tty()
if fd != -1:
# First, try the TIOCGWINSZ pixel fields (fast, no query)
import array
import fcntl
try:
buf = array.array("H", [0, 0, 0, 0])
fcntl.ioctl(fd, termios.TIOCGWINSZ, buf)
winsize[:] = buf
cell = _cell_size_from_winsize(buf)
if cell is not None: return cell
except OSError: pass
finally: os.close(fd)
# Fall back to XTWINOPS 16 (cell size in pixels); DA1 is appended to
# speed up the response (terminals treat queries as FIFO).
response = query_tty(_ctlseqs.CELL_SIZE_PX + _ctlseqs.DA1)
if response:
match = _ctlseqs.CELL_SIZE_RESPONSE_RE.search(response)
if match: return (int(match.group("w")), int(match.group("h")))
return None
def get_cell_size():
# Returns the terminal cell size in pixels as (width, height), or None
# if it could not be determined.
global cell_size
if cell_size is None: cell_size = _query_cell_size()
return cell_size
def get_terminal_size():
# Returns the active terminal size as (columns, lines).
# Uses the tty directly when available (correct even with redirected
# output), falling back to shutil.get_terminal_size.
fd = _open_tty()
if fd != -1:
try:
size = os.get_terminal_size(fd)
return (size.columns, size.lines)
except OSError: pass
finally: os.close(fd)
size = shutil.get_terminal_size(fallback=(80, 24))
return (size.columns, size.lines)
def reset_caches():
# Clears all cached capability/metric values (used by tests).
global kitty_supported, cell_size, _query_failed
kitty_supported = None
cell_size = None
_query_failed = False
last_query_response[:] = b""
last_kitty_response[:] = b""
winsize[:] = (0, 0, 0, 0)
def diagnostics():
# Returns a list of diagnostic strings about the terminal environment,
# for display/debugging.
import os
lines = [ "TERM=%r" % os.environ.get("TERM"),
"TERM_PROGRAM=%r TERM_PROGRAM_VERSION=%r" % (os.environ.get("TERM_PROGRAM"), os.environ.get("TERM_PROGRAM_VERSION")),
"kitty graphics protocol supported: %s" % is_kitty_supported(),
"kitty query response: %r" % bytes(last_kitty_response),
"cell size (px): %s" % (get_cell_size(),),
"cell size query response: %r" % bytes(last_query_response),
"winsize (rows, cols, xpix, ypix): %r" % (tuple(winsize),) ]
try:
from . import _webp
lines.append("webp conversion: %s" % _webp.describe())
except Exception: pass
return lines

View file

@ -1,367 +0,0 @@
# Multi-backend WebP decoding
#
# Kitty's graphics protocol decodes PNG natively but not WebP, so WebP
# sources are converted transparently at the ImageData layer. This module
# provides a conversion chain that *should* work almost anywhere with
# zero extra dependencies:
#
# - Pillow/PIL, if installed with WebP support
# - libwebp via ctypes, decoding to RGBA, paired with a pure-Python PNG
# encoder. The system libwebp is present on most Linux/macOS systems
# as a transitive dependency of common graphics stacks and browsers
# - sips - the macOS built-in image tool
# - dwebp - from the system PATH (the "official" WebP tool)
# - ImageMagick / GraphicsMagick (magick, convert, gm convert)
# - ffmpeg / avconv
# - gdk-pixbuf-thumbnailer (GTK/GNOME systems)
#
# Every backend's output is validated (it must be a valid PNG with the same
# dimensions as the source WebP), so missing tools, or tools built without
# WebP support, are simply skipped. The first backend that produces a valid
# conversion is remembered and preferred for subsequent conversions, but the
# preference is replaced whenever a different backend wins for a particular
# image.
#
# Environment overrides:
#
# NOMADNET_IMAGE_BACKEND force a specific backend (strict)
import ctypes
import ctypes.util
import hashlib
import io
import os
import shutil
import struct
import subprocess
import tempfile
import zlib
MAX_CACHE_BYTES = 192 * 1024 * 1024
MAX_CACHE_FILES = 512
CONVERSION_TIMEOUT = 5
# libwebp soname candidates, in order of preference.
_LIBWEBP_NAMES = [ "libwebp.so.7",
"libwebp.so.6",
"libwebp.so.5",
"libwebp.so",
"libwebp.dylib",
"libwebp-7.dll",
"libwebp.dll" ]
# The built-in backend chain, in preference order.
BACKENDS = ( "pil",
"libwebp-ctypes",
"sips",
"dwebp",
"magick",
"convert",
"gm",
"ffmpeg",
"avconv",
"gdk-pixbuf" )
_ENV_BACKEND = os.environ.get("NOMADNET_IMAGE_BACKEND")
_winner = None # name of the currently preferred backend
_last_backend = None # backend used for the most recent conversion
_convert_count = 0
_cache_hits = 0
_libwebp_cache = {"lib": None, "probed": False}
###############################
# PNG validation and encoding #
###############################
# Returns (width, height) if png is a valid PNG, else None
def _png_dims(png):
if not png.startswith(b"\x89PNG\r\n\x1a\n") or len(png) < 24:
return None
if png[12:16] != b"IHDR":
return None
width = int.from_bytes(png[16:20], "big")
height = int.from_bytes(png[20:24], "big")
if not (width > 0 and height > 0):
return None
return (width, height)
# Encodes RGBA pixel data as a PNG (8-bit RGBA, no filtering)
def _encode_png_rgba(rgba, width, height):
def chunk(tag, payload):
return (struct.pack(">I", len(payload)) + tag + payload +
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
stride = width * 4
raw = bytearray(height * (stride + 1))
pos = 0
for row in range(height):
raw[pos] = 0 # filter type: none
pos += 1
start = row * stride
raw[pos:pos + stride] = rgba[start:start + stride]
pos += stride
return (b"\x89PNG\r\n\x1a\n" +
chunk(b"IHDR", ihdr) +
chunk(b"IDAT", zlib.compress(bytes(raw), 9)) +
chunk(b"IEND", b""))
####################
# Conversion cache #
####################
CONV_CACHE_BASE = None
def cache_dir():
global CONV_CACHE_BASE
if not CONV_CACHE_BASE:
from nomadnet.NomadNetworkApp import NomadNetworkApp
CONV_CACHE_BASE = NomadNetworkApp._shared_instance.dispcachepath
return CONV_CACHE_BASE
def _cache_path(source_data):
return os.path.join(cache_dir(), hashlib.sha256(source_data).hexdigest() + ".png")
def _cache_get(source_data):
try:
with open(_cache_path(source_data), "rb") as f: png = f.read()
if _png_dims(png) is not None: return png
except OSError: pass
return None
def _cache_put(source_data, png):
try:
directory = cache_dir()
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
try:
with os.fdopen(fd, "wb") as f: f.write(png)
os.replace(tmp_path, _cache_path(source_data))
except OSError:
try: os.unlink(tmp_path)
except OSError: pass
_prune_cache()
except OSError: pass
# Evicts least-recently-used entries beyond the size/file limits
def _prune_cache():
try:
entries = []
for name in os.listdir(cache_dir()):
path = os.path.join(cache_dir(), name)
try:
st = os.stat(path)
if st.st_size > 0:
entries.append((st.st_mtime, st.st_size, path))
except OSError: pass
total = sum(e[1] for e in entries)
entries.sort()
while len(entries) > MAX_CACHE_FILES or total > MAX_CACHE_BYTES:
_, size, path = entries.pop(0)
total -= size
try: os.unlink(path)
except OSError: pass
except OSError: pass
#######################
# Individual backends #
#######################
# Runs a conversion CLI over temp files
def _run_cli(argv, data):
if shutil.which(argv[0]) is None: return None
try:
with tempfile.TemporaryDirectory(prefix="nomadnet-img-") as td:
src = os.path.join(td, "source.webp")
dst = os.path.join(td, "output.png")
with open(src, "wb") as f: f.write(data)
args = [a.replace("{src}", src).replace("{dst}", dst) for a in argv]
subprocess.run(
args,
timeout=CONVERSION_TIMEOUT,
# stdin must be closed: some tools (e.g. ffmpeg) block
# on inherited stdin otherwise.
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
with open(dst, "rb") as f: return f.read()
except (OSError, subprocess.TimeoutExpired, ValueError): return None
def _backend_pil(data, dims):
try:
from PIL import Image
from PIL import features
if not features.check("webp"): return None
except ImportError: return None
try:
image = Image.open(io.BytesIO(data))
image.load()
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
except Exception: return None
# Loads the system libwebp via ctypes, caching the result.
def _get_libwebp():
if _libwebp_cache["probed"]: return _libwebp_cache["lib"]
_libwebp_cache["probed"] = True
lib = None
names = list(_LIBWEBP_NAMES)
found = ctypes.util.find_library("webp")
if found and found not in names: names.insert(0, found)
for name in names:
try:
lib = ctypes.CDLL(name)
break
except OSError: continue
if lib is not None:
lib.WebPGetInfo.restype = ctypes.c_int
lib.WebPGetInfo.argtypes = [ ctypes.c_char_p, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int) ]
lib.WebPDecodeRGBA.restype = ctypes.c_void_p
lib.WebPDecodeRGBA.argtypes = [ ctypes.c_char_p, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int) ]
lib.WebPFree.restype = None
lib.WebPFree.argtypes = [ctypes.c_void_p]
_libwebp_cache["lib"] = lib
return lib
def _backend_ctypes(data, dims):
lib = _get_libwebp()
if lib is None: return None
try:
buf = ctypes.create_string_buffer(data, len(data))
width = ctypes.c_int(0)
height = ctypes.c_int(0)
if not lib.WebPGetInfo(buf, len(data), ctypes.byref(width), ctypes.byref(height)): return None
if width.value <= 0 or height.value <= 0: return None
ptr = lib.WebPDecodeRGBA(buf, len(data), ctypes.byref(width), ctypes.byref(height))
if not ptr: return None
try: rgba = ctypes.string_at(ptr, width.value * height.value * 4)
finally: lib.WebPFree(ptr)
return _encode_png_rgba(rgba, width.value, height.value)
except Exception: return None
def _backend_gdkpixbuf(data, dims):
if dims is None: return None
width, height = dims
return _run_cli(["gdk-pixbuf-thumbnailer", "-s", "%dx%d" % (width, height), "{src}", "{dst}"], data)
def _backend_sips(data, dims): return _run_cli(["sips", "-s", "format", "png", "{src}", "--out", "{dst}"], data)
def _backend_dwebp(data, dims): return _run_cli(["dwebp", "{src}", "-o", "{dst}"], data)
def _backend_magick(data, dims): return _run_cli(["magick", "{src}", "{dst}"], data)
def _backend_convert(data, dims): return _run_cli(["convert", "{src}", "{dst}"], data)
def _backend_gm(data, dims): return _run_cli(["gm", "convert", "{src}", "{dst}"], data)
def _backend_ffmpeg(data, dims): return _run_cli(["ffmpeg", "-y", "-loglevel", "error", "-i", "{src}", "-frames:v", "1", "{dst}"], data)
def _backend_avconv(data, dims): return _run_cli(["avconv", "-y", "-loglevel", "error", "-i", "{src}", "-frames:v", "1", "{dst}"], data)
_BACKENDS = { "pil": _backend_pil,
"libwebp-ctypes": _backend_ctypes,
"sips": _backend_sips,
"dwebp": _backend_dwebp,
"magick": _backend_magick,
"convert": _backend_convert,
"gm": _backend_gm,
"ffmpeg": _backend_ffmpeg,
"avconv": _backend_avconv,
"gdk-pixbuf": _backend_gdkpixbuf }
#####################
# Conversion driver #
#####################
def _webp_dims(data):
from ._imagedata import parse_image_header
try: fmt, width, height = parse_image_header(data)
except ValueError: return None
if fmt != "WebP": return None
return (width, height)
def _backend_order():
if _ENV_BACKEND: return [_ENV_BACKEND] if _ENV_BACKEND in BACKENDS else []
order = list(BACKENDS)
if _winner in order:
order.remove(_winner)
order.insert(0, _winner)
return order
# Converts WebP data to PNG bytes, or None on failure
def convert_webp_to_png(data):
global _cache_hits
png = _cache_get(data)
if png is not None:
_cache_hits += 1
return png
return _convert_uncached(data)
def _convert_uncached(data):
global _convert_count, _winner, _last_backend
dims = _webp_dims(data)
if dims is None: return None
for name in _backend_order():
try: png = _BACKENDS[name](data, dims)
except Exception: png = None
if png is not None and _png_dims(png) == dims:
_convert_count += 1
_last_backend = name
if not _ENV_BACKEND: _winner = name
_cache_put(data, png)
return png
return None
##################################
# Introspection and test support #
##################################
# Returns [(name, available), ...] for the built-in chain
def available_backends():
out = []
for name in BACKENDS:
if name == "pil":
try:
from PIL import features
out.append((name, bool(features.check("webp"))))
except Exception: out.append((name, False))
elif name == "libwebp-ctypes": out.append((name, _get_libwebp() is not None))
elif name == "gdk-pixbuf": out.append((name, shutil.which("gdk-pixbuf-thumbnailer") is not None))
else: out.append((name, shutil.which(name) is not None))
return out
# A diagnostic string describing the conversion setup
def describe():
if _ENV_BACKEND: return "backend forced via NOMADNET_IMAGE_BACKEND: %s" % _ENV_BACKEND
present = [name for name, avail in available_backends() if avail]
if not present: return "no conversion backend available (WebP images will not render)"
return "backend(s) available: %s; preferred: %s" % (", ".join(present), _last_backend or _winner or "none yet")
# Usage statistics for diagnostics
def stats():
return { "backend": _last_backend, "conversions": _convert_count,
"cache_hits": _cache_hits, "cache_dir": cache_dir() }
def last_backend(): return _last_backend
# Forces a specific backend name (testing/debugging); None to clear.
def force_backend(name):
global _ENV_BACKEND
_ENV_BACKEND = name if name in BACKENDS else None
# Clears dynamic state (winner, counters); used by tests.
def reset():
global _winner, _last_backend, _convert_count, _cache_hits
_winner = None
_last_backend = None
_convert_count = 0
_cache_hits = 0
_libwebp_cache["lib"] = None
_libwebp_cache["probed"] = False

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,210 +0,0 @@
import urwid
from . import _ctlseqs
from ._imagestore import image_store
from ._termlib import is_kitty_supported
from .widget import ImageWidget
# A raw display screen subclass with Kitty TGP side-channel support
class ImageScreen(urwid.raw_display.Screen):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._ti_images = {}
##########
# Output #
##########
def write(self, data):
if isinstance(data, bytes): data = data.decode("ascii")
return super().write(data)
#############
# Lifecycle #
#############
def clear(self):
# Clears all images and forces a full repaint
if is_kitty_supported():
self.write(_ctlseqs.KITTY_DELETE_ALL)
self._ti_images = {}
image_store.mark_all_untransmitted()
return super().clear()
def _start(self, *args, **kwargs):
ret = super()._start(*args, **kwargs)
if is_kitty_supported():
# Clear any images left over from a previous run/instance.
self.write(_ctlseqs.KITTY_DELETE_ALL)
self._ti_images = {}
image_store.mark_all_untransmitted()
return ret
def _stop(self):
self.purge_images()
try: return super()._stop()
finally: self._ti_images = {}
def purge_images(self):
# Purges all image data from the terminal buffer.
if not is_kitty_supported(): return
if self._ti_images or image_store.transmitted():
self.write(_ctlseqs.KITTY_DELETE_ALL)
self._ti_images = {}
image_store.mark_all_untransmitted()
###########
# Drawing #
###########
def draw_screen(self, size, canvas):
# Draws the canvas, then updates on-screen images within a
# synchronized-update region to prevent flicker.
self.write(_ctlseqs.BEGIN_SYNCED_UPDATE)
try:
super().draw_screen(size, canvas)
self._ti_update(canvas)
finally:
self.write(_ctlseqs.END_SYNCED_UPDATE)
self.flush()
def _ti_update(self, canvas):
# Places/updates/deletes image placements to match the current
# canvas, and evicts orphaned image data.
if not is_kitty_supported(): return
entries = self._ti_scan(canvas)
current = {}
for widget, row, col, canvas_cols, trim_top, vis_rows in entries:
current[id(widget)] = (widget, row, col, canvas_cols, trim_top, vis_rows)
# For widgets that scrolled completely out of view, remove their
# placement only. The image data is kept in the terminal so that
# scrolling back just re-places it.
for wid, info in list(self._ti_images.items()):
if wid in current: continue
self._ti_unplace(info["widget"])
del self._ti_images[wid]
# For visible widgets, place/update the visible slice in place.
for wid, (widget, row, col, canvas_cols, trim_top, vis_rows) in current.items():
info = self._ti_images.setdefault(wid, {"widget": widget, "drawn": None})
if vis_rows <= 0 or trim_top >= widget._ti_rows:
if info["drawn"] is not None:
self._ti_unplace(widget)
info["drawn"] = None
continue
key = self._placement_key(widget, row, col, canvas_cols, trim_top, vis_rows)
if key is None:
if info["drawn"] is not None:
self._ti_unplace(widget)
info["drawn"] = None
continue
if info["drawn"] == key: continue
self._ti_place(widget, key)
info["drawn"] = key
# Evict image data that no live widget references any more
for key, image_id in image_store.evict_orphans():
self.write(_ctlseqs.KITTY_DELETE_IMAGE % image_id)
image_store.evict(key)
#############
# Internals #
#############
def _ti_scan(self, canvas):
# Walks the canvas shards and returns a list of
# (widget, row, col, canvas_cols, trim_top, visible_rows)
# for every visible image canvas.
found = []
if not isinstance(canvas, urwid.CompositeCanvas): return found
def process_shard_tails():
nonlocal col
while col in shard_tails:
*trim, cols, rows, canv = shard_tails[col]
if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv)
else: del shard_tails[col]
col += cols
shard_tails = {}
row = 0
for n_rows, cviews in canvas.shards:
col = 0
for cview in cviews:
process_shard_tails()
*trim, cols, rows, _, canv = cview
widget = self._ti_widget_of(canv)
if widget is not None: found.append((widget, row, col, cols, trim[1], rows))
if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv)
col += cols
process_shard_tails()
row += n_rows
return found
@staticmethod
def _ti_widget_of(canv):
# Returns the ImageWidget for a leaf canvas, or None
widget_info = getattr(canv, "_widget_info", None)
if not widget_info: return None
widget = widget_info[0]
if isinstance(widget, ImageWidget) and widget._ti_data.ok and not widget._ti_closed: return widget
return None
def _placement_key(self, widget, row, col, canvas_cols, trim_top, vis_rows):
# Compute placement parameters for the visible slice of
# an image, including the alignment offset.
cols = widget._ti_cols
rows = widget._ti_rows
if not (cols and rows): return None
place_col = col
if cols < canvas_cols:
align = widget._ti_align
spare = canvas_cols - cols
if align == ">": place_col += spare
elif align == "|": place_col += spare // 2
vis_rows = max(1, min(vis_rows, rows))
y_px = h_px = None
if not (trim_top == 0 and vis_rows == rows):
# Crop to the visible slice of the image, in image pixels
ih = widget._ti_data.height
y_px = min(ih - 1, round(trim_top * ih / rows))
h_px = max(1, min(ih - y_px, round(vis_rows * ih / rows)))
return (row, place_col, cols, vis_rows, y_px, h_px)
def _ti_place(self, widget, key):
# Place (or in-place update) the visible slice of an image
row, place_col, cols, vis_rows, y_px, h_px = key
entry = image_store.entry_for(widget._ti_key)
if entry is None:
# This should not happen, but if the image data was evicted
# while the image was still alive, re-register it so the
# data can be re-transmitted to the terminal buffer.
image_store.register(widget._ti_key, widget._ti_data.data)
entry = image_store.entry_for(widget._ti_key)
image_id = entry["image_id"]
if not entry["transmitted"]:
payload = widget._ti_data.data
b64_len = (len(payload) * 4 + 2) // 3
n_chunks = max(1, (b64_len + _ctlseqs.CHUNK_SIZE - 1) // _ctlseqs.CHUNK_SIZE)
self.write(_ctlseqs.kitty_transmission(_ctlseqs.kitty_transmit_control(image_id), payload))
entry["transmitted"] = True
self.write(_ctlseqs.CURSOR_POSITION % (row + 1, place_col + 1))
self.write(_ctlseqs.kitty_placement(image_id, widget._ti_z, cols, vis_rows, widget._ti_z, y=y_px, h=h_px))
def _ti_unplace(self, widget):
# Delete a widget's placement, but keep the
# image data in the terminal buffer.
entry = image_store.entry_for(widget._ti_key)
if entry is None: return
self.write(_ctlseqs.KITTY_DELETE_PLACEMENT % (entry["image_id"], widget._ti_z))

View file

@ -1,283 +0,0 @@
# An urwid widget that occupies the space an image should fill in a layout,
# without embedding any escape sequences in its canvas. The actual terminal
# graphics are transmitted by the ImageScreen class which tracks the widget's
# on-screen position after every draw.
#
# This separation keeps the canvas trivially simple (urwid sees only blank
# rows) and lets the screen handle the hard parts; positioning, deletion when
# scrolled away, terminal resize and redraws - all of which are driven by
# urwid's own shard bookkeeping rather than by fragile escape-sequence
# manipulation inside canvas rows.
#
# When the Kitty graphics protocol is unavailable (or the image cannot be
# loaded), the widget renders a simple text placeholder instead.
import os
import re
import urwid
from . import _termlib
from ._imagestore import image_store
DEFAULT_CELL_SIZE = (1, 2) # Fallback, assume a 1:2 cell aspect ratio
MAX_Z_INDEX = 2 ** 31 - 1
UI_MARGIN = 8
_SIZE_SPEC_RE = re.compile(r"(\d+(?:\.\d+)?)%")
_ALIGNS = { "left": "<", "<": "<",
"center": "|", "|": "|",
"right": ">", ">": ">" }
def _check_align(value):
if value not in _ALIGNS: raise ValueError("invalid alignment %r (expected 'left', 'center' or 'right')" % (value,))
return _ALIGNS[value]
def _check_size_spec(value, name):
# None = auto, int = absolute cells/rows, "NN%" = percent, "n" = native resolution
if value is None: return None
if isinstance(value, int):
if value <= 0: raise ValueError("%s must be a positive integer, got %r" % (name, value))
return value
if isinstance(value, str):
if value.lower() == "n": return "n"
match = _SIZE_SPEC_RE.fullmatch(value)
if not match or float(match.group(1)) <= 0:
raise ValueError("invalid %s specification %r (expected a positive integer, a percent string like '50%%' or 'n' for native resolution)" % (name, value))
return value
raise TypeError("invalid type for %s (got %s)" % (name, type(value).__name__))
class ImageWidget(urwid.Widget):
# A widget that displays an image via the Kitty graphics protocol.
# The widget is a plain FLOW/BOX widget; its canvas is blank. The
# ImageScreen class performs the actual drawing over the Kitty
# TGP side-channel.
placeholder_glyph = ""
_sizing = frozenset((urwid.FLOW, urwid.BOX))
ignore_focus = True
# Z-index allocation. Each widget instance gets a unique z-index so that
# the screen can delete exactly one image when it scrolls away or moves.
_ti_next_z = 1
_ti_free_z = set()
def __init__(self, path, name=None, width=None, height=None, align="center"):
if path and not isinstance(path, (str, os.PathLike)): raise TypeError("path must be a string or path-like object")
self.path = path
self._notice = None
self._ti_path = None
self._ti_data = None
self._ti_width = _check_size_spec(width, "width")
self._ti_height = _check_size_spec(height, "height")
self._ti_align = _check_align(align)
self._ti_name = name if name is not None else ""
self._ti_z = self._ti_alloc_z()
self.image_id = self._ti_z
# Display size (cols, rows) from the most recent render.
# Used by the screen to place the image.
self._ti_cols = None
self._ti_rows = None
self._ti_closed = False
self._ti_key = None
super().__init__()
if self.path: self.load()
def load(self, path=None, remote_source=False):
if path: self.path = path
if self.path:
from ._imagedata import ImageData
self._ti_path = os.path.abspath(os.fspath(self.path))
self._ti_data = ImageData(self._ti_path, remote_source=remote_source)
if self._ti_data.ok:
# Register with the image store (de-duplicates identical data
# and allocates the terminal-side image id on first use).
self._ti_key = self._ti_data.key
image_store.register(self._ti_key, self._ti_data.data)
self._invalidate()
else:
error_msg = self._ti_data.error or "Unknown error"
self.notice(f"Error: {error_msg}")
def notice(self, msg=""):
if not msg: self._notice = None
else: self._notice = str(msg)
self._invalidate()
def __del__(self):
try: type(self)._ti_free_z.add(self._ti_z)
except Exception: pass
try: self.close()
except Exception: pass
def close(self):
# Releases the widget's reference to its image data.
# The data is evicted from the terminal by the screen as soon as no
# live widget references it any more. Safe to call multiple times and
# from __del__.
try:
if not self._ti_closed:
self._ti_closed = True
if self._ti_key is not None: image_store.unregister(self._ti_key)
except Exception: pass
@classmethod
def _ti_alloc_z(cls):
if cls._ti_free_z: return cls._ti_free_z.pop()
z = cls._ti_next_z
if z >= MAX_Z_INDEX: raise RuntimeError("Too many image widgets")
cls._ti_next_z += 1
return z
##########
# Sizing #
##########
width = property(
lambda self: self._ti_width,
doc="""Display width specification: None (full width), a positive
int (columns), a percent string ("NN%") of the available layout
width, or "n" for native resolution.""",
)
@width.setter
def width(self, value):
self._ti_width = _check_size_spec(value, "width")
self._invalidate()
height = property(
lambda self: self._ti_height,
doc="""Display height specification: None (derived, aspect
preserving), a positive int (rows), a percent string ("NN%") of
the visible page height, or "n" for native resolution.""",
)
@height.setter
def height(self, value):
self._ti_height = _check_size_spec(value, "height")
self._invalidate()
align = property(
lambda self: self._ti_align,
doc="""Horizontal alignment of the image within its allocated
space: ``"<"`` (left), ``"|"`` (center) or ``">"`` (right).""",
)
@align.setter
def align(self, value):
self._ti_align = _check_align(value)
self._invalidate()
def _short(self): return "img#%x" % (id(self) & 0xFFFFF)
def _display_size(self, maxcol, maxrow=None):
if not self._ti_data.ok: return (min(maxcol, 1), 1)
iw, ih = self._ti_data.size
cell_w, cell_h = _termlib.get_cell_size() or DEFAULT_CELL_SIZE
w_spec, h_spec = self._ti_width, self._ti_height
def _pct(value): return float(value[:-1]) / 100.0
def _raw_width():
if w_spec is None: return float(maxcol)
if isinstance(w_spec, int): return float(w_spec)
if w_spec == "n": return iw / cell_w
return maxcol * _pct(w_spec)
def _raw_height():
if h_spec is None: return None
if isinstance(h_spec, int): return float(h_spec)
if h_spec == "n": return ih / cell_h
columns, lines = _termlib.get_terminal_size()
page = max(1, lines - UI_MARGIN)
return page * _pct(h_spec)
def _height_for_width(cols): return cols * cell_w * ih / (cell_h * iw)
def _width_for_height(rows): return rows * cell_h * iw / (cell_w * ih)
if w_spec is not None and h_spec is not None:
# Stretch mode: Target rectangle, uniformly scaled down only if
# it would overflow the available space.
target_w = max(1.0, _raw_width())
target_h = max(1.0, _raw_height())
scale = min(1.0, maxcol / target_w)
if maxrow is not None: scale = min(scale, maxrow / target_h)
cols = max(1, round(target_w * scale))
rows = max(1, round(target_h * scale))
elif w_spec is not None:
# Width given: Height derived, aspect preserving.
cols = max(1, min(maxcol, round(_raw_width())))
rows = max(1, round(_height_for_width(cols)))
if maxrow is not None and rows > maxrow:
rows = maxrow
cols = max(1, min(maxcol, round(_width_for_height(rows))))
elif h_spec is not None:
# Height given: Width derived, aspect preserving.
rows = max(1, round(_raw_height()))
cols = max(1, min(maxcol, round(_width_for_height(rows))))
if maxrow is not None and rows > maxrow:
rows = maxrow
cols = max(1, min(maxcol, round(_width_for_height(rows))))
else:
# Default: Full width, height derived.
cols = maxcol
rows = max(1, round(_height_for_width(cols)))
if maxrow is not None and rows > maxrow:
rows = maxrow
cols = max(1, min(maxcol, round(_width_for_height(rows))))
return (cols, rows)
def rows(self, size, focus=False):
maxcol = size[0]
if not (self._ti_data and self._ti_data.ok and _termlib.is_kitty_supported()):
return len(self._placeholder_lines())
n = self._display_size(maxcol)[1]
return n
def render(self, size, focus=False):
if len(size) == 2:
maxcol, maxrow = size
is_box = True
else:
maxcol, maxrow = size[0], None
is_box = False
if not (self._ti_data and self._ti_data.ok and _termlib.is_kitty_supported()):
return self._placeholder_canvas(maxcol, maxrow, focus)
cols, rows = self._display_size(maxcol, maxrow)
self._ti_cols, self._ti_rows = cols, rows
# urwid requires canvases to fill the allocated space exactly
# (width == maxcol for FLOW, (maxcol, maxrow) for BOX). The image
# itself is placed by the screen at its resolved size inside the
# canvas, left-aligned; the rest of the canvas stays blank.
if is_box: return ImageCanvas(maxcol, maxrow)
else: return ImageCanvas(maxcol, rows)
#########################
# Placeholder rendering #
#########################
def _placeholder_lines(self):
return [f"{self.placeholder_glyph}{self._notice or self._ti_name}"]
def _placeholder_canvas(self, maxcol, maxrow, focus):
align = {"<": "left", "|": "center", ">": "right"}.get(self._ti_align, "center")
text = urwid.Text("\n".join(self._placeholder_lines()), align=align)
if maxrow is not None: return urwid.Filler(text).render((maxcol, maxrow), focus)
else: return text.render((maxcol,), focus)
class ImageCanvas(urwid.SolidCanvas):
# A blank canvas marking the space occupied by an image.
# Urwid sets _widget_info on this canvas to the owning
# ImageWidget. ImageScreen finds image canvases through
# that reference during its shard scan.
def __init__(self, cols, rows): super().__init__(" ", cols, rows)

View file

@ -1,39 +1,6 @@
import os
import re import re
import unicodedata import unicodedata
def write_config_atomic(config):
path = getattr(config, "filename", None)
if not path:
config.write()
return
directory = os.path.dirname(path) or "."
tmp = os.path.join(directory, "."+os.path.basename(path)+".tmp")
try:
try:
existing_mode = os.stat(path).st_mode
except OSError:
existing_mode = None
with open(tmp, "wb") as tmp_file:
config.write(tmp_file)
tmp_file.flush()
try: os.fsync(tmp_file.fileno())
except Exception: pass
if existing_mode is not None:
try: os.chmod(tmp, existing_mode)
except Exception: pass
os.replace(tmp, path)
except Exception:
try: os.remove(tmp)
except Exception: pass
raise
invalid_rendering = ["🕵️", "☝"] invalid_rendering = ["🕵️", "☝"]
# Unicode blocks to strip (symbols, dingbats, emoji ranges) # Unicode blocks to strip (symbols, dingbats, emoji ranges)
@ -121,9 +88,8 @@ def strip_modifiers(text):
stripped = re.sub(r'[\U0001F3FB-\U0001F3FF]', '', stripped, flags=re.UNICODE) stripped = re.sub(r'[\U0001F3FB-\U0001F3FF]', '', stripped, flags=re.UNICODE)
stripped = re.sub(r'[\u200D\u200C]', '', stripped) stripped = re.sub(r'[\u200D\u200C]', '', stripped)
stripped = re.sub(r'\r\n?', '\n', stripped) stripped = re.sub(r'\r\n?', '\n', stripped)
stripped = STRIP_CONTROL_RE.sub('', stripped)
return stripped.strip().replace("\x00", "")
return stripped.strip()
def sanitize_name(name): def sanitize_name(name):
if name is None: return None if name is None: return None

View file

@ -9,7 +9,6 @@
# GNU General Public License for more details # GNU General Public License for more details
# http://www.gnu.org/licenses/gpl-3.0.txt # http://www.gnu.org/licenses/gpl-3.0.txt
import RNS
import time import time
import urwid import urwid
from urwid.widget import BOX, FIXED, FLOW from urwid.widget import BOX, FIXED, FLOW
@ -84,10 +83,6 @@ class Scrollable(urwid.WidgetDecoration):
if canv_cols <= maxcol and canv_rows <= maxrow: if canv_cols <= maxcol and canv_rows <= maxrow:
# Canvas is small enough to fit without trimming # Canvas is small enough to fit without trimming
if self._trim_top != 0 or self._scroll_action is not None:
# Reset potentially stale _trim_top
self._trim_top = 0
self._scroll_action = None
return canv return canv
self._adjust_trim_top(canv, size) self._adjust_trim_top(canv, size)
@ -372,11 +367,6 @@ class ScrollBar(urwid.WidgetDecoration):
self._sevt_interval = 0 self._sevt_interval = 0
self._sevt_start = 0 self._sevt_start = 0
self._sfreq_deque = deque(maxlen=5) self._sfreq_deque = deque(maxlen=5)
self._debug_hz = 0
self._debug_hz_smoothed = 0
self._debug_scroll_ms = 0
self._debug_scroll_started = 0
def render(self, size, focus=False): def render(self, size, focus=False):
maxcol, maxrow = size maxcol, maxrow = size
@ -396,24 +386,11 @@ class ScrollBar(urwid.WidgetDecoration):
ow_rows_max = ow_base.rows_max(ow_size, focus) ow_rows_max = ow_base.rows_max(ow_size, focus)
ow_canv = ow.render(ow_size, focus) ow_canv = ow.render(ow_size, focus)
if self._debug_scroll_started:
total_ms = (time.time() - self._debug_scroll_started) * 1000
# RNS.log(f" accel={self._accel} , hz={RNS.prettyfrequency(self._debug_hz)} smoothed={RNS.prettyfrequency(self._debug_hz_smoothed)} scroll={self._debug_scroll_ms:.1f}ms total={total_ms:.1f}ms", RNS.LOG_DEBUG)
self._debug_scroll_started = 0
self._original_widget_size = ow_size self._original_widget_size = ow_size
pos = ow_base.get_scrollpos(ow_size, focus) pos = ow_base.get_scrollpos(ow_size, focus)
posmax = ow_rows_max - maxrow posmax = ow_rows_max - maxrow
# Clamp position to valid scroll range. The position
# reported by the underlying widget can transiently
# fall outside [0, posmax] if its layout state is
# inconsistent.
if pos < 0: pos = 0
elif pos > posmax: pos = posmax
# Thumb shrinks/grows according to the ratio of # Thumb shrinks/grows according to the ratio of
# <number of visible lines> / <number of total lines> # <number of visible lines> / <number of total lines>
thumb_weight = min(1, maxrow / max(1, ow_rows_max)) thumb_weight = min(1, maxrow / max(1, ow_rows_max))
@ -425,12 +402,9 @@ class ScrollBar(urwid.WidgetDecoration):
if top_height == 0 and top_weight > 0: if top_height == 0 and top_weight > 0:
top_height = 1 top_height = 1
# Bottom part is remaining space. Guard against # Bottom part is remaining space
# negative values so the scrollbar canvas can
# never be taller than maxrow.
bottom_height = maxrow - thumb_height - top_height bottom_height = maxrow - thumb_height - top_height
if bottom_height < 0: bottom_height = 0 assert thumb_height + top_height + bottom_height == maxrow
assert thumb_height + top_height + bottom_height <= maxrow
# Create scrollbar canvas # Create scrollbar canvas
# Creating SolidCanvases of correct height may result in "cviews do not # Creating SolidCanvases of correct height may result in "cviews do not
@ -449,6 +423,10 @@ class ScrollBar(urwid.WidgetDecoration):
combinelist = [(ow_canv, None, True, ow_size[0]), combinelist = [(ow_canv, None, True, ow_size[0]),
(sb_canv, None, False, sb_width)] (sb_canv, None, False, sb_width)]
self._sb_visible = True self._sb_visible = True
self._sb_thumb_height = thumb_height self._sb_thumb_height = thumb_height
self._sb_ow_rows_max = ow_rows_max self._sb_ow_rows_max = ow_rows_max
@ -549,22 +527,23 @@ class ScrollBar(urwid.WidgetDecoration):
sevt_freq = 1/self._sevt_interval sevt_freq = 1/self._sevt_interval
self._sfreq_deque.append(sevt_freq) self._sfreq_deque.append(sevt_freq)
freq_smth = sum(self._sfreq_deque)/len(self._sfreq_deque) freq_smth = sum(self._sfreq_deque)/len(self._sfreq_deque)
self._debug_hz = sevt_freq
self._debug_hz_smoothed = freq_smth
if len(self._sfreq_deque) >= self._sfreq_deque.maxlen: if len(self._sfreq_deque) >= self._sfreq_deque.maxlen:
if freq_smth > 25.0: self._accel = max(self._accel, 3) if freq_smth > 25.0: self._accel = max(self._accel, 3)
elif freq_smth > 12.0: self._accel = max(self._accel, 2) elif freq_smth > 12.0: self._accel = max(self._accel, 2)
else: self._accel = max(self._accel, 1) else: self._accel = max(self._accel, 1)
# import RNS; RNS.log(f"a={self._accel} s={RNS.prettyfrequency(freq_smth)} f={RNS.prettyfrequency(sevt_freq)}", RNS.LOG_CRITICAL)
step = self._accel step = self._accel
if button in (4, 5): if button == 4: # scroll wheel up
scroll_started = time.time()
pos = ow.get_scrollpos(ow_size) pos = ow.get_scrollpos(ow_size)
if button == 4: newpos = max(0, pos - step) newpos = pos - step
else: newpos = pos + step if newpos < 0: newpos = 0
ow.set_scrollpos(newpos) ow.set_scrollpos(newpos)
self._debug_scroll_started = scroll_started return True
self._debug_scroll_ms = (time.time() - scroll_started) * 1000 elif button == 5: # scroll wheel down
pos = ow.get_scrollpos(ow_size)
ow.set_scrollpos(pos + step)
return True return True
return False return False

View file

@ -1,14 +1,7 @@
import urwid import urwid
import nomadnet
from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit
def dropdown_indicator():
try:
return nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs["dropdown"]
except Exception:
return " ▾"
class DialogLineBox(urwid.LineBox): class DialogLineBox(urwid.LineBox):
def __init__(self, body, parent=None, title="?"): def __init__(self, body, parent=None, title="?"):
super().__init__(body, title=title) super().__init__(body, title=title)
@ -45,13 +38,6 @@ class Dropdown(urwid.WidgetWrap):
self.main_button = urwid.SelectableIcon(self.main_text, 0) self.main_button = urwid.SelectableIcon(self.main_text, 0)
self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus") self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus")
self.indicator_widget = urwid.Text(dropdown_indicator())
self.main_row = urwid.Columns([
('pack', self.main_button),
('pack', self.indicator_widget),
('weight', 1, urwid.Text("")),
])
self.option_widgets = [] self.option_widgets = []
for opt in options: for opt in options:
icon = urwid.SelectableIcon(opt, 0) icon = urwid.SelectableIcon(opt, 0)
@ -62,7 +48,7 @@ class Dropdown(urwid.WidgetWrap):
self.options_listbox = urwid.ListBox(self.options_walker) self.options_listbox = urwid.ListBox(self.options_walker)
self.dropdown_box = None # will be created on open_dropdown self.dropdown_box = None # will be created on open_dropdown
self.pile = urwid.Pile([self.main_row]) self.pile = urwid.Pile([self.main_button])
self.dropdown_visible = False self.dropdown_visible = False
super().__init__(self.pile) super().__init__(self.pile)
@ -139,20 +125,9 @@ class FormEdit(Placeholder, FormField):
self.error = "This field is required" self.error = "This field is required"
break break
elif validation == "number": elif validation == "number":
try: if value and not value.replace('-', '').replace('.', '').isdigit():
if value: self.error = "This field must be a number"
int(value)
except ValueError:
self.error = "This field must be a whole number"
break 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": elif validation == "float":
try: try:
if value: if value:
@ -183,7 +158,7 @@ class FormCheckbox(urwid.CheckBox, FormField):
for validation in self.validation_types: for validation in self.validation_types:
if validation == "required": if validation == "required":
if not value: if not value:
self.error = "This field is required" self.error = "This field is required"
break break
self.error_widget.set_text(("error", self.error or "")) self.error_widget.set_text(("error", self.error or ""))
@ -266,25 +241,14 @@ class FormMultiList(urwid.Pile, FormField):
urwid.Pile.__init__(self, pile_widgets) urwid.Pile.__init__(self, pile_widgets)
FormField.__init__(self, config_key, transform) FormField.__init__(self, config_key, transform)
def create_entry_row(self, text=""): def create_entry_row(self):
edit = ReadlineEdit("", text) edit = ReadlineEdit("", "")
entry_row = urwid.Columns([ entry_row = urwid.Columns([
('weight', 1, edit), ('weight', 1, edit),
(3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))),
]) ])
return entry_row return entry_row
def set_value(self, value):
self.entries = []
if not value:
self.entries.append(self.create_entry_row())
else:
if isinstance(value, str):
value = [value]
for item in value:
self.entries.append(self.create_entry_row(str(item)))
self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
def remove_entry(self, button, entry_row): def remove_entry(self, button, entry_row):
if len(self.entries) > 1: if len(self.entries) > 1:
self.entries.remove(entry_row) self.entries.remove(entry_row)

View file

@ -13,7 +13,6 @@ if "--getversion" in sys.argv:
package_data = { package_data = {
"": [ "": [
"examples/messageboard/*", "examples/messageboard/*",
"ui/textui/images/demo.webp",
] ]
} }
@ -36,6 +35,6 @@ setuptools.setup(
entry_points= { entry_points= {
'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] 'console_scripts': ['nomadnet=nomadnet.nomadnet:main']
}, },
install_requires=["rns>=1.5.5", "lxmf>=1.2.0", "urwid>=3.0.5", "qrcode"], install_requires=["rns>=1.3.8", "lxmf>=1.0.1", "urwid>=3.0.5", "qrcode"],
python_requires=">=3.8", python_requires=">=3.8",
) )