diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..caf3250 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ✨ Feature Request or Idea + url: https://github.com/markqvist/Reticulum/discussions/new?category=ideas + about: Propose and discuss features and ideas + - name: 💬 Questions, Help & Discussion + about: Ask anything, or get help + url: https://github.com/markqvist/Reticulum/discussions/new/choose + - name: 📖 Read the Reticulum Manual + url: https://markqvist.github.io/Reticulum/manual/ + about: The complete documentation for Reticulum diff --git a/.github/ISSUE_TEMPLATE/🐛-bug-report.md b/.github/ISSUE_TEMPLATE/🐛-bug-report.md new file mode 100644 index 0000000..65b492e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/🐛-bug-report.md @@ -0,0 +1,35 @@ +--- +name: "\U0001F41B Bug Report" +about: Report a reproducible bug +title: '' +labels: '' +assignees: '' + +--- + +**Read the Contribution Guidelines** +Before creating a bug report on this issue tracker, you **must** read the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md). Issues that do not follow the contribution guidelines **will be deleted without comment**. + +- The issue tracker is used by developers of this project. **Do not use it to ask general questions, or for support requests**. +- Ideas and feature requests can be made on the [Discussions](https://github.com/markqvist/Reticulum/discussions). **Only** feature requests accepted by maintainers and developers are tracked and included on the issue tracker. **Do not post feature requests here**. +- After reading the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md), **delete this section only** (*"Read the Contribution Guidelines"*) from your bug report, **and fill in all the other sections**. + +**Describe the Bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Describe in detail how to reproduce the bug. + +**Expected Behavior** +A clear and concise description of what you expected to happen. + +**Logs & Screenshots** +Please include any relevant log output. If applicable, also add screenshots to help explain your problem. + +**System Information** +- OS and version +- Python version +- Program version + +**Additional context** +Add any other context about the problem here. diff --git a/Dockerfile b/Dockerfile index 0944827..e9920e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-rc-alpine3.14 as build +FROM python:3.12-alpine as build RUN apk add --no-cache build-base linux-headers libffi-dev cargo @@ -8,10 +8,10 @@ ENV PATH="/opt/venv/bin:$PATH" RUN pip3 install setuptools-rust pyopenssl cryptography COPY . /app/ -RUN cd /app/ && python3 setup.py install +RUN cd /app/ && pip3 install . # Use multi-stage build, as we don't need rust compilation on the final image -FROM python:3.11-rc-alpine3.14 +FROM python:3.12-alpine LABEL org.opencontainers.image.documentation="https://github.com/markqvist/NomadNet#nomad-network-daemon-with-docker" diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..e28e9bd --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,28 @@ +FROM python:alpine +LABEL authors="Petr Blaha petr.blaha@cleverdata.cz" +USER root +RUN apk update +RUN apk add build-base libffi-dev cargo pkgconfig linux-headers py3-virtualenv + +RUN addgroup -S myuser && adduser -S -G myuser myuser +USER myuser +WORKDIR /home/myuser + +RUN pip install --upgrade pip +RUN pip install setuptools-rust pyopenssl cryptography + + +ENV PATH="/home/myuser/.local/bin:${PATH}" + +################### BEGIN NomadNet ########################################### + + +COPY --chown=myuser:myuser . . + +#Python create virtual environment +RUN virtualenv /home/myuser/NomadNet/venv +RUN source /home/myuser/NomadNet/venv/bin/activate + +RUN make all + +################### END NomadNet ########################################### diff --git a/Dockerfile.howto b/Dockerfile.howto new file mode 100644 index 0000000..cfd4ee8 --- /dev/null +++ b/Dockerfile.howto @@ -0,0 +1,6 @@ +# Run docker command one by one(all four), it will build NomadNet artifact and copy to dist directory. +# No need to build locally and install dependencies +docker build -t nomadnetdockerimage -f Dockerfile.build . +docker run -d -it --name nomadnetdockercontainer nomadnetdockerimage /bin/sh +docker cp nomadnetdockercontainer:/home/myuser/dist . +docker rm -f nomadnetdockercontainer \ No newline at end of file diff --git a/FUNDING.yml b/FUNDING.yml new file mode 100644 index 0000000..d125d55 --- /dev/null +++ b/FUNDING.yml @@ -0,0 +1,3 @@ +liberapay: Reticulum +ko_fi: markqvist +custom: "https://unsigned.io/donate" diff --git a/MIRROR.md b/MIRROR.md new file mode 100644 index 0000000..ef2fdd4 --- /dev/null +++ b/MIRROR.md @@ -0,0 +1,33 @@ +This repository is a public mirror. All potential future development is happening elsewhere. + +I am stepping back from all public-facing interaction with this project. Reticulum has always been primarily my work, and continuing in the current public, internet-facing model is no longer sustainable. + +The software remains available for use as-is. Occasional updates may appear at unpredictable intervals, but there will be no support, no responses to issues, no discussions, and no community management in this or any other public venue. If it doesn't work for you, it doesn't work. That is the entire extent of available troubleshooting assistance I can offer you. + +If you've followed this project for a while, you already know what this means. You know who designed, wrote and tested this, and you know how many years of my life it took. You'll also know about both my particular challenges and strengths, and how I believe anything worth building needs to be built and maintained with our own hands. + +Seven months ago, I said I needed to step back, that I was exhausted, and that I needed to recover. I believed a public resolve would be enough to effectuate that, but while striving to get just a few more useful features and protocols out, the unproductive requests and demands also ramped up, and I got pulled back into the same patterns and draining interactions that I'd explicitly said I couldn't sustain anymore. + +So here's what you might have already guessed: I'm done playing the game by rules I can't win at. + +Everything you need is right here, and by any sensible measure, it's done. Anyone who wants to invest the time, skill and persistence can build on it, or completely re-imagine it with different priorities. That was always the point. + +The people who actually contributed - you know who you are, and you know I mean it when I say: Thank you. All of you who've used this to build something real - that was the goal, and you did it without needing me to hold your hand. + +The rest of you: You have what you need. Use it or don't. I am not going to be the person who explains it to you anymore. + +This is not a temporary break. It's not "see you after some rest", but a recognition that the current model is fundamentally incompatible with my life, my health, and my reality. + +If you want to support continued work, you can do so at the donation links listed in this repository. But please understand, that this is not purchasing support or guaranteeing updates. It is support for work that happens on my timeline, according to my capacity, which at the moment is not what it was. + +If you want Reticulum to continue evolving, you have the power to make that happen. The protocol is public domain. The code is open source. Everything you need is right here. I've provided the tools, but building what comes next is not my responsibility anymore. It's yours. + +To the small group of people who has actually been here, and understood what this work was and what it cost - you already know where to find me if it actually matters. + +To everyone else: This is where we part ways. No hard feelings. It's just time. + +--- + +असतो मा सद्गमय +तमसो मा ज्योतिर्गमय +मृत्योर्मा अमृतं गमय diff --git a/Makefile b/Makefile index 0aac3f3..5626de8 100644 --- a/Makefile +++ b/Makefile @@ -21,5 +21,10 @@ build_wheel: release: remove_symlinks build_wheel create_symlinks upload: + @echo Ready to publish release over Reticulum + @read VOID + rngit release rns://7649a50d84610232d1416b41d2896aff/reticulum/nomadnet create $$(python setup.py --getversion):dist --name nomadnet + +upload-pip: @echo Uploading to PyPi... - twine upload dist/* + twine upload dist/*.whl dist/*.tar.gz diff --git a/README.md b/README.md index a91f9cf..b306b86 100755 --- a/README.md +++ b/README.md @@ -1,40 +1,52 @@ -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.* Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. -![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) +![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1a.png) -Nomad Network Allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. +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 is build on [LXMF](https://github.com/markqvist/LXMF) and [Reticulum](https://github.com/markqvist/Reticulum), which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. -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. +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. + +Other Nomad Network clients include: + +- [MeshChatX](https://meshchatx.com/) +- [Columba (mobile)](https://github.com/torlando-tech/columba) +- [Ren Browser](https://github.com/Quad4-Software/Ren-Browser) ## Notable Features - Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports. - Zero-configuration, minimal-infrastructure mesh communication - Distributed and encrypted message store holds messages for offline users - Connectable nodes that can host pages and files + - Image rendering support in terminals that support it - Node-side generated pages with PHP, Python, bash or others - Built-in text-based browser for interacting with contents on nodes + - Built-in RRC client for live, many-to-many chat - An easy to use and bandwidth efficient markup language for writing pages - Page caching in browser -## Current Status -The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the ideal time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion, report bugs and request features here on the GitHub project. - -### Feature roadmap - - Network-wide propagated bulletins and discussion threads - - Collaborative maps and geospatial information sharing - - Facilitation of trade and barter - ## How do I get started? The easiest way to install Nomad Network is via pip: ```bash # Install Nomad Network and dependencies -pip3 install nomadnet +pip install nomadnet # Run the client nomadnet @@ -46,80 +58,99 @@ nomadnet --daemon nomadnet --help ``` -The first time the program is running, you will be presented with the guide section, which contains all the information you need to start using Nomad Network. +If you are using an operating system that blocks normal user package installation via `pip`, you can return `pip` to normal behaviour by editing the `~/.config/pip/pip.conf` file, and adding the following directive in the `[global]` section: + +```text +[global] +break-system-packages = true +``` + +Alternatively, you can use the `pipx` tool to install Nomad Network in an isolated environment: + +```bash +# Install Nomad Network +pipx install nomadnet + +# Optionally install Reticulum utilities +pipx install rns + +# Optionally install standalone LXMF utilities +pipx install lxmf + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +``` + +**Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + +The first time the program is running, you will be presented with the **Guide section**, which contains all the information you need to start using Nomad Network. To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). -If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. +If you 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: -**Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + - `9ce92808be498e9e05590ff27cbfdfe4` The rns.recipes forum + - `a4a5e861626ce97c9aa544d9ecdf6d22` rmap.world + +To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. ### Install on Android You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. -For a native Android application with a graphical user interface, have a look at [Sideband](https://unsigned.io/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.1.8`) for a specific release. +## Tools & Extensions -```sh -$ docker pull ghcr.io/markqvist/nomadnet:master +Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist: -# Run nomadnet interactively in a container -$ docker run -it ghcr.io/markqvist/nomadnet:master --textui +- [Retipedia](https://github.com/RFnexus/Retipedia) +- [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum)) +- [NomadForecast](https://github.com/faragher/NomadForecast) +- [micron-blog](https://github.com/randogoth/micron-blog) +- [md2mu](https://github.com/randogoth/md2mu) +- [Any2MicronConverter](https://github.com/SebastianObi/Any2MicronConverter) +- [Some nomadnet page examples](https://github.com/SebastianObi/NomadNet-Pages) +- [More nomadnet page examples](https://github.com/epenguins/NomadNet_pages) +- [LXMF-Bot](https://github.com/randogoth/lxmf-bot) +- [LXMF Messageboard](https://github.com/chengtripp/lxmf_messageboard) +- [LXMEvent](https://github.com/faragher/LXMEvent) +- [POPR](https://github.com/faragher/POPR) +- [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools) -# 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 -``` - -## Help & Discussion - -For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). ## Support Nomad Network You can help support the continued development of open, free and private communications systems by donating via one of the following channels: - Monero: ``` - 84FpY1QbxHcgdseePYNmhTHcrgMX4nFf - BYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD1 - 9b3B8NiLCGVxzKV17UMmmeEsCrPyA5w - ``` -- Ethereum - ``` - 0x81F7B979fEa6134bA9FD5c701b3501A2e61E897a + 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w ``` - Bitcoin ``` - 3CPmacGm34qYvR6XWLVEJmi2aNe3PZqUuq + bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6 ``` +- Ethereum + ``` + 0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E + ``` +- Liberapay: https://liberapay.com/Reticulum/ + - Ko-Fi: https://ko-fi.com/markqvist + ## Caveat Emptor -Nomad Network is beta software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch. +Nomad Network is beta software, and should be considered as such. Use at your own risk and responsibility. ## Screenshots -![Screenshot 1](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) +![Screenshot 1](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1a.png) ![Screenshot 2](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/2.png) @@ -128,3 +159,5 @@ Nomad Network is beta software, and should be considered as such. While it has b ![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 6](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/6.png) diff --git a/README.mu b/README.mu new file mode 100644 index 0000000..2c95bf1 --- /dev/null +++ b/README.mu @@ -0,0 +1,123 @@ +>> Nomad Network - Communicate Freely + +Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. + +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 is build on `_`!`[LXMF`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=lxmf]`!`_ and `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_, which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. + +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`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_, which is available for Linux, Android, Windows and macOS. + +>> Notable Features + + • Encrypted messaging over packet-radio, LoRa, WiFi or anything else `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_ supports. + • Zero-configuration, minimal-infrastructure mesh communication + • Distributed and encrypted message store holds messages for offline users + • Connectable nodes that can host pages and files + • Image rendering support in terminals that support it + • Node-side generated pages with PHP, Python, bash or others + • Built-in text-based browser for interacting with contents on nodes + • Built-in RRC client for live, many-to-many chat + • An easy to use and bandwidth efficient markup language for writing pages + • Page caching in browser + +>> How do I get started? + +The easiest way to install Nomad Network is via `B333pip`b: + +`B333 +`= +# Install Nomad Network and dependencies +pip install nomadnet + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +`= +`b + +If you are using an operating system that blocks normal user package installation via `B333pip`b, you can return `B333pip`b to normal behaviour by editing the `B333~/.config/pip/pip.conf`b file, and adding the following directive in the `B333[global]`b section: + +`B333 +`= +[global] +break-system-packages = true +`= +`b + +Alternatively, you can use the `B333pipx`b tool to install Nomad Network in an isolated environment: + +`B333 +`= +# Install Nomad Network +pipx install nomadnet + +# Optionally install Reticulum utilities +pipx install rns + +# Optionally install standalone LXMF utilities +pipx install lxmf + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +`= +`b + +`!Please Note`!: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + +The first time the program is running, you will be presented with the `!Guide section`!, which contains all the information you need to start using Nomad Network. + +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`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/index.md]`!`_ for info. + +If you want to try Nomad Network without building your own physical network, you can connect to the `_`!`[distributed RNS backbone`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=connect-to-the-distributed-backbone]`!`_ 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: + + • `!`_`[9ce92808be498e9e05590ff27cbfdfe4]`_`! The rns.recipes forum + • `!`_`[a4a5e861626ce97c9aa544d9ecdf6d22]`_`! rmap.world + +To browse pages on a node that is not currently known, open the URL dialog in the `!Network`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!Go`! or press enter. Nomadnet will attempt to discover and connect to the requested node. + +>>> Install on Android + +You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the `_`!`[Android Installation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=reticulum-on-android]`!`_ section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. + +For a native Android application with a graphical user interface, have a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_. + +>> Help & Discussion + +For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the `_`!`[rns.recipes forum`9ce92808be498e9e05590ff27cbfdfe4]`!`_. + +>> Support Nomad Network + +For this to be possible, I need your help. Please support the continued development of open, free and private communications systems by donating via one of the following channels: + +• `!Monero`! + 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w + +• `!Bitcoin`! + bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6 + +• `!Ethereum`! + 0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E + +• `!Liberapay`! + `[https://liberapay.com/Reticulum/] + +• `!Ko-Fi`! + `[https://ko-fi.com/markqvist] + +>> Caveat Emptor + +Nomad Network is experimental software, and should be considered as such. Use at your own risk and responsibility. diff --git a/docs/screenshots/1a.png b/docs/screenshots/1a.png new file mode 100644 index 0000000..3df866e Binary files /dev/null and b/docs/screenshots/1a.png differ diff --git a/docs/screenshots/1.png b/docs/screenshots/6.png similarity index 100% rename from docs/screenshots/1.png rename to docs/screenshots/6.png diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 6d66219..86d26bb 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -1,36 +1,49 @@ import os +import re import RNS import LXMF import shutil +import RNS.vendor.umsgpack as msgpack import nomadnet from nomadnet.Directory import DirectoryEntry +from LXMF import display_name_from_app_data class Conversation: cached_conversations = {} unread_conversations = {} + failed_conversations = {} created_callback = None aspect_filter = "lxmf.delivery" + receive_path_responses = True @staticmethod - def received_announce(destination_hash, announced_identity, app_data): + def received_announce(destination_hash, announced_identity, app_data, announce_packet_hash=None, is_path_response=False): app = nomadnet.NomadNetworkApp.get_shared_instance() + if not display_name_from_app_data(app_data): + RNS.log("Ignored invalid lxmf.delivery announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + if not destination_hash in app.ignored_list: destination_hash_text = RNS.hexrep(destination_hash, delimit=False) - # Check if the announced destination is in - # our list of conversations - if destination_hash_text in [e[0] for e in Conversation.conversation_list(app)]: - if app.directory.find(destination_hash): - if Conversation.created_callback != None: - Conversation.created_callback() - else: - if Conversation.created_callback != None: - Conversation.created_callback() + # Notify if the announced destination is one of our + # existing conversations. Checking the directory directly + # avoids scanning and opening every conversation on disk + # for each announce received. + if os.path.isdir(app.conversationpath + "/" + destination_hash_text): + if Conversation.created_callback != None: + Conversation.created_callback() + + if not is_path_response: + # This reformats the new v0.5.0 announce data back to the expected format + # for nomadnets storage and other handling functions. + dn = LXMF.display_name_from_app_data(app_data) + app_data = b"" + if dn != None: app_data = dn.encode("utf-8") + + # Add the announce to the directory announce + # stream logger + app.directory.lxmf_announce_received(destination_hash, app_data) - # Add the announce to the directory announce - # stream logger - app.directory.lxmf_announce_received(destination_hash, app_data) - else: RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) @@ -47,7 +60,7 @@ class Conversation: source_hash = lxmessage.destination_hash else: source_hash = lxmessage.source_hash - + source_hash_path = RNS.hexrep(source_hash, delimit=False) conversation_path = app.conversationpath + "/" + source_hash_path @@ -59,20 +72,64 @@ class Conversation: ingested_path = lxmessage.write_to_directory(conversation_path) + if not originator and ingested_path is not None: + try: + rssi = getattr(lxmessage, "rssi", None) + snr = getattr(lxmessage, "snr", None) + q = getattr(lxmessage, "q", None) + if rssi is not None or snr is not None or q is not None: + index = ConversationMessage.read_index(conversation_path) + filename = os.path.basename(ingested_path) + entry = index.get(filename) or {} + if rssi is not None: entry["rssi"] = rssi + if snr is not None: entry["snr"] = snr + if q is not None: entry["q"] = q + index[filename] = entry + with open(os.path.join(conversation_path, ".index"), "wb") as index_file: + index_file.write(msgpack.packb(index)) + except Exception as e: + RNS.log("Error while storing reception stats for message: "+str(e), RNS.LOG_ERROR) + + try: + ConversationMessage.extract_attachments_from_lxm(lxmessage, app) + except Exception as e: + RNS.log("Error extracting attachments: "+str(e), RNS.LOG_ERROR) + if RNS.hexrep(source_hash, delimit=False) in Conversation.cached_conversations: conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)] conversation.scan_storage() - if not source_hash in Conversation.unread_conversations: - Conversation.unread_conversations[source_hash] = True + lxm_state = getattr(lxmessage, "state", None) + is_failed = lxm_state in (LXMF.LXMessage.FAILED, LXMF.LXMessage.REJECTED) + + if not originator: + if source_hash in Conversation.unread_conversations: + Conversation.unread_conversations[source_hash] += 1 + else: + Conversation.unread_conversations[source_hash] = 1 + try: dirname = RNS.hexrep(source_hash, delimit=False) - open(app.conversationpath + "/" + dirname + "/unread", 'a').close() - except Exception as e: + with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf: + uf.write(str(Conversation.unread_conversations[source_hash])) + except Exception: pass - if Conversation.created_callback != None: - Conversation.created_callback() + elif is_failed: + if source_hash in Conversation.failed_conversations: + Conversation.failed_conversations[source_hash] += 1 + else: + Conversation.failed_conversations[source_hash] = 1 + + try: + dirname = RNS.hexrep(source_hash, delimit=False) + with open(app.conversationpath + "/" + dirname + "/failed", "w") as ff: + ff.write(str(Conversation.failed_conversations[source_hash])) + except Exception: + pass + + if Conversation.created_callback != None: + Conversation.created_callback() return ingested_path @@ -87,30 +144,53 @@ class Conversation: app_data = RNS.Identity.recall_app_data(source_hash) display_name = app.directory.display_name(source_hash) - unread = False + unread = 0 if source_hash in Conversation.unread_conversations: - unread = True + unread = Conversation.unread_conversations[source_hash] elif os.path.isfile(app.conversationpath + "/" + dirname + "/unread"): - Conversation.unread_conversations[source_hash] = True - unread = True + try: + with open(app.conversationpath + "/" + dirname + "/unread", "r") as uf: + content = uf.read().strip() + unread = int(content) if content else 1 + except Exception: + unread = 1 + Conversation.unread_conversations[source_hash] = unread + + failed = 0 + if source_hash in Conversation.failed_conversations: + failed = Conversation.failed_conversations[source_hash] + elif os.path.isfile(app.conversationpath + "/" + dirname + "/failed"): + try: + with open(app.conversationpath + "/" + dirname + "/failed", "r") as ff: + content = ff.read().strip() + failed = int(content) if content else 1 + except Exception: + failed = 1 + Conversation.failed_conversations[source_hash] = failed if display_name == None and app_data: - display_name = app_data.decode("utf-8") + display_name = LXMF.display_name_from_app_data(app_data) if display_name == None: sort_name = "" else: sort_name = display_name - + trust_level = app.directory.trust_level(source_hash, display_name) - - entry = (source_hash_text, display_name, trust_level, sort_name, unread) + + conversation_dir = app.conversationpath + "/" + dirname + try: + last_activity = os.path.getmtime(conversation_dir) + except Exception: + last_activity = 0 + + entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity, failed) conversations.append(entry) except Exception as e: RNS.log("Error while loading conversation "+str(dirname)+", skipping it. The contained exception was: "+str(e), RNS.LOG_ERROR) - conversations.sort(key=lambda e: (-e[2], e[3], e[0]), reverse=False) + conversations.sort(key=lambda e: e[5], reverse=True) return conversations @@ -142,11 +222,8 @@ class Conversation: self.__changed_callback = None - self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) - - if self.source_identity: - self.source_known = True - self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + if not self.ensure_send_destination(): + RNS.Transport.request_path(bytes.fromhex(source_hash)) if initiator: if not os.path.isdir(self.messages_path): @@ -162,14 +239,46 @@ class Conversation: def scan_storage(self): old_len = len(self.messages) + existing = {} + for msg in self.messages: + existing[msg.file_path] = msg + + index = ConversationMessage.read_index(self.messages_path) + self.messages = [] for filename in os.listdir(self.messages_path): if len(filename) == RNS.Identity.HASHLENGTH//8*2: message_path = self.messages_path + "/" + filename - self.messages.append(ConversationMessage(message_path)) + if message_path in existing: + old_msg = existing[message_path] + try: + current_mtime = os.path.getmtime(message_path) + if current_mtime > old_msg.sort_timestamp: + old_msg._cached_state = None + old_msg._cached_method = None + old_msg.sort_timestamp = current_mtime + except Exception: + pass + self.messages.append(old_msg) + else: + msg = ConversationMessage(message_path) + if filename in index: + msg.restore_from_index(index[filename]) + self.messages.append(msg) new_len = len(self.messages) + needs_index_update = [] + for msg in self.messages: + filename = os.path.basename(msg.file_path) + if msg._cached_state is not None: + if filename not in index: + needs_index_update.append(msg) + elif "content" not in index[filename]: + needs_index_update.append(msg) + if needs_index_update: + ConversationMessage.write_index(self.messages_path, needs_index_update) + if new_len > old_len: self.unread = True @@ -198,16 +307,37 @@ class Conversation: def register_changed_callback(self, callback): self.__changed_callback = callback - def send(self, content="", title=""): - if self.send_destination: + def ensure_send_destination(self): + if self.send_destination == None: + self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) + + if self.source_identity: + self.source_known = True + self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + + return self.send_destination != None + + def send(self, content="", title="", fields=None): + if self.ensure_send_destination(): dest = self.send_destination source = self.app.lxmf_destination desired_method = LXMF.LXMessage.DIRECT - if self.app.directory.preferred_delivery(dest.hash) == DirectoryEntry.PROPAGATED: + preferred = self.app.directory.preferred_delivery(dest.hash) + if preferred == DirectoryEntry.PROPAGATED: if self.app.message_router.get_outbound_propagation_node() != None: desired_method = LXMF.LXMessage.PROPAGATED + elif preferred == DirectoryEntry.OPPORTUNISTIC: + desired_method = LXMF.LXMessage.OPPORTUNISTIC + else: + if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None: + RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG) + desired_method = LXMF.LXMessage.OPPORTUNISTIC - lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method) + dest_is_trusted = False + if self.app.directory.trust_level(dest.hash) == DirectoryEntry.TRUSTED: + dest_is_trusted = True + + lxm = LXMF.LXMessage(dest, source, content, title=title, fields=fields, desired_method=desired_method, include_ticket=dest_is_trusted) lxm.register_delivery_callback(self.message_notification) lxm.register_failed_callback(self.message_notification) @@ -220,57 +350,242 @@ class Conversation: self.messages.append(ConversationMessage(message_path)) return True + else: + RNS.log("Destination is not known, cannot create LXMF Message. Requesting path...", RNS.LOG_VERBOSE) + RNS.Transport.request_path(bytes.fromhex(self.source_hash)) + return False + + def paper_output(self, content="", title="", mode="print_qr"): + if self.ensure_send_destination(): + try: + dest = self.send_destination + source = self.app.lxmf_destination + desired_method = LXMF.LXMessage.PAPER + + lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method) + + if mode == "print_qr": + qr_code = lxm.as_qr() + qr_tmp_path = self.app.tmpfilespath+"/"+str(RNS.hexrep(lxm.hash, delimit=False)) + qr_code.save(qr_tmp_path) + + print_result = self.app.print_file(qr_tmp_path) + os.unlink(qr_tmp_path) + + if print_result: + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + + return print_result + + elif mode == "save_qr": + qr_code = lxm.as_qr() + qr_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".png") + qr_code.save(qr_save_path) + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + return qr_save_path + + elif mode == "save_uri": + lxm_uri = lxm.as_uri()+"\n" + uri_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".txt") + with open(uri_save_path, "wb") as f: + f.write(lxm_uri.encode("utf-8")) + + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + return uri_save_path + + elif mode == "return_uri": + return lxm.as_uri() + + except Exception as e: + RNS.log("An error occurred while generating paper message, the contained exception was: "+str(e), RNS.LOG_ERROR) + return False + else: RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) return False def message_notification(self, message): if message.state == LXMF.LXMessage.FAILED and hasattr(message, "try_propagation_on_fail") and message.try_propagation_on_fail: - RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) - message.try_propagation_on_fail = None - message.delivery_attempts = 0 - del message.next_delivery_attempt - message.packed = None - message.desired_method = LXMF.LXMessage.PROPAGATED - self.app.message_router.handle_outbound(message) + if hasattr(message, "stamp_generation_failed") and message.stamp_generation_failed == True: + RNS.log(f"Could not send {message} due to a stamp generation failure", RNS.LOG_ERROR) + else: + RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) + message.try_propagation_on_fail = None + message.delivery_attempts = 0 + if hasattr(message, "next_delivery_attempt"): + del message.next_delivery_attempt + message.packed = None + message.desired_method = LXMF.LXMessage.PROPAGATED + self.app.message_router.handle_outbound(message) else: message_path = Conversation.ingest(message, self.app, originator=True) def __str__(self): string = self.source_hash - if self.source_identity: - if self.source_identity.app_data: - # TODO: Sanitise for viewing - string += " | "+self.source.source_identity.app_data.decode("utf-8") + # TODO: Remove this + # if self.source_identity: + # if self.source_identity.app_data: + # # TODO: Sanitise for viewing, or just clean this + # string += " | "+self.source_identity.app_data.decode("utf-8") return string class ConversationMessage: + # Sentinel marking the renderer as not yet cached, so that a real + # cached value of None (no renderer field) can be told apart from a + # cache miss without forcing a disk load. + _RENDERER_UNSET = object() + def __init__(self, file_path): self.file_path = file_path self.loaded = False self.timestamp = None self.lxm = None + self._cached_renderer = ConversationMessage._RENDERER_UNSET + self._cached_hash = None + self._cached_state = None + self._cached_title = None + self._cached_content = None + self._cached_source_hash = None + self._cached_transport_encrypted = None + self._cached_transport_encryption = None + self._cached_signature_validated = None + self._cached_unverified_reason = None + self._cached_method = None + self._cached_has_attachments = None + self._cached_attachment_names = None + self._cached_rssi = None + self._cached_snr = None + self._cached_q = None + + self.sort_timestamp = os.path.getmtime(file_path) if os.path.isfile(file_path) else 0 + + filename = os.path.basename(file_path) + if len(filename) == RNS.Identity.HASHLENGTH//8*2: + try: + self._cached_hash = bytes.fromhex(filename) + except Exception: + pass + def load(self): try: - self.lxm = LXMF.LXMessage.unpack_from_file(open(self.file_path, "rb")) + with open(self.file_path, "rb") as lxm_file: + self.lxm = LXMF.LXMessage.unpack_from_file(lxm_file) self.loaded = True self.timestamp = self.lxm.timestamp self.sort_timestamp = os.path.getmtime(self.file_path) - if self.lxm.state > LXMF.LXMessage.DRAFT and self.lxm.state < LXMF.LXMessage.SENT: + + if self.lxm.state > LXMF.LXMessage.GENERATING and self.lxm.state < LXMF.LXMessage.SENT: found = False + for pending in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_outbound: if pending.hash == self.lxm.hash: found = True + for pending_id in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_deferred_stamps: + if pending_id == self.lxm.hash: + found = True + if not found: self.lxm.state = LXMF.LXMessage.FAILED + if self._cached_hash is None: + self._cached_hash = self.lxm.hash + self._cached_state = self.lxm.state + if self._cached_source_hash is None: + self._cached_source_hash = self.lxm.source_hash + self._cached_transport_encrypted = self.lxm.transport_encrypted + self._cached_transport_encryption = self.lxm.transport_encryption + if self._cached_signature_validated is None: + self._cached_signature_validated = self.lxm.signature_validated + self._cached_method = self.lxm.method + if self._cached_rssi is None: + self._cached_rssi = getattr(self.lxm, "rssi", None) + if self._cached_snr is None: + self._cached_snr = getattr(self.lxm, "snr", None) + if self._cached_q is None: + self._cached_q = getattr(self.lxm, "q", None) + if hasattr(self.lxm, "get_fields"): + _fields = self.lxm.get_fields() + if _fields and isinstance(_fields, dict) and LXMF.FIELD_RENDERER in _fields: + self._cached_renderer = _fields[LXMF.FIELD_RENDERER] + else: + self._cached_renderer = None + else: + self._cached_renderer = None + if self._cached_unverified_reason is None and hasattr(self.lxm, "unverified_reason"): + self._cached_unverified_reason = self.lxm.unverified_reason + self._cached_title = self.lxm.title_as_string() + self._cached_content = self.lxm.content_as_string() + + if self._cached_has_attachments is None: + found_in_fields = False + fields = None + if hasattr(self.lxm, "get_fields"): + fields = self.lxm.get_fields() + if fields and isinstance(fields, dict): + found_in_fields = ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + if found_in_fields: + names = [] + file_atts = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + for idx, att in enumerate(file_atts): + if isinstance(att, list) and len(att) >= 2: + size = len(att[1]) if isinstance(att[1], bytes) else 0 + safe = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx)) + names.append(("file", safe, size)) + if LXMF.FIELD_IMAGE in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) + if data: + size = len(data) + ext = ConversationMessage._ext_from_media_format(fmt, data) + safe = ConversationMessage.safe_attachment_name("image"+ext, fallback="image") + names.append(("file", safe, size)) + if LXMF.FIELD_AUDIO in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) + if data: + size = len(data) + ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) + safe = ConversationMessage.safe_attachment_name("audio"+ext, fallback="audio") + names.append(("file", safe, size)) + self._cached_has_attachments = True + self._cached_attachment_names = names + + if not found_in_fields: + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest and len(manifest["files"]) > 0: + names = [] + for entry in manifest["files"]: + names.append(("file", entry["name"], entry.get("size", 0))) + self._cached_has_attachments = True + self._cached_attachment_names = names + else: + self._cached_has_attachments = False + self._cached_attachment_names = [] + else: + self._cached_has_attachments = False + self._cached_attachment_names = [] + + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() + ConversationMessage.extract_attachments_from_lxm(self.lxm, app) + ConversationMessage.strip_attachments_from_file(self.file_path, app) + except Exception: + pass + except Exception as e: RNS.log("Error while loading LXMF message "+str(self.file_path)+" from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -284,60 +599,470 @@ class ConversationMessage: os.unlink(self.file_path) def get_timestamp(self): + if self.timestamp is not None: + return self.timestamp if not self.loaded: self.load() - return self.timestamp def get_title(self): + if self._cached_title is not None: + return self._cached_title if not self.loaded: self.load() - - return self.lxm.title_as_string() + return self._cached_title if self._cached_title is not None else "" def get_content(self): + if self._cached_content is not None: + return self._cached_content if not self.loaded: self.load() - - return self.lxm.content_as_string() + return self._cached_content if self._cached_content is not None else "" def get_hash(self): + if self._cached_hash is not None: + return self._cached_hash if not self.loaded: self.load() - - return self.lxm.hash + return self._cached_hash def get_state(self): + if self._cached_state is not None: + return self._cached_state if not self.loaded: self.load() + return self._cached_state - return self.lxm.state + def get_method(self): + if self._cached_method is not None: + return self._cached_method + if not self.loaded: + self.load() + return self._cached_method + + def get_rssi(self): + return self._cached_rssi + + def get_snr(self): + return self._cached_snr + + def get_q(self): + return self._cached_q def get_transport_encryption(self): + if self._cached_transport_encryption is not None: + return self._cached_transport_encryption if not self.loaded: self.load() - - return self.lxm.transport_encryption + return self._cached_transport_encryption def get_transport_encrypted(self): + if self._cached_transport_encrypted is not None: + return self._cached_transport_encrypted if not self.loaded: self.load() - - return self.lxm.transport_encrypted + return self._cached_transport_encrypted def signature_validated(self): + if self._cached_signature_validated is not None: + return self._cached_signature_validated if not self.loaded: self.load() - - return self.lxm.signature_validated + return self._cached_signature_validated def get_signature_description(self): if self.signature_validated(): return "Signature Verified" else: - if self.lxm.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN: + reason = self._cached_unverified_reason + if reason == LXMF.LXMessage.SOURCE_UNKNOWN: return "Unknown Origin" - elif self.lxm.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID: + elif reason == LXMF.LXMessage.SIGNATURE_INVALID: return "Invalid Signature" else: - return "Unknown signature validation failure" \ No newline at end of file + return "Unknown signature validation failure" + + def get_fields(self): + if not self.loaded: + self.load() + + if self.lxm and hasattr(self.lxm, "get_fields"): + fields = self.lxm.get_fields() + if fields and isinstance(fields, dict): + return fields + + return {} + + def content_renderer(self): + if self._cached_renderer is not ConversationMessage._RENDERER_UNSET: + return self._cached_renderer + if not self.loaded: + self.load() + # load() sets _cached_renderer on success; if it failed, lxm is None + if self._cached_renderer is ConversationMessage._RENDERER_UNSET: + return None + return self._cached_renderer + + def has_attachments(self): + if self._cached_has_attachments is not None: + return self._cached_has_attachments + fields = self.get_fields() + return ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + + def get_file_attachments(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest: + result = [] + for entry in manifest["files"]: + fpath = os.path.join(att_dir, entry["stored_name"]) + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + result.append([entry["name"], f.read()]) + return result + + fields = self.get_fields() + return fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + + def get_image(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + fpath = os.path.join(att_dir, "image") + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + return f.read() + + fields = self.get_fields() + return fields.get(LXMF.FIELD_IMAGE, None) + + def get_audio(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + fpath = os.path.join(att_dir, "audio") + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + return f.read() + + fields = self.get_fields() + return fields.get(LXMF.FIELD_AUDIO, None) + + def get_attachment_file_path(self, field_type, field_index=0): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest and field_index < len(manifest["files"]): + return os.path.join(att_dir, manifest["files"][field_index]["stored_name"]) + # Fallback for old extraction format + if field_type == "image": + fpath = os.path.join(att_dir, "image") + if os.path.isfile(fpath): + return fpath + elif field_type == "audio": + fpath = os.path.join(att_dir, "audio") + if os.path.isfile(fpath): + return fpath + return None + + def _attachment_dir(self): + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() + msg_hash = self.get_hash() + if msg_hash: + return os.path.join(app.attachmentpath, RNS.hexrep(msg_hash, delimit=False)) + except Exception: + pass + return None + + def _read_attachment_manifest(self, att_dir): + manifest_path = os.path.join(att_dir, "manifest") + if os.path.isfile(manifest_path): + try: + with open(manifest_path, "rb") as f: + manifest = msgpack.unpackb(f.read(), raw=False) + safe_files = [] + for idx, entry in enumerate(manifest.get("files", [])): + if not isinstance(entry, dict): + continue + entry["name"] = ConversationMessage.safe_attachment_name(entry.get("name"), fallback="attachment_"+str(idx)) + stored = entry.get("stored_name") + if not isinstance(stored, str) or not re.fullmatch(r"file_\d+", stored): + continue + safe_files.append(entry) + manifest["files"] = safe_files + return manifest + + except Exception as e: + RNS.log(f"Error loading attachment manifest: {e}") + return None + + return None + + @staticmethod + def extract_attachments_from_lxm(lxmessage, app): + if not hasattr(lxmessage, "get_fields"): + return + fields = lxmessage.get_fields() + if not fields or not isinstance(fields, dict): + return + + has_any = ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + if not has_any: + return + + msg_hash_hex = RNS.hexrep(lxmessage.hash, delimit=False) + att_dir = os.path.join(app.attachmentpath, msg_hash_hex) + if os.path.isdir(att_dir): + return + + os.makedirs(att_dir) + manifest = {"files": []} + + file_attachments = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + if file_attachments: + for idx, att in enumerate(file_attachments): + try: + if isinstance(att, list) and len(att) >= 2: + filename = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx)) + data = att[1] if isinstance(att[1], bytes) else b"" + stored_name = "file_"+str(idx) + with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + except Exception as e: + RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR) + continue + + if LXMF.FIELD_IMAGE in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) + if data: + ext = ConversationMessage._ext_from_media_format(fmt, data) + filename = ConversationMessage.safe_attachment_name("image" + ext, fallback="image") + stored_name = "file_"+str(len(manifest["files"])) + with open(os.path.join(att_dir, stored_name), "wb") as f: + f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + if LXMF.FIELD_AUDIO in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) + if data: + ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) + filename = ConversationMessage.safe_attachment_name("audio" + ext, fallback="audio") + stored_name = "file_"+str(len(manifest["files"])) + with open(os.path.join(att_dir, stored_name), "wb") as f: + f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + with open(os.path.join(att_dir, "manifest"), "wb") as f: + f.write(msgpack.packb(manifest)) + + @staticmethod + def safe_attachment_name(name, fallback="attachment"): + try: + if isinstance(name, bytes): + name = name.decode("utf-8", errors="replace") + elif not isinstance(name, str): + name = str(name) if name is not None else "" + except Exception: + name = "" + name = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", name) + parts = re.split(r"[/\\]", name) + name = parts[-1] if parts else "" + if ":" in name: + name = name.split(":")[-1] + name = name.lstrip(".") + if not name or name in (".", ".."): + return fallback + if len(name) > 200: + base, ext = os.path.splitext(name) + ext = ext[:16] + name = base[:200 - len(ext)] + ext + return name + + @staticmethod + def _unpack_media_field(field_data): + """Normalize FIELD_IMAGE/FIELD_AUDIO which can be raw bytes or [format, bytes]. + Format element can be a string ('webp') or integer (LXMF audio mode constant).""" + if isinstance(field_data, bytes): + return None, field_data + elif isinstance(field_data, list) and len(field_data) >= 2 and isinstance(field_data[1], bytes): + return field_data[0], field_data[1] + return None, None + + @staticmethod + def _detect_image_ext(data): + if not isinstance(data, bytes) or len(data) < 12: + return ".bin" + if data[:8] == b'\x89PNG\r\n\x1a\n': + return ".png" + elif data[:3] == b'\xff\xd8\xff': + return ".jpg" + elif data[:4] == b'GIF8': + return ".gif" + elif data[:4] == b'RIFF' and data[8:12] == b'WEBP': + return ".webp" + elif data[:4] == b'\x00\x00\x00\x1c' or data[:4] == b'\x00\x00\x00\x18': + return ".heic" + return ".bin" + + @staticmethod + def _detect_audio_ext(data): + if not isinstance(data, bytes) or len(data) < 12: + return ".bin" + if data[:4] == b'OggS': + return ".ogg" + elif data[:2] == b'\xff\xfb' or data[:3] == b'ID3': + return ".mp3" + elif data[:4] == b'RIFF' and data[8:12] == b'WAVE': + return ".wav" + elif data[:4] == b'fLaC': + return ".flac" + return ".bin" + + @staticmethod + def _ext_from_media_format(fmt, data, is_audio=False): + if isinstance(fmt, str) and len(fmt) > 0: + safe = re.sub(r"[^A-Za-z0-9]", "", fmt).lower()[:8] + if safe: + return "." + safe + if isinstance(fmt, int) and is_audio: + if fmt >= 16 and fmt <= 25: + return ".ogg" + elif fmt >= 1 and fmt <= 9: + return ".c2" + if is_audio: + return ConversationMessage._detect_audio_ext(data) + return ConversationMessage._detect_image_ext(data) + + @staticmethod + def strip_attachments_from_file(file_path, app): + try: + with open(file_path, "rb") as f: + container = msgpack.unpackb(f.read(), strict_map_key=False) + + lxmf_bytes = container[b"lxmf_bytes"] if b"lxmf_bytes" in container else container.get("lxmf_bytes") + if lxmf_bytes is None: + return + + dest_len = LXMF.LXMessage.DESTINATION_LENGTH + sig_len = LXMF.LXMessage.SIGNATURE_LENGTH + header_len = 2 * dest_len + sig_len + + header = lxmf_bytes[:header_len] + payload_bytes = lxmf_bytes[header_len:] + payload = msgpack.unpackb(payload_bytes, strict_map_key=False) + + if len(payload) < 4 or not isinstance(payload[3], dict): + return + + fields = payload[3] + attachment_keys = [LXMF.FIELD_FILE_ATTACHMENTS, LXMF.FIELD_IMAGE, LXMF.FIELD_AUDIO] + if not any(k in fields for k in attachment_keys): + return + + # Compute message hash matching LXMF's logic + if len(payload) > 4: + hash_payload = msgpack.packb(payload[:4]) + else: + hash_payload = payload_bytes + msg_hash = RNS.Identity.full_hash(lxmf_bytes[:2*dest_len] + hash_payload) + msg_hash_hex = RNS.hexrep(msg_hash, delimit=False) + + att_dir = os.path.join(app.attachmentpath, msg_hash_hex) + if not os.path.isdir(att_dir): + return + + for k in attachment_keys: + if k in fields: + del fields[k] + + new_lxmf_bytes = header + msgpack.packb(payload) + key = b"lxmf_bytes" if b"lxmf_bytes" in container else "lxmf_bytes" + container[key] = new_lxmf_bytes + + with open(file_path, "wb") as f: + f.write(msgpack.packb(container)) + + except Exception as e: + RNS.log("Error stripping attachments from LXM file: "+str(e), RNS.LOG_ERROR) + + def to_index_entry(self): + return { + "timestamp": self.timestamp, + "sort_timestamp": self.sort_timestamp, + "state": self._cached_state, + "title": self._cached_title, + "content": self._cached_content, + "source_hash": self._cached_source_hash, + "transport_encrypted": self._cached_transport_encrypted, + "transport_encryption": self._cached_transport_encryption, + "signature_validated": self._cached_signature_validated, + "unverified_reason": self._cached_unverified_reason, + "method": self._cached_method, + "renderer": None if self._cached_renderer is ConversationMessage._RENDERER_UNSET else self._cached_renderer, + "has_attachments": self._cached_has_attachments, + "attachment_names": self._cached_attachment_names, + "rssi": self._cached_rssi, + "snr": self._cached_snr, + "q": self._cached_q, + } + + def restore_from_index(self, entry): + self.timestamp = entry.get("timestamp") + self.sort_timestamp = entry.get("sort_timestamp", self.sort_timestamp) + self._cached_state = entry.get("state") + self._cached_title = entry.get("title") + self._cached_content = entry.get("content") + self._cached_source_hash = entry.get("source_hash") + self._cached_transport_encrypted = entry.get("transport_encrypted") + self._cached_transport_encryption = entry.get("transport_encryption") + self._cached_signature_validated = entry.get("signature_validated") + self._cached_unverified_reason = entry.get("unverified_reason") + self._cached_method = entry.get("method") + self._cached_renderer = entry.get("renderer", ConversationMessage._RENDERER_UNSET) + self._cached_has_attachments = entry.get("has_attachments") + self._cached_attachment_names = entry.get("attachment_names") + self._cached_rssi = entry.get("rssi") + self._cached_snr = entry.get("snr") + self._cached_q = entry.get("q") + + @staticmethod + def read_index(conversation_path): + index_path = os.path.join(conversation_path, ".index") + if os.path.isfile(index_path): + try: + with open(index_path, "rb") as f: + return msgpack.unpackb(f.read(), raw=False) + except Exception: + pass + return {} + + @staticmethod + def write_index(conversation_path, messages): + index_path = os.path.join(conversation_path, ".index") + index = {} + if os.path.isfile(index_path): + try: + with open(index_path, "rb") as f: + index = msgpack.unpackb(f.read(), raw=False) + except Exception: + index = {} + + for msg in messages: + if msg._cached_state is not None: + filename = os.path.basename(msg.file_path) + index[filename] = msg.to_index_entry() + + try: + with open(index_path, "wb") as f: + f.write(msgpack.packb(index)) + except Exception as e: + RNS.log("Error writing conversation index: "+str(e), RNS.LOG_ERROR) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index 9d3029b..8727b7d 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -3,63 +3,122 @@ import RNS import LXMF import time import nomadnet +import threading import RNS.vendor.umsgpack as msgpack +from LXMF import pn_announce_data_is_valid, PN_META_IMPL_NAME, PN_META_VERSION +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name + +class PNAnnounceHandler: + def __init__(self, owner): + self.aspect_filter = "lxmf.propagation" + self.owner = owner + + def received_announce(self, destination_hash, announced_identity, app_data): + try: + if pn_announce_data_is_valid(app_data): + data = msgpack.unpackb(app_data) + + if data[2] == True: + metadata = data[6] + if not PN_META_IMPL_NAME in metadata or not PN_META_VERSION in metadata: impl_str = "" + else: impl_str = f" running {metadata[PN_META_IMPL_NAME]} {metadata[PN_META_VERSION]}" + RNS.log(f"Received active propagation node announce from {RNS.prettyhexrep(destination_hash)}{impl_str}", RNS.LOG_DEBUG) + + associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) + associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity) + + self.owner.app.directory.pn_announce_received(destination_hash, app_data, associated_peer, associated_node) + self.owner.app.autoselect_propagation_node() + + except Exception as e: + RNS.log("Error while evaluating propagation node announce, ignoring announce.", RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + class Directory: - ANNOUNCE_STREAM_MAXLENGTH = 64 + ANNOUNCE_STREAM_MAXLENGTH = 256 aspect_filter = "nomadnetwork.node" @staticmethod def received_announce(destination_hash, announced_identity, app_data): - app = nomadnet.NomadNetworkApp.get_shared_instance() + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() - if not destination_hash in app.ignored_list: - destination_hash_text = RNS.hexrep(destination_hash, delimit=False) + if not destination_hash in app.ignored_list: + 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) + app.directory.node_announce_received(destination_hash, app_data, associated_peer) + app.autoselect_propagation_node() + + else: + RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) - app.directory.node_announce_received(destination_hash, app_data, associated_peer) - app.autoselect_propagation_node() - - else: - RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + except Exception as e: + RNS.log("Error while evaluating LXMF destination announce, ignoring announce.", RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + @property + def announce_stream(self): + return self._node_announces+self._peer_announces+self._pn_announces + + def _clean_node_announces(self): + while len(self._node_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._node_announces.pop() + + def _clean_peer_announces(self): + while len(self._peer_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._peer_announces.pop() + + def _clean_pn_announces(self): + while len(self._pn_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._pn_announces.pop() + def __init__(self, app): self.directory_entries = {} - self.announce_stream = [] + self._node_announces = [] + self._peer_announces = [] + self._pn_announces = [] self.app = app + self.announce_lock = threading.Lock() self.load_from_disk() + self.pn_announce_handler = PNAnnounceHandler(self) + RNS.Transport.register_announce_handler(self.pn_announce_handler) + def save_to_disk(self): try: packed_list = [] for source_hash in self.directory_entries: e = self.directory_entries[source_hash] - packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify)) + packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank, getattr(e, "notes", ""))) directory = { "entry_list": packed_list, "announce_stream": self.announce_stream } - file = open(self.app.directorypath, "wb") - file.write(msgpack.packb(directory)) - file.close() + with open(self.app.directorypath, "wb") as file: + file.write(msgpack.packb(directory)) + except Exception as e: RNS.log("Could not write directory to disk. Then contained exception was: "+str(e), RNS.LOG_ERROR) def load_from_disk(self): if os.path.isfile(self.app.directorypath): try: - file = open(self.app.directorypath, "rb") - unpacked_directory = msgpack.unpackb(file.read()) + with open(self.app.directorypath, "rb") as file: + unpacked_directory = msgpack.unpackb(file.read()) unpacked_list = unpacked_directory["entry_list"] - file.close() entries = {} for e in unpacked_list: + + if e[1] == None: + e[1] = "Undefined" + if len(e) > 3: hosts_node = e[3] else: @@ -75,70 +134,174 @@ class Directory: else: identify = False - entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify) + if len(e) > 6: + sort_rank = e[6] + else: + sort_rank = None + + if len(e) > 7: + notes = e[7] + else: + notes = None + + entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank, notes=notes) self.directory_entries = entries - self.announce_stream = unpacked_directory["announce_stream"] - + astream = unpacked_directory["announce_stream"] + self._node_announces = [e for e in astream if e[3] == "node"] + self._peer_announces = [e for e in astream if e[3] == "peer"] + self._pn_announces = [e for e in astream if e[3] == "pn"] except Exception as e: RNS.log("Could not load directory from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) def lxmf_announce_received(self, source_hash, app_data): - if app_data != None: - timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, False)) - while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: - self.announce_stream.pop() + with self.announce_lock: + if app_data != None: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self._peer_announces: + if announce[1] == source_hash: remove_announces.append(announce) - if hasattr(self.app.ui, "main_display"): - self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + for a in remove_announces: self._peer_announces.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self._peer_announces.insert(0, (timestamp, source_hash, app_data, "peer")) + self._clean_peer_announces() + + if hasattr(self.app, "ui") and self.app.ui != None: + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() def node_announce_received(self, source_hash, app_data, associated_peer): - if app_data != None: - timestamp = time.time() - self.announce_stream.insert(0, (timestamp, source_hash, app_data, True)) - while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: - self.announce_stream.pop() + with self.announce_lock: + if app_data != None: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self._node_announces: + if announce[1] == source_hash: + remove_announces.append(announce) - if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: - existing_entry = self.find(source_hash) - if not existing_entry: - node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) - self.remember(node_entry) - - if hasattr(self.app.ui, "main_display"): - self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + for a in remove_announces: + self._node_announces.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self._node_announces.insert(0, (timestamp, source_hash, app_data, "node")) + self._clean_node_announces() + + # if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: + # existing_entry = self.find(source_hash) + # if not existing_entry: + # node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) + # self.remember(node_entry) + + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + def pn_announce_received(self, source_hash, app_data, associated_peer, associated_node): + with self.announce_lock: + found_node = None + for sh in self.directory_entries: + if sh == associated_node: + found_node = True + break + + for e in self._pn_announces: + if e[1] == associated_node: + found_node = True + break + + # TODO: Remove debug and rethink this (needs way to set PN when node is saved) + if True or not found_node: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self._pn_announces: + if announce[1] == source_hash: + remove_announces.append(announce) + + for a in remove_announces: + self._pn_announces.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self._pn_announces.insert(0, (timestamp, source_hash, app_data, "pn")) + self._clean_pn_announces() + + if hasattr(self.app, "ui") and hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() def remove_announce_with_timestamp(self, timestamp): selected_announce = None - for announce in self.announce_stream: + for announce in self._node_announces: if announce[0] == timestamp: selected_announce = announce + break if selected_announce != None: - self.announce_stream.remove(selected_announce) + self._node_announces.remove(selected_announce) + return + + for announce in self._peer_announces: + if announce[0] == timestamp: + selected_announce = announce + break + + if selected_announce != None: + self._peer_announces.remove(selected_announce) + return + + for announce in self._pn_announces: + if announce[0] == timestamp: + selected_announce = announce + break + + if selected_announce != None: + self._pn_announces.remove(selected_announce) + return def display_name(self, source_hash): if source_hash in self.directory_entries: - return self.directory_entries[source_hash].display_name + return strip_modifiers(self.directory_entries[source_hash].display_name) else: return None - def simplest_display_str(self, source_hash): + def simplest_display_str(self, source_hash, san=True): + def s(name): + if self.app.config["textui"]["sanitize_names"] and san: return sanitize_name(name) + else: return strip_modifiers(name) + trust_level = self.trust_level(source_hash) if trust_level == DirectoryEntry.WARNING or trust_level == DirectoryEntry.UNTRUSTED: - return "<"+RNS.hexrep(source_hash, delimit=False)+">" + if source_hash in self.directory_entries: + dn = s(self.directory_entries[source_hash].display_name) + if not dn: return RNS.prettyhexrep(source_hash) + else: return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">" + + else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" + else: if source_hash in self.directory_entries: - return self.directory_entries[source_hash].display_name - else: - return "<"+RNS.hexrep(source_hash, delimit=False)+">" + dn = s(self.directory_entries[source_hash].display_name) + if not dn: return RNS.prettyhexrep(source_hash) + else: return dn + + else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" def alleged_display_str(self, source_hash): if source_hash in self.directory_entries: - return self.directory_entries[source_hash].display_name + return strip_modifiers(self.directory_entries[source_hash].display_name) else: return None @@ -148,16 +311,29 @@ class Directory: if announced_display_name == None: return self.directory_entries[source_hash].trust_level else: - for entry in self.directory_entries: - e = self.directory_entries[entry] - if e.display_name == announced_display_name: - if e.source_hash != source_hash: - return DirectoryEntry.WARNING + if not self.directory_entries[source_hash].trust_level == DirectoryEntry.TRUSTED: + for entry in self.directory_entries: + e = self.directory_entries[entry] + if e.display_name == announced_display_name: + if e.source_hash != source_hash: + return DirectoryEntry.WARNING return self.directory_entries[source_hash].trust_level else: return DirectoryEntry.UNKNOWN + def pn_trust_level(self, source_hash): + recalled_identity = RNS.Identity.recall(source_hash) + if recalled_identity != None: + associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", recalled_identity) + return self.trust_level(associated_node) + + def sort_rank(self, source_hash): + if source_hash in self.directory_entries: + return self.directory_entries[source_hash].sort_rank + else: + return None + def preferred_delivery(self, source_hash): if source_hash in self.directory_entries: return self.directory_entries[source_hash].preferred_delivery @@ -174,6 +350,8 @@ class Directory: node_entry = self.directory_entries[associated_node] node_entry.trust_level = entry.trust_level + self.save_to_disk() + def forget(self, source_hash): if source_hash in self.directory_entries: self.directory_entries.pop(source_hash) @@ -215,6 +393,7 @@ class Directory: if e.hosts_node: node_list.append(e) + node_list.sort(key = lambda e: (e.sort_rank if e.sort_rank != None else 2^32, DirectoryEntry.TRUSTED-e.trust_level, e.display_name if e.display_name != None else "_")) return node_list def number_of_known_nodes(self): @@ -236,15 +415,15 @@ class DirectoryEntry: UNKNOWN = 0x02 TRUSTED = 0xFF - DIRECT = 0x01 - PROPAGATED = 0x02 + DIRECT = 0x01 + PROPAGATED = 0x02 + OPPORTUNISTIC = 0x03 - def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False): + 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: self.source_hash = source_hash self.display_name = display_name - if display_name == None: - display_name = source_hash + self.sort_rank = sort_rank if preferred_delivery == None: self.preferred_delivery = DirectoryEntry.DIRECT @@ -254,5 +433,6 @@ class DirectoryEntry: self.trust_level = trust_level self.hosts_node = hosts_node self.identify = identify_on_connect + self.notes = notes or "" else: - raise TypeError("Attempt to add invalid source hash to directory") \ No newline at end of file + raise TypeError("Attempt to add invalid source hash to directory") diff --git a/nomadnet/InterfaceProfiles.py b/nomadnet/InterfaceProfiles.py new file mode 100644 index 0000000..254380b --- /dev/null +++ b/nomadnet/InterfaceProfiles.py @@ -0,0 +1,240 @@ +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() diff --git a/nomadnet/Node.py b/nomadnet/Node.py index 018d43b..31d4a26 100644 --- a/nomadnet/Node.py +++ b/nomadnet/Node.py @@ -1,12 +1,19 @@ import os +import sys + import RNS import time import threading +import hashlib +import shutil import subprocess import RNS.vendor.umsgpack as msgpack +from RNS.Utilities.rngit.media import convert_file_to_webp + class Node: JOB_INTERVAL = 5 + SETTINGS_FLUSH_INTERVAL = 60 START_ANNOUNCE_DELAY = 6 def __init__(self, app): @@ -15,14 +22,22 @@ class Node: self.identity = self.app.identity self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, "nomadnetwork", "node") self.last_announce = time.time() + self.last_file_refresh = time.time() + self.last_page_refresh = time.time() self.announce_interval = self.app.node_announce_interval + self.page_refresh_interval = self.app.page_refresh_interval + self.file_refresh_interval = self.app.file_refresh_interval self.job_interval = Node.JOB_INTERVAL self.should_run_jobs = True + self.settings_dirty = False + self.conversion_cache_lock = threading.Lock() + self.last_settings_flush = time.time() self.app_data = None self.name = self.app.node_name self.register_pages() self.register_files() + self.register_media() self.destination.set_link_established_callback(self.peer_connected) @@ -46,6 +61,8 @@ class Node: def register_pages(self): + # TODO: Deregister previously registered pages + # that no longer exist. self.servedpages = [] self.scan_pages(self.app.pagespath) @@ -53,18 +70,22 @@ class Node: self.destination.register_request_handler( "/page/index.mu", response_generator = self.serve_default_index, - allow = RNS.Destination.ALLOW_ALL - ) + allow = RNS.Destination.ALLOW_ALL) for page in self.servedpages: request_path = "/page"+page.replace(self.app.pagespath, "") self.destination.register_request_handler( request_path, 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): + # TODO: Deregister previously registered files + # that no longer exist. self.servedfiles = [] self.scan_files(self.app.filespath) @@ -73,8 +94,8 @@ class Node: self.destination.register_request_handler( request_path, response_generator = self.serve_file, - allow = RNS.Destination.ALLOW_ALL - ) + allow = RNS.Destination.ALLOW_ALL, + auto_compress = 32_000_000) def scan_pages(self, base_path): files = [file for file in os.listdir(base_path) if os.path.isfile(os.path.join(base_path, file)) and file[:1] != "."] @@ -92,72 +113,46 @@ class Node: directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."] for file in files: - self.servedfiles.append(base_path+"/"+file) + if not file.endswith(".allowed"): + self.servedfiles.append(base_path+"/"+file) for directory in directories: self.scan_files(base_path+"/"+directory) - def serve_page(self, path, data, request_id, remote_identity, requested_at): + def serve_page(self, path, data, request_id, link_id, remote_identity, requested_at): RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) try: self.app.peer_settings["served_page_requests"] += 1 - self.app.save_peer_settings() + self.settings_dirty = True except Exception as e: RNS.log("Could not increase served page request count", RNS.LOG_ERROR) file_path = path.replace("/page", self.app.pagespath, 1) - - allowed_path = file_path+".allowed" - request_allowed = False - - if os.path.isfile(allowed_path): - allowed_list = [] - - try: - if os.access(allowed_path, os.X_OK): - allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE) - allowed_input = allowed_result.stdout - - else: - fh = open(allowed_path, "rb") - allowed_input = fh.read() - fh.close() - - allowed_hash_strs = allowed_input.splitlines() - - for hash_str in allowed_hash_strs: - if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: - try: - allowed_hash = bytes.fromhex(hash_str.decode("utf-8")) - allowed_list.append(allowed_hash) - - except Exception as e: - RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG) - RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) - - except Exception as e: - RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR) - - if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list: - request_allowed = True - else: - request_allowed = False - RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE) - - else: - request_allowed = True + request_allowed = self.request_allowed(file_path, remote_identity) try: if request_allowed: RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) - if os.access(file_path, os.X_OK): - generated = subprocess.run([file_path], stdout=subprocess.PIPE) + if not RNS.vendor.platformutils.is_windows() and os.access(file_path, os.X_OK): + env_map = {} + if "PATH" in os.environ: + env_map["PATH"] = os.environ["PATH"] + if link_id != None: + env_map["link_id"] = RNS.hexrep(link_id, delimit=False) + if remote_identity != None: + env_map["remote_identity"] = RNS.hexrep(remote_identity.hash, delimit=False) + + if data != None and isinstance(data, dict): + for e in data: + if isinstance(e, str) and (e.startswith("field_") or e.startswith("var_")): + env_map[e] = data[e] + + generated = subprocess.run([file_path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env_map) return generated.stdout else: - fh = open(file_path, "rb") - response_data = fh.read() - fh.close() + with open(file_path, "rb") as fh: + response_data = fh.read() return response_data else: RNS.log("Request denied", RNS.LOG_VERBOSE) @@ -168,24 +163,174 @@ class Node: RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) return None - # TODO: Improve file handling, this will be slow for large files + NATIVE_MEDIA_EXTS = [".webp"] + MEDIA_EXTS = [".webp", ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] + CONVERSION_QUALITY = 85 + CONVERSION_MAX_DIMENSION = 1200 + CONVERSION_CACHE_MAX_FILES = 256 + CONVERSION_CACHE_MAX_BYTES = 192*1024*1024 + def serve_media(self, path, data, request_id, link_id, remote_identity, requested_at): + if not type(data) == dict: return None + if not "path" in data: return None + 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): RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) try: self.app.peer_settings["served_file_requests"] += 1 - self.app.save_peer_settings() + self.settings_dirty = True except Exception as e: RNS.log("Could not increase served file request count", RNS.LOG_ERROR) file_path = path.replace("/file", self.app.filespath, 1) - file_name = path.replace("/file/", "", 1) + file_name = os.path.basename(file_path) + if not self.request_allowed(file_path, remote_identity): + RNS.log("Request denied", RNS.LOG_VERBOSE) + return DEFAULT_NOTALLOWED.encode("utf-8") + try: RNS.log("Serving file: "+file_path, RNS.LOG_VERBOSE) - fh = open(file_path, "rb") - file_data = fh.read() - fh.close() - return [file_name, file_data] + return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}] except Exception as e: RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_ERROR) @@ -201,7 +346,7 @@ class Node: self.last_announce = time.time() self.app.peer_settings["node_last_announce"] = self.last_announce self.destination.announce(app_data=self.app_data) - self.app.message_router.announce_propagation_node() + if not self.app.disable_propagation: self.app.message_router.announce_propagation_node() def __jobs(self): while self.should_run_jobs: @@ -209,6 +354,22 @@ class Node: if now > self.last_announce + self.announce_interval*60: self.announce() + + if self.page_refresh_interval > 0: + if now > self.last_page_refresh + self.page_refresh_interval*60: + self.register_pages() + self.last_page_refresh = time.time() + + if self.file_refresh_interval > 0: + if now > self.last_file_refresh + self.file_refresh_interval*60: + self.register_files() + self.last_file_refresh = time.time() + + if self.settings_dirty and now > self.last_settings_flush + Node.SETTINGS_FLUSH_INTERVAL: + self.settings_dirty = False + self.last_settings_flush = now + try: self.app.save_peer_settings() + except Exception as e: RNS.log("Could not save peer settings: "+str(e), RNS.LOG_ERROR) time.sleep(self.job_interval) @@ -216,7 +377,7 @@ class Node: RNS.log("Peer connected to "+str(self.destination), RNS.LOG_VERBOSE) try: self.app.peer_settings["node_connects"] += 1 - self.app.save_peer_settings() + self.settings_dirty = True except Exception as e: RNS.log("Could not increase node connection count", RNS.LOG_ERROR) @@ -237,4 +398,4 @@ If you are the node operator, you can define your own home page by creating a fi DEFAULT_NOTALLOWED = '''>Request Not Allowed You are not authorised to carry out the request. -''' \ No newline at end of file +''' diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 5b24995..b32a4e2 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -2,36 +2,69 @@ import os import io import sys import time +import shlex import atexit import threading import traceback +import subprocess import contextlib +import tempfile import RNS import LXMF import nomadnet from nomadnet.Directory import DirectoryEntry +from datetime import datetime import RNS.vendor.umsgpack as msgpack from ._version import __version__ -from .vendor.configobj import ConfigObj +from RNS.vendor.configobj import ConfigObj class NomadNetworkApp: time_format = "%Y-%m-%d %H:%M:%S" _shared_instance = None - configdir = os.path.expanduser("~")+"/.nomadnetwork" + userdir = os.path.expanduser("~") + if os.path.isdir("/etc/nomadnetwork") and os.path.isfile("/etc/nomadnetwork/config"): + configdir = "/etc/nomadnetwork" + elif os.path.isdir(userdir+"/.config/nomadnetwork") and os.path.isfile(userdir+"/.config/nomadnetwork/config"): + configdir = userdir+"/.config/nomadnetwork" + else: + configdir = userdir+"/.nomadnetwork" START_ANNOUNCE_DELAY = 3 def exit_handler(self): - RNS.log("Nomad Network Client exit handler executing...", RNS.LOG_VERBOSE) self.should_run_jobs = False RNS.log("Saving directory...", RNS.LOG_VERBOSE) self.directory.save_to_disk() + + if hasattr(self, "rrc") and self.rrc is not None: + try: + self.rrc.save() + self.rrc.shutdown() + except Exception: + pass + + if hasattr(self.ui, "restore_ixon"): + if self.ui.restore_ixon: + try: + os.system("stty ixon") + + except Exception as e: + RNS.log("Could not restore flow control sequences. The contained exception was: "+str(e), RNS.LOG_WARNING) + + if hasattr(self.ui, "restore_palette"): + if self.ui.restore_palette: + try: + self.ui.screen.write("\x1b]104\x07") + + except Exception as e: + RNS.log("Could not restore terminal color palette. The contained exception was: "+str(e), RNS.LOG_WARNING) + RNS.log("Nomad Network Client exiting now", RNS.LOG_VERBOSE) def exception_handler(self, e_type, e_value, e_traceback): @@ -54,6 +87,8 @@ class NomadNetworkApp: self.uimode = None + self.announce_interval = 6*60*60 + if configdir == None: self.configdir = NomadNetworkApp.configdir else: @@ -73,31 +108,69 @@ class NomadNetworkApp: self.ignoredpath = self.configdir+"/ignored" self.logfilepath = self.configdir+"/logfile" self.errorfilepath = self.configdir+"/errors" + self.pnannouncedpath = self.configdir+"/pnannounced" self.storagepath = self.configdir+"/storage" self.identitypath = self.configdir+"/storage/identity" 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.conversationpath = self.configdir+"/storage/conversations" self.directorypath = self.configdir+"/storage/directory" self.peersettingspath = self.configdir+"/storage/peersettings" + self.tmpfilespath = self.configdir+"/storage/tmp" + self.attachmentpath = self.configdir+"/storage/attachments" self.pagespath = self.configdir+"/storage/pages" self.filespath = self.configdir+"/storage/files" - self.cachepath = self.configdir+"/storage/cache" + self.examplespath = self.configdir+"/examples" self.downloads_path = os.path.expanduser("~/Downloads") + self.attachment_save_path = None - self.firstrun = False - self.should_run_jobs = True - self.job_interval = 5 - self.defer_jobs = 90 + self.firstrun = False + self.should_run_jobs = True + self.job_interval = 5 + self.peer_settings_lock = threading.Lock() + self.defer_jobs = 90 + self.page_refresh_interval = 0 + self.file_refresh_interval = 0 + self.static_peers = [] self.peer_announce_at_start = True self.try_propagation_on_fail = True + self.disable_propagation = True + self.notify_on_new_message = True + self.compose_markdown = True + + self.lxmf_max_propagation_size = None + self.lxmf_max_sync_size = None + self.lxmf_max_incoming_size = None + self.node_propagation_cost = LXMF.LXMRouter.PROPAGATION_COST self.periodic_lxmf_sync = True self.lxmf_sync_interval = 360*60 self.lxmf_sync_limit = 8 + self.compact_stream = False + + self.required_stamp_cost = None + self.accept_invalid_stamps = False + + self.rrc_history_per_room_cap = 500 + self.rrc_filter_loaded_history = True + self.rrc_max_accepted_resource_size = 256*1024 + self.rrc_ephemeral_notices = 600 + self.rrc_nick_colors = True + self.rrc_nick_colors_theme = None + self.rrc_mention_color = None + self.rrc_color_mention_timestamps = True + self.rrc_ui_justify_msgs = True + self.rrc_ui_space_msgs = False + self.rrc_ui_render_markdown = True + self.rrc_ui_render_micron = True + self.rrc_show_gutters = False + self.rrc_enable_esoterics = False if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -105,6 +178,15 @@ class NomadNetworkApp: if not os.path.isdir(self.cachepath): os.makedirs(self.cachepath) + if not os.path.isdir(self.imagecachepath): + os.makedirs(self.imagecachepath) + + if not os.path.isdir(self.convcachepath): + os.makedirs(self.convcachepath) + + if not os.path.isdir(self.dispcachepath): + os.makedirs(self.dispcachepath) + if not os.path.isdir(self.resourcepath): os.makedirs(self.resourcepath) @@ -120,6 +202,14 @@ class NomadNetworkApp: if not os.path.isdir(self.cachepath): os.makedirs(self.cachepath) + if not os.path.isdir(self.tmpfilespath): + os.makedirs(self.tmpfilespath) + + if not os.path.isdir(self.attachmentpath): + os.makedirs(self.attachmentpath) + else: + self.clear_tmp_dir() + if os.path.isfile(self.configpath): try: self.config = ConfigObj(self.configpath) @@ -135,6 +225,16 @@ class NomadNetworkApp: RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR) nomadnet.panic() else: + if not os.path.isdir(self.examplespath): + try: + import shutil + examplespath = os.path.join(os.path.dirname(__file__), "examples") + shutil.copytree(examplespath, self.examplespath, ignore=shutil.ignore_patterns("__pycache__")) + + except Exception as e: + RNS.log("Could not copy examples into the "+self.examplespath+" directory.", RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + RNS.log("Could not load config file, creating default configuration file...") self.createDefaultConfig() self.firstrun = True @@ -162,11 +262,13 @@ class NomadNetworkApp: RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) nomadnet.panic() - if os.path.isfile(self.peersettingspath): + self.peer_settings = None + self.peer_settings_notice = None + for path in (self.peersettingspath, self.peersettingspath+".bak"): + if self.peer_settings != None or not os.path.isfile(path): continue try: - file = open(self.peersettingspath, "rb") - self.peer_settings = msgpack.unpackb(file.read()) - file.close() + with open(path, "rb") as file: + self.peer_settings = msgpack.unpackb(file.read()) if not "node_last_announce" in self.peer_settings: self.peer_settings["node_last_announce"] = None @@ -183,22 +285,38 @@ class NomadNetworkApp: if not "served_page_requests" in self.peer_settings: self.peer_settings["served_page_requests"] = 0 + if not "served_media_requests" in self.peer_settings: + self.peer_settings["served_media_requests"] = 0 + if not "served_file_requests" in self.peer_settings: self.peer_settings["served_file_requests"] = 0 + if not "quit_confirm" in self.peer_settings: + self.peer_settings["quit_confirm"] = True + + self.peer_settings["announce_interval"] = self.announce_interval + if path != self.peersettingspath: + self.peer_settings_notice = "Peer settings were restored from the last backup.\nCheck your display name and propagation node." + except Exception as e: - RNS.log("Could not load local peer settings from "+self.peersettingspath, RNS.LOG_ERROR) - RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) - nomadnet.panic() - else: + RNS.log(f"Could not load local peer settings from {path}", RNS.LOG_ERROR) + RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR) + RNS.log(f"Moving the corrupt file to {path}.corrupt", RNS.LOG_ERROR) + try: os.replace(path, path+".corrupt") + except Exception: pass + self.peer_settings = None + self.peer_settings_notice = "Peer settings were corrupt and have been reset.\nCheck your display name and propagation node." + + if self.peer_settings == None: try: - RNS.log("No peer settings file found, creating new...") + RNS.log("Creating new peer settings file...") self.peer_settings = { "display_name": "Anonymous Peer", - "announce_interval": None, + "announce_interval": self.announce_interval, "last_announce": None, "node_last_announce": None, "propagation_node": None, + "quit_confirm": True, "last_lxmf_sync": 0, "node_connects": 0, "served_page_requests": 0, @@ -231,18 +349,41 @@ class NomadNetworkApp: RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) except Exception as e: - RNS.log("Error while fetching loading list of ignored destinations: "+str(e), RNS.LOG_ERROR) + RNS.log("Error while loading list of ignored destinations: "+str(e), RNS.LOG_ERROR) self.directory = nomadnet.Directory(self) - self.message_router = LXMF.LXMRouter(identity = self.identity, storagepath = self.storagepath, autopeer = True) + from nomadnet.RRC import RRCManager + self.rrc = RRCManager(self) + self.rrc.load() + + from nomadnet.InterfaceProfiles import InterfaceProfiles + self.interface_profiles = InterfaceProfiles(self) + + static_peers = [] + for static_peer in self.static_peers: + try: + dh = bytes.fromhex(static_peer) + if len(dh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + raise ValueError("Invalid destination length") + static_peers.append(dh) + except Exception as e: + RNS.log(f"Could not decode static peer destination hash {static_peer}: {e}", RNS.LOG_ERROR) + + self.message_router = LXMF.LXMRouter( + identity = self.identity, storagepath = self.storagepath, autopeer = True, + propagation_limit = self.lxmf_max_propagation_size, sync_limit = self.lxmf_max_sync_size, delivery_limit = self.lxmf_max_incoming_size, + max_peers = self.max_peers, static_peers = static_peers, propagation_cost=self.node_propagation_cost + ) + self.message_router.register_delivery_callback(self.lxmf_delivery) for destination_hash in self.ignored_list: self.message_router.ignore_destination(destination_hash) - self.lxmf_destination = self.message_router.register_delivery_identity(self.identity, display_name=self.peer_settings["display_name"]) - self.lxmf_destination.set_default_app_data(self.get_display_name_bytes) + self.lxmf_destination = self.message_router.register_delivery_identity(self.identity, display_name=self.peer_settings["display_name"], stamp_cost=self.required_stamp_cost) + if not self.accept_invalid_stamps: + self.message_router.enforce_stamps() RNS.Identity.remember( packet_hash=None, @@ -254,7 +395,7 @@ class NomadNetworkApp: RNS.log("LXMF Router ready to receive on: "+RNS.prettyhexrep(self.lxmf_destination.hash)) if self.enable_node: - self.message_router.set_message_storage_limit(gigabytes=self.message_storage_limit) + self.message_router.set_message_storage_limit(megabytes=self.message_storage_limit) for dest_str in self.prioritised_lxmf_destinations: try: dest_hash = bytes.fromhex(dest_str) @@ -264,12 +405,37 @@ class NomadNetworkApp: except Exception as e: RNS.log("Cannot prioritise "+str(dest_str)+", it is not a valid destination hash", RNS.LOG_ERROR) - self.message_router.enable_propagation() + if self.disable_propagation: + if os.path.isfile(self.pnannouncedpath): + try: + RNS.log("Sending indication to peered LXMF Propagation Node that this node is no longer participating", RNS.LOG_DEBUG) + self.message_router.disable_propagation() + os.unlink(self.pnannouncedpath) + except Exception as e: + RNS.log("An error ocurred while indicating that this LXMF Propagation Node is no longer participating. The contained exception was: "+str(e), RNS.LOG_ERROR) + else: + self.message_router.enable_propagation() + try: + with open(self.pnannouncedpath, "wb") as pnf: + pnf.write(msgpack.packb(time.time())) + pnf.close() + + except Exception as e: + RNS.log("An error ocurred while writing Propagation Node announce timestamp. The contained exception was: "+str(e), RNS.LOG_ERROR) + + if not self.disable_propagation: + RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) - RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) self.node = nomadnet.Node(self) else: self.node = None + if os.path.isfile(self.pnannouncedpath): + try: + RNS.log("Sending indication to peered LXMF Propagation Node that this node is no longer participating", RNS.LOG_DEBUG) + self.message_router.disable_propagation() + os.unlink(self.pnannouncedpath) + except Exception as e: + RNS.log("An error ocurred while indicating that this LXMF Propagation Node is no longer participating. The contained exception was: "+str(e), RNS.LOG_ERROR) RNS.Transport.register_announce_handler(nomadnet.Conversation) RNS.Transport.register_announce_handler(nomadnet.Directory) @@ -324,11 +490,14 @@ class NomadNetworkApp: RNS.log("Starting job scheduler now", RNS.LOG_DEBUG) while self.should_run_jobs: now = time.time() - + if now > self.peer_settings["last_lxmf_sync"] + self.lxmf_sync_interval: RNS.log("Initiating automatic LXMF sync", RNS.LOG_VERBOSE) self.request_lxmf_sync(limit=self.lxmf_sync_limit) + if now > self.peer_settings["last_announce"] + self.peer_settings["announce_interval"]: + self.announce_now() + time.sleep(self.job_interval) def set_display_name(self, display_name): @@ -357,16 +526,24 @@ class NomadNetworkApp: return "Receiving messages" elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: return "Messages received" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_PATH: + return "No path to node" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_FAILED: + return "Link establisment failed" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_TRANSFER_FAILED: + return "Sync request failed" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_IDENTITY_RCVD: + return "Remote got no identity" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_ACCESS: + return "Node rejected request" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_FAILED: + return "Sync failed" elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: new_msgs = self.message_router.propagation_transfer_last_result if new_msgs == 0: return "Done, no new messages" else: return "Downloaded "+str(new_msgs)+" new messages" - elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_IDENTITY_RCVD: - return "Node did not receive identification" - elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_ACCESS: - return "Node did not allow request" else: return "Unknown" @@ -374,13 +551,13 @@ class NomadNetworkApp: if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE: return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_PATH_REQUESTED: - return True + return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHING: - return True + return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHED: - return True + return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_REQUEST_SENT: - return True + return False elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RECEIVING: return True elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: @@ -403,16 +580,65 @@ class NomadNetworkApp: if self.message_router.propagation_transfer_state != LXMF.LXMRouter.PR_IDLE: self.message_router.cancel_propagation_node_requests() + def _persist_ignored_list(self): + try: + with open(self.ignoredpath, "wb") as fh: + for h in self.ignored_list: + fh.write((RNS.hexrep(h, delimit=False)+"\n").encode("utf-8")) + except Exception as e: + RNS.log("Could not persist ignored list: "+str(e), RNS.LOG_ERROR) + + def block_destination(self, dest_hash, reason=None): + if not isinstance(dest_hash, (bytes, bytearray)): + return False + dest_hash = bytes(dest_hash) + try: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + RNS.Transport.blackhole_identity(identity.hash, reason=reason) + except Exception as e: + RNS.log("Could not blackhole identity: "+str(e), RNS.LOG_ERROR) + if dest_hash not in self.ignored_list: + self.ignored_list.append(dest_hash) + self._persist_ignored_list() + try: + self.message_router.ignore_destination(dest_hash) + except Exception: + pass + return True + + def unblock_destination(self, dest_hash): + if not isinstance(dest_hash, (bytes, bytearray)): + return False + dest_hash = bytes(dest_hash) + try: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + RNS.Transport.unblackhole_identity(identity.hash) + except Exception as e: + RNS.log("Could not lift blackhole on identity: "+str(e), RNS.LOG_ERROR) + if dest_hash in self.ignored_list: + self.ignored_list.remove(dest_hash) + self._persist_ignored_list() + try: + self.message_router.unignore_destination(dest_hash) + except Exception: + pass + return True + def announce_now(self): - self.lxmf_destination.announce() + RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE) + self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost) + self.lxmf_destination.display_name = self.peer_settings["display_name"] + self.message_router.announce(self.lxmf_destination.hash) self.peer_settings["last_announce"] = time.time() self.save_peer_settings() def autoselect_propagation_node(self): selected_node = None - if "propagation_node" in self.peer_settings and self.directory.find(self.peer_settings["propagation_node"]): - selected_node = self.directory.find(self.peer_settings["propagation_node"]) + if "propagation_node" in self.peer_settings and self.peer_settings["propagation_node"] != None: + selected_node = self.peer_settings["propagation_node"] else: nodes = self.directory.known_nodes() trusted_nodes = [] @@ -425,19 +651,15 @@ class NomadNetworkApp: if hops < best_hops: best_hops = hops - selected_node = node + selected_node = node.source_hash 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.", 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: - node_identity = RNS.Identity.recall(selected_node.source_hash) - if node_identity != None: - propagation_hash = RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_identity) - RNS.log("Selecting "+selected_node.display_name+" "+RNS.prettyhexrep(propagation_hash)+" as default LXMF propagation node", RNS.LOG_INFO) - self.message_router.set_outbound_propagation_node(propagation_hash) - else: - RNS.log("Could not recall identity for autoselected LXMF propagation node "+RNS.prettyhexrep(selected_node.source_hash), RNS.LOG_WARNING) - RNS.log("LXMF propagation will not be available until a trusted node announces on the network.", RNS.LOG_WARNING) + if self.message_router.get_outbound_propagation_node() != selected_node: + pn_name_str = "" + RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_DEBUG) + self.message_router.set_outbound_propagation_node(selected_node) def get_user_selected_propagation_node(self): if "propagation_node" in self.peer_settings: @@ -449,14 +671,21 @@ class NomadNetworkApp: self.peer_settings["propagation_node"] = node_hash self.save_peer_settings() self.autoselect_propagation_node() - + def get_default_propagation_node(self): return self.message_router.get_outbound_propagation_node() def save_peer_settings(self): - file = open(self.peersettingspath, "wb") - file.write(msgpack.packb(self.peer_settings)) - file.close() + with self.peer_settings_lock: + fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.peersettingspath), prefix=".peersettings.") + try: + with os.fdopen(fd, "wb") as file: file.write(msgpack.packb(self.peer_settings)) + if os.path.isfile(self.peersettingspath): os.replace(self.peersettingspath, self.peersettingspath+".bak") + os.replace(tmp_path, self.peersettingspath) + except Exception: + try: os.unlink(tmp_path) + except Exception: pass + raise def lxmf_delivery(self, message): time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) @@ -471,6 +700,91 @@ class NomadNetworkApp: nomadnet.Conversation.ingest(message, self) + if self.notify_on_new_message: + self.notify_message_recieved() + + if self.should_print(message): + self.print_message(message) + + def should_print(self, message): + if self.print_messages: + if self.print_all_messages: + return True + + else: + source_hash_text = RNS.hexrep(message.source_hash, delimit=False) + + if self.print_trusted_messages: + trust_level = self.directory.trust_level(message.source_hash) + if trust_level == DirectoryEntry.TRUSTED: + return True + + if type(self.allowed_message_print_destinations) is list: + if source_hash_text in self.allowed_message_print_destinations: + return True + + return False + + def print_file(self, filename): + print_command = self.print_command+" "+filename + + try: + return_code = subprocess.call(shlex.split(print_command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + except Exception as e: + RNS.log("An error occurred while executing print command: "+str(print_command), RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + return False + + if return_code == 0: + RNS.log("Successfully printed "+str(filename)+" using print command: "+print_command, RNS.LOG_DEBUG) + return True + + else: + RNS.log("Printing "+str(filename)+" failed using print command: "+print_command, RNS.LOG_DEBUG) + return False + + + def print_message(self, message, received = None): + try: + template = self.printing_template_msg + + if received == None: + received = time.time() + + g = self.ui.glyphs + + m_rtime = datetime.fromtimestamp(message.timestamp) + stime = m_rtime.strftime(self.time_format) + + message_time = datetime.fromtimestamp(received) + rtime = message_time.strftime(self.time_format) + + display_name = self.directory.simplest_display_str(message.source_hash) + title = message.title_as_string() + if title == "": + title = "None" + + output = template.format( + origin=display_name, + stime=stime, + rtime=rtime, + mtitle=title, + mbody=message.content_as_string(), + ) + + filename = "/tmp/"+RNS.hexrep(RNS.Identity.full_hash(output.encode("utf-8")), delimit=False) + with open(filename, "wb") as f: + f.write(output.encode("utf-8")) + f.close() + + self.print_file(filename) + + os.unlink(filename) + + except Exception as e: + RNS.log("Error while printing incoming LXMF message. The contained exception was: "+str(e)) + def conversations(self): return nomadnet.Conversation.conversation_list(self) @@ -481,21 +795,40 @@ class NomadNetworkApp: return False def conversation_is_unread(self, source_hash): - if bytes.fromhex(source_hash) in nomadnet.Conversation.unread_conversations: + src_bytes = bytes.fromhex(source_hash) + if src_bytes in nomadnet.Conversation.unread_conversations: return True - else: - return False + if src_bytes in nomadnet.Conversation.failed_conversations: + return True + return False def mark_conversation_read(self, source_hash): - if bytes.fromhex(source_hash) in nomadnet.Conversation.unread_conversations: - nomadnet.Conversation.unread_conversations.pop(bytes.fromhex(source_hash)) + src_bytes = bytes.fromhex(source_hash) + if src_bytes in nomadnet.Conversation.unread_conversations: + nomadnet.Conversation.unread_conversations.pop(src_bytes) if os.path.isfile(self.conversationpath + "/" + source_hash + "/unread"): os.unlink(self.conversationpath + "/" + source_hash + "/unread") + if src_bytes in nomadnet.Conversation.failed_conversations: + nomadnet.Conversation.failed_conversations.pop(src_bytes) + if os.path.isfile(self.conversationpath + "/" + source_hash + "/failed"): + try: os.unlink(self.conversationpath + "/" + source_hash + "/failed") + except Exception: pass + + def notify_message_recieved(self): + if self.uimode == nomadnet.ui.UI_TEXT: + sys.stdout.write("\a") + sys.stdout.flush() + + def clear_tmp_dir(self): + if os.path.isdir(self.tmpfilespath): + for file in os.listdir(self.tmpfilespath): + fpath = self.tmpfilespath+"/"+file + os.unlink(fpath) def createDefaultConfig(self): self.config = ConfigObj(__default_nomadnet_config__) self.config.filename = self.configpath - + if not os.path.isdir(self.configdir): os.makedirs(self.configdir) self.config.write() @@ -533,10 +866,19 @@ class NomadNetworkApp: value = self.config["client"]["downloads_path"] self.downloads_path = os.path.expanduser(value) + if option == "attachment_save_path": + value = self.config["client"]["attachment_save_path"] + self.attachment_save_path = os.path.expanduser(value) + if option == "announce_at_start": value = self.config["client"].as_bool(option) self.peer_announce_at_start = value + if option == "announce_interval": + value = self.config["client"].as_int(option) + if value < 30: value = 30 + self.announce_interval = value*60 + if option == "try_propagation_on_send_fail": value = self.config["client"].as_bool(option) self.try_propagation_on_fail = value @@ -552,13 +894,51 @@ class NomadNetworkApp: self.lxmf_sync_interval = value if option == "lxmf_sync_limit": - value = self.config["client"].as_int(option) + value = self.config["client"].as_int(option) if value > 0: self.lxmf_sync_limit = value else: self.lxmf_sync_limit = None + if option == "required_stamp_cost": + value = self.config["client"][option] + if value.lower() == "none": + self.required_stamp_cost = None + else: + value = self.config["client"].as_int(option) + + if value > 0: + if value > 255: + value = 255 + self.required_stamp_cost = value + else: + self.required_stamp_cost = None + + if option == "accept_invalid_stamps": + value = self.config["client"].as_bool(option) + self.accept_invalid_stamps = value + + if option == "max_accepted_size": + value = self.config["client"].as_float(option) + + if value > 0: + self.lxmf_max_incoming_size = value + else: + self.lxmf_max_incoming_size = 500 + + if option == "compact_announce_stream": + value = self.config["client"].as_bool(option) + self.compact_stream = value + + if option == "notify_on_new_message": + value = self.config["client"].as_bool(option) + self.notify_on_new_message = value + + if option == "compose_in_markdown": + value = self.config["client"].as_bool(option) + self.compose_markdown = value + if option == "user_interface": value = value.lower() if value == "none": @@ -571,13 +951,13 @@ class NomadNetworkApp: if not "intro_time" in self.config["textui"]: self.config["textui"]["intro_time"] = 1 else: - self.config["textui"]["intro_time"] = self.config["textui"].as_int("intro_time") + self.config["textui"]["intro_time"] = self.config["textui"].as_float("intro_time") if not "intro_text" in self.config["textui"]: self.config["textui"]["intro_text"] = "Nomad Network" if not "editor" in self.config["textui"]: - self.config["textui"]["editor"] = "editor" + self.config["textui"]["editor"] = "nano" if not "glyphs" in self.config["textui"]: self.config["textui"]["glyphs"] = "unicode" @@ -592,6 +972,16 @@ class NomadNetworkApp: else: self.config["textui"]["hide_guide"] = self.config["textui"].as_bool("hide_guide") + if not "sanitize_names" in self.config["textui"]: + self.config["textui"]["sanitize_names"] = True + else: + self.config["textui"]["sanitize_names"] = self.config["textui"].as_bool("sanitize_names") + + if not "clipboard_copy" in self.config["textui"]: + self.config["textui"]["clipboard_copy"] = False + else: + self.config["textui"]["clipboard_copy"] = self.config["textui"].as_bool("clipboard_copy") + if not "animation_interval" in self.config["textui"]: self.config["textui"]["animation_interval"] = 1 else: @@ -613,6 +1003,20 @@ class NomadNetworkApp: else: raise ValueError("The selected Text UI color mode is invalid") + if not "image_loading" in self.config["textui"]: + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO + else: + if self.config["textui"]["image_loading"].lower() == "never": + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_NEVER + elif self.config["textui"]["image_loading"].lower() == "manual": + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_MANUAL + elif self.config["textui"]["image_loading"].lower() == "auto": + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO + elif self.config["textui"]["image_loading"].lower() == "always": + self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_ALWAYS + else: + raise ValueError("The selected image loading mode is invalid") + if not "theme" in self.config["textui"]: self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_DARK else: @@ -629,6 +1033,98 @@ class NomadNetworkApp: if value == "web": self.uimode = nomadnet.ui.UI_WEB + if not "utilities" in self.config: + self.config["utilities"] = {} + if not "enable_utilities" in self.config["utilities"]: + self.config["utilities"]["enable_utilities"] = False + else: + self.config["utilities"]["enable_utilities"] = self.config["utilities"].as_bool("enable_utilities") + + if "rrc" in self.config: + for option in self.config["rrc"]: + if option == "history_per_room_cap": + try: value = self.config["rrc"].as_int(option) + except Exception: value = None + if value is not None and value >= 0: + self.rrc_history_per_room_cap = value + + if option == "filter_loaded_history": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_filter_loaded_history = value + + if option == "max_accepted_resource_size": + try: value = self.config["rrc"].as_int(option) + except Exception: value = None + if value is not None and value >= 0: + self.rrc_max_accepted_resource_size = value*1024 + + if option == "ephemeral_notices": + try: value = self.config["rrc"].as_float(option) + except Exception: value = 0 + self.rrc_ephemeral_notices = value*60 + + if option == "justify_msgs": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_ui_justify_msgs = value + + if option == "space_msgs": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_ui_space_msgs = value + + if option == "nick_colors": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_nick_colors = value + + if option == "mention_color": + value = self.config["rrc"][option] + if len(value) == 6: + try: bytes.fromhex(value) + except Exception: value = None + self.rrc_mention_color = value + + if option == "color_mention_timestamps": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_color_mention_timestamps = value + + if option == "nick_colors_theme": + try: + colors = self.config["rrc"].as_list(option) + nick_colors_theme = [] + for c in colors: + if len(c) == 6: + try: bytes.fromhex(c) + except: continue + nick_colors_theme.append(c) + self.rrc_nick_colors_theme = nick_colors_theme + except Exception as e: + RNS.log(f"Could not load custom nick colors theme: {e}", RNS.LOG_WARNING) + self.rrc_nick_colors_theme = None + + if option == "render_markdown": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_ui_render_markdown = value + + if option == "render_micron": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_ui_render_micron = value + + if option == "show_gutters": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_show_gutters = value + + if option == "enable_esoterics": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_enable_esoterics = value + if "node" in self.config: if not "enable_node" in self.config["node"]: self.enable_node = False @@ -644,6 +1140,27 @@ class NomadNetworkApp: else: self.node_name = self.config["node"]["node_name"] + if not "disable_propagation" in self.config["node"]: + self.disable_propagation = True + else: + self.disable_propagation = self.config["node"].as_bool("disable_propagation") + + if not "max_transfer_size" in self.config["node"]: + self.lxmf_max_propagation_size = 256 + else: + value = self.config["node"].as_float("max_transfer_size") + if value < 1: + value = 1 + self.lxmf_max_propagation_size = value + + if not "max_sync_size" in self.config["node"]: + self.lxmf_max_sync_size = 256*40 + else: + value = self.config["node"].as_float("max_sync_size") + if value < self.lxmf_max_propagation_size: + value = self.lxmf_max_propagation_size + self.lxmf_max_sync_size = value + if not "announce_at_start" in self.config["node"]: self.node_announce_at_start = False else: @@ -658,26 +1175,113 @@ class NomadNetworkApp: value = 1 self.node_announce_interval = value + if not "propagation_cost" in self.config["node"]: + self.node_propagation_cost = 16 + else: + value = self.config["node"].as_int("propagation_cost") + if value < 13: value = 13 + self.node_propagation_cost = value + if "pages_path" in self.config["node"]: self.pagespath = self.config["node"]["pages_path"] + if not "page_refresh_interval" in self.config["node"]: + self.page_refresh_interval = 0 + else: + value = self.config["node"].as_int("page_refresh_interval") + if value < 0: + value = 0 + self.page_refresh_interval = value + + if "files_path" in self.config["node"]: self.filespath = self.config["node"]["files_path"] + if not "file_refresh_interval" in self.config["node"]: + self.file_refresh_interval = 0 + else: + value = self.config["node"].as_int("file_refresh_interval") + if value < 0: + value = 0 + self.file_refresh_interval = value + + if "prioritise_destinations" in self.config["node"]: self.prioritised_lxmf_destinations = self.config["node"].as_list("prioritise_destinations") else: self.prioritised_lxmf_destinations = [] + if "static_peers" in self.config["node"]: + self.static_peers = self.config["node"].as_list("static_peers") + else: + self.static_peers = [] + + if not "max_peers" in self.config["node"]: + self.max_peers = None + else: + value = self.config["node"].as_int("max_peers") + if value < 0: + value = 0 + self.max_peers = value + if not "message_storage_limit" in self.config["node"]: - self.message_storage_limit = 2 + self.message_storage_limit = 2000 else: value = self.config["node"].as_float("message_storage_limit") - if value < 0.064: - value = 0.064 + if value < 0.005: + value = 0.005 self.message_storage_limit = value - + self.print_command = "lp" + self.print_messages = False + self.print_all_messages = False + self.print_trusted_messages = False + if "printing" in self.config: + if not "print_messages" in self.config["printing"]: + self.print_messages = False + else: + self.print_messages = self.config["printing"].as_bool("print_messages") + + if "print_command" in self.config["printing"]: + self.print_command = self.config["printing"]["print_command"] + + if self.print_messages: + if not "print_from" in self.config["printing"]: + self.allowed_message_print_destinations = None + else: + if type(self.config["printing"]["print_from"]) == str: + self.allowed_message_print_destinations = [] + if self.config["printing"]["print_from"].lower() == "everywhere": + self.print_all_messages = True + + if self.config["printing"]["print_from"].lower() == "trusted": + + self.print_all_messages = False + self.print_trusted_messages = True + + if len(self.config["printing"]["print_from"]) == (RNS.Identity.TRUNCATED_HASHLENGTH//8)*2: + self.allowed_message_print_destinations.append(self.config["printing"]["print_from"]) + + if type(self.config["printing"]["print_from"]) == list: + self.allowed_message_print_destinations = self.config["printing"].as_list("print_from") + for allowed_entry in self.allowed_message_print_destinations: + if allowed_entry.lower() == "trusted": + self.print_trusted_messages = True + + + if not "message_template" in self.config["printing"]: + self.printing_template_msg = __printing_template_msg__ + else: + mt_path = os.path.expanduser(self.config["printing"]["message_template"]) + if os.path.isfile(mt_path): + template_file = open(mt_path, "rb") + self.printing_template_msg = template_file.read().decode("utf-8") + else: + template_file = open(mt_path, "wb") + template_file.write(__printing_template_msg__.encode("utf-8")) + self.printing_template_msg = __printing_template_msg__ + + @staticmethod def get_shared_instance(): if NomadNetworkApp._shared_instance != None: @@ -715,10 +1319,19 @@ enable_client = yes user_interface = text downloads_path = ~/Downloads +# Where to save received attachments. If not set, +# attachments will be saved to the downloads path. +# attachment_save_path = ~/Downloads +notify_on_new_message = yes + # By default, the peer is announced at startup # to let other peers reach it immediately. announce_at_start = yes +# Automatic announce interval in minutes for +# your LXMF address. 6 hours by default. +announce_interval = 360 + # By default, the client will try to deliver a # message via the LXMF propagation network, if # a direct delivery to the recipient is not @@ -741,6 +1354,41 @@ lxmf_sync_interval = 360 # the limit, and download everything every time. lxmf_sync_limit = 8 +# You can specify a required stamp cost for +# inbound messages to be accepted. Specifying +# a stamp cost will require untrusted senders +# that message you to include a cryptographic +# stamp in their messages. Performing this +# operation takes the sender an amount of time +# proportional to the stamp cost. As a rough +# estimate, a stamp cost of 8 will take less +# than a second to compute, and a stamp cost +# of 20 could take several minutes, even on +# a fast computer. +required_stamp_cost = None + +# You can signal stamp requirements to senders, +# but still accept messages with invalid stamps +# by setting this option to True. +accept_invalid_stamps = False + +# The maximum accepted unpacked size for mes- +# sages received directly from other peers, +# specified in kilobytes. Messages larger than +# this will be rejected before the transfer +# begins. +max_accepted_size = 500 + +# The announce stream will only show one entry +# per destination or node by default. You can +# change this to show as many announces as have +# been received, for every destination. +compact_announce_stream = yes + +# You can choose whether or not to compose +# messages in markdown format. +compose_in_markdown = yes + [textui] # Amount of time to show intro screen @@ -762,8 +1410,8 @@ theme = dark # colormode = monochrome # colormode = 16 # colormode = 88 -colormode = 256 -# colormode = 24bit +# colormode = 256 +colormode = 24bit # By default, unicode glyphs are used. If # you have a Nerd Font installed, you can @@ -772,23 +1420,98 @@ colormode = 256 # your terminal doesn't support unicode. # glyphs = plain -glyphs = unicode -# glyphs = nerdfont +# glyphs = unicode +glyphs = nerdfont + +# If your terminal supports image output, +# the page browser can display images and +# graphics on pages. You can specify how, +# or if at all, images are loaded. Valid +# options are "auto", "manual", "never" +# and "always". + +image_loading = auto # You can specify whether mouse events # should be considered as input to the # application. On by default. mouse_enabled = True -# What editor to use for editing text. By -# default the operating systems "editor" -# alias will be used. -editor = editor +# What editor to use for editing text. +editor = nano # If you don't want the Guide section to # show up in the menu, you can disable it. hide_guide = no +# You can select whether or not to perform +# text sanitization on names received in +# announces. +sanitize_names = yes + +# You can enable clipboard features. This +# relies on your terminal supporting OSC52 +# escape sequences. +clipboard_copy = no + +[utilities] + +# You can enable a Utilities section in the +# menu, where you can view the path and announce +# rate tables and probe destinations. Disabled +# by default. +enable_utilities = no + +[rrc] + +# Maximum number of messages retained per +# room in the in-memory scrollback buffer, +# and the number of messages restored from +# on-disk history at startup. The on-disk +# log itself is appended to indefinitely; +# this cap only controls how much backlog +# is visible. Set to 0 to keep every message +# in memory (full history). +history_per_room_cap = 500 + +# You can choose whether to filter system +# and notice events when room history is +# loaded. +filter_loaded_history = yes + +# Maximum size in kilobytes of a resource +# transfer that will be accepted from a +# hub. Lower this on low-bandwidth links +# to automatically reject transfers that +# would take too long to receive. Set to +# 0 to reject all resource transfers. +max_accepted_resource_size = 256 + +# You can choose whether notices and sys +# messages persist indefinitely, or are +# removed from the message history after +# the specified time in minutes. Set to 0 +# to disable and keep forever. +ephemeral_notices = 10 + +# Other display and formatting options: +color_mention_timestamps = yes +render_markdown = yes +render_micron = yes +nick_colors = yes +justify_msgs = yes +space_msgs = no +show_gutters = yes + +# You can configure your own color theme +# for nick color assignment +# nick_colors_theme = f68787, 00c394, d59e00, ... + +# You can set a specific color for mentions +# of your nick +# mention_color = FFBB44 + + [node] # Whether to enable node hosting @@ -806,16 +1529,86 @@ announce_interval = 360 # Whether to announce when the node starts. announce_at_start = Yes +# When Nomad Network is hosting a page-serving +# node, it can also act as an LXMF propagation +# node. This is a convenient feature that lets +# you easily set up and run a propagation node +# on the network, but it is not as fully +# featured as using the lxmd program to host a +# propagation node. For complete control and +# flexibility, use lxmd to run a PN. For a +# small local system or network, the built-in +# PN functionality will suffice for most cases. +# +# If there is already a large amount of +# propagation nodes on the network, or you +# simply want to run a pageserving-only node, +# you should disable running a propagation node. +# Due to lots of propagation nodes being +# available, this is currently the default. + +disable_propagation = Yes + +# For clients and other propagation nodes +# delivering messages via this node, you can +# configure the minimum required propagation +# stamp costs. All messages delivered to the +# propagation node network must have a valid +# propagation stamp, or they will be rejected. +# Clients automatically detect the stamp cost +# for the node they are delivering to, and +# compute a corresponding stamp before trying +# to deliver the message to the propagation +# node. +# +# Propagation stamps are easier to verify in +# large batches, and therefore also somewhat +# easier to compute for the senders. As such, +# a reasonable propagation stamp cost should +# be a bit higher than the normal peer-to-peer +# stamp costs. +# +# Propagation stamps does not incur any extra +# load for propagation nodes processing them, +# since they are only required to verify that +# they are correct, and only the generation +# is computationally costly. Setting a sensible +# propagation stamp cost (and periodically +# checking the average network consensus) helps +# keep spam and misuse out of the propagation +# node network. + +propagation_cost = 16 + # The maximum amount of storage to use for # the LXMF Propagation Node message store, -# specified in gigabytes. When this limit +# specified in megabytes. When this limit # is reached, LXMF will periodically remove # messages in its message store. By default, # LXMF prioritises keeping messages that are # new and small. Large and old messages will # be removed first. This setting is optional # and defaults to 2 gigabytes. -# message_storage_limit = 2 + +# message_storage_limit = 2000 + +# The maximum accepted transfer size per in- +# coming propagation message, in kilobytes. +# This sets the upper limit for the size of +# single messages accepted onto this node. + +max_transfer_size = 256 + +# The maximum accepted transfer size per in- +# coming propagation node sync. +# +# If a node wants to propagate a larger number +# of messages to this node, than what can fit +# within this limit, it will prioritise sending +# the smallest messages first, and try again +# with any remaining messages at a later point. + +max_sync_size = 10240 # You can tell the LXMF message router to # prioritise storage for one or more @@ -824,6 +1617,118 @@ announce_at_start = Yes # keeping messages for destinations specified # with this option. This setting is optional, # and generally you do not need to use it. -# prioritise_destinations = 10bc7624c27032a18639, ba780a6dff4cc1391db8 -'''.splitlines() \ No newline at end of file +# prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf + +# You can configure the maximum number of other +# propagation nodes that this node will peer +# with automatically. The default is 20. + +# max_peers = 20 + +# You can configure a list of static propagation +# node peers, that this node will always be +# peered with, by specifying a list of +# destination hashes. + +# static_peers = e17f833c4ddf8890dd3a79a6fea8161d, 5a2d0029b6e5ec87020abaea0d746da4 + +# You can specify the interval in minutes for +# rescanning the hosted pages path. By default, +# this option is disabled, and the pages path +# will only be scanned on startup. + +# page_refresh_interval = 0 + +# You can specify the interval in minutes for +# rescanning the hosted files path. By default, +# this option is disabled, and the files path +# will only be scanned on startup. + +# file_refresh_interval = 0 + +[printing] + +# You can configure Nomad Network to print +# various kinds of information and messages. + +# Printing messages is disabled by default + +print_messages = No + +# You can configure a custom template for +# message printing. If you uncomment this +# option, set a path to the template and +# restart Nomad Network, a default template +# will be created that you can edit. + +# message_template = ~/.nomadnetwork/print_template_msg.txt + +# You can configure Nomad Network to only +# print messages from trusted destinations. + +# print_from = trusted + +# Or specify the source LXMF addresses that +# will automatically have messages printed +# on arrival. + +# print_from = 76fe5751a56067d1e84eef3e88eab85b, 0e70b5848eb57c13154154feaeeb89b7 + +# Or allow printing from anywhere, if you +# are feeling brave and adventurous. + +# print_from = everywhere + +# You can configure the printing command. +# This will use the default CUPS printer on +# your system. + +print_command = lp + +# You can specify what printer to use +# print_command = lp -d [PRINTER_NAME] + +# Or specify more advanced options. This +# example works well for small thermal- +# roll printers: +# print_command = lp -d [PRINTER_NAME] -o cpi=16 -o lpi=8 + +# This one is more suitable for full-sheet +# printers. It will print a QR code at the center of any media +# your printer will accept, print in portrait mode, and move the message to +# the top of the print queue: +# print_command = lp -d [PRINTER_NAME] -o job-priority=100 -o media=Custom.75x75mm -o orientation-requested=3 + +# But you can modify the size to fit your needs. +# The custom media option accepts millimeters, centimeters, and +# inches in a width by length format like so: +# -o media=Custom.[WIDTH]x[LENGTH][mm,cm,in] +# +# The job priority option accepts 1-100, though you can remove it +# entirely if you aren't concerned with a print queue: +# -o job-priority=[1-100] +# +# Finally, the orientation option allows for 90 degree rotations beginning with 3, so: +# -o orientation-requested=4 (landscape, 90 degrees) +# -o orientation-requested=5 (reverse portrait, 180 degrees) +# +# Here is the full command with the recommended customizable variables: +# print_command = lp -d [PRINTER_NAME] -o job-priority=[N] -o media=[MEDIA_SIZE] -o orientation-requested=[N] -o sides=one-sided + +# For example, here's a configuration for USB thermal printer that uses the POS-58 PPD driver +# with rolls 47.98x209.9mm in size: +# print_command = lp -d [PRINTER_NAME] -o job-priority=100 -o media=custom_47.98x209.9mm_47.98x209.9mm -o sides=one-sided + +'''.splitlines() + +__printing_template_msg__ = """ +--------------------------- +From: {origin} +Sent: {stime} +Rcvd: {rtime} +Title: {mtitle} + +{mbody} +--------------------------- +""" diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py new file mode 100644 index 0000000..f5cde89 --- /dev/null +++ b/nomadnet/RRC.py @@ -0,0 +1,1641 @@ +import os +import re +import time +import threading +import hashlib +from collections import deque + +import RNS + +from nomadnet.vendor import cbor +from nomadnet.util import strip_modifiers + + +HISTORY_DIR_NAME = "rrc_history" +HISTORY_FILENAME_SANITIZE_RE = re.compile(r"[^a-z0-9._-]+") + +H_KIND = "k" +H_SRC = "s" +H_NICK = "n" +H_TEXT = "t" +H_TS = "ts" +H_MENTION = "m" + + +_MENTION_RE_CACHE = {} + + +def _mention_re(nick): + if not isinstance(nick, str) or not nick: + return None + pat = _MENTION_RE_CACHE.get(nick) + if pat is None: + pat = re.compile(r"(? 32: + _MENTION_RE_CACHE.clear() + _MENTION_RE_CACHE[nick] = pat + return pat + +# https://github.com/kc1awv/rrcd/blob/main/rrcd/constants.py +RRC_VERSION = 1 + +K_V = 0 +K_T = 1 +K_ID = 2 +K_TS = 3 +K_SRC = 4 +K_ROOM = 5 +K_BODY = 6 +K_NICK = 7 + +T_HELLO = 1 +T_WELCOME = 2 + +T_JOIN = 10 +T_JOINED = 11 +T_PART = 12 +T_PARTED = 13 + +T_MSG = 20 +T_NOTICE = 21 +T_ACTION = 22 + +T_PING = 30 +T_PONG = 31 + +T_ERROR = 40 + +T_RESOURCE_ENVELOPE = 50 + +B_HELLO_NAME = 0 +B_HELLO_VER = 1 +B_HELLO_CAPS = 2 + +B_WELCOME_HUB = 0 +B_WELCOME_VER = 1 +B_WELCOME_CAPS = 2 +B_WELCOME_LIMITS = 3 + +L_MAX_NICK_BYTES = 0 +L_MAX_ROOM_NAME_BYTES = 1 +L_MAX_MSG_BODY_BYTES = 2 +L_MAX_ROOMS_PER_SESSION = 3 +L_RATE_LIMIT_MSGS_PER_MINUTE= 4 + +CAP_RESOURCE_ENVELOPE = 0 +CAP_ACTION = 1 + +B_RES_ID = 0 +B_RES_KIND = 1 +B_RES_SIZE = 2 +B_RES_SHA256 = 3 +B_RES_ENCODING = 4 + +RES_KIND_NOTICE = "notice" +RES_KIND_MOTD = "motd" +RES_KIND_BLOB = "blob" + +DEFAULT_DEST_NAME = "rrc.hub" +DEFAULT_MAX_NICK_BYTES = 32 +DEFAULT_MAX_ROOM_BYTES = 64 +DEFAULT_MAX_MSG_BYTES = 350 +DEFAULT_MAX_ROOMS = 32 +DEFAULT_RATE_PER_MINUTE = 240 + + + + + + +def _now_ms(): + return int(time.time()*1000) + + +def _msg_id(): + return os.urandom(8) + + +# greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)" +_WHO_ENTRY_RE = re.compile( + r"(?:^|,\s)" + r"(?:(?P[0-9a-fA-F]{64})|(?P.+?)\s\((?P[0-9a-fA-F]{12})\))" + r"(?=,\s|$)" +) + + +# hub /who response format: "members in : nick1 (hex12), nick2 (hex12), , ..." +# nicked users carry only a 12-hex prefix of their identity hash; un-nicked users appear as the full hex +def _parse_who_notice(text): + if not isinstance(text, str): + return None + prefix = "members in " + if not text.startswith(prefix): + return None + sep_idx = text.find(": ", len(prefix)) + if sep_idx < 0: + return None + room = text[len(prefix):sep_idx].strip().lower() + if not room: + return None + body = text[sep_idx+2:].strip() + entries = [] + if body and body != "(none)": + for m in _WHO_ENTRY_RE.finditer(body): + if m.group("nick") is not None: + entries.append((m.group("nick").strip(), m.group("np").lower())) + elif m.group("bh") is not None: + entries.append((None, m.group("bh").lower())) + return (room, entries) + + +def _parse_room_list_notice(text): + if not isinstance(text, str): + return None + stripped = text.strip() + if stripped == "No public rooms registered": + return {} + lines = text.split("\n") + if not lines or not lines[0].lstrip().startswith("Registered public rooms"): + return None + rooms = {} + for line in lines[1:]: + s = line.strip() + if not s: + continue + if " - " in s: + name, topic = s.split(" - ", 1) + rooms[name.strip().lower()] = topic.strip() or None + else: + rooms[s.strip().lstrip("#").lower()] = None + return rooms + + +# On low-MTU links the hub may deliver a /list reply as a sequence of +# chunked NOTICE packets (one line per packet) instead of a single notice +# or a resource transfer. The chunks carry no continuation marker, but each +# line has a recognisable shape. Lines matching it, arriving within a short +# window after a /list header, are treated as continuation fragments and +# merged into the room list instead of being recorded as chat messages. +_LIST_FRAGMENT_RE = re.compile(r"^ (?P\S+?)(?: - (?P.*))?$") +LIST_FRAGMENT_WINDOW = 60.0 + + +def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None): + env = { + K_V: RRC_VERSION, + K_T: int(msg_type), + K_ID: mid or _msg_id(), + K_TS: ts or _now_ms(), + K_SRC: src, + } + if room is not None: + env[K_ROOM] = room + if body is not None: + env[K_BODY] = body + if nick is not None and nick != "": + env[K_NICK] = nick + return env + + +class RRCMessage: + def __init__(self, kind, room, src, nick, text, ts): + self.kind = kind + self.room = room + self.src = src + self.nick = nick + self.text = strip_modifiers(text) + self.ts = ts + self.mention = False + + +class RRCHub: + STATUS_DISCONNECTED = 0 + STATUS_CONNECTING = 1 + STATUS_CONNECTED = 2 + STATUS_FAILED = 3 + + CLEAN_HISTORY_INTERVAL = 5 + SYS_NOTICE_TIMEOUT = 600 + + def __init__(self, manager, hub_hash, dest_name=None, name=None): + self.manager = manager + self.hub_hash = hub_hash + self.dest_name = dest_name or DEFAULT_DEST_NAME + self.name = name or RNS.prettyhexrep(hub_hash) + + self.link = None + self.status = RRCHub.STATUS_DISCONNECTED + self.status_text = "Disconnected" + self.welcomed = False + self.hub_name = None + self.hub_version = None + self.hub_caps = {} + self.motd = None + + self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES + self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES + self.max_msg_body_bytes = DEFAULT_MAX_MSG_BYTES + self.max_rooms_per_session = DEFAULT_MAX_ROOMS + self.rate_limit_msgs_per_minute = DEFAULT_RATE_PER_MINUTE + + self.rooms = set() + self.messages = {} + self.notices = [] + self.unread_rooms = set() + self.mention_rooms = set() + self.members = {} + self.nicks = {} + # Display-only member entries derived from 12-hex identity prefixes + # in /who replies. Populated when the full identity hash of a nicked + # member is not yet known, and promoted to regular members as soon + # as the full hash is learned from room traffic or join fanouts. + self.prefix_members = {} + + self.auto_reconnect = False + self.auto_list = False + self.auto_who = False + + self._lock = threading.RLock() + # we need this lock to serialize writes on non-*nix systems where O_APPEND + # write()'s apparently aren't guaranteed to be atomic + self._history_io_lock = threading.Lock() + self._resource_expectations = {} + self._sent_ids = deque(maxlen=256) + + self._hello_thread = None + self._stop_hello = threading.Event() + self._manual_disconnect = False + self._reconnect_attempts = 0 + self._reconnect_timer = None + self._pending_pings = {} + self._last_history_clean = 0 + self.clean_last_removed = 0 + + self.available_rooms = {} + self._silent_list_pending = 0 + self._silent_who_rooms = set() + self._list_fragment_until = 0.0 + + self.nick_override = None + self._pending_joins = set() + self._pending_parts = set() + self._silent_joins = set() + + self._history_write_failed = False + + def _log(self, msg, level=None): + if level is None: + level = RNS.LOG_INFO + RNS.log("[RRC "+self.name+"] "+msg, level) + + def add_room(self, room): + room_n = self._normalize_room(room) + with self._lock: + self.rooms.add(room_n) + if room_n not in self.messages: + self.messages[room_n] = [] + self.manager.save() + self.manager._notify_change(self) + return room_n + + def remove_room(self, room): + r = self._normalize_room(room) + with self._lock: + self.rooms.discard(r) + self.messages.pop(r, None) + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self.members.pop(r, None) + self.prefix_members.pop(r, None) + self._delete_history(r) + self.manager.save() + self.manager._notify_change(self) + + def clear_messages(self, room): + r = self._normalize_room(room) + with self._lock: + if r in self.messages: + self.messages[r] = [] + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self._delete_history(r) + self.manager._notify_change(self) + + def get_members(self, room): + with self._lock: + return list(self.members.get(room, set()) | self.prefix_members.get(room, set())) + + def _upgrade_prefix_member(self, room, full_hash): + # Once a full identity hash is learned from room traffic or join + # fanouts, promote any display-only prefix member that matches it, + # so the entry becomes a full, addressable member. The prefix + # entry's nickname is carried over unless the promoting source + # provides one. + pset = self.prefix_members.get(room) + if pset: + for p in list(pset): + if full_hash.startswith(p): + pset.discard(p) + nick = self.nicks.pop(p, None) + if nick and full_hash not in self.nicks: + self.nicks[full_hash] = nick + break + + def display_name_for(self, peer): + if not isinstance(peer, (bytes, bytearray)): + return "" + ph = bytes(peer) + with self._lock: + nick = self.nicks.get(ph) + if nick: + return nick + return ph.hex()[:12] + + def mark_read(self, room): + r = self._normalize_room(room) + with self._lock: + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self.manager._notify_change(self) + + def _normalize_room(self, room): + r = (room or "").strip().lower() + if not r: + raise ValueError("room must not be empty") + return r + + def _set_status(self, status, text=None): + self.status = status + if text is not None: + self.status_text = text + self.manager._notify_change(self) + + def connect(self): + with self._lock: + if self.status in (RRCHub.STATUS_CONNECTING, RRCHub.STATUS_CONNECTED): + return + self._manual_disconnect = False + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + if self._reconnect_attempts > 0: + text = "Reconnecting (attempt "+str(self._reconnect_attempts)+")" + else: + text = "Connecting" + self._set_status(RRCHub.STATUS_CONNECTING, text) + + t = threading.Thread(target=self._connect_worker, daemon=True) + t.start() + + def _connect_worker(self): + try: + path_timeout = self.manager.app.rns.get_medium_path_timeout() + timeout_s = max(20.0, path_timeout) + if not RNS.Transport.has_path(self.hub_hash): + RNS.Transport.request_path(self.hub_hash) + deadline = time.monotonic() + max(5.0, path_timeout) + while time.monotonic() < deadline: + if RNS.Transport.has_path(self.hub_hash): + break + time.sleep(0.1) + + hub_identity = None + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + hub_identity = RNS.Identity.recall(self.hub_hash) + if hub_identity is not None: + break + time.sleep(0.2) + + if hub_identity is None: + self._set_status(RRCHub.STATUS_FAILED, "Hub identity unknown") + return + + app_name, aspects = RNS.Destination.app_and_aspects_from_name(self.dest_name) + hub_dest = RNS.Destination( + hub_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + app_name, + *aspects, + ) + + if hub_dest.hash != self.hub_hash: + self._set_status(RRCHub.STATUS_FAILED, "Hash/destination name mismatch") + return + + self._stop_hello.clear() + link = RNS.Link(hub_dest, established_callback=self._on_established, closed_callback=self._on_closed) + link.set_packet_callback(lambda data, pkt: self._on_packet(data)) + with self._lock: + self.link = link + + except Exception as e: + self._set_status(RRCHub.STATUS_FAILED, "Connect error: "+str(e)) + + def _on_established(self, link): + try: + link.set_resource_strategy(RNS.Link.ACCEPT_APP) + link.set_resource_callback(self._resource_advertised) + link.set_resource_started_callback(self._resource_advertised) + link.set_resource_concluded_callback(self._resource_concluded) + except Exception: + pass + + try: + link.identify(self.manager.identity) + except Exception as e: + self._log("identify failed: "+str(e), RNS.LOG_ERROR) + try: link.teardown() + except Exception: pass + return + + self._set_status(RRCHub.STATUS_CONNECTING, "Identified, sending HELLO") + + def hello_loop(): + attempts = 0 + while not self._stop_hello.is_set() and not self.welcomed and attempts < 5: + with self._lock: + cur_link = self.link + if cur_link is None or cur_link.status != RNS.Link.ACTIVE: + return + try: + self._send_hello(cur_link) + except Exception as e: + self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR) + attempts += 1 + self._stop_hello.wait(timeout=max(3.0, (cur_link.rtt or 0)*cur_link.traffic_timeout_factor)) + if not self.welcomed and not self._stop_hello.is_set(): + self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout") + try: + with self._lock: + if self.link is not None: + self.link.teardown() + except Exception: + pass + + self._hello_thread = threading.Thread(target=hello_loop, daemon=True) + self._hello_thread.start() + + def _send_hello(self, link): + body = { + B_HELLO_NAME: "nomadnet", + B_HELLO_VER: "0.1", + B_HELLO_CAPS: { + CAP_RESOURCE_ENVELOPE: True, + CAP_ACTION: True, + }, + } + env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + payload = cbor.encode(env) + RNS.Packet(link, payload).send() + + def _on_closed(self, link): + self._stop_hello.set() + with self._lock: + self.link = None + self.welcomed = False + self.motd = None + self.members.clear() + self.prefix_members.clear() + self._resource_expectations.clear() + self._pending_joins.clear() + self._pending_parts.clear() + self._silent_joins.clear() + self._silent_who_rooms.clear() + should_reconnect = self.auto_reconnect and not self._manual_disconnect + self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") + if should_reconnect: + self._schedule_reconnect() + + def _schedule_reconnect(self): + with self._lock: + self._reconnect_attempts += 1 + backoff = min(60.0, max(1.0, 2.0 ** min(self._reconnect_attempts, 6))) + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + + def fire(): + with self._lock: + self._reconnect_timer = None + if self._manual_disconnect or not self.auto_reconnect: + return + self.connect() + + self._reconnect_timer = threading.Timer(backoff, fire) + self._reconnect_timer.daemon = True + self._reconnect_timer.start() + self._set_status(RRCHub.STATUS_DISCONNECTED, "Reconnect in "+str(int(backoff))+"s") + + + def disconnect(self): + self._stop_hello.set() + with self._lock: + self._manual_disconnect = True + self._reconnect_attempts = 0 + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + link = self.link + self.link = None + if link is not None: + try: link.teardown() + except Exception: pass + self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") + + def set_auto_reconnect(self, enabled, save=True): + with self._lock: + self.auto_reconnect = bool(enabled) + if not enabled and self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + if save: + self.manager.save() + self.manager._notify_change(self) + + def set_auto_list(self, enabled, save=True): + with self._lock: + self.auto_list = bool(enabled) + if save: + self.manager.save() + self.manager._notify_change(self) + + def set_auto_who(self, enabled, save=True): + with self._lock: + self.auto_who = bool(enabled) + if save: + self.manager.save() + self.manager._notify_change(self) + + def get_effective_nick(self): + if isinstance(self.nick_override, str) and self.nick_override: + return self.nick_override + return self.manager.get_nickname() + + def set_nick_override(self, nick): + with self._lock: + if nick is None or (isinstance(nick, str) and nick == ""): + self.nick_override = None + else: + self.nick_override = str(nick) + self.manager.save() + self.manager._notify_change(self) + + def _packet_would_fit(self, link, payload): + try: + pkt = RNS.Packet(link, payload) + pkt.pack() + return True + except Exception: + return False + + def _send_env(self, env): + with self._lock: + link = self.link + if link is None or link.status != RNS.Link.ACTIVE: + raise RuntimeError("not connected") + payload = cbor.encode(env) + if not self._packet_would_fit(link, payload): + raise RuntimeError("message exceeds link MTU") + RNS.Packet(link, payload).send() + + def join_room(self, room, key=None, silent=False): + r = self._normalize_room(room) + body = key if (isinstance(key, str) and key) else None + env = _make_envelope(T_JOIN, src=self.manager.identity.hash, room=r, body=body) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + with self._lock: + self._pending_joins.add(r) + if silent: + self._silent_joins.add(r) + self._send_env(env) + with self._lock: + if r not in self.messages: + self.messages[r] = [] + self.manager._notify_change(self) + + def send_command(self, text, room=None): + if not isinstance(text, str) or not text.startswith("/"): + raise ValueError("command must start with /") + env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=room, body=text) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + self._send_env(env) + + def send_ping(self, room=None): + body = os.urandom(8) + env = _make_envelope(T_PING, src=self.manager.identity.hash, body=body) + with self._lock: + now_ms = _now_ms() + self._pending_pings[body] = (now_ms, room) + expired = [k for k, v in self._pending_pings.items() if now_ms - v[0] > 15000] + for k in expired: + self._pending_pings.pop(k, None) + self._send_env(env) + return body + + def part_room(self, room): + room_n = self._normalize_room(room) + env = _make_envelope(T_PART, src=self.manager.identity.hash, room=room_n) + with self._lock: + self._pending_parts.add(room_n) + try: + self._send_env(env) + except Exception: + pass + with self._lock: + self.rooms.discard(room_n) + self.manager.save() + self.manager._notify_change(self) + + def send_message(self, room, text): + r = self._normalize_room(room) + if not isinstance(text, str) or not text.strip(): + raise ValueError("message text must be non-empty") + if len(text.encode("utf-8")) > self.max_msg_body_bytes: + raise ValueError("message too long for hub limit") + env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=r, body=text) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + mid = env[K_ID] + if isinstance(mid, (bytes, bytearray)): + self._sent_ids.append(bytes(mid)) + self._send_env(env) + self._record_message(RRCMessage("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) + return mid + + def send_action(self, room, text): + r = self._normalize_room(room) + if not isinstance(text, str) or not text.strip(): + raise ValueError("action text must be non-empty") + if len(text.encode("utf-8")) > self.max_msg_body_bytes: + raise ValueError("action too long for hub limit") + env = _make_envelope(T_ACTION, src=self.manager.identity.hash, room=r, body=text) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + mid = env[K_ID] + if isinstance(mid, (bytes, bytearray)): + self._sent_ids.append(bytes(mid)) + self._send_env(env) + self._record_message(RRCMessage("action", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) + return mid + + def _per_room_cap(self): + try: + v = int(getattr(self.manager.app, "rrc_history_per_room_cap", 0)) + except Exception: + return None + return v if v > 0 else None + + def _filter_history(self): + try: v = getattr(self.manager.app, "rrc_filter_loaded_history", True) + except Exception: return True + return v + + def _ephemeral_notices_history(self): + try: v = getattr(self.manager.app, "rrc_ephemeral_notices", self.SYS_NOTICE_TIMEOUT) + except Exception: return self.SYS_NOTICE_TIMEOUT + return v + + def _entry_for(self, msg): + return { + H_KIND: msg.kind, + H_SRC: bytes(msg.src) if isinstance(msg.src, (bytes, bytearray)) else None, + H_NICK: msg.nick if isinstance(msg.nick, str) else None, + H_TEXT: msg.text if isinstance(msg.text, str) else "", + H_TS: int(msg.ts) if isinstance(msg.ts, int) else _now_ms(), + H_MENTION: bool(getattr(msg, "mention", False)), + } + + def _msg_from_entry(self, room, entry): + if not isinstance(entry, dict): + return None + m = RRCMessage( + entry.get(H_KIND) if isinstance(entry.get(H_KIND), str) else "msg", + room, + entry.get(H_SRC) if isinstance(entry.get(H_SRC), (bytes, bytearray)) else None, + entry.get(H_NICK) if isinstance(entry.get(H_NICK), str) else None, + entry.get(H_TEXT) if isinstance(entry.get(H_TEXT), str) else "", + entry.get(H_TS) if isinstance(entry.get(H_TS), int) else 0, + ) + m.mention = bool(entry.get(H_MENTION, False)) + return m + + def _persistable_room(self, room): + return isinstance(room, str) and room and room != "*" + + def _append_history(self, room, msg): + if not self._persistable_room(room): + return + try: + self.manager._ensure_history_dir(self) + path = self.manager._history_path(self, room) + payload = cbor.encode(self._entry_for(msg)) + with self._history_io_lock: + with open(path, "ab") as f: + f.write(payload) + self._history_write_failed = False + except Exception as e: + if not self._history_write_failed: + self._history_write_failed = True + self._log("history persistence failed, suppressing further warnings until recovery: "+str(e), RNS.LOG_ERROR) + + def _delete_history(self, room): + if not self._persistable_room(room): + return + path = self.manager._history_path(self, room) + try: + with self._history_io_lock: + if os.path.isfile(path): + os.unlink(path) + except Exception: + pass + + def _load_history(self): + with self._lock: + rooms = list(self.messages.keys()) + filter_msgs = self._filter_history() + for room in rooms: + if not self._persistable_room(room): + continue + path = self.manager._history_path(self, room) + if not os.path.isfile(path): + continue + window = deque(maxlen=self._per_room_cap()) + decode_error = None + try: + with open(path, "rb") as f: + while True: + try: + entry = cbor.load(f) + except EOFError: + break + except Exception as ex: + decode_error = ex + break + m = self._msg_from_entry(room, entry) + if m is None: + continue + if filter_msgs and (m.kind == "system" or m.kind == "notice"): + continue + window.append(m) + except OSError as ex: + self._log("history load failed for #"+room+": "+str(ex), RNS.LOG_ERROR) + continue + if decode_error is not None: + self._log("history file for #"+room+" is corrupt, truncating to last "+str(len(window))+" valid messages: "+str(decode_error), RNS.LOG_ERROR) + with self._lock: + self.messages[room] = list(window) + + def _clean_history(self): + now = time.time() + cleaned = False + remove_after = self._ephemeral_notices_history() + if now > self._last_history_clean + self.CLEAN_HISTORY_INTERVAL: + RNS.log(f"Cleaning loaded message history", RNS.LOG_DEBUG) + with self._lock: + try: + for r in self.messages: + old = set() + for m in self.messages[r]: + age = now-m.ts/1000.0 + should_filter = False + if m.kind == "system": should_filter = True + elif m.kind == "notice": should_filter = True + if should_filter and age > remove_after: old.add(m) + + for m in old: + self.messages[r].remove(m) + cleaned = True + + except Exception as e: RNS.trace_exception(e) + + self._last_history_clean = time.time() + if cleaned: self.clean_last_removed = time.time() + + def _record_message(self, msg, local=False): + cap = self._per_room_cap() + with self._lock: + buf = self.messages.setdefault(msg.room or "*", []) + buf.append(msg) + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] + if not local and msg.room: + if msg.room != self.manager.active_room_for(self): + self.unread_rooms.add(msg.room) + if msg.mention: + self.mention_rooms.add(msg.room) + self.manager._notify_messages(self, msg) + self._append_history(msg.room, msg) + self._clean_history() + + def _record_system(self, room, text): + if not room: + return + msg = RRCMessage("system", room, None, None, text, _now_ms()) + cap = self._per_room_cap() + with self._lock: + buf = self.messages.setdefault(room, []) + buf.append(msg) + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] + self.manager._notify_messages(self, msg) + self._append_history(room, msg) + self._clean_history() + + def _record_notice(self, msg): + target_room = msg.room + if not target_room: + target_room = self.manager.active_room_for(self) + if target_room: + msg.room = target_room + + cap = self._per_room_cap() + with self._lock: + self.notices.append(msg) + if len(self.notices) > 200: + del self.notices[:len(self.notices)-200] + if target_room: + buf = self.messages.setdefault(target_room, []) + buf.append(msg) + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] + if target_room != self.manager.active_room_for(self): + self.unread_rooms.add(target_room) + self.manager._notify_messages(self, msg) + if target_room: + self._append_history(target_room, msg) + self._clean_history() + + def _process_notice_text(self, text): + # Parse hub service notices (/list and /who replies) regardless of + # whether they arrived as a packet or a resource transfer. Returns + # True when the notice was consumed silently (auto /list or /who, + # 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): + if take_lock: + with self._lock: buf = list(self.messages.get(room, [])) + else: buf = list(self.messages.get(room, [])) + + return buf + + def _on_packet(self, data): + try: + env = cbor.decode(data) + except Exception as e: + self._log("decode failed: "+str(e), RNS.LOG_DEBUG) + return + if not isinstance(env, dict): + return + try: + t = env.get(K_T) + except Exception: + return + + if t == T_PING: + try: + pong = _make_envelope(T_PONG, src=self.manager.identity.hash, body=env.get(K_BODY)) + self._send_env(pong) + except Exception: + pass + return + + if t == T_PONG: + body = env.get(K_BODY) + if isinstance(body, (bytes, bytearray)): + key = bytes(body) + with self._lock: + pending = self._pending_pings.pop(key, None) + if pending is not None: + sent_ms, room = pending + rtt_ms = max(0, _now_ms() - sent_ms) + self._record_system(room, "Pong from hub: "+str(rtt_ms)+" ms") + return + + if t == T_WELCOME: + self.welcomed = True + body = env.get(K_BODY) + if isinstance(body, dict): + hub_name = body.get(B_WELCOME_HUB) + if isinstance(hub_name, str): + self.hub_name = strip_modifiers(hub_name) + ver = body.get(B_WELCOME_VER) + if isinstance(ver, str): + self.hub_version = ver + caps = body.get(B_WELCOME_CAPS) + if isinstance(caps, dict): + self.hub_caps = dict(caps) + limits = body.get(B_WELCOME_LIMITS) + if isinstance(limits, dict): + if L_MAX_NICK_BYTES in limits: + self.max_nick_bytes = int(limits[L_MAX_NICK_BYTES]) + if L_MAX_ROOM_NAME_BYTES in limits: + self.max_room_name_bytes = int(limits[L_MAX_ROOM_NAME_BYTES]) + if L_MAX_MSG_BODY_BYTES in limits: + self.max_msg_body_bytes = int(limits[L_MAX_MSG_BODY_BYTES]) + if L_MAX_ROOMS_PER_SESSION in limits: + self.max_rooms_per_session = int(limits[L_MAX_ROOMS_PER_SESSION]) + if L_RATE_LIMIT_MSGS_PER_MINUTE in limits: + self.rate_limit_msgs_per_minute = int(limits[L_RATE_LIMIT_MSGS_PER_MINUTE]) + self._set_status(RRCHub.STATUS_CONNECTED, "Connected") + with self._lock: + self._reconnect_attempts = 0 + self.manager._on_welcome(self) + if self.auto_list: + try: + with self._lock: + self._silent_list_pending += 1 + self.send_command("/list", room=None) + except Exception: + with self._lock: + if self._silent_list_pending > 0: + self._silent_list_pending -= 1 + return + + if t == T_JOINED: + room = env.get(K_ROOM) + if isinstance(room, str) and room: + r = room.strip().lower() + body = env.get(K_BODY) + joiner_nick = env.get(K_NICK) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + + body_hashes = [] + if isinstance(body, list): + body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))] + + with self._lock: + self_join = r in self._pending_joins + silent = r in self._silent_joins + if self_join: + self._pending_joins.discard(r) + if silent: + self._silent_joins.discard(r) + + self.rooms.add(r) + if r not in self.messages: + self.messages[r] = [] + members = self.members.setdefault(r, set()) + for h in body_hashes: + members.add(h) + self._upgrade_prefix_member(r, h) + if own_hash is not None: + members.add(own_hash) + self._upgrade_prefix_member(r, own_hash) + + # rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it + # so display_name_for() can render the nick instead of a hash prefix. + if (not self_join) and isinstance(joiner_nick, str) and joiner_nick and len(body_hashes) == 1: + jh = body_hashes[0] + if own_hash is None or jh != own_hash: + self.nicks[jh] = joiner_nick + + if self_join: + if not silent: + self._record_system(r, "You joined #"+r) + if self.auto_who: + try: + with self._lock: + self._silent_who_rooms.add(r) + self.send_command("/who "+r, room=r) + except Exception: + with self._lock: + self._silent_who_rooms.discard(r) + self.manager.save() + else: + joiner = None + if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash): + joiner = body_hashes[0] + if joiner is not None: + self._record_system(r, self.display_name_for(joiner)+" joined") + self.manager._notify_change(self) + return + + if t == T_PARTED: + room = env.get(K_ROOM) + if isinstance(room, str) and room: + r = room.strip().lower() + body = env.get(K_BODY) + parter_nick = env.get(K_NICK) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + + body_hashes = [] + if isinstance(body, list): + body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))] + + with self._lock: + self_part = r in self._pending_parts + if self_part: + self._pending_parts.discard(r) + + # rrcd 0.3.2 attaches advisory K_NICK to fanout PARTED; learn it + # before forgetting the member so " left" renders correctly. + if (not self_part) and isinstance(parter_nick, str) and parter_nick and len(body_hashes) == 1: + ph = body_hashes[0] + if own_hash is None or ph != own_hash: + self.nicks[ph] = parter_nick + + members = self.members.get(r) + pset = self.prefix_members.get(r) + if members is not None: + for h in body_hashes: + members.discard(h) + if pset: + for p in list(pset): + if h.startswith(p): + pset.discard(p) + self.nicks.pop(p, None) + if self_part: + self.rooms.discard(r) + self.members.pop(r, None) + self.prefix_members.pop(r, None) + + if self_part: + self.manager.save() + else: + parter = None + if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash): + parter = body_hashes[0] + if parter is not None: + self._record_system(r, self.display_name_for(parter)+" left") + self.manager._notify_change(self) + return + + if t == T_MSG: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + nick = env.get(K_NICK) + mid = env.get(K_ID) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash: + if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids: + return + if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick: + with self._lock: + self.nicks[bytes(src)] = nick + if isinstance(room, str) and room: + rn = room.strip().lower() + self.members.setdefault(rn, set()).add(bytes(src)) + self._upgrade_prefix_member(rn, bytes(src)) + if isinstance(body, str): + msg = RRCMessage( + "msg", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + nick if isinstance(nick, str) else None, + body, + _now_ms(), + ) + is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + if not is_own: + own_nick = self.get_effective_nick() + pat = _mention_re(own_nick) + if pat is not None and pat.search(body): + msg.mention = True + self._record_message(msg) + return + + if t == T_ACTION: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + nick = env.get(K_NICK) + mid = env.get(K_ID) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash: + if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids: + return + if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick: + with self._lock: + self.nicks[bytes(src)] = nick + if isinstance(room, str) and room: + rn = room.strip().lower() + self.members.setdefault(rn, set()).add(bytes(src)) + self._upgrade_prefix_member(rn, bytes(src)) + if isinstance(body, str): + msg = RRCMessage( + "action", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + nick if isinstance(nick, str) else None, + body, + _now_ms(), + ) + is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + if not is_own: + own_nick = self.get_effective_nick() + pat = _mention_re(own_nick) + if pat is not None and pat.search(body): + msg.mention = True + self._record_message(msg) + return + + if t == T_NOTICE: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + if isinstance(body, str): + #################### HACK ZONE ##################### + # This is the kind of crazy mess that happens when + # an LLM designs a chat protocol, and the client + # maintainer is too open-minded, decides to go with + # it, and now is responsible for the fundamental + # architectural chaos. + # + # Yes, I love RRC, but trying to figure out an + # elegant way to solve the underlying issues that + # make this kind of mess necessary revealed the + # following answer: There is none. + # + # There are so many bugs, weird cases, architectural + # mismatches, impossible situations and general + # weirdness here, that I am just not going to go and + # attempt to sort it all out. Sorry, I don't have the + # time, so a hack like this is what it will be. This + # makes things actually work semi-expectedly, even + # over low-bandwidth links and such, so hacky fix it + # is. Until we have something better, this will do + # just fine. On a related note, I was considering + # taking up maintenance of rrcd, now that it seems to + # not really be worked on or updated anymore, but + # looking at the entire system, I think it's better + # left as it; it works fine, albeit with a few quirks + # for it's purpose. My future work in chat-related + # protocols on Reticulum will be focused on a better + # architecture, incorporating everything we learned + # from RRC, and making it Reticulum-native from the + # start, instead of mirroring IRC too closely. + # - Mark + was_parsed = self._process_notice_text(body) + room_n = room.strip().lower() if isinstance(room, str) else None + if room_n is None and isinstance(body, str) and body.strip(): + probably_motd = True + if was_parsed: probably_motd = False + if body.lstrip().lower().startswith("registered public rooms:"): probably_motd = False; was_parsed = True + if body.lstrip().lower().startswith("members in "): probably_motd = False; was_parsed = True + if probably_motd: + with self._lock: self.motd = strip_modifiers(body) + was_parsed = False + self.manager._notify_change(self) + msg = RRCMessage( + "notice", + room_n, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + None, + body, + _now_ms(), + ) + if not was_parsed: self._record_notice(msg) + return + + if t == T_ERROR: + body = env.get(K_BODY) + room = env.get(K_ROOM) + text = body if isinstance(body, str) else "(error)" + r = room.strip().lower() if isinstance(room, str) else None + rollback_join = False + if r: + with self._lock: + if r in self._pending_joins: + rollback_join = True + self._pending_joins.discard(r) + self._silent_joins.discard(r) + self._pending_parts.discard(r) + if rollback_join: + self.rooms.discard(r) + if rollback_join: + self.manager.save() + msg = RRCMessage( + "error", + r, + None, + None, + text, + _now_ms(), + ) + self._record_notice(msg) + return + + if t == T_RESOURCE_ENVELOPE: + body = env.get(K_BODY) + if not isinstance(body, dict): + return + try: + rid = body.get(B_RES_ID) + kind = body.get(B_RES_KIND) + size = body.get(B_RES_SIZE) + sha256 = body.get(B_RES_SHA256) + encoding = body.get(B_RES_ENCODING) + if not isinstance(rid, (bytes, bytearray)): return + if not isinstance(kind, str): return + if not isinstance(size, int) or size <= 0: return + room = env.get(K_ROOM) + with self._lock: + self._resource_expectations[bytes(rid)] = { + "kind": kind, + "size": size, + "sha256": bytes(sha256) if isinstance(sha256, (bytes, bytearray)) else None, + "encoding": encoding if isinstance(encoding, str) else "utf-8", + "room": room.strip().lower() if isinstance(room, str) else None, + "expires": time.monotonic()+30.0, + } + except Exception: + pass + return + + def _resource_advertised(self, resource): + try: + if hasattr(resource, "get_data_size"): + size = resource.get_data_size() + elif hasattr(resource, "total_size"): + size = resource.total_size + else: + size = getattr(resource, "size", 0) + except Exception: + return False + try: + max_size = int(getattr(self.manager.app, "rrc_max_accepted_resource_size", 262144)) + except Exception: + max_size = 262144 + if max_size <= 0 or size > max_size: + self._log("rejecting resource transfer of "+str(size)+" bytes (max accepted is "+str(max_size)+" bytes)", RNS.LOG_DEBUG) + return False + return True + + def _resource_concluded(self, resource): + try: + if resource.status != RNS.Resource.COMPLETE: + try: + if hasattr(resource, "data") and resource.data: + resource.data.close() + except Exception: + pass + return + try: + size = resource.total_size if hasattr(resource, "total_size") else getattr(resource, "size", 0) + except Exception: + size = 0 + data = None + try: + data = resource.data.read() + finally: + try: + if hasattr(resource, "data") and resource.data: + resource.data.close() + except Exception: + pass + if data is None: + return + + now = time.monotonic() + matched = None + with self._lock: + expired = [k for k, v in self._resource_expectations.items() if v["expires"] < now] + for k in expired: + self._resource_expectations.pop(k, None) + for k, exp in list(self._resource_expectations.items()): + if exp["size"] == len(data): + matched = exp + self._resource_expectations.pop(k, None) + break + + kind = matched["kind"] if matched else RES_KIND_BLOB + room = matched["room"] if matched else None + encoding = matched["encoding"] if matched else "utf-8" + sha = matched["sha256"] if matched else None + if sha is not None: + if hashlib.sha256(data).digest() != sha: + return + if kind in (RES_KIND_NOTICE, RES_KIND_MOTD): + try: + text = data.decode(encoding, errors="replace") + except Exception: + return + if kind == RES_KIND_MOTD: + with self._lock: + self.motd = strip_modifiers(text) + self.manager._notify_change(self) + elif self._process_notice_text(text): + return + msg = RRCMessage("notice", room, None, None, text, _now_ms()) + self._record_notice(msg) + except Exception as e: + self._log("resource handling failed: "+str(e), RNS.LOG_ERROR) + + +class RRCManager: + def __init__(self, app): + self.app = app + self.hubs = [] + self._lock = threading.RLock() + self._change_callback = None + self._message_callback = None + self._active_hub = None + self._active_room = None + self._loaded = False + self._loading = False + self._save_lock = threading.Lock() + + @property + def identity(self): + return self.app.identity + + def get_nickname(self): + try: + n = self.app.peer_settings.get("display_name") + if isinstance(n, str): + return n + except Exception: + pass + return None + + def set_change_callback(self, cb): + self._change_callback = cb + + def set_message_callback(self, cb): + self._message_callback = cb + + def _notify_change(self, hub=None): + try: + if self._change_callback is not None: + self._change_callback(hub) + except Exception: + pass + + def _notify_messages(self, hub, msg): + try: + if self._message_callback is not None: + self._message_callback(hub, msg) + except Exception: + pass + + def _on_welcome(self, hub): + for r in list(hub.rooms): + try: + hub.join_room(r, silent=True) + except Exception: + pass + + def set_active(self, hub, room): + self._active_hub = hub + self._active_room = room + if hub is not None and room is not None: + hub.mark_read(room) + + def active_room_for(self, hub): + if self._active_hub is hub: + return self._active_room + return None + + def has_unread(self): + with self._lock: + for hub in self.hubs: + if hub.unread_rooms: + return True + return False + + def add_hub(self, hub_hash, dest_name=None, name=None): + with self._lock: + for h in self.hubs: + if h.hub_hash == hub_hash and (h.dest_name == (dest_name or DEFAULT_DEST_NAME)): + return h + hub = RRCHub(self, hub_hash, dest_name=dest_name, name=name) + self.hubs.append(hub) + self.save() + self._notify_change() + return hub + + def remove_hub(self, hub): + with self._lock: + if hub in self.hubs: + self.hubs.remove(hub) + try: + hub.disconnect() + except Exception: + pass + self.save() + self._notify_change() + + def find_hub(self, hub_hash, dest_name=None): + dn = dest_name or DEFAULT_DEST_NAME + with self._lock: + for h in self.hubs: + if h.hub_hash == hub_hash and h.dest_name == dn: + return h + return None + + def _store_path(self): + return os.path.join(self.app.storagepath, "rrc_hubs") + + def _history_root(self): + return os.path.join(self.app.storagepath, HISTORY_DIR_NAME) + + def _history_dir(self, hub): + hub_key = hub.hub_hash.hex() + if hub.dest_name and hub.dest_name != DEFAULT_DEST_NAME: + suffix = hashlib.sha256(hub.dest_name.encode("utf-8")).hexdigest()[:8] + hub_key = hub_key + "__" + suffix + return os.path.join(self._history_root(), hub_key) + + def _history_path(self, hub, room): + sanitized = HISTORY_FILENAME_SANITIZE_RE.sub("_", room or "")[:64] + room_hash = hashlib.sha256((room or "").encode("utf-8")).hexdigest()[:8] + filename = (sanitized + "_" + room_hash + ".log") if sanitized else (room_hash + ".log") + return os.path.join(self._history_dir(hub), filename) + + def _ensure_history_dir(self, hub): + d = self._history_dir(hub) + os.makedirs(d, exist_ok=True) + return d + + def load(self): + if self._loaded: + return + self._loaded = True + path = self._store_path() + if not os.path.isfile(path): + return + self._loading = True + try: + with open(path, "rb") as f: + data = f.read() + obj = cbor.decode(data) + if not isinstance(obj, dict): + return + entries = obj.get("hubs") + if not isinstance(entries, list): + return + for e in entries: + if not isinstance(e, dict): + continue + hh = e.get("hash") + if not isinstance(hh, (bytes, bytearray)): + continue + dn = e.get("dest_name") + nm = e.get("name") + hub = self.add_hub(bytes(hh), dest_name=dn if isinstance(dn, str) else None, name=nm if isinstance(nm, str) else None) + rooms = e.get("rooms") + if isinstance(rooms, list): + for r in rooms: + if isinstance(r, str): + hub.add_room(r) + parted = e.get("parted_rooms") + if isinstance(parted, list): + for r in parted: + if isinstance(r, str): + try: + rn = hub._normalize_room(r) + with hub._lock: + hub.messages.setdefault(rn, []) + except Exception: + pass + ar = e.get("auto_reconnect") + if isinstance(ar, bool): + hub.auto_reconnect = ar + al = e.get("auto_list") + if isinstance(al, bool): + hub.auto_list = al + aw = e.get("auto_who") + if isinstance(aw, bool): + hub.auto_who = aw + no = e.get("nick") + if isinstance(no, str) and no: + hub.nick_override = no + try: + hub._load_history() + except Exception as ex: + RNS.log("Failed to load RRC history for "+hub.name+": "+str(ex), RNS.LOG_ERROR) + except Exception as e: + RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR) + finally: + self._loading = False + + def save(self): + if self._loading: + return + path = self._store_path() + tmp_path = path + ".tmp" + with self._save_lock: + try: + entries = [] + with self._lock: + for h in self.hubs: + joined = set(h.rooms) + parted = set(h.messages.keys()) - joined + entry = { + "hash": h.hub_hash, + "dest_name": h.dest_name, + "name": h.name, + "rooms": sorted(joined), + "parted_rooms": sorted(parted), + "auto_reconnect": bool(h.auto_reconnect), + "auto_list": bool(h.auto_list), + "auto_who": bool(h.auto_who), + } + if isinstance(h.nick_override, str) and h.nick_override: + entry["nick"] = h.nick_override + entries.append(entry) + data = cbor.encode({"hubs": entries}) + with open(tmp_path, "wb") as f: + f.write(data) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + os.replace(tmp_path, path) + except Exception as e: + # + # + # + # + try: os.unlink(tmp_path) + except Exception: pass + + def shutdown(self): + for h in list(self.hubs): + try: + h.disconnect() + except Exception: + pass diff --git a/nomadnet/__init__.py b/nomadnet/__init__.py index 8757b04..eb90424 100644 --- a/nomadnet/__init__.py +++ b/nomadnet/__init__.py @@ -8,8 +8,10 @@ from .Node import Node from .ui import * -modules = glob.glob(os.path.dirname(__file__)+"/*.py") -__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) def panic(): os._exit(255) \ No newline at end of file diff --git a/nomadnet/_version.py b/nomadnet/_version.py index c11f861..c0f285b 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "0.1.9" +__version__ = "1.4.4" diff --git a/nomadnet/examples/messageboard/README.md b/nomadnet/examples/messageboard/README.md new file mode 100644 index 0000000..16e35e0 --- /dev/null +++ b/nomadnet/examples/messageboard/README.md @@ -0,0 +1,18 @@ +# lxmf_messageboard +Simple message board that can be hosted on a NomadNet node, messages can be posted by 'conversing' with a unique peer, all messages are then forwarded to the message board. + +## How Do I Use It? +A user can submit messages to the message board by initiating a chat with the message board peer, they are assigned a username (based on the first 5 characters of their address) and their messages are added directly to the message board. The message board can be viewed on a page hosted by a NomadNet node. + +An example message board can be found on the reticulum testnet hosted on the SolarExpress Node `` and the message board peer `` + +## How Does It Work? +The message board page itself is hosted on a NomadNet node, you can place the message_board.mu into the pages directory. You can then run the message_board.py script which provides the peer that the users can send messages to. The two parts are joined together using umsgpack and a flat file system similar to NomadNet and Reticulum and runs in the background. + +## How Do I Set It Up? +* Turn on node hosting in NomadNet +* Put the `message_board.mu` file into `pages` directory in the config file for `NomadNet`. Edit the file to customise from the default page. +* Run the `message_board.py` script (`python3 message_board.py` either in a `screen` or as a system service), this script uses `NomadNet` and `RNS` libraries and has no additional libraries that need to be installed. Take a note of the message boards address, it is printed on starting the board, you can then place this address in `message_board.mu` file to make it easier for users to interact the board. + +## Credits +* This example application was written and contributed by @chengtripp \ No newline at end of file diff --git a/nomadnet/examples/messageboard/messageboard.mu b/nomadnet/examples/messageboard/messageboard.mu new file mode 100644 index 0000000..24993d0 --- /dev/null +++ b/nomadnet/examples/messageboard/messageboard.mu @@ -0,0 +1,41 @@ +#!/bin/python3 +import time +import os +import RNS.vendor.umsgpack as msgpack + +message_board_peer = 'please_replace' +userdir = os.path.expanduser("~") + +if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"): + configdir = "/etc/nomadmb" +elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"): + configdir = userdir+"/.config/nomadmb" +else: + configdir = userdir+"/.nomadmb" + +storagepath = configdir+"/storage" +if not os.path.isdir(storagepath): + os.makedirs(storagepath) + +boardpath = configdir+"/storage/board" + +print('`!`F222`Bddd`cNomadNet Message Board') + +print('-') +print('`a`b`f') +print("") +print("To add a message to the board just converse with the NomadNet Message Board at `[lxmf@{}]".format(message_board_peer)) +time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) +print("Last Updated: {}".format(time_string)) +print("") +print('>Messages') +print(" Date Time Username Message") +f = open(boardpath, "rb") +board_contents = msgpack.unpack(f) +board_contents.reverse() + +for content in board_contents: + print("`a{}".format(content.rstrip())) + print("") + +f.close() diff --git a/nomadnet/examples/messageboard/messageboard.py b/nomadnet/examples/messageboard/messageboard.py new file mode 100644 index 0000000..8a71cd1 --- /dev/null +++ b/nomadnet/examples/messageboard/messageboard.py @@ -0,0 +1,187 @@ +# Simple message board that can be hosted on a NomadNet node, messages can be posted by 'conversing' with a unique peer, all messages are then forwarded to the message board. +# https://github.com/chengtripp/lxmf_messageboard + +import RNS +import LXMF +import os, time +from queue import Queue +import RNS.vendor.umsgpack as msgpack + +display_name = "NomadNet Message Board" +max_messages = 20 + +def setup_lxmf(): + if os.path.isfile(identitypath): + identity = RNS.Identity.from_file(identitypath) + RNS.log('Loaded identity from file', RNS.LOG_INFO) + else: + RNS.log('No Primary Identity file found, creating new...', RNS.LOG_INFO) + identity = RNS.Identity() + identity.to_file(identitypath) + + return identity + +def lxmf_delivery(message): + # Do something here with a received message + RNS.log("A message was received: "+str(message.content.decode('utf-8'))) + + message_content = message.content.decode('utf-8') + source_hash_text = RNS.hexrep(message.source_hash, delimit=False) + + #Create username (just first 5 char of your addr) + username = source_hash_text[0:5] + + RNS.log('Username: {}'.format(username), RNS.LOG_INFO) + + time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) + new_message = '{} {}: {}\n'.format(time_string, username, message_content) + + # Push message to board + # First read message board (if it exists + if os.path.isfile(boardpath): + f = open(boardpath, "rb") + message_board = msgpack.unpack(f) + f.close() + else: + message_board = [] + + #Check we aren't doubling up (this can sometimes happen if there is an error initially and it then gets fixed) + if new_message not in message_board: + # Append our new message to the list + message_board.append(new_message) + + # Prune the message board if needed + while len(message_board) > max_messages: + RNS.log('Pruning Message Board') + message_board.pop(0) + + # Now open the board and write the updated list + f = open(boardpath, "wb") + msgpack.pack(message_board, f) + f.close() + + # Send reply + message_reply = '{}_{}_Your message has been added to the messageboard'.format(source_hash_text, time.time()) + q.put(message_reply) + +def announce_now(lxmf_destination): + lxmf_destination.announce() + +def send_message(destination_hash, message_content): + try: + # Make a binary destination hash from a hexadecimal string + destination_hash = bytes.fromhex(destination_hash) + + except Exception as e: + RNS.log("Invalid destination hash", RNS.LOG_ERROR) + return + + # Check that size is correct + if not len(destination_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + RNS.log("Invalid destination hash length", RNS.LOG_ERROR) + + else: + # Length of address was correct, let's try to recall the + # corresponding Identity + destination_identity = RNS.Identity.recall(destination_hash) + + if destination_identity == None: + # No path/identity known, we'll have to abort or request one + RNS.log("Could not recall an Identity for the requested address. You have probably never received an announce from it. Try requesting a path from the network first. In fact, let's do this now :)", RNS.LOG_ERROR) + RNS.Transport.request_path(destination_hash) + RNS.log("OK, a path was requested. If the network knows a path, you will receive an announce with the Identity data shortly.", RNS.LOG_INFO) + + else: + # We know the identity for the destination hash, let's + # reconstruct a destination object. + lxmf_destination = RNS.Destination(destination_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + + # Create a new message object + lxm = LXMF.LXMessage(lxmf_destination, local_lxmf_destination, message_content, title="Reply", desired_method=LXMF.LXMessage.DIRECT) + + # You can optionally tell LXMF to try to send the message + # as a propagated message if a direct link fails + lxm.try_propagation_on_fail = True + + # Send it + message_router.handle_outbound(lxm) + +def announce_check(): + if os.path.isfile(announcepath): + f = open(announcepath, "r") + announce = int(f.readline()) + f.close() + else: + RNS.log('failed to open announcepath', RNS.LOG_DEBUG) + announce = 1 + + if announce > int(time.time()): + RNS.log('Recent announcement', RNS.LOG_DEBUG) + else: + f = open(announcepath, "w") + next_announce = int(time.time()) + 1800 + f.write(str(next_announce)) + f.close() + announce_now(local_lxmf_destination) + RNS.log('Announcement sent, expr set 1800 seconds', RNS.LOG_INFO) + +#Setup Paths and Config Files +userdir = os.path.expanduser("~") + +if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"): + configdir = "/etc/nomadmb" +elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"): + configdir = userdir+"/.config/nomadmb" +else: + configdir = userdir+"/.nomadmb" + +storagepath = configdir+"/storage" +if not os.path.isdir(storagepath): + os.makedirs(storagepath) + +identitypath = configdir+"/storage/identity" +announcepath = configdir+"/storage/announce" +boardpath = configdir+"/storage/board" + +# Message Queue +q = Queue(maxsize = 5) + +# Start Reticulum and print out all the debug messages +reticulum = RNS.Reticulum(loglevel=RNS.LOG_VERBOSE) + +# Create a Identity. +current_identity = setup_lxmf() + +# Init the LXMF router +message_router = LXMF.LXMRouter(identity = current_identity, storagepath = configdir) + +# Register a delivery destination (for yourself) +# In this example we use the same Identity as we used +# to instantiate the LXMF router. It could be a different one, +# but it can also just be the same, depending on what you want. +local_lxmf_destination = message_router.register_delivery_identity(current_identity, display_name=display_name) + +# Set a callback for when a message is received +message_router.register_delivery_callback(lxmf_delivery) + +# Announce node properties + +RNS.log('LXMF Router ready to receive on: {}'.format(RNS.prettyhexrep(local_lxmf_destination.hash)), RNS.LOG_INFO) +announce_check() + +while True: + + # Work through internal message queue + for i in list(q.queue): + message_id = q.get() + split_message = message_id.split('_') + destination_hash = split_message[0] + message = split_message[2] + RNS.log('{} {}'.format(destination_hash, message), RNS.LOG_INFO) + send_message(destination_hash, message) + + # Check whether we need to make another announcement + announce_check() + + #Sleep + time.sleep(10) diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py new file mode 100644 index 0000000..87f4d87 --- /dev/null +++ b/nomadnet/examples/various/input_fields.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +import os +env_string = "" +for e in os.environ: + env_string += "{}={}\n".format(e, os.environ[e]) + +template = """>Fields and Submitting Data + +Nomad Network let's 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. This page contains a few examples. + +>> Read Environment Variables + +{@ENV} +>>Examples of Fields and Submissions + +The following section contains a simple set of fields, and a few different links that submit the field data in different ways. + +-= + + +>>>Text Fields +An input field : `B444``b + +An masked field : `B444``b + +An small field : `B444`<8|small`test>`b, and some more text. + +A multi-row field : `B444`<40x4|notes`>`b + +Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b + +The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. + +>> Checkbox Fields + +`B444``b Sign me up + +>> Radio group + +Select your favorite color: + +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue + + +>>> Submitting data + +You can `!`[submit`:/page/input_fields.mu`one|password|small|color]`! other fields, or just `!`[a single one`:/page/input_fields.mu`username]`! + +Or simply `!`[submit them all`:/page/input_fields.mu`*]`!. + +Submission links can also `!`[include pre-configured variables`:/page/input_fields.mu`username|two|entitiy_id=4611|action=view]`!. + +Or take all fields and `!`[pre-configured variables`:/page/input_fields.mu`*|entitiy_id=4611|action=view]`!. + +Or only `!`[pre-configured variables`:/page/input_fields.mu`entitiy_id=4688|task=something]`! + +-= + +""" +print(template.replace("{@ENV}", env_string)) \ No newline at end of file diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 34d9340..413972d 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -5,7 +5,9 @@ import os import platform import nomadnet +from nomadnet.ui import THEME_DARK, THEME_LIGHT from nomadnet.ui.textui import * +from nomadnet.ui.textui.images import ImageScreen from nomadnet import NomadNetworkApp COLORMODE_MONO = 1 @@ -13,8 +15,11 @@ COLORMODE_16 = 16 COLORMODE_88 = 88 COLORMODE_256 = 256 COLORMODE_TRUE = 2**24 -THEME_DARK = 0x01 -THEME_LIGHT = 0x02 + +IMGLOAD_NEVER = 0x00 +IMGLOAD_MANUAL = 0x01 +IMGLOAD_AUTO = 0x02 +IMGLOAD_ALWAYS = 0x03 THEMES = { THEME_DARK: { @@ -28,6 +33,7 @@ THEMES = { ("error_text", "dark red", "default", "default", "dark red", "default"), ("warning_text", "yellow", "default", "default", "#ba4", "default"), ("inactive_text", "dark gray", "default", "default", "dark gray", "default"), + ("browser_inactive", "dark gray", "default", "default", "#444", "default"), ("buttons", "light green,bold", "default", "default", "#00a533", "default"), ("msg_editor", "black", "light cyan", "standout", "#111", "#0bb"), ("msg_header_ok", "black", "light green", "standout", "#111", "#6b2"), @@ -37,6 +43,8 @@ THEMES = { ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), + ("msg_notice_unread", "light blue", "default", "standout", "#28b", "default"), + ("msg_notice_caution", "yellow", "default", "standout", "#fd3", "default"), ("list_focus", "black", "light gray", "standout", "#111", "#aaa"), ("list_off_focus", "black", "dark gray", "standout", "#111", "#777"), ("list_trusted", "dark green", "default", "default", "#6b2", "default"), @@ -45,11 +53,30 @@ THEMES = { ("list_normal", "dark gray", "default", "default", "#bbb", "default"), ("list_untrusted", "dark red", "default", "default", "#a22", "default"), ("list_focus_untrusted", "black", "light gray", "standout", "#810", "#aaa"), + ("list_unresponsive", "yellow", "default", "default", "#b92", "default"), + ("list_focus_unresponsive", "black", "light gray", "standout", "#530", "#aaa"), ("topic_list_normal", "light gray", "default", "default", "#ddd", "default"), ("browser_controls", "light gray", "default", "default", "#bbb", "default"), ("progress_full", "black", "light gray", "standout", "#111", "#bbb"), ("progress_empty", "light gray", "default", "default", "#ddd", "default"), - ], + ("interface_title", "", "", "default", "", ""), + ("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"), + ("disconnected_status", "dark red", "default", "default", "dark red", "default"), + ("disabled_status", "dark red", "default", "default", "dark red", "default"), + ("placeholder", "dark gray", "default", "default", "dark gray", "default"), + ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"), + ("error", "light red,blink", "default", "blink", "#f44,blink", "default"), + ("irc_ts", "dark gray", "default", "default", "#888", "default"), + ("irc_nick_self", "light green", "default", "default", "#6c5", "default"), + ("irc_nick_peer", "light cyan", "default", "default", "#3cd", "default"), + ("irc_notice", "yellow", "default", "default", "#fd3", "default"), + ("irc_error", "light red", "default", "default", "#f55", "default"), + ("irc_system", "dark gray", "default", "default", "#888", "default"), + ("irc_mention", "light red,bold", "default", "bold", "#fb4,bold", "default"), + + ], }, THEME_LIGHT: { "urwid_theme": [ @@ -67,9 +94,12 @@ THEMES = { ("msg_header_ok", "black", "dark green", "standout", "#111", "#6b2"), ("msg_header_caution", "black", "yellow", "standout", "#111", "#fd3"), ("msg_header_sent", "black", "dark gray", "standout", "#111", "#ddd"), + ("msg_header_propagated", "black", "light blue", "standout", "#111", "#28b"), ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), + ("msg_notice_unread", "dark blue", "default", "standout", "#069", "default"), + ("msg_notice_caution", "yellow", "default", "standout", "#fd3", "default"), ("list_focus", "black", "dark gray", "standout", "#111", "#aaa"), ("list_off_focus", "black", "dark gray", "standout", "#111", "#777"), ("list_trusted", "dark green", "default", "default", "#4a0", "default"), @@ -78,14 +108,49 @@ THEMES = { ("list_normal", "dark gray", "default", "default", "#444", "default"), ("list_untrusted", "dark red", "default", "default", "#a22", "default"), ("list_focus_untrusted", "black", "dark gray", "standout", "#810", "#aaa"), + ("list_unresponsive", "yellow", "default", "default", "#b92", "default"), + ("list_focus_unresponsive", "black", "light gray", "standout", "#530", "#aaa"), ("topic_list_normal", "dark gray", "default", "default", "#222", "default"), ("browser_controls", "dark gray", "default", "default", "#444", "default"), ("progress_full", "black", "dark gray", "standout", "#111", "#bbb"), ("progress_empty", "dark gray", "default", "default", "#ddd", "default"), - ], + ("interface_title", "dark gray", "default", "default", "#444", "default"), + ("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"), + ("interface_tile_focus", "dark blue,bold", "default", "bold", "#06c,bold", "default"), + ("connected_status", "dark green", "default", "default", "#4a0", "default"), + ("disconnected_status", "dark red", "default", "default", "#a22", "default"), + ("disabled_status", "dark red", "default", "default", "#a22", "default"), + ("placeholder", "light gray", "default", "default", "#999", "default"), + ("placeholder_text", "light gray", "default", "default", "#999", "default"), + ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), + ("irc_ts", "dark gray", "default", "default", "#888", "default"), + ("irc_nick_self", "dark green", "default", "default", "#3a0", "default"), + ("irc_nick_peer", "dark cyan", "default", "default", "#077", "default"), + ("irc_notice", "brown", "default", "default", "#a70", "default"), + ("irc_error", "dark red", "default", "default", "#a22", "default"), + ("irc_system", "dark gray", "default", "default", "#888", "default"), + ("irc_mention", "dark red,bold", "default", "bold", "#c50,bold", "default"), + ], } } +CONFIG_SECTION_COLORS = { + "logging": ("yellow", "#fd3", "brown", "#a80"), + "client": ("light cyan", "#3cd", "dark cyan", "#077"), + "textui": ("light green", "#6c5", "dark green", "#2a0"), + "utilities": ("light gray", "#bbb", "dark gray", "#555"), + "rrc": ("light magenta", "#d7f", "dark magenta", "#a2a"), + "node": ("light blue", "#5af", "dark blue", "#06c"), + "printing": ("brown", "#b86", "brown", "#852"), + "reticulum": ("light magenta", "#a6f", "dark magenta", "#63c"), +} + +for _section, (_d16, _dhi, _l16, _lhi) in CONFIG_SECTION_COLORS.items(): + THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section, _d16, "default", "default", _dhi, "default")) + THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section+"_selected", _d16+",bold", "default", "bold", _dhi+",bold", "default")) + THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section, _l16, "default", "default", _lhi, "default")) + THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section+"_selected", _l16+",bold", "default", "bold", _lhi+",bold", "default")) + GLYPHSETS = { "plain": 1, "unicode": 2, @@ -93,10 +158,10 @@ GLYPHSETS = { } if platform.system() == "Darwin": - urm_char = " \uf0e0 " + urm_char = " \uf0e0" ur_char = "\uf0e0 " else: - urm_char = " \uf003 " + urm_char = " \uf003" ur_char = "\uf003 " GLYPHS = { @@ -111,22 +176,55 @@ GLYPHS = { ("arrow_u", "/\\", "\u2191", "\u2191"), ("arrow_d", "\\/", "\u2193", "\u2193"), ("warning", "!", "\u26a0", "\uf12a"), - ("info", "i", "\u2139", "\ufb4d"), + ("info", "i", "\u2139", "\U000f064e"), ("unread", "[!]", "\u2709", ur_char), ("divider1", "-", "\u2504", "\u2504"), ("peer", "[P]", "\u24c5 ", "\uf415"), - ("node", "[N]", "\u24c3 ", "\uf502"), + ("node", "[N]", "\u24c3 ", "\U000f0002"), ("page", "", "\u25a4 ", "\uf719 "), - ("speed", "", "\u25F7 ", "\uf9c4"), - ("decoration_menu", " +", " +", " \uf93a"), + ("speed", "", "\u25F7 ", "\U000f04c5 "), + ("decoration_menu", " +", " +", " \U000f043b"), ("unread_menu", " !", " \u2709", urm_char), ("globe", "", "", "\uf484"), - ("sent", "/\\", "\u2191", "\ufbf4") + ("sent", "/\\", "\u2191", "\U000f0cd8"), + ("papermsg", "P", "\u25a4", "\uf719"), + ("qrcode", "QR", "\u25a4", "\uf029"), + ("selected", "[*] ", "\u25CF", "\u25CF"), + ("unselected", "[ ] ", "\u25CB", "\u25CB"), + ("focus_arrow", ">", "\u25B8", "\u25B8"), + ("sep_dot", "|", "\u00B7", "\u00B7"), + ("connected", "+", "\u2713", "\U000f0201"), + ("disconnected", "x", "\u2717", "\U000f0202"), + ("file", "[F]", "\u25a4", "\uf15b"), + ("image", "[I]", "\u25a3", "\uf1c5"), + ("audio", "[~]", "\u266b", "\uf1c7"), + ("pin", "*", "\u2605", "\uf08d"), + ("copy", "[C]", "\u29c9", "\uf0c5"), + ("folder", "[+]", "\u25b8", "\uf07b"), + ("folder_open", "[-]", "\u25be", "\uf07c"), + ("fold_open", "-", "▾", "▾"), + ("fold_closed", "+", "▸", "▸"), + ("dropdown", " [v]", " ▾", " "), } class TextUI: def __init__(self): + self.restore_ixon = False + + try: + rval = os.system("stty -a | grep \"\\-ixon\"") + if rval == 0: + pass + + else: + os.system("stty -ixon") + self.restore_ixon = True + + except Exception as e: + RNS.log("Could not configure terminal flow control sequences, some keybindings may not work.", RNS.LOG_WARNING) + RNS.log("The contained exception was: "+str(e)) + self.app = NomadNetworkApp.get_shared_instance() self.app.ui = self self.loop = None @@ -142,7 +240,7 @@ class TextUI: if self.app.config["textui"]["glyphs"] == "plain": glyphset = "plain" - elif self.app.config["textui"]["glyphs"] == "unicoode": + elif self.app.config["textui"]["glyphs"] == "unicode": glyphset = "unicode" elif self.app.config["textui"]["glyphs"] == "nerdfont": glyphset = "nerdfont" @@ -153,7 +251,7 @@ class TextUI: for glyph in GLYPHS: self.glyphs[glyph[0]] = glyph[GLYPHSETS[glyphset]] - self.screen = urwid.raw_display.Screen() + self.screen = ImageScreen() self.screen.register_palette(self.palette) self.main_display = Main.MainDisplay(self, self.app) @@ -164,7 +262,7 @@ class TextUI: else: initial_widget = self.main_display.widget - self.loop = urwid.MainLoop(initial_widget, screen=self.screen, handle_mouse=mouse_enabled) + self.loop = urwid.MainLoop(initial_widget, unhandled_input=self.unhandled_input, screen=self.screen, handle_mouse=mouse_enabled) if intro_timeout > 0: self.loop.set_alarm_in(intro_timeout, self.display_main) @@ -187,12 +285,21 @@ class TextUI: self.set_colormode(colormode) self.main_display.start() + if intro_timeout <= 0 and self.app.peer_settings_notice: self.main_display.show_notice(self.app.peer_settings_notice) self.loop.run() def set_colormode(self, colormode): self.colormode = colormode self.screen.set_terminal_properties(colormode) - self.screen.reset_default_terminal_palette() + + if self.colormode < 256: + self.screen.reset_default_terminal_palette() + self.restore_palette = True + + def unhandled_input(self, key): + if key == "ctrl q": self.main_display.quit() + elif key == "ctrl e": pass def display_main(self, loop, user_data): self.loop.widget = self.main_display.widget + if self.app.peer_settings_notice: self.main_display.show_notice(self.app.peer_settings_notice) diff --git a/nomadnet/ui/__init__.py b/nomadnet/ui/__init__.py index fbafc03..cb06cf8 100644 --- a/nomadnet/ui/__init__.py +++ b/nomadnet/ui/__init__.py @@ -3,9 +3,13 @@ import glob import RNS import nomadnet -modules = glob.glob(os.path.dirname(__file__)+"/*.py") -__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) +THEME_DARK = 0x01 +THEME_LIGHT = 0x02 UI_NONE = 0x00 UI_MENU = 0x01 diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 59578e1..ad38f7a 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -1,12 +1,29 @@ import RNS +import LXMF +import io import os import time +import bisect import urwid +import shutil import nomadnet import subprocess import threading -from .MicronParser import markup_to_attrmaps +from threading import Lock +from .MicronParser import markup_to_attrmaps, make_style, default_state +from nomadnet.Directory import DirectoryEntry +from nomadnet.Conversation import ConversationMessage from nomadnet.vendor.Scrollable import * +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name +from nomadnet.ui.textui.images import _termlib +from .Helpers import ClickableIcon, osc52_copy +from .ReadlineEdit import ReadlineMixin, ReadlineEdit + +IMGLOAD_NEVER = 0x00 +IMGLOAD_MANUAL = 0x01 +IMGLOAD_AUTO = 0x02 +IMGLOAD_ALWAYS = 0x03 class BrowserFrame(urwid.Frame): def keypress(self, size, key): @@ -20,17 +37,212 @@ class BrowserFrame(urwid.Frame): self.delegate.reload() elif key == "ctrl u": self.delegate.url_dialog() - elif self.get_focus() == "body": + elif key == "ctrl l": + self.delegate.load_images() + elif key == "ctrl x": + self.delegate.load_images(force_reload=True) + elif key == "ctrl s": + self.delegate.save_node_dialog() + elif key == "ctrl b": + self.delegate.save_node_dialog() + elif key == "ctrl g": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen() + elif key == "ctrl y": + if self.delegate.app.config["textui"]["clipboard_copy"]: + self.delegate.copy_url() + elif self.focus_position == "body": + if key == "down" or key == "up": + try: + if hasattr(self.delegate, "page_list") and self.delegate.page_list: + def df(loop, user_data): + st = None + if self.delegate.page_list: + nf = self.delegate.page_list.focus + if hasattr(nf, "key_timeout"): + st = nf + elif hasattr(nf, "original_widget"): + no = nf.original_widget + if hasattr(no, "original_widget"): + st = no.original_widget + else: + if hasattr(no, "key_timeout"): + st = no + + if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): + st.keypress(None, None) + + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.25, df) + + except Exception as e: + RNS.log("Error while setting up cursor timeout. The contained exception was: "+str(e), RNS.LOG_ERROR) + return super(BrowserFrame, self).keypress(size, key) - # if key == "up" and self.delegate.messagelist.top_is_visible: - # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") - # elif key == "down" and self.delegate.messagelist.bottom_is_visible: - # self.set_focus("footer") - # else: - # return super(ConversationFrame, self).keypress(size, key) + else: return super(BrowserFrame, self).keypress(size, key) +class PageList(urwid.ListBox): + def __init__(self, body): + super().__init__(body) + self._page_size = (0, 0) + self._page_focus = None + self._page_layouts = {} + + def invalidate_layout(self): + self._page_layouts = {} + self._invalidate() + + def _layout(self, maxcol): + 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: DEFAULT_PATH = "/page/index.mu" DEFAULT_TIMEOUT = 10 @@ -57,16 +269,25 @@ class Browser: self.aspects = aspects self.destination_hash = destination_hash self.path = path + self.request_data = None self.timeout = Browser.DEFAULT_TIMEOUT self.last_keypress = None self.link = None self.loopback = None self.status = Browser.DISCONECTED + self.progress_updated_at = None + self.previous_progress = 0 self.response_progress = 0 + self.response_speed = None self.response_size = None self.response_transfer_size = None + self.page_background_color = None + self.page_foreground_color = None self.saved_file_name = None + self.saved_file_size = 0 + self.file_saved_at = 0 + self.file_save_notice_timeout = 3 self.page_data = None self.displayed_page_data = None self.auth_identity = auth_identity @@ -75,6 +296,17 @@ class Browser: self.link_target = None self.frame = None self.attr_maps = [] + self.page_list = None + self.page_rows = [] + self.page_partials = {} + self.page_images = {} + self.updater_running = False + self.image_updater_running = False + self.partial_updater_lock = Lock() + self.image_updater_lock = Lock() + self.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.history = [] @@ -92,53 +324,312 @@ class Browser: self.clean_cache() + if self.image_rendering_supported: RNS.log(f"In-browser image rendering supported", RNS.LOG_DEBUG) + else: RNS.log(f"In-browser image rendering is not supported", RNS.LOG_DEBUG) + def current_url(self): - if self.destination_hash == None: - return "" - else: - if self.path == None: - path = "" - else: - path = self.path - return RNS.hexrep(self.destination_hash, delimit=False)+":"+path + if self.destination_hash == None: return "" + path = "" if self.path == None else self.path + url = RNS.hexrep(self.destination_hash, delimit=False)+":"+path + + if isinstance(self.request_data, dict) and self.request_data: + parts = [] + for k, v in self.request_data.items(): + if not isinstance(k, str): continue + if not k.startswith("var_"): continue + parts.append(k[4:]+"="+str(v)) + if parts: url += "`" + "|".join(parts) + + return url def url_hash(self, url): - if url == None: - return None + if url == None: return None else: url = url.encode("utf-8") return RNS.hexrep(RNS.Identity.full_hash(url), delimit=False) - def marked_link(self, link_target): + def marked_link(self, link_target, link_fields=None): + if link_fields: + fields_str = "|".join(link_fields) + link_target = f"{link_target}`{fields_str}" if self.status == Browser.DONE: self.link_target = link_target self.app.ui.loop.set_alarm_in(0.1, self.marked_link_job) def marked_link_job(self, sender, event): link_target = self.link_target + can_display = time.time() > self.file_saved_at + self.file_save_notice_timeout if link_target == None: if self.link_status_showing: self.browser_footer = self.make_status_widget() self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) self.link_status_showing = False + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) else: - self.link_status_showing = True - self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Link to: "+str(link_target))]), "browser_controls") + if can_display: + self.link_status_showing = True + lt_str = str(link_target) + lmax = self._content_cols() + lstr = "Link to "+lt_str + if len(lstr) > lmax: lstr = lstr[:lmax-1]+"…" + self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text(lstr)]), "browser_controls") + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) + + def expand_shorthands(self, destination_type): + if destination_type == "nnn": + return "nomadnetwork.node" + elif destination_type == "lxmf": + return "lxmf.delivery" + elif destination_type == "rrc": + return "rrc.hub.session" + else: + return destination_type + + def handle_link(self, link_target, link_data = None): + partial_ids = None + request_data = None + if link_data != None: + link_fields = [] + request_data = {} + all_fields = True if "*" in link_data else False + + for e in link_data: + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + else: + link_fields.append(e) + + def recurse_down(w): + if isinstance(w, list): + for t in w: + recurse_down(t) + elif isinstance(w, tuple): + for t in w: + recurse_down(t) + elif hasattr(w, "contents"): + recurse_down(w.contents) + elif hasattr(w, "original_widget"): + recurse_down(w.original_widget) + elif hasattr(w, "_original_widget"): + recurse_down(w._original_widget) + else: + if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields): + field_key = "field_" + w.field_name + if isinstance(w, urwid.Edit): + request_data[field_key] = w.edit_text + elif isinstance(w, urwid.RadioButton): + if w.state: + user_data = getattr(w, "field_value", None) + if user_data is not None: + request_data[field_key] = user_data + elif isinstance(w, urwid.CheckBox): + user_data = getattr(w, "field_value", "1") + if w.state: + existing_value = request_data.get(field_key, '') + if existing_value: + # Concatenate the new value with the existing one + request_data[field_key] = existing_value + ',' + user_data + else: + # Initialize the field with the current value + request_data[field_key] = user_data + else: + pass # do nothing if checkbox is not check + + recurse_down(self.attr_maps) + RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME) + + # In-document anchor link (#name or empty #) + if link_target.startswith("#"): + def df(loop, user_data): self._jump_to_anchor(link_target[1:]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + return + + # rrc://[:]/ URL form + if link_target.startswith("rrc://"): + self.handle_rrc_link(link_target[6:]) + return + + components = link_target.split("@") + destination_type = None + + if len(components) == 2: + destination_type = self.expand_shorthands(components[0]) + link_target = components[1] + elif link_target.startswith("p:"): + comps = link_target.split(":") + if len(comps) > 1: partial_ids = comps[1:] + destination_type = "partial" + else: + destination_type = "nomadnetwork.node" + link_target = components[0] + + if destination_type == "nomadnetwork.node": + if self.status >= Browser.DISCONECTED: + RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG) + self.browser_footer = urwid.Text("Opening link to: "+str(link_target)) + try: + self.retrieve_url(link_target, request_data) + except Exception as e: + self.browser_footer = urwid.Text("Could not open link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + else: + RNS.log("Browser already handling link, cannot handle link to: "+str(link_target), RNS.LOG_DEBUG) + + elif destination_type == "lxmf.delivery": + RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG) + self.handle_lxmf_link(link_target) + + elif destination_type == "rrc.hub.session": + RNS.log("Passing RRC link to handler", RNS.LOG_DEBUG) + self.handle_rrc_link(link_target) + + elif destination_type == "partial": + if partial_ids != None and len(partial_ids) > 0: self.handle_partial_updates(partial_ids) + + else: + RNS.log("No known handler for destination type "+str(destination_type), RNS.LOG_DEBUG) + self.browser_footer = urwid.Text("Could not open link: "+"No known handler for destination type "+str(destination_type)) self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) - def handle_link(self, link_target): - if self.status >= Browser.DISCONECTED: - RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG) - try: - self.retrieve_url(link_target) - except Exception as e: - self.browser_footer = urwid.Text("Could not open link: "+str(e)) - self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) - else: - RNS.log("Browser aleady hadling link, cannot handle link to: "+str(link_target), RNS.LOG_DEBUG) + def _jump_to_anchor(self, name): + anchors = getattr(self.attr_maps, "anchors", None) or {} + header_rows = getattr(self.attr_maps, "header_rows", None) or [] + target_idx = None + if name: + target_idx = anchors.get(name) + if target_idx is None: + self.browser_footer = urwid.Text("Unknown anchor: #"+name) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + return + else: + current = 0 + try: + current = self.page_rows[self.page_list.top_position()] + except Exception: + current = 0 + for hr in header_rows: + if hr > current: + target_idx = hr + break + if target_idx is None: + return + + self.reveal_index(int(target_idx)) + + try: + self.page_list.set_focus(self.page_rows.index(int(target_idx))) + self.page_list.set_focus_valign("top") + + except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) + + def _content_cols(self): + try: + lw = self.delegate.given_list_width + screen_cols = self.app.ui.loop.screen.get_cols_rows()[0] + browser_cols = screen_cols-lw-2 + cols = browser_cols + + except Exception as e: + RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING) + RNS.trace_exception(e) + cols = 100 + + return max(40, cols) + + def handle_lxmf_link(self, link_target): + try: + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + + if not type(link_target) is str: + raise ValueError("Invalid data type for LXMF link") + + if len(link_target) != (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + raise ValueError("Invalid length for LXMF link") + + try: + bytes.fromhex(link_target) + + except Exception as e: + raise ValueError("Could not decode destination hash from LXMF link") + + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + source_hash_text = link_target + display_name_data = RNS.Identity.recall_app_data(bytes.fromhex(source_hash_text)) + + display_name = None + if display_name_data != None: + display_name = san(LXMF.display_name_from_app_data(display_name_data)) + + if not source_hash_text in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(bytes.fromhex(source_hash_text), display_name=display_name) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list() + + self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text) + self.app.ui.main_display.show_conversations(None) + + except Exception as e: + RNS.log("Error while starting conversation from link. The contained exception was: "+str(e), RNS.LOG_ERROR) + self.browser_footer = urwid.Text("Could not open LXMF link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + + def handle_rrc_link(self, link_target): + try: + if not isinstance(link_target, str): + raise ValueError("invalid RRC link payload") + rest = link_target.strip() + if rest.startswith("/"): + rest = rest[1:] + hub_part, _, room = rest.partition("/") + hex_part, _, dest = hub_part.partition(":") + hex_part = hex_part.strip() + dest = dest.strip() or None + try: + hub_hash = bytes.fromhex(hex_part) + except Exception: + raise ValueError("invalid hub hash") + expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8 + if len(hub_hash) != expected_len: + raise ValueError("hub hash must be "+str(expected_len)+" bytes") + + room = room.strip().lstrip("#").strip() + room_norm = room.lower() if room else None + + existing = self.app.rrc.find_hub(hub_hash, dest_name=dest) + self.app.ui.main_display.show_channels(None) + channels = self.app.ui.main_display.sub_displays.channels_display + + if existing is not None: + channels.update_list() + if room_norm: + channels._select_room(None, (existing, room_norm)) + else: + channels._select_hub(None, existing) + return + + + channels.confirm_new_hub_dialog(hub_hash, dest, room_norm) + + except Exception as e: + RNS.log("Could not open RRC link: "+str(e), RNS.LOG_ERROR) + self.browser_footer = urwid.Text("Could not open RRC link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) def micron_released_focus(self): @@ -149,16 +640,30 @@ class Browser: self.browser_header = urwid.Text("") self.browser_footer = urwid.Text("") - self.browser_body = urwid.Filler(urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align="center"), "middle") + self.page_list = None + self.page_rows = [] + self.page_partials = {} + self.page_images = {} + self.browser_body = urwid.Filler( + urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), + urwid.MIDDLE, + ) self.frame = BrowserFrame(self.browser_body, header=self.browser_header, footer=self.browser_footer) self.frame.delegate = self self.linebox = urwid.LineBox(self.frame, title="Remote Node") - self.display_widget = urwid.AttrMap(self.linebox, "inactive_text") + self.display_widget = urwid.AttrMap(self.linebox, "browser_inactive") def make_status_widget(self): if self.response_progress > 0: - pb = ResponseProgressBar("progress_empty" , "progress_full", current=self.response_progress, done=1.0, satt=None) + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + style_name_inverted = make_style(default_state(bg=self.page_foreground_color, fg=self.page_background_color)) + else: + style_name = "progress_empty" + style_name_inverted = "progress_full" + + pb = ResponseProgressBar(style_name , style_name_inverted, current=self.response_progress, done=1.0, satt=None, owner=self) widget = urwid.Pile([urwid.Divider(self.g["divider1"]), pb]) else: widget = urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text(self.status_text())]) @@ -166,7 +671,25 @@ class Browser: return urwid.AttrMap(widget, "browser_controls") def make_control_widget(self): - return urwid.AttrMap(urwid.Pile([urwid.Text(self.g["node"]+" "+self.current_url()), urwid.Divider(self.g["divider1"])]), "browser_controls") + clipboard_copy_enabled = self.app.config["textui"]["clipboard_copy"] + copy_glyph = self.g.get("copy", "[C]") + lstr = self.g["node"]+" "+self.current_url() + if clipboard_copy_enabled: + lmax = self._content_cols()-len(copy_glyph)-len(copy_glyph)-1 + else: + lmax = self._content_cols()-1 + if len(lstr) > lmax: lstr = lstr[:lmax-1]+"…" + url_text = urwid.Text(lstr) + if clipboard_copy_enabled: + copy_icon = ClickableIcon(copy_glyph, on_click=lambda: self.copy_url()) + copy_width = len(copy_glyph) + 2 + header_row = urwid.Columns([ + ("weight", 1, url_text), + (copy_width, urwid.Padding(copy_icon, left=1, right=1)), + ]) + else: + header_row = url_text + return urwid.AttrMap(urwid.Pile([header_row, urwid.Divider(self.g["divider1"])]), "browser_controls") def make_request_failed_widget(self): def back_action(sender): @@ -176,33 +699,42 @@ class Browser: self.update_display() columns = urwid.Columns([ - ("weight", 0.5, urwid.Text(" ")), + (urwid.WEIGHT, 0.5, urwid.Text(" ")), (8, urwid.Button("Back", on_press=back_action)), - ("weight", 0.5, urwid.Text(" ")) + (urwid.WEIGHT, 0.5, urwid.Text(" ")), ]) if len(self.attr_maps) > 0: pile = urwid.Pile([ - urwid.Text("!\n\n"+self.status_text()+"\n", align="center"), - columns + urwid.Text("!\n\n"+self.status_text()+"\n", align=urwid.CENTER), + columns ]) else: - pile = urwid.Pile([ - urwid.Text("!\n\n"+self.status_text(), align="center") - ]) + pile = urwid.Pile([urwid.Text("!\n\n"+self.status_text(), align=urwid.CENTER)]) - return urwid.Filler(pile, "middle") + return urwid.Filler(pile, urwid.MIDDLE) def update_display(self): if self.status == Browser.DISCONECTED: - self.display_widget.set_attr_map({None: "inactive_text"}) - self.browser_body = urwid.Filler(urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align="center"), "middle") + self.display_widget.set_attr_map({None: "browser_inactive"}) + self.page_list = None + self.page_rows = [] + self.page_partials = {} + self.page_images = {} + self.browser_body = urwid.Filler( + urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), + urwid.MIDDLE, + ) self.browser_footer = urwid.Text("") self.browser_header = urwid.Text("") self.linebox.set_title("Remote Node") else: self.display_widget.set_attr_map({None: "body_text"}) self.browser_header = self.make_control_widget() + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_header.set_attr_map({None: style_name}) + if self.destination_hash != None: remote_display_string = self.app.directory.simplest_display_str(self.destination_hash) else: @@ -216,6 +748,13 @@ class Browser: if self.status == Browser.DONE: self.browser_footer = self.make_status_widget() self.update_page_display() + + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_body.set_attr_map({None: style_name}) + self.browser_footer.set_attr_map({None: style_name}) + self.browser_header.set_attr_map({None: style_name}) + self.display_widget.set_attr_map({None: style_name}) elif self.status == Browser.LINK_TIMEOUT: self.browser_body = self.make_request_failed_widget() @@ -223,9 +762,18 @@ class Browser: elif self.status <= Browser.REQUEST_SENT: if len(self.attr_maps) == 0: - self.browser_body = urwid.Filler(urwid.Text("Retrieving\n["+self.current_url()+"]", align="center"), "middle") + self.browser_body = urwid.Filler( + urwid.Text("Retrieving\n["+self.current_url()+"]", align=urwid.CENTER), + urwid.MIDDLE, + ) self.browser_footer = self.make_status_widget() + + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) + self.browser_header.set_attr_map({None: style_name}) + self.display_widget.set_attr_map({None: style_name}) elif self.status == Browser.REQUEST_FAILED: self.browser_body = self.make_request_failed_widget() @@ -243,8 +791,642 @@ class Browser: self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) def update_page_display(self): - pile = urwid.Pile(self.attr_maps) - self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") + self._purge_page_images() + self.page_rows = list(range(len(self.attr_maps))) + self.page_list = PageList(urwid.SimpleFocusListWalker(list(self.attr_maps))) + self.page_partials = {} + self.page_images = {} + self.browser_body = urwid.AttrMap(ScrollBar(self.page_list, thumb_char="\u2503", trough_char=" "), "scrollbar") + self.detect_partials() + if self.should_load_images(): self.detect_images() + self.init_folds() + + def refresh_page_layout(self): + if self.page_list is not None: self.page_list.invalidate_layout() + + def image_cache_path(self, url): + 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): + path = None + destination_hash = None + components = url.split(":") + if len(components) == 1: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") + path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + elif len(components) == 2: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") + path = components[1] + if len(path) == 0: path = Browser.DEFAULT_PATH + else: + if len(components[0]) == 0: + if self.destination_hash != None: + destination_hash = self.destination_hash + path = components[1] + if len(path) == 0: path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + + return destination_hash, path + + def detect_partials(self): + for w in self.attr_maps: + o = w._original_widget + if hasattr(o, "partial_hash"): + RNS.log(f"Found partial: {o.partial_hash} / {o.partial_url} / {o.partial_refresh}") + partial = {"hash": o.partial_hash, "id": o.partial_id, "url": o.partial_url, "fields": o.partial_fields, + "refresh": o.partial_refresh, "content": None, "updated": None, "update_requested": None, "request_id": None, + "destination": None, "link": None, "pile": o, "attr_maps": None, "failed": False, "pr_throttle": 0} + + self.page_partials[o.partial_hash] = partial + + if len(self.page_partials) > 0: self.start_partial_updater() + + def partial_failed(self, request_receipt): + RNS.log("Loading page partial failed", RNS.LOG_ERROR) + for pid in self.page_partials: + partial = self.page_partials[pid] + if partial["request_id"] == request_receipt.request_id: + try: + partial["updated"] = time.time() + partial["request_id"] = None + partial["content"] = None + partial["attr_maps"] = None + url = partial["url"] + pile = partial["pile"] + pile.contents = [(urwid.Text(f"Could not load partial {url}: The resource transfer failed"), pile.options())] + self.refresh_page_layout() + except Exception as e: + RNS.log(f"Error in partial failed callback: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + + def partial_progressed(self, request_receipt): + pass + + def partial_received(self, request_receipt): + for pid in self.page_partials: + partial = self.page_partials[pid] + if partial["request_id"] == request_receipt.request_id: + try: + partial["updated"] = partial["update_requested"] + partial["request_id"] = None + partial["content"] = request_receipt.response.decode("utf-8").rstrip() + partial["attr_maps"] = markup_to_attrmaps(strip_modifiers(partial["content"]), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + pile = partial["pile"] + pile.contents = [(e, pile.options()) for e in partial["attr_maps"]] + self.refresh_page_layout() + + except Exception as e: + RNS.trace_exception(e) + + def __load_partial(self, partial): + if partial["failed"] == True: return + try: partial_destination_hash, path = self.parse_url(partial["url"]) + except Exception as e: + RNS.log(f"Could not parse partial URL: {e}", RNS.LOG_ERROR) + partial["failed"] = True + pile = partial["pile"] + url = partial["url"] + pile.contents = [(urwid.Text(f"Could not load partial {url}: {e}"), pile.options())] + self.refresh_page_layout() + return + + if partial_destination_hash != self.loopback and not RNS.Transport.has_path(partial_destination_hash): + if time.time() <= partial["pr_throttle"]: return + else: + partial["pr_throttle"] = time.time()+15 + RNS.log(f"Requesting path for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + RNS.Transport.request_path(partial_destination_hash) + pr_time = time.time()+self.path_timeout(partial_destination_hash) + while not RNS.Transport.has_path(partial_destination_hash): + if time.time() > pr_time: return + time.sleep(0.25) + + for pid in self.page_partials: + other_partial = self.page_partials[pid] + if other_partial["link"]: + existing_link = other_partial["link"] + if existing_link.destination.hash == partial_destination_hash and existing_link.status == RNS.Link.ACTIVE: + RNS.log(f"Re-using existing link: {existing_link}", RNS.LOG_EXTREME) + partial["link"] = existing_link + break + + if not partial["link"] or partial["link"].status == RNS.Link.CLOSED: + RNS.log(f"Establishing link for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + identity = RNS.Identity.recall(partial_destination_hash) + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects) + + def established(link): + RNS.log(f"Link established for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + + def closed(link): + RNS.log(f"Link closed for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + partial["link"] = None + + partial["link"] = RNS.Link(destination, established_callback = established, closed_callback = closed) + timeout = time.time()+max(self.timeout, partial["link"].establishment_timeout) + while partial["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1) + + if partial["link"] and partial["link"].status == RNS.Link.ACTIVE and partial["request_id"] == None: + RNS.log(f"Sending request for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + receipt = partial["link"].request(path, data=self.__get_partial_request_data(partial), response_callback = self.partial_received, + failed_callback = self.partial_failed, progress_callback = self.partial_progressed) + + if receipt: partial["request_id"] = receipt.request_id + else: RNS.log(f"Partial request failed", RNS.LOG_ERROR) + + def __get_partial_request_data(self, partial): + request_data = None + if partial["fields"] != None: + link_data = partial["fields"] + link_fields = [] + request_data = {} + all_fields = True if "*" in link_data else False + + for e in link_data: + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + else: + link_fields.append(e) + + def recurse_down(w): + if isinstance(w, list): + for t in w: + recurse_down(t) + elif isinstance(w, tuple): + for t in w: + recurse_down(t) + elif hasattr(w, "contents"): + recurse_down(w.contents) + elif hasattr(w, "original_widget"): + recurse_down(w.original_widget) + elif hasattr(w, "_original_widget"): + recurse_down(w._original_widget) + else: + if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields): + field_key = "field_" + w.field_name + if isinstance(w, urwid.Edit): + request_data[field_key] = w.edit_text + elif isinstance(w, urwid.RadioButton): + if w.state: + user_data = getattr(w, "field_value", None) + if user_data is not None: + request_data[field_key] = user_data + elif isinstance(w, urwid.CheckBox): + user_data = getattr(w, "field_value", "1") + if w.state: + existing_value = request_data.get(field_key, '') + if existing_value: + # Concatenate the new value with the existing one + request_data[field_key] = existing_value + ',' + user_data + else: + # Initialize the field with the current value + request_data[field_key] = user_data + else: + pass # do nothing if checkbox is not check + + recurse_down(self.attr_maps) + RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME) + + return request_data + + def start_partial_updater(self): + if not self.updater_running: self.update_partials() + + def handle_partial_updates(self, partial_ids): + RNS.log(f"Update partials: {partial_ids}") + def job(): + for pid in self.page_partials: + try: + partial = self.page_partials[pid] + if partial["id"] in partial_ids: + partial["update_requested"] = time.time() + self.__load_partial(partial) + except Exception as e: RNS.log(f"Error updating page partial: {e}", RNS.LOG_ERROR) + + threading.Thread(target=job, daemon=True).start() + + def update_partials(self, loop=None, user_data=None): + with self.partial_updater_lock: + def job(): + for pid in self.page_partials: + try: + partial = self.page_partials[pid] + if partial["failed"]: continue + if not partial["updated"] or (partial["refresh"] != None and time.time() > partial["updated"]+partial["refresh"]): + partial["update_requested"] = time.time() + self.__load_partial(partial) + except Exception as e: RNS.log(f"Error updating page partial: {e}", RNS.LOG_ERROR) + + threading.Thread(target=job, daemon=True).start() + + if len(self.page_partials) > 0: + self.updater_running = True + self.app.ui.loop.set_alarm_in(1, self.update_partials) + else: + self.updater_running = False def identify(self): if self.link != None: @@ -256,9 +1438,13 @@ class Browser: if self.link != None: self.link.teardown() + self.request_data = None self.attr_maps = [] self.status = Browser.DISCONECTED self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None @@ -270,7 +1456,24 @@ class Browser: self.update_display() - def retrieve_url(self, url): + def retrieve_url(self, url, request_data = None): + components = url.split("`") + if len(components) == 2: + url = components[0] + try: + link_fields_str = components[1] + if link_fields_str != "": + if not request_data: request_data = {} + for e in link_fields_str.split("|"): + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + + except Exception as e: + RNS.log(f"Malformed URL: {e}", RNS.LOG_WARNING) + raise ValueError(f"Malformed URL: {e}") + self.previous_destination_hash = self.destination_hash self.previous_path = self.path @@ -279,42 +1482,32 @@ class Browser: components = url.split(":") if len(components) == 1: - if len(components[0]) == 20: - try: - destination_hash = bytes.fromhex(components[0]) - except Exception as e: - raise ValueError("Malformed URL") + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") path = Browser.DEFAULT_PATH - else: - raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") elif len(components) == 2: - if len(components[0]) == 20: - try: - destination_hash = bytes.fromhex(components[0]) - except Exception as e: - raise ValueError("Malformed URL") + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") path = components[1] - if len(path) == 0: - path = Browser.DEFAULT_PATH + if len(path) == 0: path = Browser.DEFAULT_PATH else: if len(components[0]) == 0: if self.destination_hash != None: destination_hash = self.destination_hash path = components[1] - if len(path) == 0: - path = Browser.DEFAULT_PATH - else: - raise ValueError("Malformed URL") - else: - raise ValueError("Malformed URL") - else: - raise ValueError("Malformed URL") + if len(path) == 0: path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") if destination_hash != None and path != None: if path.startswith("/file/"): if destination_hash != self.loopback: if destination_hash == self.destination_hash: - self.download_file(destination_hash, path) + self.download_file(destination_hash, path, data=request_data) else: RNS.log("Cannot request file download from a node that is not currently connected.", RNS.LOG_ERROR) RNS.log("The requested URL was: "+str(url), RNS.LOG_ERROR) @@ -323,6 +1516,7 @@ class Browser: else: self.set_destination_hash(destination_hash) self.set_path(path) + self.set_request_data(request_data) self.load_page() def set_destination_hash(self, destination_hash): @@ -336,10 +1530,15 @@ class Browser: def set_path(self, path): self.path = path + def set_request_data(self, request_data): + self.request_data = request_data def set_timeout(self, timeout): self.timeout = timeout + def path_timeout(self, destination_hash): + return max(self.timeout + self.app.rns.get_first_hop_timeout(destination_hash), self.app.rns.get_medium_path_timeout()) + def download_local_file(self, path): try: file_path = self.app.filespath+path.replace("/file", "", 1) @@ -359,6 +1558,8 @@ class Browser: fs.close() self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 self.update_display() else: @@ -366,11 +1567,14 @@ class Browser: self.status = Browser.DONE self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 except Exception as e: RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR) - def download_file(self, destination_hash, path): + def download_file(self, destination_hash, path, data=None): if self.link == None or self.link.destination.hash != self.destination_hash: if not RNS.Transport.has_path(self.destination_hash): self.status = Browser.NO_PATH @@ -380,10 +1584,9 @@ class Browser: self.status = Browser.PATH_REQUESTED self.update_display() - pr_time = time.time() + pr_time = time.time()+self.path_timeout(self.destination_hash) while not RNS.Transport.has_path(self.destination_hash): - now = time.time() - if now > pr_time+self.timeout: + if time.time() > pr_time: self.request_timeout() return @@ -415,14 +1618,18 @@ class Browser: # Send the request self.status = Browser.REQUESTING self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.saved_file_name = None + self.saved_file_size = 0 self.update_display() receipt = self.link.request( path, - data = None, + data = data, response_callback = self.file_received, failed_callback = self.request_failed, progress_callback = self.response_progressed @@ -437,7 +1644,7 @@ class Browser: self.link.teardown() def write_history(self): - entry = [self.destination_hash, self.path] + entry = [self.destination_hash, self.path, self.request_data] self.history.insert(self.history_ptr, entry) self.history_ptr += 1 @@ -452,7 +1659,7 @@ class Browser: entry = self.history[target_ptr-1] url = RNS.hexrep(entry[0], delimit=False)+":"+entry[1] self.history_ptr = target_ptr - self.retrieve_url(url) + self.retrieve_url(url, request_data=entry[2]) def forward(self): target_ptr = self.history_ptr+1 @@ -462,7 +1669,7 @@ class Browser: entry = self.history[target_ptr-1] url = RNS.hexrep(entry[0], delimit=False)+":"+entry[1] self.history_ptr = target_ptr - self.retrieve_url(url) + self.retrieve_url(url, request_data=entry[2]) def reload(self): if not self.reloading and self.status == Browser.DONE: @@ -470,8 +1677,36 @@ class Browser: self.uncache_page(self.current_url()) self.load_page() + def copy_url(self): + url = self.current_url() + if not url: + return + + if not osc52_copy(url): + return + + try: + notice = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Copied URL to clipboard")]), "browser_controls") + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + notice.set_attr_map({None: style_name}) + self.browser_footer = notice + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + def restore_footer(loop, user_data): + if self.status == Browser.DONE: + self.browser_footer = self.make_status_widget() + else: + self.browser_footer = urwid.Text("") + if self.frame is not None: + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + self.app.ui.loop.set_alarm_in(2.0, restore_footer) + except Exception as e: + RNS.log("Could not update footer after URL copy: "+str(e), RNS.LOG_ERROR) + def close_dialogs(self): - options = self.delegate.columns.options("weight", self.delegate.right_area_width) + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) self.delegate.columns.contents[1] = (self.display_widget, options) def url_dialog(self): @@ -481,13 +1716,26 @@ class Browser: self.close_dialogs() def confirmed(sender): - self.retrieve_url(e_url.get_edit_text()) + try: + entered_url = e_url.get_edit_text().strip() + if not "`" in entered_url and ":" in entered_url: + pos = entered_url.find("|") + if pos > 0: entered_url = entered_url[:pos]+"`"+entered_url[pos+1:] + self.retrieve_url(entered_url) + except Exception as e: + self.browser_footer = urwid.Text("Could not open link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + self.close_dialogs() dialog = UrlDialogLineBox( urwid.Pile([ e_url, - urwid.Columns([("weight", 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Go", on_press=confirmed))]) + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Go", on_press=confirmed)), + ]) ]), title="Enter URL" ) e_url.confirmed = confirmed @@ -495,25 +1743,117 @@ class Browser: dialog.delegate = self bottom = self.display_widget - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 65), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=(urwid.RELATIVE, 65), + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.delegate.columns.options("weight", self.delegate.right_area_width) + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) self.delegate.columns.contents[1] = (overlay, options) self.delegate.columns.focus_position = 1 + def save_node_dialog(self): + if self.destination_hash != None: + try: + def dismiss_dialog(sender): + self.close_dialogs() + + display_name = RNS.Identity.recall_app_data(self.destination_hash) + disp_str = "" + if display_name != None: + display_name = display_name.decode("utf-8") + disp_str = " \""+display_name+"\"" + + def confirmed(sender): + node_entry = DirectoryEntry(self.destination_hash, display_name=display_name, hosts_node=True) + self.app.directory.remember(node_entry) + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + self.close_dialogs() + + dialog = UrlDialogLineBox( + urwid.Pile([ + urwid.Text("Save connected node"+disp_str+" "+RNS.prettyhexrep(self.destination_hash)+" to Known Nodes?\n"), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + ]) + ]), title="Save Node" + ) + + dialog.confirmed = confirmed + dialog.delegate = self + bottom = self.display_widget + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=(urwid.RELATIVE, 50), + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) + self.delegate.columns.contents[1] = (overlay, options) + self.delegate.columns.focus_position = 1 + + except Exception as e: + pass def load_page(self): - cached = self.get_cached(self.current_url()) + if self.request_data == None: + cached = self.get_cached(self.current_url()) + else: + cached = None + if cached: self.status = Browser.DONE self.page_data = cached self.markup = self.page_data.decode("utf-8") - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos >= 0: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos >= 0: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.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 page: {e}"), "browser_inactive")] self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.saved_file_name = None + self.saved_file_size = 0 self.loaded_from_cache = True self.update_display() @@ -525,6 +1865,10 @@ class Browser: self.history_inc = False self.reloading = False + if self.request_data and "var_anchor" in self.request_data: + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + else: if self.destination_hash != self.loopback: load_thread = threading.Thread(target=self.__load) @@ -537,7 +1881,15 @@ class Browser: page_data = b"The requested local page did not exist in the file system" if os.path.isfile(page_path): if os.access(page_path, os.X_OK): - generated = subprocess.run([page_path], stdout=subprocess.PIPE) + if self.request_data != None: + env_map = self.request_data + else: + env_map = {} + + if "PATH" in os.environ: + env_map["PATH"] = os.environ["PATH"] + + generated = subprocess.run([page_path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env_map) page_data = generated.stdout else: file = open(page_path, "rb") @@ -547,12 +1899,40 @@ class Browser: self.status = Browser.DONE self.page_data = page_data self.markup = self.page_data.decode("utf-8") - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos >= 0: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos >= 0: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.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 page: {e}"), "browser_inactive")] self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.saved_file_name = None + self.saved_file_size = 0 self.loaded_from_cache = False self.update_display() @@ -564,11 +1944,15 @@ class Browser: self.history_inc = False self.reloading = False + if self.request_data and "var_anchor" in self.request_data: + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + def __load(self): # If an established link exists, but it doesn't match the target # destination, we close and clear it. - if self.link != None and self.link.destination.hash != self.destination_hash: + if self.link != None and (self.link.destination.hash != self.destination_hash or self.link.status != RNS.Link.ACTIVE): self.link.teardown() self.link = None @@ -582,10 +1966,9 @@ class Browser: self.status = Browser.PATH_REQUESTED self.update_display() - pr_time = time.time() + pr_time = time.time()+self.path_timeout(self.destination_hash) while not RNS.Transport.has_path(self.destination_hash): - now = time.time() - if now > pr_time+self.timeout: + if time.time() > pr_time: self.request_timeout() return @@ -616,15 +1999,19 @@ class Browser: # Send the request self.status = Browser.REQUESTING self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.saved_file_name = None + self.saved_file_size = 0 self.update_display() receipt = self.link.request( self.path, - data = None, + data = self.request_data, response_callback = self.response_received, failed_callback = self.request_failed, progress_callback = self.response_progressed @@ -661,6 +2048,9 @@ class Browser: def link_establishment_timeout(self): self.status = Browser.LINK_TIMEOUT self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.link = None @@ -673,18 +2063,48 @@ class Browser: self.status = Browser.DONE self.page_data = request_receipt.response self.markup = self.page_data.decode("utf-8") - self.attr_maps = markup_to_attrmaps(self.markup, url_delegate=self) + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos >= 0: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos >= 0: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.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 page: {e}"), "browser_inactive")] + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.loaded_from_cache = False # Simple header handling. Should be expanded when more # header tags are added. cache_time = Browser.DEFAULT_CACHE_TIME if self.markup[:4] == "#!c=": - endpos = self.markup.find("\n") - if endpos == -1: - endpos = len(self.markup) - cache_time = int(self.markup[4:endpos]) + try: + endpos = self.markup.find("\n") + if endpos == -1: + endpos = len(self.markup) + cache_time = int(self.markup[4:endpos]) + except Exception as e: RNS.log(f"Invalid page cache time header: {e}", RNS.LOG_DEBUG) self.update_display() @@ -701,6 +2121,10 @@ class Browser: RNS.log("Received page "+str(self.current_url())+", caching for %.3f hours." % (cache_time/60/60), RNS.LOG_DEBUG) self.cache_page(cache_time) + if self.request_data and "var_anchor" in self.request_data: + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + except Exception as e: RNS.log("An error occurred while handling response. The contained exception was: "+str(e)) @@ -786,24 +2210,55 @@ class Browser: def file_received(self, request_receipt): try: - file_name = request_receipt.response[0] - file_data = request_receipt.response[1] - file_destination = self.app.downloads_path+"/"+file_name - - counter = 0 - while os.path.isfile(file_destination): - counter += 1 - file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter) + if isinstance(request_receipt.response, bytes): + markup = request_receipt.response.decode("utf-8") + self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) - fh = open(file_destination, "wb") - fh.write(file_data) - fh.close() + elif type(request_receipt.response) == io.BufferedReader: + if request_receipt.metadata != None: + file_name = ConversationMessage.safe_attachment_name(request_receipt.metadata["name"]) + file_handle = request_receipt.response + file_destination = self.app.downloads_path+"/"+file_name - self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + counter = 0 + while os.path.isfile(file_destination): + counter += 1 + file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter) + + shutil.move(file_handle.name, file_destination) + + self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 + self.file_saved_at = time.time() + + else: + file_name = request_receipt.response[0] + file_data = request_receipt.response[1] + file_destination_name = ConversationMessage.safe_attachment_name(file_name) + file_destination = self.app.downloads_path+"/"+file_destination_name + + counter = 0 + while os.path.isfile(file_destination): + counter += 1 + file_destination = self.app.downloads_path+"/"+file_destination_name+"."+str(counter) + + fh = open(file_destination, "wb") + fh.write(file_data) + fh.close() + + self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 + self.status = Browser.DONE self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.update_display() + except Exception as e: RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -813,26 +2268,48 @@ class Browser: if request_receipt.request_id == self.last_request_id: self.status = Browser.REQUEST_FAILED self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.update_display() + if self.link != None: + try: self.link.teardown() + except Exception as e: pass else: self.status = Browser.REQUEST_FAILED self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.update_display() + if self.link != None: + try: + self.link.teardown() + except Exception as e: + pass def request_timeout(self, request_receipt=None): self.status = Browser.REQUEST_TIMEOUT self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 self.response_size = None self.response_transfer_size = None self.update_display() + if self.link != None: + try: + self.link.teardown() + except Exception as e: + pass def response_progressed(self, request_receipt): @@ -840,19 +2317,39 @@ class Browser: self.response_time = request_receipt.get_response_time() self.response_size = request_receipt.response_size self.response_transfer_size = request_receipt.response_transfer_size + + now = time.time() + if self.progress_updated_at == None: self.progress_updated_at = now + if now > self.progress_updated_at+0.5: + td = now - self.progress_updated_at + pd = self.response_progress - self.previous_progress + bd = pd*self.response_transfer_size + self.response_speed = (bd/td)*8 + self.previous_progress = self.response_progress + self.progress_updated_at = now + self.update_display() + def last_response_speed(self): + if self.response_transfer_size and self.response_time: + return (self.response_transfer_size/max(self.response_time, 0.0001))*8 + else: return None def status_text(self): if self.status == Browser.DONE and self.response_transfer_size != None: - if self.response_time != None: - response_time_str = "{:.2f}".format(self.response_time) - else: - response_time_str = "None" + if self.response_time != None: response_time_str = "{:.2f}".format(self.response_time) + else: response_time_str = "None" + + if self.saved_file_name == None: resp_size = self.response_size + else: resp_size = self.saved_file_size + + if resp_size: stats_string = " "+self.g["page"]+size_str(resp_size) + else: stats_string = " " + if self.response_transfer_size: + stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str + if self.response_transfer_size and self.response_time: + stats_string += "s "+self.g["speed"]+size_str(self.response_transfer_size/self.response_time, suffix="b")+"/s" - stats_string = " "+self.g["page"]+size_str(self.response_size) - stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str - stats_string += "s "+self.g["speed"]+size_str(self.response_transfer_size/self.response_time, suffix="b")+"/s" elif self.loaded_from_cache: stats_string = " (cached)" else: @@ -879,10 +2376,8 @@ class Browser: elif self.status == Browser.RECEIVING_RESPONSE: return "Receiving response..." elif self.status == Browser.DONE: - if self.saved_file_name == None: - return "Done"+stats_string - else: - return "Saved "+str(self.saved_file_name)+stats_string + if self.saved_file_name == None: return "Done"+stats_string + else: return "Saved "+str(self.saved_file_name)+stats_string elif self.status == Browser.DISCONECTED: return "Disconnected" else: @@ -890,8 +2385,14 @@ class Browser: class ResponseProgressBar(urwid.ProgressBar): + def __init__(self, empty, full, current=None, done=None, satt=None, owner=None): + super().__init__(empty, full, current=current, done=done, satt=satt) + self.owner = owner + def get_text(self): - return "Receiving response "+super().get_text() + if self.owner.response_speed: speed_str = " "+RNS.prettyspeed(self.owner.response_speed) + else: speed_str = "" + return "Receiving response "+super().get_text().replace(" %", "%")+speed_str # A convenience function for printing a human- # readable file size @@ -921,9 +2422,9 @@ class UrlDialogLineBox(urwid.LineBox): else: return super(UrlDialogLineBox, self).keypress(size, key) -class UrlEdit(urwid.Edit): +class UrlEdit(ReadlineMixin, urwid.Edit): def keypress(self, size, key): if key == "enter": self.confirmed(self) else: - return super(UrlEdit, self).keypress(size, key) \ No newline at end of file + return super(UrlEdit, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py new file mode 100644 index 0000000..24bba5b --- /dev/null +++ b/nomadnet/ui/textui/Channels.py @@ -0,0 +1,2285 @@ +import collections +import os +import re +import time + +import RNS +import urwid + +import nomadnet +from nomadnet.RRC import RRCHub +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox +from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec +from RNS.Utilities.rngit.util import MarkdownToMicron +from RNS.Utilities.rngit.highlight import SyntaxHighlighter +from .MicronParser import markup_to_attrmaps, default_state, make_style +from .ReadlineEdit import ReadlineMixin, ReadlineEdit +from nomadnet.util import sanitize_name, strip_modifiers, strip_micron +from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags +from nomadnet.vendor.Scrollable import Scrollable, ScrollBar + + +theme_dark = { "text": "ddd", + "ts": "888", + "nick_self": "6c5", + "nick_peer": "3cd", + "notice": "fd3", + "error": "f55", + "system": "888", + "mention": "fb4", + "link": "79d", + # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 1.4 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 + "nick_colors": ["f68787", "00c394", "d59e00", "62be00", "a1ac76", "95b600", "76a9ee", "81b385", "7eb1a1", "e89264", "7cb0b0", "00c0c0", "8cacbb", "32b4db", "98a8c3", "bbab00", "95a0fd", "a9a2ca", "ad98fe", "c58ffa", "df83f4", "c49abf", "f380c7", "f484a7"], + } + +theme_light = { "text": "111", + "ts": "888", + "nick_self": "3a0", + "nick_peer": "077", + "notice": "a70", + "error": "a22", + "system": "888", + "mention": "c50", + "link": "79d", + # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 0.2 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 > ~/.nomadnetwork/storage/pages/index.mu + "nick_colors": ["ca0000", "008000", "9d1c00", "007800", "2c5200", "006800", "004ac0", "006100", "005d2c", "b70000", "005b5b", "007b7a", "005071", "0064a5", "004580", "714f00", "0026d3", "48318c", "5200d5", "8400cf", "aa00c8", "820079", "c60086", "c80043"], + } + + +class _ChatLinkableText(LinkableText): + def render(self, size, focus=False): + c = urwid.Text.render(self, size, focus) + if focus: + c = urwid.CompositeCanvas(c) + c.cursor = self.get_cursor_coords(size) + if self.delegate is not None: + self.peek_link() + return c + + +_LINK_RE = re.compile( + r"(?P(?(?(? 0 and last_space >= len(chunk) // 2: + chunk = chunk[:last_space] + if not chunk: + chunk = remaining[:1] + chunks.append(chunk.rstrip()) + remaining = remaining[len(chunk):].lstrip() + return chunks + + +def _split_message(text, max_bytes): + if not text: + return [text] + parts = [text] + for _attempt in range(10): + K_guess = max(1, len(parts)) + prefix_bytes = len(("({}/{}) ".format(K_guess, K_guess)).encode("utf-8")) + budget = max_bytes - prefix_bytes + if budget <= 0: + return None + parts = _chunk_by_bytes(text, budget) + if len(parts) == K_guess: + break + K = len(parts) + return ["({}/{}) ".format(i+1, K) + p for i, p in enumerate(parts)] + + +def _scan_mentions(text, own_nick): + if not own_nick or not text: + return + pat = re.compile(r"(? r_start: return True + return False + + spans = [(s, e, k, t) for s, e, k, t in spans if not overlaps_code(s, e)] + + spans.sort(key=lambda s: s[0]) + filtered = [] + last_end = 0 + for s in spans: + if s[0] >= last_end: + filtered.append(s) + last_end = s[1] + spans = filtered + + if not spans: + return [(body_attr, body)], False + + out = [] + pos = 0 + has_links = False + for start, end, kind, target in spans: + if start > pos: + out.append((body_attr, body[pos:start])) + if kind == "mention": + out.append(("irc_mention", body[start:end])) + elif kind == "nick_mention": + out.append(("nick_mention", body[start:end])) + else: + base = _LINK_ATTRS[kind] + if check_links: + out.append((LinkSpec(kind+":"+target, base, cm=256), body[start:end])) + has_links = True + else: + out.append((f"link_{kind}", body[start:end])) + has_links = True + pos = end + if pos < len(body): + out.append((body_attr, body[pos:])) + return out, has_links + + +def _short_hash(b, n=12): + if isinstance(b, (bytes, bytearray)): + return bytes(b).hex()[:n] + return "?" + + +def _format_ts(ts_ms): + try: + return time.strftime("%H:%M:%S", time.localtime(ts_ms/1000.0)) + except Exception: + return "" + + +class ChannelsListShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar") + + +class ChannelsRoomShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-x] Leave [F8] Collapse [Tab] Complete Nick"), "shortcutbar") + + +class ChannelsRoomBodyShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-x] Leave [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] ↓ Editor"), "shortcutbar") + + +class ChannelsDialogLineBox(urwid.LineBox): + def keypress(self, size, key): + if key == "esc": + if hasattr(self.delegate, "close_dialog"): + self.delegate.close_dialog() + else: + return super(ChannelsDialogLineBox, self).keypress(size, key) + + +class ChannelListEntry(urwid.Text): + _selectable = True + signals = ["click"] + + def keypress(self, size, key): + if self._command_map[key] != urwid.ACTIVATE: + return key + self._emit("click") + + def mouse_event(self, size, event, button, x, y, focus): + if button != 1 or not urwid.util.is_mouse_press(event): + return False + self._emit("click") + return True + + +class ChannelsExpandGutter(urwid.WidgetWrap): + def __init__(self, app, delegate): + self.app = app + self.delegate = delegate + glyph = app.ui.glyphs.get("arrow_r", ">") + if len(glyph) > 1: + glyph = ">" + self._inner = urwid.SolidFill(glyph) + super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus")) + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event): + try: + self.delegate.toggle_channel_list() + return True + except Exception: + pass + return False + + def selectable(self): + return False + + +class UsersExpandGutter(urwid.WidgetWrap): + def __init__(self, app, delegate): + self.app = app + self.delegate = delegate + glyph = app.ui.glyphs.get("arrow_l", "<") + if len(glyph) > 1: + glyph = "<" + self._inner = urwid.SolidFill(glyph) + super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus")) + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event): + try: + self.delegate.toggle_users() + return True + except Exception: + pass + return False + + def selectable(self): + return False + + +class UsersBox(urwid.LineBox): + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event) and row == 0: + try: + self.delegate.toggle_users() + return True + except Exception: + pass + return super().mouse_event(size, event, button, col, row, focus) + + def keypress(self, size, key): + if key == "tab": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.columns.focus_position = 0 + rw.frame.focus_position = "footer" + return None + except Exception: + pass + if key == "ctrl u": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.toggle_users() + return None + except Exception: + pass + if key == "ctrl y": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.display.toggle_channel_list() + return None + except Exception: + pass + return super().keypress(size, key) + + +class ChannelsListArea(urwid.LineBox): + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event) and row == 0: + try: + self.delegate.toggle_channel_list() + return True + except Exception: + pass + return super().mouse_event(size, event, button, col, row, focus) + + def keypress(self, size, key): + if key == "ctrl n": + self.delegate.new_hub_dialog() + elif key == "ctrl a": + self.delegate.join_room_dialog() + elif key == "ctrl r": + self.delegate.connect_selected() + elif key == "ctrl w": + self.delegate.disconnect_selected() + elif key == "ctrl t": + self.delegate.toggle_auto_reconnect_selected() + elif key == "ctrl e": + self.delegate.edit_hub_dialog() + elif key == "ctrl x": + self.delegate.remove_selected_dialog() + elif key == "ctrl y": + self.delegate.toggle_channel_list() + return None + elif key == "f8": + self.delegate.toggle_join_part_collapse() + return None + elif key == "tab": + self.delegate.app.ui.main_display.frame.focus_position = "header" + elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): + self.delegate.app.ui.main_display.frame.focus_position = "header" + else: + return super(ChannelsListArea, self).keypress(size, key) + + +class HubInfoArea(urwid.LineBox): + def keypress(self, size, key): + if key == "ctrl n": + self.delegate.new_hub_dialog() + return None + if key == "ctrl a": + self.delegate.join_room_dialog() + return None + if key == "ctrl r": + self.delegate.connect_selected() + return None + if key == "ctrl w": + self.delegate.disconnect_selected() + return None + if key == "ctrl t": + self.delegate.toggle_auto_reconnect_selected() + return None + if key == "ctrl e": + self.delegate.edit_hub_dialog() + return None + if key == "ctrl x": + self.delegate.remove_selected_dialog() + return None + if key == "ctrl y": + self.delegate.toggle_channel_list() + return None + if key == "f8": + self.delegate.toggle_join_part_collapse() + return None + return super(HubInfoArea, self).keypress(size, key) + + +class RoomMessageEdit(ReadlineMixin, urwid.Edit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tab_state = None + + def keypress(self, size, key): + if key == "tab": + if self._try_tab_complete(): + return None + return key + self._tab_state = None + if key == "ctrl d": + self.delegate.send_message() + elif key == "ctrl x": + self.delegate.leave_room() + elif key == "f8": + self.delegate.display.toggle_join_part_collapse() + elif key == "up": + y = self.get_cursor_coords(size)[1] + if y == 0: + self.delegate.frame.focus_position = "body" + else: + return super(RoomMessageEdit, self).keypress(size, key) + else: + return super(RoomMessageEdit, self).keypress(size, key) + + def _candidates(self, prefix_lower): + delegate = getattr(self, "delegate", None) + if delegate is None or delegate.hub is None or delegate.room is None: + return [] + members = delegate.hub.get_members(delegate.room) + own_hash = None + try: + if delegate.app.identity is not None: + own_hash = delegate.app.identity.hash + except Exception: + pass + names = set() + for m in members: + if own_hash is not None and m == own_hash: + continue + names.add(delegate.hub.display_name_for(m)) + return sorted([n for n in names if n.lower().startswith(prefix_lower)], + key=str.lower) + + def _try_tab_complete(self): + text = self.get_edit_text() + pos = self.edit_pos + state = self._tab_state + + if state is not None and state.get("cursor_after") == pos: + prefix_lower = state["prefix"] + token_start = state["token_start"] + has_at = state["has_at"] + matches = self._candidates(prefix_lower) + if not matches: + self._tab_state = None + return False + idx = (state["idx"] + 1) % len(matches) + else: + start = pos + while start > 0 and (text[start-1].isalnum() or text[start-1] in "_-"): + start -= 1 + has_at = start > 0 and text[start-1] == "@" + token_start = start - 1 if has_at else start + token = text[start:pos] + if not token: + return False + prefix_lower = token.lower() + matches = self._candidates(prefix_lower) + if not matches: + return False + idx = 0 + + selected = matches[idx] + if has_at: + replacement = "@" + selected + elif token_start == 0: + replacement = selected + ": " + else: + replacement = selected + + new_text = text[:token_start] + replacement + text[pos:] + new_cursor = token_start + len(replacement) + self.set_edit_text(new_text) + self.set_edit_pos(new_cursor) + self._tab_state = { + "prefix": prefix_lower, + "token_start": token_start, + "has_at": has_at, + "cursor_after": new_cursor, + "idx": idx, + } + return True + + +class RoomFrame(urwid.Frame): + @property + def focus_position(self): + return urwid.Frame.focus_position.fget(self) + + @focus_position.setter + def focus_position(self, part): + urwid.Frame.focus_position.fset(self, part) + try: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts() + except Exception: + pass + + def keypress(self, size, key): + if key in ("ctrl u", "ctrl x", "ctrl y", "f8", "tab"): + result = super(RoomFrame, self).keypress(size, key) + if result != key: + return result + if key == "ctrl u": + self.delegate.toggle_users() + return None + if key == "ctrl x": + self.delegate.leave_room() + return None + if key == "ctrl y": + self.delegate.display.toggle_channel_list() + return None + if key == "f8": + self.delegate.display.toggle_join_part_collapse() + return None + if self.focus_position != "footer": + self.focus_position = "footer" + return None + elif self.focus_position == "body": + if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible: + self.focus_position = "footer" + elif key == "up" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.top_is_visible: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + else: + return super(RoomFrame, self).keypress(size, key) + else: + return super(RoomFrame, self).keypress(size, key) + + +class _StickyMessageListBox(IndicativeListBox): + # Tracks whether the user is scrolled to the bottom and re-asserts that + # position on resize. urwid.ListBox stores an inset computed at the prior + # size, so without re-asserting, the focused widget can render only partly + # visible at narrower widths. + def __init__(self, body, **kwargs): + self.sticky_bottom = True + self._last_render_size = None + super().__init__(body, on_selection_change=self._track_sticky, **kwargs) + + def _track_sticky(self, old_pos, new_pos): + if new_pos is None: + self.sticky_bottom = True + else: + try: + self.sticky_bottom = (new_pos == self.rearmost_position()) + except Exception: + pass + + def render(self, size, focus=False): + if (self._last_render_size is not None + and self._last_render_size != size + and self.sticky_bottom): + try: + inner_body = self._listbox.body + if len(inner_body) > 0: + self._listbox.set_focus(len(inner_body)-1) + self._listbox.set_focus_valign("bottom") + except Exception: + pass + self._last_render_size = size + canvas = super().render(size, focus=focus) + if getattr(self, "bottom_is_visible", False): + self.sticky_bottom = True + return canvas + + +class RoomWidget(urwid.WidgetWrap): + USERS_PANE_WIDTH = 22 + + def __init__(self, display, hub, room): + self.display = display + self.hub = hub + self.room = room + self.app = nomadnet.NomadNetworkApp.get_shared_instance() + self.theme = theme_dark if self.app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light + + self.messagelist = None + self.last_history_clean = 0 + self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") + self._update_peer_info() + + editor = RoomMessageEdit(caption="", edit_text="", multiline=True) + editor.delegate = self + self.editor = editor + urwid.connect_signal(editor, "postchange", self._on_editor_change) + editor_attr = urwid.AttrMap(editor, "msg_editor") + + self.link_delegate = _ChatLinkDelegate(self.display, self.hub) + self.update_messages() + + self.frame = RoomFrame( + self.messagelist, + header=self.peer_info_widget, + footer=editor_attr, + focus_part="footer", + ) + self.frame.delegate = self + + self.chat_box = urwid.LineBox(self.frame) + self.users_walker = urwid.SimpleFocusListWalker([urwid.Text("")]) + self.users_listbox = urwid.ListBox(self.users_walker) + self.users_box = UsersBox(self.users_listbox, title="Users") + self.users_box.delegate = self + self.users_gutter = UsersExpandGutter(self.app, self) + self.show_gutters = self.app.rrc_show_gutters + self._refresh_users_pane() + + self.users_visible = self.display.users_visible + self.columns = urwid.Columns([(urwid.WEIGHT, 1, self.chat_box)], dividechars=0, focus_column=0) + self._apply_users_visibility() + super().__init__(self.columns) + + def toggle_users(self): + self.users_visible = not self.users_visible + self.display.users_visible = self.users_visible + self._apply_users_visibility() + + def _apply_users_visibility(self): + if self.users_visible: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_box, self.columns.options(urwid.GIVEN, RoomWidget.USERS_PANE_WIDTH)), + ] + else: + if self.show_gutters: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_gutter, self.columns.options(urwid.GIVEN, 1)), + ] + else: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)) + ] + + self.columns.focus_position = 0 + + def _refresh_users_pane(self): + g = self.app.ui.glyphs + walker = self.users_walker + if self.hub is None or self.room is None: + walker[:] = [urwid.Text("")] + return + members = self.hub.get_members(self.room) + own_hash = self.app.identity.hash if self.app.identity is not None else None + def _safe_name(raw): + if not raw: return "" + try: + if self.app.config["textui"]["sanitize_names"]: + return sanitize_name(str(raw)) or "" + return strip_modifiers(str(raw)) or "" + except Exception: + return str(raw or "") + + entries = [] + for m in members: + safe_name = _safe_name(self.hub.display_name_for(m)) + full_name = safe_name + safe_name = safe_name[:15]+"…" if len(safe_name) > 16 else safe_name + entries.append((safe_name, m, own_hash is not None and m == own_hash, full_name)) + entries.sort(key=lambda x: x[0].lower()) + + prev_focus_key = None + try: + prev_idx = walker.focus + if prev_idx is not None and 0 <= prev_idx < len(walker): + prev_focus_key = getattr(walker[prev_idx], "user_hash", None) + except Exception: + prev_focus_key = None + + rows = [urwid.Text(" "+str(len(entries))+" user"+("s" if len(entries) != 1 else ""))] + for name, peer_hash, is_self, full_name in entries: + if self.app.rrc_nick_colors: + style_state = default_state() + style_state["fg_color"] = get_nick_color(peer_hash, self.theme, self.app) + if is_self: + label = " "+g["arrow_r"]+" "+name + style = make_style(style_state) + else: + label = " "+g["peer"]+" "+name + style = make_style(style_state) + + else: + if is_self: + label = " "+g["arrow_r"]+" "+name + style = "list_trusted" + else: + label = " "+g["peer"]+" "+name + style = "connected_status" + entry = ChannelListEntry(label) + urwid.connect_signal(entry, "click", self.display.show_user_info, (self.hub, peer_hash, full_name)) + row = urwid.AttrMap(entry, style, "list_focus") + row.user_hash = peer_hash + rows.append(row) + if not entries: + rows.append(urwid.Text(" (no members)")) + + walker[:] = rows + + new_focus = None + if prev_focus_key is not None: + for idx, w in enumerate(walker): + if getattr(w, "user_hash", None) == prev_focus_key: + new_focus = idx + break + if new_focus is None: + for idx, w in enumerate(walker): + if hasattr(w, "user_hash"): + new_focus = idx + break + if new_focus is not None: + try: walker.set_focus(new_focus) + except Exception: pass + + def _update_peer_info(self): + if self.hub is None or self.room is None: + self.peer_info_widget.original_widget.set_text("") + return + + status_label = { + RRCHub.STATUS_DISCONNECTED: "Disconnected", + RRCHub.STATUS_CONNECTING: "Connecting", + RRCHub.STATUS_CONNECTED: "Connected", + RRCHub.STATUS_FAILED: "Failed", + }.get(self.hub.status, "") + + server = "" + if self.hub.hub_name: + server = " "+self.app.ui.glyphs["divider1"]+" "+self.hub.hub_name + if self.hub.hub_version: + server += " v"+self.hub.hub_version + left = " #"+self.room+server+" ("+self.hub.name+")" + right = status_label+" " + self.peer_info_widget.original_widget.set_text(left+" | "+right) + + def update_messages(self, replace=False): + msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] + widgets = [] + collapse = getattr(self.display, "collapse_join_part", False) + run = [] + + def flush_run(): + if not run: + return + widgets.append(_collapsed_joinpart_widget(self.app, len(run))) + run.clear() + + for m in msgs: + if collapse and _is_joinpart_system(m): + run.append(m) + continue + flush_run() + widgets.append(_message_widget(self.app, self.hub, self.room, m, link_delegate=self.link_delegate)) + flush_run() + + if not widgets: + widgets = [urwid.Text([("irc_system", " "+self.app.ui.glyphs["info"]+" No messages yet")])] + self._empty_placeholder = True + else: + self._empty_placeholder = False + + self.messagelist = _StickyMessageListBox(widgets, position=len(widgets)-1) + self.messagelist.name = "messagelist" + try: + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass + if replace and hasattr(self, "frame"): + self.frame.contents["body"] = (self.messagelist, None) + if hasattr(self, "users_walker"): + self._refresh_users_pane() + + def append_message(self, msg): + if self.messagelist is None: + self.update_messages(replace=True) + return + try: + widget = _message_widget(self.app, self.hub, self.room, msg, link_delegate=self.link_delegate) + wrapped = urwid.AttrMap(widget, None) + body = self.messagelist.get_body() + was_at_bottom = (self.messagelist.sticky_bottom + or getattr(self.messagelist, "bottom_is_visible", True)) + self.messagelist.sticky_bottom = was_at_bottom + if getattr(self, "_empty_placeholder", False): + del body[:] + self._empty_placeholder = False + + if self.hub.clean_last_removed > self.last_history_clean: + try: + with self.hub._lock: + hub_msgs = self.hub.get_messages(self.room, take_lock=False) if (self.hub is not None and self.room is not None) else [] + self.last_history_clean = time.time() + c = self.messagelist.body_len() + old = set() + for i in range(0, c): + msg = None + w = self.messagelist.get_item(i) + if hasattr(w, "_original_widget"): o = w._original_widget + else: o = None + if hasattr(o, "msg"): msg = o.msg + elif hasattr(w, "msg"): msg = w.msg + if msg and not msg in hub_msgs: old.add(w) + + list_body = self.messagelist.get_body() + for w in list(old): + try: list_body.remove(w) + except: RNS.log(f"Could not remove expired message widget {w}: {e}", RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while cleaning room history", RNS.LOG_ERROR) + RNS.trace_exception(e) + + body.append(wrapped) + cap = getattr(self.app, "rrc_history_per_room_cap", 0) + if cap and cap > 0: + while len(body) > cap: + del body[0] + if was_at_bottom: + try: + self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass + except Exception as e: + RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG) + self.update_messages(replace=True) + if hasattr(self, "users_walker"): + self._refresh_users_pane() + + def _on_editor_change(self, editor, old_text): + if self.messagelist is None: + return + try: + body = self.messagelist._listbox.body + if len(body) > 0: + self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") + self.messagelist.sticky_bottom = True + except Exception: + pass + + def send_message(self): + text = self.editor.get_edit_text() + if not text.strip(): + return + self.messagelist.sticky_bottom = True + if text.lstrip().startswith("/"): + self._handle_slash_command(text.lstrip()) + self.editor.set_edit_text("") + return + if self.hub.status != RRCHub.STATUS_CONNECTED: + try: + self.hub.connect() + except Exception: + pass + return + limit = self.hub.max_msg_body_bytes or 350 + if len(text.encode("utf-8")) > limit: + self._open_split_dialog(text, limit) + return + try: + self.hub.send_message(self.room, text) + self.editor.set_edit_text("") + except Exception as e: + RNS.log("Failed to send RRC message: "+str(e), RNS.LOG_ERROR) + + def _open_split_dialog(self, text, limit): + body_bytes = len(text.encode("utf-8")) + parts = _split_message(text, limit) + if not parts: + self._local_message("error", + "Message is "+str(body_bytes)+" bytes but per-message limit is too small to split.") + return + K = len(parts) + preview = parts[0] + if len(preview) > 70: + preview = preview[:70] + "…" + preview = preview.replace("\n", " ").replace("\t", " ") + + error_text = urwid.Text("") + + def cancel(sender): + self.display.close_dialog() + + def send_split(sender): + try: + self.messagelist.sticky_bottom = True + for p in parts: + self.hub.send_message(self.room, p) + self.editor.set_edit_text("") + self.display.close_dialog() + except Exception as e: + error_text.set_text(("error_text", "Send failed: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text(" Message is "+str(body_bytes)+" bytes."), + urwid.Text(" Hub limit : "+str(limit)+" bytes per message."), + urwid.Text(""), + urwid.Text(" Split into "+str(K)+" message"+("s" if K != 1 else "")+"."), + urwid.Text(" Preview of part 1:"), + urwid.AttrMap(urwid.Text(" "+preview), "irc_system"), + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Send Split", on_press=send_split)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel)), + ]) + ]), title="Message Too Long" + ) + dialog.delegate = self.display + self.display._show_dialog_overlay(dialog) + + def _local_message(self, kind, text): + from nomadnet.RRC import RRCMessage + msg = RRCMessage(kind, self.room, None, None, text, int(time.time()*1000)) + with self.hub._lock: + buf = self.hub.messages.setdefault(self.room, []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + self.hub.manager._notify_messages(self.hub, msg) + # printed /help + SLASH_HELP = [ + "/help - show this list", + "/ping - measure round-trip to hub", + "/list - list public rooms on this hub", + "/join - join a room on this hub", + "/part [room] - leave a room (default: current)", + "/leave [room] - alias for /part", + "/me - send an action (e.g. /me waves)", + "/nick - set your nick on this hub only", + "/who [room] - list users (current room if omitted)", + "/names [room] - alias for /who", + "/clear - clear local messages in this room", + "/connect - connect this hub", + "/disconnect - disconnect this hub", + "/quit - alias for /disconnect", + "", + "Server-side commands (auth enforced by hub):", + "/topic [text] - view or set room topic", + "/mode [+-flags] [arg] - view or set room modes", + "/register - register the current room", + "/unregister - unregister the current room", + "/kick - remove user from room", + "/ban add|del|list [target] - room ban list", + "/invite add|del|list [target] - room invite list", + "/op - grant op", + "/deop - revoke op", + "/voice - grant voice", + "/devoice - revoke voice", + "/kline add|del|list [target] - global ban", + "/stats - server statistics", + "/reload - reload server config", + ] + + # commands that we forward to the server verbatim + SERVER_SLASH_COMMANDS = { + "who", "names", + "topic", "mode", "kick", "kline", + "ban", "invite", "kline", + "op", "deop", "voice", "devoice", + "register", "unregister", + "stats", "reload", + } + + def _require_connected(self): + if self.hub.status != RRCHub.STATUS_CONNECTED: + self._local_message("error", "Not connected to hub") + return False + return True + + def _handle_slash_command(self, text): + parts = text[1:].split(None, 1) + if not parts or not parts[0]: + self._local_message("error", "Empty command") + return + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + + if cmd == "help": + for line in self.SLASH_HELP: + self._local_message("system", line) + return + + if cmd == "ping": + if not self._require_connected(): + return + try: + self.hub.send_ping(room=self.room) + self._local_message("system", "Ping sent") + except Exception as e: + self._local_message("error", "Ping failed: "+str(e)) + return + + if cmd == "list": + if not self._require_connected(): + return + try: + self.hub.send_command("/list", room=self.room) + except Exception as e: + self._local_message("error", "/list failed: "+str(e)) + return + + if cmd in ("join", "j"): + if not arg: + self._local_message("error", "Usage: /join ") + return + target = arg.lstrip("#").strip() + try: + self.hub.add_room(target) + if self.hub.status == RRCHub.STATUS_CONNECTED: + self.hub.join_room(target) + self.display.update_list() + self.display._select_room(None, (self.hub, target.lower())) + except Exception as e: + self._local_message("error", "Join failed: "+str(e)) + return + + if cmd in ("part", "leave"): + target = (arg.lstrip("#").strip().lower()) if arg else self.room + try: + self.hub.part_room(target) + self.display.update_list() + if target == self.room: + self.display.show_placeholder() + except Exception as e: + self._local_message("error", "Part failed: "+str(e)) + return + + if cmd == "me": + if not self._require_connected(): + return + if not arg: + self._local_message("error", "Usage: /me ") + return + limit = self.hub.max_msg_body_bytes or 350 + if len(arg.encode("utf-8")) > limit: + self._local_message("error", "Action too long (max "+str(limit)+" bytes)") + return + try: + self.hub.send_action(self.room, arg) + except Exception as e: + self._local_message("error", "/me failed: "+str(e)) + return + + if cmd == "nick": + if not arg: + cur = self.hub.get_effective_nick() or " unset" + src = "nick: " if (isinstance(self.hub.nick_override, str) and self.hub.nick_override) else "global" + self._local_message("system", "Nick on this hub: "+cur+" ("+src+")") + return + limit = self.hub.max_nick_bytes or 32 + if len(arg.encode("utf-8")) > limit: + self._local_message("error", "Nick too long (max "+str(limit)+" bytes)") + return + try: + self.hub.set_nick_override(arg) + self._local_message("system", "Nick on this hub set to "+arg+ + " (use /nick with no argument to view)") + except Exception as e: + self._local_message("error", "Nick change failed: "+str(e)) + return + + if cmd == "clear": + self.hub.clear_messages(self.room) + self.update_messages(replace=True) + return + + if cmd == "connect": + try: + self.hub.connect() + self._local_message("system", "Connecting...") + except Exception as e: + self._local_message("error", "Connect failed: "+str(e)) + return + + if cmd in ("disconnect", "quit"): + try: + self.hub.disconnect() + except Exception as e: + self._local_message("error", "Disconnect failed: "+str(e)) + return + + if cmd in self.SERVER_SLASH_COMMANDS: + if not self._require_connected(): + return + try: + self.hub.send_command("/"+cmd+(" "+arg if arg else ""), room=self.room) + except Exception as e: + self._local_message("error", "/"+cmd+" failed: "+str(e)) + return + + self._local_message("error", "Unknown command: /"+cmd+" (try /help)") + + def leave_room(self): + try: + self.hub.part_room(self.room) + except Exception: + pass + self.display.update_list() + self.display.show_placeholder() + + +def _ts_prefix(ts_ms): + t = _format_ts(ts_ms) if ts_ms else " " + return ("irc_ts", " ["+t+"] ") + +def _ts_prefix_raw(ts_ms): + t = _format_ts(ts_ms) if ts_ms else " " + return "["+t+"] " + + +class _ChatLinkDelegate: + def __init__(self, display, hub): + self.display = display + self.hub = hub + self.app = display.app + self.last_keypress = 0 + + def marked_link(self, target, fields=None): + pass + + def micron_released_focus(self): + pass + + def handle_link(self, target, fields=None): + if target is None: return + try: + components = target.split("://") + if len(components) < 2: return + kind = components[0] + payload = components[1] + if kind == "room": self._open_room(payload.lstrip("#")) + elif kind == "lxmf": self._open_lxmf(payload.lstrip("lxmf@")) + elif kind == "page": + final_url = payload + if fields: + final_url += "`" + for f in fields: final_url += f"{f}|" + final_url.rstrip("|") + self._open_page(final_url) + else: RNS.log(f"Invalid URL: {target}", RNS.LOG_WARNING) + except Exception as e: RNS.log("Chat link handler failed: "+str(e), RNS.LOG_ERROR) + + def _open_room(self, room): + room = (room or "").strip().lower() + if not room: + return + if room not in self.hub.rooms and self.hub.status == RRCHub.STATUS_CONNECTED: + try: self.hub.join_room(room) + except Exception: pass + self.hub.add_room(room) + self.display.update_list() + self.display._select_room(None, (self.hub, room)) + + def _open_lxmf(self, hash_hex): + try: + bytes.fromhex(hash_hex) + except Exception: + return + from nomadnet.Directory import DirectoryEntry + existing = [c[0] for c in nomadnet.Conversation.conversation_list(self.app)] + if hash_hex not in existing: + display_name = None + try: + data = RNS.Identity.recall_app_data(bytes.fromhex(hash_hex)) + if data is not None: + import LXMF + display_name = LXMF.display_name_from_app_data(data) + except Exception: + pass + try: + self.app.directory.remember(DirectoryEntry(bytes.fromhex(hash_hex), display_name=display_name)) + except Exception: + pass + try: + nomadnet.Conversation(hash_hex, self.app, initiator=True) + except Exception: + pass + conversations = self.app.ui.main_display.sub_displays.conversations_display + try: + trust_level = self.app.directory.trust_level(bytes.fromhex(hash_hex)) + except Exception: + trust_level = DirectoryEntry.UNKNOWN + target_filter = (conversations.LIST_FILTER_TRUSTED if trust_level == DirectoryEntry.TRUSTED + else conversations.LIST_FILTER_UNTRUSTED) + if conversations.list_filter != target_filter: + conversations._set_filter(target_filter) + else: + conversations.update_conversation_list() + conversations.display_conversation(None, hash_hex) + self.app.ui.main_display.show_conversations(None) + + def _open_page(self, url): + if not url: + return + self.app.ui.main_display.show_network(None) + try: + self.app.ui.main_display.sub_displays.network_display.browser.retrieve_url(url) + except Exception as e: + RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) + + +_MOTD_ROOM_RE = re.compile(r"(?", "#"] +mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) +def _message_widget(app, hub, room, m, link_delegate=None): + t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light + g = app.ui.glyphs + own_nick = None + try: + if hub is not None: + own_nick = hub.get_effective_nick() + else: + own_nick = app.rrc.get_nickname() + except Exception: + pass + + if m.kind == "system": + evt_icon = g["arrow_l"] if m.text.endswith(" left") else g["arrow_r"] + spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick) + if m.text.endswith(" left") or m.text.endswith(" joined"): spans = [(s[0], sanitize_name(s[1])) for s in spans] + markup = [_ts_prefix(m.ts), ("irc_system", evt_icon+" ")] + spans + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget + + if m.kind == "notice": + spans, has_links = _body_markup(m.text or "", body_attr="irc_notice", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_notice", g["info"]+" ")] + spans + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget + + if m.kind == "error": + spans, has_links = _body_markup(m.text or "", body_attr="irc_error", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_error", g["warning"]+" ")] + spans + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget + + own = False + try: + if hub is not None and m.src is not None and app.identity is not None: + own = bytes(m.src) == app.identity.hash + except Exception: + pass + + if m.nick: sender = sanitize_name(m.nick) + elif isinstance(m.src, (bytes, bytearray)): sender = _short_hash(m.src) + else: sender = "?" + + if isinstance(m.src, (bytes, bytearray)): + if not str(room) in room_nick_src_cache: room_nick_src_cache[str(room)] = {} + room_nick_src_cache[str(room)][str(m.nick)] = m.src + + nick_attr = "irc_nick_self" if own else "irc_nick_peer" + body = m.text or "" + spans, has_links = _body_markup(body, body_attr="body_text", own_nick=own_nick, check_links=False) + ld = link_delegate if has_links else None + + irc_ts = f"`F{t['ts']}" + message_body = "" + for span in spans: + ms = span[0] + mb = span[1] + if ms.startswith("irc_mention"): + if not app.rrc_nick_colors: + if app.rrc_mention_color: mention_color = f"`FT{app.rrc_mention_color}" + else: mention_color = f"`F{t['mention']}" + message_body += f"`!{mention_color}{mb}`f`!" + if app.rrc_color_mention_timestamps: irc_ts = f"`F{t['mention']}" + + else: + try: + if not app.rrc_mention_color: own_nick_color = get_nick_color(app.identity.hash, t, app) + else: own_nick_color = app.rrc_mention_color + message_body += f"`!`FT{own_nick_color}{mb}`f`!" + if app.rrc_color_mention_timestamps: irc_ts = f"`FT{own_nick_color}" + + except: message_body += f"`!`F{t['mention']}{mb}`f`!" + + elif ms.startswith("nick_mention"): + if not app.rrc_nick_colors: message_body += f"{mb}" + else: + mentioned_nick = mb[1:] + nick_src = get_nick_src(hub, room, mentioned_nick) + if not nick_src: message_body += f"{mb}" + else: + nick_color = get_nick_color(nick_src, t, app) + message_body += f"`!`FT{nick_color}{mb}`f`!" + + elif ms.startswith("link_"): + kind = ms[len("link_"):] + comps = mb.split("`") + label = comps[0] + fields = "|"+comps[1].replace("`", "") if len(comps) > 1 else "" + url = f"{kind}://{mb}" + link_mu = f"`_`F{t['link']}`[{label}{fields}`{url}]`f`_" + link_md = f"[{label}]({url})" + message_body += link_mu + + else: + # This is a hack for now to avoid start-of-span + # being interpreted as start-of-line by the micron + # parser, because the link-detection logic splits + # up lines into spans for link detection. Since + # We're now using the micron engine, we could + # probably avoid this alltogether. But it needs + # another kind of link handling, and I'm not going + # to write that right now, so little hack it is. + if mb[0:1] in invalid_span_starts: yanked = mb[0:1]; mb = mb[1:] + else: yanked = "" + + if app.rrc_ui_render_micron: + mbo = mdc.format_block(mb) if app.rrc_ui_render_markdown else mb + mbo = unescape_micron(mbo) + message_body += yanked+strip_non_formatting_tags(mbo) + + else: + mbo = mdc.format_block(strip_escaped_micron(mb)) if app.rrc_ui_render_markdown else strip_escaped_micron(mb) + message_body += yanked+strip_non_formatting_tags(mbo) + + if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t, app)}" + else: nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" + + prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}`f" + if m.kind == "action": + nick_micron = f" `*`f{nick_attr}{sender}`f`* " + message_body = f"`*`f{nick_attr}{strip_micron(message_body)}`f`*" + else: + nick_micron = f"`f{nick_attr}<{sender}>`f " + + if app.rrc_ui_justify_msgs: + prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"]) + body_rendered = _render_body(f"{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) + if app.rrc_ui_space_msgs: body_rendered.append(urwid.Text("")) + columns = urwid.Columns([(urwid.PACK, urwid.Pile(prefix_rendered)), urwid.Pile(body_rendered)], dividechars=1) + final_widget = urwid.Padding(columns, left=1) + + else: + rendered = _render_body(f"{prefix_micron}{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) + if app.rrc_ui_space_msgs: rendered.append(urwid.Text("")) + final_widget = urwid.Padding(urwid.Pile(rendered), left=1) + + final_widget.msg = m + return final_widget + +def _render_body(markup, link_delegate=None, fg="bbb"): + try: return markup_to_attrmaps(strip_modifiers(markup), url_delegate=link_delegate, link_class=_ChatLinkableText, fg_color=fg) + except Exception as e: + RNS.trace_exception(e) + return [] + +def _wrap_text(markup, link_delegate): + if link_delegate is not None: + return _ChatLinkableText(markup, align="left", delegate=link_delegate) + return urwid.Text(markup) + + +class ChannelsDisplay(): + list_width = 0.33 + given_list_width = 36 + + def __init__(self, app): + self.app = app + self.dialog_open = False + self.list_widgets = [] + self.selected_key = None + self.current_room_widget = None + self.users_visible = True + self.channel_list_visible = True + self.collapse_join_part = False + self._room_drafts = {} + + self._build_listbox() + self.gutter = ChannelsExpandGutter(self.app, self) + self.show_gutters = self.app.rrc_show_gutters + + self.list_shortcuts = ChannelsListShortcuts(self.app) + self.room_shortcuts = ChannelsRoomShortcuts(self.app) + self.room_body_shortcuts = ChannelsRoomBodyShortcuts(self.app) + self.shortcuts_display = self.list_shortcuts + + self.placeholder = urwid.LineBox(urwid.Filler(urwid.Text("\n Select or add a hub to begin", align=urwid.CENTER), "top")) + self.right = self.placeholder + + self.columns_widget = urwid.Columns( + [ + (ChannelsDisplay.given_list_width, self.listbox), + (urwid.WEIGHT, 1, self.right), + ], + dividechars=0, focus_column=0, box_columns=[0], + ) + self.widget = urwid.WidgetPlaceholder(self.columns_widget) + + self._pending_actions = collections.deque() + self._wake_fd = None + try: + self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending) + except Exception: + pass + + self._mention_bell_last = {} + + self.app.rrc.set_change_callback(self._on_rrc_change) + self.app.rrc.set_message_callback(self._on_rrc_message) + + def _set_right_widget(self, widget): + prev = getattr(self, "right", None) + if isinstance(prev, RoomWidget) and prev is not widget: + self._save_room_draft(prev) + self.right = widget + if widget is self.placeholder: + # The placeholder has nothing to interact with, so always keep the + # hub/channel pane visible (and focused) beside it. + self.channel_list_visible = True + self._apply_channel_list_visibility(focus_right=False) + try: + self.columns_widget.focus_position = 0 + except Exception: + pass + else: + self._apply_channel_list_visibility(focus_right=True) + + def _draft_key(self, hub, room): + try: + return (hub.hub_hash, hub.dest_name, room) + except Exception: + return None + + def _save_room_draft(self, room_widget): + try: + key = self._draft_key(room_widget.hub, room_widget.room) + if key is None: + return + text = room_widget.editor.get_edit_text() + if text: + self._room_drafts[key] = text + else: + self._room_drafts.pop(key, None) + except Exception: + pass + + def _restore_room_draft(self, room_widget): + try: + key = self._draft_key(room_widget.hub, room_widget.room) + if key is None: + return + text = self._room_drafts.get(key) + if text: + room_widget.editor.set_edit_text(text) + room_widget.editor.set_edit_pos(len(text)) + except Exception: + pass + + def toggle_channel_list(self): + if self.channel_list_visible and self.right is self.placeholder: + return + self.channel_list_visible = not self.channel_list_visible + self._apply_channel_list_visibility() + + def toggle_join_part_collapse(self): + self.collapse_join_part = not self.collapse_join_part + if self.current_room_widget is not None: + try: + self.current_room_widget.update_messages(replace=True) + except Exception: + pass + + def _apply_channel_list_visibility(self, focus_right=False): + list_opts = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width) + gutter_opts = self.columns_widget.options(urwid.GIVEN, 1) + right_opts = self.columns_widget.options(urwid.WEIGHT, 1) + if self.channel_list_visible: + self.columns_widget.contents = [ + (self.listbox, list_opts), + (self.right, right_opts), + ] + if focus_right: + try: self.columns_widget.focus_position = 1 + except Exception: pass + else: + if self.show_gutters: + self.columns_widget.contents = [ + (self.gutter, gutter_opts), + (self.right, right_opts), + ] + else: + self.columns_widget.contents = [ + (self.right, right_opts), + ] + try: self.columns_widget.focus_position = 1 + except Exception: pass + + def start(self): + self.update_list() + + def shortcuts(self): + try: + focus_path = self.columns_widget.get_focus_path() + except Exception: + focus_path = None + if focus_path and focus_path[0] == 1 and self.current_room_widget is not None: + try: + frame = self.current_room_widget.frame + if frame is not None and frame.focus_position == "body": + return self.room_body_shortcuts + except Exception: + pass + return self.room_shortcuts + return self.list_shortcuts + + def _build_listbox(self): + self._compose_list_widgets() + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=lambda a, b: None, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels") + self.listbox.delegate = self + + def _compose_list_widgets(self): + widgets = [] + manager = self.app.rrc + + if not manager.hubs: + entry = urwid.AttrMap(urwid.Text("\n No hubs yet. Press Ctrl-N to add one."), "list_unknown") + widgets.append(entry) + self.list_widgets = widgets + return + + g = self.app.ui.glyphs + for hub_idx, hub in enumerate(manager.hubs): + if hub_idx > 0: + spacer = urwid.Text("") + spacer.row_kind = "spacer" + widgets.append(spacer) + if hub.status == RRCHub.STATUS_CONNECTED: + status_glyph = g["check"] + style = "list_trusted" + elif hub.status == RRCHub.STATUS_CONNECTING: + status_glyph = g["info"] + style = "list_unresponsive" + elif hub.status == RRCHub.STATUS_FAILED: + status_glyph = g["cross"] + style = "list_untrusted" + else: + status_glyph = " " + style = "list_unknown" + + entry = ChannelListEntry(status_glyph+" "+hub.name) + urwid.connect_signal(entry, "click", self._select_hub, hub) + attr = urwid.AttrMap(entry, style, "list_focus") + attr.row_kind = "hub" + attr.hub = hub + attr.room = None + widgets.append(attr) + + for room in sorted(list(hub.rooms | set(hub.messages.keys()))): + if not room: + continue + is_joined = room in hub.rooms + mentioned = room in hub.mention_rooms + unread = room in hub.unread_rooms + if mentioned: + marker = g["warning"] + room_style = "irc_mention" + elif unread: + marker = g["unread"] + room_style = "list_unresponsive" + elif not is_joined: + marker = " " + room_style = "list_unknown" + else: + marker = " " + room_style = "list_trusted" if hub.status == RRCHub.STATUS_CONNECTED else "list_unknown" + room_entry = ChannelListEntry(" "+marker+" #"+room) + urwid.connect_signal(room_entry, "click", self._select_room, (hub, room)) + room_attr = urwid.AttrMap(room_entry, room_style, "list_focus") + room_attr.row_kind = "room" + room_attr.hub = hub + room_attr.room = room + widgets.append(room_attr) + + self.list_widgets = widgets + + def update_list(self): + prev_key = self.selected_key + self._compose_list_widgets() + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=lambda a, b: None, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels") + self.listbox.delegate = self + + if not self.dialog_open and self.channel_list_visible: + options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width) + self.columns_widget.contents[0] = (self.listbox, options) + + if prev_key is not None: + for idx, w in enumerate(self.list_widgets): + key = self._row_key(w) + if key == prev_key: + try: self.ilb.select_item(idx) + except Exception: pass + break + + self._refresh_active_header() + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _row_key(self, w): + if not hasattr(w, "row_kind"): + return None + if w.row_kind == "hub": + return ("hub", w.hub.hub_hash, w.hub.dest_name) + if w.row_kind == "room": + return ("room", w.hub.hub_hash, w.hub.dest_name, w.room) + return None + + def _refresh_active_header(self): + if self.current_room_widget is not None: + try: + self.current_room_widget._update_peer_info() + except Exception: + pass + return + if self.selected_key and self.selected_key[0] == "hub": + for h in self.app.rrc.hubs: + if h.hub_hash == self.selected_key[1] and h.dest_name == self.selected_key[2]: + self._show_hub_info(h) + break + + def _select_hub(self, sender, hub): + self.selected_key = ("hub", hub.hub_hash, hub.dest_name) + self.app.rrc.set_active(hub, None) + self._maybe_autoconnect(hub) + self._show_hub_info(hub) + + def _select_room(self, sender, payload): + hub, room = payload + self.selected_key = ("room", hub.hub_hash, hub.dest_name, room) + self.app.rrc.set_active(hub, room) + self._maybe_autoconnect(hub) + if room not in hub.rooms: + if hub.status == RRCHub.STATUS_CONNECTED: + try: hub.join_room(room) + except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR) + else: + try: hub.add_room(room) + except Exception as e: RNS.log("Pending join queue failed: "+str(e), RNS.LOG_ERROR) + self._show_room(hub, room) + + def _maybe_autoconnect(self, hub): + if hub.status in (RRCHub.STATUS_DISCONNECTED, RRCHub.STATUS_FAILED): + try: + hub.connect() + except Exception as e: + RNS.log("Auto-connect failed: "+str(e), RNS.LOG_ERROR) + + def _show_hub_info(self, hub): + g = self.app.ui.glyphs + status_label = { + RRCHub.STATUS_DISCONNECTED: "Disconnected", + RRCHub.STATUS_CONNECTING: "Connecting", + RRCHub.STATUS_CONNECTED: "Connected", + RRCHub.STATUS_FAILED: "Failed", + }.get(hub.status, "") + status_attr = { + RRCHub.STATUS_DISCONNECTED: "list_unknown", + RRCHub.STATUS_CONNECTING: "list_unresponsive", + RRCHub.STATUS_CONNECTED: "connected_status", + RRCHub.STATUS_FAILED: "list_untrusted", + }.get(hub.status, "list_unknown") + + lines = [ + urwid.Text(""), + urwid.Text(" Hub : "+hub.name), + urwid.Text(" Address : "+hub.hub_hash.hex()), + urwid.AttrMap(urwid.Text(" Status : "+status_label+" ("+hub.status_text+")"), status_attr), + ] + if hub.hub_name: + ver = " v"+str(hub.hub_version) if hub.hub_version else "" + lines.append(urwid.Text(" Server : "+str(hub.hub_name)+ver)) + + ar_glyph = g["check"] if hub.auto_reconnect else g["cross"] + ar_attr = "list_trusted" if hub.auto_reconnect else "list_unknown" + ar_text = "On" if hub.auto_reconnect else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoRcn : "+ar_glyph+" "+ar_text+" (Ctrl-T to toggle)"), ar_attr)) + + al_glyph = g["check"] if hub.auto_list else g["cross"] + al_attr = "list_trusted" if hub.auto_list else "list_unknown" + al_text = "On" if hub.auto_list else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoList : "+al_glyph+" "+al_text+" (Ctrl-E to edit)"), al_attr)) + + aw_glyph = g["check"] if hub.auto_who else g["cross"] + aw_attr = "list_trusted" if hub.auto_who else "list_unknown" + aw_text = "On" if hub.auto_who else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoWho : "+aw_glyph+" "+aw_text+" (Ctrl-E to edit)"), aw_attr)) + + lines.append(urwid.Divider(g["divider1"])) + + if hub.status == RRCHub.STATUS_CONNECTED: + lines.append(urwid.Text(" Connected. Use Ctrl-A to add a room.")) + elif hub.status == RRCHub.STATUS_CONNECTING: + lines.append(urwid.AttrMap(urwid.Text(" Connecting..."), "list_unresponsive")) + else: + lines.append(urwid.Text(" Use Ctrl-R to connect.")) + + if hub.motd: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" MOTD:")) + motd_delegate = _ChatLinkDelegate(self, hub) + try: + motd_widgets = markup_to_attrmaps(_linkify_motd(hub.motd), url_delegate=motd_delegate) + except Exception: + motd_widgets = [urwid.Text(hub.motd)] + for w in motd_widgets: + lines.append(urwid.Padding(w, left=2)) + + if hub.rooms: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" Joined rooms:")) + for r in sorted(hub.rooms): + entry = ChannelListEntry(" #"+r) + urwid.connect_signal(entry, "click", self._select_room, (hub, r)) + lines.append(urwid.AttrMap(entry, "list_trusted", "list_focus")) + + available = sorted( + (name, topic) for name, topic in hub.available_rooms.items() + if name and name not in hub.rooms + ) + if available: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" Available rooms:")) + for name, topic in available: + label = " #"+name + if topic: + label += " "+g["arrow_r"]+" "+topic + entry = ChannelListEntry(label) + urwid.connect_signal(entry, "click", self._select_room, (hub, name)) + lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus")) + + body = ScrollBar(Scrollable(urwid.Pile(lines)), thumb_char="┃", trough_char=" ") + info = HubInfoArea(urwid.AttrMap(body, "scrollbar"), title=hub.name) + info.delegate = self + self.current_room_widget = None + self._set_right_widget(info) + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def show_placeholder(self): + self.current_room_widget = None + self.selected_key = None + self._set_right_widget(self.placeholder) + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def _show_room(self, hub, room): + widget = RoomWidget(self, hub, room) + self.current_room_widget = widget + self._set_right_widget(widget) + self._restore_room_draft(widget) + self.columns_widget.focus_position = len(self.columns_widget.contents)-1 + self.shortcuts_display = self.room_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def _selected_row(self): + item = self.ilb.get_selected_item() + if item is None: + return None + return item + + def connect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + try: + item.hub.connect() + except Exception as e: + RNS.log("Connect failed: "+str(e), RNS.LOG_ERROR) + + def disconnect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + try: + item.hub.disconnect() + except Exception: + pass + + def toggle_auto_reconnect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + item.hub.set_auto_reconnect(not item.hub.auto_reconnect) + if self.current_room_widget is None: + self._show_hub_info(item.hub) + + def remove_selected_dialog(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + hub = item.hub + room = getattr(item, "room", None) + + def confirmed(sender): + self.close_dialog() + if room is not None: + try: hub.part_room(room) + except Exception: pass + hub.remove_room(room) + else: + self.app.rrc.remove_hub(hub) + self.update_list() + self.show_placeholder() + + def dismiss(sender): + self.close_dialog() + + if room is not None: + prompt = "Leave and remove room\n#"+room+"\non hub "+hub.name+"?" + else: + prompt = "Remove hub\n"+hub.name+"\nfrom this client?\n All Message history will be discarded." + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(prompt+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss)), + ]) + ]), title="?" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def new_hub_dialog(self): + e_hash = ReadlineEdit(caption="Hub address : ", edit_text="") + e_name = ReadlineEdit(caption="Display name: ", edit_text="") + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + hh_text = e_hash.get_edit_text().strip().lower() + if hh_text.startswith("0x"): + hh_text = hh_text[2:] + hh = bytes.fromhex(hh_text) + if len(hh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + raise ValueError("Hash length must be "+str(RNS.Reticulum.TRUNCATED_HASHLENGTH//8)+" bytes") + nm = e_name.get_edit_text().strip() or None + self.app.rrc.add_hub(hh, name=nm) + self.close_dialog() + self.update_list() + except Exception as e: + error_text.set_text(("error_text", "Could not add hub: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + e_hash, + e_name, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Add", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="New Hub" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def confirm_new_hub_dialog(self, hub_hash, dest_name, room): + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + hub = self.app.rrc.add_hub(hub_hash, dest_name=dest_name) + self.close_dialog() + self.update_list() + if room: + self._select_room(None, (hub, room)) + else: + self._select_hub(None, hub) + except Exception as e: + error_text.set_text(("error_text", "Could not add hub: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text(" A page is requesting to open an RRC hub."), + urwid.Text(""), + urwid.Text(" Address : "+hub_hash.hex()), + urwid.Text(" Aspect : "+(dest_name or "rrc.hub")), + urwid.Text(" Room : "+("#"+room if room else "(none)")), + urwid.Text(""), + urwid.AttrMap(urwid.Text( + " Opening will add this hub to your client,"), "list_unknown"), + urwid.AttrMap(urwid.Text( + " and reveal your identity hash to the hub"), "list_unknown"), + urwid.AttrMap(urwid.Text( + " to the hub operator."), "list_unknown"), + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Open", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]) + ]), title="Open RRC hub?" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def edit_hub_dialog(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + hub = item.hub + + e_name = ReadlineEdit(caption="Display name : ", edit_text=hub.name or "") + cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect) + cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list) + cb_autowho = urwid.CheckBox("Auto-fetch members on room join", state=hub.auto_who) + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + nm = e_name.get_edit_text().strip() or hub.name + hub.name = nm + hub.set_auto_reconnect(cb_autorcn.get_state(), save=False) + hub.set_auto_list(cb_autolist.get_state(), save=False) + hub.set_auto_who(cb_autowho.get_state(), save=False) + self.app.rrc.save() + self.close_dialog() + self.update_list() + if self.selected_key and self.selected_key[0] == "hub" and self.selected_key[1] == hub.hub_hash: + self._show_hub_info(hub) + except Exception as e: + error_text.set_text(("error_text", "Could not save: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(" Address : "+hub.hub_hash.hex()), + urwid.Text(" Server : "+(hub.hub_name or "(unknown until connected)")), + urwid.Divider(self.app.ui.glyphs["divider1"]), + e_name, + urwid.Text(""), + cb_autorcn, + cb_autolist, + cb_autowho, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="Edit Hub" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def join_room_dialog(self): + item = self._selected_row() + hub = None + if item is not None and hasattr(item, "hub"): + hub = item.hub + if hub is None: + if self.app.rrc.hubs: + hub = self.app.rrc.hubs[0] + else: + return + + e_room = ReadlineEdit(caption="Room : #", edit_text="") + e_key = ReadlineEdit(caption="Key : ", edit_text="", mask="*") + error_text = urwid.Text("") + + key_section_placeholder = urwid.WidgetPlaceholder(urwid.Text("")) + + def update_key_visibility(checkbox, state): + if state: + key_section_placeholder.original_widget = e_key + else: + key_section_placeholder.original_widget = urwid.Text("") + + cb_key = urwid.CheckBox("Keyed room (+k)", state=False, on_state_change=update_key_visibility) + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + room = e_room.get_edit_text().strip() + if not room: + raise ValueError("Room name is required") + key = e_key.get_edit_text().strip() if cb_key.get_state() else None + key = key or None + hub.add_room(room) + if hub.status == RRCHub.STATUS_CONNECTED: + hub.join_room(room, key=key) + self.close_dialog() + self.update_list() + self._select_room(None, (hub, room.lower())) + except Exception as e: + error_text.set_text(("error_text", "Could not join: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(" Hub : "+hub.name), + e_room, + cb_key, + key_section_placeholder, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Join", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="Add Room" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def show_user_info(self, sender, payload): + try: + hub, peer_hash, display_name = payload + except Exception: + return + if not isinstance(peer_hash, (bytes, bytearray)): + return + + peer_hash = bytes(peer_hash) + identity_hex = RNS.hexrep(peer_hash, delimit=False) + own_hash = self.app.identity.hash if self.app.identity is not None else None + is_self = (own_hash is not None and peer_hash == own_hash) + + lxmf_hex = None + try: + peer_identity = RNS.Identity.recall(peer_hash, from_identity_hash=True) + if peer_identity is not None: + lxmf_dest = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", peer_identity) + lxmf_hex = RNS.hexrep(lxmf_dest, delimit=False) + except Exception: + pass + + def on_close(_b): + self.close_dialog() + + def on_open(_b): + self.close_dialog() + if lxmf_hex is None: + return + try: + _ChatLinkDelegate(self, hub)._open_lxmf(lxmf_hex) + except Exception as e: + RNS.log("Could not open conversation: "+str(e), RNS.LOG_ERROR) + + safe_name = "" + try: + if display_name: + if self.app.config["textui"]["sanitize_names"]: + safe_name = sanitize_name(str(display_name)) or "" + else: + safe_name = strip_modifiers(str(display_name)) or "" + except Exception: + safe_name = "" + + lines = [ + urwid.Text(""), + urwid.Text(" Nick : "+safe_name), + urwid.Text(" Identity : "+identity_hex), + ] + if lxmf_hex: + lines.append(urwid.Text(" LXMF : "+lxmf_hex)) + + if is_self: + lines.append(urwid.Text("")) + lines.append(urwid.Text(" (This is you)", align=urwid.CENTER)) + lines.append(urwid.Text("")) + lines.append(urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Button("Close", on_press=on_close)), + ])) + else: + if lxmf_hex is None: + lines.append(urwid.Text("")) + lines.append(urwid.Text(" Identity not in local cache;", align=urwid.CENTER)) + lines.append(urwid.Text(" conversation can't be opened until", align=urwid.CENTER)) + lines.append(urwid.Text(" the peer announces.", align=urwid.CENTER)) + lines.append(urwid.Text("")) + lines.append(urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Button("Close", on_press=on_close)), + ])) + else: + lines.append(urwid.Text("")) + lines.append(urwid.Columns([ + (urwid.WEIGHT, 0.55, urwid.Button("Open Conversation", on_press=on_open)), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.40, urwid.Button("Close", on_press=on_close)), + ])) + + dialog = ChannelsDialogLineBox(urwid.Pile(lines), title="User Info") + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def _show_dialog_overlay(self, dialog): + self.dialog_open = True + overlay = urwid.Overlay( + dialog, + self.columns_widget, + align=urwid.CENTER, + width=(urwid.RELATIVE, 60), + min_width=40, + valign=urwid.MIDDLE, + height=urwid.PACK, + ) + self.widget.original_widget = overlay + + def close_dialog(self): + self.dialog_open = False + self.widget.original_widget = self.columns_widget + + def _process_pending(self, data): + while True: + try: + action = self._pending_actions.popleft() + except IndexError: + break + try: + action() + except Exception as e: + RNS.log("RRC UI action failed: "+str(e), RNS.LOG_ERROR) + return True + + def _wake(self, action): + self._pending_actions.append(action) + if self._wake_fd is not None: + try: + os.write(self._wake_fd, b".") + return + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None)) + except Exception: + pass + + def _on_rrc_change(self, hub): + def action(): + self.update_list() + if (self.current_room_widget is not None + and self.current_room_widget.hub is hub): + try: + self.current_room_widget._refresh_users_pane() + except Exception: + pass + elif (self.selected_key + and self.selected_key[0] == "hub" + and self.selected_key[1] == hub.hub_hash + and self.selected_key[2] == hub.dest_name): + try: + self._show_hub_info(hub) + except Exception: + pass + self._wake(action) + + def _on_rrc_message(self, hub, msg): + def action(): + is_active = (self.current_room_widget is not None + and self.current_room_widget.hub is hub + and self.current_room_widget.room == msg.room) + if getattr(msg, "mention", False) and not is_active: + self._ring_mention_bell(hub, msg.room) + if is_active: + self.current_room_widget.append_message(msg) + self.update_list() + self._wake(action) + + def _ring_mention_bell(self, hub, room): + key = (hub.hub_hash, room or "") + now = time.monotonic() + last = self._mention_bell_last.get(key, 0.0) + if now - last < 5.0: + return + self._mention_bell_last[key] = now + try: + import sys + sys.stdout.write("\x07") + sys.stdout.flush() + except Exception: + pass diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py index 6b7039f..8c54f08 100644 --- a/nomadnet/ui/textui/Config.py +++ b/nomadnet/ui/textui/Config.py @@ -1,47 +1,1236 @@ +import RNS import nomadnet import urwid import platform +from RNS.vendor.configobj import ConfigObj +from nomadnet.util import write_config_atomic +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * + +CONFIG_SECTION_GLYPHS = { + ("logging", "(L)", "≡", ""), + ("client", "(C)", "✉", ""), + ("utilities", "(Y)", "⚒", ""), + ("textui", "(U)", "▤", ""), + ("rrc", "(R)", "❖", ""), + ("node", "(N)", "Ⓝ", "\U000f0002"), + ("printing", "(P)", "⎙", ""), + ("reticulum", "(Rt)", "⇄", ""), + ("editor", "(E)", "✎", ""), +} + +def _get_section_icon(glyphset, name): + glyphset_index = 1 + if glyphset == "plain": + glyphset_index = 0 + elif glyphset == "nerdfont": + glyphset_index = 2 + + for glyph_tuple in CONFIG_SECTION_GLYPHS: + if glyph_tuple[0] == name: + return glyph_tuple[glyphset_index + 1] + + return "(#)" if glyphset == "plain" else "⚙" if glyphset == "unicode" else "" + +def _get_cols_rows(): + return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() + +def section_title_attr(section_key): + return "config_title_" + section_key + +class ConfigCheckbox(FormCheckbox): + def __init__(self, config_key, on_text="Enabled", off_text="Disabled", state=False, validation_types=None, transform=None, **kwargs): + self.on_text = on_text + self.off_text = off_text + super().__init__(config_key, label="", state=state, validation_types=validation_types, transform=transform, **kwargs) + self._refresh_label(self.get_state()) + urwid.connect_signal(self, "change", self._on_change) + + def _on_change(self, widget, new_state): + self._refresh_label(new_state) + + def _refresh_label(self, state): + if state: + self.set_label(("connected_status", " " + self.on_text)) + else: + self.set_label(("inactive_text", " " + self.off_text)) + +CONFIG_SECTIONS = [ + { + "key": "logging", + "name": "Logging", + "glyph": "logging", + "description": "Log verbosity and where log output is written", + "fields": [ + { + "config_key": "loglevel", + "label": "Log level: ", + "type": "dropdown", + "options": ["0", "1", "2", "3", "4", "5", "6", "7"], + "default": "4", + "help": "0 = critical, 4 = info, 7 = extreme verbosity", + }, + { + "config_key": "destination", + "label": "Destination: ", + "type": "dropdown", + "options": ["file", "stdout"], + "default": "file", + "help": "Write log to a file or to standard output", + }, + { + "config_key": "logfile", + "label": "Log file: ", + "type": "edit", + "placeholder": "Defaults to the config directory", + "help": "Path to the log file (when destination is file)", + }, + ], + }, + { + "key": "client", + "name": "LXMF client", + "glyph": "client", + "description": "Client behaviour, announces, syncing and messages", + "fields": [ + { + "config_key": "downloads_path", + "label": "Downloads path: ", + "type": "edit", + "default": "~/Downloads", + "placeholder": "~/Downloads", + "help": "Where downloaded files are saved", + }, + { + "config_key": "attachment_save_path", + "label": "Attachment path: ", + "type": "edit", + "placeholder": "Defaults to the downloads path", + "help": "Where message attachments are saved", + }, + { + "config_key": "notify_on_new_message", + "label": "Notify on message: ", + "type": "checkbox", + "default": True, + "help": "Show a notification when a new message arrives", + }, + { + "config_key": "announce_at_start", + "label": "Announce at start: ", + "type": "checkbox", + "default": True, + "help": "Announce the local peer when starting", + }, + { + "config_key": "announce_interval", + "label": "Announce interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between peer announces (minimum 30)", + }, + { + "config_key": "try_propagation_on_send_fail", + "label": "Propagate on fail: ", + "type": "checkbox", + "default": True, + "help": "Use a propagation node if direct delivery fails", + }, + { + "config_key": "periodic_lxmf_sync", + "label": "Periodic sync: ", + "type": "checkbox", + "default": True, + "help": "Automatically sync messages from a propagation node", + }, + { + "config_key": "lxmf_sync_interval", + "label": "Sync interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between automatic syncs", + }, + { + "config_key": "lxmf_sync_limit", + "label": "Sync limit: ", + "type": "edit", + "default": "8", + "validation": ["number"], + "help": "Max messages fetched per sync (0 = no limit)", + }, + { + "config_key": "required_stamp_cost", + "label": "Required stamp cost: ", + "type": "edit", + "default": "None", + "placeholder": "None", + "validation": ["stamp_cost"], + "help": "Required inbound stamp cost 1-255, or None to disable", + }, + { + "config_key": "accept_invalid_stamps", + "label": "Accept invalid stamps: ", + "type": "checkbox", + "default": False, + "help": "Accept messages carrying an invalid stamp", + }, + { + "config_key": "max_accepted_size", + "label": "Max message size: ", + "type": "edit", + "default": "500", + "validation": ["float"], + "help": "Maximum accepted incoming message size, in KB", + }, + { + "config_key": "compact_announce_stream", + "label": "Compact announces: ", + "type": "checkbox", + "default": True, + "help": "Show only one announce per destination", + }, + { + "config_key": "compose_in_markdown", + "label": "Compose in markdown: ", + "type": "checkbox", + "default": True, + "help": "Author messages using markdown", + }, + ], + }, + { + "key": "textui", + "name": "Text UI", + "glyph": "textui", + "description": "Appearance and behaviour of the text interface", + "note": "Switch between the light and dark theme, glyphs and other appearance options here.", + "fields": [ + { + "config_key": "intro_time", + "label": "Intro time: ", + "type": "edit", + "default": "1", + "validation": ["float"], + "help": "Seconds to show the intro screen at startup (0 to disable)", + }, + { + "config_key": "intro_text", + "label": "Intro text: ", + "type": "edit", + "default": "Nomad Network", + "placeholder": "Nomad Network", + "help": "Text shown on the intro screen", + }, + { + "config_key": "theme", + "label": "Theme: ", + "type": "dropdown", + "options": ["dark", "light"], + "default": "dark", + "help": "Colour theme", + }, + { + "config_key": "colormode", + "label": "Color mode: ", + "type": "dropdown", + "options": ["monochrome", "16", "88", "256", "24bit"], + "default": "24bit", + "help": "Colour capability of your terminal", + }, + { + "config_key": "image_loading", + "label": "Image Loading: ", + "type": "dropdown", + "options": ["never", "manual", "auto", "always"], + "default": "auto", + "help": "How, or if at all, to load page images", + }, + { + "config_key": "glyphs", + "label": "Glyphs: ", + "type": "dropdown", + "options": ["plain", "unicode", "nerdfont"], + "default": "nerdfont", + "help": "Glyph set used for icons", + }, + { + "config_key": "mouse_enabled", + "label": "Mouse enabled: ", + "type": "checkbox", + "default": True, + "help": "Allow mouse interaction", + }, + { + "config_key": "editor", + "label": "Editor: ", + "type": "edit", + "default": "nano", + "placeholder": "nano", + "help": "Command used to edit text", + }, + { + "config_key": "hide_guide", + "label": "Hide guide: ", + "type": "checkbox", + "default": False, + "help": "Hide the Guide entry in the menu", + }, + { + "config_key": "sanitize_names", + "label": "Sanitize names: ", + "type": "checkbox", + "default": True, + "help": "Strip control characters from announced names", + }, + { + "config_key": "clipboard_copy", + "label": "Clipboard copy: ", + "type": "checkbox", + "default": False, + "help": "Enable copying to the system clipboard", + }, + { + "config_key": "animation_interval", + "label": "Animation interval: ", + "type": "edit", + "default": "1", + "validation": ["number"], + "help": "Interval between UI animation frames", + }, + ], + }, + { + "key": "utilities", + "name": "Utilities", + "glyph": "utilities", + "description": "Optional utility views and tools", + "note": "Adds a Utilities menu where you can view path/announce tables and probe destinations.", + "fields": [ + { + "config_key": "enable_utilities", + "label": "Enable utilities: ", + "type": "checkbox", + "default": False, + "help": "Show the Utilities section (path tables, probe) in the menu", + }, + ], + }, + { + "key": "rrc", + "name": "RRC Client Settings", + "glyph": "rrc", + "description": "Rendering and history for the rooms client", + "fields": [ + { + "config_key": "history_per_room_cap", + "label": "History cap: ", + "type": "edit", + "default": "500", + "validation": ["number"], + "help": "Messages kept in memory per room", + }, + { + "config_key": "filter_loaded_history", + "label": "Filter history: ", + "type": "checkbox", + "default": True, + "help": "Filter system messages out of loaded history", + }, + { + "config_key": "ephemeral_notices", + "label": "Ephemeral notices: ", + "type": "edit", + "default": "10", + "validation": ["float"], + "help": "Minutes to keep notices (0 keeps them)", + }, + { + "config_key": "color_mention_timestamps", + "label": "Color mention time: ", + "type": "checkbox", + "default": True, + "help": "Colour the timestamp when you are mentioned", + }, + { + "config_key": "render_markdown", + "label": "Render markdown: ", + "type": "checkbox", + "default": True, + "help": "Render markdown formatting in messages", + }, + { + "config_key": "render_micron", + "label": "Render micron: ", + "type": "checkbox", + "default": True, + "help": "Render micron markup in messages", + }, + { + "config_key": "nick_colors", + "label": "Nick colors: ", + "type": "checkbox", + "default": True, + "help": "Colour user nicknames", + }, + { + "config_key": "justify_msgs", + "label": "Justify messages: ", + "type": "checkbox", + "default": True, + "help": "Justify message text", + }, + { + "config_key": "space_msgs", + "label": "Space messages: ", + "type": "checkbox", + "default": False, + "help": "Add extra spacing between messages", + }, + { + "config_key": "show_gutters", + "label": "Show gutters: ", + "type": "checkbox", + "default": True, + "help": "Show gutters alongside messages", + }, + { + "config_key": "mention_color", + "label": "Mention color: ", + "type": "edit", + "placeholder": "6-digit hex, e.g. ff8800", + "help": "Hex colour used for mentions of your nick", + }, + { + "config_key": "nick_colors_theme", + "label": "Nick color theme: ", + "type": "list", + "default": [], + "placeholder": "6-digit hex colour", + "help": "Custom palette of hex colours used for nicknames", + }, + { + "config_key": "enable_esoterics", + "label": "Enable esoterics: ", + "type": "checkbox", + "default": False, + "help": "Enable esoteric features", + }, + ], + }, + { + "key": "node", + "name": "Node", + "glyph": "node", + "description": "Node hosting, propagation and announces", + "note": "Enable this to host a node on the network, serving pages and files to others.", + "fields": [ + { + "config_key": "enable_node", + "label": "Enable node: ", + "type": "checkbox", + "default": False, + "help": "Host a node on the network", + }, + { + "config_key": "node_name", + "label": "Node name: ", + "type": "edit", + "default": "None", + "placeholder": "None", + "help": "Display name for the node, or None", + }, + { + "config_key": "announce_at_start", + "label": "Announce at start: ", + "type": "checkbox", + "default": True, + "help": "Announce the node when starting", + }, + { + "config_key": "announce_interval", + "label": "Announce interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between node announces (minimum 1)", + }, + { + "config_key": "disable_propagation", + "label": "Disable propagation: ", + "type": "checkbox", + "default": True, + "help": "Disable the propagation node functionality", + }, + { + "config_key": "propagation_cost", + "label": "Propagation cost: ", + "type": "edit", + "default": "16", + "validation": ["number"], + "help": "Stamp cost for propagation (minimum 13)", + }, + { + "config_key": "max_transfer_size", + "label": "Max transfer size: ", + "type": "edit", + "default": "256", + "validation": ["float"], + "help": "Maximum single message size on propagation, in KB", + }, + { + "config_key": "max_sync_size", + "label": "Max sync size: ", + "type": "edit", + "default": "10240", + "validation": ["float"], + "help": "Maximum sync batch size, in KB", + }, + { + "config_key": "pages_path", + "label": "Pages path: ", + "type": "edit", + "placeholder": "Default node pages directory", + "help": "Directory of pages served by the node", + }, + { + "config_key": "page_refresh_interval", + "label": "Page refresh: ", + "type": "edit", + "default": "0", + "validation": ["number"], + "help": "Minutes between page rescans (0 = never)", + }, + { + "config_key": "files_path", + "label": "Files path: ", + "type": "edit", + "placeholder": "Default node files directory", + "help": "Directory of files served by the node", + }, + { + "config_key": "file_refresh_interval", + "label": "File refresh: ", + "type": "edit", + "default": "0", + "validation": ["number"], + "help": "Minutes between file rescans (0 = never)", + }, + { + "config_key": "prioritise_destinations", + "label": "Priority dests: ", + "type": "list", + "default": [], + "placeholder": "Destination hash", + "help": "Destination hashes given storage priority", + }, + { + "config_key": "static_peers", + "label": "Static peers: ", + "type": "list", + "default": [], + "placeholder": "Destination hash", + "help": "Propagation peers always kept connected", + }, + { + "config_key": "max_peers", + "label": "Max peers: ", + "type": "edit", + "placeholder": "No limit", + "validation": ["number"], + "help": "Maximum number of propagation peers", + }, + { + "config_key": "message_storage_limit", + "label": "Storage limit: ", + "type": "edit", + "default": "2000", + "validation": ["float"], + "help": "Maximum propagation storage, in MB", + }, + ], + }, + { + "key": "printing", + "name": "Printing", + "glyph": "printing", + "description": "Printing of received messages", + "note": "The rendered message file is appended to this command, so it runs as e.g. 'lp '.", + "fields": [ + { + "config_key": "print_messages", + "label": "Print messages: ", + "type": "checkbox", + "default": False, + "help": "Print received messages", + }, + { + "config_key": "print_command", + "label": "Print command: ", + "type": "edit", + "default": "lp", + "placeholder": "lp", + "help": ("Command to print with; the message file is appended as the last argument. Examples: " + "lp | " + "lp -d NAME (named printer, list with lpstat -p) | " + "lp -d NAME -o cpi=16 -o lpi=8 (small thermal-roll printers) | " + "lp -d NAME -o media=Custom.58x210mm (58mm thermal roll)"), + }, + { + "config_key": "print_from", + "label": "Print from: ", + "type": "list", + "default": [], + "placeholder": "everywhere, trusted, or hash", + "help": "Which senders may trigger automatic printing", + }, + { + "config_key": "message_template", + "label": "Message template: ", + "type": "edit", + "placeholder": "Path to template file", + "help": "Template file used when printing messages", + }, + ], + }, + { + "key": "reticulum", + "name": "Reticulum (global)", + "glyph": "reticulum", + "configfile": "reticulum", + "description": "Global Reticulum settings (shared instance, transport)", + "fields": [ + { + "config_key": "enable_transport", + "label": "Enable transport: ", + "type": "checkbox", + "default": False, + "help": "Route traffic for other peers (transport node)", + }, + { + "config_key": "share_instance", + "label": "Share instance: ", + "type": "checkbox", + "default": True, + "help": "Run a shared instance other local programs connect to", + }, + { + "config_key": "instance_name", + "label": "Instance name: ", + "type": "edit", + "default": "default", + "placeholder": "default", + "help": "Name used to isolate multiple shared instances", + }, + { + "config_key": "shared_instance_type", + "label": "Instance type: ", + "type": "edit", + "placeholder": "unix or tcp (blank for auto)", + "help": "Transport used for shared instance communication", + }, + { + "config_key": "shared_instance_port", + "label": "Instance port: ", + "type": "edit", + "placeholder": "37428", + "validation": ["number"], + "help": "TCP port for the shared instance", + }, + { + "config_key": "instance_control_port", + "label": "Control port: ", + "type": "edit", + "placeholder": "37429", + "validation": ["number"], + "help": "TCP control port for the shared instance", + }, + { + "config_key": "discover_interfaces", + "label": "Discover interfaces: ", + "type": "checkbox", + "default": False, + "help": "Discover interfaces advertised by transport instances", + }, + { + "config_key": "enable_remote_management", + "label": "Remote management: ", + "type": "checkbox", + "default": False, + "help": "Allow remote management of this instance", + }, + { + "config_key": "remote_management_allowed", + "label": "Remote allowed: ", + "type": "list", + "default": [], + "placeholder": "Identity hash", + "help": "Identity hashes allowed to manage this instance", + }, + { + "config_key": "respond_to_probes", + "label": "Respond to probes: ", + "type": "checkbox", + "default": False, + "help": "Answer reachability probes from other nodes", + }, + { + "config_key": "use_implicit_proof", + "label": "Implicit proof: ", + "type": "checkbox", + "default": True, + "help": "Use implicit proofs for packet delivery", + }, + { + "config_key": "panic_on_interface_error", + "label": "Panic on iface error: ", + "type": "checkbox", + "default": False, + "help": "Exit if an interface has an unrecoverable error", + }, + ], + }, +] + +_SECTION_ORDER = {"node": 0, "client": 1, "logging": 2, "textui": 3, "utilities": 4, "rrc": 5, "printing": 6, "reticulum": 7} +CONFIG_SECTIONS.sort(key=lambda s: _SECTION_ORDER.get(s["key"], 99)) + class ConfigDisplayShortcuts(): def __init__(self, app): - import urwid self.app = app + self.default_shortcuts = "[Enter] Edit Section [C-w] Text Editor [Up/Down] Navigate" + self.widget = urwid.AttrMap(urwid.Text(self.default_shortcuts), "shortcutbar") - self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") + def update_shortcuts(self, new_shortcuts): + self.widget.original_widget.set_text(new_shortcuts) + + def reset_shortcuts(self): + self.update_shortcuts(self.default_shortcuts) + + def set_section_shortcuts(self): + self.update_shortcuts("[Up/Down] Navigate Fields [Enter] Select Option [C-s] Save [C-r] Reset [Esc] Cancel") class ConfigFiller(urwid.WidgetWrap): def __init__(self, widget, app): self.app = app - self.filler = urwid.Filler(widget, "top") - urwid.WidgetWrap.__init__(self, self.filler) + super().__init__(widget) + def keypress(self, size, key): + if key == "ctrl w": + self.app.ui.main_display.sub_displays.config_display.open_text_editor() + return None + return super(ConfigFiller, self).keypress(size, key) + +class SelectableConfigSection(urwid.WidgetWrap): + def __init__(self, parent, section_key, name, description, icon, note=None): + self.parent = parent + self.section_key = section_key + self.name = name + self.icon = icon + self._selectable = True + + self.title_attr = section_title_attr(section_key) + self.title_attr_selected = self.title_attr + "_selected" + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text((self.title_attr, f"{icon} {name}")) + + title_content = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + + description_row = urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("value", description)), + ]) + + pile_rows = [title_content, description_row] + if note: + pile_rows.append(urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("inactive_text", note)), + ])) + + pile = urwid.Pile(pile_rows) + padded_body = urwid.Padding(pile, left=2, right=2) + + box = urwid.LineBox( + padded_body, + title=None, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + super().__init__(box) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + + if focus: + self.title_widget.set_text((self.title_attr_selected, f"{self.icon} {self.name}")) + else: + self.title_widget.set_text((self.title_attr, f"{self.icon} {self.name}")) + + return super().render(size, focus=focus) def keypress(self, size, key): if key == "up": - self.app.ui.main_display.frame.set_focus("header") - - return super(ConfigFiller, self).keypress(size, key) + listbox = self.parent.list_box + if listbox.focus_position == 0: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": + self.parent.switch_to_section(self.section_key) + return None + return key + +class ConfigActionItem(urwid.WidgetWrap): + def __init__(self, parent, label, on_select): + self.parent = parent + self.on_select = on_select + self._selectable = True + + text_widget = urwid.Text(label, align="left") + super().__init__(urwid.AttrMap(text_widget, "list_normal", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == "enter": + self.on_select() + return None + return super().keypress(size, key) + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self.on_select() + return True + return False + +class ConfigSectionView(urwid.WidgetWrap): + LABEL_WIDTH = 24 + + def __init__(self, parent, section): + self.parent = parent + self.section = section + self.section_key = section["key"] + self.fields = {} + + self.parent.shortcuts_display.set_section_shortcuts() + + self.config_path = self._resolve_config_path(section) + self.file_config = ConfigObj(self.config_path) + section_config = self.file_config.get(self.section_key, {}) + + cols, rows = _get_cols_rows() + self.content_width = min(80, max(40, cols - 6)) + + icon = _get_section_icon(self.parent.glyphset, section["glyph"]) + + field_items = [] + for field in section["fields"]: + widget = self._build_field_widget(field) + current = section_config.get(field["config_key"], field.get("default")) + self._populate_field(widget, field, current) + self.fields[field["config_key"]] = {"field": field, "widget": widget} + field_items.append(self._build_field_row(field, widget)) + + self.list_walker = urwid.SimpleFocusListWalker(field_items) + self.form_listbox = urwid.ListBox(self.list_walker) + + header = urwid.Pile([ + urwid.Text((section_title_attr(self.section_key) + "_selected", f"{icon} {section['name']}"), align=urwid.CENTER), + urwid.Divider("─"), + ]) + + save_btn = urwid.AttrMap(urwid.Button("Save", on_press=self.on_save), "button_normal", focus_map="button_focus") + reset_btn = urwid.AttrMap(urwid.Button("Reset", on_press=self.on_reset), "button_normal", focus_map="button_focus") + back_btn = urwid.AttrMap(urwid.Button("Cancel", on_press=self.on_back), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, save_btn), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, reset_btn), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, back_btn), + ]) + self._footer = urwid.Pile([ + urwid.Divider("─"), + urwid.Padding(button_row, align=urwid.CENTER, width=self.content_width), + ]) + try: + self._footer.focus_position = 1 + except Exception: + pass + + self._frame = urwid.Frame(self.form_listbox, header=header, footer=self._footer) + super().__init__(self._frame) + + def _resolve_config_path(self, section): + if section.get("configfile") == "reticulum": + try: + return self.parent.app.rns.config.filename + except Exception: + return RNS.Reticulum.configpath + return self.parent.app.configpath + + def _build_field_row(self, field, widget): + row = urwid.Columns([ + (self.LABEL_WIDTH, urwid.Text(("key", field["label"]), align=urwid.RIGHT)), + widget, + ]) + contents = [row] + help_text = self._field_help(field) + if help_text: + contents.append(urwid.Padding(urwid.Text(("inactive_text", help_text)), left=self.LABEL_WIDTH + 2)) + contents.append(urwid.Padding(widget.error_widget, left=self.LABEL_WIDTH + 2)) + contents.append(urwid.Divider()) + field_pile = urwid.Pile(contents) + return urwid.Padding(field_pile, align=urwid.CENTER, width=self.content_width) + + def _field_help(self, field): + parts = [] + if field.get("help"): + parts.append(field["help"]) + default = field.get("default", None) + if field["type"] == "checkbox": + default_text = "yes" if default else "no" + elif field["type"] == "list": + if not default: + default_text = "none" + elif isinstance(default, (list, tuple)): + default_text = ", ".join(str(item) for item in default) + else: + default_text = str(default) + else: + default_text = str(default) if default not in (None, "") else "none" + parts.append(f"(default: {default_text})") + return " ".join(parts) + + def _build_field_widget(self, field): + if field["type"] == "dropdown": + return FormDropdown( + config_key=field["config_key"], + label=field.get("label", ""), + options=field["options"], + default=str(field.get("default", field["options"][0])), + validation_types=field.get("validation", []), + ) + elif field["type"] == "checkbox": + return ConfigCheckbox( + config_key=field["config_key"], + on_text=field.get("on_text", "Enabled"), + off_text=field.get("off_text", "Disabled"), + state=bool(field.get("default", False)), + validation_types=field.get("validation", []), + ) + elif field["type"] == "list": + return FormMultiList( + config_key=field["config_key"], + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + ) + else: + return FormEdit( + config_key=field["config_key"], + caption="", + edit_text=str(field.get("default", "")), + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + ) + + def _populate_field(self, widget, field, value): + if value is None: + return + + if field["type"] == "checkbox": + if isinstance(value, bool): + state = value + else: + s = str(value).strip().lower() + if s in ("", "false", "off", "no"): + state = False + else: + try: + state = float(s) != 0 + except ValueError: + state = True + widget.set_state(state) + elif field["type"] == "dropdown": + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + elif field["type"] == "list": + if value: + widget.set_value(value if isinstance(value, list) else [value]) + else: + if isinstance(value, (list, tuple)): + widget.edit_text = ", ".join(str(item) for item in value) + else: + widget.edit_text = str(value) + + def validate_all(self): + all_valid = True + for entry in self.fields.values(): + if not entry["widget"].validate(): + all_valid = False + return all_valid + + def _widget_write_value(self, field, widget): + if field["type"] == "checkbox": + return field.get("on_value", "yes") if widget.get_state() else field.get("off_value", "no") + return widget.get_value() + + def _default_write_value(self, field): + default = field.get("default") + if field["type"] == "checkbox": + return field.get("on_value", "yes") if bool(default) else field.get("off_value", "no") + if default is None: + return "" + if isinstance(default, (list, tuple)): + return list(default) + return str(default) + + def on_save(self, button): + if not self.validate_all(): + self.show_message("Some fields have invalid values.\nCorrect the marked fields and try again.", title="Not saved", back_to_list=False) + return + + try: + self.file_config.reload() + except Exception: + pass + + section_existed = self.section_key in self.file_config + if not section_existed: + self.file_config[self.section_key] = {} + + section_config = self.file_config[self.section_key] + + for config_key, entry in self.fields.items(): + field = entry["field"] + widget = entry["widget"] + present = config_key in section_config + value = self._widget_write_value(field, widget) + + if not value: + if present: + del section_config[config_key] + elif present or value != self._default_write_value(field): + section_config[config_key] = value + + if not section_existed and not section_config: + del self.file_config[self.section_key] + + try: + write_config_atomic(self.file_config) + if self.section.get("configfile") == "reticulum": + try: + self.parent.app.rns.config.reload() + except Exception: + pass + self.parent.mark_restart_pending() + self.show_message(f"{self.section['name']} configuration saved.\nRestart Nomad Network for changes to take effect.") + except Exception as e: + self.show_message(f"Error saving configuration: {str(e)}", title="Error", back_to_list=False) + + def on_reset(self, button=None): + self._confirm( + f"Reset all {self.section['name']} settings to their defaults?\nYou will still need to Save to apply.", + self._do_reset, + ) + + def _do_reset(self): + for entry in self.fields.values(): + self._reset_field(entry["widget"], entry["field"]) + + def _reset_field(self, widget, field): + default = field.get("default") + if field["type"] == "checkbox": + widget.set_state(bool(default)) + elif field["type"] == "dropdown": + default_str = str(default if default is not None else widget.options[0]) + if default_str in widget.options: + widget.selected = default_str + widget.main_button.base_widget.set_text(default_str) + elif field["type"] == "list": + widget.set_value(default if isinstance(default, list) else []) + else: + widget.edit_text = "" if default is None else str(default) + widget.error_widget.set_text("") + + def on_back(self, button): + self.parent.switch_to_list() + + def dismiss_dialog(self): + self._dismiss_overlay() + + def _confirm(self, message, on_yes): + def yes(button): + self._dismiss_overlay() + on_yes() + + def no(button): + self._dismiss_overlay() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align=urwid.CENTER), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]), + parent=self, + title="Confirm", + ) + self._show_overlay(dialog, height=9, width=64) + + def _show_overlay(self, dialog, height=8, width=60): + overlay = urwid.Overlay( + dialog, + self, + align=urwid.CENTER, + width=width, + valign=urwid.MIDDLE, + height=height, + min_width=1, + min_height=1, + ) + self.parent.widget = overlay + self.parent.app.ui.main_display.update_active_sub_display() + + def _dismiss_overlay(self): + self.parent.widget = self + self.parent.app.ui.main_display.update_active_sub_display() + + def show_message(self, message, title="Notice", back_to_list=True): + def dismiss_dialog(button=None): + if back_to_list: + self.parent.switch_to_list() + else: + self._dismiss_overlay() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align=urwid.CENTER), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + parent=self, + title=title + ) + + self._show_overlay(dialog, height=10, width=60) + + def keypress(self, size, key): + if key == "ctrl s": + self.on_save(None) + return None + if key == "ctrl r": + self.on_reset(None) + return None + if key == "esc": + self.parent.switch_to_list() + return None + + result = super().keypress(size, key) + if result is None: + return None + + if result in ("down", "tab") and self._frame.focus_position != "footer": + self._frame.focus_position = "footer" + try: + self._footer.focus_position = 1 + except Exception: + pass + return None + if result == "up": + if self._frame.focus_position == "footer": + self._frame.focus_position = "body" + else: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + + return result class ConfigDisplay(): def __init__(self, app): - import urwid self.app = app + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.restart_pending = False + self.editor_term = None - def open_editor(sender): - self.editor_term = EditorTerminal(self.app, self) - self.widget = urwid.LineBox(self.editor_term) - self.app.ui.main_display.update_active_sub_display() - self.app.ui.main_display.frame.set_focus("body") - self.editor_term.term.change_focus(True) - - pile = urwid.Pile([ - urwid.Text(("body_text", "\nTo change the configuration, edit the config file located at:\n\n"+self.app.configpath+"\n\nRestart Nomad Network for changes to take effect\n"), align="center"), - urwid.Padding(urwid.Button("Open Editor", on_press=open_editor), width=15, align="center"), - ]) - - self.config_explainer = ConfigFiller(pile, self.app) self.shortcuts_display = ConfigDisplayShortcuts(self.app) - self.widget = self.config_explainer + + self._build_list_view() + self.widget = self.list_view + + def _build_list_view(self): + self.section_items = [] + for section in CONFIG_SECTIONS: + icon = _get_section_icon(self.glyphset, section["glyph"]) + self.section_items.append( + SelectableConfigSection(self, section["key"], section["name"], section["description"], icon, note=section.get("note")) + ) + + editor_icon = _get_section_icon(self.glyphset, "editor") + self.editor_item = ConfigActionItem(self, f"{editor_icon} Open config file in text editor", self.open_text_editor) + + list_contents = list(self.section_items) + [urwid.Divider(), self.editor_item] + self.list_walker = urwid.SimpleFocusListWalker(list_contents) + self.list_box = urwid.ListBox(self.list_walker) + + self.header_text = urwid.Text("") + self.list_divider = urwid.Divider("─") + self.list_pile = urwid.Pile([('weight', 1, self.list_box)]) + self._update_header() + + self.list_view = ConfigFiller(self.list_pile, self.app) + + def _update_header(self): + contents = [] + if self.restart_pending: + self.header_text.set_text(("warning_text", "Changes saved. Restart NomadNet for them to take effect")) + contents.append((self.header_text, self.list_pile.options('pack'))) + contents.append((self.list_divider, self.list_pile.options('pack'))) + contents.append((self.list_box, self.list_pile.options('weight', 1))) + self.list_pile.contents = contents + + def mark_restart_pending(self): + self.restart_pending = True + self._update_header() + + def switch_to_section(self, section_key): + section = next(s for s in CONFIG_SECTIONS if s["key"] == section_key) + self.section_view = ConfigSectionView(self, section) + self.widget = self.section_view + self.app.ui.main_display.update_active_sub_display() + + def switch_to_list(self): + self.shortcuts_display.reset_shortcuts() + self.editor_term = None + self._update_header() + self.widget = self.list_view + self.app.ui.main_display.update_active_sub_display() + + def open_text_editor(self): + self.editor_term = EditorTerminal(self.app, self) + self.widget = urwid.LineBox(self.editor_term) + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.editor_term.term.change_focus(True) def shortcuts(self): return self.shortcuts_display @@ -64,18 +1253,13 @@ class EditorTerminal(urwid.WidgetWrap): ) def quit_term(*args, **kwargs): - self.parent.widget = self.parent.config_explainer - self.app.ui.main_display.update_active_sub_display() - self.app.ui.main_display.show_config(None) + self.parent.switch_to_list() self.app.ui.main_display.request_redraw() urwid.connect_signal(self.term, 'closed', quit_term) - urwid.WidgetWrap.__init__(self, self.term) + super().__init__(self.term) def keypress(self, size, key): - # TODO: Decide whether there should be a way to get out while editing - #if key == "up": - # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") - return super(EditorTerminal, self).keypress(size, key) \ No newline at end of file + return super(EditorTerminal, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 90b7034..6410abf 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1,26 +1,89 @@ import RNS +import RNS.vendor.umsgpack as msgpack +import collections import os +import shutil import time import nomadnet import LXMF import urwid -from datetime import datetime +from datetime import datetime, timedelta from nomadnet.Directory import DirectoryEntry +from LXMF import pn_announce_data_is_valid, PN_META_NAME +from nomadnet.Conversation import ConversationMessage +from .FileBrowser import FileBrowser + +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name + +from RNS.Utilities.rngit.util import MarkdownToMicron +from RNS.Utilities.rngit.highlight import SyntaxHighlighter +from .MicronParser import markup_to_attrmaps +from .Helpers import ClickableIcon, osc52_copy, qr_ascii +from .ReadlineEdit import ReadlineMixin, ReadlineEdit +from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags +from nomadnet.ui import THEME_DARK, THEME_LIGHT + +def relative_time(timestamp): + now = time.time() + delta = now - timestamp + if delta < 0: + return "just now" + elif delta < 60: + return "just now" + elif delta < 3600: + m = int(delta / 60) + return str(m)+"m ago" + elif delta < 86400: + h = int(delta / 3600) + return str(h)+"h ago" + else: + days = (datetime.fromtimestamp(now).date() - datetime.fromtimestamp(timestamp).date()).days + if days <= 1: + return "yesterday" + elif days < 7: + return str(days)+"d ago" + elif days < 30: + return str(days // 7)+"w ago" + else: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d") + + +def _format_size(size): + if size < 1024: + return str(size)+" B" + elif size < 1048576: + return str(round(size/1024, 1))+" KB" + else: + return str(round(size/1048576, 1))+" MB" + + from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox class ConversationListDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[Enter] Open [C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-o] Sort [C-p] My LXMF [C-g] Fullscreen"), "shortcutbar") class ConversationDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-k] Clear [C-w] Close [C-t] Title [C-p] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-v] Delivery [C-r] Details [C-s] Save [Tab] ↑ Messages"), "shortcutbar") + +class ConversationBodyShortcuts(): + def __init__(self, app): + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("[C-r] Details [C-v] Delivery [C-o] Sort [C-s] Save [C-u] Purge [C-k] Cancel Send [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar") + +class TabButton(urwid.Button): + button_left = urwid.Text("[") + button_right = urwid.Text("]") + class ConversationsArea(urwid.LineBox): def keypress(self, size, key): @@ -30,84 +93,494 @@ class ConversationsArea(urwid.LineBox): self.delegate.delete_selected_conversation() elif key == "ctrl n": self.delegate.new_conversation() + elif key == "ctrl u": + self.delegate.ingest_lxm_uri() elif key == "ctrl r": self.delegate.sync_conversations() + elif key == "ctrl g": + self.delegate.toggle_fullscreen() + elif key == "ctrl o": + self.delegate.toggle_list_sort() + elif key == "ctrl p": + self.delegate.show_my_qr() elif key == "tab": - self.delegate.app.ui.main_display.frame.set_focus("header") - elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): - self.delegate.app.ui.main_display.frame.set_focus("header") + self.delegate.app.ui.main_display.frame.focus_position = "header" + elif key == "up": + if self.delegate.ilb.body_is_empty(): + self.delegate.app.ui.main_display.frame.focus_position = "header" + return None + result = super(ConversationsArea, self).keypress(size, key) + if result == "up": + self.delegate.app.ui.main_display.frame.focus_position = "header" + return None + return result else: return super(ConversationsArea, self).keypress(size, key) +class PropNodePicker(urwid.WidgetWrap): + def __init__(self, options, current_hash, on_change): + self._options = list(options) + self._current = current_hash + self._on_change = on_change + self._pile = urwid.Pile([]) + super().__init__(self._pile) + self._show_collapsed() + + def _label_for(self, h): + for ph, label in self._options: + if ph == h: + return label + if h is not None: + return "<"+RNS.hexrep(h, delimit=False)+">" + return "(select propagation node)" + + def _show_collapsed(self): + btn = urwid.Button("▾ "+self._label_for(self._current)) + urwid.connect_signal(btn, "click", self._on_expand_click) + self._pile.contents = [ + (urwid.AttrMap(btn, None, focus_map="list_focus"), self._pile.options()), + ] + + def _on_expand_click(self, _btn): + self._show_expanded() + + def _on_back_click(self, _btn): + self._show_collapsed() + + def _make_row_click(self, picked_hash): + def _click(_btn): + try: + self._current = picked_hash + self._on_change(picked_hash) + except Exception as e: + RNS.log("Propagation node change handler failed: "+str(e), RNS.LOG_ERROR) + self._show_collapsed() + return _click + + def _show_expanded(self): + rows = [] + for ph, label in self._options: + marker = "● " if ph == self._current else "○ " + row = urwid.Button(marker+label) + urwid.connect_signal(row, "click", self._make_row_click(ph)) + rows.append(urwid.AttrMap(row, None, focus_map="list_focus")) + + if not rows: + rows.append(urwid.Text(" (no propagation nodes seen yet)")) + + list_height = min(10, max(3, len(rows))) + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(rows)) + boxed = urwid.BoxAdapter(listbox, list_height) + + back = urwid.Button("◀ Back") + urwid.connect_signal(back, "click", self._on_back_click) + + self._pile.contents = [ + (urwid.AttrMap(back, None, focus_map="list_focus"), self._pile.options()), + (boxed, self._pile.options()), + ] + + class DialogLineBox(urwid.LineBox): def keypress(self, size, key): if key == "esc": - self.delegate.update_conversation_list() + if hasattr(self.delegate, "update_conversation_list"): + self.delegate.update_conversation_list() + elif hasattr(self.delegate, "dialog_active"): + self.delegate.dialog_active = False + self.delegate.conversation_changed(None) else: return super(DialogLineBox, self).keypress(size, key) class ConversationsDisplay(): list_width = 0.33 + given_list_width = 52 cached_conversation_widgets = {} + SORT_RECENT = 0 + SORT_NAME = 1 + + LIST_FILTER_TRUSTED = "trusted" + LIST_FILTER_UNTRUSTED = "untrusted" + def __init__(self, app): self.app = app self.dialog_open = False self.sync_dialog = None self.currently_displayed_conversation = None + self.list_sort_mode = ConversationsDisplay.SORT_RECENT + self.list_filter = ConversationsDisplay.LIST_FILTER_TRUSTED + self.show_blocked = False def disp_list_shortcuts(sender, arg1, arg2): self.shortcuts_display = self.list_shortcuts self.app.ui.main_display.update_active_shortcuts() + self._build_persistent_listbox() self.update_listbox() self.columns_widget = urwid.Columns( [ - ("weight", ConversationsDisplay.list_width, self.listbox), - ("weight", 1-ConversationsDisplay.list_width, self.make_conversation_widget(None)) + # (urwid.WEIGHT, ConversationsDisplay.list_width, self.listbox), + # (urwid.WEIGHT, 1-ConversationsDisplay.list_width, self.make_conversation_widget(None)) + (ConversationsDisplay.given_list_width, self.listbox), + (urwid.WEIGHT, 1, self.make_conversation_widget(None)) ], dividechars=0, focus_column=0, box_columns=[0] ) self.list_shortcuts = ConversationListDisplayShortcuts(self.app) self.editor_shortcuts = ConversationDisplayShortcuts(self.app) + self.body_shortcuts = ConversationBodyShortcuts(self.app) self.shortcuts_display = self.list_shortcuts - self.widget = self.columns_widget - nomadnet.Conversation.created_callback = self.update_conversation_list + self.widget = urwid.WidgetPlaceholder(self.columns_widget) + + self._pending_actions = collections.deque() + self._wake_fd = None + try: + self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending) + except Exception: + pass + + nomadnet.Conversation.created_callback = lambda: self._wake(self._conversations_changed) + + try: + self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) + except Exception: + pass + + def _conversations_changed(self): + self.update_conversation_list() + # If the identity keys for the displayed conversation just + # became known, enable its editor without requiring the user + # to close and reopen the conversation. + if self.currently_displayed_conversation != None: + widget = ConversationsDisplay.cached_conversation_widgets.get(self.currently_displayed_conversation) + if widget != None: + widget.check_editor_allowed() + widget._update_peer_info() + + def _process_pending(self, data): + while True: + try: + action = self._pending_actions.popleft() + except IndexError: + break + try: + action() + except Exception as e: + RNS.log("Conversations UI action failed: "+str(e), RNS.LOG_ERROR) + return True + + def _wake(self, action): + self._pending_actions.append(action) + if self._wake_fd is not None: + try: + os.write(self._wake_fd, b".") + return + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None)) + except Exception: + pass def focus_change_event(self): - # This hack corrects buggy styling behaviour in IndicativeListBox if not self.dialog_open: - ilb_position = self.ilb.get_selected_position() self.update_conversation_list() - if ilb_position != None: - self.ilb.select_item(ilb_position) - def update_listbox(self): - conversation_list_widgets = [] - for conversation in self.app.conversations(): - conversation_list_widgets.append(self.conversation_list_widget(conversation)) + def toggle_list_sort(self): + if self.list_sort_mode == ConversationsDisplay.SORT_RECENT: + self.list_sort_mode = ConversationsDisplay.SORT_NAME + else: + self.list_sort_mode = ConversationsDisplay.SORT_RECENT + self.update_conversation_list() + + def _conversation_filter_predicate(self, conversation): + try: + trust_level = conversation[2] + except Exception: + return False + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED: + return trust_level in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) + return trust_level == DirectoryEntry.TRUSTED + + def _set_filter(self, key): + if self.list_filter == key: + return + self.list_filter = key + try: + self.update_conversation_list() + except Exception as e: + RNS.log("Failed to apply conversation filter: "+str(e), RNS.LOG_ERROR) + + def _on_show_blocked_change(self, _cb, new_state): + self.show_blocked = new_state + try: + self.update_conversation_list() + except Exception as e: + RNS.log("Failed to toggle show-blocked: "+str(e), RNS.LOG_ERROR) + + def _apply_pile_layout(self): + pack_opts = self.pile.options('pack') + weight_opts = self.pile.options('weight', 1) + items = [(self.tab_bar, pack_opts)] + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED: + items.append((self.show_blocked_checkbox, pack_opts)) + items.append((self.ilb, weight_opts)) + items.append((self.sync_status_text, pack_opts)) + try: + prev_focus = self.pile.focus_position + except Exception: + prev_focus = None + self.pile.contents = items + try: + if prev_focus is not None and prev_focus < len(items): + self.pile.focus_position = prev_focus + except Exception: + pass + + def _blocked_row_widget(self, dest_hash): + g = self.app.ui.glyphs + entry = self.app.directory.find(dest_hash) + display_name = None + if entry is not None and getattr(entry, "display_name", None): + display_name = strip_modifiers(entry.display_name) + if not display_name: + display_name = RNS.prettyhexrep(dest_hash) + label = " "+g["cross"]+" [blocked] "+display_name+" <"+RNS.hexrep(dest_hash, delimit=False)+">" + widget = ListEntry(label) + urwid.connect_signal(widget, "click", self._unblock_dialog, dest_hash) + attr = urwid.AttrMap(widget, "list_untrusted", "list_focus_untrusted") + attr.blocked_dest_hash = dest_hash + return attr + + def _unblock_dialog(self, _sender, dest_hash): + self.dialog_open = True + + def dismiss(_b): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(_b): + self.dialog_open = False + try: + self.app.unblock_destination(dest_hash) + except Exception as e: + RNS.log("Unblock failed: "+str(e), RNS.LOG_ERROR) + self.update_conversation_list() + + try: + who = self.app.directory.simplest_display_str(dest_hash) + except Exception: + who = RNS.hexrep(dest_hash, delimit=False) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Unblock "+str(who)+"?\n\nThis lifts the RNS blackhole on the peer's identity\nand removes them from your ignored list.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, unblock", on_press=confirmed)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]), + ]), title="Confirm unblock" + ) + dialog.delegate = self + + bottom = self.listbox + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.columns_widget.contents[0] = ( + overlay, + self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.columns_widget.focus_position = 0 + except Exception: + pass + + def _build_persistent_listbox(self): + self.tab_trusted = TabButton("Trusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED)) + self.tab_untrusted = TabButton("Untrusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED)) + + self.tab_bar = urwid.Columns([ + ('weight', 1, self.tab_trusted), + ('weight', 1, self.tab_untrusted), + ], dividechars=1) + + self.show_blocked_checkbox = urwid.CheckBox("Show blocked (0)", state=self.show_blocked) + urwid.connect_signal(self.show_blocked_checkbox, "change", self._on_show_blocked_change) - self.list_widgets = conversation_list_widgets self.ilb = IndicativeListBox( - self.list_widgets, + [urwid.Text("")], on_selection_change=self.conversation_list_selection, initialization_is_selection_change=False, - highlight_offFocus="list_off_focus" + highlight_offFocus="list_off_focus", ) - self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=("relative", 100)), title="Conversations") + self.sync_status_text = urwid.AttrMap(urwid.Text(self._sync_status_line(), align=urwid.LEFT), "shortcutbar") + + self.pile = urwid.Pile([ + ('pack', self.tab_bar), + ('weight', 1, self.ilb), + ('pack', self.sync_status_text), + ]) + try: self.pile.focus_position = 1 + except Exception: pass + + self.listbox = ConversationsArea(self.pile, title="Conversations") self.listbox.delegate = self + def update_listbox(self): + if not hasattr(self, "pile"): + self._build_persistent_listbox() + + try: + conversations = self.app.conversations() + except Exception as e: + RNS.log("Failed to enumerate conversations: "+str(e), RNS.LOG_ERROR) + conversations = [] + + try: + if self.list_sort_mode == ConversationsDisplay.SORT_NAME: + conversations.sort(key=lambda e: (e[3].lower(), e[0])) + except Exception: + pass + + def _is_pinned(c): + try: + entry = self.app.directory.find(bytes.fromhex(c[0])) + return entry is not None and entry.sort_rank is not None + except Exception: + return False + try: + conversations = sorted(conversations, key=lambda c: 0 if _is_pinned(c) else 1) + except Exception: + pass + + glyphs = self.app.ui.glyphs + def _alerts(c): + return bool(c[4]) or (len(c) > 6 and bool(c[6])) + trusted_count = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED) + untrusted_count = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN)) + trusted_unread = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED and _alerts(c)) + untrusted_unread = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) and _alerts(c)) + + def _label(name, total, unread): + if unread: + return f"{name} ({total}) {glyphs['unread']} {unread}" + return f"{name} ({total})" + + self.tab_trusted.set_label(_label("Trusted", trusted_count, trusted_unread)) + self.tab_untrusted.set_label(_label("Untrusted", untrusted_count, untrusted_unread)) + + filtered = [c for c in conversations if self._conversation_filter_predicate(c)] + + conversation_list_widgets = [] + for conversation in filtered: + try: + conversation_list_widgets.append(self.conversation_list_widget(conversation)) + except Exception as e: + try: hh = conversation[0] + except Exception: hh = "?" + RNS.log("Skipping conversation row for "+str(hh)+": "+str(e), RNS.LOG_ERROR) + + blocked_count = len(self.app.ignored_list) if hasattr(self.app, "ignored_list") else 0 + try: + self.show_blocked_checkbox.set_label(f"Show blocked ({blocked_count})") + except Exception: + pass + + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED and self.show_blocked: + for blocked_hash in list(self.app.ignored_list): + try: + conversation_list_widgets.append(self._blocked_row_widget(blocked_hash)) + except Exception as e: + RNS.log("Skipping blocked row: "+str(e), RNS.LOG_ERROR) + + empty_placeholder = False + if not conversation_list_widgets: + empty_label = { + ConversationsDisplay.LIST_FILTER_TRUSTED: "No trusted conversations", + ConversationsDisplay.LIST_FILTER_UNTRUSTED: "No untrusted conversations", + }.get(self.list_filter, "No conversations") + conversation_list_widgets = [urwid.Text(empty_label, align='center')] + empty_placeholder = True + + self.list_widgets = conversation_list_widgets + + try: + self.ilb.set_body(conversation_list_widgets) + except Exception as e: + RNS.log("Failed to populate conversation list: "+str(e), RNS.LOG_ERROR) + + self._apply_pile_layout() + + if empty_placeholder: + try: self.pile.focus_position = 0 + except Exception: pass + + try: + self.sync_status_text.original_widget.set_text(self._sync_status_line()) + except Exception: + pass + + def _sync_status_line(self): + try: + last = self.app.peer_settings.get("last_lxmf_sync") + except Exception: + last = None + if not last: + when = "never" + else: + try: + when = relative_time(float(last)) + except Exception: + when = "unknown" + + node_label = None + try: + pn_hash = self.app.get_default_propagation_node() + if pn_hash is not None: + pn_ident = RNS.Identity.recall(pn_hash) + if pn_ident is not None: + node_dest = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) + entry = self.app.directory.find(node_dest) + if entry is not None and getattr(entry, "display_name", None): + node_label = strip_modifiers(str(entry.display_name)) or None + if node_label is None: + node_label = "<"+RNS.hexrep(pn_hash, delimit=False)[:8]+"…>" + except Exception: + node_label = None + + line = " Last sync: "+when + if node_label: + line += " ("+node_label+")" + return line + + def _refresh_sync_status(self, _loop=None, _data=None): + try: + if hasattr(self, "sync_status_text") and self.sync_status_text is not None: + self.sync_status_text.original_widget.set_text(self._sync_status_line()) + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) + except Exception: + pass + def delete_selected_conversation(self): self.dialog_open = True - source_hash = self.ilb.get_selected_item().source_hash + item = self.ilb.get_selected_item() + source_hash = getattr(item, "source_hash", None) if item is not None else None + if source_hash is None: + self.dialog_open = False + return def dismiss_dialog(sender): - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() def confirmed(sender): self.dialog_open = False @@ -117,29 +590,260 @@ class ConversationsDisplay(): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n", align="center"), - urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) + urwid.Text( + "Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) ]), title="?" ) dialog.delegate = self bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) + def _refresh_open_conversation_widget(self, source_hash_text): + widget = ConversationsDisplay.cached_conversation_widgets.get(source_hash_text) + if widget is None: + return + try: + widget._trust_banner_dismissed = False + except Exception: + pass + try: + widget._refresh_trust_banner() + except Exception: + pass + try: + widget._update_peer_info() + except Exception: + pass + try: + widget.update_message_widgets(replace=True) + except Exception: + pass + + def show_my_qr(self): + try: + addr = RNS.hexrep(self.app.lxmf_destination.hash, delimit=False) + except Exception: + return + try: + display = self.app.peer_settings.get("display_name") or "My LXMF" + except Exception: + display = "My LXMF" + self.show_qr_dialog(addr, title=display) + + def show_qr_dialog(self, data, title=None): + qr_text = qr_ascii(data) + + def dismiss(_b): + self._restore_listbox_pane() + + rows = [urwid.Text("")] + if qr_text is not None: + rows.append(urwid.Text(qr_text, align=urwid.CENTER)) + rows.append(urwid.Text("")) + else: + rows.append(urwid.Text("LXMF destination address:", align=urwid.CENTER)) + rows.append(urwid.Text("")) + rows += [ + urwid.Text("< "+data+" >", align=urwid.CENTER), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Text("")), + (12, urwid.Button("Close", on_press=dismiss)), + (urwid.WEIGHT, 1, urwid.Text("")), + ]), + urwid.Text(""), + ] + dialog_title = "LXMF Address" if qr_text is None else "QR Code" + dialog = DialogLineBox(urwid.Pile(rows), title=dialog_title) + dialog.delegate = self + self._overlay_dialog(dialog) + + def _overlay_dialog(self, dialog): + overlay = urwid.Overlay( + dialog, self.columns_widget, + align=urwid.CENTER, width=(urwid.RELATIVE, 70), + valign=urwid.MIDDLE, height=urwid.PACK, + min_width=44, + ) + try: + self.widget.original_widget = overlay + self.dialog_open = True + except Exception: + pass + + def _restore_listbox_pane(self): + try: + self.widget.original_widget = self.columns_widget + self.dialog_open = False + self.update_conversation_list() + except Exception: + pass + + def _ping_peer_from_dialog(self, source_hash_text, status_widget, ping_button): + try: + dest = bytes.fromhex(source_hash_text) + except Exception: + status_widget.set_text(("error_text", "Invalid address")) + return + + identity = RNS.Identity.recall(dest) + if identity is None: + status_widget.set_text(("error_text", "Identity unknown; query first")) + return + + if not RNS.Transport.has_path(dest): + status_widget.set_text("No path; requesting…") + try: RNS.Transport.request_path(dest) + except Exception: pass + + status_widget.set_text("Pinging…") + ping_button.set_label("Pinging…") + started_at = time.time() + + def schedule_ui(fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *_: fn()) + except Exception: + try: fn() + except Exception: pass + + def on_established(link): + elapsed_ms = int((time.time() - started_at) * 1000) + try: + hops = RNS.Transport.hops_to(dest) + if hops is None or hops >= RNS.Transport.PATHFINDER_M: + hops_str = "" + else: + hops_str = f" ({hops} hop{'s' if hops != 1 else ''})" + except Exception: + hops_str = "" + def update(): + status_widget.set_text(f"Pong in {elapsed_ms} ms{hops_str}") + ping_button.set_label("Ping") + schedule_ui(update) + try: link.teardown() + except Exception: pass + + def on_closed(link): + try: + if getattr(link, "status", None) == RNS.Link.ACTIVE: + return + except Exception: + pass + def update(): + if status_widget.text.strip() in ("Pinging…", ""): + status_widget.set_text(("error_text", "Ping failed (no link)")) + ping_button.set_label("Ping") + schedule_ui(update) + + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + RNS.Link(destination, established_callback=on_established, closed_callback=on_closed) + except Exception as e: + status_widget.set_text(("error_text", f"Ping init failed: {e}")) + ping_button.set_label("Ping") + + def _block_peer_from_dialog(self, source_hash_text): + try: + dest = bytes.fromhex(source_hash_text) + except Exception: + return + + def cancel_block(_b): + self.dialog_open = False + self.update_conversation_list() + + def confirm_block(_b): + try: + self.app.block_destination(dest, reason="user-blocked from peer info dialog") + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + try: + self.delete_conversation(source_hash_text) + nomadnet.Conversation.delete_conversation(source_hash_text, self.app) + except Exception: + pass + self.dialog_open = False + self.update_conversation_list() + + try: + who = self.app.directory.simplest_display_str(dest) + except Exception: + who = source_hash_text + + confirm_dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Block "+str(who)+"?\n\nThis blackholes the peer's identity in Reticulum,\nadds them to your ignored list, and deletes any\nconversation with them.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirm_block)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel_block)), + ]), + ]), title="Confirm block" + ) + confirm_dialog.delegate = self + bottom = self.listbox + overlay = urwid.Overlay(confirm_dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.columns_widget.contents[0] = ( + overlay, + self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.columns_widget.focus_position = 0 + self.dialog_open = True + except Exception: + pass + def edit_selected_in_directory(self): g = self.app.ui.glyphs self.dialog_open = True - source_hash_text = self.ilb.get_selected_item().source_hash - display_name = self.ilb.get_selected_item().display_name - if display_name == None: + item = self.ilb.get_selected_item() + if item == None: + self.dialog_open = False + return + source_hash_text = getattr(item, "source_hash", None) + if source_hash_text is None: + blocked = getattr(item, "blocked_dest_hash", None) + if isinstance(blocked, (bytes, bytearray)): + source_hash_text = RNS.hexrep(blocked, delimit=False) + if source_hash_text is None: + self.dialog_open = False + return + display_name = getattr(item, "display_name", None) + if display_name is None: + try: + display_name = self.app.directory.display_name(bytes.fromhex(source_hash_text)) + except Exception: + display_name = None + if display_name is None: display_name = "" - e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash_text) + e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash_text) t_id = urwid.Text("Addr : "+source_hash_text) - e_name = urwid.Edit(caption="Name : ",edit_text=display_name) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_name) + e_copy = ReadlineEdit(caption="Copy : ", edit_text=source_hash_text) selected_id_widget = t_id @@ -147,11 +851,16 @@ class ConversationsDisplay(): unknown_selected = True trusted_selected = False - direct_selected = True - propagated_selected = False + direct_selected = True + propagated_selected = False + opportunistic_selected = False + + pinned_initial = False + notes_initial = "" try: - if self.app.directory.find(bytes.fromhex(source_hash_text)): + existing_entry = self.app.directory.find(bytes.fromhex(source_hash_text)) + if existing_entry: trust_level = self.app.directory.trust_level(bytes.fromhex(source_hash_text)) if trust_level == DirectoryEntry.UNTRUSTED: untrusted_selected = True @@ -166,25 +875,36 @@ class ConversationsDisplay(): unknown_selected = False trusted_selected = True - if self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) == DirectoryEntry.PROPAGATED: + preferred = self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) + if preferred == DirectoryEntry.PROPAGATED: direct_selected = False propagated_selected = True - + elif preferred == DirectoryEntry.OPPORTUNISTIC: + direct_selected = False + opportunistic_selected = True + + pinned_initial = existing_entry.sort_rank is not None + notes_initial = getattr(existing_entry, "notes", "") or "" + except Exception as e: pass + e_notes = ReadlineEdit(caption="Notes: ", edit_text=notes_initial, multiline=True) + cb_pin = urwid.CheckBox("Pin to top", state=pinned_initial) + trust_button_group = [] r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted", state=untrusted_selected) r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected) r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) method_button_group = [] - r_direct = urwid.RadioButton(method_button_group, "Deliver directly", state=direct_selected) - r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected) + r_direct = urwid.RadioButton(method_button_group, "Deliver over direct link", state=direct_selected) + r_opportunistic = urwid.RadioButton(method_button_group, "Deliver opportunistically", state=opportunistic_selected) + r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected) def dismiss_dialog(sender): - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() def confirmed(sender): try: @@ -199,19 +919,27 @@ class ConversationsDisplay(): delivery = DirectoryEntry.DIRECT if r_propagated.state == True: delivery = DirectoryEntry.PROPAGATED + elif r_opportunistic.state == True: + delivery = DirectoryEntry.OPPORTUNISTIC - entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery) + sort_rank = 0 if cb_pin.state else None + notes_value = e_notes.get_edit_text() + entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery, sort_rank=sort_rank, notes=notes_value) self.app.directory.remember(entry) - self.update_conversation_list() + self._refresh_open_conversation_widget(source_hash_text) self.dialog_open = False + self.update_conversation_list() self.app.ui.main_display.sub_displays.network_display.directory_change_callback() except Exception as e: RNS.log("Could not save directory entry. The contained exception was: "+str(e), RNS.LOG_VERBOSE) if not dialog_pile.error_display: dialog_pile.error_display = True - options = dialog_pile.options(height_type="pack") + options = dialog_pile.options(height_type=urwid.PACK) dialog_pile.contents.append((urwid.Text(""), options)) - dialog_pile.contents.append((urwid.Text(("error_text", "Could not save entry. Check your input."), align="center"), options)) + dialog_pile.contents.append(( + urwid.Text(("error_text", "Could not save entry. Check your input."), align=urwid.CENTER), + options,) + ) source_is_known = self.app.directory.is_known(bytes.fromhex(source_hash_text)) if source_is_known: @@ -220,26 +948,64 @@ class ConversationsDisplay(): def query_action(sender, user_data): self.close_conversation_by_hash(user_data) nomadnet.Conversation.query_for_peer(user_data) - options = dialog_pile.options(height_type="pack") + options = dialog_pile.options(height_type=urwid.PACK) dialog_pile.contents = [ (urwid.Text("Query sent"), options), (urwid.Button("OK", on_press=dismiss_dialog), options) ] query_button = urwid.Button("Query network for keys", on_press=query_action, user_data=source_hash_text) - known_section = urwid.Pile([urwid.Divider(g["divider1"]), urwid.Text(g["info"]+"\n", align="center"), urwid.Text("The identity of this peer is not known, and you cannot currently communicate.\n", align="center"), query_button, urwid.Divider(g["divider1"])]) + known_section = urwid.Pile([ + urwid.Divider(g["divider1"]), + urwid.Text(g["info"]+"\n", align=urwid.CENTER), + urwid.Text( + "The identity of this peer is not known, and you cannot currently send messages to it. " + "You can query the network to obtain the identity.\n", + align=urwid.CENTER, + ), + query_button, + urwid.Divider(g["divider1"]), + ]) + + action_status = urwid.Text("", align=urwid.CENTER) + ping_button = urwid.Button("Ping") + block_button = urwid.Button("Block") + qr_button = urwid.Button("LXMF") + urwid.connect_signal(ping_button, "click", lambda _b: self._ping_peer_from_dialog(source_hash_text, action_status, ping_button)) + urwid.connect_signal(block_button, "click", lambda _b: self._block_peer_from_dialog(source_hash_text)) + urwid.connect_signal(qr_button, "click", lambda _b: self.show_qr_dialog(source_hash_text, title=display_name or source_hash_text)) + + actions_row = urwid.Columns([ + (urwid.WEIGHT, 0.32, ping_button), + (urwid.WEIGHT, 0.02, urwid.Text("")), + (urwid.WEIGHT, 0.32, block_button), + (urwid.WEIGHT, 0.02, urwid.Text("")), + (urwid.WEIGHT, 0.32, qr_button), + ]) dialog_pile = urwid.Pile([ selected_id_widget, e_name, + e_copy, urwid.Divider(g["divider1"]), r_untrusted, r_unknown, r_trusted, urwid.Divider(g["divider1"]), r_direct, + r_opportunistic, r_propagated, + urwid.Divider(g["divider1"]), + cb_pin, + e_notes, known_section, - urwid.Columns([("weight", 0.45, urwid.Button("Save", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))]) + actions_row, + action_status, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) ]) dialog_pile.error_display = False @@ -247,9 +1013,19 @@ class ConversationsDisplay(): dialog.delegate = self bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def new_conversation(self): @@ -257,8 +1033,8 @@ class ConversationsDisplay(): source_hash = "" display_name = "" - e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash) - e_name = urwid.Edit(caption="Name : ",edit_text=display_name) + e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_name) trust_button_group = [] r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted") @@ -266,15 +1042,15 @@ class ConversationsDisplay(): r_trusted = urwid.RadioButton(trust_button_group, "Trusted") def dismiss_dialog(sender): - self.update_conversation_list() self.dialog_open = False + self.update_conversation_list() def confirmed(sender): try: existing_conversations = nomadnet.Conversation.conversation_list(self.app) display_name = e_name.get_edit_text() - source_hash_text = e_id.get_edit_text() + source_hash_text = e_id.get_edit_text().strip() source_hash = bytes.fromhex(source_hash_text) trust_level = DirectoryEntry.UNTRUSTED if r_unknown.state == True: @@ -282,23 +1058,34 @@ class ConversationsDisplay(): elif r_trusted.state == True: trust_level = DirectoryEntry.TRUSTED - if not source_hash in [c[0] for c in existing_conversations]: + if not source_hash_text in [c[0] for c in existing_conversations]: entry = DirectoryEntry(source_hash, display_name, trust_level) self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.update_conversation_list() - self.display_conversation(source_hash_text) + if trust_level != DirectoryEntry.TRUSTED: + if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED: + self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED) + self.display_conversation(None, source_hash_text) self.dialog_open = False + self.update_conversation_list() except Exception as e: RNS.log("Could not start conversation. The contained exception was: "+str(e), RNS.LOG_VERBOSE) if not dialog_pile.error_display: dialog_pile.error_display = True - options = dialog_pile.options(height_type="pack") + options = dialog_pile.options(height_type=urwid.PACK) dialog_pile.contents.append((urwid.Text(""), options)) - dialog_pile.contents.append((urwid.Text(("error_text", "Could not start conversation. Check your input."), align="center"), options)) + dialog_pile.contents.append(( + urwid.Text( + ("error_text", "Could not start conversation. Check your input."), + align=urwid.CENTER, + ), + options, + )) dialog_pile = urwid.Pile([ e_id, @@ -308,7 +1095,11 @@ class ConversationsDisplay(): r_unknown, r_trusted, urwid.Text(""), - urwid.Columns([("weight", 0.45, urwid.Button("Create", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))]) + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) ]) dialog_pile.error_display = False @@ -316,9 +1107,172 @@ class ConversationsDisplay(): dialog.delegate = self bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + def ingest_lxm_uri(self): + self.dialog_open = True + lxm_uri = "" + e_uri = ReadlineEdit(caption="URI : ",edit_text=lxm_uri) + + def dismiss_dialog(sender): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(sender): + try: + local_delivery_signal = "local_delivery_occurred" + duplicate_signal = "duplicate_lxm" + lxm_uri = e_uri.get_edit_text().strip() + + ingest_result = self.app.message_router.ingest_lxm_uri( + lxm_uri, + signal_local_delivery=local_delivery_signal, + signal_duplicate=duplicate_signal + ) + + if ingest_result == False: + raise ValueError("The URI contained no decodable messages") + + elif ingest_result == local_delivery_signal: + rdialog_pile = urwid.Pile([ + urwid.Text("Message was decoded, decrypted successfully, and added to your conversation list."), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + elif ingest_result == duplicate_signal: + rdialog_pile = urwid.Pile([ + urwid.Text("The decoded message has already been processed by the LXMF Router, and will not be ingested again."), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + else: + if self.app.enable_node: + propagation_text = "The decoded message was not addressed to this LXMF address, but has been added to the propagation node queues, and will be distributed on the propagation network." + else: + propagation_text = "The decoded message was not addressed to this LXMF address, and has been discarded." + + rdialog_pile = urwid.Pile([ + urwid.Text(propagation_text), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + except Exception as e: + RNS.log("Could not ingest LXM URI. The contained exception was: "+str(e), RNS.LOG_VERBOSE) + if not dialog_pile.error_display: + dialog_pile.error_display = True + options = dialog_pile.options(height_type=urwid.PACK) + dialog_pile.contents.append((urwid.Text(""), options)) + dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URI data. Check your input."), align=urwid.CENTER), options)) + + dialog_pile = urwid.Pile([ + e_uri, + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Ingest", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) + ]) + dialog_pile.error_display = False + + dialog = DialogLineBox(dialog_pile, title="Ingest message URI") + dialog.delegate = self + bottom = self.listbox + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def delete_conversation(self, source_hash): @@ -326,10 +1280,93 @@ class ConversationsDisplay(): conversation = ConversationsDisplay.cached_conversation_widgets[source_hash] self.close_conversation(conversation) + def toggle_fullscreen(self): + if ConversationsDisplay.given_list_width != 0: + self.saved_list_width = ConversationsDisplay.given_list_width + ConversationsDisplay.given_list_width = 0 + else: + ConversationsDisplay.given_list_width = self.saved_list_width + + self.update_conversation_list() + + def _decode_pn_app_data(self, app_data): + try: + if not pn_announce_data_is_valid(app_data): + return None + data = msgpack.unpackb(app_data) + enabled = bool(data[2]) + name = None + try: + meta = data[6] + if isinstance(meta, dict) and PN_META_NAME in meta: + raw = meta[PN_META_NAME] + if isinstance(raw, (bytes, bytearray)): + name = raw.decode("utf-8", errors="replace") + except Exception: + name = None + return {"enabled": enabled, "name": name} + except Exception: + return None + + def _pn_dropdown_label(self, pn_hash, meta): + name = (meta or {}).get("name") + if name: + label = sanitize_name(name) or "" + label = " ".join(label.split()) + else: + label = "" + if not label: + label = RNS.prettyhexrep(pn_hash) + max_len = 40 + if len(label) > max_len: + label = label[:max_len-1]+"…" + tail = "<"+RNS.hexrep(pn_hash, delimit=False)+">" + if tail not in label: + label = label+" "+tail + if meta is None: + status = "[?]" + elif meta.get("enabled"): + status = "[E]" + else: + status = "[D]" + return status+" "+label + + def _build_pn_options(self): + options = [] + seen = set() + + try: + pn_announces = list(self.app.directory._pn_announces) + except Exception: + pn_announces = [] + for tup in pn_announces: + if len(tup) < 3: continue + pn_hash = tup[1] + app_data = tup[2] + if pn_hash in seen: continue + seen.add(pn_hash) + meta = self._decode_pn_app_data(app_data) + options.append((pn_hash, self._pn_dropdown_label(pn_hash, meta))) + + for extra in (self.app.get_user_selected_propagation_node(), self.app.get_default_propagation_node()): + if extra is None or extra in seen: + continue + seen.add(extra) + meta = None + try: + cached = RNS.Identity.recall_app_data(extra) + if cached is not None: + meta = self._decode_pn_app_data(cached) + except Exception: + meta = None + options.append((extra, self._pn_dropdown_label(extra, meta))) + + return options + def sync_conversations(self): g = self.app.ui.glyphs self.dialog_open = True - + def dismiss_dialog(sender): self.dialog_open = False self.sync_dialog = None @@ -341,7 +1378,7 @@ class ConversationsDisplay(): r_mall = urwid.RadioButton(max_messages_group, "Download all", state=True) r_mlim = urwid.RadioButton(max_messages_group, "Limit to", state=False) ie_lim = urwid.IntEdit("", 5) - rbs = urwid.GridFlow([r_mlim, ie_lim], 12, 1, 0, align="left") + rbs = urwid.GridFlow([r_mlim, ie_lim], 12, 1, 0, align=urwid.LEFT) def sync_now(sender): limit = None @@ -365,40 +1402,144 @@ class ConversationsDisplay(): else: sync_button = hidden_sync_button - button_columns = urwid.Columns([("weight", 0.45, sync_button), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, cancel_button)]) + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, sync_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, cancel_button), + ]) real_sync_button.bc = button_columns - pn_ident = None - if self.app.get_default_propagation_node() != None: - pn_hash = self.app.get_default_propagation_node() - pn_ident = RNS.Identity.recall(pn_hash) + current_default = self.app.get_default_propagation_node() + user_selected = self.app.get_user_selected_propagation_node() - if pn_ident == None: - RNS.log("Propagation node identity is unknown, requesting from network...", RNS.LOG_DEBUG) - RNS.Transport.request_path(pn_hash) + pn_options = self._build_pn_options() - if pn_ident != None: - node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) - pn_entry = self.app.directory.find(node_hash) + selected_target = user_selected if user_selected is not None else current_default - dialog = DialogLineBox( + def on_pn_picked(picked_hash): + try: + self.app.set_user_selected_propagation_node(picked_hash) + except Exception as e: + RNS.log("Could not update propagation node: "+str(e), RNS.LOG_ERROR) + + def show_set_pn_dialog(_sender): + current_pn = self.app.get_user_selected_propagation_node() + current_str = RNS.hexrep(current_pn, delimit=False) if current_pn is not None else "" + pn_edit = ReadlineEdit(caption="Hash : ", edit_text=current_str) + status_text = urwid.Text("", align=urwid.CENTER) + + def reopen_sync(_b=None): + self.sync_conversations() + + def save_pn(_b): + text = pn_edit.get_edit_text().strip().replace(":", "").replace(" ", "") + expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8 + if text == "": + self.app.set_user_selected_propagation_node(None) + else: + try: + node_hash = bytes.fromhex(text) + except ValueError: + status_text.set_text("Invalid hex") + return + if len(node_hash) != expected_len: + status_text.set_text("Must be "+str(expected_len)+" bytes ("+str(expected_len*2)+" hex chars)") + return + self.app.set_user_selected_propagation_node(node_hash) + reopen_sync() + + def clear_pn(_b): + pn_edit.set_edit_text("") + self.app.set_user_selected_propagation_node(None) + reopen_sync() + + inner = DialogLineBox( urwid.Pile([ - urwid.Text(""+g["node"]+" "+str(pn_entry.display_name), align="center"), - urwid.Divider(g["divider1"]), - sync_progress, - urwid.Divider(g["divider1"]), - r_mall, - rbs, - urwid.Text(""), - button_columns - ]), title="Message Sync" + urwid.Text("Enter an LXMF propagation\ndestination hash as hex.", align=urwid.CENTER), + urwid.Divider(), + pn_edit, + urwid.Divider(), + status_text, + urwid.Columns([ + (urwid.WEIGHT, 0.3, urwid.Button("Save", on_press=save_pn)), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, urwid.Button("Clear", on_press=clear_pn)), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, urwid.Button("Close", on_press=reopen_sync)), + ]) + ]), title="Set Propagation Node", ) + inner.delegate = self + self.sync_dialog = None + overlay = urwid.Overlay( + inner, self.listbox, + align=urwid.CENTER, width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, height=urwid.PACK, + left=2, right=2, + ) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + set_pn_button = urwid.Button("Custom Node...", on_press=show_set_pn_dialog) + + if pn_options: + node_picker = PropNodePicker(pn_options, selected_target, on_pn_picked) + node_selector = urwid.Pile([ + urwid.Text("Propagation node:"), + node_picker, + set_pn_button, + ]) else: - button_columns = urwid.Columns([("weight", 0.45, urwid.Text("" )), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, cancel_button)]) + node_selector = None + + pn_ident = None + if current_default is not None: + pn_ident = RNS.Identity.recall(current_default) + if pn_ident is None: + RNS.log("Propagation node identity is unknown, requesting from network...", RNS.LOG_DEBUG) + RNS.Transport.request_path(current_default) + + if pn_ident is not None or node_selector is not None: + header_str = "" + if pn_ident is not None: + node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) + pn_entry = self.app.directory.find(node_hash) + if pn_entry is not None and getattr(pn_entry, "display_name", None): + header_str = " "+strip_modifiers(str(pn_entry.display_name)) + else: + header_str = " "+RNS.prettyhexrep(current_default) + else: + header_str = " (no default)" + + pile_items = [ + urwid.Text(""+g["node"]+header_str, align=urwid.CENTER), + urwid.Divider(g["divider1"]), + sync_progress, + urwid.Divider(g["divider1"]), + ] + pile_items += [button_columns, urwid.Text(""), r_mall, rbs] + if node_selector is not None: + pile_items += [urwid.Divider(g["divider1"]), node_selector] + + dialog = DialogLineBox(urwid.Pile(pile_items), title="Message Sync") + else: + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Text("" )), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, cancel_button), + ]) dialog = DialogLineBox( urwid.Pile([ urwid.Text(""), - urwid.Text("No trusted nodes found, cannot sync", align="center"), + urwid.Text("No trusted nodes found, cannot sync!\n", align=urwid.CENTER), + urwid.Text( + "To synchronise messages from the network, " + "one or more nodes must be marked as trusted in the Known Nodes list, " + "or a node must manually be selected as the default propagation node. " + "Nomad Network will then automatically sync from the nearest trusted node, " + "or the manually selected one.", + align=urwid.LEFT, + ), urwid.Text(""), button_columns ]), title="Message Sync" @@ -414,9 +1555,19 @@ class ConversationsDisplay(): self.sync_dialog = dialog bottom = self.listbox - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) def update_sync_dialog(self, loop = None, sender = None): @@ -424,9 +1575,9 @@ class ConversationsDisplay(): self.sync_dialog.sync_progress.set_completion(self.app.get_sync_progress()) if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: - self.sync_dialog.bc.contents[0] = (self.sync_dialog.real_sync_button, self.sync_dialog.bc.options("weight", 0.45)) + self.sync_dialog.bc.contents[0] = (self.sync_dialog.real_sync_button, self.sync_dialog.bc.options(urwid.WEIGHT, 0.45)) else: - self.sync_dialog.bc.contents[0] = (self.sync_dialog.hidden_sync_button, self.sync_dialog.bc.options("weight", 0.45)) + self.sync_dialog.bc.contents[0] = (self.sync_dialog.hidden_sync_button, self.sync_dialog.bc.options(urwid.WEIGHT, 0.45)) self.app.ui.loop.set_alarm_in(0.2, self.update_sync_dialog) @@ -435,18 +1586,39 @@ class ConversationsDisplay(): pass def update_conversation_list(self): - ilb_position = self.ilb.get_selected_position() - self.update_listbox() - options = self.columns_widget.options("weight", ConversationsDisplay.list_width) - if not (self.dialog_open and self.sync_dialog != None): - self.columns_widget.contents[0] = (self.listbox, options) - else: - bottom = self.listbox - overlay = urwid.Overlay(self.sync_dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) - self.columns_widget.contents[0] = (overlay, options) + selected_hash = None + selected_item = self.ilb.get_selected_item() + if selected_item is not None: + if hasattr(selected_item, "source_hash"): + selected_hash = selected_item.source_hash - if ilb_position != None: - self.ilb.select_item(ilb_position) + self.update_listbox() + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + if not self.dialog_open: + self.columns_widget.contents[0] = (self.listbox, options) + elif self.sync_dialog is not None: + bottom = self.listbox + overlay = urwid.Overlay( + self.sync_dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + self.columns_widget.contents[0] = (overlay, options) + # else: another dialog (peer info, new conversation, block confirm, etc.) is + # open as an overlay in contents[0]; leave it alone so an incoming message + # doesn't dismiss it. The underlying listbox is a persistent widget and was + # already refreshed by update_listbox() above. + + if selected_hash is not None: + for idx, widget in enumerate(self.list_widgets): + if getattr(widget, "source_hash", None) == selected_hash: + self.ilb.select_item(idx) + break nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() if self.app.ui.main_display.sub_displays.active_display == self.app.ui.main_display.sub_displays.conversations_display: @@ -468,20 +1640,20 @@ class ConversationsDisplay(): self.app.mark_conversation_read(self.currently_displayed_conversation) self.currently_displayed_conversation = source_hash - options = self.widget.options("weight", 1-ConversationsDisplay.list_width) - self.widget.contents[1] = (self.make_conversation_widget(source_hash), options) + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (self.make_conversation_widget(source_hash), options) if source_hash == None: - self.widget.set_focus_column(0) + self.columns_widget.focus_position = 0 else: if self.app.conversation_is_unread(source_hash): self.app.mark_conversation_read(source_hash) self.update_conversation_list() - self.widget.set_focus_column(1) + self.columns_widget.focus_position = 1 conversation_position = None index = 0 for widget in self.list_widgets: - if widget.source_hash == source_hash: + if getattr(widget, "source_hash", None) == source_hash: conversation_position = index index += 1 @@ -498,8 +1670,7 @@ class ConversationsDisplay(): conversation_widget.check_editor_allowed() return conversation_widget else: - widget = ConversationWidget(source_hash) - widget.delegate = self + widget = ConversationWidget(source_hash, delegate=self) ConversationsDisplay.cached_conversation_widgets[source_hash] = widget widget.check_editor_allowed() @@ -521,10 +1692,12 @@ class ConversationsDisplay(): def conversation_list_widget(self, conversation): - trust_level = conversation[2] - display_name = conversation[1] - source_hash = conversation[0] - unread = conversation[4] + trust_level = conversation[2] + display_name = conversation[1] + source_hash = conversation[0] + unread = conversation[4] + last_activity = conversation[5] + failed = conversation[6] if len(conversation) > 6 else 0 g = self.app.ui.glyphs @@ -538,8 +1711,8 @@ class ConversationsDisplay(): focus_style = "list_focus" elif trust_level == DirectoryEntry.TRUSTED: symbol = g["check"] - style = "list_trusted" - focus_style = "list_focus_trusted" + style = "list_normal" + focus_style = "list_focus" elif trust_level == DirectoryEntry.WARNING: symbol = g["warning"] style = "list_warning" @@ -549,21 +1722,45 @@ class ConversationsDisplay(): style = "list_untrusted" focus_style = "list_focus_untrusted" - display_text = symbol + is_pinned = False + try: + entry = self.app.directory.find(bytes.fromhex(source_hash)) + is_pinned = entry is not None and entry.sort_rank is not None + except Exception: + is_pinned = False + + head = symbol + if is_pinned: + head = g.get("pin", "*") + " " + head if display_name != None and display_name != "": - display_text += " "+display_name + head += " "+display_name if trust_level != DirectoryEntry.TRUSTED: - display_text += " <"+source_hash+">" - - if trust_level != DirectoryEntry.UNTRUSTED: - if unread: - if source_hash != self.currently_displayed_conversation: - display_text += " "+g["unread"] + head += " <"+source_hash+">" + markup = [head] + if failed and source_hash != self.currently_displayed_conversation: + badge_text = " "+g["warning"]+" ("+str(failed)+")" + # markup.append(("msg_notice_caution", badge_text)) + markup.append(badge_text) + elif unread and source_hash != self.currently_displayed_conversation: + badge_text = " "+g["unread"]+" ("+str(unread)+")" + # Good idea with having the badges here colored, but + # using the bg color for it is a bit much, I think. + # I set fg color attrmap styles, but that messes up + # the bg on list focus. If there's a way to handle + # that, we can re-enable this. + # markup.append(("msg_notice_unread", badge_text)) + markup.append(badge_text) - widget = ListEntry(display_text) + if trust_level == DirectoryEntry.TRUSTED and unread and source_hash != self.currently_displayed_conversation: + style = "msg_notice_unread" + + if last_activity > 0: + markup.append("\n "+relative_time(last_activity)) + + widget = ListEntry(markup) urwid.connect_signal(widget, "click", self.display_conversation, conversation[0]) display_widget = urwid.AttrMap(widget, style, focus_style) display_widget.source_hash = source_hash @@ -573,13 +1770,22 @@ class ConversationsDisplay(): def shortcuts(self): - focus_path = self.widget.get_focus_path() - if focus_path[0] == 0: + try: + focus_path = self.columns_widget.get_focus_path() + except Exception: return self.list_shortcuts - elif focus_path[0] == 1: + if not focus_path or focus_path[0] != 1: + return self.list_shortcuts + try: + cw = self.columns_widget.contents[1][0] + frame = cw.base_widget.frame + if frame is None: + return self.editor_shortcuts + if frame.focus_position == "footer": + return self.editor_shortcuts + return self.body_shortcuts + except Exception: return self.editor_shortcuts - else: - return self.list_shortcuts class ListEntry(urwid.Text): _selectable = True @@ -605,19 +1811,48 @@ class ListEntry(urwid.Text): self._emit('click') return True -class MessageEdit(urwid.Edit): +LXM_STATE_NAMES = { + LXMF.LXMessage.GENERATING: "Generating", + LXMF.LXMessage.OUTBOUND: "Outbound", + LXMF.LXMessage.SENDING: "Sending", + LXMF.LXMessage.SENT: "Sent", + LXMF.LXMessage.DELIVERED: "Delivered", + LXMF.LXMessage.REJECTED: "Rejected", + LXMF.LXMessage.CANCELLED: "Cancelled", + LXMF.LXMessage.FAILED: "Failed", +} + +LXM_METHOD_NAMES = { + LXMF.LXMessage.OPPORTUNISTIC: "Opportunistic", + LXMF.LXMessage.DIRECT: "Direct link", + LXMF.LXMessage.PROPAGATED: "Propagation node", + LXMF.LXMessage.PAPER: "Paper", +} + +class MessageEdit(ReadlineMixin, urwid.Edit): def keypress(self, size, key): if key == "ctrl d": self.delegate.send_message() + elif key == "ctrl p": + self.delegate.paper_message() + elif key == "ctrl f": + self.delegate.attach_file() + elif key == "ctrl s": + self.delegate.save_focused_attachments() + elif key == "ctrl v": + self.delegate.cycle_delivery_method() + elif key == "ctrl r": + self.delegate.toggle_message_details() elif key == "ctrl k": - self.delegate.clear_editor() + if not self.delegate.cancel_pending_send(): + return super(MessageEdit, self).keypress(size, key) elif key == "up": y = self.get_cursor_coords(size)[1] if y == 0: if self.delegate.full_editor_active and self.name == "title_editor": - self.delegate.frame.set_focus("body") + self.delegate.frame.focus_position = "body" elif not self.delegate.full_editor_active and self.name == "content_editor": - self.delegate.frame.set_focus("body") + self.delegate.frame.focus_position = "body" else: return super(MessageEdit, self).keypress(size, key) else: @@ -627,27 +1862,58 @@ class MessageEdit(urwid.Edit): class ConversationFrame(urwid.Frame): + @property + def focus_position(self): + return urwid.Frame.focus_position.fget(self) + + @focus_position.setter + def focus_position(self, part): + urwid.Frame.focus_position.fset(self, part) + try: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts() + except Exception: + pass + def keypress(self, size, key): - if self.get_focus() == "body": - if key == "up" and self.delegate.messagelist.top_is_visible: - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + if self.focus_position == "header": + result = super(ConversationFrame, self).keypress(size, key) + if result == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + if result == "down": + self.focus_position = "body" + return None + return result + if self.focus_position == "body": + if getattr(self.delegate, "dialog_active", False) or getattr(self.delegate, "dialog_open", False): + return super(ConversationFrame, self).keypress(size, key) + elif key == "up" and self.delegate.messagelist.top_is_visible: + if getattr(self.delegate, "has_visible_trust_banner", lambda: False)(): + try: + self.delegate._header_pile.focus_position = 1 + self.focus_position = "header" + return None + except Exception: + pass + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "down" and self.delegate.messagelist.bottom_is_visible: - self.set_focus("footer") + self.focus_position = "footer" else: return super(ConversationFrame, self).keypress(size, key) - elif key == "ctrl k": - self.delegate.clear_editor() else: return super(ConversationFrame, self).keypress(size, key) class ConversationWidget(urwid.WidgetWrap): - def __init__(self, source_hash): + MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024 + + def __init__(self, source_hash, delegate): self.app = nomadnet.NomadNetworkApp.get_shared_instance() g = self.app.ui.glyphs + self.delegate = delegate if source_hash == None: self.frame = None display_widget = urwid.LineBox(urwid.Filler(urwid.Text("\n No conversation selected"), "top")) - urwid.WidgetWrap.__init__(self, display_widget) + super().__init__(display_widget) else: if source_hash in ConversationsDisplay.cached_conversation_widgets: return ConversationsDisplay.cached_conversation_widgets[source_hash] @@ -656,11 +1922,14 @@ class ConversationWidget(urwid.WidgetWrap): self.conversation = nomadnet.Conversation(source_hash, nomadnet.NomadNetworkApp.get_shared_instance()) self.message_widgets = [] self.sort_by_timestamp = False - self.updating_message_widgets = False + self.pending_attachments = [] + self.dialog_active = False + self.editor_allowed = None + self.show_message_details = False self.update_message_widgets() - self.conversation.register_changed_callback(self.conversation_changed) + self.conversation.register_changed_callback(self._on_conversation_changed_from_callback) #title_editor = MessageEdit(caption="\u270E", edit_text="", multiline=False) title_editor = MessageEdit(caption="", edit_text="", multiline=False) @@ -672,9 +1941,13 @@ class ConversationWidget(urwid.WidgetWrap): msg_editor.delegate = self msg_editor.name = "content_editor" - header = None - if self.conversation.trust_level == DirectoryEntry.UNTRUSTED: - header = urwid.AttrMap(urwid.Padding(urwid.Text(g["warning"]+" Warning: Conversation with untrusted peer "+g["warning"], align="center")), "msg_warning_untrusted") + self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") + self._update_peer_info() + + self._trust_banner_dismissed = False + self._header_pile = urwid.Pile([self.peer_info_widget]) + self._refresh_trust_banner() + header = self._header_pile self.minimal_editor = urwid.AttrMap(msg_editor, "msg_editor") self.minimal_editor.name = "minimal_editor" @@ -711,7 +1984,185 @@ class ConversationWidget(urwid.WidgetWrap): self.frame ) - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) + + def has_visible_trust_banner(self): + if self._trust_banner_dismissed: + return False + try: + tl = self.app.directory.trust_level(bytes.fromhex(self.source_hash)) + except Exception: + tl = DirectoryEntry.UNKNOWN + return tl != DirectoryEntry.TRUSTED + + def _refresh_trust_banner(self): + contents = [(self.peer_info_widget, self._header_pile.options())] + if self.has_visible_trust_banner(): + banner = self._build_trust_banner() + contents.append((banner, self._header_pile.options())) + self._header_pile.contents = contents + if len(contents) > 1: + try: self._header_pile.focus_position = 1 + except Exception: pass + + def _build_trust_banner(self): + g = self.app.ui.glyphs + msg = urwid.Text(" "+g["warning"]+" This peer isn't trusted yet.") + btn_trust = urwid.Button("Trust", on_press=self._on_trust_click) + btn_block = urwid.Button("Block", on_press=self._on_block_click) + btn_nothing = urwid.Button("Do nothing", on_press=self._on_ignore_click) + row = urwid.Columns([ + ('weight', 1, msg), + ('pack', btn_trust), + (1, urwid.Text(" ")), + ('pack', btn_block), + (1, urwid.Text(" ")), + ('pack', btn_nothing), + (1, urwid.Text(" ")), + ], dividechars=0) + return urwid.AttrMap(row, "msg_warning_untrusted") + + def _on_trust_click(self, _btn): + try: + src = bytes.fromhex(self.source_hash) + existing = self.app.directory.find(src) + display_name = getattr(existing, "display_name", None) if existing is not None else None + preferred = getattr(existing, "preferred_delivery", None) if existing is not None else None + entry = DirectoryEntry(src, display_name, DirectoryEntry.TRUSTED, preferred_delivery=preferred) + self.app.directory.remember(entry) + except Exception as e: + RNS.log("Could not mark peer as trusted: "+str(e), RNS.LOG_ERROR) + self._refresh_trust_banner() + try: + self.frame.focus_position = "footer" + except Exception: + pass + try: + if self.delegate.list_filter != ConversationsDisplay.LIST_FILTER_TRUSTED: + self.delegate._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED) + else: + self.delegate.update_conversation_list() + except Exception as e: + RNS.log("Trust UI refresh failed: "+str(e), RNS.LOG_ERROR) + + def _on_ignore_click(self, _btn): + self._trust_banner_dismissed = True + self._refresh_trust_banner() + + def _on_block_click(self, _btn): + def dismiss(_b): + self.dialog_active = False + try: self.delegate.dialog_open = False + except Exception: pass + try: self.delegate.update_conversation_list() + except Exception: pass + + def confirmed(_b): + self.dialog_active = False + try: self.delegate.dialog_open = False + except Exception: pass + try: + self._block_peer() + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + try: self.delegate.update_conversation_list() + except Exception: pass + + try: + who = self.app.directory.simplest_display_str(bytes.fromhex(self.source_hash)) + except Exception: + who = self.source_hash + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Block "+str(who)+"?\n\nThis will blackhole the peer's identity in Reticulum,\nadd them to your ignored list, and delete this conversation.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirmed)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]), + ]), title="Confirm block" + ) + dialog.delegate = self.delegate + + bottom = self.delegate.listbox + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.delegate.columns_widget.contents[0] = ( + overlay, + self.delegate.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.delegate.columns_widget.focus_position = 0 + self.dialog_active = True + try: self.delegate.dialog_open = True + except Exception: pass + except Exception: + pass + + def _block_peer(self): + try: + src = bytes.fromhex(self.source_hash) + except Exception: + return + + try: + self.app.block_destination(src, reason="user-blocked from nomadnet conversation") + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + + try: + self.delegate.delete_conversation(self.source_hash) + nomadnet.Conversation.delete_conversation(self.source_hash, self.app) + except Exception as e: + RNS.log("Could not delete blocked conversation: "+str(e), RNS.LOG_ERROR) + + def _update_peer_info(self): + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + + g = self.app.ui.glyphs + source_hash_bytes = bytes.fromhex(self.source_hash) + + display_name = self.app.directory.display_name(source_hash_bytes) + app_data = None + if display_name is None or self.app.message_router.get_outbound_stamp_cost(source_hash_bytes) is None: + app_data = RNS.Identity.recall_app_data(source_hash_bytes) + + if display_name is None: + if app_data: + display_name = san(LXMF.display_name_from_app_data(app_data)) + if display_name is None: + display_name = RNS.prettyhexrep(source_hash_bytes) + + stamp_cost = self.app.message_router.get_outbound_stamp_cost(source_hash_bytes) + if stamp_cost is None and app_data: + stamp_cost = LXMF.stamp_cost_from_app_data(app_data) + + hops = RNS.Transport.hops_to(source_hash_bytes) + if hops >= RNS.Transport.PATHFINDER_M: + hops_str = "unknown" + else: + hops_str = str(hops)+" hop" + ("s" if hops != 1 else "") + + preferred = self.app.directory.preferred_delivery(source_hash_bytes) + if preferred == DirectoryEntry.PROPAGATED: + via_str = "Propagation" + elif preferred == DirectoryEntry.OPPORTUNISTIC: + via_str = "Opportunistic" + else: + via_str = "Link" + + right_parts = [] + if stamp_cost is not None: + right_parts.append("Stamp: "+str(stamp_cost)) + right_parts.append("Via: "+via_str) + right_parts.append(g["speed"]+hops_str) + + left = " "+display_name + right = " ".join(right_parts)+" " + self.peer_info_widget.original_widget.set_text(left+" | "+right) def clear_history_dialog(self): def dismiss_dialog(sender): @@ -726,35 +2177,108 @@ class ConversationWidget(urwid.WidgetWrap): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("Clear conversation history\n", align="center"), - urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) + urwid.Text("Clear conversation history\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) ]), title="?" ) dialog.delegate = self bottom = self.messagelist - overlay = urwid.Overlay(dialog, bottom, align="center", width=34, valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=34, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) self.frame.contents["body"] = (overlay, self.frame.options()) - self.frame.set_focus("body") + self.frame.focus_position = "body" + def _build_footer(self): + g = self.app.ui.glyphs + if self.full_editor_active: + editor = self.full_editor + else: + editor = self.minimal_editor + + if self.pending_attachments: + attachment_texts = [] + for path in self.pending_attachments: + attachment_texts.append(os.path.basename(path)) + indicator = urwid.AttrMap( + urwid.Text(g["file"]+" "+str(len(self.pending_attachments))+" file(s): "+", ".join(attachment_texts)), + "msg_header_sent", + ) + return urwid.Pile([indicator, editor]) + else: + return editor + def toggle_editor(self): if self.full_editor_active: - self.frame.contents["footer"] = (self.minimal_editor, None) self.full_editor_active = False else: - self.frame.contents["footer"] = (self.full_editor, None) self.full_editor_active = True + self.frame.contents["footer"] = (self._build_footer(), None) def check_editor_allowed(self): g = self.app.ui.glyphs if self.frame: allowed = nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)) + if allowed == self.editor_allowed: + return + self.editor_allowed = allowed if allowed: - self.frame.contents["footer"] = (self.minimal_editor, None) + self.conversation.ensure_send_destination() + self.frame.contents["footer"] = (self._build_footer(), None) else: - warning = urwid.AttrMap(urwid.Padding(urwid.Text(g["info"]+" You cannot currently communicate with this peer, since it's identity keys are not known", align="center")), "msg_header_caution") + warning = urwid.AttrMap( + urwid.Padding(urwid.Text( + "\n"+g["info"]+"\n\nYou cannot currently message this peer, since its identity keys are not known. " + "The keys have been requested from the network, and you will be able to send messages " + "as soon as they arrive.\n\n" + "To query the network manually, select this conversation in the conversation list, " + "press Ctrl-E, and use the query button.\n", + align=urwid.CENTER, + )), + "msg_header_caution", + ) self.frame.contents["footer"] = (warning, None) + self._schedule_editor_recheck() + + def _schedule_editor_recheck(self): + if getattr(self, "_editor_recheck_pending", False): + return + try: + loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop + if loop is None: + return + self._editor_recheck_pending = True + loop.set_alarm_in(1.0, self._editor_recheck) + except Exception: + self._editor_recheck_pending = False + + def _editor_recheck(self, loop=None, user_data=None): + self._editor_recheck_pending = False + if not self.frame: + return + if getattr(self.delegate, "currently_displayed_conversation", None) != self.source_hash: + return + if nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)): + self.check_editor_allowed() + try: + loop.draw_screen() + except Exception: + pass + else: + self._schedule_editor_recheck() def toggle_focus_area(self): name = "" @@ -764,129 +2288,846 @@ class ConversationWidget(urwid.WidgetWrap): pass if name == "messagelist": - self.frame.set_focus("footer") + self.frame.focus_position = "footer" elif name == "minimal_editor" or name == "full_editor": - self.frame.set_focus("body") + self.frame.focus_position = "body" def keypress(self, size, key): if key == "tab": self.toggle_focus_area() - elif key == "ctrl w": + return None + key = super(ConversationWidget, self).keypress(size, key) + if key is None: + return None + if key == "ctrl w": self.close() - elif key == "ctrl p": + elif key == "ctrl u": self.conversation.purge_failed() self.conversation_changed(None) + elif key == "ctrl k": + self.cancel_pending_send() elif key == "ctrl t": self.toggle_editor() elif key == "ctrl x": self.clear_history_dialog() + elif key == "ctrl g": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.conversations_display.toggle_fullscreen() elif key == "ctrl o": self.sort_by_timestamp ^= True self.conversation_changed(None) + elif key == "ctrl a": + self.attach_file() + elif key == "ctrl s": + self.save_focused_attachments() + elif key == "ctrl v": + self.cycle_delivery_method() + elif key == "ctrl r": + self.toggle_message_details() else: - return super(ConversationWidget, self).keypress(size, 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): + self.delegate._wake(lambda: self.conversation_changed(conversation)) def conversation_changed(self, conversation): + if hasattr(self, "peer_info_widget"): + self._update_peer_info() self.update_message_widgets(replace = True) def update_message_widgets(self, replace = False): - while self.updating_message_widgets: - time.sleep(0.5) + 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.updating_message_widgets = True self.message_widgets = [] - added_hashes = [] + added_hashes = set() + needs_index = [] for message in self.conversation.messages: message_hash = message.get_hash() if not message_hash in added_hashes: - added_hashes.append(message_hash) - message_widget = LXMessageWidget(message) + added_hashes.add(message_hash) + was_loaded = message.loaded + try: + message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"], conversation_widget=self) + except Exception as e: + RNS.log("Skipping message loading for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_WARNING) + message.unload() + continue self.message_widgets.append(message_widget) - + if not was_loaded and message.loaded: + needs_index.append(message) + message.unload() + + if needs_index: + try: + ConversationMessage.write_index( + self.conversation.messages_path, needs_index) + except Exception: + pass + if self.sort_by_timestamp: self.message_widgets.sort(key=lambda m: m.timestamp, reverse=False) else: self.message_widgets.sort(key=lambda m: m.sort_timestamp, reverse=False) from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox - self.messagelist = IndicativeListBox(self.message_widgets, position = len(self.message_widgets)-1) + new_count = len(self.message_widgets) + if prev_pos is None or prev_count == 0 or prev_pos >= prev_count - 1: + position = max(0, new_count - 1) + else: + position = min(prev_pos, max(0, new_count - 1)) + self.messagelist = IndicativeListBox(self.message_widgets, position = position) self.messagelist.name = "messagelist" if replace: - self.frame.contents["body"] = (self.messagelist, None) + current_body = self.frame.contents["body"][0] + if self.dialog_active and isinstance(current_body, urwid.Overlay): + current_body.bottom_w = self.messagelist + else: + self.frame.contents["body"] = (self.messagelist, None) nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() - self.updating_message_widgets = False - def clear_editor(self): self.content_editor.set_edit_text("") self.title_editor.set_edit_text("") + self.pending_attachments = [] + self.frame.contents["footer"] = (self._build_footer(), None) + + def _collect_attachment_refs(self): + g = self.app.ui.glyphs + refs = [] + sorted_messages = sorted(self.conversation.messages, key=lambda m: m.sort_timestamp, reverse=True) + for conv_message in sorted_messages: + if not conv_message.has_attachments(): + continue + + cached_names = conv_message._cached_attachment_names or [] + att_file_idx = 0 + for atype, aname, *arest in cached_names: + asize = arest[0] if arest else 0 + glyph = g["file"] if atype == "file" else g[atype] + label = glyph+" "+aname + if asize > 0: + label += " ("+_format_size(asize)+")" + if atype == "file": + refs.append((label, aname, conv_message, "file", att_file_idx)) + att_file_idx += 1 + else: + refs.append((label, aname, conv_message, atype, 0)) + + return refs + + def save_focused_attachments(self): + g = self.app.ui.glyphs + self.dialog_active = True + + try: + attachment_items = self._collect_attachment_refs() + except Exception as e: + RNS.log("Error collecting attachments: "+str(e), RNS.LOG_ERROR) + attachment_items = [] + + save_dir = self.app.attachment_save_path if self.app.attachment_save_path else self.app.downloads_path + + def dismiss_dialog(sender): + self.dialog_active = False + self.conversation_changed(None) + + if not attachment_items: + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("No attachments in this conversation.\n"), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), title="Attachments" + ) + dialog.delegate = self + bottom = self.messagelist + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=45, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + return + + checkboxes = [] + for label, filename, conv_msg, field_type, field_index in attachment_items: + cb = urwid.CheckBox(label, state=False) + cb._attachment_filename = filename + cb._conv_message = conv_msg + cb._field_type = field_type + cb._field_index = field_index + checkboxes.append(cb) + + status_text = urwid.Text("") + + def do_save(sender): + saved = [] + errors = [] + for cb in checkboxes: + if cb.get_state(): + try: + src_path = cb._conv_message.get_attachment_file_path(cb._field_type, cb._field_index) + if src_path and os.path.isfile(src_path): + path = _copy_attachment_to_dest(cb._attachment_filename, src_path) + saved.append(path) + except Exception as e: + errors.append(str(e)) + + if saved: + lines = [g["check"]+" Copied "+str(len(saved))+" file(s) to "+save_dir+":"] + for p in saved: + lines.append(" "+os.path.basename(p)) + if errors: + lines.append(g["cross"]+" "+str(len(errors))+" failed") + status_text.set_text("\n".join(lines)) + elif errors: + status_text.set_text(g["cross"]+" Failed: "+errors[0]) + else: + status_text.set_text("No files selected") + + dialog_widgets = list(checkboxes) + dialog_widgets.append(urwid.Divider(g["divider1"])) + dialog_widgets.append(urwid.Text("Copy to: "+save_dir)) + dialog_widgets.append(status_text) + dialog_widgets.append(urwid.Text("")) + dialog_widgets.append(urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Copy to Downloads", on_press=do_save)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Close", on_press=dismiss_dialog)), + ])) + + dialog = DialogLineBox(urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)), title="Attachments") + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=("relative", 80), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" def send_message(self): content = self.content_editor.get_edit_text() title = self.title_editor.get_edit_text() if not content == "": - if self.conversation.send(content, title): + fields = None + if self.pending_attachments: + file_attachments = [] + for file_path in self.pending_attachments: + try: + with open(file_path, "rb") as af: + file_data = af.read() + file_name = os.path.basename(file_path) + file_attachments.append([file_name, file_data]) + except Exception as e: + RNS.log("Error reading attachment "+str(file_path)+": "+str(e), RNS.LOG_ERROR) + + if file_attachments: + fields = {LXMF.FIELD_FILE_ATTACHMENTS: file_attachments} + + if self.app.compose_markdown: + if not fields: fields = {} + fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN + + if self.conversation.send(content, title, fields=fields): + self.clear_editor() + + def attach_file(self): + self.dialog_active = True + initial_attachments = list(self.pending_attachments) + + 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 + 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.focus_position = "body" + + def file_browser_closed(self): + self.dialog_active = False + self.frame.contents["footer"] = (self._build_footer(), None) + self.conversation_changed(None) + + def paper_message_saved(self, path): + g = self.app.ui.glyphs + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("The paper message was saved to:\n\n"+str(path)+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), title=g["papermsg"].replace(" ", "") + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def print_paper_message_qr(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + if self.conversation.paper_output(content, title): self.clear_editor() else: - pass + self.paper_message_failed() + + def save_paper_message_qr(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + output_result = self.conversation.paper_output(content, title, mode="save_qr") + if output_result != False: + self.clear_editor() + self.paper_message_saved(output_result) + else: + self.paper_message_failed() + + def save_paper_message_uri(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + output_result = self.conversation.paper_output(content, title, mode="save_uri") + if output_result != False: + self.clear_editor() + self.paper_message_saved(output_result) + else: + self.paper_message_failed() + + def paper_message(self): + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + def print_qr(sender): + dismiss_dialog(self) + self.print_paper_message_qr() + + def save_qr(sender): + dismiss_dialog(self) + self.save_paper_message_qr() + + def save_uri(sender): + dismiss_dialog(self) + self.save_paper_message_uri() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text( + "Select the desired paper message output method.\nSaved files will be written to:\n\n"+str(self.app.downloads_path)+"\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.5, urwid.Button("Print QR", on_press=print_qr)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Save QR", on_press=save_qr)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Save URI", on_press=save_uri)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Cancel", on_press=dismiss_dialog)) + ]) + ]), title="Create Paper Message" + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def paper_message_failed(self): + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text( + "Could not output paper message,\ncheck your settings. See the log\nfile for any error messages.\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), title="!" + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=34, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" def close(self): self.delegate.close_conversation(self) class LXMessageWidget(urwid.WidgetWrap): - def __init__(self, message): + mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) + + def __init__(self, message, theme=THEME_DARK, conversation_widget=None): app = nomadnet.NomadNetworkApp.get_shared_instance() g = app.ui.glyphs + self._conversation_widget = conversation_widget + self.conv_message = message self.timestamp = message.get_timestamp() self.sort_timestamp = message.sort_timestamp + self.transfer_done = False + self._live_lxm = None + + msg_hash = message.get_hash() + msg_state = message.get_state() + msg_source_hash = message._cached_source_hash + msg_method = message._cached_method time_format = app.time_format message_time = datetime.fromtimestamp(self.timestamp) + renderer = message.content_renderer() encryption_string = "" if message.get_transport_encrypted(): - encryption_string = " ["+g["encrypted"]+" "+str(message.get_transport_encryption())+"]" + encryption_string = " "+g["encrypted"] else: - encryption_string = " ["+g["plaintext"]+" "+str(message.get_transport_encryption())+"]" - - title_string = message_time.strftime(time_format)+encryption_string + encryption_string = " "+g["plaintext"] - if app.lxmf_destination.hash == message.lxm.source_hash: - if message.lxm.state == LXMF.LXMessage.DELIVERED: + title_string = relative_time(self.timestamp)+" | "+message_time.strftime(time_format)+encryption_string + + is_outbound = False + if msg_source_hash is None: + header_style = "msg_header_failed" + title_string = g["warning"]+" "+title_string + elif app.lxmf_destination.hash == msg_source_hash: + is_outbound = True + if msg_state == LXMF.LXMessage.DELIVERED: header_style = "msg_header_delivered" - title_string = g["check"]+" "+title_string - elif message.lxm.state == LXMF.LXMessage.FAILED: + title_string = g["check"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.FAILED: header_style = "msg_header_failed" - title_string = g["cross"]+" "+title_string - elif message.lxm.method == LXMF.LXMessage.PROPAGATED and message.lxm.state == LXMF.LXMessage.SENT: + title_string = g["cross"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.REJECTED: + header_style = "msg_header_failed" + title_string = g["cross"]+" "+g["arrow_r"]+" Rejected "+title_string + elif msg_method == LXMF.LXMessage.PROPAGATED and msg_state == LXMF.LXMessage.SENT: header_style = "msg_header_propagated" - title_string = g["sent"]+" "+title_string - elif message.lxm.state == LXMF.LXMessage.SENT: + title_string = g["sent"]+" "+g["arrow_r"]+" "+title_string + elif msg_method == LXMF.LXMessage.PAPER and msg_state == LXMF.LXMessage.PAPER: + header_style = "msg_header_propagated" + title_string = g["papermsg"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.SENT: header_style = "msg_header_sent" - title_string = g["sent"]+" "+title_string + title_string = g["sent"]+" "+g["arrow_r"]+" "+title_string else: header_style = "msg_header_sent" title_string = g["arrow_r"]+" "+title_string else: if message.signature_validated(): header_style = "msg_header_ok" - title_string = g["check"]+" "+title_string + title_string = g["check"]+" "+g["arrow_l"]+" "+title_string else: header_style = "msg_header_caution" - title_string = g["warning"]+" "+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() != "": - title_string += " | " + message.get_title() + title_string += " | " + strip_modifiers(message.get_title()) - title = urwid.AttrMap(urwid.Text(title_string), header_style) + inbound_untrusted = False + if not is_outbound and msg_source_hash is not None: + try: + sender_trust = app.directory.trust_level(msg_source_hash) + if sender_trust in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN): + inbound_untrusted = True + except Exception: + inbound_untrusted = False - display_widget = urwid.Pile([ - title, - urwid.Text(message.get_content()), - urwid.Text("") - ]) + has_attachments = message.has_attachments() + cached_names = message._cached_attachment_names or [] + + if has_attachments and cached_names: + attachment_strings = [] + for atype, aname, *arest in cached_names: + attachment_strings.append(g[atype if atype != "file" else "file"]+" "+aname) + title_string += " | " + " ".join(attachment_strings) + + content_text = message.get_content() + + if content_text and app.config["textui"]["clipboard_copy"]: + copy_glyph = g.get("copy", "[C]") + check_glyph = g.get("check", "v").center(len(copy_glyph)) + copy_icon = ClickableIcon(copy_glyph) + + conv_widget = self._conversation_widget + def on_copy_click(icon=copy_icon, content=content_text, cg=copy_glyph, kg=check_glyph, cw=conv_widget): + osc52_copy(content) + icon.set_text(kg) + def _restore(loop, user_data): + icon.set_text(cg) + try: + app.ui.loop.set_alarm_in(2.0, _restore) + except Exception: + icon.set_text(cg) + if cw is not None and cw.frame is not None: + def _refocus(loop, user_data): + try: + cw.frame.focus_position = "footer" + except Exception: + pass + try: + app.ui.loop.set_alarm_in(0, _refocus) + except Exception: + pass + copy_icon._on_click = on_copy_click + + copy_width = len(copy_glyph) + 2 + title_row = urwid.Columns([ + ("weight", 1, urwid.Text(title_string)), + (copy_width, urwid.Padding(copy_icon, left=1, right=1)), + ]) + title = urwid.AttrMap(title_row, header_style) + else: + title = urwid.AttrMap(urwid.Text(title_string), header_style) + + self.progress_widget = urwid.Text("") + self.progress_attr = urwid.AttrMap(self.progress_widget, "progress_full") + + content_lines = content_text.split("\n") + markdown = renderer == LXMF.RENDERER_MARKDOWN + + default_fg = "bbb" if theme == THEME_DARK else "444" + if markdown: + formatted = self.mdc.format_block(content_text) + message_body = strip_non_formatting_tags(formatted) + rendered = markup_to_attrmaps(strip_modifiers(message_body), url_delegate=None, fg_color=default_fg, bg_color=None) + content_pile = urwid.Padding(urwid.Pile(rendered), left=2, right=2) + + else: indented = "\n".join(" "+line for line in content_lines) + + pile_widgets = [title] + + if conversation_widget is not None and getattr(conversation_widget, "show_message_details", False): + def _safe(fn): + try: return fn() + except Exception: return None + + def _detail_row(glyph, values): + return urwid.Columns([ + (3, urwid.Text(("body_text", glyph), align=urwid.RIGHT)), + urwid.Text(("inactive_text", " · ".join(values))), + ], dividechars=1) + + detail_rows = [] + + delivery = [] + if msg_method is not None: + delivery.append(LXM_METHOD_NAMES.get(msg_method, "Unknown")) + if is_outbound and msg_state is not None: + delivery.append(LXM_STATE_NAMES.get(msg_state, "Unknown")) + if delivery: + detail_rows.append(_detail_row(g.get("sent" if is_outbound else "peer", ">"), delivery)) + + signal = [] + snr = _safe(message.get_snr) + rssi = _safe(message.get_rssi) + q = _safe(message.get_q) + if snr is not None: signal.append("SNR "+str(round(snr, 1))+" dB") + if rssi is not None: signal.append("RSSI "+str(round(rssi, 1))+" dBm") + if q is not None: signal.append("Q "+str(round(q))+"%") + if signal: + detail_rows.append(_detail_row(g.get("speed", "~"), signal)) + + security = [] + sig = _safe(message.get_signature_description) + if sig: security.append(sig) + enc = _safe(message.get_transport_encryption) + if enc: security.append(str(enc)) + if security: + detail_rows.append(_detail_row(g.get("encrypted", "#"), security)) + + if msg_hash is not None: + short_hash = _safe(lambda: RNS.hexrep(msg_hash, delimit=False)) + if short_hash: + detail_rows.append(_detail_row(g.get("info", "i"), [short_hash])) + + if detail_rows: + pile_widgets.append(urwid.Padding(urwid.Pile(detail_rows), left=2, right=2)) + + if is_outbound and msg_state is not None and msg_state < LXMF.LXMessage.SENT and msg_hash is not None: + try: + for pending in app.message_router.pending_outbound: + if pending.hash == msg_hash: + self._live_lxm = pending + break + except Exception: + pass + + if self._live_lxm is not None: + pct = int(self._live_lxm.progress * 100) + bar_width = 20 + filled = int(bar_width * self._live_lxm.progress) + if app.ui.colormode >= 256: + bar = "\u2588" * filled + "\u2591" * (bar_width - filled) + else: + bar = "#" * filled + "-" * (bar_width - filled) + self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") + if conversation_widget is not None: + cancel_icon = ClickableIcon(" "+g["cross"]+" Cancel ", + on_click=lambda cw=conversation_widget, cm=message: cw.cancel_message(cm)) + progress_row = urwid.Columns([ + ("weight", 1, self.progress_attr), + ("pack", urwid.AttrMap(cancel_icon, "msg_notice_caution")), + ], dividechars=1) + pile_widgets.append(progress_row) + else: + pile_widgets.append(self.progress_attr) + self._start_progress_poll() + + if markdown: pile_widgets.append(content_pile) + else: pile_widgets.append(urwid.Text(indented)) + + if has_attachments and cached_names: + if inbound_untrusted: + pile_widgets.append(urwid.AttrMap( + urwid.Text(" "+g["warning"]+" This attachment came from a peer that's untrusted. Be careful when opening it."), + "list_untrusted", + )) + + att_file_idx = 0 + for atype, aname, *arest in cached_names: + glyph = g["file"] if atype == "file" else g[atype] + asize = arest[0] if arest else 0 + label = " "+glyph+" "+aname + if asize > 0: + label += " ("+_format_size(asize)+")" + if atype == "file": + pile_widgets.append(ClickableAttachment(label, aname, message, "file", att_file_idx)) + att_file_idx += 1 + else: + pile_widgets.append(ClickableAttachment(label, aname, message, atype)) + + pile_widgets.append(urwid.Text("")) + + super().__init__(urwid.Pile(pile_widgets)) + + def _start_progress_poll(self): + try: + loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop + if loop: + loop.set_alarm_in(0.3, self._poll_progress) + except Exception: + pass + + def _poll_progress(self, loop=None, user_data=None): + if self.transfer_done: + return + + if self._live_lxm is None: + self.transfer_done = True + return + + app = nomadnet.NomadNetworkApp.get_shared_instance() + g = app.ui.glyphs + progress = self._live_lxm.progress + state = self._live_lxm.state + pct = int(progress * 100) + + if state == LXMF.LXMessage.FAILED: + self.progress_widget.set_text(" "+g["cross"]+" Transfer failed") + self.transfer_done = True + self._live_lxm = None + elif state == LXMF.LXMessage.REJECTED: + self.progress_widget.set_text(" "+g["cross"]+" Rejected: too large or not accepted") + self.transfer_done = True + self._live_lxm = None + elif state >= LXMF.LXMessage.SENT: + self.progress_widget.set_text("") + self.transfer_done = True + self._live_lxm = None + else: + bar_width = 20 + filled = int(bar_width * progress) + if app.ui.colormode >= 256: + bar = "\u2588" * filled + "\u2591" * (bar_width - filled) + else: + bar = "#" * filled + "-" * (bar_width - filled) + self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") + + if not self.transfer_done: + try: + ui_loop = app.ui.loop + if ui_loop: + ui_loop.set_alarm_in(0.3, self._poll_progress) + ui_loop.draw_screen() + except Exception: + pass + + +class ClickableAttachment(urwid.Text): + def __init__(self, label, filename, conv_message, field_type, field_index=0): + self.filename = filename + self.conv_message = conv_message + self.field_type = field_type + self.field_index = field_index + self.saved = False + super().__init__(label) + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._save() + return True + return False + + def _save(self): + if self.saved: + return + app = nomadnet.NomadNetworkApp.get_shared_instance() + g = app.ui.glyphs + try: + src_path = self.conv_message.get_attachment_file_path(self.field_type, self.field_index) + if src_path and os.path.isfile(src_path): + save_path = _copy_attachment_to_dest(self.filename, src_path) + else: + if self.field_type == "file": + attachments = self.conv_message.get_file_attachments() + if self.field_index < len(attachments): + att = attachments[self.field_index] + if isinstance(att, list) and len(att) >= 2: + data = att[1] if isinstance(att[1], bytes) else b"" + else: + data = b"" + else: + data = b"" + elif self.field_type == "image": + data = self.conv_message.get_image() + data = data if isinstance(data, bytes) else b"" + elif self.field_type == "audio": + data = self.conv_message.get_audio() + data = data if isinstance(data, bytes) else b"" + else: + data = b"" + self.conv_message.unload() + if not data: + return + save_path = _save_attachment_to_disk(self.filename, data) + + self.saved = True + self.set_text(" "+g["check"]+" Copied to: "+save_path) + except Exception as e: + RNS.log("Error saving attachment: "+str(e), RNS.LOG_ERROR) + self.set_text(" "+g["cross"]+" Save failed: "+str(e)) + + +def _resolve_attachment_save_path(filename): + app = nomadnet.NomadNetworkApp.get_shared_instance() + save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path + if not os.path.isdir(save_dir): + os.makedirs(save_dir) + safe_name = ConversationMessage.safe_attachment_name(filename) + base_dir = os.path.realpath(save_dir) + os.sep + candidate = os.path.realpath(os.path.join(save_dir, safe_name)) + if not (candidate + os.sep).startswith(base_dir): + raise OSError(13, os.strerror(13)) + counter = 0 + base, ext = os.path.splitext(safe_name) + while os.path.isfile(candidate): + counter += 1 + candidate = os.path.realpath(os.path.join(save_dir, base+"_"+str(counter)+ext)) + if not (candidate + os.sep).startswith(base_dir): + raise OSError(13, os.strerror(13)) + return candidate + + +def _copy_attachment_to_dest(filename, src_path): + save_path = _resolve_attachment_save_path(filename) + shutil.copy2(src_path, save_path) + return save_path + + +def _save_attachment_to_disk(filename, data): + save_path = _resolve_attachment_save_path(filename) + with open(save_path, "wb") as f: + f.write(data) + return save_path - urwid.WidgetWrap.__init__(self, display_widget) class SyncProgressBar(urwid.ProgressBar): def get_text(self): diff --git a/nomadnet/ui/textui/Directory.py b/nomadnet/ui/textui/Directory.py index aeec463..3ff090b 100644 --- a/nomadnet/ui/textui/Directory.py +++ b/nomadnet/ui/textui/Directory.py @@ -15,7 +15,7 @@ class DirectoryDisplay(): ]) self.shortcuts_display = DirectoryDisplayShortcuts(self.app) - self.widget = urwid.Filler(pile, 'top') + self.widget = urwid.Filler(pile, urwid.TOP) def shortcuts(self): return self.shortcuts_display \ No newline at end of file diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py new file mode 100644 index 0000000..2952311 --- /dev/null +++ b/nomadnet/ui/textui/Editor.py @@ -0,0 +1,1154 @@ +import os +import json +import shutil +import urwid +import RNS +import nomadnet + +from .ReadlineEdit import ReadlineEdit +from .Browser import Browser +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox + +PREVIEW_DELAY = 0.4 +PANE_WEIGHTS = {"files": 0.22, "editor": 0.40, "preview": 0.38} +PANE_ORDER = ["files", "editor", "preview"] +MAX_HIGHLIGHT_CHARS = 50000 +MAX_PREVIEW_CHARS = 100000 + + + + +SNIPPETS = [ + ("Heading", ">Heading\n"), + ("Sub-heading", ">>Sub heading\n"), + ("Collapsible", "`+>Section\nbody\n<\n"), + ("Bold", "`!bold`!"), + ("Italic", "`*italic`*"), + ("Underline", "`_underline`_"), + ("Foreground col", "`F00ftext`f"), + ("Background col", "`B400text`b"), + ("Link", "`[label`:/page/index.mu]"), + ("Anchor", "`:anchor-name"), + ("Anchor link", "`[label`#anchor-name]"), + ("Input field", "`<24|name`>"), + ("Masked field", "`"), + ("Checkbox", "`"), + ("Radio", "`<^|name|value`>"), + ("Divider", "-\n"), + ("Table", "`t\n| A | B |\n| 1 | 2 |\n`t\n"), + ("Literal block", "`=\nliteral text\n`=\n"), +] + + + + + + + +EDITOR_STYLES = [ + ("editor_comment", "dark gray", "default", None, "#888", "default"), + ("editor_heading", "light cyan,bold", "default", None, "#0cc", "default"), + ("editor_tag", "light magenta", "default", None, "#c6c", "default"), + ("editor_field", "yellow", "default", None, "#cc0", "default"), + ("editor_link", "light green", "default", None, "#0c6", "default"), + ("editor_dir", "light blue,bold", "default", None, "#39f", "default"), + ("editor_exec", "dark gray", "default", None, "#666", "default"), + ("editor_lineno", "dark gray", "default", None, "#555", "default"), +] + + +def is_executable(path): + try: + return (not RNS.vendor.platformutils.is_windows()) and os.path.isfile(path) and os.access(path, os.X_OK) + except Exception: + return False + + +def _scan_tag(text, i, n): + # text[i] == "`"; return (end_index, attr) + if i + 1 >= n: + return i + 1, "editor_tag" + c = text[i + 1] + if c == "<": + j = text.find(">", i + 2); return (n if j < 0 else j + 1), "editor_field" + if c == "[": + j = text.find("]", i + 2); return (n if j < 0 else j + 1), "editor_link" + if c == "{": + j = text.find("}", i + 2); return (n if j < 0 else j + 1), "editor_link" + if c in "FB": + if i + 2 < n and text[i + 2] == "T": + return min(i + 9, n), "editor_tag" + return min(i + 5, n), "editor_tag" + if c == ":": + j = i + 2 + while j < n and (text[j].isalnum() or text[j] in "_-"): + j += 1 + return j, "editor_tag" + return i + 2, "editor_tag" + + +def highlight_micron(text): + n = len(text) + attrs = [None] * n + i = 0 + line_start = True + while i < n: + c = text[i] + if line_start and c == "#": + j = text.find("\n", i); j = n if j < 0 else j + for k in range(i, j): attrs[k] = "editor_comment" + i = j; continue + if line_start and c == ">": + j = i + while j < n and text[j] == ">": j += 1 + for k in range(i, j): attrs[k] = "editor_heading" + i = j; line_start = False; continue + if line_start and c == "`" and i + 2 < n and text[i + 1] in "+-" and text[i + 2] == ">": + j = i + 2 + while j < n and text[j] == ">": j += 1 + for k in range(i, j): attrs[k] = "editor_heading" + i = j; line_start = False; continue + if c == "\n": + i += 1; line_start = True; continue + if c == "\\" and i + 1 < n: + i += 2; line_start = False; continue + if c == "`": + j, attr = _scan_tag(text, i, n) + for k in range(i, min(j, n)): attrs[k] = attr + i = j; line_start = False; continue + i += 1; line_start = False + + out = [] + run_attr = attrs[0] if n else None + run_len = 0 + for a in attrs: + if a == run_attr: + run_len += 1 + else: + + out.append((run_attr, run_len)); run_attr = a; run_len = 1 + if run_len: + out.append((run_attr, run_len)) + return out + + +class MicronEdit(ReadlineEdit): + def __init__(self, *args, **kwargs): + self._hl_text = None + self._hl_attrib = [] + super().__init__(*args, **kwargs) + + def get_text(self): + text = self.caption + self.edit_text + if text != self._hl_text: + self._hl_text = text + self._hl_attrib = [] if len(text) > MAX_HIGHLIGHT_CHARS else highlight_micron(text) + return text, self._hl_attrib + + +class GutterEdit(MicronEdit): + # MicronEdit with a wrap-aware line-number gutter on the left + def _gutter_width(self, maxcol): + gw = max(3, len(str(self.edit_text.count("\n") + 1)) + 2) + if maxcol - gw < 1: + return 0 + return gw + + def rows(self, size, focus=False): + gw = self._gutter_width(size[0]) + return super().rows((max(1, size[0] - gw),), focus) + + def get_cursor_coords(self, size): + gw = self._gutter_width(size[0]) + coords = super().get_cursor_coords((max(1, size[0] - gw),)) + if coords is None: + return None + return coords[0] + gw, coords[1] + + def move_cursor_to_coords(self, size, x, y): + gw = self._gutter_width(size[0]) + if isinstance(x, int): + x = max(0, x - gw) + return super().move_cursor_to_coords((max(1, size[0] - gw),), x, y) + + def render(self, size, focus=False): + maxcol = size[0] + gw = self._gutter_width(maxcol) + if gw == 0: + return super().render(size, focus) + tw = max(1, maxcol - gw) + + cheap = len(self.edit_text) > MAX_HIGHLIGHT_CHARS + wrap = "any" if cheap else "space" + if self.wrap != wrap: + self.set_wrap_mode(wrap) + + edit_canv = super().render((tw,), focus=False) + h = edit_canv.rows() + rows = [] + for i, line in enumerate(self.edit_text.split("\n"), 1): + rows.append(str(i).rjust(gw - 1) + " ") + if cheap: + wrapped = max(1, (len(line) + tw - 1) // tw) + else: + wrapped = max(1, urwid.Text(line).rows((tw,))) + rows.extend([""] * (wrapped - 1)) + rows = (rows + [""] * h)[:h] + + + + + gutter_canv = urwid.Text([("editor_lineno", "\n".join(r.ljust(gw) for r in rows))], wrap="clip").render((gw,)) + joined = urwid.CanvasJoin([(gutter_canv, None, False, gw), (edit_canv, None, focus, tw)]) + if focus: + coords = super().get_cursor_coords((tw,)) + if coords is not None: + joined.cursor = (coords[0] + gw, coords[1]) + return joined + + def mouse_event(self, size, event, button, x, y, focus): + gw = self._gutter_width(size[0]) + if x < gw: + return True + return super().mouse_event((max(1, size[0] - gw),), event, button, x - gw, y, focus) + + +class FileEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, label, path, depth, executable=False): + self.path = path + self.executable = executable + text = (" " * depth) + "· " + label + (" *" if executable else "") + style = "editor_exec" if executable else None + super().__init__(urwid.AttrMap(urwid.Text(text), style, focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit("click") + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class FolderEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, label, path, depth, expanded, glyphs): + self.path = path + icon = glyphs.get("folder_open" if expanded else "folder", "[-]" if expanded else "[+]") + text = (" " * depth) + icon + " " + label + "/" + super().__init__(urwid.AttrMap(urwid.Text(text), "editor_dir", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit("click") + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class PathDialog(urwid.WidgetWrap): + def __init__(self, title, default, on_ok, on_cancel): + self.on_ok = on_ok + self.on_cancel = on_cancel + self.edit = ReadlineEdit(caption="", edit_text=default) + pile = urwid.Pile([ + urwid.Text(title), + urwid.Divider(), + urwid.AttrMap(self.edit, "list_off_focus", "list_focus"), + urwid.Divider(), + urwid.Columns([ + urwid.Button("OK", on_press=lambda b: self.on_ok(self.edit.edit_text)), + urwid.Button("Cancel", on_press=lambda b: self.on_cancel()), + ], dividechars=2), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + if key == "enter" and self._w.original_widget.original_widget.focus_position == 2: + self.on_ok(self.edit.edit_text); return None + return super().keypress(size, key) + + +class InfoDialog(urwid.WidgetWrap): + def __init__(self, title, message, on_ok): + self.on_ok = on_ok + pile = urwid.Pile([ + urwid.Text(message), + urwid.Divider(), + urwid.Button("OK", on_press=lambda b: on_ok()), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key in ("esc", "enter"): + self.on_ok(); return None + return super().keypress(size, key) + + +class ConfirmDialog(urwid.WidgetWrap): + def __init__(self, title, message, buttons, on_cancel=None): + self.on_cancel = on_cancel + cols = [urwid.Button(label, on_press=lambda b, c=cb: c()) for label, cb in buttons] + pile = urwid.Pile([urwid.Text(message), urwid.Divider(), urwid.Columns(cols, dividechars=2)]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc" and self.on_cancel: + self.on_cancel(); return None + return super().keypress(size, key) + + +class SnippetDialog(urwid.WidgetWrap): + def __init__(self, on_pick, on_cancel): + self.on_cancel = on_cancel + items = [urwid.AttrMap(urwid.Button(name, on_press=lambda b, t=text: on_pick(t)), None, focus_map="list_focus") + for name, text in SNIPPETS] + super().__init__(urwid.LineBox(urwid.ListBox(urwid.SimpleFocusListWalker(items)), title="Insert Micron snippet")) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + return super().keypress(size, key) + + +class FindDialog(urwid.WidgetWrap): + def __init__(self, ed, on_close): + self.ed = ed + self.on_close = on_close + self.find_edit = ReadlineEdit(caption="Find: ") + self.repl_edit = ReadlineEdit(caption="Replace: ") + pile = urwid.Pile([ + urwid.AttrMap(self.find_edit, "list_off_focus", "list_focus"), + urwid.AttrMap(self.repl_edit, "list_off_focus", "list_focus"), + urwid.Divider(), + urwid.Columns([ + urwid.Button("Next", on_press=lambda b: ed.find_next(self.find_edit.edit_text)), + urwid.Button("Replace", on_press=lambda b: ed.replace_one(self.find_edit.edit_text, self.repl_edit.edit_text)), + urwid.Button("All", on_press=lambda b: ed.replace_all(self.find_edit.edit_text, self.repl_edit.edit_text)), + urwid.Button("Close", on_press=lambda b: on_close()), + ], dividechars=1), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title="Find / Replace")) + + def keypress(self, size, key): + if key == "esc": + self.on_close(); return None + return super().keypress(size, key) + + +class PermissionsDialog(urwid.WidgetWrap): + def __init__(self, title, note, content, on_ok, on_cancel): + self.on_ok = on_ok + self.on_cancel = on_cancel + self.edit = ReadlineEdit(caption="", edit_text=content, multiline=True) + pile = urwid.Pile([ + urwid.Text(note), + urwid.Divider(), + urwid.BoxAdapter(urwid.AttrMap(urwid.ListBox(urwid.SimpleListWalker([self.edit])), "list_off_focus", "list_focus"), 7), + urwid.Divider(), + urwid.Columns([ + urwid.Button("Save", on_press=lambda b: self.on_ok(self.edit.edit_text)), + urwid.Button("Cancel", on_press=lambda b: self.on_cancel()), + ], dividechars=2), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + return super().keypress(size, key) + + +class EditorColumns(urwid.Columns): + def keypress(self, size, key): + ed = self.editor_display + if key == "ctrl s": + ed.save_or_saveas(); return None + if key == "ctrl n": + ed.new_file_dialog(); return None + if key == "ctrl f": + ed.find_dialog(); return None + if key == "ctrl g": + ed.snippet_dialog(); return None + if key == "ctrl p": + ed.permissions_dialog(); return None + if key == "f2" and ed.files_focused(): + ed.rename_dialog(); return None + if key == "delete" and ed.files_focused(): + ed.delete_dialog(); return None + if key == "ctrl t": + ed.toggle_pane("files"); return None + if key == "ctrl b": + ed.toggle_pane("editor"); return None + if key == "ctrl r": + ed.toggle_pane("preview"); return None + if key == "tab": + self.focus_position = (self.focus_position + 1) % len(self.contents); return None + if key == "shift tab": + self.focus_position = (self.focus_position - 1) % len(self.contents); return None + if key == "esc": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + result = super().keypress(size, key) + if key == "up" and result == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + return result + + +class PageEditorShortcuts(): + def __init__(self, app): + self.app = app + self.status = urwid.Text("", align=urwid.RIGHT) + base = "[C-n]New [C-s]Save [C-f]Find [C-g]Snippet [C-p]Perms [F2]Rename [Del]Delete [Tab]Pane [C-t/b/r]Toggle [Esc]Menu" + self.widget = urwid.AttrMap(urwid.Columns([urwid.Text(base), self.status], dividechars=2), "shortcutbar") + + def set_status(self, msg): + self.status.set_text(msg) + + +class PageEditorDisplay(): + def __init__(self, app): + self.app = app + self.current_path = None + self.editable = False + self.is_exec = False + self.dirty = False + self.preview_alarm = None + self._loading = False + self._find = None + self._last_file = None + self.expanded = set(p for p in (app.pagespath, app.filespath) if p) + self.filter_mu = False + self.pane_visible = {"files": True, "editor": True, "preview": True} + self.load_state() + self.register_styles() + + self.preview_browser = Browser(self.app, "nomadnetwork", "node", delegate=None) + self.preview_browser.loopback = self.app.node.destination.hash + self.preview_browser.destination_hash = self.app.node.destination.hash + self.preview_browser.handle_link = self._preview_handle_link + self.preview_browser.marked_link = lambda *a, **k: None + self.preview_browser._content_cols = self._preview_cols + # don't fetch partials in the preview + self.preview_browser.detect_partials = lambda: None + + self.editor = GutterEdit(caption="", edit_text="", multiline=True) + urwid.connect_signal(self.editor, "postchange", self.on_edit_change) + + self.filter_checkbox = urwid.CheckBox("Show only .mu", state=self.filter_mu, on_state_change=self.on_filter_change) + self.tree_walker = urwid.SimpleFocusListWalker(self.build_entries()) + self.tree_listbox = IndicativeListBox(self.tree_walker, initialization_is_selection_change=False, highlight_offFocus="list_off_focus") + self.files_pile = urwid.Pile([ + (urwid.PACK, urwid.AttrMap(self.filter_checkbox, None, focus_map="list_focus")), + (urwid.WEIGHT, 1, self.tree_listbox), + ]) + try: self.files_pile.focus_position = 1 + except Exception: pass + + self.pane_widgets = { + "files": urwid.LineBox(self.files_pile, title="Files"), + "editor": urwid.LineBox(urwid.ListBox(urwid.SimpleListWalker([self.editor])), title="Editor"), + "preview": urwid.LineBox(urwid.SolidFill(" "), title="Preview"), + } + + self.columns = EditorColumns([], dividechars=0) + self.columns.editor_display = self + self._visible = [] + self.rebuild_columns() + + self.shortcuts_display = PageEditorShortcuts(self.app) + self.body = urwid.WidgetPlaceholder(self.columns) + self.widget = self.body + + def register_styles(self): + screen = self.app.ui.screen + for entry in EDITOR_STYLES: + try: screen.register_palette_entry(*entry) + except Exception: pass + + def _preview_handle_link(self, target, link_data=None): + # in the preview follow in page anchors but ignore external navigation + # TODO + + if isinstance(target, str) and target.startswith("#"): + name = target[1:] + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self.preview_browser._jump_to_anchor(name)) + + def _preview_cols(self): + try: + cols = self.app.ui.loop.screen.get_cols_rows()[0] + visible = self.visible_panes() + total = sum(PANE_WEIGHTS[n] for n in visible) + frac = PANE_WEIGHTS["preview"] / total if "preview" in visible else PANE_WEIGHTS["preview"] + return max(20, int(cols * frac) - 3) + except Exception: + return 80 + + def shortcuts(self): + return self.shortcuts_display + + def start(self): + self.refresh_tree() + if self._last_file and os.path.isfile(self._last_file) and self.current_path is None: + self.load_file(self._last_file) + self._last_file = None + + def files_focused(self): + try: return self.visible_panes()[self.columns.focus_position] == "files" + except Exception: return False + + def _state_file(self): + d = getattr(self.app, "configdir", None) + return os.path.join(d, "editor_state.json") if d else None + + def load_state(self): + p = self._state_file() + if not p or not os.path.isfile(p): + return + try: + with open(p) as f: + st = json.load(f) + self.filter_mu = bool(st.get("filter_mu", self.filter_mu)) + if isinstance(st.get("expanded"), list): + self.expanded = set(st["expanded"]) + pv = st.get("pane_visible") + if isinstance(pv, dict): + for k in self.pane_visible: + if k in pv: self.pane_visible[k] = bool(pv[k]) + self._last_file = st.get("last_file") + except Exception: + pass + + def save_state(self): + p = self._state_file() + if not p: + return + try: + self._atomic_write(p, json.dumps({"filter_mu": self.filter_mu, "expanded": sorted(self.expanded), + "pane_visible": self.pane_visible, "last_file": self.current_path})) + except Exception: + pass + + def _selected_tree(self): + try: + sel = self.tree_listbox.get_selected_item() + return sel.original_widget if sel else None + except Exception: + return None + + def _roots(self): + return [r for r in (self.app.pagespath, self.app.filespath) if r] + + def _is_root(self, path): + try: + rp = os.path.realpath(path) + except Exception: + return False + return any(rp == os.path.realpath(r) for r in self._roots()) + + def _confine(self, path, base=None): + # Never save outside of the Nomad storage directory + path = (path or "").strip() + if not path: + return None + path = os.path.expanduser(path) + if not os.path.isabs(path): + base = base or self.app.pagespath or self.app.filespath + if not base: + return None + path = os.path.join(base, path) + norm = os.path.normpath(path) + for root in self._roots(): + rp = os.path.normpath(root) + if norm == rp or norm.startswith(rp + os.sep): + return norm + return None + + def _atomic_write(self, path, text): + d = os.path.dirname(path) or "." + tmp = os.path.join(d, "." + os.path.basename(path) + ".tmp") + try: + existing_mode = None + try: + existing_mode = os.stat(path).st_mode + except OSError: + existing_mode = None + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + if existing_mode is not None: + try: os.chmod(tmp, existing_mode) + except Exception: pass + os.replace(tmp, path) + except Exception: + try: os.remove(tmp) + except Exception: pass + raise + + def _confirm_overwrite(self, dest, proceed): + self.show_dialog(ConfirmDialog("Overwrite", + "%s already exists.\nOverwrite it?" % os.path.basename(dest), + [("Overwrite", proceed), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + + def visible_panes(self): + return [n for n in PANE_ORDER if self.pane_visible[n]] or ["editor"] + + def rebuild_columns(self): + focused = None + try: focused = self._visible[self.columns.focus_position] + except Exception: pass + visible = self.visible_panes() + self.columns.contents = [ + (self.pane_widgets[n], self.columns.options(urwid.WEIGHT, PANE_WEIGHTS[n], box_widget=True)) + for n in visible + ] + self._visible = visible + # the rpeview starts non-selectable + target = focused if focused in visible else ("files" if "files" in visible else visible[0]) + try: self.columns.focus_position = visible.index(target) + except Exception: pass + + def toggle_pane(self, name): + visible = self.visible_panes() + if self.pane_visible[name] and len(visible) <= 1: + return + self.pane_visible[name] = not self.pane_visible[name] + self.rebuild_columns() + self.save_state() + + def refresh_tree(self): + focus_path = None + try: + sel = self.tree_listbox.get_selected_item() + if sel is not None: + focus_path = getattr(sel.original_widget, "path", None) + except Exception: + pass + entries = self.build_entries() + # indicativelistbox wraps items in attrmap for highlighting + self.tree_walker[:] = [urwid.AttrMap(e, None) for e in entries] + if focus_path is not None: + for i, e in enumerate(entries): + if getattr(e, "path", None) == focus_path: + try: self.tree_listbox.select_item(i) + except Exception: pass + break + + def build_entries(self): + entries = [] + for label, root in (("pages", self.app.pagespath), ("files", self.app.filespath)): + if not root or not os.path.isdir(root): + continue + entries.extend(self.walk_dir(label, root, 0)) + if not entries: + entries = [urwid.Text("no files")] + return entries + + def walk_dir(self, name, path, depth): + out = [] + expanded = path in self.expanded + folder = FolderEntry(name, path, depth, expanded, self.app.ui.glyphs) + urwid.connect_signal(folder, "click", self.on_folder_click) + out.append(folder) + if not expanded: + return out + try: + names = sorted(os.listdir(path), key=str.lower) + except Exception: + return out + dirs = [n for n in names if os.path.isdir(os.path.join(path, n)) and not n.startswith(".") and n != "__pycache__"] + files = [n for n in names if not os.path.isdir(os.path.join(path, n)) and not n.startswith(".")] + if self.filter_mu: + files = [n for n in files if n.endswith(".mu")] + dirs = [d for d in dirs if self._dir_has_mu(os.path.join(path, d))] + for d in dirs: + out.extend(self.walk_dir(d, os.path.join(path, d), depth + 1)) + for fn in files: + full = os.path.join(path, fn) + entry = FileEntry(fn, full, depth + 1, executable=is_executable(full)) + urwid.connect_signal(entry, "click", self.on_file_click) + out.append(entry) + return out + + def _dir_has_mu(self, path): + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + if any(f.endswith(".mu") for f in files): + return True + return False + + def on_filter_change(self, checkbox, state): + self.filter_mu = state + self.refresh_tree() + self.save_state() + + def on_folder_click(self, folder): + if folder.path in self.expanded: + self.expanded.discard(folder.path) + else: + self.expanded.add(folder.path) + self.refresh_tree() + self.save_state() + + def on_file_click(self, entry): + self.open_path(entry.path) + + def open_path(self, path): + if self.dirty: + self.confirm_discard(lambda: self._do_open(path)) + else: + self._do_open(path) + + def _do_open(self, path): + self.load_file(path) + if self.pane_visible["editor"]: + try: self.columns.focus_position = self.visible_panes().index("editor") + except Exception: pass + + def confirm_discard(self, proceed): + def save_then(): + self.close_dialog() + if self.current_path and self.editable: + self.save_file(self.current_path) + proceed() + name = os.path.basename(self.current_path) if self.current_path else "this buffer" + if self.current_path and self.editable: + buttons = [("Save", save_then), ("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)] + else: + buttons = [("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)] + self.show_dialog(ConfirmDialog("Unsaved changes", "Discard unsaved changes to %s?" % name, + buttons, on_cancel=self.close_dialog)) + + def load_file(self, path): + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + self.editable = True + except UnicodeDecodeError: + content = "" + self.editable = False + except Exception as e: + self.set_status("open failed: %s" % e); return + + self.current_path = path + self.is_exec = is_executable(path) + self.dirty = False + self._loading = True + self.editor.set_edit_text(content) + self.editor.set_edit_pos(0) + self._loading = False + + name = os.path.basename(path) + self.update_title() + self.save_state() + if not self.editable: + self.set_status("binary file (not editable)") + elif self.is_exec: + self.set_preview_note(name + " is an executable page. the node runs it and serves its output.") + self.set_status("executable page") + self.show_info_dialog("Executable page", name + " is marked executable (chmod +x). NomadNet runs it and serves its output as the page, so the preview shows the source rather than the rendered output.") + else: + self.render_preview() + self.set_status("opened " + name) + + def update_title(self, modified=False): + + + + if self.current_path is None: + self.pane_widgets["editor"].set_title("Editor"); return + suffix = " (binary)" if not self.editable else (" (executable)" if self.is_exec else "") + if modified: + suffix += " *" + self.pane_widgets["editor"].set_title("Editor: " + os.path.basename(self.current_path) + suffix) + + def set_preview_note(self, msg): + self.pane_widgets["preview"].original_widget = urwid.Filler(urwid.Text(msg), urwid.TOP) + + def show_info_dialog(self, title, message): + self.show_dialog(InfoDialog(title, message, self.close_dialog)) + + def save_or_saveas(self): + if self.current_path is None: + self.save_as_dialog(); return + self.save_file(self.current_path) + + def save_file(self, path): + if not self.editable and self.current_path is not None: + self.set_status("cannot save binary file"); return + try: + self._atomic_write(path, self.editor.edit_text) + self.current_path = path + self.editable = True + self.dirty = False + self.set_modified(False) + self.set_status("saved " + os.path.basename(path)) + self.refresh_tree() + self.save_state() + except Exception as e: + self.set_status("save failed: %s" % e) + + def new_file_dialog(self): + default = (self.app.pagespath or "") + os.sep + self.show_dialog(PathDialog("New (end with / for a folder)", default, self._do_new, self.close_dialog)) + + def _do_new(self, path): + self.close_dialog() + raw = (path or "").strip() + if not raw: + return + is_folder = raw.endswith("/") or raw.endswith(os.sep) + target = self._confine(raw) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + try: + if is_folder: + os.makedirs(target, exist_ok=True) + self.expanded.add(target) + self.refresh_tree(); self.save_state() + self.set_status("created folder " + os.path.basename(target)) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + if not os.path.exists(target): + self._atomic_write(target, "") + self.refresh_tree() + self.open_path(target) + except Exception as e: + self.set_status("new failed: %s" % e) + + def rename_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder to rename"); return + if self._is_root(w.path): + self.set_status("cannot rename the pages/files root"); return + self.show_dialog(PathDialog("Rename", w.path, lambda p, old=w.path: self._do_rename(old, p), self.close_dialog)) + + def _do_rename(self, old, new): + self.close_dialog() + target = self._confine(new, base=os.path.dirname(old)) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + if target == os.path.normpath(old): + return + + def proceed(): + self.close_dialog() + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + os.rename(old, target) + if os.path.isfile(old + ".allowed"): + try: os.replace(old + ".allowed", target + ".allowed") + except Exception: pass + old_prefix = old + os.sep + if self.current_path == old: + self.current_path = target; self.update_title() + elif self.current_path and self.current_path.startswith(old_prefix): + self.current_path = target + self.current_path[len(old):]; self.update_title() + self.expanded = set( + target if p == old else (target + p[len(old):] if p.startswith(old_prefix) else p) + for p in self.expanded + ) + self.refresh_tree(); self.save_state() + self.set_status("renamed to " + os.path.basename(target)) + except Exception as e: + self.set_status("rename failed: %s" % e) + + if os.path.exists(target) and os.path.realpath(target) != os.path.realpath(old): + self._confirm_overwrite(target, proceed) + else: + proceed() + + def delete_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder to delete"); return + path = w.path + if self._is_root(path): + self.set_status("cannot delete the pages/files root"); return + self.show_dialog(ConfirmDialog("Delete", "Delete %s?" % os.path.basename(path), + [("Delete", lambda: self._do_delete(path)), ("Cancel", self.close_dialog)], on_cancel=self.close_dialog)) + + def _do_delete(self, path): + self.close_dialog() + try: + prefix = path + os.sep + if os.path.isdir(path): + shutil.rmtree(path) + self.expanded = set(p for p in self.expanded if p != path and not p.startswith(prefix)) + else: + os.remove(path) + if os.path.isfile(path + ".allowed"): + try: os.remove(path + ".allowed") + except Exception: pass + if self.current_path is not None and (self.current_path == path or self.current_path.startswith(prefix)): + self.current_path = None; self.editable = False; self.is_exec = False; self.dirty = False + self._loading = True; self.editor.set_edit_text(""); self._loading = False + self.update_title(); self.set_preview_note("") + self.refresh_tree(); self.save_state() + self.set_status("deleted " + os.path.basename(path)) + except Exception as e: + self.set_status("delete failed: %s" % e) + + def _read_allowed(self, path): + ap = path + ".allowed" + if not os.path.isfile(ap): + return "" + try: + with open(ap, encoding="utf-8") as f: + return "\n".join(ln.strip() for ln in f.read().splitlines() if ln.strip()) + except Exception: + return "" + + def _walk_pages(self, path): + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + for fn in files: + if fn.endswith(".allowed"): + continue + yield os.path.join(root, fn) + + def _folder_permissions(self, path): + bodies = {} + total = 0 + for page in self._walk_pages(path): + total += 1 + body = self._read_allowed(page) + bodies[body] = bodies.get(body, 0) + 1 + restricted = sum(count for body, count in bodies.items() if body) + distinct = [body for body in bodies if body] + common = distinct[0] if len(bodies) == 1 and distinct else None + return common, restricted, total + + def permissions_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder for permissions"); return + path = w.path + is_dir = os.path.isdir(path) + + if is_dir: + common, restricted, total = self._folder_permissions(path) + content = common or "" + if not total: + state = "This folder contains no files." + elif not restricted: + state = "All %d files here are public." % total + elif common is not None: + state = "All %d files here share these entries." % total + else: + state = "Entries differ across files (%d of %d restricted)." % (restricted, total) + note = ("Allowed identity hashes per line.\n" + + state + "\nSaving applies these entries to every file in this folder and its subfolders.") + else: + content = self._read_allowed(path) + note = "Allowed identity hashes per line. Empty = public.\nApplies to this page or file." + + title = "Permissions: " + os.path.basename(path) + ("/" if is_dir else "") + self.show_dialog(PermissionsDialog(title, note, content, + lambda txt: self._save_permissions(path, is_dir, txt), self.close_dialog)) + + def _save_permissions(self, path, is_dir, text): + body = "\n".join(ln.strip() for ln in text.splitlines() if ln.strip()) + + if is_dir and not body: + restricted = self._folder_permissions(path)[1] + if restricted: + self.show_dialog(ConfirmDialog("Make public", + "This removes the access restrictions from %d page(s)\nunder %s, making them public.\n\nContinue?" + % (restricted, os.path.basename(path)), + [("Make public", lambda: self._apply_permissions(path, is_dir, body)), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + return + + self._apply_permissions(path, is_dir, body) + + def _apply_permissions(self, path, is_dir, body): + self.close_dialog() + try: + if is_dir: + count = 0 + for page in self._walk_pages(path): + self._write_allowed(page, body) + count += 1 + self.set_status(("permissions applied to %d files" if body else "permissions cleared on %d files") % count) + else: + self._write_allowed(path, body) + self.set_status("permissions saved" if body else "permissions cleared (public)") + self.refresh_tree() + except Exception as e: + self.set_status("permissions failed: %s" % e) + + def _write_allowed(self, path, body): + ap = path + ".allowed" + if body: + self._atomic_write(ap, body + "\n") + elif os.path.exists(ap): + os.remove(ap) + + def snippet_dialog(self): + self.show_dialog(SnippetDialog(self._insert_snippet, self.close_dialog)) + + def _insert_snippet(self, text): + self.close_dialog() + if self.pane_visible["editor"]: + try: self.columns.focus_position = self.visible_panes().index("editor") + except Exception: pass + pos = self.editor.edit_pos + t = self.editor.edit_text + self.editor.set_edit_text(t[:pos] + text + t[pos:]) + self.editor.set_edit_pos(pos + len(text)) + + def find_dialog(self): + if self._find is None: + self._find = FindDialog(self, self.close_dialog) + self.show_dialog(self._find) + + def find_next(self, term): + if not term: + return + t = self.editor.edit_text + idx = t.find(term, self.editor.edit_pos) + if idx < 0: + idx = t.find(term, 0) + if idx < 0: + self.set_status("'%s' not found" % term); return + self.editor.set_edit_pos(idx + len(term)) + self.set_status("found at %d" % idx) + + def replace_one(self, term, repl): + if not term: + return + t = self.editor.edit_text + end = self.editor.edit_pos + start = end - len(term) + if start >= 0 and t[start:end] == term: + self.editor.set_edit_text(t[:start] + repl + t[end:]) + self.editor.set_edit_pos(start + len(repl)) + self.set_status("replaced") + else: + self.find_next(term) + + def replace_all(self, term, repl): + if not term: + return + cnt = self.editor.edit_text.count(term) + if cnt: + self.editor.set_edit_text(self.editor.edit_text.replace(term, repl)) + self.set_status("replaced %d" % cnt) + else: + self.set_status("'%s' not found" % term) + + def save_as_dialog(self): + default = self.current_path or ((self.app.pagespath or "") + os.sep) + self.show_dialog(PathDialog("Save as", default, self._do_saveas, self.close_dialog)) + + def _do_saveas(self, path): + self.close_dialog() + target = self._confine(path) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + + def proceed(): + self.close_dialog() + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + except Exception: + pass + self.editable = True + self.save_file(target) + + already = self.current_path is None or os.path.realpath(target) != os.path.realpath(self.current_path) + if os.path.exists(target) and already: + self._confirm_overwrite(target, proceed) + else: + proceed() + + def show_dialog(self, dialog): + self.dialog = dialog + overlay = urwid.Overlay( + dialog, self.columns, + align=urwid.CENTER, width=(urwid.RELATIVE, 60), + valign=urwid.MIDDLE, height=(urwid.RELATIVE, 40), + min_width=30, min_height=8, + ) + self.body.original_widget = overlay + + def close_dialog(self): + self.dialog = None + self.body.original_widget = self.columns + + def on_edit_change(self, widget, old_text): + if self._loading or (self.current_path is not None and not self.editable): + return + self.dirty = True + self.set_modified(True) + if self.is_exec: + return + if self.preview_alarm is not None: + try: self.app.ui.loop.remove_alarm(self.preview_alarm) + except Exception: pass + self.preview_alarm = self.app.ui.loop.set_alarm_in(PREVIEW_DELAY, self._render_preview_alarm) + + def _render_preview_alarm(self, loop, user_data): + self.preview_alarm = None + self.render_preview() + + def render_preview(self): + if len(self.editor.edit_text) > MAX_PREVIEW_CHARS: + self.set_preview_note("File too large to preview (%d KB). Editing still works." % (len(self.editor.edit_text) // 1000)) + return + pos = self._preview_scrollpos() + try: + body = self.preview_browser.render_markup_buffer(self.editor.edit_text) + except Exception as e: + body = urwid.Filler(urwid.Text("preview error: %s" % e), urwid.TOP) + self.pane_widgets["preview"].original_widget = body + self._set_preview_scrollpos(pos) + + def _preview_scrollable(self): + try: + return self.preview_browser.browser_body.original_widget.original_widget + except Exception: + return None + + def _preview_scrollpos(self): + s = self._preview_scrollable() + try: return s.get_scrollpos() + except Exception: return None + + def _set_preview_scrollpos(self, pos): + if pos is None: return + s = self._preview_scrollable() + try: s.set_scrollpos(pos) + except Exception: pass + + def set_modified(self, modified): + self.update_title(modified) + + def set_status(self, msg): + self.shortcuts_display.set_status(msg) diff --git a/nomadnet/ui/textui/Extras.py b/nomadnet/ui/textui/Extras.py index f0314df..54b4fec 100644 --- a/nomadnet/ui/textui/Extras.py +++ b/nomadnet/ui/textui/Extras.py @@ -6,56 +6,14 @@ class IntroDisplay(): font = urwid.font.HalfBlock5x4Font() big_text = urwid.BigText(("intro_title", self.app.config["textui"]["intro_text"]), font) - big_text = urwid.Padding(big_text, align="center", width="clip") + big_text = urwid.Padding(big_text, align=urwid.CENTER, width=urwid.CLIP) intro = urwid.Pile([ big_text, - urwid.Text(("Version %s" % (str(self.app.version))), align="center"), + urwid.Text(("Version %s" % (str(self.app.version))), align=urwid.CENTER), urwid.Divider(), - urwid.Text(("-= Starting =- "), align="center"), + urwid.Text(("-= Starting =- "), align=urwid.CENTER), ]) self.widget = urwid.Filler(intro) - -class DemoDisplay(): - def __init__(self, ui, app): - import urwid - - def color_mono(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_MONO) - - def color_16(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_16) - - def color_88(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_88) - - def color_8bit(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_256) - - def color_true(btn): - ui.set_colormode(nomadnet.ui.COLORMODE_TRUE) - - # pile = urwid.Pile([ - # urwid.Text(("heading", "This is a heading")), - # urwid.Text(("body_text", "Hello World \U0001F332")), - # urwid.Button(("buttons", "Monochrome"), color_mono), - # urwid.Button(("buttons", "16 color"), color_16), - # urwid.Button(("buttons", "88 color"), color_88), - # urwid.Button(("buttons", "256 color"), color_8bit), - # urwid.Button(("buttons", "True color"), color_true), - # ]) - - gf = urwid.GridFlow([ - urwid.Text(("heading", "This is a heading")), - urwid.Text(("body_text", "Hello World \U0001F332")), - urwid.Button(("buttons", "Monochrome"), color_mono), - urwid.Button(("buttons", "16 color"), color_16), - urwid.Button(("buttons", "88 color"), color_88), - urwid.Button(("buttons", "256 color"), color_8bit), - urwid.Button(("buttons", "True color"), color_true), - ], cell_width=20, h_sep=0, v_sep=0, align="left") - - self.widget = urwid.Filler(urwid.Padding((urwid.Text("Test"),urwid.Text("Test 2"))), 'top') - #self.widget = urwid.Filler(pile, 'top') \ No newline at end of file diff --git a/nomadnet/ui/textui/FileBrowser.py b/nomadnet/ui/textui/FileBrowser.py new file mode 100644 index 0000000..f80a4f6 --- /dev/null +++ b/nomadnet/ui/textui/FileBrowser.py @@ -0,0 +1,234 @@ +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 diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 1bb002f..a204272 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -10,7 +10,7 @@ class GuideDisplayShortcuts(): self.app = app g = app.ui.glyphs - self.widget = urwid.AttrMap(urwid.Padding(urwid.Text(""), align="left"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Padding(urwid.Text(""), align=urwid.LEFT), "shortcutbar") class ListEntry(urwid.Text): _selectable = True @@ -60,6 +60,75 @@ class SelectText(urwid.Text): self._emit('click') return True +def _rows_above(attrmaps, index, cols): + if index <= 0 or not attrmaps: + return 0 + total = 0 + for i in range(min(index, len(attrmaps))): + try: + total += attrmaps[i].rows((cols,)) + except Exception: + total += 1 + return total + + +class GuideColumns(urwid.Columns): + def keypress(self, size, key): + # TODO: Scroll handling is currently very weird + # in the guide reader. When using page up/down + # or mouse scroll, the actual cursor position + # does not follow the scroll, as it does in the + # browser. If you scroll down, and then use the + # arrow keys, the position jumps back up to + # where you "left" the cursor. + # There's handling for this in the Browser code + # but I don't have time to port it over here at + # this point. If someone else takes that one up + # it's :) :) :) + return super().keypress(size, key) + + +class GuideLinkDelegate: + def __init__(self, app, reader=None): + self.app = app + self.g = app.ui.glyphs + self.reader = reader + self.last_keypress = 0 + + def marked_link(self, target, fields=None): + pass + + def micron_released_focus(self): + 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): + if not target: + return + if target.startswith("#"): + if self.reader is not None: + try: + def df(loop, user_data): self.reader.jump_to_anchor(target[1:]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + except Exception as e: + RNS.log("Guide anchor jump failed: "+str(e), RNS.LOG_ERROR) + return + try: + self.app.ui.main_display.show_network(None) + self.app.ui.main_display.sub_displays.network_display.browser.handle_link(target, fields) + except Exception as e: + RNS.log("Could not open guide link: "+str(e), RNS.LOG_ERROR) + + class GuideEntry(urwid.WidgetWrap): def __init__(self, app, parent, reader, topic_name): self.app = app @@ -75,11 +144,11 @@ class GuideEntry(urwid.WidgetWrap): style = "topic_list_normal" focus_style = "list_focus" self.display_widget = urwid.AttrMap(widget, style, focus_style) - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def display_topic(self, event, topic): markup = TOPICS[topic] - attrmaps = markup_to_attrmaps(markup, url_delegate=None) + attrmaps = markup_to_attrmaps(markup, url_delegate=GuideLinkDelegate(self.app, reader=self.reader)) topic_position = None index = 0 @@ -109,9 +178,12 @@ class TopicList(urwid.WidgetWrap): self.topic_list = [ GuideEntry(self.app, self, guide_display, "Introduction"), GuideEntry(self.app, self, guide_display, "Concepts & Terminology"), + GuideEntry(self.app, self, guide_display, "Channels & RRC"), + GuideEntry(self.app, self, guide_display, "Interfaces"), GuideEntry(self.app, self, guide_display, "Hosting a Node"), GuideEntry(self.app, self, guide_display, "Configuration Options"), - GuideEntry(self.app, self, guide_display, "Markup"), + GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"), + GuideEntry(self.app, self, guide_display, "Micron Markup"), self.first_run_entry, GuideEntry(self.app, self, guide_display, "Network Configuration"), GuideEntry(self.app, self, guide_display, "Display Test"), @@ -124,13 +196,13 @@ class TopicList(urwid.WidgetWrap): highlight_offFocus="list_off_focus" ) - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.ilb, title="Topics")) + super().__init__(urwid.LineBox(self.ilb, title="Topics")) def keypress(self, size, key): if key == "up" and (self.ilb.first_item_is_selected()): - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") - + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return super(TopicList, self).keypress(size, key) class GuideDisplay(): @@ -140,19 +212,20 @@ class GuideDisplay(): self.app = app g = self.app.ui.glyphs - topic_text = urwid.Text("\n No topic selected", align="left") + topic_text = urwid.Text("\n No topic selected", align=urwid.LEFT) self.left_area = TopicList(self.app, self) - self.right_area = urwid.LineBox(urwid.Filler(topic_text, "top")) + self.right_area = urwid.LineBox(urwid.Filler(topic_text, urwid.TOP)) - self.columns = urwid.Columns( + self.columns = GuideColumns( [ - ("weight", GuideDisplay.list_width, self.left_area), - ("weight", 1-GuideDisplay.list_width, self.right_area) + (urwid.WEIGHT, GuideDisplay.list_width, self.left_area), + (urwid.WEIGHT, 1-GuideDisplay.list_width, self.right_area) ], dividechars=0, focus_column=0 ) + self.columns.guide_display = self self.shortcuts_display = GuideDisplayShortcuts(self.app) self.widget = self.columns @@ -162,13 +235,55 @@ class GuideDisplay(): entry.display_topic(entry.display_topic, entry.topic_name) def set_content_widgets(self, new_content): - options = self.columns.options(width_type="weight", width_amount=1-GuideDisplay.list_width) + options = self.columns.options(width_type=urwid.WEIGHT, width_amount=1-GuideDisplay.list_width, box_widget=True) pile = urwid.Pile(new_content) - #content = urwid.LineBox(urwid.Filler(pile, "top")) - content = urwid.LineBox(urwid.AttrMap(ScrollBar(Scrollable(pile), thumb_char="\u2503", trough_char=" "), "scrollbar")) + self._content_attrmaps = new_content + self._content_scrollable = Scrollable(pile) + pile.automove_cursor_on_scroll = True + content = urwid.LineBox(urwid.AttrMap(ScrollBar(self._content_scrollable, thumb_char="\u2503", trough_char=" "), "scrollbar")) self.columns.contents[1] = (content, options) + def jump_to_anchor(self, name): + scrollable = getattr(self, "_content_scrollable", None) + attrmaps = getattr(self, "_content_attrmaps", None) + if scrollable is None or attrmaps is None: + return + anchors = getattr(attrmaps, "anchors", None) or {} + header_rows = getattr(attrmaps, "header_rows", None) or [] + cols = self._content_cols() + target_idx = None + if name: + target_idx = anchors.get(name) + if target_idx is None: + return + else: + try: current = scrollable.get_scrollpos() + except Exception: current = 0 + for hr in header_rows: + if _rows_above(attrmaps, hr, cols) > current: + target_idx = hr + break + if target_idx is None: + return + row_offset = _rows_above(attrmaps, target_idx, cols) + try: + scrollable.force_cursor_update = True + scrollable.anchor_cursor_update = True + scrollable.set_scrollpos(int(row_offset)) + except Exception: + pass + + def _content_cols(self): + try: cols = self.app.ui.loop.screen.get_cols_rows()[0] + except Exception: cols = 100 + + try: + widths = self.columns.column_widths((cols,)) + if len(widths) > 1 and widths[1] > 0: return max(40, widths[1] - 3) + except Exception: pass + return max(40, int(cols * (1 - GuideDisplay.list_width)) - 3) + def shortcuts(self): return self.shortcuts_display @@ -190,10 +305,109 @@ Nomad Network is build on LXMF and Reticulum, which together provides the crypto 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 Reticulum networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. -The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project on GitHub. +The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project at `_`F79d`[Aleph git`a8d24177d946de4f1f0a0fe1af9a1338:/page/index.mu]`f`_. ''' +TOPIC_SHORTCUTS = '''>Keyboard Shortcuts + +The different sections of the program has a number of keyboard shortcuts mapped, that makes operating and navigating the program easier. The following lists details all mapped shortcuts. + +>>`!Conversations Window`! +>>>Conversation List + - Ctrl-N Start a new conversation + - Ctrl-E Display and edit selected peer info + - Ctrl-X Delete conversation + - Ctrl-R Open LXMF syncronisation dialog + - Ctrl-U Ingest LXMF URI + - Ctrl-O Toggle sort mode + - Ctrl-P Display own LXMF address + - Ctrl-G Toggle fullscreen + +>>>Message Editor + - Ctrl-D Send message + - Ctrl-P Compose paper message + - Ctrl-T Toggle message title field + - Ctrl-F Attach file + - Ctrl-S Save focused attachment + - Tab Switch focus to message list + +>>>Message List + - Ctrl-W Close conversation + - Ctrl-U Purge failed messages + - Ctrl-O Toggle sort mode + - Ctrl-X Clear conversation history + - Ctrl-G Toggle fullscreen conversation + - Ctrl-S Save focused attachment + - Tab Switch focus to message editor + +>>`!Channels Window`! +>>>Channel List + - Ctrl-N Add a new hub + - Ctrl-A Add (join) a room + - Ctrl-R Connect to selected hub + - Ctrl-W Disconnect from selected hub + - Ctrl-T Toggle auto-reconnect for selected hub + - Ctrl-E Edit selected hub + - Ctrl-X Remove selected hub + - F8 Toggle join/part collapse + +>>>Message Editor + - Ctrl-D Send message + - Ctrl-X Leave current room + - F8 Toggle join/part collapse + - Tab Complete nickname + +>>>Message List + - Ctrl-X Leave current room + - Ctrl-U Toggle users pane + - Ctrl-Y Toggle channel list + - F8 Toggle join/part collapse + - Tab Switch focus to message editor + +>>`!Input Field Editing`! +All text input fields support readline-style editing shortcuts. When an input +field is focused, these take precedence over any window shortcut mapped to the +same key (so, for example, Ctrl-U edits the line rather than toggling a pane): + - Ctrl-A Move to beginning of line + - Ctrl-E Move to end of line + - Ctrl-U Delete from cursor to beginning of line + - Ctrl-K Delete from cursor to end of line + - Ctrl-W Delete previous word (whitespace-delimited) + - Ctrl-L Delete the entire buffer + - Ctrl-Y Paste (yank) most recently deleted text + - Ctrl-Left Move backward one word + - Ctrl-Right Move forward one word + +Text deleted with Ctrl-U, Ctrl-K, Ctrl-W or Ctrl-L is placed in a shared yank +buffer that Ctrl-Y pastes back, so text can be moved between input fields. + +>>`!Network Window`! +>>>Browser + - Ctrl-D Back + - Ctrl-F Forward + - Ctrl-R Reload page + - Ctrl-U Open URL entry dialog + - Ctrl-S Save connected node + - Ctrl-G Toggle fullscreen browser window + - Ctrl-W Disconnect from node + +>>>Announce Stream + - Ctrl-L Switch to Known Nodes list + - Ctrl-X Delete selected announce + - Ctrl-P Display peered LXMF Propagation Nodes + +>>>Known Nodes + - Ctrl-L Switch to Announce Stream + - Ctrl-X Delete selected node entry + - Ctrl-P Display peered LXMF Propagation Nodes + +>>>Peered LXMF Propagation Nodes + - Ctrl-L Switch to Announce Stream or Known Nodes + - Ctrl-X Break peering with selected node entry + - Ctrl-R Request immediate delivery sync of unhandled LXMs +''' + TOPIC_CONCEPTS = '''>Concepts and Terminology The following section will briefly introduce various concepts and terms used in the program. @@ -202,9 +416,9 @@ The following section will briefly introduce various concepts and terms used in A `*peer`* refers to another Nomad Network client, which will generally be operated by another person. But since Nomad Network is a general LXMF client, it could also be any other LXMF client, program, automated system or machine that can communicate over LXMF. -All peers (and nodes) are identified by their `*address`* (which is, technically speaking, a Reticulum destination hash). An address consist of 20 hexadecimal characters, and looks like this: +All peers (and nodes) are identified by their `*address`* (which is, technically speaking, a Reticulum destination hash). An address consist of 32 hexadecimal characters (16 bytes), and looks like this: -`c +`c `l Anyone can choose whatever display name they want, but addresses are always unique, and generated from the unique cryptographic keys of the peer. This is an important point to understand. Since there is not anyone controlling naming or address spaces in Nomad Network, you can easily come across another user with the same display name as you. @@ -229,13 +443,13 @@ In the `![ Conversations ]`! part of the program you can view and interact with By default, Nomad Network will attempt to deliver messages to a peer directly. This happens by first establishing an encrypted link directly to the peer, and then delivering the message over it. -If the desired peer is not available because it has disconnected from the network, this method will obviously fail. In this case, Nomad Network will attempt to deliver the message to a node, which will store and forward it over the network, for later retrieval by the destination peer. The message is encrypted with an ephemeral key before being transmitted to the network, and is only readable by the intended recipient. +If the desired peer is not available because it has disconnected from the network, this method will obviously fail. In this case, Nomad Network will attempt to deliver the message to a node, which will store and forward it over the network, for later retrieval by the destination peer. The message is encrypted before being transmitted to the network, and is only readable by the intended recipient. For propagated delivery to work, one or more nodes must be available on the network. If one or more trusted nodes are available, Nomad Network will automatically select the most suitable node to send the message via, but you can also manually specify what node to use. To select a node manually, go to the `![ Network ]`! part of the program, choose the desired node in the `*Known Nodes`* list, and select the `!< Info >`! button. In the `!Node Info`! dialog, you can specify the selected node as the default propagation node. -By default, Nomad Network will check in with propagation nodes, and download any available messages every 6 hours. You can change this interval, or disable automatic synchronisation completely, by editing the configuration file. +By default, Nomad Network will check in with propagation nodes, and download any available messages every 6 hours. You can change this interval, or disable automatic syncronisation completely, by editing the configuration file. You can always initiate a sync manually, by pressing `!Ctrl-R`! in the `![ Conversations ]`! part of the program, which will open the syncronisation window. @@ -245,7 +459,7 @@ A Nomad Network `*node`* is an instance of the Nomad Network program that has be Nodes can host pages (similar to webpages) written in a markup-language called `*micron`*, as well as make files and other resources available for download for peers on the network. Nodes also form a distributed message store for offline users, and allows messages to be exchanged between peers that are not online at the same time. -If no nodes exist on a network, all peers will still be able to communicate directly peer-to-peer, but both endpoints of a conversation will need to be available at the same time to converse. When nodes exist on the network, messages will be held and syncronised between nodes for deferred delivery if the destination peer is unavailable. Nodes will automatically discover and peer with each other, and handle synchronisation of message stores. +If no nodes exist on a network, all peers will still be able to communicate directly peer-to-peer, but both endpoints of a conversation will need to be available at the same time to converse. When nodes exist on the network, messages will be held and syncronised between nodes for deferred delivery if the destination peer is unavailable. Nodes will automatically discover and peer with each other, and handle syncronisation of message stores. To learn how to host your own node, read the `*Hosting a Node`* section of this guide. @@ -253,43 +467,59 @@ To learn how to host your own node, read the `*Hosting a Node`* section of this TOPIC_HOSTING = '''>Hosting a Node -To host a node on the network, you must enable it in the configuration file, by setting `*enable_node`* directive to `*yes`*. You should also configure the other node-related parameters such as the node name and announce interval settings. Once node hosting has been enabled in the configuration, Nomad Network will start hosting your node as soon as the program is launched, and other peers on the network will be able to connect and interact with content on your node. +To host a node on the network, you must enable it in the configuration file, by setting the `*enable_node`* directive to `*yes`*. You should also configure the other node-related parameters such as the node name and announce interval settings. Once node hosting has been enabled in the configuration, Nomad Network will start hosting your node as soon as the program is launched, and other peers on the network will be able to connect and interact with content on your node. By default, no content is defined, apart from a short placeholder home page. To learn how to add your own content, read on. >>Distributed Message Store -All nodes on the network automatically form a distributed message store that allows users to exchange messages, even when they are not available at the same time. +All nodes on the network will automatically participate in a distributed message store that allows users to exchange messages, even when they are not connected to the network at the same time. -When Nomad Network is configured to host a node, it also configures itself as an LXMF Propagation Node, and automatically discovers and peers with other propagation nodes on the network. This process is completely automatic and requires no configuration from the node operator. +When Nomad Network is configured to host a node, by default it also configures itself as an LXMF Propagation Node, and automatically discovers and peers with other propagation nodes on the network. This process is completely automatic and requires no configuration from the node operator. -To view LXMF Propagation nodes that are currently peered with your node, go to the `![ Network ]`! part of the program and press `!Ctrl-P`!. +`!However`!, if there is already an abundance of Propagation Nodes on the network, or the operator simply wishes to host a pageserving-only node, Propagation Node hosting can be disabled in the configuration file. + +To view LXMF Propagation nodes that are currently peered with your node, go to the `![ Network ]`! part of the program and press `!Ctrl-P`!. In the list of peered Propagation Nodes, it is possible to: + + - Immediately break peering with a node by pressing `!Ctrl-X`! + - Request an immediate delivery sync of all unhandled messages for a node, by pressing `!Ctrl-R`! The distributed message store is resilient to intermittency, and will remain functional as long as at least one node remains on the network. Nodes that were offline for a time will automatically be synced up to date when they regain connectivity. >>Pages -Nomad Network nodes can host pages similar to web pages, that other peers can read and interact with. Pages are written in a compact markup language called `*micron`*. To learn how to write formatted pages with micron, see the `*Markup`* section of this guide (which is, itself, written in micron). Pages can be linked arbitrarily with hyperlinks, that can also link to pages (or other resources) on other nodes. +Nomad Network nodes can host pages similar to web pages, that other peers can read and interact with. Pages are written in a compact markup language called `*micron`*. To learn how to write formatted pages with micron, see the `*Markup`* section of this guide (which is, itself, written in micron). Pages can be linked together with hyperlinks, that can also link to pages (or other resources) on other nodes. To add pages to your node, place micron files in the `*pages`* directory of your Nomad Network programs `*storage`* directory. By default, the path to this will be `!~/.nomadnetwork/storage/pages`!. You should probably create the file `!index.mu`! first, as this is the page that will get served by default to a connecting peer. You can control how long a peer will cache your pages by including the cache header in a page. To do so, the first line of your page must start with `!#!c=X`!, where `!X`! is the cache time in seconds. To tell the peer to always load the page from your node, and never cache it, set the cache time to zero. You should only do this if there is a real need, for example if your page displays dynamic content that `*must`* be updated at every page view. The default caching time is 12 hours. In most cases, you should not need to include the cache control header in your pages. -You can use a preprocessor such as PHP, bash, Python (or whatever you prefer) to generate dynamic pages. To do so, just set executable permissions on the relevant page file, and be sure to include the interpreter at the beginning of the file, for example `!#!/usr/bin/python3`!. +>> Dynamic Pages + +You can use a preprocessor such as PHP, bash, Python (or whatever you prefer) to generate dynamic pages and fully interactive applications running over Nomad Network. To do so, just set executable permissions on the relevant page file, and be sure to include the interpreter at the beginning of the file, for example `!#!/usr/bin/python3`!. + +Data from fields and link variables will be passed to these scipts or programs as environment variables, and can simply be read by any method for accessing such. + +In the `!examples`! directory, you can find various small examples for the use of this feature. The currently included examples are: + + - A messageboard that receives messages over LXMF, contributed by trippcheng + - A simple demonstration on how to create fields and read entered data in node-side scripts + +By default, you can find the examples in `!~/.nomadnetwork/examples`!. If you build something neat, that you feel would fit here, you are more than welcome to contribute it. >>Authenticating Users -Sometimes, you don't want everyone to be able to view certain pages or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. +Sometimes, you don't want everyone to be able to view certain pages, download certain files or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. -To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". +To enable authentication for any page or file, simply add a new file next to it with ".allowed" added to its file-name. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". The same works for files in your files directory, and the ".allowed" file itself is never served. For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this: `Faaa `= -24c2dc2248953e0a3c21 -564a476410f4fca2ae93 -8bb864743f12f53395a1 +d454bcdac0e64fb68ba8e267543ae110 +2b9ff3fb5902c9ca5ff97bdfb239ef50 +7106d5abbc7208bfb171f2dd84b36490 `= `` @@ -305,7 +535,7 @@ Like pages, you can place files you want to make available in the `!~/.nomadnetw Links to pages and resources in Nomad Network use a simple URL format. Here is an example: -`!1385edace36466a6b3dd:/page/index.mu`! +`!18176ffddcc8cce1ddf8e3f72068f4a6:/page/index.mu`! The first part is the 10 byte destination address of the node (represented as readable hexadecimal), followed by the `!:`! character. Everything after the `!:`! represents the request path. @@ -323,12 +553,310 @@ Links can be inserted into micron documents. See the `*Markup`* section of this ''' +TOPIC_INTERFACES = '''>Interfaces + +Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. + +The number of distinct network topologies you can create with Reticulum is more or less endless, but common to them all is that you will need to define one or more interfaces for Reticulum to use. + +The `![ Interfaces ]`! section of NomadNet lets you add, monitor, and update interfaces configured for your Reticulum instance. + +If you are starting NomadNet for the first time you will find that an `!AutoInterface`! has been added by default. This interface will try to use your available network device to communicate with other peers discovered on your local network. + +Interfaces come in many different types and can interact with physical mediums like LoRa radios or standard IP networks. + +>>Viewing Interfaces + +To view more info about an interface, navigate using the `!Up`! and `!Down`! arrow keys or by clicking with the mouse. Pressing `! < Enter >`! on a selected interface will bring you to a detailed interface view, which will show configuration parameters and realtime charts. From here you can also disable or edit the interface. To change the orientation of the TX/RX charts, press `!< V >`! for a vertical layout, and `!< H >`! for a horizontal view. + +>>Updating Interfaces + +To edit an interface, select the interface and press `!< Ctrl + E >`!. + +To remove an interface, select the interface and press `!< Ctrl + X >`!. You can also perform both of these actions from the details view. + +>>Adding Interfaces + +To add a new interface, press `!< Ctrl + A >`!. From here you can select which type of interface you want to add. Each unique interface type will have different configuration options. + +`Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b + + +>Interface Profiles + +Interface profiles let you save different combinations of enabled interfaces and switch between them quickly. For example, you might keep a `!Home`! profile that uses a local AutoInterface, and a `!Travel`! profile that reaches out over a TCP gateway instead. + +Press `!< Tab >`! from the `![ Interfaces ]`! section to move between the `!Interfaces`! and `!Profiles`! tabs. + +To save your current set of enabled interfaces as a new profile, press `!< Ctrl + S >`!. To assign an interface to one or more profiles, edit it with `!< Ctrl + E >`! and use the profile checkboxes in the edit form. You can also toggle the selected interface on or off directly with `!< Ctrl + O >`!, and filter the list by All, Enabled or Disabled with `!< Ctrl + F >`!. + +`Ffff`! (!) Note:`! Switching profiles changes which interfaces are enabled, so a restart of NomadNet or your Reticulum instance is required for the change to take effect.`f`b + + +>Interface Types + +Jump to a specific interface type: + + `F79d`_`[AutoInterface`#autointerface]`_`f + `F79d`_`[TCPClientInterface`#tcpclientinterface]`_`f + `F79d`_`[TCPServerInterface`#tcpserverinterface]`_`f + `F79d`_`[UDPInterface`#udpinterface]`_`f + `F79d`_`[I2PInterface`#i2pinterface]`_`f + `F79d`_`[RNodeInterface`#rnodeinterface]`_`f + `F79d`_`[RNodeMultiInterface`#rnodemultiinterface]`_`f + `F79d`_`[SerialInterface`#serialinterface]`_`f + `F79d`_`[KISSInterface`#kissinterface]`_`f + `F79d`_`[PipeInterface`#pipeinterface]`_`f + +>>AutoInterface + +The Auto Interface enables communication with other discoverable Reticulum nodes over autoconfigured IPv6 and UDP. It does not need any functional IP infrastructure like routers or DHCP servers, but will require at least some sort of switching medium between peers (a wired switch, a hub, a WiFi access point or similar). + +``` +Required Parameters: +Interface Name + +Optional Parameters: +Devices: Specific network devices to use +Ignored Devices: Network devices to exclude +Group ID: Create isolated networks on the same physical LAN +Discovery Scope: Can set to link, admin, site, organisation or global +``` + +The AutoInterface is ideal for quickly connecting to other Reticulum nodes on your local network without complex configuration. + +>>TCPClientInterface + +The TCP Client interface connects to remote TCP server interfaces, enabling communication over the Internet or private networks. + +``` +Required Parameters: +Target Host: Hostname or IP address of the server +Target Port: Port number to connect to + +Optional Parameters: +I2P Tunneled: Enable for connecting through I2P +KISS Framing: Enable for KISS framing for software modems +``` + +This interface is commonly used to connect to Reticulum gateways or other persistent nodes on the Internet. + +>>TCPServerInterface + +The TCP Server interface listens for incoming connections, allowing other Reticulum peers to connect to your node using TCPClientInterface. + +``` +Required Parameters: +Listen IP: IP address to bind to (0.0.0.0 for all interfaces) +Listen Port: Port number to listen on + +Optional Parameters: +Prefer IPv6: Bind to IPv6 address if available +I2P Tunneled: Enable for I2P tunnel support +Device: Specific network device to use (e,g +``` + +Useful when you want other nodes to be able to connect to your Transport instance over TCP/IP. + +>>UDPInterface + +The UDP interface allows communication over IP networks using UDP packets. + +``` +Required Parameters: +Listen IP: IP address to bind to +Listen Port: Port to listen on +Forward IP: IP address to forward to (Can be broadcast address) +Forward Port: Port to forward to + +Optional Parameters: +Device: Specific network device to use +``` + +>>I2PInterface + +The I2P interface enables connections over the Invisible Internet Protocol. The I2PInterface requires an I2P daemon to be running on your system, such as `!i2pd`! + +``` +Optional Parameters: +Peers: I2P addresses to connect to (Can be left as none if running as a Transport) +``` + + +>>RNodeInterface + +The RNode interface allows using LoRa transceivers running RNode firmware as Reticulum network interfaces. + +``` +Required Parameters: +Port: Serial port or BLE device path +Frequency: Operating frequency in MHz +Bandwidth: Channel bandwidth +TX Power: Transmit power in dBm +Spreading Factor: LoRa spreading factor (7-12) +Coding Rate: LoRa coding rate (4:5-4:8) + +Optional Parameters: +ID Callsign: Station identification +ID Interval: Identification interval in seconds +Airtime Limits: Control duty cycle +``` + +The interface includes a parameter calculator to estimate link budget, sensitivity, and data rate on the air based on your settings. + +>>RNodeMultiInterface + +The RNode Multi Interface is designed for use with specific hardware platforms that support multiple subinterfaces or virtual radios. For most LoRa hardware platforms, you will want to use the standard `!RNodeInterface`! instead. + +``` +Required Parameters: +Port: Serial port or BLE device path +Subinterfaces: Configuration for each subinterface / virtual radio port + +``` + +>>SerialInterface + +The Serial interface enables using direct serial connections as Reticulum interfaces. + +``` +Required Parameters: +Port: Serial port path +Speed: Baud rate of serial device +Databits: Number of data bits +Parity: Parity setting +Stopbits: Number of stop bits +``` + +>>KISSInterface + +The KISS interface supports packet radio modems and TNCs using the KISS protocol. + +``` +Required Parameters: + +Port: Serial port path +Speed: Baud rate of serial device +Databits: Number of data bits +Parity: Parity setting +Stopbits: Number of stop bits +Preamble: Modem preamble in milliseconds +TX Tail: Transmit tail in milliseconds +Slottime: CSMA slottime in milliseconds +Persistence: CSMA persistence value + +Optional Parameters: + +ID Callsign: Station identification +ID Interval: Identification interval in seconds +Flow Control: Enable packet flow control +``` + +>>PipeInterface + +The Pipe interface allows using external programs as Reticulum interfaces via stdin and stdout. + +``` +Required Parameters: + +Command: External command to execute + +Optional Parameters: +Respawn Delay: Seconds to wait before restarting after failure +``` + +< +>> +-∿ + For more information and to view the full Interface documentation consult the Reticulum manual or visit https://reticulum.network/manual/interfaces.html (Requires external Internet connection) + + +>Interface Access Code (IFAC) Settings + +Interface Access Codes create private networks and securely segment network traffic. Under `!Show more options`!, you'll find these IFAC settings: + +`!Virtual Network Name`! (network_name): +When added, creates a logical network that only communicates with other interfaces using the same name. This allows multiple separate Reticulum networks to exist on the same physical channel or medium. + +`!IFAC Passphrase`! (passphrase): +Sets an authentication passphrase for the interface. Only interfaces configured with the same passphrase will be able to communicate. Can be used with or without a network name. + +`!IFAC Size`! (ifac_size): +Controls the length of Interface Authentication Codes (8-512 bits). Larger sizes provide stronger security but add overhead to each packet. The default of `!8`! is usually appropriate for most uses. + +>Interface Modes + +When running a Transport node, you can configure interface modes that affect how Reticulum selects paths, propagates announces, and discovers routes: + +`!full`!: Default mode with all discovery, meshing and transport functionality +`!gateway`!: Discovers paths on behalf of other nodes +`!access_point`!: Operates as a network access point +`!roaming`!: For physically mobile interfaces +`!boundary`!: For interfaces connecting different network segments + +''' + TOPIC_CONVERSATIONS = '''>Conversations Conversations in Nomad Network ''' +TOPIC_CHANNELS = '''>Channels & RRC + +NomadNet includes a built-in client for `*RRC`* (Reticulum Relay Chat), a real-time text chat protocol that runs over Reticulum. The reference RRC server implementation lives at https://github.com/kc1awv/rrcd. Each RRC server is called a `!hub`!, and each hub hosts one or more `!rooms`! (channels) you can join. Hubs are addressed by a Reticulum destination hash, just like nodes and conversations. + +The Channels section of NomadNet (accessible from the main menu, or by pressing the corresponding shortcut) is where you manage your hubs, view connection status, and chat in rooms. + +>>Joining a hub + +To start chatting on a hub you must first add it to your hub list: + +>>> + - Open the `![ Channels ]`! section. + - Press the shortcut to open the `*New Hub`* dialog (shown in the shortcut bar at the bottom). + - Enter the `!hub address`! (the destination hash of the hub, typically 16 hex characters / 8 bytes) and an optional display name. + - Confirm the dialog. The hub will appear in your hub list. +< + +You can sometimes receive a hub link from another user or from a node page. Activating such a link will pre-fill the New Hub dialog (and optionally a room name) for you. + +>>Connecting and listing rooms + +Once a hub is added: + +>>> + - Select the hub in the list and connect to it. Hubs can also be set to auto-reconnect. + - When connected, run `*/list`* to see public rooms hosted on this hub. + - Run `*/join `* to join a room. Joined rooms appear under the hub in your channel list. +< + +>>Chatting + +Inside a room, anything you type that does not start with a `*/`* is sent as a message to everyone in the room. The most common in-room commands are: + +>>> + - `*/help`* show the full list of commands + - `*/who`* list users in the current room + - `*/nick `* set your display name on this hub + - `*/topic [text]`* view or set the room topic + - `*/part [room]`* leave a room (defaults to the current one) + - `*/quit`* disconnect from the hub +< + +Messages support both `*markdown`* and `*micron`* formatting, depending on your render settings (see the `*Configuration Options`* topic). Because the backtick is the micron control character, you must use the `!broken-bar`! character `*¦`* in place of `*\\``* when typing micron formatting in a message. The renderer converts `*¦`* back to `*\\``* for display. + +>>Mentions and privacy + +You will be notified (and the bell may ring, depending on your configuration) when another user mentions your nick with `*@yournick`*. RRC traffic is end-to-end encrypted by Reticulum between you and the hub, but other users in the same room can read what you write there. Treat rooms as semi-public spaces. + +>>Hub etiquette + +Each hub is operated independently and may have its own rules, MOTD, ban list and operator team. Be respecful, follow the hub's MOTD, and remember that operators can kick, ban and set modes on rooms they own. + +''' + + TOPIC_FIRST_RUN = '''>First Time Information Hi there. This first run message will only appear once. It contains a few pointers on getting started with Nomad Network, and getting the most out of the program. @@ -337,9 +865,35 @@ You're currently located in the guide section of the program. I'm sorry I had to To get the most out of Nomad Network, you will need a terminal that supports UTF-8 and at least 256 colors, ideally true-color. If your terminal supports true-color, you can go to the `![ Config ]`! menu item, launch the editor and change the configuration. -It is recommended to use a terminal size of at least 122x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped. +It is recommended to use a terminal size of at least 135x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped. -If you don't already have a Nerd Font installed (see https://www.nerdfonts.com/), I also highly recommend to do so, since it will greatly expand the amount of glyphs, icons and graphics that Nomad Network can use. Once you have your terminal set up with a Nerd Font, go to the `![ Config ]`! menu item and enable Nerd Fonts in the configuration instead of normal unicode glyphs. +Nerd Fonts and true-color are enabled by default. To get the full visual experience you should have a Nerd Font installed in your terminal. You can verify this by visiting the `*Display Test`* topic in this guide and checking the `!Nerd Font Rendering Test`! section. If those glyphs render correctly, you are good to go. + +If they do not render, install a Nerd Font and configure your terminal to use it. A few common approaches: + +>>> + - `!Download from nerdfonts.com`! + Visit https://www.nerdfonts.com/font-downloads, pick a font you like + (Jet Brains Mono, Hack and Meslo are popular choices), unzip it + into `*~/.local/share/fonts`* (Linux) or install via Font Book (macOS), + then refresh the font cache with `*fc-cache -fv`* on Linux. + + - `!Package manager`! + Many distros ship Nerd Font packages. On Arch: `*pacman -S ttf-nerd-fonts-symbols`* + or one of the per-family packages. On Debian/Ubuntu look for `*fonts-firacode`* + plus the symbols-only Nerd Font package, or install manually from the + nerdfonts.com release. + + - `!Homebrew (macOS)`! + `*brew install --cask font-fira-code-nerd-font`* (or any other family). + + - `!Configure your terminal`! + After installing, set your terminal emulator's font to the Nerd Font + variant (it usually has "Nerd Font" in the name). Restart NomadNet + so the new font are picked up. +< + +If for some reason you cannot or do not want to use a Nerd Font, you can disable Nerd Font glyphs in the `![ Config ]`! menu and NomadNet will fall back to plain unicode symbols. Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default one that Reticulum auto-generates on your local ethernet/WiFi network, or something much more complex. This short guide won't go into any details on building networks, but you will find other entries in the guide that deal with network setup and configuration. @@ -357,7 +911,15 @@ Now go out there and explore. This is still early days. See what you can find an TOPIC_CONFIG = '''>Configuration Options -To change the configuration of Nomad Network, you must edit the configuration file. By default, this is located at `!~/.nomadnetwork/config`! on your system. You can open it in any text-editor, and change the options. You can also use the editor built in to this program, under the `![ Config ]`! menu item. The default configuration file contains comments on all the different configuration options present, and explains their possible settings. +To change the configuration of Nomad Network, you must edit the configuration file. If you did not manually specify a config path when you started the program, Nomad Net will look for a configuration in the folllowing directories: + + `!/etc/nomadnetwork`! + `!~/.config/nomadnetwork`! + `!~/.nomadnetwork`! + +If no existing configuration file is found, one will be created at `!~/.nomadnetwork/config`! by default. The default configuration file contains comments on all the different configuration options present, and explains their possible settings. + +You can open the configuration file in any text-editor, and change the options. You can also use the editor built in to this program, under the `![ Config ]`! menu item. If the built-in editor does not gain focus, and your navigation keys are not working, try hitting enter or space, which should focus the editor and let you navigate the text. For reference, all the configuration options are listed and explained here as well. The configuration is divided into different sections, each with their own options. @@ -414,12 +976,24 @@ Selects which interface to use. Currently, only the `!text`! interface is availa Sets the filesystem path to store downloaded files in. < +>>> +`!notify_on_new_message = yes`! +>>>> +Sets whether to output a notification character (bell or flash) to the terminal when a new message is received. +< + >>> `!announce_at_start = yes`! >>>> Determines whether your LXMF address is automatically announced when the program starts. Must be a boolean value. < +>>> +`!announce_interval = 360`! +>>>> +Determines how often, in minutes, your LXMF address is announced on the network. Defaults to 6 hours. +< + >>> `!try_propagation_on_send_fail = yes`! >>>> @@ -444,6 +1018,96 @@ The number of minutes between each automatic sync. The default is equal to 6 hou On low-bandwidth networks, it can be useful to limit the amount of messages downloaded in each sync. The default is 8. Set to 0 to download all available messages every time a sync occurs. < +>>> +`!required_stamp_cost = None`! +>>>> +You can specify a required stamp cost for inbound messages to be accepted. Specifying a stamp cost will require untrusted senders that message you to include a cryptographic stamp in their messages. Performing this operation takes the sender an amount of time proportional to the stamp cost. As a rough estimate, a stamp cost of 8 will take less than a second to compute, and a stamp cost of 20 could take several minutes, even on a fast computer. +< + +>>> +`!accept_invalid_stamps = False`! +>>>> +You can signal stamp requirements to senders, but still accept messages with invalid stamps by setting this option to True. +< + +>>> +`!max_accepted_size = 500`! +>>>> +The maximum accepted unpacked size for messages received directly from other peers, specified in kilobytes. Messages larger than this will be rejected before the transfer begins. +< + +>>> +`!compact_announce_stream = yes`! +>>>> +With this option enabled, Nomad Network will only display one entry in the announce stream per destination. Older announces are culled when a new one arrives. +< + +>>> +`!compose_in_markdown = yes`! +>>>> +Configures whether sent messages are composed in markdown format. +< + +>> RRC Section + +This section hold configuration directives related to the RRC client behaviour. It is delimited by the `![rrc]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!history_per_room_cap = 500`! +>>>> +Maximum number of messages retained per room in the in-memory scrollback buffer, and the number of messages restored from on-disk history at startup. The on-disk log itself is appended to indefinitely; this cap only controls how much backlog is visible. Set to 0 to keep every message in memory. +< + +>>> +`!filter_loaded_history = yes`! +>>>> +Whether to filter notices and system message when initially loading room history. +< + +>>> +`!ephemeral_notices = 10`! +>>>> +If enabled, notices and system messages will disappear from the history after a while. 0 disables, otherwise sets the timeout in minutes. +< + +>>> +`!render_markdown = yes`! +>>>> +Whether or not to render markdown formatting in messages. +< + +>>> +`!render_micron = yes`! +>>>> +Whether or not to render micron formatting in messages. When using micron in messages, use the ¦ character in place of backticks. +< + +>>> +`!nick_colors = yes`! +>>>> +Whether or not to render RRC nicks in distinct colors based on identity hash. +< + +>>> +`!mention_color = None`! +>>>> +Sets a specific color for mentions of your nick, for example FFBB44. +< + +>>> +`!color_mention_timestamps = yes`! +>>>> +Whether or not to color timestamps of messages you were mentioned in. +< + +>>> +`!justify_msgs = yes`! +`!space_msgs = no`! +`!show_gutters = no`! +>>>> +Rendering layout options for messages. +< + >> Text UI Section This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows: @@ -482,6 +1146,18 @@ The `!nerdfont`! set allows using a much wider range of glyphs, icons and graphi Determines whether the program should react to mouse/touch input. Must be a boolean value. < +>>> +`!sanitize_names = yes`! +>>>> +This option allows sanitizing announced names before they are displayed in the user interface. +< + +>>> +`!clipboard_copy = no`! +>>>> +If your terminal supports it, you can enable copy-to-clipboard functionality using OSC52 escape sequences. +< + >>> `!hide_guide = no`! >>>> @@ -506,6 +1182,17 @@ Tells the program what color palette is supported by the terminal. Most terminal `!24bit`! Most new terminals support this full-color mode < +>>> +`!image_loading = auto`! +>>>> +Specifies how, if at all, images on pages should be loaded. Available options: +>>>>> +`!never`! Never load any images +`!manual`! Only load images when manually requseted with Ctrl-L +`!auto`! Load images automatically if link conditions are sufficient +`!always`! Always load images, regardless of link RTT and EDR +< + >>> `!theme = dark`! >>>> @@ -535,9 +1222,9 @@ Determines whether your node is automatically announced on the network when the < >>> -`!announce_interval = 720`! +`!announce_interval = 360`! >>>> -Determines how often, in minutes, your node is announced on the network. Defaults to 12 hours. +Determines how often, in minutes, your node is announced on the network. Defaults to 6 hours. < >>> @@ -546,6 +1233,12 @@ Determines how often, in minutes, your node is announced on the network. Default Determines where the node server will look for hosted pages. Must be a readable filesystem path. < +>>> +`!page_refresh_interval = 0`! +>>>> +Determines the interval in minutes for rescanning the hosted pages path. By default, this option is disabled, and the pages path will only be scanned on startup. +< + >>> `!files_path = ~/.nomadnetwork/storage/files`! >>>> @@ -553,20 +1246,78 @@ Determines where the node server will look for downloadable files. Must be a rea < >>> -`!message_storage_limit = 2`! +`!file_refresh_interval = 0`! >>>> -Configures the maximum amount of storage, in gigabytes, the LXMF Propagation Node will use to store messages. +Determines the interval in minutes for rescanning the hosted files path. By default, this option is disabled, and the files path will only be scanned on startup. < >>> -`!prioritise_destinations = 10bc7624c27032a18639, ba780a6dff4cc1391db8`! +`!disable_propagation = yes`! +>>>> +When Nomad Network is hosting a node, it can also run an LXMF propagation node. If there is already a large amount of propagation nodes on the network, or you simply want to run a pageserving-only node, you can disable running a propagation node. +< + +>>> +`!message_storage_limit = 2000`! +>>>> +Configures the maximum amount of storage, in megabytes, that the LXMF Propagation Node will use to store messages. +< + +>>> +`!max_transfer_size = 256`! +>>>> +The maximum accepted transfer size per incoming propagation transfer, in kilobytes. This also sets the upper limit for the size of single messages accepted onto this propagation node. If a node wants to propagate a larger number of messages to this node, than what can fit within this limit, it will prioritise sending the smallest, newest messages first, and try with any remaining messages at a later point. +< + +>>> +`!prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf`! >>>> Configures the LXMF Propagation Node to prioritise storing messages for certain destinations. If the message store reaches the specified limit, LXMF will prioritise keeping messages for destinations specified with this option. This setting is optional, and generally you do not need to use it. < +>>> +`!max_peers = 25`! +>>>> +Configures the maximum number of other nodes the LXMF Propagation Node will automatically peer with. The default is 50, but can be lowered or increased according to available resources. +< + +>>> +`!static_peers = e17f833c4ddf8890dd3a79a6fea8161d, 5a2d0029b6e5ec87020abaea0d746da4`! +>>>> +Configures the LXMF Propagation Node to always maintain propagation node peering with the specified list of destination hashes. +< + +>> Printing Section + +This section holds configuration directives related to printing. It is delimited by the `![printing]`! header in the configuration file. Available directives, along with example values, are as follows: + +>>> +`!print_messages = no`! +>>>> +Determines whether messages should be printed upon arrival. Must be a boolean value, and is turned off by default. +< + +>>> +`!message_template = ~/.nomadnetwork/print_template_msg.txt`! +>>>> +Determines where the template for printed messages is found. Must be a filesystem path. If you set this path to a non-existing file, an example will be generated in the specified location. +< + +>>> +`!print_from = 76fe5751a56067d1e84eef3e88eab85b, trusted`! +>>>> +Determines from which destinations messages are printed. Can be a list of destinations hashes, the keyword "trusted", or "everywhere". +< + +>>> +`!print_command = lp -d PRINTER_NAME -o cpi=16 -o lpi=8`! +>>>> +Specifies the command that Nomad Network uses to print the message. Defaults to "lp". The above example works well for small thermal-roll printers. +< + >Ignoring Destinations -If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 20-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. +If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 32-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. ''' TOPIC_NETWORKS = '''>Network Configuration @@ -591,20 +1342,13 @@ For future reference, you can download the Reticulum Manual in PDF format here: It might be nice to keep that handy when you are not connected to the Internet, as it is full of information and examples that are also very relevant to Nomad Network. ->The Unsigned.io Testnet +>The Distributed Backbone -If you have Internet access, and just want to get started experimenting, you are welcome to join the Unsigned.io RNS Testnet. The testnet is just that, an informal network for testing and experimenting. It will be up most of the time, and anyone can join, but it also means that there's no guarantees for service availability. +If you have Internet access, and just want to get started experimenting, you can connected to the distributed global Reticulum backbone. For more information about this, see the Reticulum Manual, or sites such as https://directory.rns.recips or https://rmap.world. -The Testnet also runs the latest version of Reticulum, often even a short while before it is publicly released, which means strange behaviour might occur. If none of that scares you, add the following interface to your Reticulum configuration file to join: - ->> -[[RNS Testnet Frankfurt]] - type = TCPClientInterface - interface_enabled = yes - outgoing = True - target_host = frankfurt.rns.unsigned.io - target_port = 4965 +If you connect to the public backbone, 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: +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. You can save the currently connected node by pressing `!Ctrl+S`!. ''' TOPIC_DISPLAYTEST = '''>Markup & Color Display Test @@ -641,7 +1385,33 @@ The following line should contain a grayscale gradient bar: Unicode Glyphs : \u2713 \u2715 \u26a0 \u24c3 \u2193 -Nerd Font Glyphs : \uf484 \uf9c4 \uf719 \uf502 \uf415 \uf023 \uf06e +Nerd Font Glyphs : \uf484 \U000f04c5 \U000f0219 \U000f0002 \uf415 \uf023 \uf06e + + +>>Nerd Font Rendering Test + +`cSince Nerd Font glyphs and true-color are enabled by default, the rows below should render as crisp icons rather than empty squares, question marks, or fallback ASCII. If any glyph appears blank or boxed, your terminal is not using a Nerd Font, or the font is missing the required glyph range. +`` + +>>> +Common UI icons : \uf484 \uf415 \uf023 \uf06e \uf055 \uf056 \uf059 +Status / state : \U000f04c5 \U000f0219 \U000f0002 \U000f1397 \U000f12fc \U000f0156 +Network / radio : \U000f05a9 \U000f05aa \U000f05ab \U000f05ac \uf1eb \uf012 +People / messaging: \uf0c0 \uf007 \uf2bd \uf0e0 \uf27a \uf086 +Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0 +< + +`cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions. +`` + +>>Image Rendering Test + +`(The RNS logo`w=n`a=c`:/media/demo.webp) + +If you see the RNS logo above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, 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. + ''' @@ -700,13 +1470,31 @@ Nomad Network supports a simple and functional markup language called `*micron`* With micron you can easily create structured documents and pages with formatting, colors, glyphs and icons, ideal for display in terminals. +>Table of Contents + + `F79d`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f + `F79d`_`[Micron Tags`#micron-tags]`_`f + `F79d`_`[High Level Stuff`#high-level-stuff]`_`f + `F79d`_`[Collapsible Sections`#collapsible-sections]`_`f + `F79d`_`[Colors`#colors]`_`f + `F79d`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f + `F79d`_`[Links`#links]`_`f + `F79d`_`[Anchors`#anchors]`_`f + `F79d`_`[Tables`#tables]`_`f + `F79d`_`[Images`#images]`_`f + `F79d`_`[Fields & Requests`#fields-requests]`_`f + `F79d`_`[Comments`#comments]`_`f + `F79d`_`[Partials`#partials]`_`f + `F79d`_`[Literals`#literals]`_`f + `F79d`_`[Closing Remarks`#closing-remarks]`_`f + >>Recommendations and Requirements While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal `*must`* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better. Formatting such as `_underline`_, `!bold`! or `*italics`* will be displayed if your terminal supports it. -If you are having trouble getting micron output to display correctly, try using `*gnome-terminal`*, which should work with all formatting options out of the box. Most other terminals will work fine as well, but you might have to change some settings to get certain formatting to display correctly. +If you are having trouble getting micron output to display correctly, try using `*gnome-terminal`* or `*alacritty`*, which should work with all formatting options out of the box. Most other terminals will work fine as well, but you might have to change some settings to get certain formatting to display correctly. >>>Encoding @@ -767,7 +1555,7 @@ If no heading text is defined, the section will appear as a sub-section without Tags are used to format text with micron. Some tags can appear anywhere in text, and some must appear at the beginning of a line. If you need to write text that contains a sequence that would be interpreted as a tag, you can escape it with the character \\. -In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\`\\` tag anywhere in the markup, which will immediately remove any formatting previously specified. +In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\`\\` tag anywhere in the markup, which will immediately remove any formatting previously specified. >>Alignment @@ -856,6 +1644,32 @@ A sub sub section. We could continue, but you get the point. Wait! It's worth noting that we can also create sections without headings. They look like this. `` +>Collapsible Sections + +Any section heading can be made collapsible, turning it into an accordion the reader can fold away and reveal on demand. Prefix a heading with \\`+ to make it foldable and open by default, or with \\`- to make it foldable but start collapsed: + +`Faaa +`= +`+>Open by default +This text can be folded away by selecting the heading. + +`->Hidden until expanded +This starts collapsed, and appears when the reader expands it. +`= +`` + +A collapsible heading folds everything beneath it, up to the next heading of the same or a higher level, exactly like a normal section. It can therefore contain anything: text, tables, input fields, sub-sections, and even live partials. + +To fold or unfold a section, move onto its heading and press Enter or Space, or click it with the mouse. Open sections are marked with ▾ and collapsed ones with ▸. + +You can choose your own indicators for a page by placing a #!fold directive near the top, giving the open glyph followed by the collapsed glyph: + +`Faaa +`= +#!fold [-] [+] +`= +`` + >Colors @@ -877,6 +1691,9 @@ You can use `B5d5`F222 color `f`B333 `Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft` `` +>Page Foreground and Background Colors + +To specify a background color for the entire page, place the `!#!bg=X`! header on one of the first lines of your page, where `!X`! is the color you want to use, for example `!444`!. If you're also using the cache control header, the background specifier must come `*after`* the cache control header. Likewise, you can specify the default text color by using the `!#!fg=X`! header. >Links @@ -886,11 +1703,11 @@ Here's a few examples: `Faaa `= -Here is a link without any label: `[1385edace36466a6b3dd:/page/index.mu] +Here is a link without any label: `[72914442a3689add83a09a767963f57c:/page/index.mu] -This is a `[labeled link`1385edace36466a6b3dd:/page/index.mu] to the same page, but it's hard to see if you don't know it +This is a `[labeled link`72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it -Here is `F00a`_`[a more visible link`1385edace36466a6b3dd:/page/index.mu]`_`f +Here is `F79d`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f `= `` @@ -898,16 +1715,265 @@ The above markup produces the following output: `Faaa`B333 -Here is a link without any label: `[1385edace36466a6b3dd:/page/index.mu] +Here is a link without any label: `[72914442a3689add83a09a767963f57c:/page/index.mu] -This is a `[labeled link`1385edace36466a6b3dd:/page/index.mu] to the same page, but it's hard to see if you don't know it +This is a `[labeled link`72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it -Here is `F00f`_`[a more visible link`1385edace36466a6b3dd:/page/index.mu]`_`f +Here is `F79d`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f `` When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL. +>Anchors + +Anchors let you create jump points within a single page similar to anchors in HTML. You declare a position in the page with a name, then link to it from anywhere on the same page. + +>>Auto-anchors from headers + +Every section heading you write also becomes an anchor automatically. The anchor name is the heading text after `*slugifying`*: lowercased, with any run of non-alphanumeric characters replaced by a single hyphen, and leading or trailing hyphens stripped. So `*>Hello World`* becomes the anchor `*hello-world`*, and `*>Introduction & Setup`* becomes `*introduction-setup`*. + +>>Explicit anchors + +If you want an anchor, that isn't tied to a heading, place one anywhere in your text with the \\`: tag, followed by a name. Names may contain the characters `*A-Z`*, `*a-z`*, `*0-9`*, `*_`* and `*-`*, and end at any other character (a space, a newline, or punctuation). + +The anchor itself takes up no space and does not render. Is's just a position marker. An explicit anchor declared on an otherwise empty line binds to that line's position. + +`Faaa +`= +`:install-notes +Some installation notes for the user. + +You can also drop one mid-line. Example: see `:tip-3 ⚠ tip 3 below for caveats. +`= +`` + +>>Linking to an anchor + +Reuse the standard link syntax, with a `*#`*-prefixed URL: + +`Faaa +`= +`[Jump to Install Notes`#install-notes] +`= +`` + +When the user activates the link the browser scrolls the current page to the anchor's row. + +>>Jumping to the next section + +If the URL is just `*#`* with no name after it, the link jumps to the next \\`> header that appears after the link's own position in the document. This is convenient for "Continue ↓" buttons after a long paragraph, without having to name every section: + +`Faaa +`= +`[Continue`#] +`= +`` + +>>Anchors in external links + +If you want to link to an anchor on another page, you can include it as a request variable: + +`Faaa +`= +`[Conclusion`a8d24177d946de4f1f0a0fe1af9a1338:/page/document.mu`anchor=conclusion] +`= +`` + +>>Notes on namespaces and collisions + +Auto-anchors from headings and explicit \\`: anchors share a single namespace per page. If an explicit anchor collides with a heading slug, the first one declared is where it will jump to. + +>Tables + +You can include rendered tables by enclosing them in \\`t tags. Optionally, you can also specify alignment and max rendering width by adding these properties to the opening \\`t tag, like \\`tc30. Here's an example: + +`Faaa +`= +`t +| Name | Price | Qty | +| ---- | :---: | --: | +| `F3a3Apple`f | Free | `!5`! | +| Orange | Ask, nicely | 3 | +`t +`= +`` + +The above markup produces the following table: + +`t +| Name | Price | Qty | +| ---- | :---: | --: | +| `F3a3Apple`f | Free | `!5`! | +| Orange | Ask, nicely | 3 | +`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 + +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. + +>>Request Links + +Links can contain request variables and a list of fields to submit to the node-side application. You can include all fields on the page, only specific ones, and any number of request variables. To simply submit all fields on a page to a specified node-side page, create a link like this: + +`Faaa +`= +`[Submit Fields`:/page/fields.mu`*] +`= +`` + +Note the `!*`! following the extra `!\\``! at the end of the path. This `!*`! denotes `*all fields`*. You can also specify a list of fields to include: + +`Faaa +`= +`[Submit Fields`:/page/fields.mu`username|auth_token] +`= +`` + +If you want to include pre-set variables, you can do it like this: + +`Faaa +`= +`[Query the System`:/page/fields.mu`username|auth_token|action=view|amount=64] +`= +`` + +>> Fields + +Here's an example of creating a field. We'll create a field named `!user_input`! and fill it with the text `!Pre-defined data`!. Note that we are using background color tags to make the field more visible to the user: + +`Faaa +`= +A simple input field: `B444``b +`= +`` + +You must always set a field `*name`*, but you can of course omit the pre-defined value of the field: + +`Faaa +`= +An empty input field: `B444``b +`= +`` + +You can set the size of the field like this: + +`Faaa +`= +A sized input field: `B444`<16|with_size`>`b +`= +`` + +You can also reserve more than one row for a field by appending `!xROWS`! to the width, which is useful for longer, multi-line input. This creates a field that is 40 columns wide and 5 rows tall: + +`Faaa +`= +A multi-row input field: `B444`<40x5|notes`>`b +`= +`` + +It is possible to mask fields, for example for use with passwords and similar: + +`Faaa +`= +A masked input field: `B444``b +`= +`` + +And you can of course control all parameters at the same time: + +`Faaa +`= +Full control: `B444``b +`= +`` + +Collecting the above markup produces the following output: + +`Faaa`B333 + +A simple input field: `B444``B333 + +An empty input field: `B444``B333 + +A sized input field: `B444`<16|with_size`>`B333 + +A masked input field: `B444``B333 + +A multi-row input field: `B444`<40x5|notes`>`B333 + +Full control: `B444``B333 +`b + +>>> Checkboxes + +In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options. + +`Faaa +`= +``b Label Text` +`= +When the checkbox is checked, it's field will be set to the provided value. If there are multiple checkboxes that share the same field name, the checked values will be concatenated when they are sent to the node by a comma. +`` + +`B444``b Sign me up` + +You can also pre-check both checkboxes and radio groups by appending a |* after the field value. + +`B444``b Pre-checked checkbox` + +>>> Radio groups + +Radio groups are another input that lets the user chose from a set of options. Unlike checkboxes, radio buttons with the same field name are mutually exclusive. + +Example: + +`= +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue +`= + +will render: + +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue + +In this example, when the data is submitted, `B444` field_color`b will be set to whichever value from the list was selected. + +`` + >Comments You can insert comments that will not be displayed in the output by starting a line with the # character. @@ -931,6 +1997,36 @@ This line will `` +>Partials + +You can include partials in pages, which will load asynchronously once the page itself has loaded. + +`Faaa +`= +`{f64a846313b874ee4a357040807f8c77:/page/partial_1.mu} +`= +`` + +It's also possible to set an auto-refresh interval for partials. Omit or set to 0 to disable. The following partial will update every 10 seconds. + +`Faaa +`= +`{f64a846313b874ee4a357040807f8c77:/page/refreshing_partial.mu`10} +`= +`` + +You can include field values and variables in partial updates, and by setting the `!pid`! variable, you can create links that update one or more specific partials. + +`Faaa +`= +Name: `B444``b + +`F38a`[Say hello`p:32]`f + +`{f64a846313b874e84a357039807f8c77:/page/hello_partial.mu`0`pid=32|user_name} +`= +`` + >Literals To display literal content, for example source-code, or blocks of text that should not be interpreted by micron, you can use literal blocks, specified by the \\`= tag. Below is the source code of this entire document, presented as a literal block. @@ -940,16 +2036,19 @@ To display literal content, for example source-code, or blocks of text that shou `= ''' TOPIC_MARKUP += TOPIC_MARKUP.replace("`=", "\\`=") + "[ micron source for document goes here, we don't want infinite recursion now, do we? ]\n\\`=" -TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, you should be well equipped to write documents and pages using micron. Thank you for staying with me.\n\n`c\U0001F332\n" +TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, you should be well equipped to write documents, pages and applications using micron and Nomad Network. Thank you for staying with me.\n" TOPICS = { "Introduction": TOPIC_INTRODUCTION, "Concepts & Terminology": TOPIC_CONCEPTS, + "Channels & RRC": TOPIC_CHANNELS, "Conversations": TOPIC_CONVERSATIONS, + "Interfaces": TOPIC_INTERFACES, "Hosting a Node": TOPIC_HOSTING, "Configuration Options": TOPIC_CONFIG, - "Markup": TOPIC_MARKUP, + "Keyboard Shortcuts": TOPIC_SHORTCUTS, + "Micron Markup": TOPIC_MARKUP, "Display Test": TOPIC_DISPLAYTEST, "Network Configuration": TOPIC_NETWORKS, "Credits & Licenses": TOPIC_LICENSES, diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py new file mode 100644 index 0000000..ea97bee --- /dev/null +++ b/nomadnet/ui/textui/Helpers.py @@ -0,0 +1,46 @@ +import sys +import base64 +import urwid +import RNS + + +def osc52_copy(text): + if not text: + return False + try: + encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") + sys.stdout.write("\x1b]52;c;" + encoded + "\x07") + sys.stdout.flush() + return True + except Exception as e: + RNS.log("Could not emit clipboard escape sequence: "+str(e), RNS.LOG_ERROR) + return False + + +def qr_ascii(data): + try: + import qrcode + import io + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) + qr.add_data(data) + qr.make() + buf = io.StringIO() + qr.print_ascii(out=buf, invert=False) + return buf.getvalue().rstrip("\n") + except Exception as e: + RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) + return None + + +class ClickableIcon(urwid.Text): + _selectable = False + + def __init__(self, text, on_click=None): + super().__init__(text) + self._on_click = on_click + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and "press" in event and self._on_click is not None: + self._on_click() + return True + return False diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py new file mode 100644 index 0000000..b3914ef --- /dev/null +++ b/nomadnet/ui/textui/Interfaces.py @@ -0,0 +1,4177 @@ +import RNS +import time +import nomadnet +import threading +from math import log10, pow + +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * +from nomadnet.vendor.AsciiChart import AsciiChart +from .ReadlineEdit import ReadlineEdit +from .Helpers import ClickableIcon +from nomadnet.util import write_config_atomic + +### GYLPHS ### +INTERFACE_GLYPHS = { + # Glyph name # Plain # Unicode # Nerd Font + ("NetworkInterfaceType", "(IP)", "\U0001f5a7", "\U000f0200"), + ("SerialInterfaceType", "(<->)", "\u2194", "\U000f065c"), + ("RNodeInterfaceType", "(R)" , "\u16b1", "\U000f043a"), + ("OtherInterfaceType", "(#)" , "\U0001f67e", "\ued95"), +} + +### HELPER ### +PLATFORM_IS_LINUX = False +try: + PLATFORM_IS_LINUX = (RNS.vendor.platformutils.is_android() or + RNS.vendor.platformutils.is_linux()) +except Exception: + pass + +def _get_interface_icon(glyphset, iface_type): + glyphset_index = 1 # Default to unicode + if glyphset == "plain": + glyphset_index = 0 # plain + elif glyphset == "nerdfont": + glyphset_index = 2 # nerdfont + + type_to_glyph_tuple = { + "BackboneInterface": "NetworkInterfaceType", + "AutoInterface": "NetworkInterfaceType", + "TCPClientInterface": "NetworkInterfaceType", + "TCPServerInterface": "NetworkInterfaceType", + "UDPInterface": "NetworkInterfaceType", + "I2PInterface": "NetworkInterfaceType", + + "RNodeInterface": "RNodeInterfaceType", + "RNodeMultiInterface": "RNodeInterfaceType", + + "SerialInterface": "SerialInterfaceType", + "KISSInterface": "SerialInterfaceType", + "AX25KISSInterface": "SerialInterfaceType", + + "PipeInterface": "OtherInterfaceType" + } + + glyph_tuple_name = type_to_glyph_tuple.get(iface_type, "OtherInterfaceType") + + for glyph_tuple in INTERFACE_GLYPHS: + if glyph_tuple[0] == glyph_tuple_name: + return glyph_tuple[glyphset_index + 1] + + # Fallback + return "(#)" if glyphset == "plain" else "\U0001f67e" if glyphset == "unicode" else "\ued95" + +def format_bytes(bytes_value): + units = ['bytes', 'KB', 'MB', 'GB', 'TB'] + size = float(bytes_value) + unit_index = 0 + + while size >= 1024.0 and unit_index < len(units) - 1: + size /= 1024.0 + unit_index += 1 + + if unit_index == 0: + return f"{int(size)} {units[unit_index]}" + else: + return f"{size:.1f} {units[unit_index]}" + + +def format_announce_rate(hz): + try: + hz = float(hz or 0) + except (TypeError, ValueError): + return "-" + if hz <= 0: + return "-" + per_hour = hz * 3600 + if per_hour < 100: + return "<1/hr" if per_hour < 1 else "%d/hr" % round(per_hour) + return "%d/min" % round(hz * 60) + +def format_speed(bps): + try: + bps = float(bps or 0) + except (TypeError, ValueError): + return "-" + try: + return RNS.prettyspeed(bps) + except Exception: + return "%d bps" % int(bps) + +def interface_mode_str(mode): + modes = { + 1: "Full", # MODE_FULL + 2: "Point-to-Point", # MODE_POINT_TO_POINT + 3: "Access Point", # MODE_ACCESS_POINT + 4: "Roaming", # MODE_ROAMING + 5: "Boundary", # MODE_BOUNDARY + 6: "Gateway", # MODE_GATEWAY + } + return modes.get(mode, "Full") + +def connection_markup(glyphs, is_connected, label=None): + icon = glyphs.get("connected" if is_connected else "disconnected", "") + if label is None: + label = "Connected" if is_connected else "Disconnected" + attr = "connected_status" if is_connected else "disconnected_status" + sep = " " if icon else "" + return (attr, "%s%s%s" % (icon, sep, label)) + +def _get_cols_rows(): + return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() + + +### PORT FUNCTIONS ### +PYSERIAL_AVAILABLE = False # If NomadNet is installed on environments with rnspure instead of rns, pyserial won't be available +try: + import serial.tools.list_ports + PYSERIAL_AVAILABLE = True +except ImportError: + class DummyPort: + def __init__(self, device, description=None, manufacturer=None, hwid=None): + self.device = device + self.description = description or device + self.manufacturer = manufacturer + self.hwid = hwid + self.vid = None + self.pid = None + +def get_port_info(): + if not PYSERIAL_AVAILABLE: + return [] + + try: + ports = serial.tools.list_ports.comports() + port_info = [] + + # Ports are sorted into categories for dropdown, priority ports appear first + priority_ports = [] # USB, ACM, bluetooth, etc + standard_ports = [] # COM, tty/s ports + + for port in ports: + desc = f"{port.device}" + if port.description and port.description != port.device: + desc += f" ({port.description})" + if port.manufacturer: + desc += f" - {port.manufacturer}" + + is_standard = ( + port.device.startswith("COM") or # windows + "/dev/ttyS" in port.device or # Linux + "Serial" in port.description + ) + + port_data = { + 'device': port.device, + 'description': desc, + 'hwid': port.hwid, + 'vid': port.vid, + 'pid': port.pid, + 'is_standard': is_standard + } + + if is_standard: + standard_ports.append(port_data) + else: + priority_ports.append(port_data) + + priority_ports.sort(key=lambda x: x['device']) + standard_ports.sort(key=lambda x: x['device']) + + return priority_ports + standard_ports + except Exception as e: + RNS.log(f"error accessing serial ports: {str(e)}", RNS.LOG_ERROR) + return [] + +def get_port_field(): + if not PYSERIAL_AVAILABLE: + return { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "/dev/ttyUSB0 or COM port (pyserial not installed)", + "validation": ["required"], + "transform": lambda x: x.strip() + } + + port_info = get_port_info() + + if len(port_info) > 1: + options = [p['description'] for p in port_info] + device_map = {p['description']: p['device'] for p in port_info} + + return { + "config_key": "port", + "type": "dropdown", + "label": "Port: ", + "options": options, + "default": options[0] if options else "", + "validation": ["required"], + "transform": lambda x: device_map[x] + } + else: + # single or no ports - use text field + default = port_info[0]['device'] if port_info else "" + placeholder = "/dev/ttyXXX (or COM port on Windows)" + + return { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": default, + "placeholder": placeholder, + "validation": ["required"], + "transform": lambda x: x.strip() + } + +### RNODE #### +def calculate_rnode_parameters(bandwidth, spreading_factor, coding_rate, noise_floor=6, antenna_gain=0, + transmit_power=17): + crn = { + 5: 1, + 6: 2, + 7: 3, + 8: 4, + } + coding_rate_n = crn.get(coding_rate, 1) + + sfn = { + 5: -2.5, + 6: -5, + 7: -7.5, + 8: -10, + 9: -12.5, + 10: -15, + 11: -17.5, + 12: -20 + } + + data_rate = spreading_factor * ( + (4 / (4 + coding_rate_n)) / (pow(2, spreading_factor) / (bandwidth / 1000))) * 1000 + + sensitivity = -174 + 10 * log10(bandwidth) + noise_floor + (sfn.get(spreading_factor, 0)) + + if bandwidth == 203125 or bandwidth == 406250 or bandwidth > 500000: + sensitivity = -165.6 + 10 * log10(bandwidth) + noise_floor + (sfn.get(spreading_factor, 0)) + + link_budget = (transmit_power - sensitivity) + antenna_gain + + if data_rate < 1000: + data_rate_str = f"{data_rate:.0f} bps" + else: + data_rate_str = f"{(data_rate / 1000):.2f} kbps" + + return { + "data_rate": data_rate_str, + "link_budget": f"{link_budget:.1f} dB", + "sensitivity": f"{sensitivity:.1f} dBm", + "raw_data_rate": data_rate, + "raw_link_budget": link_budget, + "raw_sensitivity": sensitivity + } + +class RNodeCalculator(urwid.WidgetWrap): + def __init__(self, parent_view): + self.parent_view = parent_view + self.update_alarm = None + + self.data_rate_widget = urwid.Text("Data Rate: Calculating...") + self.link_budget_widget = urwid.Text("Link Budget: Calculating...") + self.sensitivity_widget = urwid.Text("Sensitivity: Calculating...") + + self.noise_floor_edit = ReadlineEdit("", "0") + self.antenna_gain_edit = ReadlineEdit("", "0") + + layout = urwid.Pile([ + urwid.Divider("-"), + + urwid.Columns([ + (28, urwid.Text(("key", "Enter Noise Floor (dB): "), align="right")), + self.noise_floor_edit + ]), + + urwid.Columns([ + (28, urwid.Text(("key", "Enter Antenna Gain (dBi): "), align="right")), + self.antenna_gain_edit + ]), + + urwid.Divider(), + + urwid.Text(("connected_status", "On-Air Calculations:"), align="left"), + self.data_rate_widget, + self.link_budget_widget, + self.sensitivity_widget, + urwid.Divider(), + + urwid.Text([ + "These calculations will update as you change RNode parameters" + ]) + ]) + + super().__init__(layout) + + self.connect_all_field_signals() + + self.update_calculation() + + def connect_all_field_signals(self): + urwid.connect_signal(self.noise_floor_edit, 'change', self._queue_update) + urwid.connect_signal(self.antenna_gain_edit, 'change', self._queue_update) + rnode_fields = ['bandwidth', 'spreadingfactor', 'codingrate', 'txpower'] + + for field_name in rnode_fields: + if field_name in self.parent_view.fields: + + field_widget = self.parent_view.fields[field_name]['widget'] + + if hasattr(field_widget, 'edit_text'): + urwid.connect_signal(field_widget, 'change', self._queue_update) + elif hasattr(field_widget, '_emit') and 'change' in getattr(field_widget, 'signals', []): + urwid.connect_signal(field_widget, 'change', self._queue_update) + + def _queue_update(self, widget, new_text): + if self.update_alarm is not None: + try: + self.parent_view.parent.app.ui.loop.remove_alarm(self.update_alarm) + except: + pass + + self.update_alarm = self.parent_view.parent.app.ui.loop.set_alarm_in( + 0.3, self._delayed_update) + + def _delayed_update(self, loop, user_data): + self.update_alarm = None + self.update_calculation() + + def update_calculation(self): + try: + + try: + bandwidth_widget = self.parent_view.fields.get('bandwidth', {}).get('widget') + bandwidth = int(bandwidth_widget.get_value()) if bandwidth_widget else 125000 + except (ValueError, AttributeError): + bandwidth = 125000 + + try: + sf_widget = self.parent_view.fields.get('spreadingfactor', {}).get('widget') + spreading_factor = int(sf_widget.get_value()) if sf_widget else 7 + except (ValueError, AttributeError): + spreading_factor = 7 + + try: + cr_widget = self.parent_view.fields.get('codingrate', {}).get('widget') + coding_rate = int(cr_widget.get_value()) if cr_widget else 5 + if isinstance(coding_rate, str) and ":" in coding_rate: + coding_rate = int(coding_rate.split(":")[1]) + except (ValueError, AttributeError): + coding_rate = 5 + + try: + txpower_widget = self.parent_view.fields.get('txpower', {}).get('widget') + if hasattr(txpower_widget, 'edit_text'): + txpower_text = txpower_widget.edit_text.strip() + txpower = int(txpower_text) if txpower_text else 17 + else: + txpower = int(txpower_widget.get_value()) if txpower_widget else 17 + except (ValueError, AttributeError): + txpower = 17 + + try: + noise_floor_text = self.noise_floor_edit.edit_text.strip() + noise_floor = int(noise_floor_text) if noise_floor_text else 0 + except (ValueError, AttributeError): + noise_floor = 0 + + try: + antenna_gain_text = self.antenna_gain_edit.edit_text.strip() + antenna_gain = int(antenna_gain_text) if antenna_gain_text else 0 + except (ValueError, AttributeError): + antenna_gain = 0 + + result = calculate_rnode_parameters( + bandwidth=bandwidth, + spreading_factor=spreading_factor, + coding_rate=coding_rate, + noise_floor=noise_floor, + antenna_gain=antenna_gain, + transmit_power=txpower + ) + + self.data_rate_widget.set_text(f"Data Rate: {result['data_rate']}") + self.link_budget_widget.set_text(f"Link Budget: {result['link_budget']}") + self.sensitivity_widget.set_text(f"Sensitivity: {result['sensitivity']}") + + except (ValueError, KeyError, TypeError) as e: + self.data_rate_widget.set_text(f"Data Rate: Waiting for parameters...") + self.link_budget_widget.set_text(f"Link Budget: Waiting for valid parameters...") + self.sensitivity_widget.set_text(f"Sensitivity: Waiting for parameters...") + +### INTERFACE FIELDS ### +COMMON_INTERFACE_OPTIONS = [ + { + "config_key": "network_name", + "type": "edit", + "label": "Virtual Network Name: ", + "placeholder": "Optional virtual network name", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "passphrase", + "type": "edit", + "label": "IFAC Passphrase: ", + "placeholder": "IFAC authentication passphrase", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "ifac_size", + "type": "edit", + "label": "IFAC Size: ", + "placeholder": "8 - 512", + "default": "", + "validation": ['number'], + "transform": lambda x: x.strip() + }, + { + "config_key": "bitrate", + "type": "edit", + "label": "Inferred Bitrate: ", + "placeholder": "Automatically determined", + "default": "", + "validation": ['number'], + "transform": lambda x: x.strip() + }, +] + +INTERFACE_FIELDS = { + "BackboneInterface": [ + { + "config_key": "listen_on", + "type": "edit", + "label": "Listen On: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "default": "", + "placeholder": "e.g., eth0", + "transform": lambda x: x.strip() + }, + { + "config_key": "remote", + "type": "edit", + "label": "Remote: ", + "default": "", + "placeholder": "e.g., a remote TCPServerInterface location", + "transform": lambda x: x.strip() + }, + { + "config_key": "target_host", + "type": "edit", + "label": "Target Host: ", + "default": "", + "placeholder": "e.g., 201:5d78:af73:5caf:a4de:a79f:3278:71e5", + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Target Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "config_key": "prefer_ipv6", + "type": "checkbox", + "label": "", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + ], + "AutoInterface": [ + { + + }, + { + "additional_options": [ + { + "config_key": "devices", + "type": "multilist", + "label": "Devices: ", + "validation": [], + "transform": lambda x: ",".join(x) + }, + { + "config_key": "ignored_devices", + "type": "multilist", + "label": "Ignored Devices: ", + "validation": [], + "transform": lambda x: ",".join(x) + }, + { + "config_key": "group_id", + "type": "edit", + "label": "Group ID: ", + "default": "", + "placeholder": "e.g., my_custom_network", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "discovery_scope", + "type": "dropdown", + "label": "Discovery Scope: ", + "options": ["None", "link", "admin", "site", "organisation", "global"], + "default": "None", + "validation": [], + "transform": lambda x: "" if x == "None" else x.strip() + } + ] + }, + ], + "I2PInterface": [ + { + "config_key": "peers", + "type": "multilist", + "label": "Peers: ", + "placeholder": "", + "validation": ["required"], + "transform": lambda x: ",".join(x) + } + ], + "TCPServerInterface": [ + { + "config_key": "listen_ip", + "type": "edit", + "label": "Listen IP: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "listen_port", + "type": "edit", + "label": "Listen Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "additional_options": [ + { + "config_key": "prefer_ipv6", + "type": "checkbox", + "label": "Prefer IPv6?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "i2p_tunneled", + "type": "checkbox", + "label": "I2P Tunneled?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "placeholder": "A specific network device to listen on - e.g. eth0", + "default": "", + "validation": [], + "transform": lambda x: x.strip() if x.strip() else None + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + ] + } + ], + "TCPClientInterface": [ + { + "config_key": "target_host", + "type": "edit", + "label": "Target Host: ", + "default": "", + "placeholder": "e.g., 127.0.0.1", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "target_port", + "type": "edit", + "label": "Target Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "additional_options": [ + { + "config_key": "i2p_tunneled", + "type": "checkbox", + "label": "I2P Tunneled?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "kiss_framing", + "type": "checkbox", + "label": "KISS Framing?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + } + ] + } + ], + "UDPInterface": [ + { + "config_key": "listen_ip", + "type": "edit", + "label": "Listen IP: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "listen_port", + "type": "edit", + "label": "Listen Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "config_key": "forward_ip", + "type": "edit", + "label": "Forward IP: ", + "default": "", + "placeholder": "e.g., 255.255.255.255", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "forward_port", + "type": "edit", + "label": "Forward Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "additional_options": [ + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "placeholder": "A specific network device to listen on - e.g. eth0", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + ] + } + ], + "RNodeInterface": [ + get_port_field(), + { + "config_key": "frequency", + "type": "edit", + "label": "Frequency (MHz): ", + "default": "", + "placeholder": "868.5", + "validation": ["required", "float"], + "transform": lambda x: int(float(x.strip()) * 1000000) if x.strip() else 868500000 + }, + { + "config_key": "txpower", + "type": "edit", + "label": "Transmit Power (dBm): ", + "default": "", + "placeholder": "17", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else 17 + }, + { + "config_key": "bandwidth", + "type": "dropdown", + "label": "Bandwidth (Hz): ", + "options": ["7800", "10400", "15600", "20800", "31250", "41700", "62500", "125000", "250000", + "500000", "1625000"], + "default": "7800", + "validation": ["required"], + "transform": lambda x: int(x) + }, + { + "config_key": "spreadingfactor", + "type": "dropdown", + "label": "Spreading Factor: ", + "options": ["7", "8", "9", "10", "11", "12"], + "default": "7", + "validation": ["required"], + "transform": lambda x: int(x) + }, + { + "config_key": "codingrate", + "type": "dropdown", + "label": "Coding Rate: ", + "options": ["4:5", "4:6", "4:7", "4:8"], + "default": "4:5", + "validation": ["required"], + "transform": lambda x: int(x.split(":")[1]) + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "airtime_limit_long", + "type": "edit", + "label": "Airtime Limit Long (Seconds): ", + "placeholder": "e.g. 1.5", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "airtime_limit_short", + "type": "edit", + "label": "Airtime Limit Short (Seconds): ", + "placeholder": "e.g. 33", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + ] + } + ], + "RNodeMultiInterface": [ + get_port_field(), + { + "config_key": "subinterfaces", + "type": "multitable", + "fields": { + "frequency": { + "label": "Freq (Hz)", + "type": "edit", + "validation": ["required", "float"], + "transform": lambda x: int(x) if x else None + }, + "bandwidth": { + "label": "BW (Hz)", + "type": "edit", + "options": ["7800", "10400", "15600", "20800", "31250", "41700", "62500", "125000", "250000", "500000", "1625000"], + "transform": lambda x: int(x) if x else None + }, + "txpower": { + "label": "TX (dBm)", + "type": "edit", + "validation": ["required", "number"], + "transform": lambda x: int(x) if x else None + }, + "vport": { + "label": "V.Port", + "type": "edit", + "validation": ["required", "number"], + "transform": lambda x: int(x) if x else None + }, + "spreadingfactor": { + "label": "SF", + "type": "edit", + "transform": lambda x: int(x) if x else None + }, + "codingrate": { + "label": "CR", + "type": "edit", + "transform": lambda x: int(x) if x else None + } + }, + "validation": ["required"], + "transform": lambda x: x + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + } + ] + } + ], + "SerialInterface": [ + get_port_field(), + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + ], + "PipeInterface": [ + { + "config_key": "command", + "type": "edit", + "label": "Command: ", + "default": "", + "placeholder": "e.g. netcat -l 5757", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "respawn_delay", + "type": "edit", + "label": "Respawn Delay (seconds): ", + "default": "", + "placeholder": "e.g. 5", + "validation": ["number"], + "transform": lambda x: x.strip() + }, + ], + "KISSInterface": [ + get_port_field(), + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "preamble", + "type": "edit", + "label": "Preamble (miliseconds): ", + "default": "", + "placeholder": "e.g. 150", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "txtail", + "type": "edit", + "label": "TX Tail (miliseconds): ", + "default": "", + "placeholder": "e.g. 10", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "slottime", + "type": "edit", + "label": "slottime (miliseconds): ", + "default": "", + "placeholder": "e.g. 20", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "persistence", + "type": "edit", + "label": "Persistence (miliseconds): ", + "default": "", + "placeholder": "e.g. 200", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "ID Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "flow_control", + "type": "checkbox", + "label": "Flow Control ", + "validation": [], + "transform": lambda x: "" if x == "" else bool(x) + }, + ] + } + ], + "AX25KISSInterface": [ + get_port_field(), + { + "config_key": "callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. NO1CLL", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "ssid", + "type": "edit", + "label": "SSID: ", + "default": "", + "placeholder": "e.g. 0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "preamble", + "type": "edit", + "label": "Preamble (miliseconds): ", + "default": "", + "placeholder": "e.g. 150", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "txtail", + "type": "edit", + "label": "TX Tail (miliseconds): ", + "default": "", + "placeholder": "e.g. 10", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "slottime", + "type": "edit", + "label": "Slottime (miliseconds): ", + "default": "", + "placeholder": "e.g. 20", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "persistence", + "type": "edit", + "label": "Persistence (miliseconds): ", + "default": "", + "placeholder": "e.g. 200", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "additional_options": [ + { + "config_key": "flow_control", + "type": "checkbox", + "label": "Flow Control ", + "validation": [], + "transform": lambda x: "" if x == "" else bool(x) + }, + ] + } + ], + "CustomInterface": [ + { + "config_key": "type", + "type": "edit", + "label": "Interface Type: ", + "default": "", + "placeholder": "Name of custom interface class", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "custom_parameters", + "type": "keyvaluepairs", + "label": "Parameters: ", + "validation": [], + "transform": lambda x: x + }, + ], + "default": [ + { + + }, + ] +} + +### INTERFACE WIDGETS #### +class SelectableInterfaceItem(urwid.WidgetWrap): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None, profiles_label=None, announce_rate="N/A"): + self.parent = parent + self._selectable = True + self.icon = icon + self.name = name + self.is_connected = is_connected + self.is_enabled = is_enabled + self.iface_options = iface_options + + + if is_enabled: + enabled_txt = ("connected_status", "Enabled") + else: + enabled_txt = ("disabled_status", "Disabled") + + connected_txt = connection_markup(parent.g, is_connected) + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text(("interface_title", f"{icon} {name}")) + + title_content = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + + self.tx_widget = urwid.Text(("value", format_bytes(tx))) + self.rx_widget = urwid.Text(("value", format_bytes(rx))) + self.announce_widget = urwid.Text(("value", announce_rate)) + + self.status_widget = urwid.Text(enabled_txt) + self.connection_widget = urwid.Text(connected_txt) + + if is_enabled: + status_row = urwid.Columns([ + (10, urwid.Text(("key", "Status: "))), + (10, self.status_widget), + (3, urwid.Text(" | ")), + self.connection_widget, + ]) + else: + status_row = urwid.Columns([ + (10, urwid.Text(("key", "Status: "))), + self.status_widget, + ]) + + rows = [ + status_row, + + urwid.Columns([ + (10, urwid.Text(("key", "Type:"))), + urwid.Text(("value", iface_type)), + ]), + ] + + if profiles_label is not None: + rows.append(urwid.Columns([ + (10, urwid.Text(("key", "Profiles:"))), + urwid.Text(("value", profiles_label)), + ])) + + if is_enabled: + rows.append(urwid.Divider("-")) + rows.append(urwid.Columns([ + (5, urwid.Text(("key", "TX:"))), + (14, self.tx_widget), + (5, urwid.Text(("key", "RX:"))), + (14, self.rx_widget), + (12, urwid.Text(("key", "Announces:"))), + self.announce_widget, + ])) + + pile_contents = [title_content] + rows + + pile = urwid.Pile(pile_contents) + + padded_body = urwid.Padding(pile, left=2, right=2) + + box = urwid.LineBox( + padded_body, + title=None, + #todo + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + super().__init__(box) + + def update_status_display(self): + if self.is_enabled: + self.status_widget.set_text(("connected_status", "Enabled")) + else: + self.status_widget.set_text(("disabled_status", "Disabled")) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + + if focus: + self.title_widget.set_text( + ("interface_title_selected", f"{self.icon} {self.name}")) + else: + self.title_widget.set_text(("interface_title", f"{self.icon} {self.name}")) + + return super().render(size, focus=focus) + + def keypress(self, size, key): + if key == "up": + listbox = self.parent.box_adapter._original_widget + walker = listbox.body + + interface_items = [i for i, item in enumerate(walker) + if isinstance(item, SelectableInterfaceItem)] + + if interface_items and walker[listbox.focus_position] is self and \ + listbox.focus_position == interface_items[0]: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": + self.parent.switch_to_show_interface(self.name) + return None + return key + + def update_stats(self, tx, rx, announce_rate=None): + self.tx_widget.set_text(("value", format_bytes(tx))) + self.rx_widget.set_text(("value", format_bytes(rx))) + if announce_rate is not None: + self.announce_widget.set_text(("value", announce_rate)) + + def update_connection(self, is_connected): + if is_connected == self.is_connected: + return + self.is_connected = is_connected + self.connection_widget.set_text(connection_markup(self.parent.g, is_connected)) + +class InterfaceOptionItem(urwid.WidgetWrap): + def __init__(self, parent_display, label, value, on_select=None): + self.parent_display = parent_display + self.label = label + self.value = value + self.on_select = on_select + self._selectable = True + + text_widget = urwid.Text(label, align="left") + super().__init__(urwid.AttrMap(text_widget, "list_normal", focus_map="list_focus")) + + def selectable(self): + return True + + def _activate(self): + if self.on_select is not None: + self.on_select() + else: + self.parent_display.dismiss_dialog() + self.parent_display.switch_to_add_interface(self.value) + + def keypress(self, size, key): + if key == "enter": + self._activate() + return None + return super().keypress(size, key) + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._activate() + return True + return False + +class InterfaceBandwidthChart: + + def __init__(self, history_length=60, glyphset="unicode"): + self.history_length = history_length + self.glyphset = glyphset + self.rx_rates = [0] * history_length + self.tx_rates = [0] * history_length + + self.prev_rx = None + self.prev_tx = None + self.prev_time = None + + self.max_rx_rate = 1 + self.max_tx_rate = 1 + + self.first_update = True + self.initialization_complete = False + self.stabilization_updates = 2 + self.update_count = 0 + + self.peak_rx_for_display = 0 + self.peak_tx_for_display = 0 + + def update(self, rx_bytes, tx_bytes): + current_time = time.time() + + if self.prev_rx is None or self.first_update: + self.prev_rx = rx_bytes + self.prev_tx = tx_bytes + self.prev_time = current_time + self.first_update = False + return + + time_delta = max(0.1, current_time - self.prev_time) + + rx_delta = max(0, rx_bytes - self.prev_rx) / time_delta + tx_delta = max(0, tx_bytes - self.prev_tx) / time_delta + + self.prev_rx = rx_bytes + self.prev_tx = tx_bytes + self.prev_time = current_time + + self.update_count += 1 + + self.rx_rates.pop(0) + self.tx_rates.pop(0) + self.rx_rates.append(rx_delta*8) + self.tx_rates.append(tx_delta*8) + + if self.update_count >= self.stabilization_updates: + self.initialization_complete = True + + self.peak_rx_for_display = max(self.peak_rx_for_display, rx_delta) + self.peak_tx_for_display = max(self.peak_tx_for_display, tx_delta) + + + current_rx_max = max(self.rx_rates) + current_tx_max = max(self.tx_rates) + + self.max_rx_rate = max(1, current_rx_max) + self.max_tx_rate = max(1, current_tx_max) + + def get_charts(self, height=8): + chart = AsciiChart(glyphset=self.glyphset) + + rx_data = self.rx_rates.copy() + tx_data = self.tx_rates.copy() + + peak_rx = self.peak_rx_for_display if self.initialization_complete else 0 + peak_tx = self.peak_tx_for_display if self.initialization_complete else 0 + + peak_rx_str = RNS.prettyspeed(peak_rx*8) + peak_tx_str = RNS.prettyspeed(peak_tx*8) + + rx_chart = chart.plot( + [rx_data], + { + 'height': height, + 'format': RNS.prettyspeed, + 'min': 0, + 'max': self.max_rx_rate * 1.1, + } + ) + + tx_chart = chart.plot( + [tx_data], + { + 'height': height, + 'format': RNS.prettyspeed, + 'min': 0, + 'max': self.max_tx_rate * 1.1, + } + ) + + return rx_chart, tx_chart, peak_rx_str, peak_tx_str + + +class ResponsiveChartContainer(urwid.WidgetWrap): + + def __init__(self, rx_box, tx_box, min_cols_for_horizontal=100): + self.rx_box = rx_box + self.tx_box = tx_box + self.min_cols_for_horizontal = min_cols_for_horizontal + + self.horizontal_layout = urwid.Columns([ + (urwid.WEIGHT, 1, self.rx_box), + (urwid.WEIGHT, 1, self.tx_box) + ]) + + self.vertical_layout = urwid.Pile([ + self.rx_box, + self.tx_box + ]) + + self.layout = urwid.WidgetPlaceholder(self.horizontal_layout) + + super().__init__(self.layout) + + def render(self, size, focus=False): + maxcol = size[0] if len(size) > 0 else 0 + + if maxcol >= self.min_cols_for_horizontal and self.layout.original_widget is not self.horizontal_layout: + self.layout.original_widget = self.horizontal_layout + elif maxcol < self.min_cols_for_horizontal and self.layout.original_widget is not self.vertical_layout: + self.layout.original_widget = self.vertical_layout + + return super().render(size, focus) + +### URWID FILLER ### +class InterfaceFiller(urwid.WidgetWrap): + def __init__(self, widget, app): + self.app = app + self.filler = urwid.Filler(widget, urwid.TOP) + super().__init__(self.filler) + + def keypress(self, size, key): + if key == "tab": + self.app.ui.main_display.sub_displays.interface_display.show_profiles() + return None + if key == "ctrl t": + self.app.ui.main_display.sub_displays.interface_display.toggle_view_mode() + return None + if key == "ctrl s": + self.app.ui.main_display.sub_displays.interface_display.save_current_profile() + return None + if key == "ctrl f": + self.app.ui.main_display.sub_displays.interface_display.cycle_filter() + return None + if key == "ctrl o": + self.app.ui.main_display.sub_displays.interface_display.toggle_focused_interface() + return None + if key == "ctrl b": + self.app.ui.main_display.sub_displays.interface_display.show_bulk_actions() + return None + if key == "ctrl a": + # add interface + self.app.ui.main_display.sub_displays.interface_display.add_interface() + return + elif key == "ctrl x": + # remove Interface + self.app.ui.main_display.sub_displays.interface_display.remove_selected_interface() + return + elif key == "ctrl e": + # edit interface + self.app.ui.main_display.sub_displays.interface_display.edit_selected_interface() + return None + elif key == "ctrl w": + # open config file editor + self.app.ui.main_display.sub_displays.interface_display.open_config_editor() + return None + + return super().keypress(size, key) + +### VIEWS ### +class AddInterfaceView(urwid.WidgetWrap): + def __init__(self, parent, iface_type): + self.parent = parent + self.iface_type = iface_type + self.fields = {} + self.port_pile = None + self.additional_fields = {} + self.additional_pile_contents = [] + self.common_fields = {} + + self.parent.shortcuts_display.set_add_interface_shortcuts() + + name_field = FormEdit( + config_key="name", + placeholder="Enter interface name", + validation_types=["required"] + ) + self.fields['name'] = { + 'label': "Name: ", + 'widget': name_field + } + + config = INTERFACE_FIELDS.get(iface_type, INTERFACE_FIELDS["default"]) + iface_fields = [field for field in config if "config_key" in field] + + for field in iface_fields: + self._initialize_field(field) + + self._initialize_additional_fields(config) + + self._initialize_common_fields() + + pile_items = self._build_form_layout(iface_fields) + + form_pile = urwid.Pile(pile_items) + form_filler = urwid.Filler(form_pile, valign="top") + form_box = urwid.LineBox( + form_filler, + title="Add Interface", + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + background = urwid.SolidFill(" ") + self.overlay = urwid.Overlay( + top_w=form_box, + bottom_w=background, + align='center', + width=('relative', 85), + valign='middle', + height=('relative', 85), + ) + super().__init__(self.overlay) + + def _initialize_field(self, field): + if field["type"] == "dropdown": + widget = FormDropdown( + config_key=field["config_key"], + label=field.get("label", ""), + options=field["options"], + default=field.get("default"), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "checkbox": + widget = FormCheckbox( + config_key=field["config_key"], + label=field.get("label", ""), + state=field.get("default", False), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "multilist": + widget = FormMultiList( + config_key=field["config_key"], + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "multitable": + widget = FormMultiTable( + config_key=field["config_key"], + fields=field.get("fields", {}), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "keyvaluepairs": + widget = FormKeyValuePairs( + config_key=field["config_key"], + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + else: + widget = FormEdit( + config_key=field["config_key"], + caption="", + edit_text=field.get("default", ""), + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + + label = field.get("label", "") + if not label: + label = " ".join(word.capitalize() for word in field["config_key"].split('_')) + ": " + + self.fields[field["config_key"]] = { + 'label': label, + 'widget': widget + } + + def _initialize_additional_fields(self, config): + for field in config: + if isinstance(field, dict) and "additional_options" in field: + for option in field["additional_options"]: + if option["type"] == "checkbox": + widget = FormCheckbox( + config_key=option["config_key"], + label=option.get("label", ""), + state=option.get("default", False), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "dropdown": + widget = FormDropdown( + config_key=option["config_key"], + label=option.get("label", ""), + options=option["options"], + default=option.get("default"), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "multilist": + widget = FormMultiList( + config_key=option["config_key"], + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + else: + widget = FormEdit( + config_key=option["config_key"], + caption="", + edit_text=str(option.get("default", "")), + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + + label = option.get("label", "") + if not label: + label = " ".join(word.capitalize() for word in option["config_key"].split('_')) + ": " + + self.additional_fields[option["config_key"]] = { + 'label': label, + 'widget': widget, + 'type': option["type"] + } + + def _initialize_common_fields(self): + if self.parent.app.rns.transport_enabled(): + # Transport mode options + COMMON_INTERFACE_OPTIONS.extend([ + { + "config_key": "outgoing", + "type": "checkbox", + "label": "Allow outgoing traffic", + "default": True, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "mode", + "type": "dropdown", + "label": "Interface Mode: ", + "options": ["full", "gateway", "access_point", "roaming", "boundary"], + "default": "full", + "validation": [], + "transform": lambda x: x + }, + { + "config_key": "announce_cap", + "type": "edit", + "label": "Announce Cap: ", + "placeholder": "Default: 2.0", + "default": "", + "validation": ["float"], + "transform": lambda x: float(x) if x.strip() else 2.0 + } + ]) + + for option in COMMON_INTERFACE_OPTIONS: + if option["type"] == "checkbox": + widget = FormCheckbox( + config_key=option["config_key"], + label=option["label"], + state=option.get("default", False), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "dropdown": + widget = FormDropdown( + config_key=option["config_key"], + label=option["label"], + options=option["options"], + default=option.get("default"), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + else: + widget = FormEdit( + config_key=option["config_key"], + caption="", + edit_text=str(option.get("default", "")), + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + + self.common_fields[option["config_key"]] = { + 'label': option["label"], + 'widget': widget, + 'type': option["type"] + } + + def _on_rnode_field_change(self, widget, new_value): + if hasattr(self, 'rnode_calculator') and self.calculator_visible: + self.rnode_calculator.update_calculation() + + def _build_form_layout(self, iface_fields): + pile_items = [] + pile_items.append(urwid.Text( + ("form_title", f"Add new {_get_interface_icon(self.parent.glyphset, self.iface_type)} {self.iface_type}"), + align="center")) + pile_items.append(urwid.Divider("─")) + + for key in ["name"] + [f["config_key"] for f in iface_fields]: + field = self.fields[key] + widget = field["widget"] + + # Special case for multitable and keyvaluepairs - they already have their own layout + if isinstance(widget, (FormMultiTable, FormKeyValuePairs)): + pile_items.append(urwid.Text(("key", field["label"]), align="left")) + pile_items.append(widget) + pile_items.append(urwid.Padding(widget.error_widget, left=2)) + continue + + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget, + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + if self.iface_type in ["RNodeInterface", "RNodeMultiInterface", "SerialInterface", "AX25KISSInterface", + "KISSInterface"] and key == "port": + refresh_btn = urwid.Button("Refresh Ports", on_press=self.refresh_ports) + refresh_btn = urwid.AttrMap(refresh_btn, "button_normal", focus_map="button_focus") + refresh_row = urwid.Padding(refresh_btn, left=26, width=20) + field_pile.contents.append((refresh_row, field_pile.options())) + self.port_pile = field_pile + + pile_items.append(field_pile) + + self.more_options_visible = False + self.more_options_button = urwid.Button("Show more options", on_press=self.toggle_more_options) + self.more_options_button = urwid.AttrMap(self.more_options_button, "button_normal", focus_map="button_focus") + self.more_options_widget = urwid.Pile([]) + + self.ifac_options_visible = False + self.ifac_options_button = urwid.Button("Show IFAC options", on_press=self.toggle_ifac_options) + self.ifac_options_button = urwid.AttrMap(self.ifac_options_button, "button_normal", focus_map="button_focus") + self.ifac_options_widget = urwid.Pile([]) + + if self.iface_type in ["RNodeInterface"]: + self.calculator_button = urwid.Button("Show On-Air Calculations", on_press=self.toggle_calculator) + self.calculator_button = urwid.AttrMap(self.calculator_button, "button_normal", focus_map="button_focus") + + save_btn = urwid.Button("Save", on_press=self.on_save) + back_btn = urwid.Button("Cancel", on_press=self.on_back) + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, save_btn), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, back_btn), + ]) + + pile_items.extend([ + urwid.Divider(), + self.more_options_button, + self.more_options_widget, + ]) + if self.iface_type in ["RNodeInterface"]: + self.rnode_calculator = RNodeCalculator(self) + self.calculator_visible = False + self.calculator_widget = urwid.Pile([]) + pile_items.extend([ + self.calculator_button, + self.calculator_widget, + ]) + pile_items.extend(self._build_profiles_section()) + pile_items.extend([ + urwid.Divider("─"), + button_row, + ]) + + return pile_items + + def _build_profiles_section(self): + self.profile_checkboxes = [] + profiles = getattr(self.parent.app, "interface_profiles", None) + if profiles is None or not profiles.profiles: + return [] + iface_name = getattr(self, "iface_name", None) + current = set() + if iface_name: + current = set(p["id"] for p in profiles.profiles_for(iface_name)) + items = [ + urwid.Divider("─"), + urwid.Text(("key", "Profiles"), align="left"), + urwid.Text(("inactive_text", " Assign this interface to one or more profiles."), align="left"), + ] + for p in profiles.profiles: + cb = urwid.CheckBox(p["name"] or "(unnamed)", state=(p["id"] in current)) + cb.profile_id = p["id"] + self.profile_checkboxes.append(cb) + items.append(urwid.Padding(cb, left=2)) + return items + + def _apply_profile_membership(self, iface_name): + profiles = getattr(self.parent.app, "interface_profiles", None) + if profiles is None: + return + checked = [cb.profile_id for cb in getattr(self, "profile_checkboxes", []) if cb.get_state()] + try: + profiles.set_interface_profiles(iface_name, checked) + except Exception: + pass + + def toggle_more_options(self, button): + if self.more_options_visible: + self.more_options_widget.contents = [] + button.base_widget.set_label("Show more options") + self.more_options_visible = False + else: + pile_contents = [] + + if self.additional_fields: + for key, field in self.additional_fields.items(): + widget = field['widget'] + + if field['type'] == "checkbox": + centered_widget = urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', widget), + ('weight', 1, urwid.Text("")) + ]) + field_pile = urwid.Pile([ + centered_widget, + urwid.Padding(widget.error_widget, left=24) + ]) + else: + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + pile_contents.append(field_pile) + + if self.additional_fields and self.common_fields: + pile_contents.append(urwid.Divider("─")) + + if self.common_fields: + for key, field in self.common_fields.items(): + widget = field['widget'] + + if field['type'] == "checkbox": + centered_widget = urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', widget), + ('weight', 1, urwid.Text("")) + ]) + field_pile = urwid.Pile([ + centered_widget, + urwid.Padding(widget.error_widget, left=24) + ]) + else: + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + pile_contents.append(field_pile) + + if pile_contents: + self.more_options_widget.contents = [(w, self.more_options_widget.options()) for w in pile_contents] + else: + self.more_options_widget.contents = [( + urwid.Text("No additional options available", align="center"), + self.more_options_widget.options() + )] + + button.base_widget.set_label("Hide more options") + self.more_options_visible = True + + def toggle_ifac_options(self, button): + if self.ifac_options_visible: + self.ifac_options_widget.contents = [] + button.base_widget.set_label("Show IFAC options") + self.ifac_options_visible = False + else: + dummy = urwid.Text("IFAC (Interface Access Codes)", align="left") + self.ifac_options_widget.contents = [(dummy, self.more_options_widget.options())] + button.base_widget.set_label("Hide IFAC options") + self.ifac_options_visible = True + + def toggle_calculator(self, button): + if self.calculator_visible: + self.calculator_widget.contents = [] + button.base_widget.set_label("Show On-Air Calculations") + self.calculator_visible = False + else: + calculator_contents = [self.rnode_calculator] + + self.calculator_widget.contents = [(w, self.calculator_widget.options()) for w in calculator_contents] + + self.rnode_calculator.update_calculation() + + button.base_widget.set_label("Hide On-Air Calculations") + self.calculator_visible = True + + def refresh_ports(self, button): + if self.port_pile is not None: + # Get fresh port config + port_field = get_port_field() + + if port_field["type"] == "dropdown": + widget = FormDropdown( + config_key=port_field["config_key"], + label=port_field["label"], + options=port_field["options"], + default=port_field.get("default"), + validation_types=port_field.get("validation", []), + transform=port_field.get("transform") + ) + else: + widget = FormEdit( + config_key=port_field["config_key"], + caption="", + edit_text=port_field.get("default", ""), + placeholder=port_field.get("placeholder", ""), + validation_types=port_field.get("validation", []), + transform=port_field.get("transform") + ) + + self.fields["port"] = { + 'label': port_field["label"], + 'widget': widget + } + + columns = urwid.Columns([ + (26, urwid.Text(("key", port_field["label"]), align="right")), + widget + ]) + self.port_pile.contents[0] = (columns, self.port_pile.options()) + + self.port_pile.contents[1] = (urwid.Padding(widget.error_widget, left=24), self.port_pile.options()) + + def validate_all(self): + all_valid = True + + # validate main fields + for field in self.fields.values(): + if not field["widget"].validate(): + all_valid = False + + # validate additional iface fields + for field in self.additional_fields.values(): + if not field["widget"].validate(): + all_valid = False + + # validate common fields + for field in self.common_fields.values(): + if not field["widget"].validate(): + all_valid = False + + return all_valid + + def on_save(self, button): + all_valid = self.validate_all() + + name = self.fields['name']["widget"].get_value() or "Untitled interface" + + existing_interfaces = self.parent.app.rns.config['interfaces'] + if name in existing_interfaces: + self.fields['name']["widget"].error = f"Interface name '{name}' already exists" + self.fields['name']["widget"].error_widget.set_text(("error", self.fields['name']["widget"].error)) + all_valid = False + + if not all_valid: + return + + if self.iface_type == "CustomInterface": + custom_type = self.fields.get('type', {}).get('widget').get_value() + interface_config = { + "type": custom_type, + "interface_enabled": True + } + else: + interface_config = { + "type": self.iface_type, + "interface_enabled": True + } + + for field_key, field in self.fields.items(): + if field_key not in ["name", "custom_parameters", "type"]: + widget = field["widget"] + value = widget.get_value() + + if field_key == "subinterfaces" and self.iface_type == "RNodeMultiInterface" and isinstance(value, + dict): + for subname, subconfig in value.items(): + interface_config[f"{subname}"] = subconfig + elif value is not None and value != "": + interface_config[widget.config_key] = value + + if self.iface_type == "CustomInterface" and "custom_parameters" in self.fields: + custom_params = self.fields["custom_parameters"]["widget"].get_value() + if isinstance(custom_params, dict): + for param_key, param_value in custom_params.items(): + interface_config[param_key] = param_value + + for field_key, field in self.additional_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + interface_config[widget.config_key] = value + + for field_key, field in self.common_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + interface_config[widget.config_key] = value + + try: + interfaces = self.parent.app.rns.config['interfaces'] + interfaces[name] = interface_config + write_config_atomic(self.parent.app.rns.config) + self._apply_profile_membership(name) + + display_type = custom_type if self.iface_type == "CustomInterface" else self.iface_type + + new_item = SelectableInterfaceItem( + parent=self.parent, + name=name, + is_connected=False, # will always be false until restart + is_enabled=True, + iface_type=display_type, + tx=0, + rx=0, + icon=_get_interface_icon(self.parent.glyphset, display_type), + iface_options=interface_config + ) + + self.parent.interface_items.append(new_item) + self.parent._rebuild_list() + self.parent.mark_restart_pending() + + self.show_message(f"Interface {name} added. Restart NomadNet to start using this interface") + + except Exception as e: + print(f"Error saving interface: {str(e)}") + self.show_message(f"Error: {str(e)}", title="Error") + + def on_back(self, button): + self.parent.switch_to_list() + + def show_message(self, message, title="Notice"): + def dismiss_dialog(button): + self.parent.switch_to_list() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title=title + ) + + overlay = urwid.Overlay( + dialog, + self.parent.interfaces_display, + align='center', + width=100, + valign='middle', + height=8, + min_width=1, + min_height=1 + ) + + self.parent.widget = overlay + self.parent.app.ui.main_display.update_active_sub_display() + + +class EditInterfaceView(AddInterfaceView): + def __init__(self, parent, iface_name): + self.parent = parent + self.iface_name = iface_name + + self.interface_config = parent.app.rns.config['interfaces'][iface_name] + config_type = self.interface_config.get("type", "Unknown") + + # check if this is a custom interface type + known_types = list(INTERFACE_FIELDS.keys()) + if config_type not in known_types: + self.original_type = config_type + self.iface_type = "CustomInterface" + else: + self.original_type = None + self.iface_type = config_type + + super().__init__(parent, self.iface_type) + + self.overlay.top_w.title_widget.set_text(f"Edit Interface: {iface_name}") + + self._populate_form_fields() + + def _populate_form_fields(self): + self.fields['name']['widget'].edit_text = self.iface_name + + if self.original_type and self.iface_type == "CustomInterface": + if "type" in self.fields: + self.fields["type"]["widget"].edit_text = self.original_type + + if "custom_parameters" in self.fields: + custom_params = {} + standard_keys = ["type", "interface_enabled", "enabled", "description", + "network_name", "bitrate", "passphrase", "ifac_size", + "mode", "outgoing", "announce_cap"] + + for key, value in self.interface_config.items(): + if key not in standard_keys: + custom_params[key] = value + + self.fields["custom_parameters"]["widget"].set_value(custom_params) + + for key, field in self.fields.items(): + if key not in ['name', 'type', 'custom_parameters']: + widget = field['widget'] + + if key == "subinterfaces" and isinstance(widget, FormMultiTable): + self._populate_subinterfaces(widget) + continue + + if key in self.interface_config: + value = self.interface_config[key] + + if key == 'frequency': + value = float(value) / 1000000 + value = f"{value:.6f}".rstrip('0').rstrip('.') if '.' in f"{value:.6f}" else f"{value}" + + self._set_field_value(widget, value) + + for key, field in self.additional_fields.items(): + if key in self.interface_config: + self._set_field_value(field['widget'], self.interface_config[key]) + + for key, field in self.common_fields.items(): + if key in self.interface_config: + self._set_field_value(field['widget'], self.interface_config[key]) + + def _set_field_value(self, widget, value): + if hasattr(widget, 'edit_text'): + widget.edit_text = str(value) + elif hasattr(widget, 'set_state'): + checkbox_state = value if isinstance(value, bool) else value.strip().lower() not in ( + 'false', 'off', 'no', '0') + widget.set_state(checkbox_state) + elif isinstance(widget, FormDropdown): + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + else: + # Try to match after transform + for opt in widget.options: + try: + if widget.transform(opt) == value: + widget.selected = opt + widget.main_button.base_widget.set_text(opt) + break + except: + pass + elif isinstance(widget, FormMultiList): + self._populate_multilist(widget, value) + + def _populate_multilist(self, widget, value): + items = [] + if isinstance(value, str): + items = [item.strip() for item in value.split(',') if item.strip()] + elif isinstance(value, list): + items = value + + while len(widget.entries) > 1: + widget.remove_entry(None, widget.entries[-1]) + + if items: + first_entry = widget.entries[0] + first_edit = first_entry.contents[0][0] + if len(items) > 0: + first_edit.edit_text = items[0] + + for i in range(1, len(items)): + widget.add_entry(None) + entry = widget.entries[i] + edit_widget = entry.contents[0][0] + edit_widget.edit_text = items[i] + + def _populate_subinterfaces(self, widget): + subinterfaces = {} + + for key, value in self.interface_config.items(): + if isinstance(value, dict): + if key.startswith('[[[') and key.endswith(']]]'): + clean_key = key[3:-3] # removes [[[...]]] + subinterfaces[clean_key] = value + elif key not in ["type", "interface_enabled", "enabled", "port", + "id_callsign", "id_interval"]: + subinterfaces[key] = value + + if subinterfaces: + widget.set_value(subinterfaces) + + def on_save(self, button): + if not self.validate_all(): + return + + new_name = self.fields['name']["widget"].get_value() or self.iface_name + + if new_name != self.iface_name and new_name in self.parent.app.rns.config['interfaces']: + self.fields['name']["widget"].error = f"Interface name '{new_name}' already exists" + self.fields['name']["widget"].error_widget.set_text(("error", self.fields['name']["widget"].error)) + return + + if self.iface_type == "CustomInterface": + interface_type = self.fields.get('type', {}).get('widget').get_value() + else: + interface_type = self.iface_type + + updated_config = { + "type": interface_type, + "interface_enabled": self.interface_config.get("interface_enabled", True), + } + if "enabled" in self.interface_config: + updated_config["enabled"] = self.interface_config["enabled"] + + for field_key, field in self.fields.items(): + if field_key not in ["name", "custom_parameters", "type", "subinterfaces"]: + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + if self.iface_type == "CustomInterface" and "custom_parameters" in self.fields: + custom_params = self.fields["custom_parameters"]["widget"].get_value() + if isinstance(custom_params, dict): + for param_key, param_value in custom_params.items(): + updated_config[param_key] = param_value + + elif self.iface_type == "RNodeMultiInterface" and "subinterfaces" in self.fields: + subinterfaces = self.fields["subinterfaces"]["widget"].get_value() + for subname, subconfig in subinterfaces.items(): + updated_config[subname] = subconfig + + for field_key, field in self.additional_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + for field_key, field in self.common_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + try: + interfaces = self.parent.app.rns.config['interfaces'] + + if new_name != self.iface_name: + del interfaces[self.iface_name] + interfaces[new_name] = updated_config + + try: self.parent.app.interface_profiles.rename_interface(self.iface_name, new_name) + except Exception: pass + + for i, item in enumerate(self.parent.interface_items): + if item.name == self.iface_name: + self.parent.interface_items[i].name = new_name + break + else: + interfaces[self.iface_name] = updated_config + + write_config_atomic(self.parent.app.rns.config) + self._apply_profile_membership(new_name) + + display_type = interface_type + + for item in self.parent.interface_items: + if item.name == new_name: + item.iface_type = display_type + break + + self.parent._rebuild_list() + self.parent.mark_restart_pending() + self.show_message(f"Interface {new_name} updated. Restart NomadNet for these changes to take effect") + + except Exception as e: + print(f"Error saving interface: {str(e)}") + self.show_message(f"Error updating interface: {str(e)}", title="Error") + + +class ShowInterface(urwid.WidgetWrap): + def __init__(self, parent, iface_name): + self.parent = parent + self.iface_name = iface_name + self.started = False + self.g = self.parent.app.ui.glyphs + + # get config + self.interface_config = self.parent.app.rns.config['interfaces'][iface_name] + iface_type = self.interface_config.get("type", "Unknown") + + self.parent.shortcuts_display.set_show_interface_shortcuts() + + self.config_rows = [] + + screen_cols, _ = _get_cols_rows() + margin = 22 + if screen_cols >= 145: + self.history_length = screen_cols//2-margin + else: + self.history_length = screen_cols-(margin+2) + + # get interface stats + interface_stats = self.parent.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + self.stats = stats_lookup.get(iface_name, {}) + + self.tx = self.stats.get("txb", 0) + self.rx = self.stats.get("rxb", 0) + self.is_connected = self.stats.get("status", False) + self.is_enabled = (str(self.interface_config.get("enabled")).lower() != 'false' and + str(self.interface_config.get("interface_enabled")).lower() != 'false') + + header_content = [ + urwid.Text(("interface_title", f"Interface: {iface_name}"), align="center"), + urwid.Divider("=") + ] + header = urwid.Pile(header_content) + + self.edit_button = urwid.Button("Edit", on_press=self.on_edit) + self.back_button = urwid.Button("Back", on_press=self.on_back) + + self.toggle_button = urwid.Button( + "Disable" if self.is_enabled else "Enable", + on_press=self.on_toggle_enabled + ) + + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, self.back_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.toggle_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.edit_button), + ]) + + footer_content = [ + urwid.Divider("="), + button_row + ] + footer = urwid.Pile(footer_content) + + # status widgets + self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disabled_status", + "Enabled" if self.is_enabled else "Disabled")) + + self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disabled_status", + self.parent.g['selected'] if self.is_enabled else self.parent.g[ + 'unselected'])) + + self.connection_text = urwid.Text(connection_markup(self.parent.g, self.is_connected)) + + if self.is_enabled: + status_columns = urwid.Columns([ + (10, urwid.Text(("key", "Status:"))), + (4, self.status_indicator), + (8, self.status_text), + (3, urwid.Text(" | ")), + self.connection_text, + ]) + else: + status_columns = urwid.Columns([ + (10, urwid.Text(("key", "Status:"))), + (4, self.status_indicator), + self.status_text, + ]) + + self.info_rows = [ + urwid.Columns([ + (10, urwid.Text(("key", "Type:"))), + urwid.Text(("value", f"{_get_interface_icon(self.parent.glyphset, iface_type)} {iface_type}")), + ]), + status_columns, + urwid.Divider("-") + ] + + for row in self._build_statistics(): + self.info_rows.append(row) + self.info_rows.append(urwid.Divider("-")) + + self.bandwidth_chart = InterfaceBandwidthChart(history_length=self.history_length, glyphset=self.parent.glyphset) + self.bandwidth_chart.update(self.rx, self.tx) + + self.rx_chart_text = urwid.Text("Loading RX data...", align='left') + self.tx_chart_text = urwid.Text("Loading TX data...", align='left') + self.rx_peak_text = urwid.Text("Peak: 0 B/s", align='right') + self.tx_peak_text = urwid.Text("Peak: 0 B/s", align='right') + + self.rx_box = urwid.LineBox( + urwid.Pile([ + urwid.AttrMap(self.rx_chart_text, "rx"), + self.rx_peak_text + ]), + title="RX Traffic (60s)" + ) + + self.tx_box = urwid.LineBox( + urwid.Pile([ + urwid.AttrMap(self.tx_chart_text, "tx"), + self.tx_peak_text + ]), + title="TX Traffic (60s)" + ) + + self.horizontal_charts = urwid.Columns([ + (urwid.WEIGHT, 1, self.rx_box), + (urwid.WEIGHT, 1, self.tx_box) + ]) + + self.vertical_charts = urwid.Pile([ + self.rx_box, + self.tx_box + ]) + + self.disconnected_message = urwid.Filler( + urwid.Text(("disabled_status", + "Charts not available - Interface is not connected"), + align="center"), + valign="top" + ) + self.disconnected_box = urwid.LineBox(self.disconnected_message, title="Bandwidth Charts") + + self.charts_widget = self.vertical_charts + self.is_horizontal = False + + screen_cols, _ = _get_cols_rows() + # RNS.log(screen_cols) + if screen_cols >= 145: + self.charts_widget = self.horizontal_charts + self.is_horizontal = True + + if not self.is_connected: + self.charts_widget = self.disconnected_box + + connection_params = [] + radio_params = [] + network_params = [] + ifac_params = [] + other_params = [] + + # Sort parameters into groups + for key, value in self.interface_config.items(): + # skip empty + if value is None or value == "": + continue + + # Skip these keys as their shown elsewhere + if key in ["type", "interface_enabled", "enabled", 'selected_interface_mode', 'name']: + continue + + # Connection parameters + elif key in ["port", "listen_ip", "listen_port", "target_host", "target_port", "device"]: + connection_params.append((key, value)) + + # Radio parameters + elif key in ["frequency", "bandwidth", "spreadingfactor", "codingrate", "txpower"]: + radio_params.append((key, value)) + + # Network parameters + elif key in ["network_name", "bitrate", "peers", "group_id", "multicast_address_type", + "discovery_scope", "announce_cap", "mode"]: + network_params.append((key, value)) + + # IFAC parameters + elif key in ["passphrase", "ifac_size", "ifac_netname", "ifac_netkey"]: + ifac_params.append((key, value)) + + else: + other_params.append((key, value)) + + def create_param_row(key, value): + if isinstance(value, bool): + value_str = "Yes" if value else "No" + elif key == "frequency": + int_value = int(value) + value_str = f"{int_value / 1000000:.3f} MHz" + elif key == "bandwidth": + int_value = int(value) + value_str = f"{int_value / 1000:.1f} kHz" + elif key == "passphrase": + value_str = len(value)*"*" + else: + value_str = str(value) + # format display keys: "listen_port" => Listen Port + display_key = key.replace('_', ' ').title() + + return urwid.Columns([ + (18, urwid.Text(("key", f"{display_key}:"))), + urwid.Text(("value", value_str)), + ]) + + if connection_params: + connection_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Connection Parameters"), align="left")) + for key, value in connection_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if radio_params: + radio_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Radio Parameters"), align="left")) + for key, value in radio_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if network_params: + network_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Network Parameters"), align="left")) + for key, value in network_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if ifac_params: + ifac_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "IFAC Parameters"), align="left")) + for key, value in ifac_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if other_params: + other_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Additional Parameters"), align="left")) + for key, value in other_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if not self.config_rows: + self.config_rows.append(urwid.Text("No additional parameters", align="center")) + + body_content = [] + body_content.extend(self.info_rows) + body_content.append( + self.charts_widget) + body_content.append(urwid.Divider("-")) + body_content.extend(self.config_rows) + + body_pile = urwid.Pile(body_content) + body_padding = urwid.Padding(body_pile, left=2, right=2) + + body = urwid.ListBox(urwid.SimpleListWalker([body_padding])) + + self.frame = urwid.Frame( + body=body, + header=header, + footer=footer + ) + + self.content_box = urwid.LineBox(self.frame) + + super().__init__(self.content_box) + + def _stat_rows_spec(self, stats): + # Ordered (row_id, label) pairs that apply to this interface + spec = [("mode", "Mode:")] + if stats.get("bitrate") is not None: + spec.append(("bitrate", "Bitrate:")) + spec.append(("tx", "TX:")) + spec.append(("rx", "RX:")) + if "rxs" in stats and "txs" in stats: + spec.append(("speed", "Speed:")) + if stats.get("incoming_announce_frequency") is not None: + spec.append(("announce", "Announces:")) + if stats.get("incoming_pr_frequency") is not None: + spec.append(("pathreq", "Path Reqs:")) + if stats.get("clients") is not None: + spec.append(("clients", "Clients:")) + if stats.get("peers") is not None: + spec.append(("peers", "Peers:")) + if stats.get("ifac_netname") is not None or stats.get("ifac_signature") is not None: + spec.append(("ifac", "Network:")) + if stats.get("announce_queue") is not None: + spec.append(("queued", "Queued:")) + if "held_announces" in stats: + spec.append(("held", "Held:")) + return spec + + def _radio_rows_spec(self, stats): + spec = [] + if "channel_load_short" in stats and "channel_load_long" in stats: + spec.append(("chload", "Ch. Load:")) + if "airtime_short" in stats and "airtime_long" in stats: + spec.append(("airtime", "Airtime:")) + if "noise_floor" in stats: + spec.append(("noise", "Noise Fl.:")) + if stats.get("battery_percent") is not None: + spec.append(("battery", "Battery:")) + if "cpu_load" in stats or "cpu_temp" in stats: + spec.append(("cpu", "CPU:")) + if "mem_load" in stats: + spec.append(("mem", "Memory:")) + return spec + + def _fmt_pct(self, v): + try: + v = float(v) + except (TypeError, ValueError): + return "0" + if v <= 1.0: + v *= 100.0 + return "%d" % int(round(v)) + + def _fmt_pct_pair(self, short_v, long_v): + return "%s%% (15s), %s%% (1h)" % (self._fmt_pct(short_v), self._fmt_pct(long_v)) + + def _format_stat(self, row_id, stats): + if row_id == "mode": + return interface_mode_str(stats.get("mode")) + if row_id == "bitrate": + return format_speed(stats.get("bitrate")) + if row_id == "tx": + return format_bytes(stats.get("txb", 0)) + if row_id == "rx": + return format_bytes(stats.get("rxb", 0)) + if row_id == "speed": + return "TX %s RX %s" % (format_speed(stats.get("txs", 0)), format_speed(stats.get("rxs", 0))) + if row_id == "announce": + return "In %s Out %s" % (format_announce_rate(stats.get("incoming_announce_frequency")), + format_announce_rate(stats.get("outgoing_announce_frequency"))) + if row_id == "pathreq": + return "In %s Out %s" % (format_announce_rate(stats.get("incoming_pr_frequency")), + format_announce_rate(stats.get("outgoing_pr_frequency"))) + if row_id == "clients": + return str(stats.get("clients")) + if row_id == "peers": + return "%s reachable" % stats.get("peers") + if row_id == "ifac": + netname = stats.get("ifac_netname") + size = stats.get("ifac_size") + bits = ("%d-bit IFAC" % (size * 8)) if size else "IFAC" + return ("%s (%s)" % (netname, bits)) if netname else bits + if row_id == "queued": + n = stats.get("announce_queue") or 0 + return "%d announce%s" % (n, "" if n == 1 else "s") + if row_id == "held": + n = stats.get("held_announces") or 0 + return "%d announce%s" % (n, "" if n == 1 else "s") + if row_id == "chload": + return self._fmt_pct_pair(stats.get("channel_load_short"), stats.get("channel_load_long")) + if row_id == "airtime": + return self._fmt_pct_pair(stats.get("airtime_short"), stats.get("airtime_long")) + if row_id == "noise": + nf = stats.get("noise_floor") + return ("%s dBm" % nf) if nf is not None else "Unknown" + if row_id == "battery": + try: + return "%d%% (%s)" % (int(stats.get("battery_percent")), stats.get("battery_state", "Unknown")) + except (TypeError, ValueError): + return "Unknown" + if row_id == "cpu": + load = stats.get("cpu_load") + temp = stats.get("cpu_temp") + parts = [] + if load is not None: + parts.append("%s%% load" % load) + if temp is not None: + parts.append("%s°C" % temp) + return ", ".join(parts) if parts else "Unknown" + if row_id == "mem": + m = stats.get("mem_load") + return ("%s%%" % m) if m is not None else "Unknown" + return "" + + def _build_statistics(self): + # Builds the live statistics section and records the dynamic widgets in + # self.stat_w so they can be refreshed in place each poll tick. + self.stat_w = {} + + def stat_row(row_id, label): + w = urwid.Text(("value", self._format_stat(row_id, self.stats))) + self.stat_w[row_id] = w + return urwid.Columns([(12, urwid.Text(("key", label))), w]) + + rows = [urwid.Text(("interface_title", "Statistics"), align="left")] + for row_id, label in self._stat_rows_spec(self.stats): + rows.append(stat_row(row_id, label)) + + radio = self._radio_rows_spec(self.stats) + if radio: + rows.append(urwid.Divider("-")) + rows.append(urwid.Text(("interface_title", "Radio"), align="left")) + for row_id, label in radio: + rows.append(stat_row(row_id, label)) + + return rows + + def _refresh_stats(self, stats): + for row_id, w in self.stat_w.items(): + try: + w.set_text(("value", self._format_stat(row_id, stats))) + except Exception: + pass + + def update_status_display(self): + if self.is_enabled: + self.status_indicator.set_text(("connected_status", self.parent.g['selected'])) + self.status_text.set_text(("connected_status", "Enabled")) + else: + self.status_indicator.set_text(("disabled_status", self.parent.g['unselected'])) + self.status_text.set_text(("disabled_status", "Disabled")) + + def update_connection_display(self, is_connected): + old_connection_state = self.is_connected + self.is_connected = is_connected + + self.connection_text.set_text(connection_markup(self.parent.g, self.is_connected)) + + if old_connection_state != self.is_connected: + body_pile = self.frame.body.body[0].original_widget + + chart_index = None + for i, (widget, options) in enumerate(body_pile.contents): + if (widget == self.horizontal_charts or + widget == self.vertical_charts or + widget == self.disconnected_box): + chart_index = i + break + + if chart_index is not None: + if self.is_connected: + new_widget = self.horizontal_charts if self.is_horizontal else self.vertical_charts + else: + new_widget = self.disconnected_box + + body_pile.contents[chart_index] = (new_widget, body_pile.options()) + + def on_toggle_enabled(self, button): + self.is_enabled = not self.is_enabled + + self.toggle_button.set_label("Disable" if self.is_enabled else "Enable") + + self.interface_config["interface_enabled"] = self.is_enabled + if "enabled" in self.interface_config: + self.interface_config["enabled"] = self.is_enabled + + try: + interfaces = self.parent.app.rns.config['interfaces'] + interfaces[self.iface_name] = self.interface_config + write_config_atomic(self.parent.app.rns.config) + + self.update_status_display() + + for item in self.parent.interface_items: + if item.name == self.iface_name: + item.is_enabled = self.is_enabled + item.update_status_display() + + if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None: + self.parent.app.ui.loop.draw_screen() + + self.parent.mark_restart_pending() + + except Exception as e: + self.show_error_message(f"Error updating interface: {str(e)}") + + def show_restart_required_message(self): + + def dismiss_dialog(button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text( + f"Interface {self.iface_name} has been " + + ("enabled" if self.is_enabled else "disabled") + + ".\nRestart required for changes to take effect.", + align="center" + ), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title="Notice" + ) + + overlay = urwid.Overlay( + dialog, + self.parent.app.ui.main_display.frame.body, + align='center', + width=50, + valign='middle', + height=8 + ) + + self.parent.app.ui.main_display.frame.body = overlay + + def show_error_message(self, message): + + def dismiss_dialog(button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title="Error" + ) + + overlay = urwid.Overlay( + dialog, + self.parent.app.ui.main_display.frame.body, + align='center', + width=50, + valign='middle', + height=8 + ) + + self.parent.app.ui.main_display.frame.body = overlay + + def keypress(self, size, key): + if key == 'tab': + if self.frame.focus_position == 'body': + self.frame.focus_position = 'footer' + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + footer_pile.focus_position = 1 # button row + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + button_row.focus_position = 0 + return None + elif self.frame.focus_position == 'footer': + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + # If on first button (Back), move to Toggle button + if button_row.focus_position == 0: + button_row.focus_position = 2 # skip spacer + return None + # If on toggle button, move to Edit button + elif button_row.focus_position == 2: + button_row.focus_position = 4 + return None + # if on edit button wrap back to toggle button + elif button_row.focus_position == 4: + button_row.focus_position = 0 + return None + elif key == 'shift tab': + if self.frame.focus_position == 'footer': + self.frame.focus_position = 'body' + return None + elif self.frame.focus_position == 'footer': + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + if button_row.focus_position == 4: # edit button + button_row.focus_position = 2 # toggle button + return None + elif button_row.focus_position == 2: + button_row.focus_position = 0 # back button + return None + elif button_row.focus_position == 0: # back button + self.frame.focus_position = 'body' + return None + elif key == 'down': + if self.frame.focus_position == 'body': + result = super().keypress(size, key) + # if the key wasn't consumed, we're at the bottom + if result == 'down': + self.frame.focus_position = 'footer' + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + footer_pile.focus_position = 1 # button row + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + button_row.focus_position = 0 # focus on back button + return None + return result + elif key == 'up': + if self.frame.focus_position == 'footer': + self.frame.focus_position = 'body' + listbox = self.frame.body + if hasattr(listbox, 'body') and len(listbox.body) > 0: + listbox.focus_position = len(listbox.body) - 1 + return None + elif self.frame.focus_position == 'body': + result = super().keypress(size, key) + # if the key wasn't consumed, we're at the top + if result == 'up': + pass + return result + elif key == "h" and self.is_connected: # horizontal layout + if not self.is_horizontal: + self.switch_to_horizontal() + return None + elif key == "v" and self.is_connected: # vertical layout + if self.is_horizontal: + self.switch_to_vertical() + return None + + return super().keypress(size, key) + + def switch_to_horizontal(self): + if not self.is_connected: + return + + self.is_horizontal = True + + body_pile = self.frame.body.body[0].original_widget + for i, (widget, options) in enumerate(body_pile.contents): + if widget == self.vertical_charts: + body_pile.contents[i] = (self.horizontal_charts, options) + self.charts_widget = self.horizontal_charts + break + + def switch_to_vertical(self): + if not self.is_connected: + return + + self.is_horizontal = False + body_pile = self.frame.body.body[0].original_widget + for i, (widget, options) in enumerate(body_pile.contents): + if widget == self.horizontal_charts: + body_pile.contents[i] = (self.vertical_charts, options) + self.charts_widget = self.vertical_charts + break + + def start(self): + # Poll while the detail view is open, regardless of connection state, so + # that the connection indicator and live stats keep refreshing and a + # dropped link can be seen to come back up. + if not self.started: + self.started = True + self.parent.app.ui.loop.set_alarm_in(1, self.update_bandwidth_charts) + + def _show_disconnect_overlay(self): + # Uses its own overlay object so it never collides with the interface + # list's disconnect overlay handling while both poll loops are running. + if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay: + return + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text("Waiting to Reconnect...", align="center"), + ]) + dialog_box = urwid.LineBox(urwid.Filler(dialog_text)) + self._disconnect_overlay = urwid.Overlay( + dialog_box, self, align='center', width=35, valign='middle', height=4) + self.parent.widget = self._disconnect_overlay + self.parent.app.ui.main_display.update_active_sub_display() + + def update_bandwidth_charts(self, loop, user_data): + if not self.started: + return + + try: + # The interface list's poll loop owns the actual stats fetch; when the + # shared RNS instance drops it flags _rns_disconnected, which we surface + # here too instead of silently charting stale data. + if getattr(self.parent, "_rns_disconnected", False): + self._show_disconnect_overlay() + else: + if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay: + self.parent.widget = self + self.parent.app.ui.main_display.update_active_sub_display() + + interface_stats = self.parent.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + stats = stats_lookup.get(self.iface_name, {}) + + tx = stats.get("txb", self.tx) + rx = stats.get("rxb", self.rx) + + new_connection_status = stats.get("status", False) + if new_connection_status != self.is_connected: + self.update_connection_display(new_connection_status) + + self._refresh_stats(stats) + + if self.is_connected: + self.bandwidth_chart.update(rx, tx) + + rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) + + self.rx_chart_text.set_text(rx_chart) + self.tx_chart_text.set_text(tx_chart) + self.rx_peak_text.set_text(f"Peak: {peak_rx}") + self.tx_peak_text.set_text(f"Peak: {peak_tx}") + + self.tx = tx + self.rx = rx + except Exception as e: + self._show_disconnect_overlay() + finally: + if self.started: + loop.set_alarm_in(1, self.update_bandwidth_charts) + + def on_back(self, button): + self.started = False + self.parent.switch_to_list() + + def on_edit(self, button): + self.started = False + self.parent.switch_to_edit_interface(self.iface_name) + +class InterfaceTile(urwid.WidgetWrap): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, icon, announce_rate="0"): + self.parent = parent + self.name = name + self.icon = icon + self.g = parent.g + self.dot_style = "connected_status" if is_enabled else "disabled_status" + self.dot = self.g['selected'] if is_enabled else self.g['unselected'] + self.title_widget = urwid.Text("", wrap="clip") + typ = urwid.Text(("value", iface_type), wrap="clip") + if is_enabled: + status = urwid.Text([ + (self.dot_style, "Enabled"), + ("value", f" {self.g['sep_dot']} "), + connection_markup(self.g, is_connected, "Conn." if is_connected else "Disc."), + ], wrap="clip") + else: + status = urwid.Text([(self.dot_style, "Disabled")], wrap="clip") + inner_items = [self.title_widget, typ, status] + if is_enabled: + inner_items.append(urwid.Text([("key", "Announces "), ("value", announce_rate)], wrap="clip")) + inner = urwid.Pile(inner_items) + self.box = urwid.LineBox( + urwid.Padding(inner, left=1, right=1), + tlcorner="╭", trcorner="╮", blcorner="╰", brcorner="╯", + ) + self.attr = urwid.AttrMap(self.box, None, focus_map={None: "interface_tile_focus"}) + super().__init__(self.attr) + self._render_title(False) + + def _render_title(self, focus): + arrow = self.g['focus_arrow'] if focus else " " + style = "interface_tile_focus" if focus else "interface_title" + self.title_widget.set_text([(self.dot_style, self.dot), (style, f" {arrow} {self.icon} {self.name}")]) + + def selectable(self): + return True + + def render(self, size, focus=False): + self._render_title(focus) + return super().render(size, focus) + + def keypress(self, size, key): + if key == "enter": + self.parent.switch_to_show_interface(self.name) + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self.parent.switch_to_show_interface(self.name) + return True + return False + + +### PROFILE VIEWS ### +class ProfileListRow(urwid.WidgetWrap): + def __init__(self, view, pid, label, selected): + self.view = view + self.pid = pid + self.label = label + self.selected = selected + self.g = view.parent.g + self.text_widget = urwid.Text("") + super().__init__(urwid.AttrMap(self.text_widget, None)) + self._render_row(False) + + def _render_row(self, focus): + mark = self.g['selected'] if self.selected else self.g['unselected'] + arrow = self.g['focus_arrow'] if focus else " " + if focus: + style = "interface_tile_focus" + elif self.selected: + style = "connected_status" + else: + style = "interface_title" + self.text_widget.set_text((style, f" {arrow} {mark} {self.label}")) + + def selectable(self): + return True + + def render(self, size, focus=False): + self._render_row(focus) + return super().render(size, focus) + + def keypress(self, size, key): + if key in (" ", "enter"): + if self.pid is not None: + self.view.select(self.pid) + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + if self.pid is not None: + self.view.select(self.pid) + return True + return False + + +class ProfilesView(urwid.WidgetWrap): + def __init__(self, parent): + self.parent = parent + self.app = parent.app + self.profiles = parent.app.interface_profiles + self.profiles.prune() + self.status = urwid.Text("", align="center") + self.walker = urwid.SimpleFocusListWalker([]) + self.listbox = urwid.ListBox(self.walker) + body_rows = max(3, parent.list_rows - 3) + self.pile = urwid.Pile([ + ('pack', parent._make_tab_bar("profiles")), + ('pack', self.status), + ('pack', urwid.Divider()), + urwid.BoxAdapter(self.listbox, body_rows), + ]) + super().__init__(urwid.Filler(self.pile, urwid.TOP)) + self.refresh() + + def refresh(self, focus_pid="__keep__"): + prev = None + try: + w, _ = self.listbox.body.get_focus() + prev = getattr(w, "pid", None) + except Exception: + pass + self.profiles.update_default_if_custom() + active = self.profiles.active_profile_id() + items = [ProfileListRow(self, "__default__", "Default", active is None)] + for p in self.profiles.profiles: + count = self.profiles.member_count(p) + label = f"{p['name'] or '(unnamed)'} ({count} interface{'' if count == 1 else 's'})" + items.append(ProfileListRow(self, p["id"], label, p["id"] == active)) + if len(self.profiles.profiles) == 0: + items.append(urwid.Text("\n No profiles yet. Press Ctrl + A to create one.", align="left")) + self.walker[:] = items + target = focus_pid if focus_pid != "__keep__" else prev + for i, w in enumerate(self.walker): + if getattr(w, "pid", "__x__") == target: + try: self.listbox.focus_position = i + except Exception: pass + break + + def _focused(self): + try: + w, _ = self.listbox.body.get_focus() + return w if isinstance(w, ProfileListRow) else None + except Exception: + return None + + def select(self, pid): + if pid == "__default__": + if self.profiles.default_members is None: + return + count = len(self.profiles.default_members) + title = "Switch to Default" + msg = "Restore your saved manual setup?\nThis enables %d interface%s and disables the rest." % (count, "" if count == 1 else "s") + else: + p = self.profiles.get(pid) + if p is None: + return + count = self.profiles.member_count(p) + title = "Switch Profile" + msg = "Switch to profile '%s'?\nThis enables its %d interface%s and disables the rest." % (p["name"] or "(unnamed)", count, "" if count == 1 else "s") + + def yes(_b): + self.dismiss_dialog() + if pid == "__default__": + self.profiles.select_default() + else: + self.profiles.select_profile(pid) + self.parent.mark_restart_pending() + self.refresh(focus_pid=pid) + self.status.set_text(("connected_status", "Restart NomadNet for changes to take effect")) + + def no(_b): + self.dismiss_dialog() + + pile = urwid.Pile([ + urwid.Text(msg, align="center"), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title), height=10) + + def _overlay(self, dialog, height=8, width=50): + frame = self.app.ui.main_display.frame + frame.body = urwid.Overlay(dialog, self, align="center", width=width, + valign="middle", height=height, min_width=20) + + def dismiss_dialog(self): + self.app.ui.main_display.frame.body = self + + def new_profile(self): + edit = ReadlineEdit(caption="Name: ") + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + pid = self.profiles.create(val) + self.refresh(focus_pid=pid) + + self._prompt("New Profile", edit, ok) + + def save_current(self): + n = len(self.profiles.enabled_set()) + edit = ReadlineEdit(caption="Name (%d enabled): " % n) + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + pid = self.profiles.save_current_as_profile(val) + self.refresh(focus_pid=pid) + self.status.set_text(("connected_status", "Saved current setup (%d interface%s) as a profile" % (n, "" if n == 1 else "s"))) + + self._prompt("Save Current as Profile", edit, ok) + + def rename_profile(self): + w = self._focused() + if w is None or w.pid is None: + return + p = self.profiles.get(w.pid) + if p is None: + return + edit = ReadlineEdit(caption="Name: ", edit_text=p["name"]) + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + self.profiles.rename(p["id"], val) + self.refresh(focus_pid=p["id"]) + + self._prompt("Rename Profile", edit, ok) + + def delete_profile(self): + w = self._focused() + if w is None or w.pid is None: + return + p = self.profiles.get(w.pid) + if p is None: + return + + def yes(_b): + self.dismiss_dialog() + self.profiles.delete(p["id"]) + self.refresh() + + def no(_b): + self.dismiss_dialog() + + pile = urwid.Pile([ + urwid.Text(f"Delete profile '{p['name'] or p['id']}'?\nInterfaces keep their current enabled state.", align="center"), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title="Confirm"), height=10) + + def _prompt(self, title, edit, on_ok): + pile = urwid.Pile([ + edit, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("OK", on_press=on_ok)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title)) + + def keypress(self, size, key): + if key in ("tab", "esc"): + self.parent.show_interfaces(); return None + if key == "ctrl a": + self.new_profile(); return None + if key == "ctrl s": + self.save_current(); return None + if key == "ctrl e": + self.rename_profile(); return None + if key == "ctrl x": + self.delete_profile(); return None + return super().keypress(size, key) + + +### MAIN DISPLAY ### +class InterfaceDisplay: + def __init__(self, app): + self.app = app + self.started = False + self.poll_scheduler = False + self.size_scheduler = False + self.interface_items = [] + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.terminal_cols, self.terminal_rows = _get_cols_rows() + self.iface_row_offset = 4 + self.list_rows = self.terminal_rows - self.iface_row_offset + + self.active_tab = "interfaces" + self.iface_view_mode = "rows" + self.iface_filter = "all" + self.total_interfaces = 0 + self.restart_pending = False + self._notice_shown = False + self._rns_disconnected = False + self.profiles_view = None + + self._build_interface_items() + + self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents()) + self.list_box = urwid.ListBox(self.list_walker) + + self.body_rows = max(3, self.list_rows - 4) + self.box_adapter = urwid.BoxAdapter(self.list_box, self.body_rows) + + self.tabs_pile = urwid.Pile([ + ('pack', self._make_tab_bar("interfaces")), + ('pack', self._make_filter_bar()), + ('pack', urwid.Divider()), + self.box_adapter, + ]) + self.interfaces_display = InterfaceFiller(self.tabs_pile, self.app) + self.shortcuts_display = InterfaceDisplayShortcuts(self.app) + self.widget = self.interfaces_display + + def _empty_message(self): + if self.iface_filter != "all" and self.total_interfaces > 0: + return "No %s interfaces." % self.iface_filter + return "No interfaces found. Press Ctrl + A to add a new interface " + + def _interface_list_contents(self): + if not self.interface_items: + return [urwid.Text(("interface_title", self._empty_message()), align="center")] + return list(self.interface_items) + + def _make_filter_bar(self): + cols = [('weight', 1, urwid.Text(""))] + cols.append(('pack', urwid.Text(("interface_title", "Filter by: ")))) + for key, label in (("all", "All"), ("enabled", "Enabled"), ("disabled", "Disabled"), ("connected", "Connected"), ("disconnected", "Disconnected")): + sel = self.iface_filter == key + mark = self.g['selected'] if sel else self.g['unselected'] + style = "interface_tile_focus" if sel else "interface_title" + cols.append(('pack', ClickableIcon((style, "%s %s " % (mark, label)), on_click=lambda k=key: self._set_filter(k)))) + cols.append(('weight', 1, urwid.Text(""))) + return urwid.Columns(cols, dividechars=0) + + def _set_filter(self, key): + if key == self.iface_filter: + return + self.iface_filter = key + self._build_interface_items() + self._rebuild_list() + self.app.ui.main_display.update_active_sub_display() + + def cycle_filter(self): + order = ["all", "enabled", "disabled", "connected", "disconnected"] + i = order.index(self.iface_filter) if self.iface_filter in order else 0 + self._set_filter(order[(i + 1) % len(order)]) + + def _make_tab_bar(self, active): + iface = f" Interfaces ({self.total_interfaces}) " + prof = " Profiles " + if active == "interfaces": + iface_w = ClickableIcon(("interface_title_selected", "["+iface+"]"), on_click=self.show_interfaces) + prof_w = ClickableIcon(("interface_title", " "+prof+" "), on_click=self.show_profiles) + else: + iface_w = ClickableIcon(("interface_title", " "+iface+" "), on_click=self.show_interfaces) + prof_w = ClickableIcon(("interface_title_selected", "["+prof+"]"), on_click=self.show_profiles) + return urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', iface_w), + ('pack', urwid.Text(" ")), + ('pack', prof_w), + ('weight', 1, urwid.Text("")), + ], dividechars=0) + + def _build_grid_listbox(self): + tiles = self.interface_tiles if self.interface_tiles else [urwid.Text(self._empty_message(), align="center")] + self.iface_grid = urwid.GridFlow(tiles, cell_width=34, h_sep=2, v_sep=1, align="center") + return urwid.ListBox(urwid.SimpleFocusListWalker([self.iface_grid])) + + def _set_iface_body(self): + if self.iface_view_mode == "grid": + self.box_adapter.original_widget = self._build_grid_listbox() + else: + self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents()) + self.list_box = urwid.ListBox(self.list_walker) + self.box_adapter.original_widget = self.list_box + + def _focused_interface_name(self): + try: + if self.iface_view_mode == "grid": + return getattr(self.iface_grid.focus, "name", None) + fw, _ = self.box_adapter._original_widget.body.get_focus() + return getattr(fw, "name", None) if isinstance(fw, SelectableInterfaceItem) else None + except Exception: + return None + + def toggle_view_mode(self): + self.iface_view_mode = "grid" if self.iface_view_mode == "rows" else "rows" + self._set_iface_body() + self.app.ui.main_display.update_active_sub_display() + + def show_profiles(self): + if getattr(self.app, "interface_profiles", None) is None: + return + self.active_tab = "profiles" + self.profiles_view = ProfilesView(self) + self.shortcuts_display.set_profiles_tab_shortcuts() + self.widget = self.profiles_view + self.app.ui.main_display.update_active_sub_display() + + def show_interfaces(self): + self.active_tab = "interfaces" + self._build_interface_items() + self._rebuild_list() + self.shortcuts_display.reset_shortcuts() + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + + def start(self): + # started from Main.py + self.started = True + if not self.poll_scheduler: self.app.ui.loop.set_alarm_in(1, self.poll_stats) + if not self.size_scheduler: self.app.ui.loop.set_alarm_in(5, self.check_terminal_size) + + def switch_to_edit_interface(self, iface_name): + self.edit_interface_view = EditInterfaceView(self, iface_name) + self.widget = self.edit_interface_view + self.app.ui.main_display.update_active_sub_display() + + def edit_selected_interface(self): + interface_name = self._focused_interface_name() + if interface_name is None: + return + self.switch_to_edit_interface(interface_name) + + def check_terminal_size(self, loop, user_data): + self.size_scheduler = True + new_cols, new_rows = _get_cols_rows() + + if new_rows != self.terminal_rows or new_cols != self.terminal_cols: + self.terminal_cols, self.terminal_rows = new_cols, new_rows + self.list_rows = self.terminal_rows - self.iface_row_offset + self.body_rows = max(3, self.list_rows - 4 - (1 if self.restart_pending else 0)) + + self.box_adapter.height = self.body_rows + + loop.draw_screen() + + if self.started: + loop.set_alarm_in(5, self.check_terminal_size) + + def _show_disconnect_overlay(self): + # Surfaced when the shared RNS instance (rnsd) we are attached to goes away. + if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: + return + if self.widget is not self.interfaces_display: + return + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text("Waiting to Reconnect...", align="center"), + ]) + dialog_box = urwid.LineBox(urwid.Filler(dialog_text)) + self.disconnect_overlay = urwid.Overlay( + dialog_box, + self.interfaces_display, + align='center', + width=35, + valign='middle', + height=4, + ) + self.widget = self.disconnect_overlay + self.app.ui.main_display.update_active_sub_display() + + def poll_stats(self, loop, user_data): + self.poll_scheduler = True + try: + # React to the outcome of the previous (asynchronous) stats fetch. The + # fetch runs in a daemon thread so the UI never blocks on the RPC round + # trip; the thread cannot raise into this loop, so it reports failure via + # the _rns_disconnected flag instead. + if self._rns_disconnected: + self._show_disconnect_overlay() + else: + if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + + interface_stats = self.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + for item in self.interface_items: + # use interface name as the key + stats_for_interface = stats_lookup.get(item.name) + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0)) + item.update_stats(tx, rx, announce_rate) + item.update_connection(stats_for_interface.get("status", False)) + else: + item.update_connection(False) + + def job(): + try: + self.app.interface_stats = self.app.rns.get_interface_stats() + self._rns_disconnected = False + except Exception: + self._rns_disconnected = True + threading.Thread(target=job, daemon=True).start() + except Exception as e: + self._show_disconnect_overlay() + finally: + if self.started: + loop.set_alarm_in(1, self.poll_stats) + + def shortcuts(self): + return self.shortcuts_display + + def switch_to_show_interface(self, iface_name): + show_interface = ShowInterface(self, iface_name) + self.widget = show_interface + self.app.ui.main_display.update_active_sub_display() + + show_interface.start() + + def switch_to_list(self): + self.show_interfaces() + + def add_interface(self): + dialog_widgets = [] + + def add_heading(txt): + dialog_widgets.append(urwid.Text(("interface_title", txt), align="left")) + + def add_option(label, value): + item = InterfaceOptionItem(self, label, value) + dialog_widgets.append(item) + + add_heading("Paste Configuration") + dialog_widgets.append(InterfaceOptionItem(self, "Paste an interface config…", None, on_select=self.show_paste_config_dialog)) + dialog_widgets.append(urwid.Divider()) + + # Get the icons based on plain, unicode, nerdfont glyphset + network_icon = _get_interface_icon(self.glyphset, "AutoInterface") + rnode_icon = _get_interface_icon(self.glyphset, "RNodeInterface") + serial_icon = _get_interface_icon(self.glyphset, "SerialInterface") + other_icon = _get_interface_icon(self.glyphset, "PipeInterface") + + add_heading(f"{network_icon} IP Networks") + add_option("Auto Interface", "AutoInterface") + add_option("TCP Client Interface", "TCPClientInterface") + add_option("TCP Server Interface", "TCPServerInterface") + add_option("UDP Interface", "UDPInterface") + add_option("I2P Interface", "I2PInterface") + if PLATFORM_IS_LINUX: + add_option("Backbone Interface", "BackboneInterface") + + if PYSERIAL_AVAILABLE: + add_heading(f"{rnode_icon} RNodes") + add_option("RNode Interface", "RNodeInterface") + add_option("RNode Multi Interface", "RNodeMultiInterface") + + add_heading(f"{serial_icon} Hardware") + add_option("Serial Interface", "SerialInterface") + add_option("KISS Interface", "KISSInterface") + add_option("AX.25 KISS Interface", "AX25KISSInterface") + + add_heading(f"{other_icon} Other") + add_option("Pipe Interface", "PipeInterface") + add_option("Custom Interface", "CustomInterface") + + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)) + dialog = DialogLineBox(listbox, parent=self, title="Select Interface Type") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 50), + valign='middle', + height=('relative', 50), + min_width=20, + min_height=15, + left=2, + right=2 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def show_paste_config_dialog(self): + self.paste_edit = ReadlineEdit(caption="", multiline=True) + self.paste_status = urwid.Text("") + + instructions = urwid.Text( + "Paste an interface block, including its [[Name]] header, then Create. " + "Multiple interfaces are supported.", align="left") + + editor = urwid.AttrMap( + urwid.LineBox(urwid.BoxAdapter(urwid.ListBox(urwid.SimpleListWalker([self.paste_edit])), 12)), + "list_off_focus", "list_focus") + + pile = urwid.Pile([ + instructions, + urwid.Divider(), + editor, + self.paste_status, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=lambda _b: self._create_interfaces_from_text(self.paste_edit.get_edit_text()))), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + + dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Paste Interface Config") + overlay = urwid.Overlay( + dialog, self.interfaces_display, + align="center", width=("relative", 75), + valign="middle", height=("relative", 75), + min_width=44, min_height=14, + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def _section_to_dict(self, sec): + out = {} + for k, v in sec.items(): + out[k] = self._section_to_dict(v) if hasattr(v, "items") else v + return out + + def _parse_pasted_interfaces(self, text): + import RNS.vendor.configobj as configobj + raw = (text or "").strip("\n") + if not raw.strip(): + return {} + for prefix in ("", "[interfaces]\n"): + try: + parsed = configobj.ConfigObj(infile=(prefix + raw).split("\n")) + except Exception: + continue + found = {} + sections = [] + ifsec = parsed.get("interfaces") + if hasattr(ifsec, "items"): + sections.extend(ifsec.items()) + for name, sec in parsed.items(): + if name != "interfaces" and hasattr(sec, "items"): + sections.append((name, sec)) + for name, sec in sections: + if not hasattr(sec, "items"): + continue + d = self._section_to_dict(sec) + if "type" in d and name not in found: + found[name] = d + if found: + return found + return {} + + def _create_interfaces_from_text(self, text): + parsed = self._parse_pasted_interfaces(text) + if not parsed: + self.paste_status.set_text(("error", " Could not find an interface with a 'type'. Include the [[Name]] header.")) + return + + interfaces = self.app.rns.config['interfaces'] + existing = [n for n in parsed if n in interfaces] + if existing: + self.paste_status.set_text(("error", " Already exists: " + ", ".join(existing))) + return + + try: + for name, conf in parsed.items(): + interfaces[name] = conf + write_config_atomic(self.app.rns.config) + except Exception as e: + self.paste_status.set_text(("error", " Failed: " + str(e))) + return + + self.mark_restart_pending() + self.dismiss_dialog() + self.show_interfaces() + + def switch_to_add_interface(self, iface_type): + self.add_interface_view = AddInterfaceView(self, iface_type) + self.widget = self.add_interface_view + self.app.ui.main_display.update_active_sub_display() + + def remove_selected_interface(self): + interface_name = self._focused_interface_name() + if interface_name is None: + return + + def on_confirm_yes(button): + try: + if interface_name in self.app.rns.config['interfaces']: + del self.app.rns.config['interfaces'][interface_name] + write_config_atomic(self.app.rns.config) + try: self.app.interface_profiles.remove_interface(interface_name) + except Exception: pass + + self.mark_restart_pending() + self._build_interface_items() + self._rebuild_list() + self.dismiss_dialog() + + except Exception as e: + print(e) + + def on_confirm_no(button): + self.dismiss_dialog() + + confirm_text = urwid.Text(("interface_title", f"Remove interface {interface_name}?"), align="center") + yes_button = urwid.Button("Yes", on_press=on_confirm_yes) + no_button = urwid.Button("No", on_press=on_confirm_no) + + buttons_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, yes_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, no_button), + ]) + + pile = urwid.Pile([ + confirm_text, + buttons_row + ]) + + dialog = DialogLineBox(pile, parent=self, title="?") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 35), + valign='middle', + height=(5), + min_width=5, + left=2, + right=2 + ) + dialog.original_widget.focus_position = 1 # columns row + buttons_row = dialog.original_widget.contents[1][0] + buttons_row.focus_position = 2 # second button "No" + + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def dismiss_dialog(self): + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + + def _build_interface_items(self): + self.interface_items = [] + self.interface_tiles = [] + interfaces = self.app.rns.config['interfaces'] + processed_interfaces = {} + + for interface_name, interface in interfaces.items(): + interface_data = interface.copy() + + if interface_data.get("type") == "RNodeMultiInterface": + sub_interfaces = [] + for sub_name, sub_config in interface_data.items(): + if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", + "configured_bitrate"}: + if isinstance(sub_config, dict): + sub_config["name"] = sub_name + sub_interfaces.append(sub_config) + + interface_data["sub_interfaces"] = sub_interfaces + + for sub in sub_interfaces: + del interface_data[sub["name"]] + + processed_interfaces[interface_name] = interface_data + + self.total_interfaces = len(processed_interfaces) + self.app.interface_stats = self.app.rns.get_interface_stats() + stats_lookup = {interface['short_name']: interface for interface in self.app.interface_stats['interfaces']} + + profiles = getattr(self.app, "interface_profiles", None) + + for interface_name, interface_data in processed_interfaces.items(): + is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + if self.iface_filter == "enabled" and not is_enabled: + continue + if self.iface_filter == "disabled" and is_enabled: + continue + + iface_type = interface_data.get("type", "Unknown") + icon = _get_interface_icon(self.glyphset, iface_type) + + stats_for_interface = stats_lookup.get(interface_name) + + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + is_connected = stats_for_interface["status"] + announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0)) + else: + tx = 0 + rx = 0 + is_connected = False + announce_rate = "-" + + if self.iface_filter == "connected" and not is_connected: + continue + if self.iface_filter == "disconnected" and is_connected: + continue + + profiles_label = profiles.label_for(interface_name) if profiles is not None else None + + item = SelectableInterfaceItem( + parent=self, + name=interface_data.get("name", interface_name), + is_connected=is_connected, + is_enabled=is_enabled, + iface_type=iface_type, + tx=tx, + rx=rx, + icon=icon, + profiles_label=profiles_label, + announce_rate=announce_rate + ) + + self.interface_items.append(item) + + tile = InterfaceTile( + parent=self, + name=interface_data.get("name", interface_name), + is_connected=is_connected, + is_enabled=is_enabled, + iface_type=iface_type, + icon=icon, + announce_rate=announce_rate, + ) + self.interface_tiles.append(tile) + + def _rebuild_list(self): + self._set_iface_body() + self.tabs_pile.contents[0] = (self._make_tab_bar("interfaces"), self.tabs_pile.options('pack')) + self.tabs_pile.contents[1] = (self._make_filter_bar(), self.tabs_pile.options('pack')) + + def mark_restart_pending(self): + self.restart_pending = True + if self._notice_shown: + return + try: + mark = "!" if self.glyphset == "plain" else "⚠" + banner = urwid.AttrMap( + urwid.Text("%s Interfaces have been updated. Restart required" % mark, align="center"), + "warning_text") + self.tabs_pile.contents.insert(2, (banner, self.tabs_pile.options('pack'))) + self._notice_shown = True + self.body_rows = max(3, self.body_rows - 1) + self.box_adapter.height = self.body_rows + except Exception: + pass + + def _focus_interface(self, name): + try: + if self.iface_view_mode == "grid": + for i, t in enumerate(self.interface_tiles): + if getattr(t, "name", None) == name: + self.iface_grid.focus_position = i + return + else: + body = self.box_adapter._original_widget.body + for i, w in enumerate(body): + if getattr(w, "name", None) == name: + self.box_adapter._original_widget.focus_position = i + return + except Exception: + pass + + def toggle_focused_interface(self): + name = self._focused_interface_name() + if name is None: + return + interfaces = self.app.rns.config['interfaces'] + if name not in interfaces: + return + iface = interfaces[name] + is_enabled = str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \ + str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + value = "false" if is_enabled else "true" + iface["interface_enabled"] = value + if "enabled" in iface: + iface["enabled"] = value + try: + write_config_atomic(self.app.rns.config) + except Exception: + return + self.mark_restart_pending() + self._build_interface_items() + self._rebuild_list() + self._focus_interface(name) + self.app.ui.main_display.update_active_sub_display() + + def _iface_is_enabled(self, iface): + return str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \ + str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + def _set_iface_enabled(self, iface, enabled): + value = "true" if enabled else "false" + iface["interface_enabled"] = value + if "enabled" in iface: + iface["enabled"] = value + + def show_bulk_actions(self): + interfaces = self.app.rns.config['interfaces'] + if not interfaces: + self._bulk_notice("No interfaces configured.") + return + + dialog_widgets = [ + urwid.Text(("interface_title", "Apply an action to all interfaces"), align="left"), + urwid.Divider(), + InterfaceOptionItem(self, "Enable all interfaces", None, on_select=lambda: self._bulk_apply("enable")), + InterfaceOptionItem(self, "Disable all interfaces", None, on_select=lambda: self._bulk_apply("disable")), + InterfaceOptionItem(self, "Invert (toggle each interface)", None, on_select=lambda: self._bulk_apply("invert")), + InterfaceOptionItem(self, "Solo selected (disable all others)", None, on_select=lambda: self._bulk_apply("solo")), + ] + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)) + dialog = DialogLineBox(listbox, parent=self, title="Bulk Actions") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 50), + valign='middle', + height=('relative', 50), + min_width=20, + min_height=10, + left=2, + right=2 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def _bulk_apply(self, action): + interfaces = self.app.rns.config['interfaces'] + focused = self._focused_interface_name() + + if action == "solo" and focused is None: + self._bulk_notice("Select an interface first to solo it.") + return + + changed = 0 + for name, iface in interfaces.items(): + current = self._iface_is_enabled(iface) + if action == "enable": + target = True + elif action == "disable": + target = False + elif action == "invert": + target = not current + elif action == "solo": + target = (name == focused) + else: + target = current + + if target != current: + self._set_iface_enabled(iface, target) + changed += 1 + + try: + write_config_atomic(self.app.rns.config) + except Exception as e: + self._bulk_notice(f"Error applying bulk action: {str(e)}", title="Error") + return + + self.mark_restart_pending() + self._build_interface_items() + self._rebuild_list() + + verb = {"enable": "Enabled all", "disable": "Disabled all", "invert": "Inverted all", "solo": "Soloed selected"}[action] + if changed == 0: + self._bulk_notice("No interfaces needed changing.") + else: + self._bulk_notice(f"{verb} interfaces ({changed} changed).\nRestart required for changes to take effect.") + + def _bulk_notice(self, message, title="Notice"): + def dismiss_dialog(button): + self.dismiss_dialog() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + parent=self, + title=title + ) + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=60, + valign='middle', + height=8, + min_width=20, + min_height=1 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def save_current_profile(self): + profiles = getattr(self.app, "interface_profiles", None) + if profiles is None: + return + n = len(profiles.enabled_set()) + edit = ReadlineEdit(caption="Name: ") + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + profiles.save_current_as_profile(val) + self.show_interfaces() + + pile = urwid.Pile([ + urwid.Text("Save the %d currently enabled interface%s as a new profile." % (n, "" if n == 1 else "s"), align="left"), + urwid.Divider(), + edit, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=ok)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Save Current as Profile") + overlay = urwid.Overlay( + dialog, self.interfaces_display, + align="center", width=("relative", 60), + valign="middle", height=("relative", 45), min_width=44, min_height=9, + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def open_config_editor(self): + import platform + + editor_cmd = self.app.config["textui"]["editor"] + + if platform.system() == "Darwin" and editor_cmd == "editor": + editor_cmd = "nano" + + editor_term = urwid.Terminal( + (editor_cmd, self.app.rns.configpath), + encoding='utf-8', + main_loop=self.app.ui.loop, + ) + + def quit_term(*args, **kwargs): + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.request_redraw() + + urwid.connect_signal(editor_term, 'closed', quit_term) + + editor_box = urwid.LineBox(editor_term, title="Editing RNS Config") + self.widget = editor_box + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + editor_term.change_focus(True) + +### SHORTCUTS ### +class InterfaceDisplayShortcuts: + def __init__(self, app): + self.app = app + self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [C-o] On/Off [C-b] Bulk [Enter] Show [C-f] Filter [C-s] Save Profile [C-t] Grid/Rows [Tab] Profiles" + self.current_shortcuts = self.default_shortcuts + self.widget = urwid.AttrMap( + urwid.Text(self.current_shortcuts), + "shortcutbar" + ) + + def update_shortcuts(self, new_shortcuts): + self.current_shortcuts = new_shortcuts + self.widget.original_widget.set_text(new_shortcuts) + + def reset_shortcuts(self): + self.update_shortcuts(self.default_shortcuts) + + def set_show_interface_shortcuts(self): + show_shortcuts = "[Up/Down] Navigate [Tab] Switch Focus [h] Horizontal Charts [v] Vertical Charts " + self.update_shortcuts(show_shortcuts) + + def set_add_interface_shortcuts(self): + add_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option" + self.update_shortcuts(add_shortcuts) + + def set_edit_interface_shortcuts(self): + edit_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option" + self.update_shortcuts(edit_shortcuts) + + def set_profiles_tab_shortcuts(self): + profiles_shortcuts = "[Space] Select [C-a] New [C-s] Save Current [C-e] Rename [C-x] Delete [Tab] Interfaces" + self.update_shortcuts(profiles_shortcuts) \ No newline at end of file diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index b38e4b3..8472809 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -1,5 +1,11 @@ +import os +import sys +import itertools +import mmap import urwid import nomadnet +from nomadnet.util import strip_modifiers + class LogDisplayShortcuts(): def __init__(self, app): @@ -8,31 +14,119 @@ class LogDisplayShortcuts(): self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") + class LogDisplay(): def __init__(self, app): - import urwid self.app = app - self.log_term = LogTerminal(self.app) self.shortcuts_display = LogDisplayShortcuts(self.app) - self.widget = urwid.LineBox(self.log_term) + self.widget = None + + @property + def log_term(self): + return self.widget + + def show(self): + if self.widget is None: + self.widget = log_widget(self.app) + + def kill(self): + if self.widget is not None: + self.widget.terminate() + self.widget = None def shortcuts(self): return self.shortcuts_display + class LogTerminal(urwid.WidgetWrap): def __init__(self, app): self.app = app self.log_term = urwid.Terminal( ("tail", "-fn50", self.app.logfilepath), encoding='utf-8', - escape_sequence="up" + escape_sequence="up", + main_loop=self.app.ui.loop, ) - urwid.WidgetWrap.__init__(self, self.log_term) + self.widget = urwid.LineBox(self.log_term) + super().__init__(self.widget) + + def terminate(self): + self.log_term.terminate() def keypress(self, size, key): + if key == "ctrl q": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.quit() + return None + if key == "up": - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") - - return super(LogTerminal, self).keypress(size, key) \ No newline at end of file + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + + return super(LogTerminal, self).keypress(size, key) + + +class LogTail(urwid.WidgetWrap): + def __init__(self, app): + self.app = app + self.log_tail = urwid.Text(strip_modifiers(tail(self.app.logfilepath, 50))) + self.log = urwid.Scrollable(self.log_tail) + self.log.set_scrollpos(-1) + self.log_scrollbar = urwid.ScrollBar(self.log) + # We have this here because ui.textui.Main depends on this field to kill it + self.log_term = None + + super().__init__(self.log_scrollbar) + + def terminate(self): + pass + + +def log_widget(app, platform=sys.platform): + if platform == "win32": + return LogTail(app) + else: + return LogTerminal(app) + +# https://stackoverflow.com/a/34029605/3713120 +def _tail(f_name, n, offset=0): + def skip_back_lines(mm: mmap.mmap, numlines: int, startidx: int) -> int: + '''Factored out to simplify handling of n and offset''' + for _ in itertools.repeat(None, numlines): + startidx = mm.rfind(b'\n', 0, startidx) + if startidx < 0: + break + return startidx + + # Open file in binary mode + with open(f_name, 'rb') as binf, mmap.mmap(binf.fileno(), 0, access=mmap.ACCESS_READ) as mm: + # len(mm) - 1 handles files ending w/newline by getting the prior line + startofline = skip_back_lines(mm, offset, len(mm) - 1) + if startofline < 0: + return [] # Offset lines consumed whole file, nothing to return + # If using a generator function (yield-ing, see below), + # this should be a plain return, no empty list + + endoflines = startofline + 1 # Slice end to omit offset lines + + # Find start of lines to capture (add 1 to move from newline to beginning of following line) + startofline = skip_back_lines(mm, n, startofline) + 1 + + # Passing True to splitlines makes it return the list of lines without + # removing the trailing newline (if any), so list mimics f.readlines() + # return mm[startofline:endoflines].splitlines(True) + # If Windows style \r\n newlines need to be normalized to \n + return mm[startofline:endoflines].replace(os.linesep.encode(sys.getdefaultencoding()), b'\n').splitlines(True) + + +def tail(f_name, n): + """ + Return the last n lines of a given file name, f_name. + Akin to `tail - ` + """ + def decode(b): + return b.decode(encoding) + + encoding = sys.getdefaultencoding() + lines = map(decode, _tail(f_name=f_name, n=n)) + return ''.join(lines) diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 22445da..e439db2 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -1,13 +1,17 @@ import RNS -import time from .Network import * from .Conversations import * +from .Channels import * from .Directory import * from .Config import * +from .Interfaces import * +from .Transport import * +from .Utilities import * from .Map import * from .Log import * from .Guide import * +from .Editor import * import urwid class SubDisplays(): @@ -15,17 +19,27 @@ class SubDisplays(): self.app = app self.network_display = NetworkDisplay(self.app) self.conversations_display = ConversationsDisplay(self.app) + self.channels_display = ChannelsDisplay(self.app) self.directory_display = DirectoryDisplay(self.app) self.config_display = ConfigDisplay(self.app) + self.interface_display = InterfaceDisplay(self.app) + self.utilities_display = UtilitiesDisplay(self.app) if self._utilities_enabled(app) else None self.map_display = MapDisplay(self.app) self.log_display = LogDisplay(self.app) self.guide_display = GuideDisplay(self.app) + self.page_editor_display = PageEditorDisplay(self.app) if app.enable_node else None if app.firstrun: self.active_display = self.guide_display else: self.active_display = self.conversations_display + def _utilities_enabled(self, app): + try: + return app.config["utilities"].as_bool("enable_utilities") + except Exception: + return False + def active(self): return self.active_display @@ -82,6 +96,18 @@ class MainFrame(urwid.Frame): return super(MainFrame, self).keypress(size, key) +class QuitDialogLineBox(urwid.LineBox): + def __init__(self, *args, on_cancel=None, **kwargs): + self.on_cancel = on_cancel + super().__init__(*args, **kwargs) + + def keypress(self, size, key): + if key == "esc": + if self.on_cancel is not None: + self.on_cancel() + return None + return super().keypress(size, key) + class MainDisplay(): def __init__(self, ui, app): self.ui = ui @@ -102,6 +128,11 @@ class MainDisplay(): self.sub_displays.active_display = self.sub_displays.conversations_display self.update_active_sub_display() + def show_channels(self, user_data): + self.sub_displays.active_display = self.sub_displays.channels_display + self.update_active_sub_display() + self.sub_displays.channels_display.start() + def show_directory(self, user_data): self.sub_displays.active_display = self.sub_displays.directory_display self.update_active_sub_display() @@ -114,19 +145,40 @@ class MainDisplay(): self.sub_displays.active_display = self.sub_displays.config_display self.update_active_sub_display() + def show_interfaces(self, user_data): + self.sub_displays.active_display = self.sub_displays.interface_display + self.update_active_sub_display() + self.sub_displays.interface_display.start() + + def show_utilities(self, user_data): + if self.sub_displays.utilities_display is None: + return + self.sub_displays.active_display = self.sub_displays.utilities_display + self.update_active_sub_display() + self.sub_displays.utilities_display.start() + def show_log(self, user_data): self.sub_displays.active_display = self.sub_displays.log_display + self.sub_displays.log_display.show() self.update_active_sub_display() def show_guide(self, user_data): self.sub_displays.active_display = self.sub_displays.guide_display self.update_active_sub_display() + def show_page_editor(self, user_data): + if not self.app.enable_node: + return + self.sub_displays.active_display = self.sub_displays.page_editor_display + self.update_active_sub_display() + self.sub_displays.page_editor_display.start() + self.frame.focus_position = "body" + def update_active_sub_display(self): self.frame.contents["body"] = (self.sub_displays.active().widget, None) self.update_active_shortcuts() - # TODO: Remove when new mitigation has been tested - # self.app.ui.main_display.request_redraw(extra_delay=0.0) + if self.sub_displays.active_display != self.sub_displays.log_display: + self.sub_displays.log_display.kill() def update_active_shortcuts(self): self.frame.contents["footer"] = (self.sub_displays.active().shortcuts().widget, None) @@ -142,13 +194,87 @@ class MainDisplay(): self.menu_display.start() 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 + 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.log_term.log_term != None: + logterm_pid = self.sub_displays.log_display.log_term.log_term.pid + if logterm_pid != None: + import os, signal + os.kill(logterm_pid, signal.SIGKILL) + raise urwid.ExitMainLoop class MenuColumns(urwid.Columns): def keypress(self, size, key): if key == "tab" or key == "down": - self.handler.frame.set_focus("body") + self.handler.frame.focus_position = "body" return super(MenuColumns, self).keypress(size, key) @@ -162,21 +288,36 @@ class MenuDisplay(): self.menu_indicator = urwid.Text("") - menu_text = ("pack", self.menu_indicator) - button_network = (11, MenuButton("Network", on_press=handler.show_network)) - button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) - button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) - button_map = (7, MenuButton("Map", on_press=handler.show_map)) - button_log = (7, MenuButton("Log", on_press=handler.show_log)) - button_config = (10, MenuButton("Config", on_press=handler.show_config)) - button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) - button_quit = (8, MenuButton("Quit", on_press=handler.quit)) + menu_text = (urwid.PACK, self.menu_indicator) + button_network = (11, MenuButton("Network", on_press=handler.show_network)) + button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) + button_channels = (12, MenuButton("Channels", on_press=handler.show_channels)) + button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) + button_map = (7, MenuButton("Map", on_press=handler.show_map)) + button_log = (7, MenuButton("Log", on_press=handler.show_log)) + button_config = (10, MenuButton("Config", on_press=handler.show_config)) + button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) + button_utilities = (13, MenuButton("Utilities", on_press=handler.show_utilities)) + button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) + button_mynode = (11, MenuButton("My Node", on_press=handler.show_page_editor)) + button_quit = (8, MenuButton("Quit", on_press=handler.quit)) # buttons = [menu_text, button_conversations, button_node, button_directory, button_map] - if self.app.config["textui"]["hide_guide"]: - buttons = [menu_text, button_conversations, button_network, button_log, button_config, button_quit] - else: - buttons = [menu_text, button_conversations, button_network, button_log, button_config, button_guide, button_quit] + buttons = [menu_text, button_conversations, button_network] + if self.app.enable_node: + buttons.append(button_mynode) + buttons += [button_channels, button_log, button_interfaces] + 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.handler = handler diff --git a/nomadnet/ui/textui/Map.py b/nomadnet/ui/textui/Map.py index 3d4a03d..b2ef4dd 100644 --- a/nomadnet/ui/textui/Map.py +++ b/nomadnet/ui/textui/Map.py @@ -15,7 +15,7 @@ class MapDisplay(): ]) self.shortcuts_display = MapDisplayShortcuts(self.app) - self.widget = urwid.Filler(pile, 'top') + self.widget = urwid.Filler(pile, urwid.TOP) def shortcuts(self): return self.shortcuts_display \ No newline at end of file diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 7a8ff7e..d4ac77d 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -1,9 +1,16 @@ import nomadnet import urwid +import random +import re import time +import RNS from urwid.util import is_mouse_press from urwid.text_layout import calc_coords -import re +from .ReadlineEdit import ReadlineEdit +from RNS.Utilities.rngit.util import MarkdownToMicron +from nomadnet.util import STRIP_CONTROL_RE +try: from .images import ImageWidget +except Exception: ImageWidget = None DEFAULT_FG_DARK = "ddd" DEFAULT_FG_LIGHT = "222" @@ -30,21 +37,31 @@ SYNTH_SPECS = {} SECTION_INDENT = 2 INDENT_RIGHT = 1 +MAX_TABLE_WIDTH = 100 -def markup_to_attrmaps(markup, url_delegate = None): +DEFAULT_FOLD_GLYPHS = ("▾", "▸") + +def default_fold_glyphs(): + try: + g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs + return (g.get("fold_open", DEFAULT_FOLD_GLYPHS[0]), g.get("fold_closed", DEFAULT_FOLD_GLYPHS[1])) + except Exception: + return DEFAULT_FOLD_GLYPHS + +def default_state(fg=None, bg=None): global SELECTED_STYLES - if nomadnet.NomadNetworkApp.get_shared_instance().config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK: - SELECTED_STYLES = STYLES_DARK - else: - SELECTED_STYLES = STYLES_LIGHT - - attrmaps = [] - + ensure_selected_styles() + if fg == None: fg = SELECTED_STYLES["plain"]["fg"] + if bg == None: bg = DEFAULT_BG state = { "literal": False, + "table_mode": False, + "table_buffer": [], + "table_align": None, + "table_maxwidth": MAX_TABLE_WIDTH, "depth": 0, - "fg_color": SELECTED_STYLES["plain"]["fg"], - "bg_color": DEFAULT_BG, + "fg_color": fg, + "bg_color": bg, "formatting": { "bold": False, "underline": False, @@ -54,7 +71,58 @@ def markup_to_attrmaps(markup, url_delegate = None): }, "default_align": "left", "align": "left", + "default_fg": fg, + "default_bg": bg, + "anchors": {}, + "pending_anchors": [], + "header_rows": [], } + return state + + +_MICRON_STRIP_RE = re.compile( + r"`[FB]T[0-9a-fA-F]{6}" + r"|`[FB][0-9a-fA-F]{3}" + r"|`:[A-Za-z0-9_\-]*" + r"|`[!*_=fbacrl`<>{]" +) + +def slugify_micron(text): + if text is None: return "" + stripped = _MICRON_STRIP_RE.sub("", text) + s = re.sub(r"[^A-Za-z0-9]+", "-", stripped).strip("-").lower() + return s + +class _AttrMapList(list): + """list subclass that allows attaching parser metadata like `anchors`.""" + pass + +def ensure_selected_styles(): + global SELECTED_STYLES + theme = nomadnet.NomadNetworkApp.get_shared_instance().config["textui"]["theme"] + if theme == nomadnet.ui.TextUI.THEME_DARK: SELECTED_STYLES = STYLES_DARK + else: SELECTED_STYLES = STYLES_LIGHT + +def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None): + markup = STRIP_CONTROL_RE.sub("", markup) + global LINK_CLASS + if link_class: LINK_CLASS = link_class + else: LINK_CLASS = LinkableText + + global SELECTED_STYLES + ensure_selected_styles() + + attrmaps = _AttrMapList() + + fgc = None; bgc = DEFAULT_BG + if bg_color != None: bgc = bg_color + if fg_color != None: fgc = fg_color + + state = default_state(fgc, bgc) + if anchors is None: + anchors = state["anchors"] + else: + state["anchors"] = anchors # Split entire document into lines for # processing. @@ -62,18 +130,351 @@ def markup_to_attrmaps(markup, url_delegate = None): for line in lines: if len(line) > 0: - display_widget = parse_line(line, state, url_delegate) + display_widgets = parse_line(line, state, url_delegate) else: - display_widget = urwid.Text("") - - if display_widget != None: - attrmap = urwid.AttrMap(display_widget, make_style(state)) - attrmaps.append(attrmap) + display_widgets = [urwid.Text("")] + + if display_widgets != None and len(display_widgets) != 0: + row_index = len(attrmaps) + pending = state.get("pending_anchors") or [] + if pending: + for name in pending: + if name and name not in anchors: + anchors[name] = row_index + state["pending_anchors"] = [] + + if state.get("_header_pending"): + state["header_rows"].append(row_index) + state.setdefault("header_levels", {})[row_index] = state["depth"] + cp = state.get("_collapsible_pending") + if cp is not None: + state.setdefault("collapsibles", {})[row_index] = bool(cp["collapsed"]) + state["_collapsible_pending"] = None + state["_header_pending"] = False + + row_levels = state.setdefault("row_levels", {}) + for display_widget in display_widgets: + row_levels[len(attrmaps)] = state["depth"] + attrmap = urwid.AttrMap(display_widget, make_style(state)) + attrmaps.append(attrmap) + + try: + setattr(attrmaps, "anchors", anchors) + setattr(attrmaps, "header_rows", list(state.get("header_rows", []))) + setattr(attrmaps, "header_levels", dict(state.get("header_levels", {}))) + setattr(attrmaps, "row_levels", dict(state.get("row_levels", {}))) + setattr(attrmaps, "collapsibles", dict(state.get("collapsibles", {}))) + setattr(attrmaps, "fold_glyphs", state.get("fold_glyphs") or default_fold_glyphs()) + except Exception: + pass return attrmaps +def parse_partial(line): + try: + endpos = line.find("}") + if endpos == -1: return None + else: + partial_data = line[0:endpos] + + partial_id = None + partial_components = partial_data.split("`") + if len(partial_components) == 1: + partial_url = partial_components[0] + partial_refresh = None + partial_fields = "" + elif len(partial_components) == 2: + partial_url = partial_components[0] + partial_refresh = float(partial_components[1]) + partial_fields = "" + elif len(partial_components) == 3: + partial_url = partial_components[0] + partial_refresh = float(partial_components[1]) + partial_fields = partial_components[2] + else: + partial_url = "" + partial_fields = "" + partial_refresh = None + + if partial_refresh != None and partial_refresh < 1: partial_refresh = None + + pf = partial_fields.split("|") + if len(pf) > 0: + partial_fields = pf + for f in pf: + if f.startswith("pid="): + pcs = f.split("=") + partial_id = pcs[1] + + if len(partial_url): + pile = urwid.Pile([urwid.Text(f"⧖")]) + partial_descriptor = "|".join(partial_components) + pile.partial_id = partial_id + pile.partial_hash = RNS.hexrep(RNS.Identity.full_hash(partial_descriptor.encode("utf-8")), delimit=False) + pile.partial_url = partial_url + pile.partial_fields = partial_fields + pile.partial_refresh = partial_refresh + return [pile] + + 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): + if len(lines) < 2: return None + if state["table_maxwidth"]: max_width = state["table_maxwidth"] + else: max_width = MAX_TABLE_WIDTH + if state["table_align"]: align = state["table_align"] + else: align = None + + converter = MarkdownToMicron(max_width=max_width) + micron_lines = converter.format_table_raw(lines, align=align) + + widgets = [] + for line in micron_lines: + # Disable table mode to avoid recursion + was_table_mode = state["table_mode"] + state["table_mode"] = False + + line_widgets = parse_line(line, state, url_delegate) + state["table_mode"] = was_table_mode + + if line_widgets: widgets.extend(line_widgets) + + 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): + pre_escape = False if len(line) > 0: first_char = line[0] @@ -84,14 +485,71 @@ def parse_line(line, state, url_delegate): # Only parse content if not in literal state if not state["literal"]: + collapsible_heading = False + collapsed_initial = False + if (line.startswith("`+") or line.startswith("`-")) and line[2:3] == ">": + collapsible_heading = True + collapsed_initial = line[1] == "-" + line = line[2:] + first_char = line[0] + + # Apply markup sanitization + if first_char == ">" and "`<" in line: + # Remove heading status from lines containing fields + line = line.lstrip(">") + first_char = line[0] + # Check if the command is an escape if first_char == "\\": line = line[1:] + pre_escape = True # Check for comments elif first_char == "#": + parts = line.split() + if parts and parts[0] == "#!fold": + if len(parts) >= 3: state["fold_glyphs"] = (parts[1], parts[2]) + elif len(parts) == 2: state["fold_glyphs"] = (parts[1], parts[1]) return None + # Check for tables + if line.startswith("`t"): + line = line[2:] + align = line[0] if len(line) and line[0] in ["l", "c", "r"] else None + max_width = None + if align: line = line[1:] + if len(line): + try: max_width = int(line) + except: pass + + if state["table_mode"]: + widgets = render_table(state["table_buffer"], state, url_delegate) + state["table_mode"] = False + state["table_buffer"] = [] + state["table_align"] = None + state["table_maxwidth"] = MAX_TABLE_WIDTH + return widgets + + else: + state["table_mode"] = True + state["table_buffer"] = [] + state["table_align"] = align + state["table_maxwidth"] = max_width + return None + + # Buffer the line if in table mode + if state["table_mode"]: + state["table_buffer"].append(line) + return None + + # Check for partials + elif line.startswith("`{"): + return parse_partial(line[2:]) + + # Check for images + elif line.startswith("`("): + return parse_image(line[2:], state, url_delegate) + # Check for section heading reset elif first_char == "<": state["depth"] = 0 @@ -103,7 +561,7 @@ def parse_line(line, state, url_delegate): while i < len(line) and line[i] == ">": i += 1 state["depth"] = i - + for j in range(1, i+1): wanted_style = "heading"+str(i) if wanted_style in SELECTED_STYLES: @@ -116,15 +574,25 @@ def parse_line(line, state, url_delegate): heading_style = make_style(state) output = make_output(state, line, url_delegate) - + style_to_state(latched_style, state) + slug = slugify_micron(line) + if slug: + state.setdefault("pending_anchors", []).append(slug) + state["_header_pending"] = True + if len(output) > 0: first_style = output[0][0] heading_style = first_style output.insert(0, " "*left_indent(state)) - return urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style) + if collapsible_heading: + state["_collapsible_pending"] = {"collapsed": collapsed_initial} + glyphs = state.get("fold_glyphs") or default_fold_glyphs() + return [CollapsibleHeading(output, state["align"], heading_style, + state["depth"], collapsed_initial, glyphs, url_delegate)] + return [urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)] else: return None else: @@ -134,25 +602,92 @@ def parse_line(line, state, url_delegate): elif first_char == "-": if len(line) == 2: divider_char = line[1] - else: - divider_char = "\u2500" - if state["depth"] == 0: - return urwid.Divider(divider_char) - else: - return urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state)) + # Control characters don't make sense here and otherwise crash nomadnet + if ord(divider_char) < 32: divider_char = "\u2500" + # Check if the divider char is renderable + try: urwid.Divider(divider_char).render((30,)) + except Exception as e: divider_char = "\u2500" + else: divider_char = "\u2500" - output = make_output(state, line, url_delegate) + if state["depth"] == 0: return [urwid.Divider(divider_char)] + else: return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))] + + output = make_output(state, line, url_delegate, pre_escape) if output != None: - if url_delegate != None: - text_widget = LinkableText(output, align=state["align"], delegate=url_delegate) + text_only = True + for o in output: + if not isinstance(o, tuple): + text_only = False + break + + if not text_only: + widgets = [] + for o in output: + if isinstance(o, tuple): + if url_delegate != None: + tw = LINK_CLASS(o, align=state["align"], delegate=url_delegate) + tw.in_columns = True + else: + tw = urwid.Text(o, align=state["align"]) + widgets.append((urwid.PACK, tw)) + else: + if o["type"] == "field": + fw = o["width"] + fd = o["data"] + fn = o["name"] + fs = o["style"] + frows = o.get("rows", 1) + fmask = "*" if o["masked"] else None + f = MicronField(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask, min_rows=frows) + f.field_name = fn + fa = urwid.AttrMap(f, fs) + widgets.append((fw, fa)) + elif o["type"] == "checkbox": + fn = o["name"] + fv = o["value"] + flabel = o["label"] + fs = o["style"] + fprechecked = o.get("prechecked", False) + f = urwid.CheckBox(flabel, state=fprechecked) + f.field_name = fn + f.field_value = fv + fa = urwid.AttrMap(f, fs) + widgets.append((urwid.PACK, fa)) + elif o["type"] == "radio": + fn = o["name"] + fv = o["value"] + flabel = o["label"] + fs = o["style"] + fprechecked = o.get("prechecked", False) + if "radio_groups" not in state: + state["radio_groups"] = {} + if fn not in state["radio_groups"]: + state["radio_groups"][fn] = [] + group = state["radio_groups"][fn] + f = urwid.RadioButton(group, flabel, state=fprechecked, user_data=fv) + f.field_name = fn + f.field_value = fv + fa = urwid.AttrMap(f, fs) + widgets.append((urwid.PACK, fa)) + + + + + columns_widget = FormColumns(widgets, dividechars=0) + text_widget = columns_widget + # text_widget = urwid.Text("<"+output+">", align=state["align"]) + else: - text_widget = urwid.Text(output, align=state["align"]) + if url_delegate != None: + text_widget = LINK_CLASS(output, align=state["align"], delegate=url_delegate) + else: + text_widget = urwid.Text(output, align=state["align"]) if state["depth"] == 0: - return text_widget + return [text_widget] else: - return urwid.Padding(text_widget, left=left_indent(state), right=right_indent(state)) + return [urwid.Padding(text_widget, left=left_indent(state), right=right_indent(state))] else: return None else: @@ -203,14 +738,11 @@ def make_style(state): if color[0] == "g": val = int(color[1:2]) - if val < 25: - result = "black" - elif val < 50: - result = "dark gray" - elif val < 75: - result = "light gray" - else: - result = "white" + if val < 25: result = "black" + elif val < 50: result = "dark gray" + elif val < 75: result = "light gray" + else: result = "white" + else: r = int(color[0], 16) g = int(color[1], 16) @@ -218,65 +750,43 @@ def make_style(state): if r == g == b: val = int(color[0], 16)*6 - if val < 12: - result = "black" - elif val < 50: - result = "dark gray" - elif val < 80: - result = "light gray" - else: - result = "white" + if val < 12: result = "black" + elif val < 50: result = "dark gray" + elif val < 80: result = "light gray" + else: result = "white" else: if r == b: if r > g: - if r > t: - result = "light magenta" - else: - result = "dark magenta" + if r > t: result = "light magenta" + else: result = "dark magenta" else: - if g > t: - result = "light green" - else: - result = "dark green" + if g > t: result = "light green" + else: result = "dark green" if b == g: if b > r: - if b > t: - result = "light cyan" - else: - result = "dark cyan" + if b > t: result = "light cyan" + else: result = "dark cyan" else: - if r > t: - result = "light red" - else: - result = "dark red" + if r > t: result = "light red" + else: result = "dark red" if g == r: if g > b: - if g > t: - result = "yellow" - else: - result = "brown" + if g > t: result = "yellow" + else: result = "brown" else: - if b > t: - result = "light blue" - else: - result = "dark blue" + if b > t: result = "light blue" + else: result = "dark blue" if r > g and r > b: - if r > t: - result = "light red" - else: - result = "dark red" + if r > t: result = "light red" + else: result = "dark red" if g > r and g > b: - if g > t: - result = "light green" - else: - result = "dark green" + if g > t: result = "light green" + else: result = "dark green" if b > g and b > r: - if b > t: - result = "light blue" - else: - result = "dark blue" + if b > t: result = "light blue" + else: result = "dark blue" except Exception as e: result = "default" @@ -342,12 +852,9 @@ def make_style(state): bg = state["bg_color"] format_string = "" - if bold: - format_string += ",bold" - if underline: - format_string += ",underline" - if italic: - format_string += ",italics" + if bold: format_string += ",bold" + if underline: format_string += ",underline" + if italic: format_string += ",italics" name = "micron_"+fg+"_"+bg+"_"+format_string if not name in SYNTH_STYLES: @@ -361,7 +868,7 @@ def make_style(state): return name -def make_output(state, line, url_delegate): +def make_output(state, line, url_delegate, pre_escape=False): output = [] if state["literal"]: if line == "\\`=": @@ -370,8 +877,9 @@ def make_output(state, line, url_delegate): else: part = "" mode = "text" - escape = False + escape = pre_escape skip = 0 + for i in range(0, len(line)): c = line[i] if skip > 0: @@ -386,43 +894,158 @@ def make_output(state, line, url_delegate): state["formatting"]["italic"] ^= True elif c == "F": if len(line) >= i+4: - color = line[i+1:i+4] - state["fg_color"] = color - skip = 3 + if line[i+1] == "T" and len(line) >= i+8: + color = line[i+2:i+8] + state["fg_color"] = color + skip = 7 + else: + color = line[i+1:i+4] + state["fg_color"] = color + skip = 3 elif c == "f": - state["fg_color"] = SELECTED_STYLES["plain"]["fg"] + state["fg_color"] = state["default_fg"] elif c == "B": if len(line) >= i+4: - color = line[i+1:i+4] - state["bg_color"] = color - skip = 3 + if line[i+1] == "T" and len(line) >= i+8: + color = line[i+2:i+8] + state["bg_color"] = color + skip = 7 + else: + color = line[i+1:i+4] + state["bg_color"] = color + skip = 3 elif c == "b": - state["bg_color"] = DEFAULT_BG + state["bg_color"] = state["default_bg"] elif c == "`": state["formatting"]["bold"] = False state["formatting"]["underline"] = False state["formatting"]["italic"] = False - state["fg_color"] = SELECTED_STYLES["plain"]["fg"] - state["bg_color"] = DEFAULT_BG + state["fg_color"] = state["default_fg"] + state["bg_color"] = state["default_bg"] state["align"] = state["default_align"] elif c == "c": - if state["align"] != "center": - state["align"] = "center" - else: - state["align"] = state["default_align"] + if state["align"] != "center": state["align"] = "center" elif c == "l": - if state["align"] != "left": - state["align"] = "left" - else: - state["align"] = state["default_align"] + if state["align"] != "left": state["align"] = "left" elif c == "r": - if state["align"] != "right": - state["align"] = "right" - else: - state["align"] = state["default_align"] + if state["align"] != "right": state["align"] = "right" elif c == "a": state["align"] = state["default_align"] + elif c == ":": + # Anchor declaration: `:anchor-name (terminated by any non-name char). The anchor is a zero width position narker bound to the current line by markup_to_attrmaps + name_start = i + 1 + name_end = name_start + while name_end < len(line) and (line[name_end].isalnum() or line[name_end] in "_-"): + name_end += 1 + anchor_name = line[name_start:name_end] + if anchor_name: + state.setdefault("pending_anchors", []).append(anchor_name) + skip = (name_end - i) - 1 + if skip < 0: skip = 0 + + elif c == '<': + if len(part) > 0: + output.append(make_part(state, part)) + part = "" + try: + field_start = i + 1 # position after '<' + backtick_pos = line.find('`', field_start) + if backtick_pos == -1: + pass # No '`', invalid field + else: + field_content = line[field_start:backtick_pos] + field_masked = False + field_width = 24 + field_rows = 1 + field_type = "field" + field_name = field_content + field_value = "" + field_data = "" + field_prechecked = False + + # check if field_content contains '|' + if '|' in field_content: + f_components = field_content.split('|') + field_flags = f_components[0] + field_name = f_components[1] + + # handle field type indicators + if '^' in field_flags: + field_type = "radio" + field_flags = field_flags.replace("^", "") + elif '?' in field_flags: + field_type = "checkbox" + field_flags = field_flags.replace("?", "") + elif '!' in field_flags: + field_flags = field_flags.replace("!", "") + field_masked = True + + # Handle field width + if len(field_flags) > 0: + width_flag, _, rows_flag = field_flags.partition("x") + try: + field_width = min(int(width_flag), 256) + except ValueError: + pass # Ignore invalid width + if rows_flag: + try: + field_rows = max(1, min(int(rows_flag), 256)) + except ValueError: + pass + + # Check for value and pre-checked flag + if len(f_components) > 2: + field_value = f_components[2] + else: + field_value = "" + if len(f_components) > 3: + if f_components[3] == '*': + field_prechecked = True + + else: + # No '|', so field_name is field_content + field_name = field_content + field_type = "field" + field_masked = False + field_width = 24 + field_value = "" + field_prechecked = False + + # Find the closing '>' character + field_end = line.find('>', backtick_pos) + if field_end == -1: + pass # No closing '>', invalid field + else: + field_data = line[backtick_pos+1:field_end] + + # Now, we have all field data + if field_type in ["checkbox", "radio"]: + # for checkboxes and radios, field_data is the label + output.append({ + "type": field_type, + "name": field_name, + "value": field_value if field_value else field_data, + "label": field_data, + "prechecked": field_prechecked, + "style": make_style(state) + }) + else: + # For text fields field_data is the initial text + output.append({ + "type": "field", + "name": field_name, + "width": field_width, + "rows": field_rows, + "masked": field_masked, + "data": field_data, + "style": make_style(state) + }) + skip = field_end - i + except Exception as e: + pass + + elif c == "[": endpos = line[i:].find("]") if endpos == -1: @@ -434,13 +1057,20 @@ def make_output(state, line, url_delegate): link_components = link_data.split("`") if len(link_components) == 1: link_label = "" + link_fields = "" link_url = link_data elif len(link_components) == 2: link_label = link_components[0] link_url = link_components[1] + link_fields = "" + elif len(link_components) == 3: + link_label = link_components[0] + link_url = link_components[1] + link_fields = link_components[2] else: link_url = "" link_label = "" + link_fields = "" if len(link_url) != 0: if link_label == "": @@ -467,12 +1097,15 @@ def make_output(state, line, url_delegate): orig_spec = speclist[4] if url_delegate != None: - linkspec = LinkSpec(link_url, orig_spec) + linkspec = LinkSpec(link_url, orig_spec, cm=cm) + if link_fields != "": + lf = link_fields.split("|") + if len(lf) > 0: + linkspec.link_fields = lf + output.append((linkspec, link_label)) else: - output.append(make_part(state, link_label)) - - + output.append(make_part(state, link_label)) mode = "text" if len(part) > 0: @@ -480,7 +1113,11 @@ def make_output(state, line, url_delegate): elif mode == "text": if c == "\\": - escape = True + if escape: + part += c + escape = False + else: + escape = True elif c == "`": if escape: part += c @@ -492,6 +1129,7 @@ def make_output(state, line, url_delegate): part = "" else: part += c + escape = False if i == len(line)-1: if len(part) > 0: @@ -504,10 +1142,11 @@ def make_output(state, line, url_delegate): class LinkSpec(urwid.AttrSpec): - def __init__(self, link_target, orig_spec): + def __init__(self, link_target, orig_spec, cm=256): self.link_target = link_target + self.link_fields = None - urwid.AttrSpec.__init__(self, orig_spec.foreground, orig_spec.background) + super().__init__(orig_spec.foreground, orig_spec.background, colors=cm) class LinkableText(urwid.Text): @@ -517,16 +1156,19 @@ class LinkableText(urwid.Text): signals = ["click", "change"] def __init__(self, text, align=None, cursor_position=0, delegate=None): - self.__super.__init__(text, align=align) + super().__init__(text, align=align) self.delegate = delegate self._cursor_position = 0 - self.key_timeout = 3 + self._cursor_promised = None + self._cursor_promised_at = 0 + self.key_timeout = 2 + self.in_columns = False if self.delegate != None: self.delegate.last_keypress = 0 - def handle_link(self, link_target): + def handle_link(self, link_target, link_fields): if self.delegate != None: - self.delegate.handle_link(link_target) + self.delegate.handle_link(link_target, link_fields) def find_next_part_pos(self, pos, part_positions): for position in part_positions: @@ -558,7 +1200,7 @@ class LinkableText(urwid.Text): if item != None: if isinstance(item, LinkSpec): if self.delegate != None: - self.delegate.marked_link(item.link_target) + self.delegate.marked_link(item.link_target, item.link_fields) else: if self.delegate != None: self.delegate.marked_link(None) @@ -584,7 +1226,7 @@ class LinkableText(urwid.Text): item = self.find_item_at_pos(self._cursor_position) if item != None: if isinstance(item, LinkSpec): - self.handle_link(item.link_target) + self.handle_link(item.link_target, item.link_fields) elif key == "up": self._cursor_position = 0 @@ -597,16 +1239,23 @@ class LinkableText(urwid.Text): elif key == "right": old = self._cursor_position self._cursor_position = self.find_next_part_pos(self._cursor_position, part_positions) + if self._cursor_position == old: - self._cursor_position = 0 - return "down" + if self.in_columns: + return "right" + else: + self._cursor_position = 0 + return "down" self._invalidate() elif key == "left": if self._cursor_position > 0: - self._cursor_position = self.find_prev_part_pos(self._cursor_position, part_positions) - self._invalidate() + if self.in_columns: + return "left" + else: + self._cursor_position = self.find_prev_part_pos(self._cursor_position, part_positions) + self._invalidate() else: if self.delegate != None: @@ -618,65 +1267,78 @@ class LinkableText(urwid.Text): def kt_event(self, loop, user_data): self._invalidate() + def cursor_visible(self): + return self.delegate == None or time.time() < self.delegate.last_keypress+self.key_timeout + def render(self, size, focus=False): - now = time.time() - c = self.__super.render(size, focus) - - if focus and (self.delegate == None or now < self.delegate.last_keypress+self.key_timeout): - c = urwid.CompositeCanvas(c) - c.cursor = self.get_cursor_coords(size) - if self.delegate != None: - self.peek_link() - + c = super().render(size, focus) + if focus: + if time.time() < self._cursor_promised_at+0.5: + coords = self._cursor_promised + else: + coords = self.get_cursor_coords(size) + if coords != None: + c = urwid.CompositeCanvas(c) + c.cursor = coords + if self.delegate != None: + self.peek_link() return c def get_cursor_coords(self, size): - if self._cursor_position > len(self.text): - return None - - (maxcol,) = size - trans = self.get_line_translation(maxcol) - x, y = calc_coords(self.text, trans, self._cursor_position) - if maxcol <= x: - return None - return x, y + self._cursor_promised_at = time.time() + self._cursor_promised = None + if not self.cursor_visible() or self._cursor_position > len(self.text): + return None + (maxcol,) = size + trans = self.get_line_translation(maxcol) + x, y = calc_coords(self.text, trans, self._cursor_position) + if maxcol <= x: + return None + self._cursor_promised = (x, y) + return x, y def mouse_event(self, size, event, button, x, y, focus): - if button != 1 or not is_mouse_press(event): - return False - else: - (maxcol,) = size - translation = self.get_line_translation(maxcol) - line_offset = 0 - - if self.align == "center": - line_offset = translation[y][1][1]-translation[y][0][0] - if x < translation[y][0][0]: - x = translation[y][0][0] - - if x > translation[y][1][0]+translation[y][0][0]: - x = translation[y][1][0]+translation[y][0][0] - - elif self.align == "right": - line_offset = translation[y][1][1]-translation[y][0][0] - if x < translation[y][0][0]: - x = translation[y][0][0] - + try: + if button != 1 or not is_mouse_press(event): + return False else: - line_offset = translation[y][0][1] - if x > translation[y][0][0]: - x = translation[y][0][0] + (maxcol,) = size + translation = self.get_line_translation(maxcol) + line_offset = 0 - pos = line_offset+x + if self.align == "center": + line_offset = translation[y][1][1]-translation[y][0][0] + if x < translation[y][0][0]: + x = translation[y][0][0] - self._cursor_position = pos - item = self.find_item_at_pos(self._cursor_position) + if x > translation[y][1][0]+translation[y][0][0]: + x = translation[y][1][0]+translation[y][0][0] - if item != None: - if isinstance(item, LinkSpec): - self.handle_link(item.link_target) + elif self.align == "right": + line_offset = translation[y][1][1]-translation[y][0][0] + if x < translation[y][0][0]: + x = translation[y][0][0] - self._invalidate() - self._emit("change") + else: + line_offset = translation[y][0][1] + if x > translation[y][0][0]: + x = translation[y][0][0] + + pos = line_offset+x + + self._cursor_position = pos + item = self.find_item_at_pos(self._cursor_position) + + if item != None: + if isinstance(item, LinkSpec): + self.handle_link(item.link_target, item.link_fields) + + self._invalidate() + self._emit("change") + + return True - return True \ No newline at end of file + except Exception as e: + return False + +LINK_CLASS = LinkableText \ No newline at end of file diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index acfd4cf..11c4e54 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -2,25 +2,51 @@ import RNS import urwid import nomadnet import time +import threading from datetime import datetime from nomadnet.Directory import DirectoryEntry from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name from .Browser import Browser +from .ReadlineEdit import ReadlineEdit +from .Helpers import qr_ascii class NetworkDisplayShortcuts(): def __init__(self, app): self.app = app g = app.ui.glyphs - self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL"), "shortcutbar") - # "[C-"+g["arrow_u"]+g["arrow_d"]+"] Navigate Lists" + shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-g] Fullscreen" + self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") +class BrowserShortcuts(): + def __init__(self, app, browser): + self.app = app + self.browser = browser + g = app.ui.glyphs + + shortcut_text = "[C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL " + if app.config["textui"]["clipboard_copy"]: shortcut_text += "[C-y] Copy " + shortcut_text += "[C-g] Fullscreen [C-s/b] Save Node" + if browser.image_rendering_supported: shortcut_text += " [C-l] Load Images [C-x] Reload Images" + self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") class DialogLineBox(urwid.LineBox): def keypress(self, size, key): if key == "esc": - self.delegate.update_conversation_list() + delegate = getattr(self, "delegate", None) + if delegate is not None and hasattr(delegate, "update_conversation_list"): + delegate.update_conversation_list() + elif delegate is not None and getattr(delegate, "parent", None) is not None: + try: + delegate.dialog_open = False + options = delegate.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + delegate.parent.left_pile.contents[1] = (type(delegate)(delegate.app, delegate.parent), options) + except Exception: + pass + return None else: return super(DialogLineBox, self).keypress(size, key) @@ -51,6 +77,13 @@ class ListEntry(urwid.Text): class AnnounceInfo(urwid.WidgetWrap): + def keypress(self, size, key): + if key == "esc": + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) + else: + return super(AnnounceInfo, self).keypress(size, key) + def __init__(self, announce, parent, app): self.app = nomadnet.NomadNetworkApp.get_shared_instance() self.parent = self.app.ui.main_display.sub_displays.network_display @@ -64,15 +97,21 @@ class AnnounceInfo(urwid.WidgetWrap): trust_str = "" display_str = self.app.directory.simplest_display_str(source_hash) addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" - is_node = announce[3] + info_type = announce[3] - if is_node: - type_string = "Node " + g["node"] - else: + is_node = False + is_pn = False + if info_type == "node" or info_type == True: + type_string = "NomadNet Node " + g["node"] + is_node = True + elif info_type == "pn": + type_string = "LXMF Propagation Node " + g["sent"] + is_pn = True + elif info_type == "peer" or info_type == False: type_string = "Peer " + g["peer"] try: - data_str = announce[2].decode("utf-8") + data_str = strip_modifiers(announce[2].decode("utf-8")) data_style = "" if trust_level != DirectoryEntry.TRUSTED and len(data_str) > 32: data_str = data_str[:32]+" [...]" @@ -103,12 +142,10 @@ class AnnounceInfo(urwid.WidgetWrap): style = "list_untrusted" def show_announce_stream(sender): - options = self.parent.left_pile.options(height_type="weight", height_amount=1) + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) def connect(sender): - # TODO: Remove when new mitigation has been tested - # self.app.ui.main_display.request_redraw(extra_delay=0.75) self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) show_announce_stream(None) @@ -120,6 +157,9 @@ class AnnounceInfo(urwid.WidgetWrap): if is_node: node_ident = RNS.Identity.recall(source_hash) + if not node_ident: + raise KeyError("Could not recall identity for selected node") + op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) op_str = self.app.directory.simplest_display_str(op_hash) @@ -128,12 +168,12 @@ class AnnounceInfo(urwid.WidgetWrap): if is_node: try: existing_conversations = nomadnet.Conversation.conversation_list(self.app) - + source_hash_text = RNS.hexrep(op_hash, delimit=False) display_name = op_str if not source_hash_text in [c[0] for c in existing_conversations]: - entry = DirectoryEntry(source_hash, display_name, trust_level) + entry = DirectoryEntry(op_hash, display_name, trust_level) self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) @@ -149,7 +189,7 @@ class AnnounceInfo(urwid.WidgetWrap): show_announce_stream(None) try: existing_conversations = nomadnet.Conversation.conversation_list(self.app) - + source_hash_text = RNS.hexrep(source_hash, delimit=False) display_name = data_str @@ -166,53 +206,89 @@ class AnnounceInfo(urwid.WidgetWrap): except Exception as e: RNS.log("Error while starting conversation from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) + def use_pn(sender): + show_announce_stream(None) + try: + self.app.set_user_selected_propagation_node(source_hash) + except Exception as e: + RNS.log("Error while setting active propagation node from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) + if is_node: - type_button = ("weight", 0.45, urwid.Button("Connect", on_press=connect)) - msg_button = ("weight", 0.45, urwid.Button("Msg Op", on_press=msg_op)) - save_button = ("weight", 0.45, urwid.Button("Save", on_press=save_node)) + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Connect", on_press=connect)) + msg_button = (urwid.WEIGHT, 0.45, urwid.Button("Msg Op", on_press=msg_op)) + save_button = (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_node)) + elif is_pn: + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Use as default", on_press=use_pn)) + save_button = None else: - type_button = ("weight", 0.45, urwid.Button("Converse", on_press=converse)) + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Converse", on_press=converse)) save_button = None if is_node: - button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_announce_stream)), ("weight", 0.1, urwid.Text("")), type_button, ("weight", 0.1, urwid.Text("")), msg_button, ("weight", 0.1, urwid.Text("")), save_button]) + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=show_announce_stream)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + type_button, + (urwid.WEIGHT, 0.1, urwid.Text("")), + msg_button, + (urwid.WEIGHT, 0.1, urwid.Text("")), + save_button, + ]) else: - button_columns = urwid.Columns([("weight", 0.45, urwid.Button("Back", on_press=show_announce_stream)), ("weight", 0.1, urwid.Text("")), type_button]) + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=show_announce_stream)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + type_button, + ]) - pile_widgets = [ - urwid.Text("Time : "+ts_string, align="left"), - urwid.Text("Addr : "+addr_str, align="left"), - urwid.Text("Type : "+type_string, align="left"), - urwid.Text("Name : "+display_str, align="left"), - urwid.Text(["Trust : ", (style, trust_str)], align="left"), - urwid.Divider(g["divider1"]), - urwid.Text(["Announce Data: \n", (data_style, data_str)], align="left"), - urwid.Divider(g["divider1"]), - button_columns - ] + pile_widgets = [] - if is_node: - operator_entry = urwid.Text("Oprtr : "+op_str, align="left") - pile_widgets.insert(4, operator_entry) + if is_pn: + pile_widgets = [ + urwid.Text("Time : "+ts_string, align=urwid.LEFT), + urwid.Text("Addr : "+addr_str, align=urwid.LEFT), + urwid.Text("Type : "+type_string, align=urwid.LEFT), + urwid.Divider(g["divider1"]), + button_columns + ] + + else: + pile_widgets = [ + urwid.Text("Time : "+ts_string, align=urwid.LEFT), + urwid.Text("Addr : "+addr_str, align=urwid.LEFT), + urwid.Text("Type : "+type_string, align=urwid.LEFT), + urwid.Text("Name : "+display_str, align=urwid.LEFT), + urwid.Text(["Trust : ", (style, trust_str)], align=urwid.LEFT), + urwid.Divider(g["divider1"]), + urwid.Text(["Announce Data: \n", (data_style, data_str)], align=urwid.LEFT), + urwid.Divider(g["divider1"]), + button_columns + ] + + if is_node: + operator_entry = urwid.Text("Oprtr : "+op_str, align=urwid.LEFT) + pile_widgets.insert(4, operator_entry) pile = urwid.Pile(pile_widgets) - self.display_widget = urwid.Filler(pile, valign="top", height="pack") + self.display_widget = urwid.Filler(pile, valign=urwid.TOP, height=urwid.PACK) - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Announce Info")) + super().__init__(urwid.LineBox(self.display_widget, title="Announce Info")) class AnnounceStreamEntry(urwid.WidgetWrap): - def __init__(self, app, announce): + def __init__(self, app, announce, delegate, show_destination=False): full_time_format = "%Y-%m-%d %H:%M:%S" date_time_format = "%Y-%m-%d" time_time_format = "%H:%M:%S" short_time_format = "%Y-%m-%d %H:%M" + date_only_format = "%Y-%m-%d" timestamp = announce[0] source_hash = announce[1] - is_node = announce[3] + announce_type = announce[3] self.app = app + self.delegate = delegate self.timestamp = timestamp time_format = app.time_format dt = datetime.fromtimestamp(self.timestamp) @@ -222,10 +298,22 @@ class AnnounceStreamEntry(urwid.WidgetWrap): if dt.strftime(date_time_format) == dtn.strftime(date_time_format): ts_string = dt.strftime(time_time_format) else: - ts_string = dt.strftime(short_time_format) + ts_string = dt.strftime(date_only_format) trust_level = self.app.directory.trust_level(source_hash) - display_str = self.app.directory.simplest_display_str(source_hash) + + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + + if show_destination: + display_str = RNS.hexrep(source_hash, delimit=False) + else: + try: display_str = san(announce[2].decode("utf-8")) + except: display_str = self.app.directory.simplest_display_str(source_hash) + + if not display_str: display_str = RNS.prettyhexrep(source_hash) + if len(display_str) > 34: display_str = display_str[:34] + "…" if trust_level == DirectoryEntry.UNTRUSTED: symbol = g["cross"] @@ -248,26 +336,81 @@ class AnnounceStreamEntry(urwid.WidgetWrap): style = "list_untrusted" focus_style = "list_focus_untrusted" - if is_node: + if announce_type == "node" or announce_type == True: type_symbol = g["node"] - else: + elif announce_type == "peer" or announce_type == False: type_symbol = g["peer"] + elif announce_type == "pn": + type_symbol = g["sent"] widget = ListEntry(ts_string+" "+type_symbol+" "+display_str) urwid.connect_signal(widget, "click", self.display_announce, announce) self.display_widget = urwid.AttrMap(widget, style, focus_style) - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def display_announce(self, event, announce): + try: parent = self.app.ui.main_display.sub_displays.network_display info_widget = AnnounceInfo(announce, parent, self.app) - options = parent.left_pile.options(height_type="weight", height_amount=1) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) parent.left_pile.contents[0] = (info_widget, options) + except KeyError as e: + def dismiss_dialog(sender): + self.delegate.parent.close_list_dialogs() + + def confirmed(sender): + def close_req(sender): + self.delegate.parent.close_list_dialogs() + + dialog_pile.contents[0] = (urwid.Text("\nKeys requested from network\n", align=urwid.CENTER), options) + RNS.Transport.request_path(announce[1]) + + confirmed_button = urwid.Button("Request keys", on_press=confirmed) + + dialog_pile = urwid.Pile([ + urwid.Text( + "The keys for the announced destination could not be recalled. " + "You can wait for an announce to arrive, or request the keys from the network.\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.45, confirmed_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Close", on_press=dismiss_dialog)), + ]) + ]) + + dialog = ListDialogLineBox( + dialog_pile, + title="Keys Unknown" + ) + confirmed_button.dialog_pile = dialog_pile + dialog.delegate = self.delegate.parent + bottom = self.delegate + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.delegate.parent.left_pile.options(urwid.WEIGHT, 1) + self.delegate.parent.left_pile.contents[0] = (overlay, options) + def timestamp(self): return self.timestamp +class TabButton(urwid.Button): + button_left = urwid.Text("[") + button_right = urwid.Text("]") + class AnnounceStream(urwid.WidgetWrap): def __init__(self, app, parent): self.app = app @@ -276,9 +419,33 @@ class AnnounceStream(urwid.WidgetWrap): self.timeout = self.app.config["textui"]["animation_interval"]*2 self.ilb = None self.no_content = True - + self.current_tab = "nodes" + self.show_destination = False + self.search_text = "" + self.added_entries = [] self.widget_list = [] + + self.tab_nodes = TabButton("Nodes (0)", on_press=self.show_nodes_tab) + self.tab_peers = TabButton("Peers (0)", on_press=self.show_peers_tab) + self.tab_pn = TabButton("Propagation Nodes (0)", on_press=self.show_pn_tab) + + self.tab_bar = urwid.Columns([ + ('weight', 1, self.tab_nodes), + ('weight', 1, self.tab_peers), + ('weight', 3, self.tab_pn), + ], dividechars=1) + + self.search_edit = ReadlineEdit(caption="Search: ") + urwid.connect_signal(self.search_edit, 'change', self.on_search_change) + + self.display_toggle = TabButton("Show: Name", on_press=self.toggle_display_mode) + + self.filter_bar = urwid.Columns([ + ('weight', 2, self.search_edit), + ('weight', 1, self.display_toggle), + ], dividechars=1) + self.update_widget_list() self.ilb = ExceptionHandlingListBox( @@ -289,21 +456,41 @@ class AnnounceStream(urwid.WidgetWrap): #highlight_offFocus="list_off_focus" ) - self.display_widget = self.ilb - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Announce Stream")) + self.pile = urwid.Pile([ + ('pack', self.tab_bar), + ('pack', self.filter_bar), + ('weight', 1, self.ilb), + ]) + + self.display_widget = self.pile + super().__init__(urwid.LineBox(self.display_widget, title="Announce Stream")) def keypress(self, size, key): - if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + if key == "up" and self.pile.focus == self.tab_bar: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() - + return super(AnnounceStream, self).keypress(size, key) + def on_search_change(self, widget, text): + self.search_text = text.lower() + self.update_widget_list() + + def toggle_display_mode(self, button): + self.show_destination = not self.show_destination + if self.show_destination: + self.display_toggle.set_label("Show: Dest") + else: + self.display_toggle.set_label("Show: Name") + self.update_widget_list() + def delete_selected_entry(self): - if self.ilb.get_selected_item() != None: - self.app.directory.remove_announce_with_timestamp(self.ilb.get_selected_item().original_widget.timestamp) - self.rebuild_widget_list() + sel = self.ilb.get_selected_item() + if sel != None and hasattr(sel, "original_widget") and sel.original_widget: + if hasattr(sel.original_widget, "timestamp"): + self.app.directory.remove_announce_with_timestamp(sel.original_widget.timestamp) + self.rebuild_widget_list() def rebuild_widget_list(self): self.no_content = True @@ -312,28 +499,65 @@ class AnnounceStream(urwid.WidgetWrap): self.update_widget_list() def update_widget_list(self): + self.widget_list = [] new_entries = [] + + node_count = 0 + peer_count = 0 + pn_count = 0 + for e in self.app.directory.announce_stream: - if not e[0] in self.added_entries: - self.added_entries.insert(0, e[0]) - new_entries.insert(0, e) + announce_type = e[3] + + if self.search_text: + try: + announce_data = e[2].decode("utf-8").lower() + except: + announce_data = "" + if self.search_text not in announce_data: + continue + + if announce_type == "node" or announce_type == True: + node_count += 1 + if self.current_tab == "nodes": new_entries.append(e) + + elif announce_type == "peer" or announce_type == False: + peer_count += 1 + if self.current_tab == "peers": new_entries.append(e) + + elif announce_type == "pn": + pn_count += 1 + if self.current_tab == "pn": new_entries.append(e) for e in new_entries: - nw = AnnounceStreamEntry(self.app, e) + nw = AnnounceStreamEntry(self.app, e, self, show_destination=self.show_destination) nw.timestamp = e[0] - self.widget_list.insert(0, nw) + self.widget_list.append(nw) if len(new_entries) > 0: self.no_content = False - if self.ilb != None: - self.ilb.set_body(self.widget_list) else: - if len(self.widget_list) == 0: - self.no_content = True - - if self.ilb != None: - self.ilb.set_body(self.widget_list) + self.no_content = True + self.widget_list = [urwid.Text(f"No {self.current_tab} announces", align='center')] + self.tab_nodes.set_label(f"Nodes ({node_count})") + self.tab_peers.set_label(f"Peers ({peer_count})") + self.tab_pn.set_label(f"Propagation Nodes ({pn_count})") + + if self.ilb: + self.ilb.set_body(self.widget_list) + + def show_nodes_tab(self, button): + self.current_tab = "nodes" + self.update_widget_list() + + def show_peers_tab(self, button): + self.current_tab = "peers" + self.update_widget_list() + + def show_pn_tab(self, button): + self.current_tab = "pn" + self.update_widget_list() def list_selection(self, arg1, arg2): pass @@ -387,6 +611,13 @@ class ListDialogLineBox(urwid.LineBox): return super(ListDialogLineBox, self).keypress(size, key) class KnownNodeInfo(urwid.WidgetWrap): + def keypress(self, size, key): + if key == "esc": + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) + else: + return super(KnownNodeInfo, self).keypress(size, key) + def __init__(self, node_hash): self.app = nomadnet.NomadNetworkApp.get_shared_instance() self.parent = self.app.ui.main_display.sub_displays.network_display @@ -399,20 +630,31 @@ class KnownNodeInfo(urwid.WidgetWrap): trust_level = self.app.directory.trust_level(source_hash) trust_str = "" node_entry = self.app.directory.find(source_hash) + sort_str = self.app.directory.sort_rank(source_hash) + if sort_str == None: + sort_str = "None" + else: + sort_str = str(sort_str) + if node_entry == None: display_str = self.app.directory.simplest_display_str(source_hash) else: - display_str = node_entry.display_name + display_str = strip_modifiers(node_entry.display_name) addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" + if display_str == None: + display_str = addr_str + + pn_hash = RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident) + if node_ident != None: - lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident)) + lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(pn_hash) else: lxmf_addr_str = "No associated Propagation Node known" - type_string = "Node " + g["node"] + type_string = "NomadNet Node " + g["node"] if trust_level == DirectoryEntry.UNTRUSTED: trust_str = "Untrusted" @@ -453,7 +695,8 @@ class KnownNodeInfo(urwid.WidgetWrap): r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected) r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) - e_name = urwid.Edit(caption="Name : ",edit_text=display_str) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_str) + e_sort = ReadlineEdit(caption="Sort Rank : ",edit_text=sort_str) node_ident = RNS.Identity.recall(source_hash) op_hash = None @@ -465,7 +708,7 @@ class KnownNodeInfo(urwid.WidgetWrap): op_str = "Unknown" def show_known_nodes(sender): - options = self.parent.left_pile.options(height_type="weight", height_amount=1) + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) def connect(sender): @@ -477,12 +720,12 @@ class KnownNodeInfo(urwid.WidgetWrap): if node_ident != None: try: existing_conversations = nomadnet.Conversation.conversation_list(self.app) - + source_hash_text = RNS.hexrep(op_hash, delimit=False) display_name = op_str if not source_hash_text in [c[0] for c in existing_conversations]: - entry = DirectoryEntry(source_hash, display_name, trust_level) + entry = DirectoryEntry(op_hash, display_name, trust_level) self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) @@ -500,44 +743,57 @@ class KnownNodeInfo(urwid.WidgetWrap): def ident_change(sender, userdata): pass - propagation_node_checkbox = urwid.CheckBox("Use as default propagation node", state=(self.app.get_user_selected_propagation_node() == source_hash), on_state_change=pn_change) + propagation_node_checkbox = urwid.CheckBox("Use as default propagation node", state=(self.app.get_user_selected_propagation_node() == pn_hash), on_state_change=pn_change) connect_identify_checkbox = urwid.CheckBox("Identify when connecting", state=self.app.directory.should_identify_on_connect(source_hash), on_state_change=ident_change) def save_node(sender): if self.pn_changed: if propagation_node_checkbox.get_state(): - self.app.set_user_selected_propagation_node(source_hash) + self.app.set_user_selected_propagation_node(pn_hash) else: self.app.set_user_selected_propagation_node(None) trust_level = DirectoryEntry.UNTRUSTED if r_unknown.get_state() == True: trust_level = DirectoryEntry.UNKNOWN - + if r_trusted.get_state() == True: trust_level = DirectoryEntry.TRUSTED display_str = e_name.get_edit_text() + sort_rank = e_sort.get_edit_text() + try: + if int(sort_rank) >= 0: + sort_rank = int(sort_rank) + else: + sort_rank = None + except: + sort_rank = None - node_entry = DirectoryEntry(source_hash, display_name=display_str, trust_level=trust_level, hosts_node=True, identify_on_connect=connect_identify_checkbox.get_state()) + node_entry = DirectoryEntry(source_hash, display_name=display_str, trust_level=trust_level, hosts_node=True, identify_on_connect=connect_identify_checkbox.get_state(), sort_rank=sort_rank) self.app.directory.remember(node_entry) self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + if trust_level == DirectoryEntry.TRUSTED: + self.app.autoselect_propagation_node() + show_known_nodes(None) - back_button = ("weight", 0.2, urwid.Button("Back", on_press=show_known_nodes)) - connect_button = ("weight", 0.2, urwid.Button("Connect", on_press=connect)) - save_button = ("weight", 0.2, urwid.Button("Save", on_press=save_node)) - msg_button = ("weight", 0.2, urwid.Button("Msg Op", on_press=msg_op)) - bdiv = ("weight", 0.02, urwid.Text("")) + back_button = (urwid.WEIGHT, 0.2, urwid.Button("Back", on_press=show_known_nodes)) + connect_button = (urwid.WEIGHT, 0.2, urwid.Button("Connect", on_press=connect)) + save_button = (urwid.WEIGHT, 0.2, urwid.Button("Save", on_press=save_node)) + msg_button = (urwid.WEIGHT, 0.2, urwid.Button("Msg Op", on_press=msg_op)) + bdiv = (urwid.WEIGHT, 0.02, urwid.Text("")) button_columns = urwid.Columns([back_button, bdiv, connect_button, bdiv, msg_button, bdiv, save_button]) pile_widgets = [ - urwid.Text("Type : "+type_string, align="left"), + urwid.Text("Type : "+type_string, align=urwid.LEFT), e_name, - urwid.Text("Node Addr : "+addr_str, align="left"), + urwid.Text("Node Addr : "+addr_str, align=urwid.LEFT), + e_sort, urwid.Divider(g["divider1"]), - urwid.Text(lxmf_addr_str, align="center"), + urwid.Text(lxmf_addr_str, align=urwid.CENTER), urwid.Divider(g["divider1"]), propagation_node_checkbox, connect_identify_checkbox, @@ -549,7 +805,7 @@ class KnownNodeInfo(urwid.WidgetWrap): button_columns ] - operator_entry = urwid.Text("Operator : "+op_str, align="left") + operator_entry = urwid.Text("Operator : "+op_str, align=urwid.LEFT) pile_widgets.insert(3, operator_entry) hops = RNS.Transport.hops_to(source_hash) @@ -563,14 +819,18 @@ class KnownNodeInfo(urwid.WidgetWrap): else: hops_str = "Unknown" - operator_entry = urwid.Text("Distance : "+hops_str, align="left") + operator_entry = urwid.Text("Distance : "+hops_str, align=urwid.LEFT) pile_widgets.insert(4, operator_entry) pile = urwid.Pile(pile_widgets) - self.display_widget = urwid.Filler(pile, valign="top", height="pack") + pile.focus_position = len(pile.contents)-1 + button_columns.focus_position = 0 - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Node Info")) + + self.display_widget = urwid.Filler(pile, valign=urwid.TOP, height=urwid.PACK) + + super().__init__(urwid.LineBox(self.display_widget, title="Node Info")) # Yes, this is weird. There is a bug in Urwid/ILB that causes @@ -583,9 +843,11 @@ class ExceptionHandlingListBox(IndicativeListBox): except Exception as e: if key == "up": - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "down": - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.left_pile.set_focus(1) + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.left_pile.focus_position = 1 + else: + RNS.log("An error occurred while processing an interface event. The contained exception was: "+str(e), RNS.LOG_ERROR) class KnownNodes(urwid.WidgetWrap): @@ -595,7 +857,7 @@ class KnownNodes(urwid.WidgetWrap): g = self.app.ui.glyphs self.widget_list = self.make_node_widgets() - + self.ilb = ExceptionHandlingListBox( self.widget_list, on_selection_change=self.node_list_selection, @@ -610,17 +872,26 @@ class KnownNodes(urwid.WidgetWrap): else: self.no_content = True widget_style = "inactive_text" - self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no nodes are known\n\n"), align="center")]) - self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") + self.pile = urwid.Pile([ + urwid.Text(("warning_text", g["info"]+"\n"), align=urwid.CENTER), + SelectText( + ( + "warning_text", + "Currently, no nodes are saved\n\nCtrl+L to view the announce stream\n\n", + ), + align=urwid.CENTER, + ), + ]) + self.display_widget = urwid.Filler(self.pile, valign=urwid.TOP, height=urwid.PACK) - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style)) + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style)) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() - + return super(KnownNodes, self).keypress(size, key) @@ -644,27 +915,27 @@ class KnownNodes(urwid.WidgetWrap): def show_info(sender): info_widget = KnownNodeInfo(source_hash) - options = parent.left_pile.options(height_type="weight", height_amount=1) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) parent.left_pile.contents[0] = (info_widget, options) dialog = ListDialogLineBox( urwid.Pile([ - urwid.Text("Connect to node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align="center"), + urwid.Text("Connect to node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align=urwid.CENTER), urwid.Columns([ - ("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog)), - ("weight", 0.1, urwid.Text("")), - ("weight", 0.45, urwid.Button("Info", on_press=show_info))]) + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Info", on_press=show_info))]) ]), title="?" ) dialog.delegate = self.delegate bottom = self - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) - options = self.delegate.left_pile.options("weight", 1) + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) self.delegate.left_pile.contents[0] = (overlay, options) def delete_selected_entry(self): @@ -683,16 +954,29 @@ class KnownNodes(urwid.WidgetWrap): dialog = ListDialogLineBox( urwid.Pile([ - urwid.Text("Delete Node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align="center"), - urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) + urwid.Text("Delete Node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) ]), title="?" ) dialog.delegate = self.delegate bottom = self - overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) - options = self.delegate.left_pile.options("weight", 1) + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) self.delegate.left_pile.contents[0] = (overlay, options) @@ -726,7 +1010,7 @@ class NodeEntry(urwid.WidgetWrap): g = self.app.ui.glyphs trust_level = self.app.directory.trust_level(source_hash) - display_str = self.app.directory.simplest_display_str(source_hash) + display_str = self.app.directory.simplest_display_str(source_hash, san=False) if trust_level == DirectoryEntry.UNTRUSTED: symbol = g["cross"] @@ -750,13 +1034,13 @@ class NodeEntry(urwid.WidgetWrap): focus_style = "list_focus_untrusted" type_symbol = g["node"] - + widget = ListEntry(type_symbol+" "+display_str) urwid.connect_signal(widget, "click", delegate.connect_node, node) self.display_widget = urwid.AttrMap(widget, style, focus_style) self.display_widget.source_hash = source_hash - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) class AnnounceTime(urwid.WidgetWrap): @@ -767,7 +1051,7 @@ class AnnounceTime(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_time() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_time(self): self.last_announce_string = "Never" @@ -799,14 +1083,14 @@ class NodeAnnounceTime(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_time() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_time(self): self.last_announce_string = "Never" if self.app.peer_settings["node_last_announce"] != None: self.last_announce_string = pretty_date(int(self.app.peer_settings["node_last_announce"])) - self.display_widget.set_text("Last Announce : "+self.last_announce_string) + self.display_widget.set_text("Last Announce : "+self.last_announce_string) def update_time_callback(self, loop=None, user_data=None): self.update_time() @@ -830,14 +1114,14 @@ class NodeActiveConnections(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" if self.app.node != None: self.stat_string = str(len(self.app.node.destination.links)) - self.display_widget.set_text("Connections Now : "+self.stat_string) + self.display_widget.set_text("Connected Now : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -853,6 +1137,48 @@ class NodeActiveConnections(urwid.WidgetWrap): def stop(self): self.started = False +class NodeStorageStats(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + super().__init__(self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None and not self.app.disable_propagation: + + limit = self.app.message_router.message_storage_limit + used = self.app.message_router.message_storage_size() + + if limit != None and used != None: + pct = round((used/limit)*100, 1) + pct_str = str(pct)+"%, " + limit_str = " of "+RNS.prettysize(limit) + else: + limit_str = "" + pct_str = "" + + self.stat_string = pct_str+RNS.prettysize(used)+limit_str + + self.display_widget.set_text("LXMF Storage : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False class NodeTotalConnections(urwid.WidgetWrap): def __init__(self, app): @@ -862,14 +1188,14 @@ class NodeTotalConnections(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" if self.app.node != None: self.stat_string = str(self.app.peer_settings["node_connects"]) - self.display_widget.set_text("Total Connections : "+self.stat_string) + self.display_widget.set_text("Total Connects : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -894,14 +1220,14 @@ class NodeTotalPages(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" if self.app.node != None: self.stat_string = str(self.app.peer_settings["served_page_requests"]) - self.display_widget.set_text("Served Pages : "+self.stat_string) + self.display_widget.set_text("Served Pages : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -926,14 +1252,14 @@ class NodeTotalFiles(urwid.WidgetWrap): self.display_widget = urwid.Text("") self.update_stat() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update_stat(self): self.stat_string = "None" if self.app.node != None: self.stat_string = str(self.app.peer_settings["served_file_requests"]) - self.display_widget.set_text("Served Files : "+self.stat_string) + self.display_widget.set_text("Served Files : "+self.stat_string) def update_stat_callback(self, loop=None, user_data=None): self.update_stat() @@ -964,7 +1290,7 @@ class LocalPeer(urwid.WidgetWrap): t_id = urwid.Text("LXMF Addr : "+RNS.prettyhexrep(self.app.lxmf_destination.hash)) i_id = urwid.Text("Identity : "+RNS.prettyhexrep(self.app.identity.hash)) - e_name = urwid.Edit(caption="Name : ", edit_text=display_name) + e_name = ReadlineEdit(caption="Name : ", edit_text=display_name) def save_query(sender): def dismiss_dialog(sender): @@ -975,45 +1301,45 @@ class LocalPeer(urwid.WidgetWrap): dialog = DialogLineBox( urwid.Pile([ - urwid.Text("\n\n\nSaved\n\n", align="center"), + urwid.Text("\n\n\nSaved\n\n", align=urwid.CENTER), urwid.Button("OK", on_press=dismiss_dialog) ]), title=g["info"] ) dialog.delegate = self bottom = self - #overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=4, right=4) + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) overlay = dialog - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.dialog_open = True self.parent.left_pile.contents[1] = (overlay, options) def announce_query(sender): def dismiss_dialog(sender): self.dialog_open = False - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) self.app.announce_now() dialog = DialogLineBox( urwid.Pile([ - urwid.Text("\n\n\nAnnounce Sent\n\n\n", align="center"), + urwid.Text("\n\n\nAnnounce Sent\n\n\n", align=urwid.CENTER), urwid.Button("OK", on_press=dismiss_dialog) ]), title=g["info"] ) dialog.delegate = self bottom = self - #overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=4, right=4) + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) overlay = dialog - + self.dialog_open = True - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (overlay, options) def node_info_query(sender): - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (self.parent.node_info_display, options) if LocalPeer.announce_timer == None: @@ -1024,21 +1350,30 @@ class LocalPeer(urwid.WidgetWrap): self.t_last_announce.update_time() announce_button = urwid.Button("Announce Now", on_press=announce_query) - - self.display_widget = urwid.Pile( - [ - t_id, - i_id, - e_name, - urwid.Divider(g["divider1"]), - self.t_last_announce, - announce_button, - urwid.Divider(g["divider1"]), - urwid.Columns([("weight", 0.45, urwid.Button("Save", on_press=save_query)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Node Info", on_press=node_info_query))]) - ] - ) - urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Local Peer Info")) + peer_widgets = [ + t_id, + i_id, + e_name, + urwid.Divider(g["divider1"]), + self.t_last_announce, + announce_button, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), + ]), + ] + + if self.app.enable_node: + peer_widgets.append( + urwid.AttrMap(urwid.Text(" "+g["node"]+" Node Online", align=urwid.LEFT), "shortcutbar") + ) + + self.display_widget = urwid.Pile(peer_widgets) + + super().__init__(urwid.LineBox(self.display_widget, title="Local Peer Info")) def start(self): self.t_last_announce.start() @@ -1050,6 +1385,7 @@ class NodeInfo(urwid.WidgetWrap): conns_timer = None pages_timer = None files_timer = None + storage_timer = None def __init__(self, app, parent): self.app = app @@ -1061,15 +1397,15 @@ class NodeInfo(urwid.WidgetWrap): widget_style = "" def show_peer_info(sender): - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) - + if self.app.enable_node: if self.app.node != None: display_name = self.app.node.name else: display_name = None - + if display_name == None: display_name = "" @@ -1085,30 +1421,61 @@ class NodeInfo(urwid.WidgetWrap): def announce_query(sender): def dismiss_dialog(sender): self.dialog_open = False - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + 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) self.app.node.announce() dialog = DialogLineBox( urwid.Pile([ - urwid.Text("\n\n\nAnnounce Sent\n\n", align="center"), + urwid.Text("\n\n\nAnnounce Sent\n\n", align=urwid.CENTER), urwid.Button("OK", on_press=dismiss_dialog) ]), title=g["info"] ) dialog.delegate = self bottom = self - #overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=4, right=4) + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) overlay = dialog - + self.dialog_open = True - options = self.parent.left_pile.options(height_type="pack", height_amount=None) + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) self.parent.left_pile.contents[1] = (overlay, options) def connect_query(sender): self.parent.browser.retrieve_url(RNS.hexrep(self.app.node.destination.hash, delimit=False)) + def edit_query(sender): + self.app.ui.main_display.show_page_editor(None) + + def show_qr_query(sender): + def dismiss_dialog(sender): + self.dialog_open = False + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (NodeInfo(self.app, self.parent), options) + + dest_addr = RNS.hexrep(self.app.node.destination.hash, delimit=False) + qr_text = qr_ascii(dest_addr) + + rows = [urwid.Text("")] + if qr_text is not None: + rows.append(urwid.Text(qr_text, align=urwid.CENTER)) + else: + rows.append(urwid.Text("Node destination address:", align=urwid.CENTER)) + rows += [ + urwid.Text(""), + urwid.Text("< "+dest_addr+" >", align=urwid.CENTER), + urwid.Text(""), + urwid.Button("Close", on_press=dismiss_dialog), + ] + + dialog = DialogLineBox(urwid.Pile(rows), title="Node Destination") + dialog.delegate = self + + self.dialog_open = True + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (dialog, options) + if NodeInfo.announce_timer == None: self.t_last_announce = NodeAnnounceTime(self.app) NodeInfo.announce_timer = self.t_last_announce @@ -1123,6 +1490,13 @@ class NodeInfo(urwid.WidgetWrap): self.t_active_links = NodeInfo.links_timer self.t_active_links.update_stat() + if NodeInfo.storage_timer == None: + self.t_storage_stats = NodeStorageStats(self.app) + NodeInfo.storage_timer = self.t_storage_stats + else: + self.t_storage_stats = NodeInfo.storage_timer + self.t_storage_stats.update_stat() + if NodeInfo.conns_timer == None: self.t_total_connections = NodeTotalConnections(self.app) NodeInfo.conns_timer = self.t_total_connections @@ -1145,44 +1519,80 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_files.update_stat() lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", self.app.node.destination.identity)) - e_lxmf = urwid.Text(lxmf_addr_str, align="center") + e_lxmf = urwid.Text(lxmf_addr_str, align=urwid.CENTER) announce_button = urwid.Button("Announce", on_press=announce_query) connect_button = urwid.Button("Browse", on_press=connect_query) + edit_button = urwid.Button("Edit", on_press=edit_query) reset_button = urwid.Button("Rst Stats", on_press=stats_query) + qr_button = urwid.Button("Show Destination", on_press=show_qr_query) - pile = urwid.Pile([ + if not self.app.disable_propagation: + pile = urwid.Pile([ + t_id, + e_name, + urwid.Divider(g["divider1"]), + e_lxmf, + urwid.Divider(g["divider1"]), + self.t_last_announce, + self.t_storage_stats, + self.t_active_links, + self.t_total_connections, + self.t_total_pages, + self.t_total_files, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 6, connect_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 8, reset_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 7, announce_button), + ]), + urwid.Columns([ + (urwid.WEIGHT, 4, edit_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 9, qr_button), + ]) + ]) + else: + pile = urwid.Pile([ t_id, e_name, urwid.Divider(g["divider1"]), - e_lxmf, - urwid.Divider(g["divider1"]), self.t_last_announce, + self.t_storage_stats, self.t_active_links, self.t_total_connections, self.t_total_pages, self.t_total_files, urwid.Divider(g["divider1"]), urwid.Columns([ - ("weight", 5, urwid.Button("Back", on_press=show_peer_info)), - ("weight", 0.5, urwid.Text("")), - ("weight", 6, connect_button), - ("weight", 0.5, urwid.Text("")), - ("weight", 8, reset_button), - ("weight", 0.5, urwid.Text("")), - ("weight", 7, announce_button), + (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 6, connect_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 8, reset_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 7, announce_button), + ]), + urwid.Columns([ + (urwid.WEIGHT, 4, edit_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 9, qr_button), ]) ]) else: pile = urwid.Pile([ - urwid.Text("\n"+g["info"], align="center"), - urwid.Text("\nThis instance is not hosting a node\n\n", align="center"), - urwid.Padding(urwid.Button("Back", on_press=show_peer_info), "center", "pack") + urwid.Text("\n"+g["info"], align=urwid.CENTER), + urwid.Text("\nThis instance is not hosting a node\n\n", align=urwid.CENTER), + urwid.Padding(urwid.Button("Back", on_press=show_peer_info), urwid.CENTER, urwid.PACK) ]) self.display_widget = pile - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Local Node Info"), widget_style)) + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title="Local Node Info"), widget_style)) def start(self): if self.app.node != None: @@ -1205,7 +1615,7 @@ class UpdatingText(urwid.WidgetWrap): self.append_text = append_text self.update() - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def update(self): self.value = self.value_method() @@ -1247,7 +1657,7 @@ class NetworkStats(urwid.WidgetWrap): self.display_widget = urwid.LineBox(pile, title="Network Stats") - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def start(self): self.w_heard_peers.start() @@ -1257,6 +1667,10 @@ class NetworkLeftPile(urwid.Pile): def keypress(self, size, key): if key == "ctrl l": self.parent.toggle_list() + elif key == "ctrl g": + self.parent.toggle_fullscreen() + elif key == "ctrl e": + self.parent.selected_node_info() elif key == "ctrl p": self.parent.reinit_lxmf_peers() self.parent.show_peers() @@ -1264,12 +1678,16 @@ class NetworkLeftPile(urwid.Pile): self.parent.browser.disconnect() elif key == "ctrl u": self.parent.browser.url_dialog() + elif key == "ctrl s": + self.parent.browser.save_node_dialog() + else: return super(NetworkLeftPile, self).keypress(size, key) class NetworkDisplay(): list_width = 0.33 + given_list_width = 52 def __init__(self, app): self.app = app @@ -1290,10 +1708,11 @@ class NetworkDisplay(): self.known_nodes_display.delegate = self self.list_display = 1 + default_info_display = self.node_info_display if self.app.enable_node else self.local_peer_display self.left_pile = NetworkLeftPile([ - ("weight", 1, self.known_nodes_display), - # ("pack", self.network_stats_display), - ("pack", self.local_peer_display), + (urwid.WEIGHT, 1, self.known_nodes_display), + # (urwid.PACK, self.network_stats_display), + (urwid.PACK, default_info_display), ]) self.left_pile.parent = self @@ -1304,27 +1723,41 @@ class NetworkDisplay(): self.columns = urwid.Columns( [ - ("weight", NetworkDisplay.list_width, self.left_area), - ("weight", self.right_area_width, self.right_area) + # (urwid.WEIGHT, NetworkDisplay.list_width, self.left_area), + # (urwid.WEIGHT, self.right_area_width, self.right_area) + (NetworkDisplay.given_list_width, self.left_area), + (urwid.WEIGHT, 1, self.right_area) ], dividechars=0, focus_column=0 ) - self.shortcuts_display = NetworkDisplayShortcuts(self.app) + self.list_shortcuts = NetworkDisplayShortcuts(self.app) + self.browser_shortcuts = BrowserShortcuts(self.app, self.browser) + self.shortcuts_display = self.list_shortcuts self.widget = self.columns def toggle_list(self): if self.list_display != 0: - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.announce_stream_display, options) self.list_display = 0 else: - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.known_nodes_display, options) self.list_display = 1 + def toggle_fullscreen(self): + if NetworkDisplay.given_list_width != 0: + self.saved_list_width = NetworkDisplay.given_list_width + NetworkDisplay.given_list_width = 0 + else: + NetworkDisplay.given_list_width = self.saved_list_width + + options = self.widget.options("given", NetworkDisplay.given_list_width) + self.widget.contents[0] = (self.left_area, options) + def show_peers(self): - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.lxmf_peers_display, options) if self.list_display != 0: @@ -1332,38 +1765,57 @@ class NetworkDisplay(): else: self.list_display = 1 + def selected_node_info(self): + if self.list_display == 1: + parent = self.app.ui.main_display.sub_displays.network_display + selected_node_entry = parent.known_nodes_display.ilb.get_selected_item() + if selected_node_entry is not None: + selected_node_hash = selected_node_entry.base_widget.display_widget.source_hash + + if selected_node_hash is not None: + info_widget = KnownNodeInfo(selected_node_hash) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + parent.left_pile.contents[0] = (info_widget, options) + def focus_lists(self): self.columns.focus_position = 0 def reinit_known_nodes(self): self.known_nodes_display = KnownNodes(self.app) self.known_nodes_display.delegate = self - self.close_list_dialogs() self.announce_stream_display.rebuild_widget_list() def reinit_lxmf_peers(self): + if self.lxmf_peers_display: + si = self.lxmf_peers_display.ilb.get_selected_position() + else: + si = None self.lxmf_peers_display = LXMFPeers(self.app) self.lxmf_peers_display.delegate = self self.close_list_dialogs() + if si != None: + self.lxmf_peers_display.ilb.select_item(si) def close_list_dialogs(self): if self.list_display == 0: - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.announce_stream_display, options) else: - options = self.left_pile.options(height_type="weight", height_amount=1) + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) self.left_pile.contents[0] = (self.known_nodes_display, options) def start(self): self.local_peer_display.start() self.node_info_display.start() self.network_stats_display.start() - # There seems to be an intermittent memory leak somewhere - # in the periodic updating here. The periodic updater should - # not be needed anymore, so dis - #self.announce_stream_display.start() def shortcuts(self): + try: focus_path = self.columns.get_focus_path() + except Exception: return self.list_shortcuts + try: + if focus_path[0] == 0: return self.list_shortcuts + if focus_path[0] == 1: return self.browser_shortcuts + except: pass return self.shortcuts_display def directory_change_callback(self): @@ -1398,17 +1850,26 @@ class LXMFPeers(urwid.WidgetWrap): else: self.no_content = True widget_style = "inactive_text" - self.pile = urwid.Pile([urwid.Text(("warning_text", g["info"]+"\n"), align="center"), SelectText(("warning_text", "Currently, no LXMF nodes are peered\n\n"), align="center")]) - self.display_widget = urwid.Filler(self.pile, valign="top", height="pack") + self.pile = urwid.Pile([ + urwid.Text(("warning_text", g["info"]+"\n"), align=urwid.CENTER), + SelectText(("warning_text", "Currently, no LXMF nodes are peered\n\n"), align=urwid.CENTER), + ]) + self.display_widget = urwid.Filler(self.pile, valign=urwid.TOP, height=urwid.PACK) - urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="LXMF Propagation Peers"), widget_style)) + if hasattr(self, "peer_list") and self.peer_list: + pl = len(self.peer_list) + else: + pl = 0 + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title=f"LXMF Propagation Peers ({pl})"), widget_style)) def keypress(self, size, key): if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): - nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header") + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "ctrl x": self.delete_selected_entry() - + elif key == "ctrl r": + self.sync_selected_entry() + return super(LXMFPeers, self).keypress(size, key) @@ -1423,6 +1884,48 @@ class LXMFPeers(urwid.WidgetWrap): self.delegate.reinit_lxmf_peers() self.delegate.show_peers() + def sync_selected_entry(self): + sync_grace = 10 + si = self.ilb.get_selected_item() + if si != None: + destination_hash = si.original_widget.destination_hash + if destination_hash in self.app.message_router.peers: + peer = self.app.message_router.peers[destination_hash] + if time.time() > peer.last_sync_attempt+sync_grace: + peer.next_sync_attempt = time.time()-1 + + def job(): + peer.sync() + threading.Thread(target=job, daemon=True).start() + + time.sleep(0.25) + + def dismiss_dialog(sender): + self.close_list_dialogs() + + dialog = ListDialogLineBox( + urwid.Pile([ + urwid.Text("A delivery sync of all unhandled LXMs was manually requested for the selected node\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), + title="!", + + ) + dialog.delegate = self.delegate + bottom = self + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) + self.delegate.left_pile.contents[0] = (overlay, options) + + + def close_list_dialogs(self): + self.delegate.reinit_lxmf_peers() + self.delegate.show_peers() def rebuild_widget_list(self): self.peer_list = self.app.message_router.peers @@ -1436,17 +1939,18 @@ class LXMFPeers(urwid.WidgetWrap): def make_peer_widgets(self): widget_list = [] - for peer_id in self.peer_list: + sorted_peers = sorted(self.peer_list, key=lambda pid: (self.app.directory.pn_trust_level(pid), self.peer_list[pid].sync_transfer_rate), reverse=True) + for peer_id in sorted_peers: peer = self.peer_list[peer_id] - pe = LXMFPeerEntry(self.app, peer, self) + trust_level = self.app.directory.pn_trust_level(peer_id) + pe = LXMFPeerEntry(self.app, peer, self, trust_level) pe.destination_hash = peer.destination_hash widget_list.append(pe) - # TODO: Sort list return widget_list class LXMFPeerEntry(urwid.WidgetWrap): - def __init__(self, app, peer, delegate): + def __init__(self, app, peer, delegate, trust_level): destination_hash = peer.destination_hash self.app = app @@ -1454,22 +1958,53 @@ class LXMFPeerEntry(urwid.WidgetWrap): node_identity = RNS.Identity.recall(destination_hash) display_str = RNS.prettyhexrep(destination_hash) - if node_identity != None: + if node_identity != None: node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", node_identity) display_name = self.app.directory.alleged_display_str(node_hash) if display_name != None: - display_str += " "+str(display_name) + display_str = str(display_name)+"\n "+display_str sym = g["sent"] style = "list_unknown" focus_style = "list_focus" - widget = ListEntry(sym+" "+display_str+"\n "+str(len(peer.unhandled_messages))+" unhandled messages - "+"Last heard "+pretty_date(int(peer.last_heard))) - # urwid.connect_signal(widget, "click", delegate.connect_node, node) + alive_string = "Unknown" + if hasattr(peer, "alive"): + if peer.alive: + alive_string = "Available" + if trust_level == DirectoryEntry.TRUSTED: + style = "list_trusted" + focus_style = "list_focus_trusted" + else: + style = "list_normal" + focus_style = "list_focus" + else: + alive_string = "Unresponsive" + style = "list_unresponsive" + focus_style = "list_focus_unresponsive" + if peer.propagation_transfer_limit: txfer_limit = RNS.prettysize(peer.propagation_transfer_limit*1000) + else: txfer_limit = "No" + + if peer.propagation_sync_limit: sync_limit = RNS.prettysize(peer.propagation_sync_limit*1000) + else: sync_limit = "Unknown" + + if peer.propagation_stamp_cost: sct = peer.propagation_stamp_cost + else: sct = "Unknown" + + if peer.propagation_stamp_cost_flexibility: scf = f" (flex {peer.propagation_stamp_cost_flexibility})" + else: scf = "" + + ar = round(peer.acceptance_rate*100, 2) + peer_info_str = sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard)) + peer_info_str += f"\n {sync_limit} sync limit, {txfer_limit} msg limit" + peer_info_str += f"\n {RNS.prettyspeed(peer.sync_transfer_rate)} STR, {RNS.prettyspeed(peer.link_establishment_rate)} LER" + peer_info_str += f"\n Propagation cost {sct}{scf}" + peer_info_str += "\n "+str(peer.unhandled_message_count)+f" unhandled LXMs, {ar}% AR" + widget = ListEntry(peer_info_str) self.display_widget = urwid.AttrMap(widget, style, focus_style) self.display_widget.destination_hash = destination_hash - urwid.WidgetWrap.__init__(self, self.display_widget) + super().__init__(self.display_widget) def pretty_date(time=False): @@ -1481,13 +2016,14 @@ def pretty_date(time=False): from datetime import datetime now = datetime.now() if type(time) is int: - diff = now - datetime.fromtimestamp(time) + then = datetime.fromtimestamp(time) elif isinstance(time,datetime): - diff = now - time + then = time elif not time: - diff = now - now + then = now + diff = now - then second_diff = diff.seconds - day_diff = diff.days + day_diff = (now.date() - then.date()).days if day_diff < 0: return '' @@ -1513,4 +2049,4 @@ def pretty_date(time=False): return str(int(day_diff / 7)) + " weeks ago" if day_diff < 365: return str(int(day_diff / 30)) + " months ago" - return str(int(day_diff / 365)) + " years ago" + return str(int(day_diff / 365)) + " years ago" \ No newline at end of file diff --git a/nomadnet/ui/textui/ReadlineEdit.py b/nomadnet/ui/textui/ReadlineEdit.py new file mode 100644 index 0000000..85b8dd9 --- /dev/null +++ b/nomadnet/ui/textui/ReadlineEdit.py @@ -0,0 +1,150 @@ +import urwid + + +_KILL_KEYS = frozenset(("ctrl u", "ctrl k", "ctrl w", "ctrl l")) + + +class _KillRing: + """Module-global kill buffer shared across all ReadlineMixin widgets. + + Mirrors GNU readline: consecutive kills accumulate into the same entry + (forward kills append, backward kills prepend); any non-kill keypress + breaks the chain so the next kill replaces the buffer. + """ + text = "" + last_was_kill = False + + @classmethod + def reset_chain(cls): + cls.last_was_kill = False + + @classmethod + def kill(cls, killed, direction): + if not killed: + return + if cls.last_was_kill: + cls.text = cls.text + killed if direction == "forward" else killed + cls.text + else: + cls.text = killed + cls.last_was_kill = True + + +class ReadlineMixin: + """Mixin adding readline-style editing keys to an urwid.Edit-derived widget. + + Bindings (GNU readline defaults, plus ctrl-l as kill-whole-buffer): + ctrl-a beginning-of-line + ctrl-e end-of-line + ctrl-u unix-line-discard (kill from cursor to beginning of line) + ctrl-k kill-line (kill from cursor to end of line) + ctrl-w unix-word-rubout (kill previous whitespace-delimited word) + ctrl-l kill-whole-buffer (kill the entire edit buffer) + ctrl-y yank (insert most-recently-killed text) + ctrl-left backward-word (alphanumeric boundary) + ctrl-right forward-word (alphanumeric boundary) + + "Line" is the current logical line within the edit buffer, delimited by + newlines -- so on multiline Edits these act on the line under the cursor, + not the entire buffer. + + The kill buffer is shared across all widgets using this mixin, so text + killed in one Edit can be yanked into another. + """ + + def keypress(self, size, key): + if key == "ctrl a": self._rl_beg_of_line() + elif key == "ctrl e": self._rl_end_of_line() + elif key == "ctrl u": self._rl_kill_to_beg() + elif key == "ctrl k": self._rl_kill_to_end() + elif key == "ctrl w": self._rl_kill_word_back() + elif key == "ctrl l": self._rl_kill_whole_buffer() + elif key == "ctrl y": self._rl_yank() + elif key == "ctrl left": self._rl_backward_word() + elif key == "ctrl right": self._rl_forward_word() + else: + result = super().keypress(size, key) + if key not in _KILL_KEYS: + _KillRing.reset_chain() + return result + if key not in _KILL_KEYS: + _KillRing.reset_chain() + return None + + def _rl_line_bounds(self): + text, pos = self.edit_text, self.edit_pos + bol = text.rfind("\n", 0, pos) + bol = 0 if bol == -1 else bol + 1 + eol = text.find("\n", pos) + eol = len(text) if eol == -1 else eol + return bol, eol + + def _rl_delete(self, start, end, kill_direction=None): + if start == end: + return + text = self.edit_text + if kill_direction is not None: + _KillRing.kill(text[start:end], kill_direction) + self.set_edit_text(text[:start] + text[end:]) + self.set_edit_pos(start) + + @staticmethod + def _rl_is_word_char(ch): + return ch.isalnum() or ch == "_" + + def _rl_beg_of_line(self): + bol, _ = self._rl_line_bounds() + self.set_edit_pos(bol) + + def _rl_end_of_line(self): + _, eol = self._rl_line_bounds() + self.set_edit_pos(eol) + + def _rl_kill_to_beg(self): + bol, _ = self._rl_line_bounds() + self._rl_delete(bol, self.edit_pos, kill_direction="backward") + + def _rl_kill_to_end(self): + _, eol = self._rl_line_bounds() + self._rl_delete(self.edit_pos, eol, kill_direction="forward") + + def _rl_kill_word_back(self): + text, pos = self.edit_text, self.edit_pos + p = pos + while p > 0 and text[p - 1].isspace(): + p -= 1 + while p > 0 and not text[p - 1].isspace(): + p -= 1 + self._rl_delete(p, pos, kill_direction="backward") + + def _rl_kill_whole_buffer(self): + self._rl_delete(0, len(self.edit_text), kill_direction="forward") + + def _rl_yank(self): + if not _KillRing.text: + return + pos = self.edit_pos + text = self.edit_text + self.set_edit_text(text[:pos] + _KillRing.text + text[pos:]) + self.set_edit_pos(pos + len(_KillRing.text)) + + def _rl_backward_word(self): + text, pos = self.edit_text, self.edit_pos + while pos > 0 and not self._rl_is_word_char(text[pos - 1]): + pos -= 1 + while pos > 0 and self._rl_is_word_char(text[pos - 1]): + pos -= 1 + self.set_edit_pos(pos) + + def _rl_forward_word(self): + text, pos = self.edit_text, self.edit_pos + n = len(text) + while pos < n and not self._rl_is_word_char(text[pos]): + pos += 1 + while pos < n and self._rl_is_word_char(text[pos]): + pos += 1 + self.set_edit_pos(pos) + + +class ReadlineEdit(ReadlineMixin, urwid.Edit): + """Drop-in urwid.Edit replacement with readline-style editing keys.""" + pass diff --git a/nomadnet/ui/textui/Transport.py b/nomadnet/ui/textui/Transport.py new file mode 100644 index 0000000..a358bc1 --- /dev/null +++ b/nomadnet/ui/textui/Transport.py @@ -0,0 +1,495 @@ +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) diff --git a/nomadnet/ui/textui/Utilities.py b/nomadnet/ui/textui/Utilities.py new file mode 100644 index 0000000..ec0dfee --- /dev/null +++ b/nomadnet/ui/textui/Utilities.py @@ -0,0 +1,1766 @@ +import RNS +import os +import time +import tempfile +import threading +import nomadnet +import urwid + +from .Transport import TransportDisplay +from .ReadlineEdit import ReadlineEdit +from .FileBrowser import FileBrowser +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import Dropdown + +class OutputLog: + def __init__(self, title="Output"): + self.walker = urwid.SimpleListWalker([]) + self.listbox = urwid.ListBox(self.walker) + self.widget = urwid.LineBox( + urwid.ScrollBar(self.listbox), + title=title, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + def log(self, message, attr="body_text"): + self.walker.append(urwid.Text((attr, message))) + try: + self.listbox.set_focus(len(self.walker) - 1) + self.listbox.set_focus_valign("bottom") + except Exception: + pass + + def clear(self): + self.walker[:] = [] + +def _utility_body(form_rows, output): + return urwid.Pile([ + ('pack', urwid.Pile(form_rows)), + ('weight', 1, output.widget), + ]) + +DEFAULT_PROBE_SIZE = 16 +DEFAULT_PROBE_TIMEOUT = 12 + +def _interface_enabled(iface): + try: + return str(iface.get("enabled")).lower() not in ("false", "off", "no", "0") and \ + str(iface.get("interface_enabled")).lower() not in ("false", "off", "no", "0") + except Exception: + return True + +def _has_active_interfaces(app): + try: + interfaces = app.rns.config["interfaces"] + except Exception: + return True + try: + return any(_interface_enabled(interfaces[name]) for name in interfaces) + except Exception: + return True + +class UtilityItem(urwid.WidgetWrap): + def __init__(self, parent, name, description, on_select): + self.parent = parent + self.name = name + self.on_select = on_select + self._selectable = True + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text(("interface_title", name)) + + title_row = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + description_row = urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("value", description)), + ]) + + box = urwid.LineBox( + urwid.Padding(urwid.Pile([title_row, description_row]), left=2, right=2), + title=None, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + super().__init__(box) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + attr = "interface_title_selected" if focus else "interface_title" + self.title_widget.set_text((attr, self.name)) + return super().render(size, focus=focus) + + def keypress(self, size, key): + if key == "up": + if self.parent.landing_listbox.focus_position == 0: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": + self.on_select() + return None + return key + +class UtilitiesDisplayShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Enter] Open [Up/Down] Navigate"), + "shortcutbar" + ) + +class UtilitiesDisplay: + def __init__(self, app): + self.app = app + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.active = "landing" + self.transport = None + self.probe = None + self.path = None + self.identity = None + self.send = None + self.speedtest = None + + self.shortcuts_display = UtilitiesDisplayShortcuts(self.app) + self._build_landing() + self.widget = self.landing_view + + def _build_landing(self): + items = [ + UtilityItem(self, "Path Tables", "View routing, announce rate and per-interface tables", self.open_transport), + UtilityItem(self, "Path Resolver", "Look up or drop the path to a destination", self.open_path), + UtilityItem(self, "Probe", "Send probes to a destination and measure round trip", self.open_probe), + UtilityItem(self, "Identity", "Show your addresses and look up a destination's identity", self.open_identity), + UtilityItem(self, "Send File", "Send a file to a destination over Reticulum to a rncp listener", self.open_send), + UtilityItem(self, "Speed Test", "Measure throughput to a peer also running this screen", self.open_speedtest), + ] + self.landing_listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + + self.landing_view = urwid.Pile([ + ('pack', urwid.Text(("body_text", "Select a utility to open."), align=urwid.CENTER)), + ('pack', urwid.Divider("─")), + ('weight', 1, self.landing_listbox), + ]) + + def shortcuts(self): + if self.active == "transport" and self.transport is not None: + return self.transport.shortcuts_display + if self.active == "probe" and self.probe is not None: + return self.probe.shortcuts_display + if self.active == "path" and self.path is not None: + return self.path.shortcuts_display + if self.active == "identity" and self.identity is not None: + return self.identity.shortcuts_display + if self.active == "send" and self.send is not None: + return self.send.shortcuts_display + if self.active == "speedtest" and self.speedtest is not None: + return self.speedtest.shortcuts_display + return self.shortcuts_display + + def _leave_speedtest(self): + if self.speedtest is not None and self.active == "speedtest": + self.speedtest.stop_listening() + + def start(self): + self.show_landing() + + def show_landing(self): + self._leave_speedtest() + self.active = "landing" + self.widget = self.landing_view + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_transport(self): + if self.transport is None: + self.transport = TransportDisplay(self.app, parent=self) + self.active = "transport" + self.widget = self.transport.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.transport.start() + + def open_probe(self): + if self.probe is None: + self.probe = ProbeView(self.app, parent=self) + self.probe.update_interface_status() + self.active = "probe" + self.widget = self.probe.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_path(self): + if self.path is None: + self.path = PathView(self.app, parent=self) + self.path.update_interface_status() + self.active = "path" + self.widget = self.path.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_identity(self): + if self.identity is None: + self.identity = IdentityView(self.app, parent=self) + self.active = "identity" + self.widget = self.identity.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_send(self): + if self.send is None: + self.send = SendFileView(self.app, parent=self) + self.send.update_interface_status() + self.active = "send" + self.widget = self.send.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_speedtest(self): + if self.speedtest is None: + self.speedtest = SpeedTestView(self.app, parent=self) + self.active = "speedtest" + self.widget = self.speedtest.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.speedtest.update_interface_status() + self.speedtest.start_listening() + +class ProbeShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Probe / Activate [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class UtilityViewFiller(urwid.WidgetWrap): + def __init__(self, body, view): + self.view = view + super().__init__(body) + + def keypress(self, size, key): + if key == "esc": + self.view.go_back() + return None + if key == "ctrl l": + if hasattr(self.view, "clear_output"): + self.view.clear_output() + return None + result = super().keypress(size, key) + if hasattr(self.view, "after_keypress"): + self.view.after_keypress() + if key == "up" and result == "up": + self.view.go_back_to_header() + return None + return result + +class ProbeView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.shortcuts_display = ProbeShortcuts(self.app) + + self._probing = False + self._concluded = False + self._remaining = 0 + self._sent = 0 + self._replies = 0 + self._cur_receipt = None + + self.name_edit = ReadlineEdit("", "rnstransport.probe") + self.hash_edit = ReadlineEdit("", "") + self.size_edit = ReadlineEdit("", str(DEFAULT_PROBE_SIZE)) + self.count_edit = ReadlineEdit("", "1") + + self.probe_button = urwid.AttrMap(urwid.Button("Probe", on_press=self.on_probe), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.probe_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + self.status_text = urwid.Text("") + + rows = [ + urwid.Text(("form_title", "Probe a destination"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + self._field_row("Destination name:", self.name_edit), + urwid.Padding(urwid.Text(("inactive_text", "Full dotted name for example rnstransport.probe")), left=22), + self._field_row("Destination hash:", self.hash_edit), + self._field_row("Payload size:", self.size_edit), + self._field_row("Probe count:", self.count_edit), + urwid.Divider(), + button_row, + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + self.update_interface_status() + + def _field_row(self, label, edit): + return urwid.Columns([ + (20, urwid.Text(("key", label), align=urwid.RIGHT)), + urwid.AttrMap(edit, "list_normal", focus_map="list_focus"), + ], dividechars=1) + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def on_probe(self, button=None): + if self._probing: + return + + self.update_interface_status() + + full_name = self.name_edit.edit_text.strip() + hexhash = self.hash_edit.edit_text.strip() + + if not full_name: + self._log("Enter a destination name (e.g. rnstransport.probe)", "error_text") + return + + try: + app_name, aspects = RNS.Destination.app_and_aspects_from_name(full_name) + except Exception as e: + self._log("Invalid destination name: %s" % str(e), "error_text") + return + + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + destination_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + try: + size = int(self.size_edit.edit_text.strip() or str(DEFAULT_PROBE_SIZE)) + except ValueError: + size = DEFAULT_PROBE_SIZE + try: + count = int(self.count_edit.edit_text.strip() or "1") + except ValueError: + count = 1 + if count < 1: + count = 1 + + self._app_name = app_name + self._aspects = aspects + self._dest_hash = destination_hash + self._size = max(1, size) + self._remaining = count + self._sent = 0 + self._replies = 0 + self._probing = True + self.probe_button.original_widget.set_label("Probing…") + self._begin_probe() + + def _begin_probe(self): + dest_hash = self._dest_hash + if not RNS.Transport.has_path(dest_hash): + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._path_deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + else: + self._send_one() + + def _timeout(self): + try: + return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout()) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._send_one() + elif time.time() > self._path_deadline: + self._log("Path request timed out", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _send_one(self): + dest_hash = self._dest_hash + identity = RNS.Identity.recall(dest_hash) + if identity is None: + self._log("Could not recall identity for destination", "error_text") + self._finish() + return + + try: + request_destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self._app_name, *self._aspects) + probe = RNS.Packet(request_destination, os.urandom(self._size)) + probe.pack() + except OSError: + self._log("Probe payload of %d bytes exceeds the MTU" % self._size, "error_text") + self._finish() + return + except Exception as e: + self._log("Could not create probe: %s" % str(e), "error_text") + self._finish() + return + + self._concluded = False + receipt = probe.send() + self._cur_receipt = receipt + self._sent += 1 + self._log("Sent probe %d (%d bytes) to %s …" % (self._sent, self._size, RNS.prettyhexrep(dest_hash))) + + try: + receipt.set_delivery_callback(self._on_delivered) + except Exception: + pass + self.app.ui.loop.set_alarm_in(self._timeout(), self._on_timeout, user_data=receipt) + + def _on_delivered(self, receipt): + self._schedule(lambda: self._conclude(receipt, True)) + + def _on_timeout(self, loop, user_data): + if user_data is not self._cur_receipt: + return + self._conclude(user_data, False) + + def _conclude(self, receipt, delivered): + if self._concluded: + return + if receipt is not self._cur_receipt: + return + self._concluded = True + + is_delivered = delivered + if not is_delivered and receipt is not None: + try: + is_delivered = receipt.status == RNS.PacketReceipt.DELIVERED + except Exception: + is_delivered = False + + if is_delivered and receipt is not None: + self._replies += 1 + try: + hops = RNS.Transport.hops_to(self._dest_hash) + except Exception: + hops = 0 + try: + rtt = receipt.get_rtt() + rtt_str = "%.0f ms" % (rtt * 1000) if rtt < 1 else "%.3f s" % rtt + except Exception: + rtt_str = "?" + hop_str = "%d hop%s" % (hops, "" if hops == 1 else "s") + self._log("Reply from %s %s over %s%s" % ( + RNS.prettyhexrep(receipt.destination.hash), rtt_str, hop_str, self._reception_stats(receipt)), + "connected_status") + else: + self._log("Probe %d timed out" % self._sent, "warning_text") + + self._next() + + def _reception_stats(self, receipt): + stats = "" + try: + proof_packet = getattr(receipt, "proof_packet", None) + if proof_packet is None: + return "" + if getattr(self.app.rns, "is_connected_to_shared_instance", False): + packet_hash = proof_packet.packet_hash + rssi = self.app.rns.get_packet_rssi(packet_hash) + snr = self.app.rns.get_packet_snr(packet_hash) + q = self.app.rns.get_packet_q(packet_hash) + else: + rssi = getattr(proof_packet, "rssi", None) + snr = getattr(proof_packet, "snr", None) + q = None + if rssi is not None: + stats += " [RSSI %s dBm]" % rssi + if snr is not None: + stats += " [SNR %s dB]" % snr + if q is not None: + stats += " [Q %s%%]" % q + except Exception: + pass + return stats + + def _next(self): + self._remaining -= 1 + if self._remaining > 0: + self._send_one() + else: + self._finish() + + def _finish(self): + if self._sent > 0: + loss = round((1 - (self._replies / self._sent)) * 100, 1) + self._log("Sent %d, received %d, packet loss %s%%" % (self._sent, self._replies, loss), "key") + self._probing = False + self.probe_button.original_widget.set_label("Probe") + self._draw() + +class PathShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Resolve / Activate [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class PathView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.shortcuts_display = PathShortcuts(self.app) + + self._resolving = False + + self.hash_edit = ReadlineEdit("", "") + + self.resolve_button = urwid.AttrMap(urwid.Button("Resolve", on_press=self.on_resolve), "button_normal", focus_map="button_focus") + self.drop_button = urwid.AttrMap(urwid.Button("Drop path", on_press=self.on_drop), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, self.resolve_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.drop_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.back_button), + ]) + + self.status_text = urwid.Text("") + + rows = [ + urwid.Text(("form_title", "Resolve a path"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Requests the path from the network if it is not already known.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + self.update_interface_status() + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _validate_hash(self): + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return None + try: + return bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return None + + def _timeout(self): + try: + return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout()) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def on_resolve(self, button=None): + if self._resolving: + return + self.update_interface_status() + dest_hash = self._validate_hash() + if dest_hash is None: + return + self._dest_hash = dest_hash + if RNS.Transport.has_path(dest_hash): + self._show_path() + return + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._resolving = True + self.resolve_button.original_widget.set_label("Resolving…") + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._resolving = False + self.resolve_button.original_widget.set_label("Resolve") + self._show_path() + self._draw() + elif time.time() > self._deadline: + self._resolving = False + self.resolve_button.original_widget.set_label("Resolve") + self._log("Path not found", "warning_text") + self._draw() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _show_path(self): + dest_hash = self._dest_hash + try: + hops = RNS.Transport.hops_to(dest_hash) + next_hop_bytes = self.app.rns.get_next_hop(dest_hash) + except Exception: + next_hop_bytes = None + hops = 0 + if not next_hop_bytes: + self._log("Path data unavailable", "warning_text") + return + try: + interface = self.app.rns.get_next_hop_if_name(dest_hash) + except Exception: + interface = "?" + hop_str = "%d hop%s" % (hops, "" if hops == 1 else "s") + self._log("Path found: %s is %s away via %s on %s" % ( + RNS.prettyhexrep(dest_hash), hop_str, RNS.prettyhexrep(next_hop_bytes), interface), + "connected_status") + + def on_drop(self, button=None): + dest_hash = self._validate_hash() + if dest_hash is None: + return + try: + dropped = self.app.rns.drop_path(dest_hash) + except Exception as e: + self._log("Could not drop path: %s" % str(e), "error_text") + return + if dropped: + self._log("Dropped path to %s" % RNS.prettyhexrep(dest_hash)) + else: + self._log("No path to %s to drop" % RNS.prettyhexrep(dest_hash), "warning_text") + +class IdentityShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Look up / Activate [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class IdentityView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = IdentityShortcuts(self.app) + self._resolving = False + + self.hash_edit = ReadlineEdit("", "") + self.lookup_button = urwid.AttrMap(urwid.Button("Look up", on_press=self.on_lookup), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.lookup_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Identity"), align=urwid.CENTER), + urwid.Divider("─"), + urwid.Text(("key", "Your addresses")), + ] + rows += self._address_rows() + rows += [ + urwid.Divider(), + urwid.Text(("key", "Look up a destination")), + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Recalls the identity, requesting it from the network if needed.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + + def _addr(self, obj): + try: + return RNS.prettyhexrep(obj.hash) if obj is not None else "" + except Exception: + return "" + + def _address_rows(self): + rows = [] + + def row(label, value): + return urwid.Columns([ + (18, urwid.Text(("key", label), align=urwid.RIGHT)), + urwid.Text(("value", value)), + ], dividechars=1) + + rows.append(row("Identity:", self._addr(getattr(self.app, "identity", None)))) + rows.append(row("LXMF address:", self._addr(getattr(self.app, "lxmf_destination", None)))) + node = getattr(self.app, "node", None) + if node is not None: + rows.append(row("Node:", self._addr(getattr(node, "destination", None)))) + return rows + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def on_lookup(self, button=None): + if self._resolving: + return + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + self._dest_hash = dest_hash + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + self._show_identity(identity) + return + + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._resolving = True + self.lookup_button.original_widget.set_label("Looking up…") + self._log("Identity unknown, requesting from the network …") + try: + self._deadline = time.time() + max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(dest_hash), self.app.rns.get_medium_path_timeout()) + except Exception: + self._deadline = time.time() + DEFAULT_PROBE_TIMEOUT + self.app.ui.loop.set_alarm_in(0.25, self._await_identity) + + def _await_identity(self, loop, user_data): + identity = RNS.Identity.recall(self._dest_hash) + if identity is not None: + self._resolving = False + self.lookup_button.original_widget.set_label("Look up") + self._show_identity(identity) + self._draw() + elif time.time() > self._deadline: + self._resolving = False + self.lookup_button.original_widget.set_label("Look up") + self._log("Identity not found", "warning_text") + self._draw() + else: + loop.set_alarm_in(0.25, self._await_identity) + + def _show_identity(self, identity): + self._log("%s resolves to identity %s" % ( + RNS.prettyhexrep(self._dest_hash), RNS.prettyhexrep(identity.hash)), "connected_status") + +RNCP_APP_NAME = "rncp" + +class SendFileShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Choose / Send [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class SendFileView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = SendFileShortcuts(self.app) + + self._sending = False + self._concluded = False + self._link = None + self._resource = None + self.file_path = None + + self.hash_edit = ReadlineEdit("", "") + self.file_label = urwid.Text(("inactive_text", "No file selected")) + self.status_text = urwid.Text("") + self.progress_text = urwid.Text("") + + self.choose_button = urwid.AttrMap(urwid.Button("Choose file…", on_press=self.choose_file), "button_normal", focus_map="button_focus") + self.send_button = urwid.AttrMap(urwid.Button("Send", on_press=self.on_send), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.send_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Send a file"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + urwid.Columns([ + (20, urwid.Text(("key", "File:"), align=urwid.RIGHT)), + urwid.AttrMap(self.choose_button, "button_normal", focus_map="button_focus"), + ], dividechars=1), + urwid.Padding(self.file_label, left=22), + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "The receiver must be running 'rncp --listen' and have your identity hash in the allow list.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), + self.progress_text, ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + self.update_interface_status() + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def choose_file(self, button=None): + def on_done(selected): + if selected: + self.file_path = selected[0] + self.file_label.set_text(("value", os.path.basename(self.file_path))) + self._close_browser() + + def on_cancel(): + self._close_browser() + + browser = FileBrowser(on_done=on_done, on_cancel=on_cancel, title="Select File to Send", multi_select=False) + overlay = urwid.Overlay(browser, self.widget, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) + self.parent.widget = overlay + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def _close_browser(self): + self.parent.widget = self.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self._draw() + + def _timeout(self): + try: + return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout()) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def on_send(self, button=None): + if self._sending: + return + self.update_interface_status() + + if not self.file_path or not os.path.isfile(self.file_path): + self._log("Choose a file to send first", "error_text") + return + + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + self._dest_hash = dest_hash + self._sending = True + self._concluded = False + self._resource = None + self.send_button.original_widget.set_label("Sending…") + self.progress_text.set_text("") + + if RNS.Transport.has_path(dest_hash): + self._establish_link() + else: + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + elif time.time() > self._deadline: + self._log("Path not found", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _establish_link(self): + identity = RNS.Identity.recall(self._dest_hash) + if identity is None: + self._log("Could not recall identity for destination", "error_text") + self._finish() + return + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, RNCP_APP_NAME, "receive") + self._log("Establishing link with %s …" % RNS.prettyhexrep(self._dest_hash)) + self._link = RNS.Link(destination, established_callback=self._on_link_up, closed_callback=self._on_link_closed) + except Exception as e: + self._log("Could not establish link: %s" % str(e), "error_text") + self._finish() + return + self._link_deadline = time.time() + max(self._timeout(), self._link.establishment_timeout) + self.app.ui.loop.set_alarm_in(0.5, self._check_link) + + def _check_link(self, loop, user_data): + if not self._sending or self._resource is not None: + return + try: + active = self._link is not None and self._link.status == RNS.Link.ACTIVE + except Exception: + active = False + if active: + return + if time.time() > self._link_deadline: + self._log("Link establishment timed out", "warning_text") + try: + self._link.teardown() + except Exception: + pass + self._finish() + else: + loop.set_alarm_in(0.5, self._check_link) + + def _on_link_up(self, link): + self._schedule(lambda: self._start_resource(link)) + + def _start_resource(self, link): + if not self._sending or self._resource is not None: + return + try: + link.identify(self.app.identity) + metadata = {"name": os.path.basename(self.file_path).encode("utf-8")} + self._resource = RNS.Resource(open(self.file_path, "rb"), link, metadata=metadata, + callback=self._on_resource_done, progress_callback=self._on_progress, auto_compress=True) + self._log("Advertising %s …" % os.path.basename(self.file_path)) + except Exception as e: + self._log("Could not start transfer: %s" % str(e), "error_text") + self._finish() + + def _on_progress(self, resource): + self._schedule(lambda: self._show_progress(resource)) + + def _show_progress(self, resource): + try: + pct = int(resource.get_progress() * 100) + except Exception: + pct = 0 + self.progress_text.set_text(("body_text", "Transferring… %d%%" % pct)) + + def _on_resource_done(self, resource): + self._schedule(lambda: self._resource_done(resource)) + + def _resource_done(self, resource): + if self._concluded: + return + self._concluded = True + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if complete: + self.progress_text.set_text(("connected_status", "Transferring… 100%")) + self._log("Transfer complete", "connected_status") + else: + self._log("Transfer failed or the file was not accepted", "warning_text") + try: + if self._link is not None: + self._link.teardown() + except Exception: + pass + self._finish() + + def _on_link_closed(self, link): + self._schedule(self._link_closed) + + def _link_closed(self): + if self._sending and self._resource is None and not self._concluded: + self._log("Link closed before the transfer started", "warning_text") + self._finish() + + def _finish(self): + self._sending = False + self.send_button.original_widget.set_label("Send") + self._draw() + + +SPEEDTEST_APP_NAME = "nomadnetwork" +SPEEDTEST_ASPECTS = ("utilities", "speedtest") +SPEEDTEST_FULL_NAME = ".".join((SPEEDTEST_APP_NAME,) + SPEEDTEST_ASPECTS) +SPEEDTEST_INMEM_MAX = 8 * 1024 * 1024 + +SPEEDTEST_SIZES = [ + ("2 KB", 2 * 1024), + ("8 KB", 8 * 1024), + ("32 KB", 32 * 1024), + ("128 KB", 128 * 1024), + ("512 KB", 512 * 1024), + ("1 MB", 1024 * 1024), + ("4 MB", 4 * 1024 * 1024), + ("16 MB", 16 * 1024 * 1024), + ("64 MB", 64 * 1024 * 1024), + ("256 MB", 256 * 1024 * 1024), + ("1000 MB", 1000 * 1024 * 1024), + ("2000 MB", 2000 * 1024 * 1024), +] +SPEEDTEST_SIZE_LABELS = [label for label, _ in SPEEDTEST_SIZES] +SPEEDTEST_BYTES_BY_LABEL = {label: nbytes for label, nbytes in SPEEDTEST_SIZES} +DEFAULT_SPEEDTEST_LABEL = "1 MB" + +def _fmt_bitrate(bytes_per_sec): + bits = float(bytes_per_sec) * 8 + for unit in ("bps", "Kbps", "Mbps", "Gbps"): + if bits < 1000: + return "%.1f %s" % (bits, unit) + bits /= 1000 + return "%.1f Tbps" % bits + +def _size_str(num, suffix="B"): + num = float(num) + for unit in ("", "K", "M", "G", "T"): + if abs(num) < 1024.0: + return "%.1f %s%s" % (num, unit, suffix) + num /= 1024.0 + return "%.1f P%s" % (num, suffix) + +class SpeedTestShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Start / Cancel [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class SpeedTestView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = SpeedTestShortcuts(self.app) + + self.server_destination = None + self._listening = False + self._incoming_link = None + self._recv_link = None + self._recv_resource = None + self._recv_started = None + self._recv_polling = False + + self._sending = False + self._client_link = None + self._client_resource = None + self._send_started = None + self._send_bytes = 0 + self._send_seq = 0 + self._tmppath = None + + self.hash_edit = ReadlineEdit("", "") + self.size_dropdown = Dropdown("", SPEEDTEST_SIZE_LABELS, DEFAULT_SPEEDTEST_LABEL) + self.address_text = urwid.Text(("value", "-")) + self.status_text = urwid.Text("") + self.listen_status = urwid.Text("") + self.rate_text = urwid.Text("") + self.progress_bar = urwid.ProgressBar("progress_empty", "progress_full", current=0, done=100) + + self.start_button = urwid.AttrMap(urwid.Button("Send test file", on_press=self.on_start), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.start_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Speed test"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + self.listen_status, + urwid.Columns([ + (22, urwid.Text(("key", "Your speedtest hash:"), align=urwid.RIGHT)), + self.address_text, + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Both sides open this screen and enter each other's hash; then one presses Start.")), left=24), + urwid.Columns([ + (22, urwid.Text(("key", "Peer hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Columns([ + (22, urwid.Text(("key", "Test size:"), align=urwid.RIGHT)), + self.size_dropdown, + ], dividechars=1), + urwid.Divider(), + button_row, + urwid.Divider("─"), + urwid.Columns([ + (22, urwid.Text(("key", "Progress:"), align=urwid.RIGHT)), + self.progress_bar, + ], dividechars=1), + urwid.Padding(self.rate_text, left=24), + ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def _peer_hash_entered(self): + return len(self.hash_edit.edit_text.strip()) == (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + + def _update_listen_status(self): + dot = self.g.get("connected", "*") + if self._sending: + self.listen_status.set_text(("connected_status", "%s %s Sending test…" % (dot, self.g.get("arrow_u", "^")))) + elif self._recv_resource is not None or self._recv_link is not None: + self.listen_status.set_text(("connected_status", "%s %s Receiving test…" % (dot, self.g.get("arrow_d", "v")))) + elif self._listening and self.server_destination is not None: + if self._peer_hash_entered(): + self.listen_status.set_text(("connected_status", "%s Listening, ready to receive from or send to the entered peer" % dot)) + else: + self.listen_status.set_text(("warning_text", "%s Listening, enter the peer's hash to accept their test" % dot)) + else: + self.listen_status.set_text(("inactive_text", "Not listening")) + + def after_keypress(self): + self._update_listen_status() + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def _set_progress(self, pct): + try: + self.progress_bar.set_completion(max(0, min(100, pct))) + except Exception: + pass + + def _update_address(self): + if self.server_destination is not None: + self.address_text.set_text(("value", RNS.prettyhexrep(self.server_destination.hash))) + else: + self.address_text.set_text(("value", "-")) + + def start_listening(self): + if self.server_destination is None: + try: + self.server_destination = RNS.Destination(self.app.identity, RNS.Destination.IN, RNS.Destination.SINGLE, SPEEDTEST_APP_NAME, *SPEEDTEST_ASPECTS) + self.server_destination.set_link_established_callback(self._on_incoming_link) + try: + self.server_destination.accepts_links(True) + except Exception: + pass + except Exception as e: + self._log("Could not start listener: %s" % str(e), "error_text") + self.server_destination = None + self._listening = True + self._update_address() + self._update_listen_status() + + def stop_listening(self): + self._listening = False + self._recv_polling = False + try: + if self._incoming_link is not None: + self._incoming_link.teardown() + except Exception: + pass + self._incoming_link = None + self._recv_link = None + self._recv_resource = None + try: + if self.server_destination is not None: + RNS.Transport.deregister_destination(self.server_destination) + except Exception: + pass + self.server_destination = None + self._update_address() + self._update_listen_status() + + def _log_link_mtu(self, link): + try: + parts = [] + mtu = getattr(link, "mtu", None) + mdu = getattr(link, "mdu", None) + if mtu: + parts.append("MTU: %s (MDU %s)" % (RNS.prettysize(mtu), RNS.prettysize(mdu) if mdu else "?")) + rtt = getattr(link, "rtt", None) + if rtt: + if hasattr(RNS, "prettyshorttime"): + rtt_str = RNS.prettyshorttime(rtt) + else: + rtt_str = "%.0f ms" % (rtt * 1000) if rtt < 1 else "%.3f s" % rtt + parts.append("RTT: %s" % rtt_str) + if parts: + self._log("Link " + ", ".join(parts)) + except Exception: + pass + + def _is_active(self): + try: + md = self.app.ui.main_display + return self._listening and md.sub_displays.active_display is self.parent and getattr(self.parent, "active", None) == "speedtest" + except Exception: + return False + + def _entered_peer_hash(self): + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + return None + try: + return bytes.fromhex(hexhash) + except Exception: + return None + + def go_back(self): + self.cancel(silent=True) + self.stop_listening() + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _cleanup_temp(self): + if self._tmppath: + try: + os.remove(self._tmppath) + except Exception: + pass + self._tmppath = None + + def _on_incoming_link(self, link): + if not self._is_active(): + try: + link.teardown() + except Exception: + pass + return + self._incoming_link = link + link.set_remote_identified_callback(self._on_remote_identified) + self.app.ui.loop.set_alarm_in(max(8, self.app.rns.get_medium_path_timeout()), lambda l, u: self._incoming_timeout(link)) + + def _incoming_timeout(self, link): + if self._recv_link is link: + return + try: + link.teardown() + except Exception: + pass + + def _on_remote_identified(self, link, identity): + self._schedule(lambda: self._verify_incoming(link, identity)) + + def _verify_incoming(self, link, identity): + expected_hash = self._entered_peer_hash() + try: + identified_hash = RNS.Destination.hash_from_name_and_identity(SPEEDTEST_FULL_NAME, identity) + except Exception: + identified_hash = None + if expected_hash is None or identified_hash is None or identified_hash != expected_hash: + self._log("Rejected speed test from unauthorized peer", "warning_text") + try: + link.teardown() + except Exception: + pass + return + self._recv_link = link + self._recv_started = None + self._recv_resource = None + link.set_resource_strategy(RNS.Link.ACCEPT_ALL) + link.set_resource_started_callback(self._on_recv_started) + link.set_resource_concluded_callback(self._on_recv_concluded) + self._set_progress(0) + self._log("Incoming speed test from %s" % RNS.prettyhexrep(identity.hash), "connected_status") + self._log_link_mtu(link) + + def _on_recv_started(self, resource): + self._schedule(lambda: self._recv_started_now(resource)) + + def _recv_started_now(self, resource): + self._recv_resource = resource + if self._recv_started is None: + self._recv_started = time.time() + self.rate_text.set_text(("body_text", "Receiving…")) + self._update_listen_status() + if not self._recv_polling: + self._recv_polling = True + self.app.ui.loop.set_alarm_in(0.3, self._recv_poll) + + def _recv_poll(self, loop, user_data): + if not self._recv_polling or self._recv_resource is None: + return + failed = False + try: + if self._recv_resource.status in (RNS.Resource.FAILED, RNS.Resource.CORRUPT): + failed = True + elif self._recv_link is not None and self._recv_link.status == RNS.Link.CLOSED: + failed = True + except Exception: + pass + if failed: + self._recv_failed() + return + try: + pct = int(self._recv_resource.get_progress() * 100) + except Exception: + pct = 0 + self._set_progress(pct) + self.rate_text.set_text(("body_text", "Downloading… %d%%" % max(0, min(100, pct)))) + self._draw() + if self._recv_polling: + loop.set_alarm_in(0.3, self._recv_poll) + + def _on_recv_concluded(self, resource): + self._schedule(lambda: self._recv_concluded(resource)) + + def _recv_failed(self): + self._recv_polling = False + self._log("Incoming transfer failed", "warning_text") + self.rate_text.set_text("") + self._recv_resource = None + self._recv_link = None + self._update_listen_status() + + def _recv_concluded(self, resource): + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if not complete: + self._recv_failed() + return + try: + if resource.segment_index < resource.total_segments: + return + except Exception: + pass + self._recv_polling = False + size = self._resource_size(resource) + elapsed = max(0.001, time.time() - (self._recv_started or time.time())) + self._set_progress(100) + rate = _fmt_bitrate(size / elapsed) + self._log("Download: %s (%s in %.2fs)" % (rate, _size_str(size), elapsed), "connected_status") + self.rate_text.set_text(("connected_status", "Download: %s" % rate)) + self._recv_resource = None + self._recv_link = None + self._update_listen_status() + + def _resource_size(self, resource): + for getter in ("get_data_size", "get_transfer_size"): + try: + fn = getattr(resource, getter, None) + if fn: + v = fn() + if v: + return v + except Exception: + pass + try: + return getattr(resource, "size", 0) or len(resource.data) + except Exception: + return 0 + + def on_start(self, button=None): + if self._sending: + self.cancel() + return + self.update_interface_status() + + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + hexhash = self.hash_edit.edit_text.strip() + if len(hexhash) != dest_len: + self._log("Peer hash must be %d hex characters" % dest_len, "error_text") + return + try: + self._dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid peer hash", "error_text") + return + + label = self.size_dropdown.get_value() + self._send_bytes = SPEEDTEST_BYTES_BY_LABEL.get(label, 1024 * 1024) + + self._send_seq += 1 + self._sending = True + self.start_button.original_widget.set_label("Cancel") + self._set_progress(0) + self.rate_text.set_text("") + self._update_listen_status() + + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + else: + try: + RNS.Transport.request_path(self._dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(self._dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _timeout(self): + try: + return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout()) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def _await_path(self, loop, user_data): + if not self._sending: + return + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + elif time.time() > self._deadline: + self._log("Path not found", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _establish_link(self): + identity = RNS.Identity.recall(self._dest_hash) + if identity is None: + self._log("Could not recall identity for peer", "error_text") + self._finish() + return + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, SPEEDTEST_APP_NAME, *SPEEDTEST_ASPECTS) + self._log("Establishing link with %s …" % RNS.prettyhexrep(self._dest_hash)) + self._client_link = RNS.Link(destination, established_callback=self._on_client_link_up, closed_callback=self._on_client_link_closed) + except Exception as e: + self._log("Could not establish link: %s" % str(e), "error_text") + self._finish() + return + self._link_deadline = time.time() + max(self._timeout(), self._client_link.establishment_timeout) + self.app.ui.loop.set_alarm_in(0.5, self._check_link) + + def _check_link(self, loop, user_data): + if not self._sending or self._client_resource is not None: + return + try: + active = self._client_link is not None and self._client_link.status == RNS.Link.ACTIVE + except Exception: + active = False + if active: + return + if time.time() > self._link_deadline: + self._log("Link establishment timed out", "warning_text") + try: + self._client_link.teardown() + except Exception: + pass + self._finish() + else: + loop.set_alarm_in(0.5, self._check_link) + + def _on_client_link_up(self, link): + self._schedule(lambda: self._client_ready(link)) + + def _client_ready(self, link): + if not self._sending: + return + try: + link.identify(self.app.identity) + except Exception: + pass + self._log_link_mtu(link) + seq = self._send_seq + self.app.ui.loop.set_alarm_in(0.6, lambda l, u: self._prepare_and_send(link, seq)) + + def _prepare_and_send(self, link, seq): + if not self._sending or seq != self._send_seq or self._client_resource is not None: + return + nbytes = self._send_bytes + if nbytes <= SPEEDTEST_INMEM_MAX: + self._begin_resource(link, os.urandom(nbytes), None, seq) + return + self._log("Preparing %s payload …" % _size_str(nbytes)) + + def worker(): + path = None + try: + fd, path = tempfile.mkstemp(prefix="nn_speedtest_") + written = 0 + with os.fdopen(fd, "wb") as f: + while written < nbytes and self._sending and seq == self._send_seq: + chunk = min(1024 * 1024, nbytes - written) + f.write(os.urandom(chunk)) + written += chunk + except Exception as e: + if path: + try: + os.remove(path) + except Exception: + pass + self._schedule(lambda: (self._log("Could not prepare payload: %s" % str(e), "error_text"), self._finish())) + return + if not self._sending or seq != self._send_seq: + if path: + try: + os.remove(path) + except Exception: + pass + return + self._schedule(lambda: self._begin_resource(link, None, path, seq)) + + threading.Thread(target=worker, daemon=True).start() + + def _begin_resource(self, link, data_bytes, tmppath, seq): + if not self._sending or seq != self._send_seq: + if tmppath: + try: + os.remove(tmppath) + except Exception: + pass + return + self._tmppath = tmppath + try: + src = data_bytes if data_bytes is not None else open(tmppath, "rb") + self._send_started = time.time() + self._client_resource = RNS.Resource(src, link, callback=self._on_send_done, progress_callback=self._on_send_progress, auto_compress=False) + self._log("Sending %s test payload …" % _size_str(self._send_bytes)) + except Exception as e: + self._log("Could not start transfer: %s" % str(e), "error_text") + self._cleanup_temp() + self._finish() + + def _on_send_progress(self, resource): + self._schedule(lambda: self._send_progress(resource)) + + def _send_progress(self, resource): + try: + pct = int(resource.get_progress() * 100) + except Exception: + pct = 0 + self._set_progress(pct) + self.rate_text.set_text(("body_text", "Uploading… %d%%" % max(0, min(100, pct)))) + + def _on_send_done(self, resource): + self._schedule(lambda: self._send_done(resource)) + + def _send_done(self, resource): + if not self._sending: + return + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if complete: + self._set_progress(100) + elapsed = max(0.001, time.time() - (self._send_started or time.time())) + rate = _fmt_bitrate(self._send_bytes / elapsed) + self._log("Upload: %s (%s in %.2fs)" % (rate, _size_str(self._send_bytes), elapsed), "connected_status") + self.rate_text.set_text(("connected_status", "Upload: %s" % rate)) + else: + self._log("Transfer failed or was not accepted", "warning_text") + self.rate_text.set_text("") + try: + if self._client_link is not None: + self._client_link.teardown() + except Exception: + pass + self._cleanup_temp() + self._finish() + + def _on_client_link_closed(self, link): + self._schedule(self._client_closed) + + def _client_closed(self): + if self._sending and self._client_resource is None: + self._log("Link closed before the test started", "warning_text") + self._finish() + + def cancel(self, silent=False): + if not self._sending: + return + self._send_seq += 1 + self._sending = False + try: + if self._client_resource is not None: + self._client_resource.cancel() + except Exception: + pass + try: + if self._client_link is not None: + self._client_link.teardown() + except Exception: + pass + self._client_resource = None + self._cleanup_temp() + if not silent: + self._log("Speed test cancelled", "warning_text") + self.rate_text.set_text("") + self._set_progress(0) + self.start_button.original_widget.set_label("Send test file") + self._update_listen_status() + self._draw() + + def _finish(self): + self._sending = False + self._client_resource = None + self.start_button.original_widget.set_label("Send test file") + self._update_listen_status() + self._draw() diff --git a/nomadnet/ui/textui/__init__.py b/nomadnet/ui/textui/__init__.py index e3164b0..72302cb 100644 --- a/nomadnet/ui/textui/__init__.py +++ b/nomadnet/ui/textui/__init__.py @@ -1,5 +1,7 @@ import os import glob -modules = glob.glob(os.path.dirname(__file__)+"/*.py") -__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) diff --git a/nomadnet/ui/textui/images/__init__.py b/nomadnet/ui/textui/images/__init__.py new file mode 100644 index 0000000..619be17 --- /dev/null +++ b/nomadnet/ui/textui/images/__init__.py @@ -0,0 +1,18 @@ +# A minimal, dependency-free image widget for urwid, rendering images through +# the Kitty terminal graphics protocol. + +from ._termlib import get_cell_size, is_kitty_supported, reset_caches, diagnostics +from ._imagedata import ImageData, parse_image_header +from .widget import ImageWidget, ImageCanvas, UI_MARGIN +from .screen import ImageScreen + +__all__ = ( "ImageWidget", + "ImageCanvas", + "ImageScreen", + "ImageData", + "parse_image_header", + "is_kitty_supported", + "get_cell_size", + "diagnostics", + "reset_caches", + "UI_MARGIN" ) diff --git a/nomadnet/ui/textui/images/_ctlseqs.py b/nomadnet/ui/textui/images/_ctlseqs.py new file mode 100644 index 0000000..564b7d2 --- /dev/null +++ b/nomadnet/ui/textui/images/_ctlseqs.py @@ -0,0 +1,78 @@ +# Kitty Terminal Graphics Protocol control sequences +# +# Minimal set of control sequences needed for the Kitty graphics protocol +# side-channel image rendering used by the urwid image widget. + +from base64 import standard_b64encode +import re + +# Basic building blocks +APC = b"\x1b_" # Application Program Command +ST = b"\x1b\\" # String Terminator +CSI = b"\x1b[" + +# Kitty graphics: transmit-and-display with chunking support. +# ESC _ G ; ESC \ +KITTY_TRANSMISSION = b"\x1b_G%s;%s\x1b\\" + +# Delete images: d=A (all placements visible on screen), d=i (one +# placement, data kept), d=I (whole image, data freed). +KITTY_DELETE_ALL = b"\x1b_Ga=d,d=A\x1b\\" +KITTY_DELETE_PLACEMENT = b"\x1b_Ga=d,d=i,i=%d,p=%d\x1b\\" +KITTY_DELETE_IMAGE = b"\x1b_Ga=d,d=I,i=%d\x1b\\" + +# Support query (payload is base64 of 4 zero bytes; expects "i=31;OK") +KITTY_QUERY = b"\x1b_Ga=q,t=d,i=31,f=24,s=1,v=1,C=1,c=1,r=1;AAAA\x1b\\" +KITTY_QUERY_RESPONSE_RE = re.compile(b"\x1b_Gi=31;(?P[^\x1b]*)\x1b\\\\") + +# Cursor: absolute positioning, erase characters, cursor forward +CURSOR_POSITION = b"\x1b[%d;%dH" +ERASE_CHARS = b"\x1b[%dX" + +# Synchronized output (DEC 2026) - reduces flicker/tearing when redrawing +BEGIN_SYNCED_UPDATE = b"\x1b[?2026h" +END_SYNCED_UPDATE = b"\x1b[?2026l" + +# Terminal queries: cell size in pixels (XTWINOPS 16) + primary DA +CELL_SIZE_PX = b"\x1b[16t" +DA1 = b"\x1b[c" +# Response: CSI 4 ; ; t +CELL_SIZE_RESPONSE_RE = re.compile(b"\x1b\\[4;(?P\\d+);(?P\\d+)t") + +# Maximum payload (base64) per transmission chunk, as recommended by the +# protocol documentation +CHUNK_SIZE = 4096 + +# Control data for a transmit-only (a=t) with a quiet mode (q=1) +def kitty_transmit_control(image_id): return b"a=t,i=%d,f=100,t=d,q=1" % image_id + +# A placement command (a=p) for a previously transmitted image. +# Re-sending the same (image id, placement id) pair replaces the +# placement in place - used to move/resize/slice without flicker. +# y/h select the source rectangle (image pixels) when a slice of +# the image is displayed. +def kitty_placement(image_id, placement_id, cols, rows, z, y=None, h=None): + control = b"a=p,i=%d,p=%d,c=%d,r=%d,z=%d,C=1,q=1" % (image_id, placement_id, cols, rows, z) + if y is not None and h is not None: control += b",y=%d,h=%d" % (y, h) + return KITTY_TRANSMISSION % (control, b"") + +# Encodes a kitty graphics transmission, chunking the base64-encoded +# payload into ``chunk_size``-byte pieces. +def kitty_transmission(control, payload, chunk_size=CHUNK_SIZE): + encoded = standard_b64encode(payload) + chunks = [encoded[i:i + chunk_size] for i in range(0, len(encoded), chunk_size)] + if not chunks: + chunks = [b""] + + def control_for(index, n_chunks): + if n_chunks == 1: + return control + if index == 0: + # The control data appears only in the first chunk; the chunk + # marker says more chunks are coming. + return control + b",m=1" + if index == n_chunks - 1: + return b"m=0" + return b"m=1" + + return b"".join(KITTY_TRANSMISSION % (control_for(i, len(chunks)), chunk) for i, chunk in enumerate(chunks)) diff --git a/nomadnet/ui/textui/images/_imagedata.py b/nomadnet/ui/textui/images/_imagedata.py new file mode 100644 index 0000000..a78045a --- /dev/null +++ b/nomadnet/ui/textui/images/_imagedata.py @@ -0,0 +1,142 @@ +# 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 diff --git a/nomadnet/ui/textui/images/_imagestore.py b/nomadnet/ui/textui/images/_imagestore.py new file mode 100644 index 0000000..78935b5 --- /dev/null +++ b/nomadnet/ui/textui/images/_imagestore.py @@ -0,0 +1,90 @@ +# Registry of terminal-side image data +# +# Tracks which image payloads have been transmitted to the terminal (Kitty +# image ids), de-duplicates identical image data across widgets (one payload +# transmitted once, placed many times), and counts live widget references so +# that orphaned data can be evicted from the terminal's buffers. +# +# This module only tracks *state*; all protocol output is performed by +# ImageScreen, which reads and updates the store. +# +# Eviction: +# +# - Automatic, widgets unregister when closed/destroyed; the screen evicts +# (deletes from the terminal) any registered data with no live references. +# - Explicit, the browser can call ImageScreen.purge_images() on page +# navigation to drop everything at once. + +DEFAULT_IMAGE_ID_MAX = 2 ** 32 - 1 # Protocol limit for image ids + +class ImageStore(object): + def __init__(self): + self._entries = {} # content key -> {"image_id", "data", "refs", "transmitted"} + self._next_image_id = 1 + + ############# + # Lifecycle # + ############# + + # Registers image data and returns (key, is_new). + # If identical data is already registered, the existing entry is + # reused (de-duplication) and its reference count is incremented. + def register(self, key, data): + entry = self._entries.get(key) + if entry is None: + image_id = self._next_image_id + self._next_image_id += 1 + if image_id > DEFAULT_IMAGE_ID_MAX: raise OverflowError("image id space exhausted") + entry = { "image_id": image_id, "data": data, + "refs": 0, "transmitted": False } + self._entries[key] = entry + is_new = True + else: is_new = False + + entry["refs"] += 1 + return key, is_new + + # Decrements the reference count of registered data + def unregister(self, key): + entry = self._entries.get(key) + if entry is None: return + entry["refs"] = max(0, entry["refs"] - 1) + + ########### + # Queries # + ########### + + def entry_for(self, key): return self._entries.get(key) + + def transmitted(self): return [key for key, entry in self._entries.items() if entry["transmitted"]] + + # Returns [(key, image_id), ...] for entries with no live + # widget references that are still present in the terminal. + def evict_orphans(self): + return [ (key, entry["image_id"]) + for key, entry in self._entries.items() + if entry["refs"] == 0 and entry["transmitted"] ] + + ############ + # Mutation # + ############ + + def mark_transmitted(self, key): + entry = self._entries.get(key) + if entry is not None: + entry["transmitted"] = True + + def mark_all_untransmitted(self): + for entry in self._entries.values(): + entry["transmitted"] = False + + # Drops the entry for evicted data (frees the payload bytes) + def evict(self, key): self._entries.pop(key, None) + + # Forgets all state (used by tests) + def reset(self): + self._entries.clear() + self._next_image_id = 1 + +# Module-level singleton shared by widgets and the screen. +image_store = ImageStore() diff --git a/nomadnet/ui/textui/images/_selftest.py b/nomadnet/ui/textui/images/_selftest.py new file mode 100644 index 0000000..ce132c4 --- /dev/null +++ b/nomadnet/ui/textui/images/_selftest.py @@ -0,0 +1,718 @@ +#!/usr/bin/env python3 +""" +.. Images - headless self test + +Exercises the widget + screen machinery without a terminal: image header +parsing, display sizing, shard scanning, APC construction/chunking and the +transmit/delete state machine at various scroll positions. + +Run:: + + python3 -m nomadnet.ui.textui.images._selftest [image-file] +""" + +import base64 +import io +import os +import shutil +import struct +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) + +import urwid + +from nomadnet.ui.textui.images import _ctlseqs +from nomadnet.ui.textui.images import _imagestore +from nomadnet.ui.textui.images import _termlib +from nomadnet.ui.textui.images._imagedata import ImageData, parse_image_header +from nomadnet.ui.textui.images.screen import ImageScreen +from nomadnet.ui.textui.images.widget import ImageWidget + +DEFAULT_IMAGE = os.path.expanduser("~/Scratchpad/test.png") +VIEWPORT = (80, 24) +CELL_SIZE = (1, 2) # forced fallback for deterministic layout + +PASS = 0 +FAIL = 0 + + +def check(cond, label): + global PASS, FAIL + if cond: + PASS += 1 + print(" ok - %s" % label) + else: + FAIL += 1 + print(" FAIL - %s" % label) + + +def build_pile(image_path, wrap_attrmap=False): + widgets = [] + for i in range(12): + widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % i)) # 3 rows each + img = ImageWidget(image_path) + if wrap_attrmap: + img = urwid.AttrMap(img, "body") + widgets.append(urwid.Divider(" ")) + widgets.append(img) + widgets.append(urwid.Divider(" ")) + for i in range(12): + widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % (13 + i))) + return urwid.Pile(widgets) + + +class RecordingScreen(ImageScreen): + def __init__(self): + super().__init__() + self.log = [] + + def write(self, data): + if isinstance(data, bytes): + data = data.decode("ascii") + self.log.append(data) + return data + + def flush(self): + pass + + +def extract_transmit(log): + """Returns (image_id, joined_b64_payload) of the a=t transmission, or + None if no transmission is present.""" + image_id = None + payloads = [] + for item in log: + if item.startswith("\x1b_Ga=t"): + for piece in item.split("\x1b_G")[1:]: + body = piece[:-2] # strip the trailing ST (ESC \) + head, sep, payload = body.partition(";") + if not sep: + continue + if image_id is None: + image_id = int(head.split("i=")[1].split(",")[0]) + payloads.append(payload) + if image_id is None: + return None + return image_id, "".join(payloads) + + +def extract_placements(log): + """Returns the control data of every a=p placement command.""" + return [ + item[3:-2] + for item in log + if item.startswith("\x1b_Ga=p") + ] + + +def extract_deletes(log): + """Returns the control data of every a=d delete command.""" + return [ + item[3:-2] + for item in log + if item.startswith("\x1b_Ga=d") + ] + + +def screen_probe_placement_key(widget, row, col, canvas_cols, trim_top, vis_rows): + """Computes an ImageScreen placement key without a screen instance.""" + return ImageScreen._placement_key( + None, widget, row, col, canvas_cols, trim_top, vis_rows) + + +def test_header_parse(image_path): + print("== header parsing ==") + with open(image_path, "rb") as f: + data = f.read() + fmt, w, h = parse_image_header(data) + check(fmt == "PNG", "detected PNG") + check(w == 1280 and h == 960, "dimensions %dx%d" % (w, h)) + imgdata = ImageData(image_path) + check(imgdata.ok, "ImageData loads without error") + check(imgdata.size == (w, h), "ImageData size matches") + + +def test_transmission_encoding(image_path): + print("== APC encoding/chunking ==") + small = b"\x89PNG\r\n\x1a\n" + b"x" * 100 + single = _ctlseqs.kitty_transmission(b"a=T,f=100,c=80,r=30,z=1", small) + check(single.count(b"\x1b_G") == 1, "single-chunk transmission has one APC") + check(single.startswith(b"\x1b_Ga=T,f=100,c=80,r=30,z=1;"), "control data correct") + check(single.endswith(b"\x1b\\"), "ends with ST") + payload = single.split(b";", 1)[1][:-2] + check(base64.b64decode(payload) == small, "payload round-trips to original file") + + with open(image_path, "rb") as f: + data = f.read() + chunks = _ctlseqs.kitty_transmission(b"a=T,f=100", data, chunk_size=16) + apcs = chunks.split(b"\x1b\\")[:-1] + check(len(apcs) > 1, "multi-chunk transmission splits into %d APCs" % len(apcs)) + first, last = apcs[0], apcs[-1] + heads = [a.split(b";", 1)[0][3:] for a in apcs] + check(b",m=1;" in first, "first chunk carries control + m=1") + check(last.startswith(b"\x1b_Gm=0;"), "last chunk carries m=0") + check(sum(b"a=T" in h for h in heads) == 1, "control data appears only once") + check(all(h == b"m=1" or h == b"m=0" for h in heads[1:]), "continuation chunks carry only the chunk marker") + joined_b64 = b"".join(a.split(b";", 1)[1] for a in apcs) + check(base64.b64decode(joined_b64) == data, "multi-chunk payload reassembles") + + +_TINY_WEBP = base64.b64decode( + "UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuUAAA=") + + +def _webp_fixture(): + """Returns (webp_bytes, (width, height)): a 4x4 WebP generated with + Pillow when available (more representative), else a 1x1 constant.""" + try: + from PIL import Image + if _webp_module_available(): + image = Image.new("RGBA", (4, 4)) + for y in range(4): + for x in range(4): + image.putpixel((x, y), (x * 60, y * 60, x * y * 10, 255)) + buf = io.BytesIO() + image.save(buf, format="WEBP") + data = buf.getvalue() + if parse_image_header(data)[0] == "WebP": + return data, (4, 4) + except Exception: + pass + return _TINY_WEBP, (1, 1) + + +def _webp_module_available(): + try: + from PIL import features + return bool(features.check("webp")) + except Exception: + return False + + +def test_webp_conversion(): + print("== webp conversion ==") + from nomadnet.ui.textui.images import _webp + from nomadnet.ui.textui.images._imagedata import MAX_CONVERTED_PAYLOAD_BYTES + + fmt, w, h = parse_image_header(_TINY_WEBP) + check((fmt, w, h) == ("WebP", 1, 1), "embedded fixture is a 1x1 WebP") + + fixture, dims = _webp_fixture() + check(parse_image_header(fixture)[1:] == dims, + "webp fixture has expected dimensions") + + cache_tmp = tempfile.mkdtemp(prefix="imgw-test-cache-") + prev_cache_env = os.environ.get("NOMADNET_IMAGE_CACHE") + os.environ["NOMADNET_IMAGE_CACHE"] = cache_tmp + try: + # Each available backend must convert the fixture to a valid PNG + # with matching dimensions. Backends are invoked directly, since + # the on-disk cache would otherwise mask all but the first one. + _webp.reset() + present = [name for name, avail in _webp.available_backends() if avail] + converted = 0 + for name in present: + png = _webp._BACKENDS[name](fixture, dims) + ok = (png is not None and png.startswith(b"\x89PNG") + and _webp._png_dims(png) == dims) + check(ok, "%s backend converts to a valid PNG" % name) + if ok: + converted += 1 + check(converted >= 1, "at least one backend converts webp (got %d)" % converted) + + # Repeat conversion is served from the on-disk cache: the backend + # table is disabled and the conversion still succeeds. + _webp.reset() + png1 = _webp.convert_webp_to_png(fixture) + check(png1 is not None, "first conversion succeeds") + orig_backends = dict(_webp._BACKENDS) + _webp._BACKENDS = {name: (lambda *a, **k: None) for name in _webp.BACKENDS} + png2 = _webp.convert_webp_to_png(fixture) + check(png1 == png2 and _webp.stats()["cache_hits"] >= 1, + "repeat conversion served from the on-disk cache") + _webp._BACKENDS = orig_backends + + # ImageData-level transparency: a webp file loads as a PNG. + _webp.reset() + fd, webp_path = tempfile.mkstemp(suffix=".webp") + with os.fdopen(fd, "wb") as f: + f.write(fixture) + d = ImageData(webp_path) + check(d.ok and d.format == "PNG" and d.size == dims, + "ImageData converts webp transparently to PNG") + check(d.data.startswith(b"\x89PNG\r\n\x1a\n") and d.key is not None, + "converted payload is a keyed PNG") + check("-> PNG" in d.description(), "description notes the conversion") + os.unlink(webp_path) + + # Converted-payload cap. + _webp.reset() + orig_convert = _webp.convert_webp_to_png + _webp.convert_webp_to_png = lambda data: b"x" * (MAX_CONVERTED_PAYLOAD_BYTES + 1) + fd, webp_path = tempfile.mkstemp(suffix=".webp") + with os.fdopen(fd, "wb") as f: + f.write(fixture) + d = ImageData(webp_path) + check(not d.ok and "Converted image too large" in d.error, + "converted payload capped at %d MiB" + % (MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024))) + os.unlink(webp_path) + _webp.convert_webp_to_png = orig_convert + + # Undecodable webp (valid header/dimensions, garbage bitstream). + _webp.reset() + junk = (b"RIFF" + struct.pack(" (10, 20)") + check(_termlib._cell_size_from_winsize([24, 80, 960, 480]) == (12, 20), + "correct winsize -> (12, 20)") + check(_termlib._cell_size_from_winsize([24, 80, 0, 0]) is None, + "zero pixel fields -> None") + check(_termlib._cell_size_from_winsize([40, 100, 800, 1000]) == (8, 25), + "portrait window cell keeps valid aspect") + check(_termlib._cell_size_from_winsize([30, 80, 1200, 240]) is None, + "implausible aspect (broken reporting) -> None") + check(_termlib._cell_size_from_winsize([0, 0, 0, 0]) is None, + "empty winsize -> None") + + +def test_widget_layout(image_path): + print("== widget layout ==") + _termlib.kitty_supported = True + _termlib.cell_size = CELL_SIZE + # Force a deterministic terminal size for the height-percent cases. + _orig_terminal_size = _termlib.get_terminal_size + _termlib.get_terminal_size = lambda: (80, 24) + _orig_ui_margin = None + from nomadnet.ui.textui.images import widget as _widget_mod + _orig_ui_margin = _widget_mod.UI_MARGIN + _widget_mod.UI_MARGIN = 8 + try: + imgdata = ImageData(image_path) + w = ImageWidget(image_path) + iw, ih = imgdata.size + expected_rows = max(1, round(80 * CELL_SIZE[0] * ih / (CELL_SIZE[1] * iw))) + rows = w.rows((80,)) + check(rows == expected_rows, + "default: rows((80,)) == %d, got %d" % (expected_rows, rows)) + canvas = w.render((80,)) + check(isinstance(canvas, urwid.SolidCanvas), "render returns a solid canvas") + check(canvas.rows() == rows and canvas.cols() == 80, "canvas sized 80x%d" % rows) + check(w._ti_rows == rows and w._ti_cols == 80, "widget stores display size") + w.close() + + # width as absolute columns (aspect preserved); the canvas fills the + # allocated width while the image rect is the resolved size. + w = ImageWidget(image_path, width=40) + c, r = w._display_size(80) + check((c, r) == (40, 15), "width=40 -> (40, 15), got (%d, %d)" % (c, r)) + canvas = w.render((80,)) + check((canvas.cols(), canvas.rows()) == (80, 15), + "width=40 canvas fills the row (80, 15), got (%d, %d)" + % (canvas.cols(), canvas.rows())) + check((w._ti_cols, w._ti_rows) == (40, 15), + "width=40 image rect stored (40, 15)") + w.close() + + # width as percent of the layout width + w = ImageWidget(image_path, width="50%") + c, r = w._display_size(80) + check((c, r) == (40, 15), "width=50%% -> (40, 15), got (%d, %d)" % (c, r)) + w.close() + + # height as absolute rows (width derived, aspect preserved) + w = ImageWidget(image_path, height=20) + c, r = w._display_size(80) + check(r == 20 and c == 53, "height=20 -> (53, 20), got (%d, %d)" % (c, r)) + w.close() + + # height as percent of the visible page height + # page = terminal lines (24) - UI_MARGIN (8) = 16; 50% -> 8 rows + w = ImageWidget(image_path, height="50%") + c, r = w._display_size(80) + check(r == 8 and c == 21, "height=50%% -> (21, 8), got (%d, %d)" % (c, r)) + w.close() + + # both given: stretch to the exact rectangle + w = ImageWidget(image_path, width=40, height=30) + c, r = w._display_size(80) + check((c, r) == (40, 30), "width=40,height=30 stretches -> (40, 30), got (%d, %d)" % (c, r)) + w.close() + + # stretching is clamped uniformly when overflowing the space + w = ImageWidget(image_path, width=400, height=200) + c, r = w._display_size(80) + check((c, r) == (80, 40), "overflowing stretch clamped -> (80, 40), got (%d, %d)" % (c, r)) + w.close() + + # BOX layout: the canvas fills the box; the image rect is clamped + # and re-derived (width from the clamped height). + w = ImageWidget(image_path, name="box") + canvas = w.render((80, 10)) + check((canvas.cols(), canvas.rows()) == (80, 10), + "BOX render((80,10)) canvas fills the box, got (%d, %d)" + % (canvas.cols(), canvas.rows())) + check((w._ti_cols, w._ti_rows) == (27, 10), + "BOX image rect clamped -> (27, 10), got (%d, %d)" + % (w._ti_cols, w._ti_rows)) + w.close() + + # invalid specifications + def raises(ctor, want): + try: + ctor() + except want: + return True + except Exception: + return False + return False + check(raises(lambda: ImageWidget(image_path, width="xx"), ValueError), + "width='xx' raises ValueError") + check(raises(lambda: ImageWidget(image_path, height=0), ValueError), + "height=0 raises ValueError") + check(raises(lambda: ImageWidget(image_path, width=1.5), TypeError), + "width=1.5 raises TypeError") + check(raises(lambda: ImageWidget(image_path, height="50"), ValueError), + "height='50' raises ValueError") + + # settable properties re-resolve and invalidate + w = ImageWidget(image_path) + w.width = "50%" + c, r = w._display_size(80) + check((c, r) == (40, 15), "set width=50%% property -> (40, 15)") + w.height = 10 + c, r = w._display_size(80) + check((c, r) == (40, 10), "width+height set -> stretch (40, 10), got (%d, %d)" % (c, r)) + w.width = None + w.height = 10 + c, r = w._display_size(80) + check((c, r) == (27, 10), "height-only after reset -> (27, 10), got (%d, %d)" % (c, r)) + w.close() + + # 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()) diff --git a/nomadnet/ui/textui/images/_termlib.py b/nomadnet/ui/textui/images/_termlib.py new file mode 100644 index 0000000..a974e84 --- /dev/null +++ b/nomadnet/ui/textui/images/_termlib.py @@ -0,0 +1,199 @@ +# 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 diff --git a/nomadnet/ui/textui/images/_webp.py b/nomadnet/ui/textui/images/_webp.py new file mode 100644 index 0000000..9238216 --- /dev/null +++ b/nomadnet/ui/textui/images/_webp.py @@ -0,0 +1,367 @@ +# Multi-backend WebP decoding +# +# Kitty's graphics protocol decodes PNG natively but not WebP, so WebP +# sources are converted transparently at the ImageData layer. This module +# provides a conversion chain 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 diff --git a/nomadnet/ui/textui/images/demo.webp b/nomadnet/ui/textui/images/demo.webp new file mode 100644 index 0000000..82b04ab Binary files /dev/null and b/nomadnet/ui/textui/images/demo.webp differ diff --git a/nomadnet/ui/textui/images/screen.py b/nomadnet/ui/textui/images/screen.py new file mode 100644 index 0000000..c7a9f59 --- /dev/null +++ b/nomadnet/ui/textui/images/screen.py @@ -0,0 +1,210 @@ +import urwid + +from . import _ctlseqs +from ._imagestore import image_store +from ._termlib import is_kitty_supported +from .widget import ImageWidget + +# A raw display screen subclass with Kitty TGP side-channel support +class ImageScreen(urwid.raw_display.Screen): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ti_images = {} + + ########## + # Output # + ########## + + def write(self, data): + if isinstance(data, bytes): data = data.decode("ascii") + return super().write(data) + + ############# + # Lifecycle # + ############# + + def clear(self): + # Clears all images and forces a full repaint + if is_kitty_supported(): + self.write(_ctlseqs.KITTY_DELETE_ALL) + self._ti_images = {} + image_store.mark_all_untransmitted() + return super().clear() + + def _start(self, *args, **kwargs): + ret = super()._start(*args, **kwargs) + if is_kitty_supported(): + # Clear any images left over from a previous run/instance. + self.write(_ctlseqs.KITTY_DELETE_ALL) + self._ti_images = {} + image_store.mark_all_untransmitted() + return ret + + def _stop(self): + self.purge_images() + try: return super()._stop() + finally: self._ti_images = {} + + def purge_images(self): + # Purges all image data from the terminal buffer. + if not is_kitty_supported(): return + if self._ti_images or image_store.transmitted(): + self.write(_ctlseqs.KITTY_DELETE_ALL) + self._ti_images = {} + image_store.mark_all_untransmitted() + + ########### + # Drawing # + ########### + + def draw_screen(self, size, canvas): + # Draws the canvas, then updates on-screen images within a + # synchronized-update region to prevent flicker. + self.write(_ctlseqs.BEGIN_SYNCED_UPDATE) + try: + super().draw_screen(size, canvas) + self._ti_update(canvas) + finally: + self.write(_ctlseqs.END_SYNCED_UPDATE) + self.flush() + + def _ti_update(self, canvas): + # Places/updates/deletes image placements to match the current + # canvas, and evicts orphaned image data. + if not is_kitty_supported(): return + + entries = self._ti_scan(canvas) + current = {} + for widget, row, col, canvas_cols, trim_top, vis_rows in entries: + current[id(widget)] = (widget, row, col, canvas_cols, trim_top, vis_rows) + + # For widgets that scrolled completely out of view, remove their + # placement only. The image data is kept in the terminal so that + # scrolling back just re-places it. + for wid, info in list(self._ti_images.items()): + if wid in current: continue + self._ti_unplace(info["widget"]) + del self._ti_images[wid] + + # For visible widgets, place/update the visible slice in place. + for wid, (widget, row, col, canvas_cols, trim_top, vis_rows) in current.items(): + info = self._ti_images.setdefault(wid, {"widget": widget, "drawn": None}) + if vis_rows <= 0 or trim_top >= widget._ti_rows: + if info["drawn"] is not None: + self._ti_unplace(widget) + info["drawn"] = None + continue + + key = self._placement_key(widget, row, col, canvas_cols, trim_top, vis_rows) + if key is None: + if info["drawn"] is not None: + self._ti_unplace(widget) + info["drawn"] = None + continue + + if info["drawn"] == key: continue + self._ti_place(widget, key) + info["drawn"] = key + + # Evict image data that no live widget references any more + for key, image_id in image_store.evict_orphans(): + self.write(_ctlseqs.KITTY_DELETE_IMAGE % image_id) + image_store.evict(key) + + ############# + # Internals # + ############# + + def _ti_scan(self, canvas): + # Walks the canvas shards and returns a list of + # (widget, row, col, canvas_cols, trim_top, visible_rows) + # for every visible image canvas. + found = [] + if not isinstance(canvas, urwid.CompositeCanvas): return found + + def process_shard_tails(): + nonlocal col + while col in shard_tails: + *trim, cols, rows, canv = shard_tails[col] + if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv) + else: del shard_tails[col] + col += cols + + shard_tails = {} + row = 0 + for n_rows, cviews in canvas.shards: + col = 0 + for cview in cviews: + process_shard_tails() + *trim, cols, rows, _, canv = cview + widget = self._ti_widget_of(canv) + if widget is not None: found.append((widget, row, col, cols, trim[1], rows)) + if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv) + col += cols + + process_shard_tails() + row += n_rows + + return found + + @staticmethod + def _ti_widget_of(canv): + # Returns the ImageWidget for a leaf canvas, or None + widget_info = getattr(canv, "_widget_info", None) + if not widget_info: return None + widget = widget_info[0] + if isinstance(widget, ImageWidget) and widget._ti_data.ok and not widget._ti_closed: return widget + return None + + def _placement_key(self, widget, row, col, canvas_cols, trim_top, vis_rows): + # Compute placement parameters for the visible slice of + # an image, including the alignment offset. + + cols = widget._ti_cols + rows = widget._ti_rows + if not (cols and rows): return None + place_col = col + if cols < canvas_cols: + align = widget._ti_align + spare = canvas_cols - cols + if align == ">": place_col += spare + elif align == "|": place_col += spare // 2 + + vis_rows = max(1, min(vis_rows, rows)) + y_px = h_px = None + if not (trim_top == 0 and vis_rows == rows): + # Crop to the visible slice of the image, in image pixels + ih = widget._ti_data.height + y_px = min(ih - 1, round(trim_top * ih / rows)) + h_px = max(1, min(ih - y_px, round(vis_rows * ih / rows))) + + return (row, place_col, cols, vis_rows, y_px, h_px) + + def _ti_place(self, widget, key): + # Place (or in-place update) the visible slice of an image + row, place_col, cols, vis_rows, y_px, h_px = key + entry = image_store.entry_for(widget._ti_key) + if entry is None: + # This should not happen, but if the image data was evicted + # while the image was still alive, re-register it so the + # data can be re-transmitted to the terminal buffer. + image_store.register(widget._ti_key, widget._ti_data.data) + entry = image_store.entry_for(widget._ti_key) + image_id = entry["image_id"] + + if not entry["transmitted"]: + payload = widget._ti_data.data + b64_len = (len(payload) * 4 + 2) // 3 + n_chunks = max(1, (b64_len + _ctlseqs.CHUNK_SIZE - 1) // _ctlseqs.CHUNK_SIZE) + self.write(_ctlseqs.kitty_transmission(_ctlseqs.kitty_transmit_control(image_id), payload)) + entry["transmitted"] = True + + self.write(_ctlseqs.CURSOR_POSITION % (row + 1, place_col + 1)) + self.write(_ctlseqs.kitty_placement(image_id, widget._ti_z, cols, vis_rows, widget._ti_z, y=y_px, h=h_px)) + + def _ti_unplace(self, widget): + # Delete a widget's placement, but keep the + # image data in the terminal buffer. + entry = image_store.entry_for(widget._ti_key) + if entry is None: return + self.write(_ctlseqs.KITTY_DELETE_PLACEMENT % (entry["image_id"], widget._ti_z)) diff --git a/nomadnet/ui/textui/images/widget.py b/nomadnet/ui/textui/images/widget.py new file mode 100644 index 0000000..30dd3d0 --- /dev/null +++ b/nomadnet/ui/textui/images/widget.py @@ -0,0 +1,283 @@ +# 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) diff --git a/nomadnet/util.py b/nomadnet/util.py new file mode 100644 index 0000000..4c3a079 --- /dev/null +++ b/nomadnet/util.py @@ -0,0 +1,222 @@ +import os +import re +import unicodedata + + +def write_config_atomic(config): + path = getattr(config, "filename", None) + if not path: + config.write() + return + + directory = os.path.dirname(path) or "." + tmp = os.path.join(directory, "."+os.path.basename(path)+".tmp") + try: + try: + existing_mode = os.stat(path).st_mode + except OSError: + existing_mode = None + + with open(tmp, "wb") as tmp_file: + config.write(tmp_file) + tmp_file.flush() + try: os.fsync(tmp_file.fileno()) + except Exception: pass + + if existing_mode is not None: + try: os.chmod(tmp, existing_mode) + except Exception: pass + + os.replace(tmp, path) + except Exception: + try: os.remove(tmp) + except Exception: pass + raise + + +invalid_rendering = ["🕵️", "☝"] + +# Unicode blocks to strip (symbols, dingbats, emoji ranges) +STRIP_BLOCKS_RE = re.compile( + '[' + '\U0001F600-\U0001F64F' # Emoticons + '\U0001F300-\U0001F5FF' # Misc Symbols & Pictographs + '\U0001F680-\U0001F6FF' # Transport & Map Symbols + '\U0001F700-\U0001F77F' # Alchemical Symbols + '\U0001F780-\U0001F7FF' # Geometric Shapes Extended + '\U0001F800-\U0001F8FF' # Supplemental Arrows-C + '\U0001F900-\U0001F9FF' # Supplemental Symbols & Pictographs + '\U0001FA00-\U0001FA6F' # Chess Symbols + '\U0001FA70-\U0001FAFF' # Symbols & Pictographs Extended-A + '\U0001F1E0-\U0001F1FF' # Flags (iOS/regional indicators) + '\u2600-\u26FF' # Misc Symbols (☀, ☁, ☂, etc.) + '\u2700-\u27BF' # Dingbats (✂, ✈, ✉, ✌, etc.) + '\uFE00-\uFE0F' # Variation Selectors + '\U000E0100-\U000E01EF' # Variation Selectors Supplement + '\U0001F3FB-\U0001F3FF' # Emoji modifiers (skin tones) + ']+', + flags=re.UNICODE +) + +# Control characters and zero-width characters to strip +STRIP_CONTROL_RE = re.compile( + '[' + '\x00-\x08' # C0 controls (NUL-BS) + '\x0B\x0C' # VT, FF + '\x0E-\x1F' # C0 controls (SO-US) + '\x7F-\x9F' # DEL and C1 controls + '\u200B-\u200F' # Zero-width chars, LRM, RLM, etc. + '\u202A-\u202E' # Bidi embedding controls + '\u2060-\u206F' # Format chars (word joiner, etc.) + '\uFEFF' # BOM / Zero Width NBSP + '\uFFF0-\uFFF8' # Specials + ']+', + flags=re.UNICODE +) + +# Surrogates and private use areas +# Shouldn't appear in valid UTF-8, but strip just in case +STRIP_PRIVATE_RE = re.compile( + '[' + '\uD800-\uDFFF' # Surrogates + '\uE000-\uF8FF' # Private Use Area + '\uF900-\uFAFF' # CJK Compatibility Ideographs (keep? strip for safety) + '\uFE10-\uFE1F' # Vertical Forms + '\uFE20-\uFE2F' # Combining Half Marks + '\U000F0000-\U000FFFFF' # Supplementary Private Use Area-A + '\U00100000-\U0010FFFF' # Supplementary Private Use Area-B + ']+', + flags=re.UNICODE +) + +def strip_modifiers(text): + def process_characters(text): + result = [] + i = 0 + while i < len(text): + char = text[i] + category = unicodedata.category(char) + + if category.startswith(('L', 'N', 'P', 'S')): + result.append(char) + i += 1 + + elif category.startswith(('M', 'Sk', 'Cf')) or char in '\u200d\u200c': + i += 1 + + else: + result.append(char) + i += 1 + + return ''.join(result) + + if text == None: return None + + for char in invalid_rendering: + text = text.replace(char, " ") + + stripped = process_characters(text) + stripped = re.sub(r'[\uFE00-\uFE0F]', '', stripped) + stripped = re.sub(r'[\U000E0100-\U000E01EF]', '', 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'\r\n?', '\n', stripped) + stripped = STRIP_CONTROL_RE.sub('', stripped) + + return stripped.strip() + +def sanitize_name(name): + if name is None: return None + + # Convert to string and normalize to NFKC + # NFKC: Compatibility decomposition followed by canonical composition + # This handles: ① to 1, Ⅰ to I, etc., while keeping composed forms + name = str(name) + name = unicodedata.normalize('NFKC', name) + + # Build result using category-based filtering + result = [] + for char in name: + cat = unicodedata.category(char) + cat_prefix = cat[0] if cat else 'C' + + # Allow letters (L*), numbers (N*), and punctuation (P*) + if cat_prefix in ('L', 'N', 'P'): result.append(char) + + # Allow space separator, normalize to regular space + elif cat == 'Zs': result.append(' ') + + # Convert line/paragraph separators to space + elif cat in ('Zl', 'Zp'): result.append(' ') + + # Allow spacing combining marks (Mc) for Indic, Hebrew, etc. + elif cat == 'Mc': result.append(char) + + # Allow modifier letters (Lm) - e.g., ʰ, ʱ, ː + elif cat == 'Lm': result.append(char) + + # Strip everything else: + # - Mn (Nonspacing Mark): diacritics, combining marks (Zalgo) + # - Me (Enclosing Mark): enclosing combining marks + # - C* (Controls, Format, Surrogates, Private Use, Unassigned) + # - S* (Symbols: currency, math, modifiers, other) + + name = ''.join(result) + + # Additional block-based stripping for symbols that categories missed + name = STRIP_BLOCKS_RE.sub('', name) + name = STRIP_CONTROL_RE.sub('', name) + name = STRIP_PRIVATE_RE.sub('', name) + + # Collapse multiple whitespace characters + name = re.sub(r'\s+', ' ', name) + + # Strip leading/trailing whitespace + name = name.strip() + + return name + +def strip_micron(text): + text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text) + text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text) + text = re.sub(r'`[!*_=]', '', text) + text = re.sub(r'`f`b', '', text) + text = re.sub(r'`f', '', text) + text = re.sub(r'`b', '', text) + text = re.sub(r'`<', '', text) + text = re.sub(r'`>', '', text) + text = re.sub(r'`{', '', text) + return text + +def strip_escaped_micron(text): + text = re.sub(r'¦[FB][0-9a-fA-F]{3}', '', text) + text = re.sub(r'¦[FB]T[0-9a-fA-F]{6}', '', text) + text = re.sub(r'¦[!*_=]', '', text) + text = re.sub(r'¦f`b', '', text) + text = re.sub(r'¦f', '', text) + text = re.sub(r'¦b', '', text) + text = re.sub(r'¦<', '', text) + text = re.sub(r'¦>', '', text) + text = re.sub(r'¦{', '', text) + return text + +def unescape_micron(text): + text = re.sub(r'¦([FB][0-9a-fA-F]{3})', r'`\1', text) + text = re.sub(r'¦([FB]T[0-9a-fA-F]{6})', r'`\1', text) + text = re.sub(r'¦([!*_=])', r'`\1', text) + text = re.sub(r'¦(f`b)', r'`\1', text) + text = re.sub(r'¦(f)', r'`\1', text) + text = re.sub(r'¦(b)', r'`\1', text) + text = re.sub(r'¦(<)', r'`\1', text) + text = re.sub(r'¦(>)', r'`\1', text) + text = re.sub(r'¦({)', r'`\1', text) + return text + +def strip_non_formatting_tags(text): + text = re.sub(r'`<', '', text) + text = re.sub(r'`>', '', text) + text = re.sub(r'`{', '', text) + text = re.sub(r'`r', '', text) + text = re.sub(r'`c', '', text) + text = re.sub(r'`l', '', text) + return text \ No newline at end of file diff --git a/nomadnet/vendor/AsciiChart.py b/nomadnet/vendor/AsciiChart.py new file mode 100644 index 0000000..cd24d56 --- /dev/null +++ b/nomadnet/vendor/AsciiChart.py @@ -0,0 +1,91 @@ +from __future__ import division +from math import ceil, floor, isnan +# Derived from asciichartpy | https://github.com/kroitor/asciichart/blob/master/asciichartpy/__init__.py +class AsciiChart: + def __init__(self, glyphset="unicode"): + self.symbols = ['┼', '┤', '╶', '╴', '─', '╰', '╭', '╮', '╯', '│'] + if glyphset == "plain": + self.symbols = ['+', '|', '-', '-', '-', '\'', ',', '.', '`', '|'] + def plot(self, series, cfg=None): + if len(series) == 0: + return '' + if not isinstance(series[0], list): + if all(isnan(n) for n in series): + return '' + else: + series = [series] + cfg = cfg or {} + minimum = cfg.get('min', min(filter(lambda n: not isnan(n), [j for i in series for j in i]))) + maximum = cfg.get('max', max(filter(lambda n: not isnan(n), [j for i in series for j in i]))) + symbols = cfg.get('symbols', self.symbols) + if minimum > maximum: + raise ValueError('The min value cannot exceed the max value.') + interval = maximum - minimum + offset = cfg.get('offset', 3) + height = cfg.get('height', interval) + ratio = height / interval if interval > 0 else 1 + + min2 = int(floor(minimum * ratio)) + max2 = int(ceil(maximum * ratio)) + + def clamp(n): + return min(max(n, minimum), maximum) + + def scaled(y): + return int(round(clamp(y) * ratio) - min2) + + rows = max2 - min2 + + width = 0 + for i in range(0, len(series)): + width = max(width, len(series[i])) + width += offset + + placeholder = cfg.get('format', '{:8.2f} ') + + result = [[' '] * width for i in range(rows + 1)] + + for y in range(min2, max2 + 1): + if callable(placeholder): + label = placeholder(maximum - ((y - min2) * interval / (rows if rows else 1))).rjust(12) + else: + label = placeholder.format(maximum - ((y - min2) * interval / (rows if rows else 1))) + + result[y - min2][max(offset - len(label), 0)] = label + result[y - min2][offset - 1] = symbols[0] if y == 0 else symbols[1] + + d0 = series[0][0] + if not isnan(d0): + result[rows - scaled(d0)][offset - 1] = symbols[0] + + for i in range(0, len(series)): + for x in range(0, len(series[i]) - 1): + d0 = series[i][x + 0] + d1 = series[i][x + 1] + + if isnan(d0) and isnan(d1): + continue + + if isnan(d0) and not isnan(d1): + result[rows - scaled(d1)][x + offset] = symbols[2] + continue + + if not isnan(d0) and isnan(d1): + result[rows - scaled(d0)][x + offset] = symbols[3] + continue + + y0 = scaled(d0) + y1 = scaled(d1) + if y0 == y1: + result[rows - y0][x + offset] = symbols[4] + continue + + result[rows - y1][x + offset] = symbols[5] if y0 > y1 else symbols[6] + result[rows - y0][x + offset] = symbols[7] if y0 > y1 else symbols[8] + + start = min(y0, y1) + 1 + end = max(y0, y1) + for y in range(start, end): + result[rows - y][x + offset] = symbols[9] + + return '\n'.join([''.join(row).rstrip() for row in result]) \ No newline at end of file diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py index ee28a46..45ea781 100644 --- a/nomadnet/vendor/Scrollable.py +++ b/nomadnet/vendor/Scrollable.py @@ -9,8 +9,11 @@ # GNU General Public License for more details # http://www.gnu.org/licenses/gpl-3.0.txt +import RNS +import time import urwid from urwid.widget import BOX, FIXED, FLOW +from collections import deque # Scroll actions SCROLL_LINE_UP = 'line up' @@ -50,7 +53,10 @@ class Scrollable(urwid.WidgetDecoration): self._old_cursor_coords = None self._rows_max_cached = 0 self.force_forward_keypress = force_forward_keypress - self.__super.__init__(widget) + self.force_cursor_update = False + self.anchor_cursor_update = False + self.cursor_update_forced_at = 0 + super().__init__(widget) def render(self, size, focus=False): maxcol, maxrow = size @@ -78,6 +84,10 @@ class Scrollable(urwid.WidgetDecoration): if canv_cols <= maxcol and canv_rows <= maxrow: # Canvas is small enough to fit without trimming + if self._trim_top != 0 or self._scroll_action is not None: + # Reset potentially stale _trim_top + self._trim_top = 0 + self._scroll_action = None return canv self._adjust_trim_top(canv, size) @@ -104,9 +114,58 @@ class Scrollable(urwid.WidgetDecoration): # Trimmed canvas contains the cursor, e.g. in an Edit widget self._forward_keypress = True else: - if canv_full.cursor is not None: + force_check = self.force_cursor_update and (time.time()-self.cursor_update_forced_at < 0.25) + force_check |= self.anchor_cursor_update + if canv_full.cursor is not None or force_check: + self.force_cursor_update = False + self.anchor_cursor_update = False # Full canvas contains the cursor, but scrolled out of view self._forward_keypress = False + + # Reset cursor position on page/up down scrolling + try: + if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll: + pwi = 0 + ch = 0 + last_hidden = False + first_visible = False + for w,o in ow.contents: + wcanv = w.render((maxcol,)) + wh = wcanv.rows() + if wh: + ch += wh + + if not last_hidden and ch >= self._trim_top: + last_hidden = True + + elif last_hidden: + if not first_visible: + first_visible = True + + if w.selectable(): + ow.focus = pwi + + st = None + nf = ow.get_focus() + if hasattr(nf, "key_timeout"): + st = nf + elif hasattr(nf, "original_widget"): + no = nf.original_widget + if hasattr(no, "original_widget"): + st = no.original_widget + else: + if hasattr(no, "key_timeout"): + st = no + + if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): + st.keypress(None, None) + + break + + pwi += 1 + except Exception as e: + pass + else: # Original widget does not have a cursor, but may be selectable @@ -223,10 +282,10 @@ class Scrollable(urwid.WidgetDecoration): def _get_original_widget_size(self, size): ow = self._original_widget sizing = ow.sizing() - if FIXED in sizing: - return () - elif FLOW in sizing: + if FLOW in sizing: return (size[0],) + elif FIXED in sizing: + return () def get_scrollpos(self, size=None, focus=False): """Current scrolling position @@ -295,12 +354,29 @@ class ScrollBar(urwid.WidgetDecoration): """ if BOX not in widget.sizing(): raise ValueError('Not a box widget: %r' % widget) - self.__super.__init__(widget) + super().__init__(widget) self._thumb_char = thumb_char self._trough_char = trough_char self.scrollbar_side = side self.scrollbar_width = max(1, width) self._original_widget_size = (0, 0) + self._sb_visible = False + self._sb_col_start = 0 + self._sb_col_end = 0 + self._sb_thumb_height = 1 + self._sb_ow_rows_max = 0 + self._sb_maxrow = 0 + self._sb_dragging = False + self._sb_drag_offset = 0 + self._accel = 1 + self._sevt_interval = 0 + self._sevt_start = 0 + self._sfreq_deque = deque(maxlen=5) + + self._debug_hz = 0 + self._debug_hz_smoothed = 0 + self._debug_scroll_ms = 0 + self._debug_scroll_started = 0 def render(self, size, focus=False): maxcol, maxrow = size @@ -315,15 +391,29 @@ class ScrollBar(urwid.WidgetDecoration): if ow_rows_max <= maxrow: # Canvas fits without scrolling - no scrollbar needed self._original_widget_size = size + self._sb_visible = False return ow.render(size, focus) ow_rows_max = ow_base.rows_max(ow_size, focus) ow_canv = ow.render(ow_size, focus) + + if self._debug_scroll_started: + total_ms = (time.time() - self._debug_scroll_started) * 1000 + # RNS.log(f" accel={self._accel} , hz={RNS.prettyfrequency(self._debug_hz)} smoothed={RNS.prettyfrequency(self._debug_hz_smoothed)} scroll={self._debug_scroll_ms:.1f}ms total={total_ms:.1f}ms", RNS.LOG_DEBUG) + self._debug_scroll_started = 0 + self._original_widget_size = ow_size pos = ow_base.get_scrollpos(ow_size, focus) posmax = ow_rows_max - maxrow + # Clamp position to valid scroll range. The position + # reported by the underlying widget can transiently + # fall outside [0, posmax] if its layout state is + # inconsistent. + if pos < 0: pos = 0 + elif pos > posmax: pos = posmax + # Thumb shrinks/grows according to the ratio of # / thumb_weight = min(1, maxrow / max(1, ow_rows_max)) @@ -335,9 +425,12 @@ class ScrollBar(urwid.WidgetDecoration): if top_height == 0 and top_weight > 0: top_height = 1 - # Bottom part is remaining space + # Bottom part is remaining space. Guard against + # negative values so the scrollbar canvas can + # never be taller than maxrow. bottom_height = maxrow - thumb_height - top_height - assert thumb_height + top_height + bottom_height == maxrow + if bottom_height < 0: bottom_height = 0 + assert thumb_height + top_height + bottom_height <= maxrow # Create scrollbar canvas # Creating SolidCanvases of correct height may result in "cviews do not @@ -355,9 +448,18 @@ class ScrollBar(urwid.WidgetDecoration): combinelist = [(ow_canv, None, True, ow_size[0]), (sb_canv, None, False, sb_width)] + + self._sb_visible = True + self._sb_thumb_height = thumb_height + self._sb_ow_rows_max = ow_rows_max + self._sb_maxrow = maxrow if self._scrollbar_side != SCROLLBAR_LEFT: + self._sb_col_start = ow_size[0] + self._sb_col_end = ow_size[0] + sb_width return urwid.CanvasJoin(combinelist) else: + self._sb_col_start = 0 + self._sb_col_end = sb_width return urwid.CanvasJoin(reversed(combinelist)) @property @@ -405,21 +507,77 @@ class ScrollBar(urwid.WidgetDecoration): def mouse_event(self, size, event, button, col, row, focus): ow = self._original_widget ow_size = self._original_widget_size + ow_base = self.scrolling_base_widget if hasattr(self, 'scrolling_base_widget') else ow + on_sb = (self._sb_visible + and self._sb_col_start <= col < self._sb_col_end) + + if self._sb_dragging: + if 'drag' in event: + self._set_scrollpos_from_row(row) + return True + if 'release' in event or 'press' in event: + self._sb_dragging = False + if 'release' in event: + return True + + # Begin a click/drag + if on_sb and 'press' in event and button == 1 and hasattr(ow_base, 'set_scrollpos'): + pos = ow_base.get_scrollpos(ow_size) + posmax = max(1, self._sb_ow_rows_max - self._sb_maxrow) + track_len = max(1, self._sb_maxrow - self._sb_thumb_height) + top_height = int(round(track_len * pos / posmax)) + if top_height <= row < top_height + self._sb_thumb_height: + self._sb_drag_offset = row - top_height + else: + # jump + self._sb_drag_offset = self._sb_thumb_height // 2 + self._set_scrollpos_from_row(row) + self._sb_dragging = True + return True + handled = False if hasattr(ow, 'mouse_event'): handled = ow.mouse_event(ow_size, event, button, col, row, focus) if not handled and hasattr(ow, 'set_scrollpos'): - if button == 4: # scroll wheel up + ow.force_cursor_update = True + ow.cursor_update_forced_at = time.time() + self._sevt_interval = time.time()-self._sevt_start + if self._sevt_interval > 0.3: self._sevt_start = 0; self._accel = 1; self._sfreq_deque.clear() + if self._sevt_start == 0: self._sevt_start = time.time() + if self._sevt_interval > 0: + sevt_freq = 1/self._sevt_interval + self._sfreq_deque.append(sevt_freq) + freq_smth = sum(self._sfreq_deque)/len(self._sfreq_deque) + self._debug_hz = sevt_freq + self._debug_hz_smoothed = freq_smth + if len(self._sfreq_deque) >= self._sfreq_deque.maxlen: + if freq_smth > 25.0: self._accel = max(self._accel, 3) + elif freq_smth > 12.0: self._accel = max(self._accel, 2) + else: self._accel = max(self._accel, 1) + + step = self._accel + if button in (4, 5): + scroll_started = time.time() pos = ow.get_scrollpos(ow_size) - newpos = pos - 1 - if newpos < 0: - newpos = 0 + if button == 4: newpos = max(0, pos - step) + else: newpos = pos + step ow.set_scrollpos(newpos) - return True - elif button == 5: # scroll wheel down - pos = ow.get_scrollpos(ow_size) - ow.set_scrollpos(pos + 1) + self._debug_scroll_started = scroll_started + self._debug_scroll_ms = (time.time() - scroll_started) * 1000 return True return False + + def _set_scrollpos_from_row(self, row): + ow_base = self.scrolling_base_widget + if not hasattr(ow_base, 'set_scrollpos'): + return + posmax = self._sb_ow_rows_max - self._sb_maxrow + if posmax <= 0: + return + track_len = max(1, self._sb_maxrow - self._sb_thumb_height) + top = max(0, min(track_len, row - self._sb_drag_offset)) + new_pos = int(round(top * posmax / track_len)) + new_pos = max(0, min(posmax, new_pos)) + ow_base.set_scrollpos(new_pos) diff --git a/nomadnet/vendor/__init__.py b/nomadnet/vendor/__init__.py index e3164b0..72302cb 100644 --- a/nomadnet/vendor/__init__.py +++ b/nomadnet/vendor/__init__.py @@ -1,5 +1,7 @@ import os import glob -modules = glob.glob(os.path.dirname(__file__)+"/*.py") -__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py new file mode 100644 index 0000000..9ab5dea --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -0,0 +1,575 @@ +import urwid +import nomadnet + +from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit + +def dropdown_indicator(): + try: + return nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs["dropdown"] + except Exception: + return " ▾" + +class DialogLineBox(urwid.LineBox): + def __init__(self, body, parent=None, title="?"): + super().__init__(body, title=title) + self.parent = parent + + def keypress(self, size, key): + if key == "esc": + if self.parent and hasattr(self.parent, "dismiss_dialog"): + self.parent.dismiss_dialog() + return None + return super().keypress(size, key) + +class Placeholder(ReadlineMixin, urwid.Edit): + def __init__(self, caption="", edit_text="", placeholder="", **kwargs): + super().__init__(caption, edit_text, **kwargs) + self.placeholder = placeholder + + def render(self, size, focus=False): + if not self.edit_text and not focus: + placeholder_widget = urwid.Text(("placeholder", self.placeholder)) + return placeholder_widget.render(size, focus) + else: + return super().render(size, focus) + +class Dropdown(urwid.WidgetWrap): + signals = ['change'] # emit for urwid.connect_signal fn + + def __init__(self, label, options, default=None): + self.label = label + self.options = options + self.selected = default if default is not None else options[0] + + self.main_text = f"{self.selected}" + self.main_button = urwid.SelectableIcon(self.main_text, 0) + self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus") + + self.indicator_widget = urwid.Text(dropdown_indicator()) + self.main_row = urwid.Columns([ + ('pack', self.main_button), + ('pack', self.indicator_widget), + ('weight', 1, urwid.Text("")), + ]) + + self.option_widgets = [] + for opt in options: + icon = urwid.SelectableIcon(opt, 0) + icon = urwid.AttrMap(icon, "list_normal", "list_focus") + self.option_widgets.append(icon) + + self.options_walker = urwid.SimpleFocusListWalker(self.option_widgets) + self.options_listbox = urwid.ListBox(self.options_walker) + self.dropdown_box = None # will be created on open_dropdown + + self.pile = urwid.Pile([self.main_row]) + self.dropdown_visible = False + + super().__init__(self.pile) + + def open_dropdown(self): + if not self.dropdown_visible: + height = len(self.options) + self.dropdown_box = urwid.BoxAdapter(self.options_listbox, height) + self.pile.contents.append((self.dropdown_box, self.pile.options())) + self.dropdown_visible = True + self.pile.focus_position = 1 + self.options_walker.set_focus(0) + + def close_dropdown(self): + if self.dropdown_visible: + self.pile.contents.pop() # remove the dropdown_box + self.dropdown_visible = False + self.pile.focus_position = 0 + self.dropdown_box = None + + def keypress(self, size, key): + if not self.dropdown_visible: + if key == "enter": + self.open_dropdown() + return None + return self.main_button.keypress(size, key) + else: + if key == "enter": + focus_result = self.options_walker.get_focus() + if focus_result is not None: + focus_widget = focus_result[0] + new_val = focus_widget.base_widget.text + old_val = self.selected + self.selected = new_val + self.main_button.base_widget.set_text(f"{self.selected}") + + if old_val != new_val: + self._emit('change', new_val) + + self.close_dropdown() + return None + return self.dropdown_box.keypress(size, key) + + def get_value(self): + return self.selected + +class ValidationError(urwid.Text): + def __init__(self, message=""): + super().__init__(("error", message)) + +class FormField: + def __init__(self, config_key, transform=None): + self.config_key = config_key + self.transform = transform or (lambda x: x) + +class FormEdit(Placeholder, FormField): + def __init__(self, config_key, caption="", edit_text="", placeholder="", validation_types=None, transform=None, **kwargs): + Placeholder.__init__(self, caption, edit_text, placeholder, **kwargs) + FormField.__init__(self, config_key, transform) + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + def get_value(self): + return self.transform(self.edit_text.strip()) + + def validate(self): + value = self.edit_text.strip() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + elif validation == "number": + try: + if value: + int(value) + except ValueError: + self.error = "This field must be a whole number" + break + elif validation == "stamp_cost": + if value and value.lower() != "none": + try: + if not 1 <= int(value) <= 255: + raise ValueError + except ValueError: + self.error = "This field must be 1-255 or None" + break + elif validation == "float": + try: + if value: + float(value) + except ValueError: + self.error = "This field must be decimal number" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + +class FormCheckbox(urwid.CheckBox, FormField): + def __init__(self, config_key, label="", state=False, validation_types=None, transform=None, **kwargs): + urwid.CheckBox.__init__(self, label, state, **kwargs) + FormField.__init__(self, config_key, transform) + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + def get_value(self): + return self.transform(self.get_state()) + + def validate(self): + + value = self.get_state() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + +class FormDropdown(Dropdown, FormField): + signals = ['change'] + + def __init__(self, config_key, label, options, default=None, validation_types=None, transform=None): + self.options = [str(opt) for opt in options] + + if default is not None: + default_str = str(default) + if default_str in self.options: + default = default_str + elif transform: + try: + default_transformed = transform(default_str) + for opt in self.options: + if transform(opt) == default_transformed: + default = opt + break + except: + default = self.options[0] + else: + default = self.options[0] + else: + default = self.options[0] + + Dropdown.__init__(self, label, self.options, default) + FormField.__init__(self, config_key, transform) + + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + if hasattr(self, 'main_button'): + self.main_button.base_widget.set_text(str(default)) + + def get_value(self): + return self.transform(self.selected) + + def validate(self): + value = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + def open_dropdown(self): + if not self.dropdown_visible: + super().open_dropdown() + try: + current_index = self.options.index(self.selected) + self.options_walker.set_focus(current_index) + except ValueError: + pass + +class FormMultiList(urwid.Pile, FormField): + def __init__(self, config_key, placeholder="", validation_types=None, transform=None, **kwargs): + self.entries = [] + self.error_widget = urwid.Text("") + self.error = None + self.placeholder = placeholder + self.validation_types = validation_types or [] + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add Another", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [first_entry, add_button_padded] + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self, text=""): + edit = ReadlineEdit("", text) + entry_row = urwid.Columns([ + ('weight', 1, edit), + (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), + ]) + return entry_row + + def set_value(self, value): + self.entries = [] + if not value: + self.entries.append(self.create_entry_row()) + else: + if isinstance(value, str): + value = [value] + for item in value: + self.entries.append(self.create_entry_row(str(item))) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return self.entries + [urwid.Padding(self.add_button, left=2, right=2)] + + def get_value(self): + values = [] + for entry in self.entries: + edit_widget = entry.contents[0][0] + value = edit_widget.edit_text.strip() + if value: + values.append(value) + return self.transform(values) + + def validate(self): + values = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "At least one entry is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + +class FormMultiTable(urwid.Pile, FormField): + def __init__(self, config_key, fields, validation_types=None, transform=None, **kwargs): + self.entries = [] + self.fields = fields + self.error_widget = urwid.Text("") + self.error = None + self.validation_types = validation_types or [] + + header_columns = [('weight', 3, urwid.Text(("list_focus", "Name")))] + for field_key, field_config in self.fields.items(): + header_columns.append(('weight', 2, urwid.Text(("list_focus", field_config.get("label", field_key))))) + header_columns.append((4, urwid.Text(("list_focus", "")))) + + self.header_row = urwid.Columns(header_columns) + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add ", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [ + self.header_row, + urwid.Divider("-"), + first_entry, + add_button_padded + ] + + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self, name="", values=None): + if values is None: + values = {} + + name_edit = ReadlineEdit("", name) + + columns = [('weight', 3, name_edit)] + + field_widgets = {} + for field_key, field_config in self.fields.items(): + field_value = values.get(field_key, "") + + if field_config.get("type") == "checkbox": + widget = urwid.CheckBox("", state=bool(field_value)) + elif field_config.get("type") == "dropdown": + # TODO: dropdown in MultiTable + widget = ReadlineEdit("", str(field_value)) + else: + widget = ReadlineEdit("", str(field_value)) + + field_widgets[field_key] = widget + columns.append(('weight', 2, widget)) + + remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) + columns.append((4, remove_button)) + + entry_row = urwid.Columns(columns) + entry_row.name_edit = name_edit + entry_row.field_widgets = field_widgets + + return entry_row + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return [ + self.header_row, + urwid.Divider("-") + ] + self.entries + [ + urwid.Padding(self.add_button, left=2, right=2) + ] + + def get_value(self): + values = {} + for entry in self.entries: + name = entry.name_edit.edit_text.strip() + if name: + subinterface = {} + subinterface["interface_enabled"] = True + + for field_key, widget in entry.field_widgets.items(): + field_config = self.fields.get(field_key, {}) + + if hasattr(widget, "get_state"): + value = widget.get_state() + elif hasattr(widget, "edit_text"): + value = widget.edit_text.strip() + + transform = field_config.get("transform") + if transform and value: + try: + value = transform(value) + except (ValueError, TypeError): + value = "" + + if value: + subinterface[field_key] = value + + values[name] = subinterface + + return self.transform(values) if self.transform else values + + def set_value(self, value): + self.entries = [] + + if not value: + self.entries.append(self.create_entry_row()) + else: + for name, config in value.items(): + self.entries.append(self.create_entry_row(name=name, values=config)) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def validate(self): + values = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "At least one subinterface is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + +class FormKeyValuePairs(urwid.Pile, FormField): + def __init__(self, config_key, validation_types=None, transform=None, **kwargs): + self.entries = [] + self.error_widget = urwid.Text("") + self.error = None + self.validation_types = validation_types or [] + + header_columns = [ + ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Key"), "multitable_header")), + ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Value"), "multitable_header")), + (4, urwid.AttrMap(urwid.Text("Action"), "multitable_header")) + ] + + self.header_row = urwid.AttrMap(urwid.Columns(header_columns), "multitable_header") + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add Parameter", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [ + self.header_row, + urwid.Divider("-"), + first_entry, + add_button_padded + ] + + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self, key="", value=""): + key_edit = ReadlineEdit("", key) + value_edit = ReadlineEdit("", value) + + remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) + + entry_row = urwid.Columns([ + ('weight', 1, key_edit), + ('weight', 1, value_edit), + (4, remove_button) + ]) + + entry_row.key_edit = key_edit + entry_row.value_edit = value_edit + + return entry_row + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return [ + self.header_row, + urwid.Divider("-") + ] + self.entries + [ + urwid.Padding(self.add_button, left=2, right=2) + ] + + def get_value(self): + values = {} + for entry in self.entries: + key = entry.key_edit.edit_text.strip() + value = entry.value_edit.edit_text.strip() + + if key: + if value.isdigit(): + values[key] = int(value) + elif value.replace('.', '', 1).isdigit() and value.count('.') <= 1: + values[key] = float(value) + elif value.lower() == 'true': + values[key] = True + elif value.lower() == 'false': + values[key] = False + else: + values[key] = value + + return self.transform(values) if self.transform else values + + def set_value(self, value): + self.entries = [] + + if not value or not isinstance(value, dict): + self.entries.append(self.create_entry_row()) + else: + for key, val in value.items(): + self.entries.append(self.create_entry_row(key=key, value=str(val))) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def validate(self): + values = self.get_value() + self.error = None + + keys = [entry.key_edit.edit_text.strip() for entry in self.entries + if entry.key_edit.edit_text.strip()] + if len(keys) != len(set(keys)): + self.error = "Duplicate keys are not allowed" + self.error_widget.set_text(("error", self.error)) + return False + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "Atleast one parameter is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py index d4f18dc..c0a95b0 100644 --- a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py @@ -257,10 +257,15 @@ class IndicativeListBox(urwid.WidgetWrap): # An event is changed to a modified one ('mouse press' => 'ctrl mouse press'). This prevents the widget from responding # when mouse buttons are also used to navigate between widgets. if event == self._modifier_key.prepend_to("mouse press"): - # Store the focus position before passing the input to the contained list box. That way, it can be compared with the + # Store the focus position before passing the input to the contained list box. That way, it can be compared with the # position after the input is processed. If the list box body is empty, store None. focus_position_before_input = self.get_selected_position() - + + # Also remember the currently selected item itself. A button 1 press makes the contained list box move the focus, but + # the off focus highlighting is only (un)done during 'render()' while the widget has the focus. When the selection is + # changed by mouse while the widget is unfocused, the highlight would otherwise stay stuck on the previous item. + item_before_input = self.get_selected_item() + # left mouse button, if not top bar or bottom bar. if (button == 1.0) and (topBar_rows <= row < (size[1] - bottomBar_rows)): # Because 'row' includes the top bar, the offset must be substracted before passing it to the contained list box. @@ -269,18 +274,29 @@ class IndicativeListBox(urwid.WidgetWrap): # mousewheel up elif button == 4.0: - was_handeled = self._pass_key_to_contained_listbox(modified_size, "page up") + # was_handeled = self._pass_key_to_contained_listbox(modified_size, "page up") + was_handeled = self._pass_key_to_contained_listbox(modified_size, "up") # mousewheel down elif button == 5.0: - was_handeled = self._pass_key_to_contained_listbox(modified_size, "page down") + # was_handeled = self._pass_key_to_contained_listbox(modified_size, "page down") + was_handeled = self._pass_key_to_contained_listbox(modified_size, "down") focus_position_after_input = self.get_selected_position() - - # If the focus position has changed, execute the hook (if existing). - if (focus_position_before_input != focus_position_after_input) and (self.on_selection_change is not None): - self.on_selection_change(focus_position_before_input, - focus_position_after_input) + + if focus_position_before_input != focus_position_after_input: + # The mouse moved the selection. If off focus highlighting is currently applied (the widget is unfocused), restore + # the previously selected item and clear the stored state, so the next 'render()' re-applies the highlight to the + # newly selected item instead of leaving it stuck on the old one. + if (not self._last_focus_state) and (self._original_item_attr_map is not None) and (item_before_input is not None): + item_before_input.set_attr_map(self._original_item_attr_map) + self._original_item_attr_map = None + self._last_focus_state = None + + # If the focus position has changed, execute the hook (if existing). + if self.on_selection_change is not None: + self.on_selection_change(focus_position_before_input, + focus_position_after_input) return was_handeled diff --git a/nomadnet/vendor/cbor.py b/nomadnet/vendor/cbor.py new file mode 100644 index 0000000..6c63096 --- /dev/null +++ b/nomadnet/vendor/cbor.py @@ -0,0 +1,430 @@ +# https://github.com/brianolson/cbor_py +# Copyright 2014-2015 Brian Olson +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import re +import struct +from io import BytesIO + + +CBOR_TYPE_MASK = 0xE0 # top 3 bits +CBOR_INFO_BITS = 0x1F # low 5 bits + + +CBOR_UINT = 0x00 +CBOR_NEGINT = 0x20 +CBOR_BYTES = 0x40 +CBOR_TEXT = 0x60 +CBOR_ARRAY = 0x80 +CBOR_MAP = 0xA0 +CBOR_TAG = 0xC0 +CBOR_7 = 0xE0 # float and other types + +CBOR_UINT8_FOLLOWS = 24 # 0x18 +CBOR_UINT16_FOLLOWS = 25 # 0x19 +CBOR_UINT32_FOLLOWS = 26 # 0x1a +CBOR_UINT64_FOLLOWS = 27 # 0x1b +CBOR_VAR_FOLLOWS = 31 # 0x1f + +CBOR_BREAK = 0xFF + +CBOR_FALSE = (CBOR_7 | 20) +CBOR_TRUE = (CBOR_7 | 21) +CBOR_NULL = (CBOR_7 | 22) +CBOR_UNDEFINED = (CBOR_7 | 23) # js 'undefined' value + +CBOR_FLOAT16 = (CBOR_7 | 25) +CBOR_FLOAT32 = (CBOR_7 | 26) +CBOR_FLOAT64 = (CBOR_7 | 27) + +CBOR_TAG_DATE_STRING = 0 # RFC3339 +CBOR_TAG_DATE_ARRAY = 1 # any number, seconds since 1970-01-01T00:00:00 UTC +CBOR_TAG_BIGNUM = 2 # big-endian byte string follows +CBOR_TAG_NEGBIGNUM = 3 # big-endian byte string follows +CBOR_TAG_DECIMAL = 4 +CBOR_TAG_BIGFLOAT = 5 +CBOR_TAG_BASE64URL = 21 +CBOR_TAG_BASE64 = 22 +CBOR_TAG_BASE16 = 23 +CBOR_TAG_CBOR = 24 + +CBOR_TAG_URI = 32 +CBOR_TAG_REGEX = 35 +CBOR_TAG_MIME = 36 +CBOR_TAG_CBOR_FILEHEADER = 55799 # 0xd9d9f7 + +_CBOR_TAG_BIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_BIGNUM) +_CBOR_TAG_NEGBIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_NEGBIGNUM) + + +def _dumps_bignum_to_bytearray(val): + out = [] + while val > 0: + out.insert(0, val & 0x0ff) + val = val >> 8 + return bytes(out) + + +def dumps_int(val): + "return bytes representing int val in CBOR" + if val >= 0: + if val <= 23: + return struct.pack('B', val) + if val <= 0x0ff: + return struct.pack('BB', CBOR_UINT8_FOLLOWS, val) + if val <= 0x0ffff: + return struct.pack('!BH', CBOR_UINT16_FOLLOWS, val) + if val <= 0x0ffffffff: + return struct.pack('!BI', CBOR_UINT32_FOLLOWS, val) + if val <= 0x0ffffffffffffffff: + return struct.pack('!BQ', CBOR_UINT64_FOLLOWS, val) + outb = _dumps_bignum_to_bytearray(val) + return _CBOR_TAG_BIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb + val = -1 - val + return _encode_type_num(CBOR_NEGINT, val) + + +def dumps_float(val): + return struct.pack("!Bd", CBOR_FLOAT64, val) + + +def _encode_type_num(cbor_type, val): + """For some CBOR primary type [0..7] and an auxiliary unsigned number, + return CBOR encoded bytes.""" + assert val >= 0 + if val <= 23: + return struct.pack('B', cbor_type | val) + if val <= 0x0ff: + return struct.pack('BB', cbor_type | CBOR_UINT8_FOLLOWS, val) + if val <= 0x0ffff: + return struct.pack('!BH', cbor_type | CBOR_UINT16_FOLLOWS, val) + if val <= 0x0ffffffff: + return struct.pack('!BI', cbor_type | CBOR_UINT32_FOLLOWS, val) + if (((cbor_type == CBOR_NEGINT) and (val <= 0x07fffffffffffffff)) or + ((cbor_type != CBOR_NEGINT) and (val <= 0x0ffffffffffffffff))): + return struct.pack('!BQ', cbor_type | CBOR_UINT64_FOLLOWS, val) + if cbor_type != CBOR_NEGINT: + raise Exception("value too big for CBOR unsigned number: {0!r}".format(val)) + outb = _dumps_bignum_to_bytearray(val) + return _CBOR_TAG_NEGBIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb + + +def dumps_string(val, is_text=None, is_bytes=None): + if isinstance(val, str): + val = val.encode('utf8') + is_text = True + is_bytes = False + if (is_bytes) or not (is_text == True): + return _encode_type_num(CBOR_BYTES, len(val)) + val + return _encode_type_num(CBOR_TEXT, len(val)) + val + + +def dumps_array(arr, sort_keys=False): + head = _encode_type_num(CBOR_ARRAY, len(arr)) + parts = [dumps(x, sort_keys=sort_keys) for x in arr] + return head + b''.join(parts) + + +def dumps_dict(d, sort_keys=False): + head = _encode_type_num(CBOR_MAP, len(d)) + parts = [head] + if sort_keys: + for k in sorted(d.keys()): + v = d[k] + parts.append(dumps(k, sort_keys=sort_keys)) + parts.append(dumps(v, sort_keys=sort_keys)) + else: + for k, v in d.items(): + parts.append(dumps(k, sort_keys=sort_keys)) + parts.append(dumps(v, sort_keys=sort_keys)) + return b''.join(parts) + + +def dumps_bool(b): + if b: + return struct.pack('B', CBOR_TRUE) + return struct.pack('B', CBOR_FALSE) + + +def dumps_tag(t, sort_keys=False): + return _encode_type_num(CBOR_TAG, t.tag) + dumps(t.value, sort_keys=sort_keys) + + +def dumps(ob, sort_keys=False): + if ob is None: + return struct.pack('B', CBOR_NULL) + if isinstance(ob, bool): + return dumps_bool(ob) + if isinstance(ob, (str, bytes, bytearray)): + if isinstance(ob, bytearray): + ob = bytes(ob) + return dumps_string(ob) + if isinstance(ob, (list, tuple)): + return dumps_array(ob, sort_keys=sort_keys) + if isinstance(ob, dict): + return dumps_dict(ob, sort_keys=sort_keys) + if isinstance(ob, float): + return dumps_float(ob) + if isinstance(ob, int): + return dumps_int(ob) + if isinstance(ob, Tag): + return dumps_tag(ob, sort_keys=sort_keys) + raise Exception("don't know how to cbor serialize object of type %s" % type(ob)) + + +def dump(obj, fp, sort_keys=False): + """obj: Python object to serialize. fp: file-like object capable of .write(bytes).""" + blob = dumps(obj, sort_keys=sort_keys) + fp.write(blob) + + +class Tag(object): + def __init__(self, tag=None, value=None): + self.tag = tag + self.value = value + + def __repr__(self): + return "Tag({0!r}, {1!r})".format(self.tag, self.value) + + def __eq__(self, other): + if not isinstance(other, Tag): + return False + return (self.tag == other.tag) and (self.value == other.value) + + def __hash__(self): + return hash((self.tag, self.value)) if not isinstance(self.value, (list, dict, bytearray)) else id(self) + + +def loads(data): + """Parse CBOR bytes and return Python objects.""" + if data is None: + raise ValueError("got None for buffer to decode in loads") + if isinstance(data, (bytes, bytearray, memoryview)): + fp = BytesIO(bytes(data)) + else: + fp = data + return _loads(fp)[0] + + +def load(fp): + """Parse and return object from fp, a file-like object supporting .read(n).""" + return _loads(fp)[0] + + +_MAX_DEPTH = 100 + + +def _tag_aux(fp, tb): + bytes_read = 1 + tag = tb & CBOR_TYPE_MASK + tag_aux = tb & CBOR_INFO_BITS + if tag_aux <= 23: + aux = tag_aux + elif tag_aux == CBOR_UINT8_FOLLOWS: + data = fp.read(1) + aux = struct.unpack_from("!B", data, 0)[0] + bytes_read += 1 + elif tag_aux == CBOR_UINT16_FOLLOWS: + data = fp.read(2) + aux = struct.unpack_from("!H", data, 0)[0] + bytes_read += 2 + elif tag_aux == CBOR_UINT32_FOLLOWS: + data = fp.read(4) + aux = struct.unpack_from("!I", data, 0)[0] + bytes_read += 4 + elif tag_aux == CBOR_UINT64_FOLLOWS: + data = fp.read(8) + aux = struct.unpack_from("!Q", data, 0)[0] + bytes_read += 8 + else: + assert tag_aux == CBOR_VAR_FOLLOWS, "bogus tag {0:02x}".format(tb) + aux = None + + return tag, tag_aux, aux, bytes_read + + +def _read_byte(fp): + tb = fp.read(1) + if len(tb) == 0: + raise EOFError() + return tb[0] + + +def _loads_var_array(fp, limit, depth, returntags, bytes_read): + ob = [] + tb = _read_byte(fp) + while tb != CBOR_BREAK: + (subob, sub_len) = _loads_tb(fp, tb, limit, depth, returntags) + bytes_read += 1 + sub_len + ob.append(subob) + tb = _read_byte(fp) + return (ob, bytes_read + 1) + + +def _loads_var_map(fp, limit, depth, returntags, bytes_read): + ob = {} + tb = _read_byte(fp) + while tb != CBOR_BREAK: + (subk, sub_len) = _loads_tb(fp, tb, limit, depth, returntags) + bytes_read += 1 + sub_len + (subv, sub_len) = _loads(fp, limit, depth, returntags) + bytes_read += sub_len + ob[subk] = subv + tb = _read_byte(fp) + return (ob, bytes_read + 1) + + +def _loads_array(fp, limit, depth, returntags, aux, bytes_read): + ob = [] + for _ in range(aux): + subob, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + ob.append(subob) + return ob, bytes_read + + +def _loads_map(fp, limit, depth, returntags, aux, bytes_read): + ob = {} + for _ in range(aux): + subk, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + subv, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + ob[subk] = subv + return ob, bytes_read + + +def _loads(fp, limit=None, depth=0, returntags=False): + "return (object, bytes read)" + if depth > _MAX_DEPTH: + raise Exception("hit CBOR loads recursion depth limit") + tb = _read_byte(fp) + return _loads_tb(fp, tb, limit, depth, returntags) + + +def _loads_tb(fp, tb, limit=None, depth=0, returntags=False): + # Some special cases of CBOR_7 best handled by special struct.unpack logic here + if tb == CBOR_FLOAT16: + data = fp.read(2) + hibyte, lowbyte = struct.unpack_from("BB", data, 0) + exp = (hibyte >> 2) & 0x1F + mant = ((hibyte & 0x03) << 8) | lowbyte + if exp == 0: + val = mant * (2.0 ** -24) + elif exp == 31: + val = float('Inf') if mant == 0 else float('NaN') + else: + val = (mant + 1024.0) * (2 ** (exp - 25)) + if hibyte & 0x80: + val = -1.0 * val + return (val, 3) + elif tb == CBOR_FLOAT32: + data = fp.read(4) + pf = struct.unpack_from("!f", data, 0) + return (pf[0], 5) + elif tb == CBOR_FLOAT64: + data = fp.read(8) + pf = struct.unpack_from("!d", data, 0) + return (pf[0], 9) + + tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb) + + if tag == CBOR_UINT: + return (aux, bytes_read) + elif tag == CBOR_NEGINT: + return (-1 - aux, bytes_read) + elif tag == CBOR_BYTES: + ob, subpos = loads_bytes(fp, aux) + return (ob, bytes_read + subpos) + elif tag == CBOR_TEXT: + raw, subpos = loads_bytes(fp, aux, btag=CBOR_TEXT) + ob = raw.decode('utf8') + return (ob, bytes_read + subpos) + elif tag == CBOR_ARRAY: + if aux is None: + return _loads_var_array(fp, limit, depth, returntags, bytes_read) + return _loads_array(fp, limit, depth, returntags, aux, bytes_read) + elif tag == CBOR_MAP: + if aux is None: + return _loads_var_map(fp, limit, depth, returntags, bytes_read) + return _loads_map(fp, limit, depth, returntags, aux, bytes_read) + elif tag == CBOR_TAG: + ob, subpos = _loads(fp, limit, depth + 1, returntags) + bytes_read += subpos + if returntags: + ob = Tag(aux, ob) + else: + ob = tagify(ob, aux) + return ob, bytes_read + elif tag == CBOR_7: + if tb == CBOR_TRUE: + return (True, bytes_read) + if tb == CBOR_FALSE: + return (False, bytes_read) + if tb == CBOR_NULL: + return (None, bytes_read) + if tb == CBOR_UNDEFINED: + return (None, bytes_read) + raise ValueError("unknown cbor tag 7 byte: {:02x}".format(tb)) + + +def loads_bytes(fp, aux, btag=CBOR_BYTES): + if aux is not None: + ob = fp.read(aux) + return (ob, aux) + chunklist = [] + total_bytes_read = 0 + while True: + tb = fp.read(1)[0] + if tb == CBOR_BREAK: + total_bytes_read += 1 + break + tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb) + assert tag == btag, 'variable length value contains unexpected component' + ob = fp.read(aux) + chunklist.append(ob) + total_bytes_read += bytes_read + aux + return (b''.join(chunklist), total_bytes_read) + + +def _bytes_to_biguint(bs): + out = 0 + for ch in bs: + out = out << 8 + out = out | ch + return out + + +def tagify(ob, aux): + if aux == CBOR_TAG_DATE_STRING: + # RFC3339 date string parsing not implemented; return as Tag. + return Tag(aux, ob) + if aux == CBOR_TAG_DATE_ARRAY: + return datetime.datetime.fromtimestamp(ob, tz=datetime.timezone.utc) + if aux == CBOR_TAG_BIGNUM: + return _bytes_to_biguint(ob) + if aux == CBOR_TAG_NEGBIGNUM: + return -1 - _bytes_to_biguint(ob) + if aux == CBOR_TAG_REGEX: + return re.compile(ob) + return Tag(aux, ob) + + +def encode(obj, sort_keys=False): + return dumps(obj, sort_keys=sort_keys) + + +def decode(data): + return loads(data) diff --git a/nomadnet/vendor/configobj.py b/nomadnet/vendor/configobj.py deleted file mode 100644 index e0c01b6..0000000 --- a/nomadnet/vendor/configobj.py +++ /dev/null @@ -1,2483 +0,0 @@ -# configobj.py -# A config file reader/writer that supports nested sections in config files. -# Copyright (C) 2005-2014: -# (name) : (email) -# Michael Foord: fuzzyman AT voidspace DOT org DOT uk -# Nicola Larosa: nico AT tekNico DOT net -# Rob Dennis: rdennis AT gmail DOT com -# Eli Courtwright: eli AT courtwright DOT org - -# This software is licensed under the terms of the BSD license. -# http://opensource.org/licenses/BSD-3-Clause - -# ConfigObj 5 - main repository for documentation and issue tracking: -# https://github.com/DiffSK/configobj - -import os -import re -import sys - -from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF16_BE, BOM_UTF16_LE - -import RNS.vendor.six as six -__version__ = '5.0.6' - -# imported lazily to avoid startup performance hit if it isn't used -compiler = None - -# A dictionary mapping BOM to -# the encoding to decode with, and what to set the -# encoding attribute to. -BOMS = { - BOM_UTF8: ('utf_8', None), - BOM_UTF16_BE: ('utf16_be', 'utf_16'), - BOM_UTF16_LE: ('utf16_le', 'utf_16'), - BOM_UTF16: ('utf_16', 'utf_16'), - } -# All legal variants of the BOM codecs. -# TODO: the list of aliases is not meant to be exhaustive, is there a -# better way ? -BOM_LIST = { - 'utf_16': 'utf_16', - 'u16': 'utf_16', - 'utf16': 'utf_16', - 'utf-16': 'utf_16', - 'utf16_be': 'utf16_be', - 'utf_16_be': 'utf16_be', - 'utf-16be': 'utf16_be', - 'utf16_le': 'utf16_le', - 'utf_16_le': 'utf16_le', - 'utf-16le': 'utf16_le', - 'utf_8': 'utf_8', - 'u8': 'utf_8', - 'utf': 'utf_8', - 'utf8': 'utf_8', - 'utf-8': 'utf_8', - } - -# Map of encodings to the BOM to write. -BOM_SET = { - 'utf_8': BOM_UTF8, - 'utf_16': BOM_UTF16, - 'utf16_be': BOM_UTF16_BE, - 'utf16_le': BOM_UTF16_LE, - None: BOM_UTF8 - } - - -def match_utf8(encoding): - return BOM_LIST.get(encoding.lower()) == 'utf_8' - - -# Quote strings used for writing values -squot = "'%s'" -dquot = '"%s"' -noquot = "%s" -wspace_plus = ' \r\n\v\t\'"' -tsquot = '"""%s"""' -tdquot = "'''%s'''" - -# Sentinel for use in getattr calls to replace hasattr -MISSING = object() - -__all__ = ( - 'DEFAULT_INDENT_TYPE', - 'DEFAULT_INTERPOLATION', - 'ConfigObjError', - 'NestingError', - 'ParseError', - 'DuplicateError', - 'ConfigspecError', - 'ConfigObj', - 'SimpleVal', - 'InterpolationError', - 'InterpolationLoopError', - 'MissingInterpolationOption', - 'RepeatSectionError', - 'ReloadError', - 'UnreprError', - 'UnknownType', - 'flatten_errors', - 'get_extra_values' -) - -DEFAULT_INTERPOLATION = 'configparser' -DEFAULT_INDENT_TYPE = ' ' -MAX_INTERPOL_DEPTH = 10 - -OPTION_DEFAULTS = { - 'interpolation': True, - 'raise_errors': False, - 'list_values': True, - 'create_empty': False, - 'file_error': False, - 'configspec': None, - 'stringify': True, - # option may be set to one of ('', ' ', '\t') - 'indent_type': None, - 'encoding': None, - 'default_encoding': None, - 'unrepr': False, - 'write_empty_values': False, -} - -# this could be replaced if six is used for compatibility, or there are no -# more assertions about items being a string - - -def getObj(s): - global compiler - if compiler is None: - import compiler - s = "a=" + s - p = compiler.parse(s) - return p.getChildren()[1].getChildren()[0].getChildren()[1] - - -class UnknownType(Exception): - pass - - -class Builder(object): - - def build(self, o): - if m is None: - raise UnknownType(o.__class__.__name__) - return m(o) - - def build_List(self, o): - return list(map(self.build, o.getChildren())) - - def build_Const(self, o): - return o.value - - def build_Dict(self, o): - d = {} - i = iter(map(self.build, o.getChildren())) - for el in i: - d[el] = next(i) - return d - - def build_Tuple(self, o): - return tuple(self.build_List(o)) - - def build_Name(self, o): - if o.name == 'None': - return None - if o.name == 'True': - return True - if o.name == 'False': - return False - - # An undefined Name - raise UnknownType('Undefined Name') - - def build_Add(self, o): - real, imag = list(map(self.build_Const, o.getChildren())) - try: - real = float(real) - except TypeError: - raise UnknownType('Add') - if not isinstance(imag, complex) or imag.real != 0.0: - raise UnknownType('Add') - return real+imag - - def build_Getattr(self, o): - parent = self.build(o.expr) - return getattr(parent, o.attrname) - - def build_UnarySub(self, o): - return -self.build_Const(o.getChildren()[0]) - - def build_UnaryAdd(self, o): - return self.build_Const(o.getChildren()[0]) - - -_builder = Builder() - - -def unrepr(s): - if not s: - return s - - # this is supposed to be safe - import ast - return ast.literal_eval(s) - - -class ConfigObjError(SyntaxError): - """ - This is the base class for all errors that ConfigObj raises. - It is a subclass of SyntaxError. - """ - def __init__(self, message='', line_number=None, line=''): - self.line = line - self.line_number = line_number - SyntaxError.__init__(self, message) - - -class NestingError(ConfigObjError): - """ - This error indicates a level of nesting that doesn't match. - """ - - -class ParseError(ConfigObjError): - """ - This error indicates that a line is badly written. - It is neither a valid ``key = value`` line, - nor a valid section marker line. - """ - - -class ReloadError(IOError): - """ - A 'reload' operation failed. - This exception is a subclass of ``IOError``. - """ - def __init__(self): - IOError.__init__(self, 'reload failed, filename is not set.') - - -class DuplicateError(ConfigObjError): - """ - The keyword or section specified already exists. - """ - - -class ConfigspecError(ConfigObjError): - """ - An error occured whilst parsing a configspec. - """ - - -class InterpolationError(ConfigObjError): - """Base class for the two interpolation errors.""" - - -class InterpolationLoopError(InterpolationError): - """Maximum interpolation depth exceeded in string interpolation.""" - - def __init__(self, option): - InterpolationError.__init__( - self, - 'interpolation loop detected in value "%s".' % option) - - -class RepeatSectionError(ConfigObjError): - """ - This error indicates additional sections in a section with a - ``__many__`` (repeated) section. - """ - - -class MissingInterpolationOption(InterpolationError): - """A value specified for interpolation was missing.""" - def __init__(self, option): - msg = 'missing option "%s" in interpolation.' % option - InterpolationError.__init__(self, msg) - - -class UnreprError(ConfigObjError): - """An error parsing in unrepr mode.""" - - - -class InterpolationEngine(object): - """ - A helper class to help perform string interpolation. - - This class is an abstract base class; its descendants perform - the actual work. - """ - - # compiled regexp to use in self.interpolate() - _KEYCRE = re.compile(r"%\(([^)]*)\)s") - _cookie = '%' - - def __init__(self, section): - # the Section instance that "owns" this engine - self.section = section - - - def interpolate(self, key, value): - # short-cut - if not self._cookie in value: - return value - - def recursive_interpolate(key, value, section, backtrail): - """The function that does the actual work. - - ``value``: the string we're trying to interpolate. - ``section``: the section in which that string was found - ``backtrail``: a dict to keep track of where we've been, - to detect and prevent infinite recursion loops - - This is similar to a depth-first-search algorithm. - """ - # Have we been here already? - if (key, section.name) in backtrail: - # Yes - infinite loop detected - raise InterpolationLoopError(key) - # Place a marker on our backtrail so we won't come back here again - backtrail[(key, section.name)] = 1 - - # Now start the actual work - match = self._KEYCRE.search(value) - while match: - # The actual parsing of the match is implementation-dependent, - # so delegate to our helper function - k, v, s = self._parse_match(match) - if k is None: - # That's the signal that no further interpolation is needed - replacement = v - else: - # Further interpolation may be needed to obtain final value - replacement = recursive_interpolate(k, v, s, backtrail) - # Replace the matched string with its final value - start, end = match.span() - value = ''.join((value[:start], replacement, value[end:])) - new_search_start = start + len(replacement) - # Pick up the next interpolation key, if any, for next time - # through the while loop - match = self._KEYCRE.search(value, new_search_start) - - # Now safe to come back here again; remove marker from backtrail - del backtrail[(key, section.name)] - - return value - - # Back in interpolate(), all we have to do is kick off the recursive - # function with appropriate starting values - value = recursive_interpolate(key, value, self.section, {}) - return value - - - def _fetch(self, key): - """Helper function to fetch values from owning section. - - Returns a 2-tuple: the value, and the section where it was found. - """ - # switch off interpolation before we try and fetch anything ! - save_interp = self.section.main.interpolation - self.section.main.interpolation = False - - # Start at section that "owns" this InterpolationEngine - current_section = self.section - while True: - # try the current section first - val = current_section.get(key) - if val is not None and not isinstance(val, Section): - break - # try "DEFAULT" next - val = current_section.get('DEFAULT', {}).get(key) - if val is not None and not isinstance(val, Section): - break - # move up to parent and try again - # top-level's parent is itself - if current_section.parent is current_section: - # reached top level, time to give up - break - current_section = current_section.parent - - # restore interpolation to previous value before returning - self.section.main.interpolation = save_interp - if val is None: - raise MissingInterpolationOption(key) - return val, current_section - - - def _parse_match(self, match): - """Implementation-dependent helper function. - - Will be passed a match object corresponding to the interpolation - key we just found (e.g., "%(foo)s" or "$foo"). Should look up that - key in the appropriate config file section (using the ``_fetch()`` - helper function) and return a 3-tuple: (key, value, section) - - ``key`` is the name of the key we're looking for - ``value`` is the value found for that key - ``section`` is a reference to the section where it was found - - ``key`` and ``section`` should be None if no further - interpolation should be performed on the resulting value - (e.g., if we interpolated "$$" and returned "$"). - """ - raise NotImplementedError() - - - -class ConfigParserInterpolation(InterpolationEngine): - """Behaves like ConfigParser.""" - _cookie = '%' - _KEYCRE = re.compile(r"%\(([^)]*)\)s") - - def _parse_match(self, match): - key = match.group(1) - value, section = self._fetch(key) - return key, value, section - - - -class TemplateInterpolation(InterpolationEngine): - """Behaves like string.Template.""" - _cookie = '$' - _delimiter = '$' - _KEYCRE = re.compile(r""" - \$(?: - (?P\$) | # Two $ signs - (?P[_a-z][_a-z0-9]*) | # $name format - {(?P[^}]*)} # ${name} format - ) - """, re.IGNORECASE | re.VERBOSE) - - def _parse_match(self, match): - # Valid name (in or out of braces): fetch value from section - key = match.group('named') or match.group('braced') - if key is not None: - value, section = self._fetch(key) - return key, value, section - # Escaped delimiter (e.g., $$): return single delimiter - if match.group('escaped') is not None: - # Return None for key and section to indicate it's time to stop - return None, self._delimiter, None - # Anything else: ignore completely, just return it unchanged - return None, match.group(), None - - -interpolation_engines = { - 'configparser': ConfigParserInterpolation, - 'template': TemplateInterpolation, -} - - -def __newobj__(cls, *args): - # Hack for pickle - return cls.__new__(cls, *args) - -class Section(dict): - """ - A dictionary-like object that represents a section in a config file. - - It does string interpolation if the 'interpolation' attribute - of the 'main' object is set to True. - - Interpolation is tried first from this object, then from the 'DEFAULT' - section of this object, next from the parent and its 'DEFAULT' section, - and so on until the main object is reached. - - A Section will behave like an ordered dictionary - following the - order of the ``scalars`` and ``sections`` attributes. - You can use this to change the order of members. - - Iteration follows the order: scalars, then sections. - """ - - - def __setstate__(self, state): - dict.update(self, state[0]) - self.__dict__.update(state[1]) - - def __reduce__(self): - state = (dict(self), self.__dict__) - return (__newobj__, (self.__class__,), state) - - - def __init__(self, parent, depth, main, indict=None, name=None): - """ - * parent is the section above - * depth is the depth level of this section - * main is the main ConfigObj - * indict is a dictionary to initialise the section with - """ - if indict is None: - indict = {} - dict.__init__(self) - # used for nesting level *and* interpolation - self.parent = parent - # used for the interpolation attribute - self.main = main - # level of nesting depth of this Section - self.depth = depth - # purely for information - self.name = name - # - self._initialise() - # we do this explicitly so that __setitem__ is used properly - # (rather than just passing to ``dict.__init__``) - for entry, value in indict.items(): - self[entry] = value - - - def _initialise(self): - # the sequence of scalar values in this Section - self.scalars = [] - # the sequence of sections in this Section - self.sections = [] - # for comments :-) - self.comments = {} - self.inline_comments = {} - # the configspec - self.configspec = None - # for defaults - self.defaults = [] - self.default_values = {} - self.extra_values = [] - self._created = False - - - def _interpolate(self, key, value): - try: - # do we already have an interpolation engine? - engine = self._interpolation_engine - except AttributeError: - # not yet: first time running _interpolate(), so pick the engine - name = self.main.interpolation - if name == True: # note that "if name:" would be incorrect here - # backwards-compatibility: interpolation=True means use default - name = DEFAULT_INTERPOLATION - name = name.lower() # so that "Template", "template", etc. all work - class_ = interpolation_engines.get(name, None) - if class_ is None: - # invalid value for self.main.interpolation - self.main.interpolation = False - return value - else: - # save reference to engine so we don't have to do this again - engine = self._interpolation_engine = class_(self) - # let the engine do the actual work - return engine.interpolate(key, value) - - - def __getitem__(self, key): - """Fetch the item and do string interpolation.""" - val = dict.__getitem__(self, key) - if self.main.interpolation: - if isinstance(val, six.string_types): - return self._interpolate(key, val) - if isinstance(val, list): - def _check(entry): - if isinstance(entry, six.string_types): - return self._interpolate(key, entry) - return entry - new = [_check(entry) for entry in val] - if new != val: - return new - return val - - - def __setitem__(self, key, value, unrepr=False): - """ - Correctly set a value. - - Making dictionary values Section instances. - (We have to special case 'Section' instances - which are also dicts) - - Keys must be strings. - Values need only be strings (or lists of strings) if - ``main.stringify`` is set. - - ``unrepr`` must be set when setting a value to a dictionary, without - creating a new sub-section. - """ - if not isinstance(key, six.string_types): - raise ValueError('The key "%s" is not a string.' % key) - - # add the comment - if key not in self.comments: - self.comments[key] = [] - self.inline_comments[key] = '' - # remove the entry from defaults - if key in self.defaults: - self.defaults.remove(key) - # - if isinstance(value, Section): - if key not in self: - self.sections.append(key) - dict.__setitem__(self, key, value) - elif isinstance(value, dict) and not unrepr: - # First create the new depth level, - # then create the section - if key not in self: - self.sections.append(key) - new_depth = self.depth + 1 - dict.__setitem__( - self, - key, - Section( - self, - new_depth, - self.main, - indict=value, - name=key)) - else: - if key not in self: - self.scalars.append(key) - if not self.main.stringify: - if isinstance(value, six.string_types): - pass - elif isinstance(value, (list, tuple)): - for entry in value: - if not isinstance(entry, six.string_types): - raise TypeError('Value is not a string "%s".' % entry) - else: - raise TypeError('Value is not a string "%s".' % value) - dict.__setitem__(self, key, value) - - - def __delitem__(self, key): - """Remove items from the sequence when deleting.""" - dict. __delitem__(self, key) - if key in self.scalars: - self.scalars.remove(key) - else: - self.sections.remove(key) - del self.comments[key] - del self.inline_comments[key] - - - def get(self, key, default=None): - """A version of ``get`` that doesn't bypass string interpolation.""" - try: - return self[key] - except KeyError: - return default - - - def update(self, indict): - """ - A version of update that uses our ``__setitem__``. - """ - for entry in indict: - self[entry] = indict[entry] - - - def pop(self, key, default=MISSING): - """ - 'D.pop(k[,d]) -> v, remove specified key and return the corresponding value. - If key is not found, d is returned if given, otherwise KeyError is raised' - """ - try: - val = self[key] - except KeyError: - if default is MISSING: - raise - val = default - else: - del self[key] - return val - - - def popitem(self): - """Pops the first (key,val)""" - sequence = (self.scalars + self.sections) - if not sequence: - raise KeyError(": 'popitem(): dictionary is empty'") - key = sequence[0] - val = self[key] - del self[key] - return key, val - - - def clear(self): - """ - A version of clear that also affects scalars/sections - Also clears comments and configspec. - - Leaves other attributes alone : - depth/main/parent are not affected - """ - dict.clear(self) - self.scalars = [] - self.sections = [] - self.comments = {} - self.inline_comments = {} - self.configspec = None - self.defaults = [] - self.extra_values = [] - - - def setdefault(self, key, default=None): - """A version of setdefault that sets sequence if appropriate.""" - try: - return self[key] - except KeyError: - self[key] = default - return self[key] - - - def items(self): - """D.items() -> list of D's (key, value) pairs, as 2-tuples""" - return list(zip((self.scalars + self.sections), list(self.values()))) - - - def keys(self): - """D.keys() -> list of D's keys""" - return (self.scalars + self.sections) - - - def values(self): - """D.values() -> list of D's values""" - return [self[key] for key in (self.scalars + self.sections)] - - - def iteritems(self): - """D.iteritems() -> an iterator over the (key, value) items of D""" - return iter(list(self.items())) - - - def iterkeys(self): - """D.iterkeys() -> an iterator over the keys of D""" - return iter((self.scalars + self.sections)) - - __iter__ = iterkeys - - - def itervalues(self): - """D.itervalues() -> an iterator over the values of D""" - return iter(list(self.values())) - - - def __repr__(self): - """x.__repr__() <==> repr(x)""" - def _getval(key): - try: - return self[key] - except MissingInterpolationOption: - return dict.__getitem__(self, key) - return '{%s}' % ', '.join([('%s: %s' % (repr(key), repr(_getval(key)))) - for key in (self.scalars + self.sections)]) - - __str__ = __repr__ - __str__.__doc__ = "x.__str__() <==> str(x)" - - - # Extra methods - not in a normal dictionary - - def dict(self): - """ - Return a deepcopy of self as a dictionary. - - All members that are ``Section`` instances are recursively turned to - ordinary dictionaries - by calling their ``dict`` method. - - >>> n = a.dict() - >>> n == a - 1 - >>> n is a - 0 - """ - newdict = {} - for entry in self: - this_entry = self[entry] - if isinstance(this_entry, Section): - this_entry = this_entry.dict() - elif isinstance(this_entry, list): - # create a copy rather than a reference - this_entry = list(this_entry) - elif isinstance(this_entry, tuple): - # create a copy rather than a reference - this_entry = tuple(this_entry) - newdict[entry] = this_entry - return newdict - - - def merge(self, indict): - """ - A recursive update - useful for merging config files. - - >>> a = '''[section1] - ... option1 = True - ... [[subsection]] - ... more_options = False - ... # end of file'''.splitlines() - >>> b = '''# File is user.ini - ... [section1] - ... option1 = False - ... # end of file'''.splitlines() - >>> c1 = ConfigObj(b) - >>> c2 = ConfigObj(a) - >>> c2.merge(c1) - >>> c2 - ConfigObj({'section1': {'option1': 'False', 'subsection': {'more_options': 'False'}}}) - """ - for key, val in list(indict.items()): - if (key in self and isinstance(self[key], dict) and - isinstance(val, dict)): - self[key].merge(val) - else: - self[key] = val - - - def rename(self, oldkey, newkey): - """ - Change a keyname to another, without changing position in sequence. - - Implemented so that transformations can be made on keys, - as well as on values. (used by encode and decode) - - Also renames comments. - """ - if oldkey in self.scalars: - the_list = self.scalars - elif oldkey in self.sections: - the_list = self.sections - else: - raise KeyError('Key "%s" not found.' % oldkey) - pos = the_list.index(oldkey) - # - val = self[oldkey] - dict.__delitem__(self, oldkey) - dict.__setitem__(self, newkey, val) - the_list.remove(oldkey) - the_list.insert(pos, newkey) - comm = self.comments[oldkey] - inline_comment = self.inline_comments[oldkey] - del self.comments[oldkey] - del self.inline_comments[oldkey] - self.comments[newkey] = comm - self.inline_comments[newkey] = inline_comment - - - def walk(self, function, raise_errors=True, - call_on_sections=False, **keywargs): - """ - Walk every member and call a function on the keyword and value. - - Return a dictionary of the return values - - If the function raises an exception, raise the errror - unless ``raise_errors=False``, in which case set the return value to - ``False``. - - Any unrecognised keyword arguments you pass to walk, will be pased on - to the function you pass in. - - Note: if ``call_on_sections`` is ``True`` then - on encountering a - subsection, *first* the function is called for the *whole* subsection, - and then recurses into it's members. This means your function must be - able to handle strings, dictionaries and lists. This allows you - to change the key of subsections as well as for ordinary members. The - return value when called on the whole subsection has to be discarded. - - See the encode and decode methods for examples, including functions. - - .. admonition:: caution - - You can use ``walk`` to transform the names of members of a section - but you mustn't add or delete members. - - >>> config = '''[XXXXsection] - ... XXXXkey = XXXXvalue'''.splitlines() - >>> cfg = ConfigObj(config) - >>> cfg - ConfigObj({'XXXXsection': {'XXXXkey': 'XXXXvalue'}}) - >>> def transform(section, key): - ... val = section[key] - ... newkey = key.replace('XXXX', 'CLIENT1') - ... section.rename(key, newkey) - ... if isinstance(val, (tuple, list, dict)): - ... pass - ... else: - ... val = val.replace('XXXX', 'CLIENT1') - ... section[newkey] = val - >>> cfg.walk(transform, call_on_sections=True) - {'CLIENT1section': {'CLIENT1key': None}} - >>> cfg - ConfigObj({'CLIENT1section': {'CLIENT1key': 'CLIENT1value'}}) - """ - out = {} - # scalars first - for i in range(len(self.scalars)): - entry = self.scalars[i] - try: - val = function(self, entry, **keywargs) - # bound again in case name has changed - entry = self.scalars[i] - out[entry] = val - except Exception: - if raise_errors: - raise - else: - entry = self.scalars[i] - out[entry] = False - # then sections - for i in range(len(self.sections)): - entry = self.sections[i] - if call_on_sections: - try: - function(self, entry, **keywargs) - except Exception: - if raise_errors: - raise - else: - entry = self.sections[i] - out[entry] = False - # bound again in case name has changed - entry = self.sections[i] - # previous result is discarded - out[entry] = self[entry].walk( - function, - raise_errors=raise_errors, - call_on_sections=call_on_sections, - **keywargs) - return out - - - def as_bool(self, key): - """ - Accepts a key as input. The corresponding value must be a string or - the objects (``True`` or 1) or (``False`` or 0). We allow 0 and 1 to - retain compatibility with Python 2.2. - - If the string is one of ``True``, ``On``, ``Yes``, or ``1`` it returns - ``True``. - - If the string is one of ``False``, ``Off``, ``No``, or ``0`` it returns - ``False``. - - ``as_bool`` is not case sensitive. - - Any other input will raise a ``ValueError``. - - >>> a = ConfigObj() - >>> a['a'] = 'fish' - >>> a.as_bool('a') - Traceback (most recent call last): - ValueError: Value "fish" is neither True nor False - >>> a['b'] = 'True' - >>> a.as_bool('b') - 1 - >>> a['b'] = 'off' - >>> a.as_bool('b') - 0 - """ - val = self[key] - if val == True: - return True - elif val == False: - return False - else: - try: - if not isinstance(val, six.string_types): - # TODO: Why do we raise a KeyError here? - raise KeyError() - else: - return self.main._bools[val.lower()] - except KeyError: - raise ValueError('Value "%s" is neither True nor False' % val) - - - def as_int(self, key): - """ - A convenience method which coerces the specified value to an integer. - - If the value is an invalid literal for ``int``, a ``ValueError`` will - be raised. - - >>> a = ConfigObj() - >>> a['a'] = 'fish' - >>> a.as_int('a') - Traceback (most recent call last): - ValueError: invalid literal for int() with base 10: 'fish' - >>> a['b'] = '1' - >>> a.as_int('b') - 1 - >>> a['b'] = '3.2' - >>> a.as_int('b') - Traceback (most recent call last): - ValueError: invalid literal for int() with base 10: '3.2' - """ - return int(self[key]) - - - def as_float(self, key): - """ - A convenience method which coerces the specified value to a float. - - If the value is an invalid literal for ``float``, a ``ValueError`` will - be raised. - - >>> a = ConfigObj() - >>> a['a'] = 'fish' - >>> a.as_float('a') #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - ValueError: invalid literal for float(): fish - >>> a['b'] = '1' - >>> a.as_float('b') - 1.0 - >>> a['b'] = '3.2' - >>> a.as_float('b') #doctest: +ELLIPSIS - 3.2... - """ - return float(self[key]) - - - def as_list(self, key): - """ - A convenience method which fetches the specified value, guaranteeing - that it is a list. - - >>> a = ConfigObj() - >>> a['a'] = 1 - >>> a.as_list('a') - [1] - >>> a['a'] = (1,) - >>> a.as_list('a') - [1] - >>> a['a'] = [1] - >>> a.as_list('a') - [1] - """ - result = self[key] - if isinstance(result, (tuple, list)): - return list(result) - return [result] - - - def restore_default(self, key): - """ - Restore (and return) default value for the specified key. - - This method will only work for a ConfigObj that was created - with a configspec and has been validated. - - If there is no default value for this key, ``KeyError`` is raised. - """ - default = self.default_values[key] - dict.__setitem__(self, key, default) - if key not in self.defaults: - self.defaults.append(key) - return default - - - def restore_defaults(self): - """ - Recursively restore default values to all members - that have them. - - This method will only work for a ConfigObj that was created - with a configspec and has been validated. - - It doesn't delete or modify entries without default values. - """ - for key in self.default_values: - self.restore_default(key) - - for section in self.sections: - self[section].restore_defaults() - - -class ConfigObj(Section): - """An object to read, create, and write config files.""" - - _keyword = re.compile(r'''^ # line start - (\s*) # indentation - ( # keyword - (?:".*?")| # double quotes - (?:'.*?')| # single quotes - (?:[^'"=].*?) # no quotes - ) - \s*=\s* # divider - (.*) # value (including list values and comments) - $ # line end - ''', - re.VERBOSE) - - _sectionmarker = re.compile(r'''^ - (\s*) # 1: indentation - ((?:\[\s*)+) # 2: section marker open - ( # 3: section name open - (?:"\s*\S.*?\s*")| # at least one non-space with double quotes - (?:'\s*\S.*?\s*')| # at least one non-space with single quotes - (?:[^'"\s].*?) # at least one non-space unquoted - ) # section name close - ((?:\s*\])+) # 4: section marker close - \s*(\#.*)? # 5: optional comment - $''', - re.VERBOSE) - - # this regexp pulls list values out as a single string - # or single values and comments - # FIXME: this regex adds a '' to the end of comma terminated lists - # workaround in ``_handle_value`` - _valueexp = re.compile(r'''^ - (?: - (?: - ( - (?: - (?: - (?:".*?")| # double quotes - (?:'.*?')| # single quotes - (?:[^'",\#][^,\#]*?) # unquoted - ) - \s*,\s* # comma - )* # match all list items ending in a comma (if any) - ) - ( - (?:".*?")| # double quotes - (?:'.*?')| # single quotes - (?:[^'",\#\s][^,]*?)| # unquoted - (?:(? 1: - msg = "Parsing failed with several errors.\nFirst error %s" % info - error = ConfigObjError(msg) - else: - error = self._errors[0] - # set the errors attribute; it's a list of tuples: - # (error_type, message, line_number) - error.errors = self._errors - # set the config attribute - error.config = self - raise error - # delete private attributes - del self._errors - - if configspec is None: - self.configspec = None - else: - self._handle_configspec(configspec) - - - def _initialise(self, options=None): - if options is None: - options = OPTION_DEFAULTS - - # initialise a few variables - self.filename = None - self._errors = [] - self.raise_errors = options['raise_errors'] - self.interpolation = options['interpolation'] - self.list_values = options['list_values'] - self.create_empty = options['create_empty'] - self.file_error = options['file_error'] - self.stringify = options['stringify'] - self.indent_type = options['indent_type'] - self.encoding = options['encoding'] - self.default_encoding = options['default_encoding'] - self.BOM = False - self.newlines = None - self.write_empty_values = options['write_empty_values'] - self.unrepr = options['unrepr'] - - self.initial_comment = [] - self.final_comment = [] - self.configspec = None - - if self._inspec: - self.list_values = False - - # Clear section attributes as well - Section._initialise(self) - - - def __repr__(self): - def _getval(key): - try: - return self[key] - except MissingInterpolationOption: - return dict.__getitem__(self, key) - return ('ConfigObj({%s})' % - ', '.join([('%s: %s' % (repr(key), repr(_getval(key)))) - for key in (self.scalars + self.sections)])) - - - def _handle_bom(self, infile): - """ - Handle any BOM, and decode if necessary. - - If an encoding is specified, that *must* be used - but the BOM should - still be removed (and the BOM attribute set). - - (If the encoding is wrongly specified, then a BOM for an alternative - encoding won't be discovered or removed.) - - If an encoding is not specified, UTF8 or UTF16 BOM will be detected and - removed. The BOM attribute will be set. UTF16 will be decoded to - unicode. - - NOTE: This method must not be called with an empty ``infile``. - - Specifying the *wrong* encoding is likely to cause a - ``UnicodeDecodeError``. - - ``infile`` must always be returned as a list of lines, but may be - passed in as a single string. - """ - - if ((self.encoding is not None) and - (self.encoding.lower() not in BOM_LIST)): - # No need to check for a BOM - # the encoding specified doesn't have one - # just decode - return self._decode(infile, self.encoding) - - if isinstance(infile, (list, tuple)): - line = infile[0] - else: - line = infile - - if isinstance(line, six.text_type): - # it's already decoded and there's no need to do anything - # else, just use the _decode utility method to handle - # listifying appropriately - return self._decode(infile, self.encoding) - - if self.encoding is not None: - # encoding explicitly supplied - # And it could have an associated BOM - # TODO: if encoding is just UTF16 - we ought to check for both - # TODO: big endian and little endian versions. - enc = BOM_LIST[self.encoding.lower()] - if enc == 'utf_16': - # For UTF16 we try big endian and little endian - for BOM, (encoding, final_encoding) in list(BOMS.items()): - if not final_encoding: - # skip UTF8 - continue - if infile.startswith(BOM): - ### BOM discovered - ##self.BOM = True - # Don't need to remove BOM - return self._decode(infile, encoding) - - # If we get this far, will *probably* raise a DecodeError - # As it doesn't appear to start with a BOM - return self._decode(infile, self.encoding) - - # Must be UTF8 - BOM = BOM_SET[enc] - if not line.startswith(BOM): - return self._decode(infile, self.encoding) - - newline = line[len(BOM):] - - # BOM removed - if isinstance(infile, (list, tuple)): - infile[0] = newline - else: - infile = newline - self.BOM = True - return self._decode(infile, self.encoding) - - # No encoding specified - so we need to check for UTF8/UTF16 - for BOM, (encoding, final_encoding) in list(BOMS.items()): - if not isinstance(line, six.binary_type) or not line.startswith(BOM): - # didn't specify a BOM, or it's not a bytestring - continue - else: - # BOM discovered - self.encoding = final_encoding - if not final_encoding: - self.BOM = True - # UTF8 - # remove BOM - newline = line[len(BOM):] - if isinstance(infile, (list, tuple)): - infile[0] = newline - else: - infile = newline - # UTF-8 - if isinstance(infile, six.text_type): - return infile.splitlines(True) - elif isinstance(infile, six.binary_type): - return infile.decode('utf-8').splitlines(True) - else: - return self._decode(infile, 'utf-8') - # UTF16 - have to decode - return self._decode(infile, encoding) - - - if six.PY2 and isinstance(line, str): - # don't actually do any decoding, since we're on python 2 and - # returning a bytestring is fine - return self._decode(infile, None) - # No BOM discovered and no encoding specified, default to UTF-8 - if isinstance(infile, six.binary_type): - return infile.decode('utf-8').splitlines(True) - else: - return self._decode(infile, 'utf-8') - - - def _a_to_u(self, aString): - """Decode ASCII strings to unicode if a self.encoding is specified.""" - if isinstance(aString, six.binary_type) and self.encoding: - return aString.decode(self.encoding) - else: - return aString - - - def _decode(self, infile, encoding): - """ - Decode infile to unicode. Using the specified encoding. - - if is a string, it also needs converting to a list. - """ - if isinstance(infile, six.string_types): - return infile.splitlines(True) - if isinstance(infile, six.binary_type): - # NOTE: Could raise a ``UnicodeDecodeError`` - if encoding: - return infile.decode(encoding).splitlines(True) - else: - return infile.splitlines(True) - - if encoding: - for i, line in enumerate(infile): - if isinstance(line, six.binary_type): - # NOTE: The isinstance test here handles mixed lists of unicode/string - # NOTE: But the decode will break on any non-string values - # NOTE: Or could raise a ``UnicodeDecodeError`` - infile[i] = line.decode(encoding) - return infile - - - def _decode_element(self, line): - """Decode element to unicode if necessary.""" - if isinstance(line, six.binary_type) and self.default_encoding: - return line.decode(self.default_encoding) - else: - return line - - - # TODO: this may need to be modified - def _str(self, value): - """ - Used by ``stringify`` within validate, to turn non-string values - into strings. - """ - if not isinstance(value, six.string_types): - # intentially 'str' because it's just whatever the "normal" - # string type is for the python version we're dealing with - return str(value) - else: - return value - - - def _parse(self, infile): - """Actually parse the config file.""" - temp_list_values = self.list_values - if self.unrepr: - self.list_values = False - - comment_list = [] - done_start = False - this_section = self - maxline = len(infile) - 1 - cur_index = -1 - reset_comment = False - - while cur_index < maxline: - if reset_comment: - comment_list = [] - cur_index += 1 - line = infile[cur_index] - sline = line.strip() - # do we have anything on the line ? - if not sline or sline.startswith('#'): - reset_comment = False - comment_list.append(line) - continue - - if not done_start: - # preserve initial comment - self.initial_comment = comment_list - comment_list = [] - done_start = True - - reset_comment = True - # first we check if it's a section marker - mat = self._sectionmarker.match(line) - if mat is not None: - # is a section line - (indent, sect_open, sect_name, sect_close, comment) = mat.groups() - if indent and (self.indent_type is None): - self.indent_type = indent - cur_depth = sect_open.count('[') - if cur_depth != sect_close.count(']'): - self._handle_error("Cannot compute the section depth", - NestingError, infile, cur_index) - continue - - if cur_depth < this_section.depth: - # the new section is dropping back to a previous level - try: - parent = self._match_depth(this_section, - cur_depth).parent - except SyntaxError: - self._handle_error("Cannot compute nesting level", - NestingError, infile, cur_index) - continue - elif cur_depth == this_section.depth: - # the new section is a sibling of the current section - parent = this_section.parent - elif cur_depth == this_section.depth + 1: - # the new section is a child the current section - parent = this_section - else: - self._handle_error("Section too nested", - NestingError, infile, cur_index) - continue - - sect_name = self._unquote(sect_name) - if sect_name in parent: - self._handle_error('Duplicate section name', - DuplicateError, infile, cur_index) - continue - - # create the new section - this_section = Section( - parent, - cur_depth, - self, - name=sect_name) - parent[sect_name] = this_section - parent.inline_comments[sect_name] = comment - parent.comments[sect_name] = comment_list - continue - # - # it's not a section marker, - # so it should be a valid ``key = value`` line - mat = self._keyword.match(line) - if mat is None: - self._handle_error( - 'Invalid line ({0!r}) (matched as neither section nor keyword)'.format(line), - ParseError, infile, cur_index) - else: - # is a keyword value - # value will include any inline comment - (indent, key, value) = mat.groups() - if indent and (self.indent_type is None): - self.indent_type = indent - # check for a multiline value - if value[:3] in ['"""', "'''"]: - try: - value, comment, cur_index = self._multiline( - value, infile, cur_index, maxline) - except SyntaxError: - self._handle_error( - 'Parse error in multiline value', - ParseError, infile, cur_index) - continue - else: - if self.unrepr: - comment = '' - try: - value = unrepr(value) - except Exception as e: - if type(e) == UnknownType: - msg = 'Unknown name or type in value' - else: - msg = 'Parse error from unrepr-ing multiline value' - self._handle_error(msg, UnreprError, infile, - cur_index) - continue - else: - if self.unrepr: - comment = '' - try: - value = unrepr(value) - except Exception as e: - if isinstance(e, UnknownType): - msg = 'Unknown name or type in value' - else: - msg = 'Parse error from unrepr-ing value' - self._handle_error(msg, UnreprError, infile, - cur_index) - continue - else: - # extract comment and lists - try: - (value, comment) = self._handle_value(value) - except SyntaxError: - self._handle_error( - 'Parse error in value', - ParseError, infile, cur_index) - continue - # - key = self._unquote(key) - if key in this_section: - self._handle_error( - 'Duplicate keyword name', - DuplicateError, infile, cur_index) - continue - # add the key. - # we set unrepr because if we have got this far we will never - # be creating a new section - this_section.__setitem__(key, value, unrepr=True) - this_section.inline_comments[key] = comment - this_section.comments[key] = comment_list - continue - # - if self.indent_type is None: - # no indentation used, set the type accordingly - self.indent_type = '' - - # preserve the final comment - if not self and not self.initial_comment: - self.initial_comment = comment_list - elif not reset_comment: - self.final_comment = comment_list - self.list_values = temp_list_values - - - def _match_depth(self, sect, depth): - """ - Given a section and a depth level, walk back through the sections - parents to see if the depth level matches a previous section. - - Return a reference to the right section, - or raise a SyntaxError. - """ - while depth < sect.depth: - if sect is sect.parent: - # we've reached the top level already - raise SyntaxError() - sect = sect.parent - if sect.depth == depth: - return sect - # shouldn't get here - raise SyntaxError() - - - def _handle_error(self, text, ErrorClass, infile, cur_index): - """ - Handle an error according to the error settings. - - Either raise the error or store it. - The error will have occured at ``cur_index`` - """ - line = infile[cur_index] - cur_index += 1 - message = '{0} at line {1}.'.format(text, cur_index) - error = ErrorClass(message, cur_index, line) - if self.raise_errors: - # raise the error - parsing stops here - raise error - # store the error - # reraise when parsing has finished - self._errors.append(error) - - - def _unquote(self, value): - """Return an unquoted version of a value""" - if not value: - # should only happen during parsing of lists - raise SyntaxError - if (value[0] == value[-1]) and (value[0] in ('"', "'")): - value = value[1:-1] - return value - - - def _quote(self, value, multiline=True): - """ - Return a safely quoted version of a value. - - Raise a ConfigObjError if the value cannot be safely quoted. - If multiline is ``True`` (default) then use triple quotes - if necessary. - - * Don't quote values that don't need it. - * Recursively quote members of a list and return a comma joined list. - * Multiline is ``False`` for lists. - * Obey list syntax for empty and single member lists. - - If ``list_values=False`` then the value is only quoted if it contains - a ``\\n`` (is multiline) or '#'. - - If ``write_empty_values`` is set, and the value is an empty string, it - won't be quoted. - """ - if multiline and self.write_empty_values and value == '': - # Only if multiline is set, so that it is used for values not - # keys, and not values that are part of a list - return '' - - if multiline and isinstance(value, (list, tuple)): - if not value: - return ',' - elif len(value) == 1: - return self._quote(value[0], multiline=False) + ',' - return ', '.join([self._quote(val, multiline=False) - for val in value]) - if not isinstance(value, six.string_types): - if self.stringify: - # intentially 'str' because it's just whatever the "normal" - # string type is for the python version we're dealing with - value = str(value) - else: - raise TypeError('Value "%s" is not a string.' % value) - - if not value: - return '""' - - no_lists_no_quotes = not self.list_values and '\n' not in value and '#' not in value - need_triple = multiline and ((("'" in value) and ('"' in value)) or ('\n' in value )) - hash_triple_quote = multiline and not need_triple and ("'" in value) and ('"' in value) and ('#' in value) - check_for_single = (no_lists_no_quotes or not need_triple) and not hash_triple_quote - - if check_for_single: - if not self.list_values: - # we don't quote if ``list_values=False`` - quot = noquot - # for normal values either single or double quotes will do - elif '\n' in value: - # will only happen if multiline is off - e.g. '\n' in key - raise ConfigObjError('Value "%s" cannot be safely quoted.' % value) - elif ((value[0] not in wspace_plus) and - (value[-1] not in wspace_plus) and - (',' not in value)): - quot = noquot - else: - quot = self._get_single_quote(value) - else: - # if value has '\n' or "'" *and* '"', it will need triple quotes - quot = self._get_triple_quote(value) - - if quot == noquot and '#' in value and self.list_values: - quot = self._get_single_quote(value) - - return quot % value - - - def _get_single_quote(self, value): - if ("'" in value) and ('"' in value): - raise ConfigObjError('Value "%s" cannot be safely quoted.' % value) - elif '"' in value: - quot = squot - else: - quot = dquot - return quot - - - def _get_triple_quote(self, value): - if (value.find('"""') != -1) and (value.find("'''") != -1): - raise ConfigObjError('Value "%s" cannot be safely quoted.' % value) - if value.find('"""') == -1: - quot = tdquot - else: - quot = tsquot - return quot - - - def _handle_value(self, value): - """ - Given a value string, unquote, remove comment, - handle lists. (including empty and single member lists) - """ - if self._inspec: - # Parsing a configspec so don't handle comments - return (value, '') - # do we look for lists in values ? - if not self.list_values: - mat = self._nolistvalue.match(value) - if mat is None: - raise SyntaxError() - # NOTE: we don't unquote here - return mat.groups() - # - mat = self._valueexp.match(value) - if mat is None: - # the value is badly constructed, probably badly quoted, - # or an invalid list - raise SyntaxError() - (list_values, single, empty_list, comment) = mat.groups() - if (list_values == '') and (single is None): - # change this if you want to accept empty values - raise SyntaxError() - # NOTE: note there is no error handling from here if the regex - # is wrong: then incorrect values will slip through - if empty_list is not None: - # the single comma - meaning an empty list - return ([], comment) - if single is not None: - # handle empty values - if list_values and not single: - # FIXME: the '' is a workaround because our regex now matches - # '' at the end of a list if it has a trailing comma - single = None - else: - single = single or '""' - single = self._unquote(single) - if list_values == '': - # not a list value - return (single, comment) - the_list = self._listvalueexp.findall(list_values) - the_list = [self._unquote(val) for val in the_list] - if single is not None: - the_list += [single] - return (the_list, comment) - - - def _multiline(self, value, infile, cur_index, maxline): - """Extract the value, where we are in a multiline situation.""" - quot = value[:3] - newvalue = value[3:] - single_line = self._triple_quote[quot][0] - multi_line = self._triple_quote[quot][1] - mat = single_line.match(value) - if mat is not None: - retval = list(mat.groups()) - retval.append(cur_index) - return retval - elif newvalue.find(quot) != -1: - # somehow the triple quote is missing - raise SyntaxError() - # - while cur_index < maxline: - cur_index += 1 - newvalue += '\n' - line = infile[cur_index] - if line.find(quot) == -1: - newvalue += line - else: - # end of multiline, process it - break - else: - # we've got to the end of the config, oops... - raise SyntaxError() - mat = multi_line.match(line) - if mat is None: - # a badly formed line - raise SyntaxError() - (value, comment) = mat.groups() - return (newvalue + value, comment, cur_index) - - - def _handle_configspec(self, configspec): - """Parse the configspec.""" - # FIXME: Should we check that the configspec was created with the - # correct settings ? (i.e. ``list_values=False``) - if not isinstance(configspec, ConfigObj): - try: - configspec = ConfigObj(configspec, - raise_errors=True, - file_error=True, - _inspec=True) - except ConfigObjError as e: - # FIXME: Should these errors have a reference - # to the already parsed ConfigObj ? - raise ConfigspecError('Parsing configspec failed: %s' % e) - except IOError as e: - raise IOError('Reading configspec failed: %s' % e) - - self.configspec = configspec - - - - def _set_configspec(self, section, copy): - """ - Called by validate. Handles setting the configspec on subsections - including sections to be validated by __many__ - """ - configspec = section.configspec - many = configspec.get('__many__') - if isinstance(many, dict): - for entry in section.sections: - if entry not in configspec: - section[entry].configspec = many - - for entry in configspec.sections: - if entry == '__many__': - continue - if entry not in section: - section[entry] = {} - section[entry]._created = True - if copy: - # copy comments - section.comments[entry] = configspec.comments.get(entry, []) - section.inline_comments[entry] = configspec.inline_comments.get(entry, '') - - # Could be a scalar when we expect a section - if isinstance(section[entry], Section): - section[entry].configspec = configspec[entry] - - - def _write_line(self, indent_string, entry, this_entry, comment): - """Write an individual line, for the write method""" - # NOTE: the calls to self._quote here handles non-StringType values. - if not self.unrepr: - val = self._decode_element(self._quote(this_entry)) - else: - val = repr(this_entry) - return '%s%s%s%s%s' % (indent_string, - self._decode_element(self._quote(entry, multiline=False)), - self._a_to_u(' = '), - val, - self._decode_element(comment)) - - - def _write_marker(self, indent_string, depth, entry, comment): - """Write a section marker line""" - return '%s%s%s%s%s' % (indent_string, - self._a_to_u('[' * depth), - self._quote(self._decode_element(entry), multiline=False), - self._a_to_u(']' * depth), - self._decode_element(comment)) - - - def _handle_comment(self, comment): - """Deal with a comment.""" - if not comment: - return '' - start = self.indent_type - if not comment.startswith('#'): - start += self._a_to_u(' # ') - return (start + comment) - - - # Public methods - - def write(self, outfile=None, section=None): - """ - Write the current ConfigObj as a file - - tekNico: FIXME: use StringIO instead of real files - - >>> filename = a.filename - >>> a.filename = 'test.ini' - >>> a.write() - >>> a.filename = filename - >>> a == ConfigObj('test.ini', raise_errors=True) - 1 - >>> import os - >>> os.remove('test.ini') - """ - if self.indent_type is None: - # this can be true if initialised from a dictionary - self.indent_type = DEFAULT_INDENT_TYPE - - out = [] - cs = self._a_to_u('#') - csp = self._a_to_u('# ') - if section is None: - int_val = self.interpolation - self.interpolation = False - section = self - for line in self.initial_comment: - line = self._decode_element(line) - stripped_line = line.strip() - if stripped_line and not stripped_line.startswith(cs): - line = csp + line - out.append(line) - - indent_string = self.indent_type * section.depth - for entry in (section.scalars + section.sections): - if entry in section.defaults: - # don't write out default values - continue - for comment_line in section.comments[entry]: - comment_line = self._decode_element(comment_line.lstrip()) - if comment_line and not comment_line.startswith(cs): - comment_line = csp + comment_line - out.append(indent_string + comment_line) - this_entry = section[entry] - comment = self._handle_comment(section.inline_comments[entry]) - - if isinstance(this_entry, Section): - # a section - out.append(self._write_marker( - indent_string, - this_entry.depth, - entry, - comment)) - out.extend(self.write(section=this_entry)) - else: - out.append(self._write_line( - indent_string, - entry, - this_entry, - comment)) - - if section is self: - for line in self.final_comment: - line = self._decode_element(line) - stripped_line = line.strip() - if stripped_line and not stripped_line.startswith(cs): - line = csp + line - out.append(line) - self.interpolation = int_val - - if section is not self: - return out - - if (self.filename is None) and (outfile is None): - # output a list of lines - # might need to encode - # NOTE: This will *screw* UTF16, each line will start with the BOM - if self.encoding: - out = [l.encode(self.encoding) for l in out] - if (self.BOM and ((self.encoding is None) or - (BOM_LIST.get(self.encoding.lower()) == 'utf_8'))): - # Add the UTF8 BOM - if not out: - out.append('') - out[0] = BOM_UTF8 + out[0] - return out - - # Turn the list to a string, joined with correct newlines - newline = self.newlines or os.linesep - if (getattr(outfile, 'mode', None) is not None and outfile.mode == 'w' - and sys.platform == 'win32' and newline == '\r\n'): - # Windows specific hack to avoid writing '\r\r\n' - newline = '\n' - output = self._a_to_u(newline).join(out) - if not output.endswith(newline): - output += newline - - if isinstance(output, six.binary_type): - output_bytes = output - else: - output_bytes = output.encode(self.encoding or - self.default_encoding or - 'ascii') - - if self.BOM and ((self.encoding is None) or match_utf8(self.encoding)): - # Add the UTF8 BOM - output_bytes = BOM_UTF8 + output_bytes - - if outfile is not None: - outfile.write(output_bytes) - else: - with open(self.filename, 'wb') as h: - h.write(output_bytes) - - def validate(self, validator, preserve_errors=False, copy=False, - section=None): - """ - Test the ConfigObj against a configspec. - - It uses the ``validator`` object from *validate.py*. - - To run ``validate`` on the current ConfigObj, call: :: - - test = config.validate(validator) - - (Normally having previously passed in the configspec when the ConfigObj - was created - you can dynamically assign a dictionary of checks to the - ``configspec`` attribute of a section though). - - It returns ``True`` if everything passes, or a dictionary of - pass/fails (True/False). If every member of a subsection passes, it - will just have the value ``True``. (It also returns ``False`` if all - members fail). - - In addition, it converts the values from strings to their native - types if their checks pass (and ``stringify`` is set). - - If ``preserve_errors`` is ``True`` (``False`` is default) then instead - of a marking a fail with a ``False``, it will preserve the actual - exception object. This can contain info about the reason for failure. - For example the ``VdtValueTooSmallError`` indicates that the value - supplied was too small. If a value (or section) is missing it will - still be marked as ``False``. - - You must have the validate module to use ``preserve_errors=True``. - - You can then use the ``flatten_errors`` function to turn your nested - results dictionary into a flattened list of failures - useful for - displaying meaningful error messages. - """ - if section is None: - if self.configspec is None: - raise ValueError('No configspec supplied.') - if preserve_errors: - # We do this once to remove a top level dependency on the validate module - # Which makes importing configobj faster - from validate import VdtMissingValue - self._vdtMissingValue = VdtMissingValue - - section = self - - if copy: - section.initial_comment = section.configspec.initial_comment - section.final_comment = section.configspec.final_comment - section.encoding = section.configspec.encoding - section.BOM = section.configspec.BOM - section.newlines = section.configspec.newlines - section.indent_type = section.configspec.indent_type - - # - # section.default_values.clear() #?? - configspec = section.configspec - self._set_configspec(section, copy) - - - def validate_entry(entry, spec, val, missing, ret_true, ret_false): - section.default_values.pop(entry, None) - - try: - section.default_values[entry] = validator.get_default_value(configspec[entry]) - except (KeyError, AttributeError, validator.baseErrorClass): - # No default, bad default or validator has no 'get_default_value' - # (e.g. SimpleVal) - pass - - try: - check = validator.check(spec, - val, - missing=missing - ) - except validator.baseErrorClass as e: - if not preserve_errors or isinstance(e, self._vdtMissingValue): - out[entry] = False - else: - # preserve the error - out[entry] = e - ret_false = False - ret_true = False - else: - ret_false = False - out[entry] = True - if self.stringify or missing: - # if we are doing type conversion - # or the value is a supplied default - if not self.stringify: - if isinstance(check, (list, tuple)): - # preserve lists - check = [self._str(item) for item in check] - elif missing and check is None: - # convert the None from a default to a '' - check = '' - else: - check = self._str(check) - if (check != val) or missing: - section[entry] = check - if not copy and missing and entry not in section.defaults: - section.defaults.append(entry) - return ret_true, ret_false - - # - out = {} - ret_true = True - ret_false = True - - unvalidated = [k for k in section.scalars if k not in configspec] - incorrect_sections = [k for k in configspec.sections if k in section.scalars] - incorrect_scalars = [k for k in configspec.scalars if k in section.sections] - - for entry in configspec.scalars: - if entry in ('__many__', '___many___'): - # reserved names - continue - if (not entry in section.scalars) or (entry in section.defaults): - # missing entries - # or entries from defaults - missing = True - val = None - if copy and entry not in section.scalars: - # copy comments - section.comments[entry] = ( - configspec.comments.get(entry, [])) - section.inline_comments[entry] = ( - configspec.inline_comments.get(entry, '')) - # - else: - missing = False - val = section[entry] - - ret_true, ret_false = validate_entry(entry, configspec[entry], val, - missing, ret_true, ret_false) - - many = None - if '__many__' in configspec.scalars: - many = configspec['__many__'] - elif '___many___' in configspec.scalars: - many = configspec['___many___'] - - if many is not None: - for entry in unvalidated: - val = section[entry] - ret_true, ret_false = validate_entry(entry, many, val, False, - ret_true, ret_false) - unvalidated = [] - - for entry in incorrect_scalars: - ret_true = False - if not preserve_errors: - out[entry] = False - else: - ret_false = False - msg = 'Value %r was provided as a section' % entry - out[entry] = validator.baseErrorClass(msg) - for entry in incorrect_sections: - ret_true = False - if not preserve_errors: - out[entry] = False - else: - ret_false = False - msg = 'Section %r was provided as a single value' % entry - out[entry] = validator.baseErrorClass(msg) - - # Missing sections will have been created as empty ones when the - # configspec was read. - for entry in section.sections: - # FIXME: this means DEFAULT is not copied in copy mode - if section is self and entry == 'DEFAULT': - continue - if section[entry].configspec is None: - unvalidated.append(entry) - continue - if copy: - section.comments[entry] = configspec.comments.get(entry, []) - section.inline_comments[entry] = configspec.inline_comments.get(entry, '') - check = self.validate(validator, preserve_errors=preserve_errors, copy=copy, section=section[entry]) - out[entry] = check - if check == False: - ret_true = False - elif check == True: - ret_false = False - else: - ret_true = False - - section.extra_values = unvalidated - if preserve_errors and not section._created: - # If the section wasn't created (i.e. it wasn't missing) - # then we can't return False, we need to preserve errors - ret_false = False - # - if ret_false and preserve_errors and out: - # If we are preserving errors, but all - # the failures are from missing sections / values - # then we can return False. Otherwise there is a - # real failure that we need to preserve. - ret_false = not any(out.values()) - if ret_true: - return True - elif ret_false: - return False - return out - - - def reset(self): - """Clear ConfigObj instance and restore to 'freshly created' state.""" - self.clear() - self._initialise() - # FIXME: Should be done by '_initialise', but ConfigObj constructor (and reload) - # requires an empty dictionary - self.configspec = None - # Just to be sure ;-) - self._original_configspec = None - - - def reload(self): - """ - Reload a ConfigObj from file. - - This method raises a ``ReloadError`` if the ConfigObj doesn't have - a filename attribute pointing to a file. - """ - if not isinstance(self.filename, six.string_types): - raise ReloadError() - - filename = self.filename - current_options = {} - for entry in OPTION_DEFAULTS: - if entry == 'configspec': - continue - current_options[entry] = getattr(self, entry) - - configspec = self._original_configspec - current_options['configspec'] = configspec - - self.clear() - self._initialise(current_options) - self._load(filename, configspec) - - - -class SimpleVal(object): - """ - A simple validator. - Can be used to check that all members expected are present. - - To use it, provide a configspec with all your members in (the value given - will be ignored). Pass an instance of ``SimpleVal`` to the ``validate`` - method of your ``ConfigObj``. ``validate`` will return ``True`` if all - members are present, or a dictionary with True/False meaning - present/missing. (Whole missing sections will be replaced with ``False``) - """ - - def __init__(self): - self.baseErrorClass = ConfigObjError - - def check(self, check, member, missing=False): - """A dummy check method, always returns the value unchanged.""" - if missing: - raise self.baseErrorClass() - return member - - -def flatten_errors(cfg, res, levels=None, results=None): - """ - An example function that will turn a nested dictionary of results - (as returned by ``ConfigObj.validate``) into a flat list. - - ``cfg`` is the ConfigObj instance being checked, ``res`` is the results - dictionary returned by ``validate``. - - (This is a recursive function, so you shouldn't use the ``levels`` or - ``results`` arguments - they are used by the function.) - - Returns a list of keys that failed. Each member of the list is a tuple:: - - ([list of sections...], key, result) - - If ``validate`` was called with ``preserve_errors=False`` (the default) - then ``result`` will always be ``False``. - - *list of sections* is a flattened list of sections that the key was found - in. - - If the section was missing (or a section was expected and a scalar provided - - or vice-versa) then key will be ``None``. - - If the value (or section) was missing then ``result`` will be ``False``. - - If ``validate`` was called with ``preserve_errors=True`` and a value - was present, but failed the check, then ``result`` will be the exception - object returned. You can use this as a string that describes the failure. - - For example *The value "3" is of the wrong type*. - """ - if levels is None: - # first time called - levels = [] - results = [] - if res == True: - return sorted(results) - if res == False or isinstance(res, Exception): - results.append((levels[:], None, res)) - if levels: - levels.pop() - return sorted(results) - for (key, val) in list(res.items()): - if val == True: - continue - if isinstance(cfg.get(key), dict): - # Go down one level - levels.append(key) - flatten_errors(cfg[key], val, levels, results) - continue - results.append((levels[:], key, val)) - # - # Go up one level - if levels: - levels.pop() - # - return sorted(results) - - -def get_extra_values(conf, _prepend=()): - """ - Find all the values and sections not in the configspec from a validated - ConfigObj. - - ``get_extra_values`` returns a list of tuples where each tuple represents - either an extra section, or an extra value. - - The tuples contain two values, a tuple representing the section the value - is in and the name of the extra values. For extra values in the top level - section the first member will be an empty tuple. For values in the 'foo' - section the first member will be ``('foo',)``. For members in the 'bar' - subsection of the 'foo' section the first member will be ``('foo', 'bar')``. - - NOTE: If you call ``get_extra_values`` on a ConfigObj instance that hasn't - been validated it will return an empty list. - """ - out = [] - - out.extend([(_prepend, name) for name in conf.extra_values]) - for name in conf.sections: - if name not in conf.extra_values: - out.extend(get_extra_values(conf[name], _prepend + (name,))) - return out - - -"""*A programming language is a medium of expression.* - Paul Graham""" diff --git a/nomadnet/vendor/quotes.py b/nomadnet/vendor/quotes.py index ee142f7..4c87571 100644 --- a/nomadnet/vendor/quotes.py +++ b/nomadnet/vendor/quotes.py @@ -3,4 +3,5 @@ quotes = [ ("That's enough entropy for you my friend", "Unknown"), ("Any time two people connect online, it's financed by a third person who believes they can manipulate the first two", "Jaron Lanier"), ("The landscape of the future is set, but how one will march across it is not determined", "Terence McKenna") + ("Freedom originates in the division of power, despotism in its concentration.", "John Acton") ] \ No newline at end of file diff --git a/setup.py b/setup.py index 6d4bfdc..45721bd 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +import sys import setuptools exec(open("nomadnet/_version.py", "r").read()) @@ -5,6 +6,17 @@ exec(open("nomadnet/_version.py", "r").read()) with open("README.md", "r") as fh: long_description = fh.read() +if "--getversion" in sys.argv: + print(__version__, end="") + exit(0) + +package_data = { +"": [ + "examples/messageboard/*", + "ui/textui/images/demo.webp", + ] +} + setuptools.setup( name="nomadnet", version=__version__, @@ -15,6 +27,7 @@ setuptools.setup( long_description_content_type="text/markdown", url="https://github.com/markqvist/nomadnet", packages=setuptools.find_packages(), + package_data=package_data, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -23,6 +36,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=['rns>=0.3.8', 'lxmf>=0.1.7', 'urwid>=2.1.2'], - python_requires='>=3.6', + install_requires=["rns>=1.5.5", "lxmf>=1.2.0", "urwid>=3.0.5", "qrcode"], + python_requires=">=3.8", ) diff --git a/tools/colorgen.py b/tools/colorgen.py new file mode 100644 index 0000000..0ff636a --- /dev/null +++ b/tools/colorgen.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Color Table Generator with Perceptual Normalization + +Generates HSL color tables with optional normalization to uniform perceptual +intensity (CIELAB L*) followed by global lightness scaling. +""" + +import argparse +import colorsys +import math +import sys + + +def hsl_to_rgb(h, s, l): + """Convert HSL (0-360, 0-100, 0-100) to RGB (0.0-1.0).""" + h_norm = (h % 360) / 360.0 + s_norm = max(0, min(100, s)) / 100.0 + l_norm = max(0, min(100, l)) / 100.0 + r, g, b = colorsys.hls_to_rgb(h_norm, l_norm, s_norm) + return (r, g, b) + + +def rgb_to_hex(r, g, b): + """Convert RGB 0.0-1.0 to lowercase hex string.""" + r = max(0, min(1, r)) + g = max(0, min(1, g)) + b = max(0, min(1, b)) + return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" + + +# CIELAB conversion functions +def srgb_to_linear(c): + if c <= 0.04045: + return c / 12.92 + return ((c + 0.055) / 1.055) ** 2.4 + + +def linear_to_srgb(c): + if c <= 0.0031308: + return c * 12.92 + return 1.055 * (c ** (1/2.4)) - 0.055 + + +def rgb_to_lab(r, g, b): + """Convert sRGB (0-1) to CIELAB (L: 0-100, a,b: ~-128 to 127). D65 illuminant.""" + # sRGB to Linear + r_lin = srgb_to_linear(r) + g_lin = srgb_to_linear(g) + b_lin = srgb_to_linear(b) + + # Linear to XYZ (D65) + X = 0.4124564 * r_lin + 0.3575761 * g_lin + 0.1804375 * b_lin + Y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin + Z = 0.0193339 * r_lin + 0.1191920 * g_lin + 0.9503041 * b_lin + + # XYZ to LAB + Xn, Yn, Zn = 95.047, 100.0, 108.883 + delta = 6/29 + + def f(t): + if t > delta**3: + return t ** (1/3) + return t / (3 * delta**2) + 4/29 + + L = 116 * f(Y / Yn) - 16 + a = 500 * (f(X / Xn) - f(Y / Yn)) + b_val = 200 * (f(Y / Yn) - f(Z / Zn)) + + return (L, a, b_val) + + +def lab_to_rgb(L, a, b_val): + """Convert CIELAB to sRGB (0-1).""" + Xn, Yn, Zn = 95.047, 100.0, 108.883 + delta = 6/29 + + def inv_f(y): + if y > delta: + return y ** 3 + return 3 * delta**2 * (y - 4/29) + + L_adj = (L + 16) / 116 + X = Xn * inv_f(L_adj + a / 500) + Y = Yn * inv_f(L_adj) + Z = Zn * inv_f(L_adj - b_val / 200) + + # XYZ to Linear RGB + r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z + g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z + b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z + + r = linear_to_srgb(r_lin) + g = linear_to_srgb(g_lin) + b = linear_to_srgb(b_lin) + + return (r, g, b) + + +def generate_colors( + hues=None, + hue_start=0.0, + hue_step=30.0, + sat_start=70.0, + sat_step=0.0, + sat_steps=1, + light_start=50.0, + light_step=0.0, + light_steps=1, + perceptual_multiplier=1.0, + use_hsl_space=False, + normalize=False, + normalize_target=None, + discards=[] +): + """ + Generate color table with optional perceptual normalization. + + Pipeline: + 1. Generate HSL variations + 2. If normalize: Convert to LAB, set all L* to target (mean or specified) + 3. Apply global perceptual multiplier to L* + 4. Convert to RGB and output hex + """ + # Determine hue list + if hues is not None: + hue_list = [float(h) % 360 for h in hues] + else: + hue_list = [] + current = hue_start % 360 + if hue_step <= 0: + raise ValueError("hue_step must be positive when using start/step mode") + while current < 360: + hue_list.append(current) + current += hue_step + + # Determine if we need LAB processing + needs_lab = normalize or (perceptual_multiplier != 1.0) or not use_hsl_space + + colors = [] + lab_colors = [] # Store as (L, a, b) tuples if processing needed + + for h in hue_list: + for i in range(sat_steps): + s = sat_start + (i * sat_step) + s = max(0, min(100, s)) + + for j in range(light_steps): + # l = light_start + (j * light_step) + l = light_start + (i * light_step) + l = max(0, min(100, l)) + + r, g, b = hsl_to_rgb(h, s, l) + + if not needs_lab: + colors.append(rgb_to_hex(r, g, b)) + else: + L, a, b_lab = rgb_to_lab(r, g, b) + lab_colors.append((L, a, b_lab)) + + filtered_colors = [] + filtered_lab_colors = [] + i = 0 + for c in colors: + i += 1 + if not str(i) in discards: + filtered_colors.append(c) + + i = 0 + for c in lab_colors: + i += 1 + if not str(i) in discards: + filtered_lab_colors.append(c) + + colors = filtered_colors + lab_colors = filtered_lab_colors + + if not needs_lab: + return colors + + # Step 2: Perceptual Normalization (equalize intensity) + if normalize: + if normalize_target is not None: + target_L = normalize_target + else: + # Calculate mean perceptual lightness + target_L = sum(lab[0] for lab in lab_colors) / len(lab_colors) + print(f"Target L*: {target_L}") + + # Clamp target to valid LAB range + target_L = max(0, min(100, target_L)) + + # Normalize all colors to target L, preserving hue (a, b) + lab_colors = [(target_L, a, b) for (L, a, b) in lab_colors] + + # Step 3: Apply final global perceptual multiplier + final_colors = [] + for L, a, b in lab_colors: + L_final = L * perceptual_multiplier + L_final = max(0, min(100, L_final)) + r, g, b = lab_to_rgb(L_final, a, b) + final_colors.append(rgb_to_hex(r, g, b)) + + return final_colors + + +def main(): + parser = argparse.ArgumentParser( + description="Generate perceptually normalized color tables", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Pipeline: HSL Generation → [Normalize to uniform L*] → Global Multiplier → Hex + +Examples: + # 32 colors normalized to same perceptual intensity, then darkened 20% + python3 colorgen.py --hue-start 0 --hue-step 11.25 --sat-start 75 --normalize --perceptual-multiplier 0.8 + + # Force all colors to exactly L*=55, then scale by 1.1 + python3 colorgen.py --hues "0,90,180,270" --normalize --normalize-target 55 --perceptual-multiplier 1.1 + + # Generate with lightness variation, normalize to mean intensity, output brightened + python3 colorgen.py --hue-start 15 --hue-step 30 --light-start 40 --light-step 10 --light-steps 3 --normalize --perceptual-multiplier 1.2 + """ + ) + + # Hue configuration + hue_group = parser.add_mutually_exclusive_group() + hue_group.add_argument("--hues", type=str, metavar="LIST", + help='Comma-separated hue values, e.g., "0,60,120"') + hue_group.add_argument("--hue-start", type=float, default=0.0, + help="Initial hue offset in degrees") + parser.add_argument("--hue-step", type=float, default=30.0, + help="Separation between hues") + + # Saturation/Lightness configuration + parser.add_argument("--sat-start", type=float, default=75.0, + help="Initial saturation (0-100)") + parser.add_argument("--sat-step", type=float, default=0.0, + help="Saturation step") + parser.add_argument("--sat-steps", type=int, default=1, + help="Number of saturation variations") + parser.add_argument("--light-start", type=float, default=50.0, + help="Initial lightness (0-100)") + parser.add_argument("--light-step", type=float, default=0.0, + help="Lightness step") + parser.add_argument("--light-steps", type=int, default=1, + help="Number of lightness variations") + + # Perceptual processing + parser.add_argument("--normalize", action="store_true", + help="Normalize all colors to same perceptual intensity (L*) before applying multiplier") + parser.add_argument("--normalize-target", type=float, default=None, metavar="L", + help="Target L* value (0-100) for normalization. Default: mean of generated colors") + parser.add_argument("--perceptual-multiplier", type=float, default=1.0, + help="Final global lightness multiplier (1.0=no change)") + parser.add_argument("--hsl-space", action="store_true", + help="Skip LAB conversion if not normalizing (faster, less accurate perceptually)") + + # Output + parser.add_argument("--python-list", action="store_true", + help="Output as Python list") + parser.add_argument("--separator", type=str, default="\n", + help="Separator between colors") + parser.add_argument("--stats", action="store_true", + help="Print generation stats to stderr") + hue_group.add_argument("--discard", type=str, metavar="LIST", + help='Discard output indexes, e.g., "20,2,33"') + + args = parser.parse_args() + + if args.hsl_space and args.normalize: + print("Warning: --hsl-space ignored because --normalize requires LAB space", file=sys.stderr) + args.hsl_space = False + + try: + if args.discard: discards = args.discard.split(",") + else: discards = [] + + colors = generate_colors( + hues=args.hues.split(",") if args.hues else None, + hue_start=args.hue_start, + hue_step=args.hue_step, + sat_start=args.sat_start, + sat_step=args.sat_step, + sat_steps=args.sat_steps, + light_start=args.light_start, + light_step=args.light_step, + light_steps=args.light_steps, + perceptual_multiplier=args.perceptual_multiplier, + use_hsl_space=args.hsl_space, + normalize=args.normalize, + normalize_target=args.normalize_target, + discards=discards, + ) + + i = 0 + # Enable to test on light background + # print(f"#!bg=fff\n") + if args.python_list: print("colors = [", end="") + for c in colors: + i+=1 + if args.python_list: + print(f"\"{c}\", ", end="") + + else: print(f"`FT{c}colored {i}`f") + + if args.python_list: print("]") + + if args.stats: + total = len(colors) + mode = "normalized + " if args.normalize else "" + print(f"# Generated {total} colors ({mode}L* × {args.perceptual_multiplier})", + file=sys.stderr) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/rrc-log-dump.py b/tools/rrc-log-dump.py new file mode 100755 index 0000000..30c9bb8 --- /dev/null +++ b/tools/rrc-log-dump.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Dump the contents of rrc on-disk room history logs. + +Usage: + rrc-log-dump.py + + may be: + - a single .log file + - a hub directory under rrc_history/ + - the rrc_history/ root (or any ancestor; .log files are found recursively) +""" + +import os +import sys +import time + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(SCRIPT_DIR, "..", "nomadnet", "vendor")) +import cbor + + +H_KIND = "k" +H_SRC = "s" +H_NICK = "n" +H_TEXT = "t" +H_TS = "ts" +H_MENTION = "m" + +KIND_COLOR = { + "msg": "", + "system": "\033[2m", + "notice": "\033[36m", + "error": "\033[31m", +} +RESET = "\033[0m" + + +def _fmt_ts(ts_ms): + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts_ms) // 1000)) + except Exception: + return "????-??-?? ??:??:??" + + +def _fmt_speaker(entry): + nick = entry.get(H_NICK) + src = entry.get(H_SRC) + src_hex = src.hex()[:12] if isinstance(src, (bytes, bytearray)) else None + if nick and src_hex: + return "%s (%s)" % (nick, src_hex) + if nick: + return nick + if src_hex: + return src_hex + return "" + + +def dump_file(path, use_color): + print("=== %s ===" % path) + count = 0 + truncated = False + with open(path, "rb") as f: + while True: + try: + entry = cbor.load(f) + except EOFError: + break + except Exception as ex: + truncated = True + print(" [corrupt record after entry %d: %s]" % (count, ex), file=sys.stderr) + break + if not isinstance(entry, dict): + print(" [skipping non-dict entry: %r]" % (entry,), file=sys.stderr) + continue + kind = entry.get(H_KIND) or "msg" + ts = _fmt_ts(entry.get(H_TS, 0)) + speaker = _fmt_speaker(entry) + text = entry.get(H_TEXT) or "" + mention = " *" if entry.get(H_MENTION) else " " + color = KIND_COLOR.get(kind, "") if use_color else "" + end = RESET if (color and use_color) else "" + line = "%s %-6s%s %-32s %s" % (ts, kind, mention, speaker, text) + print(color + line + end) + count += 1 + suffix = " (truncated)" if truncated else "" + print("--- %d entries%s ---" % (count, suffix)) + + +def main(argv): + if len(argv) != 2 or argv[1] in ("-h", "--help"): + print(__doc__, file=sys.stderr) + return 2 + target = argv[1] + use_color = sys.stdout.isatty() + if os.path.isfile(target): + dump_file(target, use_color) + return 0 + if os.path.isdir(target): + files = [] + for root, _dirs, names in os.walk(target): + for name in names: + if name.endswith(".log"): + files.append(os.path.join(root, name)) + files.sort() + if not files: + print("no .log files found under %s" % target, file=sys.stderr) + return 1 + for i, path in enumerate(files): + if i > 0: + print() + dump_file(path, use_color) + return 0 + print("not a file or directory: %s" % target, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))