├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── 🐛-bug-report.md └── workflows │ └── publish-container.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.build ├── Dockerfile.howto ├── FUNDING.yml ├── LICENSE ├── Makefile ├── README.md ├── docs └── screenshots │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ └── 5.png ├── nomadnet ├── Conversation.py ├── Directory.py ├── Node.py ├── NomadNetworkApp.py ├── __init__.py ├── _version.py ├── examples │ ├── messageboard │ │ ├── README.md │ │ ├── messageboard.mu │ │ └── messageboard.py │ └── various │ │ └── input_fields.py ├── nomadnet.py ├── ui │ ├── GraphicalUI.py │ ├── MenuUI.py │ ├── NoneUI.py │ ├── TextUI.py │ ├── WebUI.py │ ├── __init__.py │ └── textui │ │ ├── Browser.py │ │ ├── Config.py │ │ ├── Conversations.py │ │ ├── Directory.py │ │ ├── Extras.py │ │ ├── Guide.py │ │ ├── Interfaces.py │ │ ├── Log.py │ │ ├── Main.py │ │ ├── Map.py │ │ ├── MicronParser.py │ │ ├── Network.py │ │ └── __init__.py └── vendor │ ├── AsciiChart.py │ ├── Scrollable.py │ ├── __init__.py │ ├── additional_urwid_widgets │ ├── FormWidgets.py │ ├── LICENSE │ ├── __init__.py │ ├── assisting_modules │ │ ├── __init__.py │ │ ├── modifier_key.py │ │ └── useful_functions.py │ └── widgets │ │ ├── __init__.py │ │ ├── date_picker.py │ │ ├── indicative_listbox.py │ │ ├── integer_picker.py │ │ ├── message_dialog.py │ │ └── selectable_row.py │ └── quotes.py └── setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ✨ Feature Request or Idea 4 | url: https://github.com/markqvist/Reticulum/discussions/new?category=ideas 5 | about: Propose and discuss features and ideas 6 | - name: 💬 Questions, Help & Discussion 7 | about: Ask anything, or get help 8 | url: https://github.com/markqvist/Reticulum/discussions/new/choose 9 | - name: 📖 Read the Reticulum Manual 10 | url: https://markqvist.github.io/Reticulum/manual/ 11 | about: The complete documentation for Reticulum 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🐛-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug Report" 3 | about: Report a reproducible bug 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Read the Contribution Guidelines** 11 | 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**. 12 | 13 | - The issue tracker is used by developers of this project. **Do not use it to ask general questions, or for support requests**. 14 | - 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**. 15 | - 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**. 16 | 17 | **Describe the Bug** 18 | A clear and concise description of what the bug is. 19 | 20 | **To Reproduce** 21 | Describe in detail how to reproduce the bug. 22 | 23 | **Expected Behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Logs & Screenshots** 27 | Please include any relevant log output. If applicable, also add screenshots to help explain your problem. 28 | 29 | **System Information** 30 | - OS and version 31 | - Python version 32 | - Program version 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/workflows/publish-container.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | tags: ['*.*.*'] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Log in to the Container registry 24 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 25 | with: 26 | registry: ${{ env.REGISTRY }} 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 33 | with: 34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *.pyc 3 | testutils 4 | TODO 5 | NOTES 6 | RNS 7 | LXMF 8 | build 9 | dist 10 | nomadnet*.egg-info 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine as build 2 | 3 | RUN apk add --no-cache build-base linux-headers libffi-dev cargo 4 | 5 | # Create a virtualenv that we'll copy to the published image 6 | RUN python -m venv /opt/venv 7 | ENV PATH="/opt/venv/bin:$PATH" 8 | RUN pip3 install setuptools-rust pyopenssl cryptography 9 | 10 | COPY . /app/ 11 | RUN cd /app/ && pip3 install . 12 | 13 | # Use multi-stage build, as we don't need rust compilation on the final image 14 | FROM python:3.12-alpine 15 | 16 | LABEL org.opencontainers.image.documentation="https://github.com/markqvist/NomadNet#nomad-network-daemon-with-docker" 17 | 18 | ENV PATH="/opt/venv/bin:$PATH" 19 | ENV PYTHONUNBUFFERED="yes" 20 | COPY --from=build /opt/venv /opt/venv 21 | 22 | VOLUME /root/.reticulum 23 | VOLUME /root/.nomadnetwork 24 | 25 | ENTRYPOINT ["nomadnet"] 26 | CMD ["--daemon"] 27 | -------------------------------------------------------------------------------- /Dockerfile.build: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | LABEL authors="Petr Blaha petr.blaha@cleverdata.cz" 3 | USER root 4 | RUN apk update 5 | RUN apk add build-base libffi-dev cargo pkgconfig linux-headers py3-virtualenv 6 | 7 | RUN addgroup -S myuser && adduser -S -G myuser myuser 8 | USER myuser 9 | WORKDIR /home/myuser 10 | 11 | RUN pip install --upgrade pip 12 | RUN pip install setuptools-rust pyopenssl cryptography 13 | 14 | 15 | ENV PATH="/home/myuser/.local/bin:${PATH}" 16 | 17 | ################### BEGIN NomadNet ########################################### 18 | 19 | 20 | COPY --chown=myuser:myuser . . 21 | 22 | #Python create virtual environment 23 | RUN virtualenv /home/myuser/NomadNet/venv 24 | RUN source /home/myuser/NomadNet/venv/bin/activate 25 | 26 | RUN make all 27 | 28 | ################### END NomadNet ########################################### 29 | -------------------------------------------------------------------------------- /Dockerfile.howto: -------------------------------------------------------------------------------- 1 | # Run docker command one by one(all four), it will build NomadNet artifact and copy to dist directory. 2 | # No need to build locally and install dependencies 3 | docker build -t nomadnetdockerimage -f Dockerfile.build . 4 | docker run -d -it --name nomadnetdockercontainer nomadnetdockerimage /bin/sh 5 | docker cp nomadnetdockercontainer:/home/myuser/dist . 6 | docker rm -f nomadnetdockercontainer -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | liberapay: Reticulum 2 | ko_fi: markqvist 3 | custom: "https://unsigned.io/donate" 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: release 2 | 3 | clean: 4 | @echo Cleaning... 5 | -rm -r ./build 6 | -rm -r ./dist 7 | 8 | remove_symlinks: 9 | @echo Removing symlinks for build... 10 | -rm ./LXMF 11 | -rm ./RNS 12 | 13 | create_symlinks: 14 | @echo Creating symlinks... 15 | -ln -s ../Reticulum/RNS ./ 16 | -ln -s ../LXMF/LXMF ./ 17 | 18 | build_wheel: 19 | python3 setup.py sdist bdist_wheel 20 | 21 | release: remove_symlinks build_wheel create_symlinks 22 | 23 | upload: 24 | @echo Uploading to PyPi... 25 | twine upload dist/* 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nomad Network - Communicate Freely 2 | 3 | Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. 4 | 5 | ![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) 6 | 7 | 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. 8 | 9 | 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. 10 | 11 | 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. 12 | 13 | If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android and macOS. 14 | 15 | ## Notable Features 16 | - Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports. 17 | - Zero-configuration, minimal-infrastructure mesh communication 18 | - Distributed and encrypted message store holds messages for offline users 19 | - Connectable nodes that can host pages and files 20 | - Node-side generated pages with PHP, Python, bash or others 21 | - Built-in text-based browser for interacting with contents on nodes 22 | - An easy to use and bandwidth efficient markup language for writing pages 23 | - Page caching in browser 24 | 25 | ## How do I get started? 26 | The easiest way to install Nomad Network is via pip: 27 | 28 | ```bash 29 | # Install Nomad Network and dependencies 30 | pip install nomadnet 31 | 32 | # Run the client 33 | nomadnet 34 | 35 | # Or alternatively run as a daemon, with no user interface 36 | nomadnet --daemon 37 | 38 | # List options 39 | nomadnet --help 40 | ``` 41 | 42 | 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: 43 | 44 | ```text 45 | [global] 46 | break-system-packages = true 47 | ``` 48 | 49 | Alternatively, you can use the `pipx` tool to install Nomad Network in an isolated environment: 50 | 51 | ```bash 52 | # Install Nomad Network 53 | pipx install nomadnet 54 | 55 | # Optionally install Reticulum utilities 56 | pipx install rns 57 | 58 | # Optionally install standalone LXMF utilities 59 | pipx install lxmf 60 | 61 | # Run the client 62 | nomadnet 63 | 64 | # Or alternatively run as a daemon, with no user interface 65 | nomadnet --daemon 66 | 67 | # List options 68 | nomadnet --help 69 | ``` 70 | 71 | **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. 72 | 73 | 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. 74 | 75 | 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/). 76 | 77 | If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: 78 | 79 | - `abb3ebcd03cb2388a838e70c001291f9` Dublin Hub Testnet Node 80 | - `ea6a715f814bdc37e56f80c34da6ad51` Frankfurt Hub Testnet Node 81 | 82 | 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. 83 | 84 | ### Install on Android 85 | 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. 86 | 87 | For a native Android application with a graphical user interface, have a look at [Sideband](https://github.com/markqvist/Sideband). 88 | 89 | ### Docker Images 90 | 91 | Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the very latest commit) or the version number (eg `0.2.0`) for a specific release. 92 | 93 | ```sh 94 | $ docker pull ghcr.io/markqvist/nomadnet:master 95 | 96 | # Run nomadnet interactively in a container 97 | $ docker run -it ghcr.io/markqvist/nomadnet:master --textui 98 | 99 | # Run nomadnet as a daemon, using config stored on the host machine in specified 100 | # directories, and connect the containers network to the host network (which will 101 | # allow the default AutoInterface to automatically peer with other discovered 102 | # Reticulum instances). 103 | $ docker run -d \ 104 | -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ 105 | -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ 106 | --network host 107 | ghcr.io/markqvist/nomadnet:master 108 | 109 | # You can also keep the network of the container isolated from the host, but you 110 | # will need to manually configure one or more Reticulum interfaces to reach other 111 | # nodes in a network, by editing the Reticulum configuration file. 112 | $ docker run -d \ 113 | -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ 114 | -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ 115 | ghcr.io/markqvist/nomadnet:master 116 | 117 | # Send daemon log output to console instead of file 118 | $ docker run -i ghcr.io/markqvist/nomadnet:master --daemon --console 119 | ``` 120 | 121 | ## Tools & Extensions 122 | 123 | Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist: 124 | 125 | - [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum)) 126 | - [NomadForecast](https://github.com/faragher/NomadForecast) 127 | - [micron-blog](https://github.com/randogoth/micron-blog) 128 | - [md2mu](https://github.com/randogoth/md2mu) 129 | - [Any2MicronConverter](https://github.com/SebastianObi/Any2MicronConverter) 130 | - [Some nomadnet page examples](https://github.com/SebastianObi/NomadNet-Pages) 131 | - [More nomadnet page examples](https://github.com/epenguins/NomadNet_pages) 132 | - [LXMF-Bot](https://github.com/randogoth/lxmf-bot) 133 | - [LXMF Messageboard](https://github.com/chengtripp/lxmf_messageboard) 134 | - [LXMEvent](https://github.com/faragher/LXMEvent) 135 | - [POPR](https://github.com/faragher/POPR) 136 | - [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools) 137 | 138 | ## Help & Discussion 139 | 140 | 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). 141 | 142 | ## Support Nomad Network 143 | You can help support the continued development of open, free and private communications systems by donating via one of the following channels: 144 | 145 | - Monero: 146 | ``` 147 | 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w 148 | ``` 149 | - Bitcoin 150 | ``` 151 | bc1p4a6axuvl7n9hpapfj8sv5reqj8kz6uxa67d5en70vzrttj0fmcusgxsfk5 152 | ``` 153 | - Ethereum 154 | ``` 155 | 0xae89F3B94fC4AD6563F0864a55F9a697a90261ff 156 | ``` 157 | - Ko-Fi: https://ko-fi.com/markqvist 158 | 159 | ## Development Roadmap 160 | 161 | - New major features 162 | - Network-wide propagated bulletins and discussion threads 163 | - Collaborative maps and geospatial information sharing 164 | - Minor improvements and fixes 165 | - Link status (RSSI and SNR) in conversation or conv list 166 | - Ctrl-M shorcut for jumping to menu 167 | - Share node with other users / send node info to user 168 | - Fix internal editor failing on some OSes with no "editor" alias 169 | - Possibly add a required-width header 170 | - Improve browser handling of remote link close 171 | - Better navigation handling when requests fail (also because of closed links) 172 | - Retry failed messages mechanism 173 | - Re-arrange buttons to be more consistent 174 | - Input field for pages 175 | - Post mechanism 176 | - Term compatibility notice in readme 177 | - Selected icon in conversation list 178 | - Possibly a Search Local Nodes function 179 | - Possibly add via entry in node info box, next to distance 180 | 181 | ## Caveat Emptor 182 | 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. 183 | 184 | ## Screenshots 185 | 186 | ![Screenshot 1](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) 187 | 188 | ![Screenshot 2](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/2.png) 189 | 190 | ![Screenshot 3](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/3.png) 191 | 192 | ![Screenshot 4](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/4.png) 193 | 194 | ![Screenshot 5](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/5.png) 195 | -------------------------------------------------------------------------------- /docs/screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markqvist/NomadNet/a20b4c9bc360f6dfc118bfb797964de64dc7b90f/docs/screenshots/1.png -------------------------------------------------------------------------------- /docs/screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markqvist/NomadNet/a20b4c9bc360f6dfc118bfb797964de64dc7b90f/docs/screenshots/2.png -------------------------------------------------------------------------------- /docs/screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markqvist/NomadNet/a20b4c9bc360f6dfc118bfb797964de64dc7b90f/docs/screenshots/3.png -------------------------------------------------------------------------------- /docs/screenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markqvist/NomadNet/a20b4c9bc360f6dfc118bfb797964de64dc7b90f/docs/screenshots/4.png -------------------------------------------------------------------------------- /docs/screenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markqvist/NomadNet/a20b4c9bc360f6dfc118bfb797964de64dc7b90f/docs/screenshots/5.png -------------------------------------------------------------------------------- /nomadnet/Conversation.py: -------------------------------------------------------------------------------- 1 | import os 2 | import RNS 3 | import LXMF 4 | import shutil 5 | import nomadnet 6 | from nomadnet.Directory import DirectoryEntry 7 | 8 | class Conversation: 9 | cached_conversations = {} 10 | unread_conversations = {} 11 | created_callback = None 12 | 13 | aspect_filter = "lxmf.delivery" 14 | @staticmethod 15 | def received_announce(destination_hash, announced_identity, app_data): 16 | app = nomadnet.NomadNetworkApp.get_shared_instance() 17 | 18 | if not destination_hash in app.ignored_list: 19 | destination_hash_text = RNS.hexrep(destination_hash, delimit=False) 20 | # Check if the announced destination is in 21 | # our list of conversations 22 | if destination_hash_text in [e[0] for e in Conversation.conversation_list(app)]: 23 | if app.directory.find(destination_hash): 24 | if Conversation.created_callback != None: 25 | Conversation.created_callback() 26 | else: 27 | if Conversation.created_callback != None: 28 | Conversation.created_callback() 29 | 30 | # This reformats the new v0.5.0 announce data back to the expected format 31 | # for nomadnets storage and other handling functions. 32 | dn = LXMF.display_name_from_app_data(app_data) 33 | app_data = b"" 34 | if dn != None: 35 | app_data = dn.encode("utf-8") 36 | 37 | # Add the announce to the directory announce 38 | # stream logger 39 | app.directory.lxmf_announce_received(destination_hash, app_data) 40 | 41 | else: 42 | RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) 43 | 44 | @staticmethod 45 | def query_for_peer(source_hash): 46 | try: 47 | RNS.Transport.request_path(bytes.fromhex(source_hash)) 48 | except Exception as e: 49 | RNS.log("Error while querying network for peer identity. The contained exception was: "+str(e), RNS.LOG_ERROR) 50 | 51 | @staticmethod 52 | def ingest(lxmessage, app, originator = False, delegate = None): 53 | if originator: 54 | source_hash = lxmessage.destination_hash 55 | else: 56 | source_hash = lxmessage.source_hash 57 | 58 | source_hash_path = RNS.hexrep(source_hash, delimit=False) 59 | 60 | conversation_path = app.conversationpath + "/" + source_hash_path 61 | 62 | if not os.path.isdir(conversation_path): 63 | os.makedirs(conversation_path) 64 | if Conversation.created_callback != None: 65 | Conversation.created_callback() 66 | 67 | ingested_path = lxmessage.write_to_directory(conversation_path) 68 | 69 | if RNS.hexrep(source_hash, delimit=False) in Conversation.cached_conversations: 70 | conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)] 71 | conversation.scan_storage() 72 | 73 | if not source_hash in Conversation.unread_conversations: 74 | Conversation.unread_conversations[source_hash] = True 75 | try: 76 | dirname = RNS.hexrep(source_hash, delimit=False) 77 | open(app.conversationpath + "/" + dirname + "/unread", 'a').close() 78 | except Exception as e: 79 | pass 80 | 81 | if Conversation.created_callback != None: 82 | Conversation.created_callback() 83 | 84 | return ingested_path 85 | 86 | @staticmethod 87 | def conversation_list(app): 88 | conversations = [] 89 | for dirname in os.listdir(app.conversationpath): 90 | if len(dirname) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2 and os.path.isdir(app.conversationpath + "/" + dirname): 91 | try: 92 | source_hash_text = dirname 93 | source_hash = bytes.fromhex(dirname) 94 | app_data = RNS.Identity.recall_app_data(source_hash) 95 | display_name = app.directory.display_name(source_hash) 96 | 97 | unread = False 98 | if source_hash in Conversation.unread_conversations: 99 | unread = True 100 | elif os.path.isfile(app.conversationpath + "/" + dirname + "/unread"): 101 | Conversation.unread_conversations[source_hash] = True 102 | unread = True 103 | 104 | if display_name == None and app_data: 105 | display_name = LXMF.display_name_from_app_data(app_data) 106 | 107 | if display_name == None: 108 | sort_name = "" 109 | else: 110 | sort_name = display_name 111 | 112 | trust_level = app.directory.trust_level(source_hash, display_name) 113 | 114 | entry = (source_hash_text, display_name, trust_level, sort_name, unread) 115 | conversations.append(entry) 116 | 117 | except Exception as e: 118 | RNS.log("Error while loading conversation "+str(dirname)+", skipping it. The contained exception was: "+str(e), RNS.LOG_ERROR) 119 | 120 | conversations.sort(key=lambda e: (-e[2], e[3], e[0]), reverse=False) 121 | 122 | return conversations 123 | 124 | @staticmethod 125 | def cache_conversation(conversation): 126 | Conversation.cached_conversations[conversation.source_hash] = conversation 127 | 128 | @staticmethod 129 | def delete_conversation(source_hash_path, app): 130 | conversation_path = app.conversationpath + "/" + source_hash_path 131 | 132 | try: 133 | if os.path.isdir(conversation_path): 134 | shutil.rmtree(conversation_path) 135 | except Exception as e: 136 | RNS.log("Could not remove conversation at "+str(conversation_path)+". The contained exception was: "+str(e), RNS.LOG_ERROR) 137 | 138 | def __init__(self, source_hash, app, initiator=False): 139 | self.app = app 140 | self.source_hash = source_hash 141 | self.send_destination = None 142 | self.messages = [] 143 | self.messages_path = app.conversationpath + "/" + source_hash 144 | self.messages_load_time = None 145 | self.source_known = False 146 | self.source_trusted = False 147 | self.source_blocked = False 148 | self.unread = False 149 | 150 | self.__changed_callback = None 151 | 152 | if not RNS.Identity.recall(bytes.fromhex(self.source_hash)): 153 | RNS.Transport.request_path(bytes.fromhex(source_hash)) 154 | 155 | self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) 156 | 157 | if self.source_identity: 158 | self.source_known = True 159 | self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") 160 | 161 | if initiator: 162 | if not os.path.isdir(self.messages_path): 163 | os.makedirs(self.messages_path) 164 | if Conversation.created_callback != None: 165 | Conversation.created_callback() 166 | 167 | self.scan_storage() 168 | 169 | self.trust_level = app.directory.trust_level(bytes.fromhex(self.source_hash)) 170 | 171 | Conversation.cache_conversation(self) 172 | 173 | def scan_storage(self): 174 | old_len = len(self.messages) 175 | self.messages = [] 176 | for filename in os.listdir(self.messages_path): 177 | if len(filename) == RNS.Identity.HASHLENGTH//8*2: 178 | message_path = self.messages_path + "/" + filename 179 | self.messages.append(ConversationMessage(message_path)) 180 | 181 | new_len = len(self.messages) 182 | 183 | if new_len > old_len: 184 | self.unread = True 185 | 186 | if self.__changed_callback != None: 187 | self.__changed_callback(self) 188 | 189 | def purge_failed(self): 190 | purged_messages = [] 191 | for conversation_message in self.messages: 192 | if conversation_message.get_state() == LXMF.LXMessage.FAILED: 193 | purged_messages.append(conversation_message) 194 | conversation_message.purge() 195 | 196 | for purged_message in purged_messages: 197 | self.messages.remove(purged_message) 198 | 199 | def clear_history(self): 200 | purged_messages = [] 201 | for conversation_message in self.messages: 202 | purged_messages.append(conversation_message) 203 | conversation_message.purge() 204 | 205 | for purged_message in purged_messages: 206 | self.messages.remove(purged_message) 207 | 208 | def register_changed_callback(self, callback): 209 | self.__changed_callback = callback 210 | 211 | def send(self, content="", title=""): 212 | if self.send_destination: 213 | dest = self.send_destination 214 | source = self.app.lxmf_destination 215 | desired_method = LXMF.LXMessage.DIRECT 216 | if self.app.directory.preferred_delivery(dest.hash) == DirectoryEntry.PROPAGATED: 217 | if self.app.message_router.get_outbound_propagation_node() != None: 218 | desired_method = LXMF.LXMessage.PROPAGATED 219 | else: 220 | if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None: 221 | RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG) 222 | desired_method = LXMF.LXMessage.OPPORTUNISTIC 223 | 224 | dest_is_trusted = False 225 | if self.app.directory.trust_level(dest.hash) == DirectoryEntry.TRUSTED: 226 | dest_is_trusted = True 227 | 228 | lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method, include_ticket=dest_is_trusted) 229 | lxm.register_delivery_callback(self.message_notification) 230 | lxm.register_failed_callback(self.message_notification) 231 | 232 | if self.app.message_router.get_outbound_propagation_node() != None: 233 | lxm.try_propagation_on_fail = self.app.try_propagation_on_fail 234 | 235 | self.app.message_router.handle_outbound(lxm) 236 | 237 | message_path = Conversation.ingest(lxm, self.app, originator=True) 238 | self.messages.append(ConversationMessage(message_path)) 239 | 240 | return True 241 | else: 242 | RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) 243 | return False 244 | 245 | def paper_output(self, content="", title="", mode="print_qr"): 246 | if self.send_destination: 247 | try: 248 | dest = self.send_destination 249 | source = self.app.lxmf_destination 250 | desired_method = LXMF.LXMessage.PAPER 251 | 252 | lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method) 253 | 254 | if mode == "print_qr": 255 | qr_code = lxm.as_qr() 256 | qr_tmp_path = self.app.tmpfilespath+"/"+str(RNS.hexrep(lxm.hash, delimit=False)) 257 | qr_code.save(qr_tmp_path) 258 | 259 | print_result = self.app.print_file(qr_tmp_path) 260 | os.unlink(qr_tmp_path) 261 | 262 | if print_result: 263 | message_path = Conversation.ingest(lxm, self.app, originator=True) 264 | self.messages.append(ConversationMessage(message_path)) 265 | 266 | return print_result 267 | 268 | elif mode == "save_qr": 269 | qr_code = lxm.as_qr() 270 | qr_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".png") 271 | qr_code.save(qr_save_path) 272 | message_path = Conversation.ingest(lxm, self.app, originator=True) 273 | self.messages.append(ConversationMessage(message_path)) 274 | return qr_save_path 275 | 276 | elif mode == "save_uri": 277 | lxm_uri = lxm.as_uri()+"\n" 278 | uri_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".txt") 279 | with open(uri_save_path, "wb") as f: 280 | f.write(lxm_uri.encode("utf-8")) 281 | 282 | message_path = Conversation.ingest(lxm, self.app, originator=True) 283 | self.messages.append(ConversationMessage(message_path)) 284 | return uri_save_path 285 | 286 | elif mode == "return_uri": 287 | return lxm.as_uri() 288 | 289 | except Exception as e: 290 | RNS.log("An error occurred while generating paper message, the contained exception was: "+str(e), RNS.LOG_ERROR) 291 | return False 292 | 293 | else: 294 | RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) 295 | return False 296 | 297 | def message_notification(self, message): 298 | if message.state == LXMF.LXMessage.FAILED and hasattr(message, "try_propagation_on_fail") and message.try_propagation_on_fail: 299 | if hasattr(message, "stamp_generation_failed") and message.stamp_generation_failed == True: 300 | RNS.log(f"Could not send {message} due to a stamp generation failure", RNS.LOG_ERROR) 301 | else: 302 | RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) 303 | message.try_propagation_on_fail = None 304 | message.delivery_attempts = 0 305 | if hasattr(message, "next_delivery_attempt"): 306 | del message.next_delivery_attempt 307 | message.packed = None 308 | message.desired_method = LXMF.LXMessage.PROPAGATED 309 | self.app.message_router.handle_outbound(message) 310 | else: 311 | message_path = Conversation.ingest(message, self.app, originator=True) 312 | 313 | def __str__(self): 314 | string = self.source_hash 315 | 316 | # TODO: Remove this 317 | # if self.source_identity: 318 | # if self.source_identity.app_data: 319 | # # TODO: Sanitise for viewing, or just clean this 320 | # string += " | "+self.source_identity.app_data.decode("utf-8") 321 | 322 | return string 323 | 324 | 325 | 326 | class ConversationMessage: 327 | def __init__(self, file_path): 328 | self.file_path = file_path 329 | self.loaded = False 330 | self.timestamp = None 331 | self.lxm = None 332 | 333 | def load(self): 334 | try: 335 | self.lxm = LXMF.LXMessage.unpack_from_file(open(self.file_path, "rb")) 336 | self.loaded = True 337 | self.timestamp = self.lxm.timestamp 338 | self.sort_timestamp = os.path.getmtime(self.file_path) 339 | 340 | if self.lxm.state > LXMF.LXMessage.GENERATING and self.lxm.state < LXMF.LXMessage.SENT: 341 | found = False 342 | 343 | for pending in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_outbound: 344 | if pending.hash == self.lxm.hash: 345 | found = True 346 | 347 | for pending_id in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_deferred_stamps: 348 | if pending_id == self.lxm.hash: 349 | found = True 350 | 351 | if not found: 352 | self.lxm.state = LXMF.LXMessage.FAILED 353 | 354 | except Exception as e: 355 | RNS.log("Error while loading LXMF message "+str(self.file_path)+" from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) 356 | 357 | def unload(self): 358 | self.loaded = False 359 | self.lxm = None 360 | 361 | def purge(self): 362 | self.unload() 363 | if os.path.isfile(self.file_path): 364 | os.unlink(self.file_path) 365 | 366 | def get_timestamp(self): 367 | if not self.loaded: 368 | self.load() 369 | 370 | return self.timestamp 371 | 372 | def get_title(self): 373 | if not self.loaded: 374 | self.load() 375 | 376 | return self.lxm.title_as_string() 377 | 378 | def get_content(self): 379 | if not self.loaded: 380 | self.load() 381 | 382 | return self.lxm.content_as_string() 383 | 384 | def get_hash(self): 385 | if not self.loaded: 386 | self.load() 387 | 388 | return self.lxm.hash 389 | 390 | def get_state(self): 391 | if not self.loaded: 392 | self.load() 393 | 394 | return self.lxm.state 395 | 396 | def get_transport_encryption(self): 397 | if not self.loaded: 398 | self.load() 399 | 400 | return self.lxm.transport_encryption 401 | 402 | def get_transport_encrypted(self): 403 | if not self.loaded: 404 | self.load() 405 | 406 | return self.lxm.transport_encrypted 407 | 408 | def signature_validated(self): 409 | if not self.loaded: 410 | self.load() 411 | 412 | return self.lxm.signature_validated 413 | 414 | def get_signature_description(self): 415 | if self.signature_validated(): 416 | return "Signature Verified" 417 | else: 418 | if self.lxm.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN: 419 | return "Unknown Origin" 420 | elif self.lxm.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID: 421 | return "Invalid Signature" 422 | else: 423 | return "Unknown signature validation failure" -------------------------------------------------------------------------------- /nomadnet/Directory.py: -------------------------------------------------------------------------------- 1 | import os 2 | import RNS 3 | import LXMF 4 | import time 5 | import nomadnet 6 | import threading 7 | import RNS.vendor.umsgpack as msgpack 8 | 9 | class PNAnnounceHandler: 10 | def __init__(self, owner): 11 | self.aspect_filter = "lxmf.propagation" 12 | self.owner = owner 13 | 14 | def received_announce(self, destination_hash, announced_identity, app_data): 15 | try: 16 | if type(app_data) == bytes: 17 | data = msgpack.unpackb(app_data) 18 | 19 | if data[0] == True: 20 | RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash)) 21 | 22 | associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) 23 | associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity) 24 | 25 | self.owner.app.directory.pn_announce_received(destination_hash, app_data, associated_peer, associated_node) 26 | self.owner.app.autoselect_propagation_node() 27 | 28 | except Exception as e: 29 | RNS.log("Error while evaluating propagation node announce, ignoring announce.", RNS.LOG_DEBUG) 30 | RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) 31 | 32 | class Directory: 33 | ANNOUNCE_STREAM_MAXLENGTH = 256 34 | 35 | aspect_filter = "nomadnetwork.node" 36 | @staticmethod 37 | def received_announce(destination_hash, announced_identity, app_data): 38 | try: 39 | app = nomadnet.NomadNetworkApp.get_shared_instance() 40 | 41 | if not destination_hash in app.ignored_list: 42 | associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) 43 | 44 | app.directory.node_announce_received(destination_hash, app_data, associated_peer) 45 | app.autoselect_propagation_node() 46 | 47 | else: 48 | RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) 49 | 50 | except Exception as e: 51 | RNS.log("Error while evaluating LXMF destination announce, ignoring announce.", RNS.LOG_DEBUG) 52 | RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) 53 | 54 | 55 | def __init__(self, app): 56 | self.directory_entries = {} 57 | self.announce_stream = [] 58 | self.app = app 59 | self.announce_lock = threading.Lock() 60 | self.load_from_disk() 61 | 62 | self.pn_announce_handler = PNAnnounceHandler(self) 63 | RNS.Transport.register_announce_handler(self.pn_announce_handler) 64 | 65 | 66 | def save_to_disk(self): 67 | try: 68 | packed_list = [] 69 | for source_hash in self.directory_entries: 70 | e = self.directory_entries[source_hash] 71 | packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank)) 72 | 73 | directory = { 74 | "entry_list": packed_list, 75 | "announce_stream": self.announce_stream 76 | } 77 | 78 | file = open(self.app.directorypath, "wb") 79 | file.write(msgpack.packb(directory)) 80 | file.close() 81 | 82 | except Exception as e: 83 | RNS.log("Could not write directory to disk. Then contained exception was: "+str(e), RNS.LOG_ERROR) 84 | 85 | def load_from_disk(self): 86 | if os.path.isfile(self.app.directorypath): 87 | try: 88 | file = open(self.app.directorypath, "rb") 89 | unpacked_directory = msgpack.unpackb(file.read()) 90 | unpacked_list = unpacked_directory["entry_list"] 91 | file.close() 92 | 93 | entries = {} 94 | for e in unpacked_list: 95 | 96 | if e[1] == None: 97 | e[1] = "Undefined" 98 | 99 | if len(e) > 3: 100 | hosts_node = e[3] 101 | else: 102 | hosts_node = False 103 | 104 | if len(e) > 4: 105 | preferred_delivery = e[4] 106 | else: 107 | preferred_delivery = None 108 | 109 | if len(e) > 5: 110 | identify = e[5] 111 | else: 112 | identify = False 113 | 114 | if len(e) > 6: 115 | sort_rank = e[6] 116 | else: 117 | sort_rank = None 118 | 119 | entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank) 120 | 121 | self.directory_entries = entries 122 | 123 | self.announce_stream = unpacked_directory["announce_stream"] 124 | 125 | except Exception as e: 126 | RNS.log("Could not load directory from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) 127 | 128 | def lxmf_announce_received(self, source_hash, app_data): 129 | with self.announce_lock: 130 | if app_data != None: 131 | if self.app.compact_stream: 132 | try: 133 | remove_announces = [] 134 | for announce in self.announce_stream: 135 | if announce[1] == source_hash: 136 | remove_announces.append(announce) 137 | 138 | for a in remove_announces: 139 | self.announce_stream.remove(a) 140 | 141 | except Exception as e: 142 | RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) 143 | 144 | timestamp = time.time() 145 | self.announce_stream.insert(0, (timestamp, source_hash, app_data, "peer")) 146 | while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: 147 | self.announce_stream.pop() 148 | 149 | if hasattr(self.app, "ui") and self.app.ui != None: 150 | if hasattr(self.app.ui, "main_display"): 151 | self.app.ui.main_display.sub_displays.network_display.directory_change_callback() 152 | 153 | def node_announce_received(self, source_hash, app_data, associated_peer): 154 | with self.announce_lock: 155 | if app_data != None: 156 | if self.app.compact_stream: 157 | try: 158 | remove_announces = [] 159 | for announce in self.announce_stream: 160 | if announce[1] == source_hash: 161 | remove_announces.append(announce) 162 | 163 | for a in remove_announces: 164 | self.announce_stream.remove(a) 165 | 166 | except Exception as e: 167 | RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) 168 | 169 | timestamp = time.time() 170 | self.announce_stream.insert(0, (timestamp, source_hash, app_data, "node")) 171 | while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: 172 | self.announce_stream.pop() 173 | 174 | if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: 175 | existing_entry = self.find(source_hash) 176 | if not existing_entry: 177 | node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) 178 | self.remember(node_entry) 179 | 180 | if hasattr(self.app.ui, "main_display"): 181 | self.app.ui.main_display.sub_displays.network_display.directory_change_callback() 182 | 183 | def pn_announce_received(self, source_hash, app_data, associated_peer, associated_node): 184 | with self.announce_lock: 185 | found_node = None 186 | for sh in self.directory_entries: 187 | if sh == associated_node: 188 | found_node = True 189 | break 190 | 191 | for e in self.announce_stream: 192 | if e[1] == associated_node: 193 | found_node = True 194 | break 195 | 196 | if not found_node: 197 | if self.app.compact_stream: 198 | try: 199 | remove_announces = [] 200 | for announce in self.announce_stream: 201 | if announce[1] == source_hash: 202 | remove_announces.append(announce) 203 | 204 | for a in remove_announces: 205 | self.announce_stream.remove(a) 206 | 207 | except Exception as e: 208 | RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) 209 | 210 | timestamp = time.time() 211 | self.announce_stream.insert(0, (timestamp, source_hash, app_data, "pn")) 212 | while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH: 213 | self.announce_stream.pop() 214 | 215 | if hasattr(self.app, "ui") and hasattr(self.app.ui, "main_display"): 216 | self.app.ui.main_display.sub_displays.network_display.directory_change_callback() 217 | 218 | def remove_announce_with_timestamp(self, timestamp): 219 | selected_announce = None 220 | for announce in self.announce_stream: 221 | if announce[0] == timestamp: 222 | selected_announce = announce 223 | 224 | if selected_announce != None: 225 | self.announce_stream.remove(selected_announce) 226 | 227 | def display_name(self, source_hash): 228 | if source_hash in self.directory_entries: 229 | return self.directory_entries[source_hash].display_name 230 | else: 231 | return None 232 | 233 | def simplest_display_str(self, source_hash): 234 | trust_level = self.trust_level(source_hash) 235 | if trust_level == DirectoryEntry.WARNING or trust_level == DirectoryEntry.UNTRUSTED: 236 | if source_hash in self.directory_entries: 237 | dn = self.directory_entries[source_hash].display_name 238 | if dn == None: 239 | return RNS.prettyhexrep(source_hash) 240 | else: 241 | return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">" 242 | else: 243 | return "<"+RNS.hexrep(source_hash, delimit=False)+">" 244 | else: 245 | if source_hash in self.directory_entries: 246 | dn = self.directory_entries[source_hash].display_name 247 | if dn == None: 248 | return RNS.prettyhexrep(source_hash) 249 | else: 250 | return dn 251 | else: 252 | return "<"+RNS.hexrep(source_hash, delimit=False)+">" 253 | 254 | def alleged_display_str(self, source_hash): 255 | if source_hash in self.directory_entries: 256 | return self.directory_entries[source_hash].display_name 257 | else: 258 | return None 259 | 260 | 261 | def trust_level(self, source_hash, announced_display_name=None): 262 | if source_hash in self.directory_entries: 263 | if announced_display_name == None: 264 | return self.directory_entries[source_hash].trust_level 265 | else: 266 | if not self.directory_entries[source_hash].trust_level == DirectoryEntry.TRUSTED: 267 | for entry in self.directory_entries: 268 | e = self.directory_entries[entry] 269 | if e.display_name == announced_display_name: 270 | if e.source_hash != source_hash: 271 | return DirectoryEntry.WARNING 272 | 273 | return self.directory_entries[source_hash].trust_level 274 | else: 275 | return DirectoryEntry.UNKNOWN 276 | 277 | def pn_trust_level(self, source_hash): 278 | recalled_identity = RNS.Identity.recall(source_hash) 279 | if recalled_identity != None: 280 | associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", recalled_identity) 281 | return self.trust_level(associated_node) 282 | 283 | def sort_rank(self, source_hash): 284 | if source_hash in self.directory_entries: 285 | return self.directory_entries[source_hash].sort_rank 286 | else: 287 | return None 288 | 289 | def preferred_delivery(self, source_hash): 290 | if source_hash in self.directory_entries: 291 | return self.directory_entries[source_hash].preferred_delivery 292 | else: 293 | return DirectoryEntry.DIRECT 294 | 295 | def remember(self, entry): 296 | self.directory_entries[entry.source_hash] = entry 297 | 298 | identity = RNS.Identity.recall(entry.source_hash) 299 | if identity != None: 300 | associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", identity) 301 | if associated_node in self.directory_entries: 302 | node_entry = self.directory_entries[associated_node] 303 | node_entry.trust_level = entry.trust_level 304 | 305 | self.save_to_disk() 306 | 307 | def forget(self, source_hash): 308 | if source_hash in self.directory_entries: 309 | self.directory_entries.pop(source_hash) 310 | 311 | def find(self, source_hash): 312 | if source_hash in self.directory_entries: 313 | return self.directory_entries[source_hash] 314 | else: 315 | return None 316 | 317 | def is_known(self, source_hash): 318 | try: 319 | self.source_identity = RNS.Identity.recall(source_hash) 320 | 321 | if self.source_identity: 322 | return True 323 | else: 324 | return False 325 | 326 | except Exception as e: 327 | return False 328 | 329 | def should_identify_on_connect(self, source_hash): 330 | if source_hash in self.directory_entries: 331 | entry = self.directory_entries[source_hash] 332 | return entry.identify 333 | else: 334 | return False 335 | 336 | def set_identify_on_connect(self, source_hash, state): 337 | if source_hash in self.directory_entries: 338 | entry = self.directory_entries[source_hash] 339 | entry.identify = state 340 | 341 | def known_nodes(self): 342 | node_list = [] 343 | for eh in self.directory_entries: 344 | e = self.directory_entries[eh] 345 | if e.hosts_node: 346 | node_list.append(e) 347 | 348 | 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 "_")) 349 | return node_list 350 | 351 | def number_of_known_nodes(self): 352 | return len(self.known_nodes()) 353 | 354 | def number_of_known_peers(self, lookback_seconds=None): 355 | unique_hashes = [] 356 | cutoff_time = time.time()-lookback_seconds 357 | for entry in self.announce_stream: 358 | if not entry[1] in unique_hashes: 359 | if lookback_seconds == None or entry[0] > cutoff_time: 360 | unique_hashes.append(entry[1]) 361 | 362 | return len(unique_hashes) 363 | 364 | class DirectoryEntry: 365 | WARNING = 0x00 366 | UNTRUSTED = 0x01 367 | UNKNOWN = 0x02 368 | TRUSTED = 0xFF 369 | 370 | DIRECT = 0x01 371 | PROPAGATED = 0x02 372 | 373 | def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None): 374 | if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: 375 | self.source_hash = source_hash 376 | self.display_name = display_name 377 | self.sort_rank = sort_rank 378 | 379 | if preferred_delivery == None: 380 | self.preferred_delivery = DirectoryEntry.DIRECT 381 | else: 382 | self.preferred_delivery = preferred_delivery 383 | 384 | self.trust_level = trust_level 385 | self.hosts_node = hosts_node 386 | self.identify = identify_on_connect 387 | else: 388 | raise TypeError("Attempt to add invalid source hash to directory") 389 | -------------------------------------------------------------------------------- /nomadnet/Node.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import RNS 5 | import time 6 | import threading 7 | import subprocess 8 | import RNS.vendor.umsgpack as msgpack 9 | 10 | class Node: 11 | JOB_INTERVAL = 5 12 | START_ANNOUNCE_DELAY = 6 13 | 14 | def __init__(self, app): 15 | RNS.log("Nomad Network Node starting...", RNS.LOG_VERBOSE) 16 | self.app = app 17 | self.identity = self.app.identity 18 | self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, "nomadnetwork", "node") 19 | self.last_announce = time.time() 20 | self.last_file_refresh = time.time() 21 | self.last_page_refresh = time.time() 22 | self.announce_interval = self.app.node_announce_interval 23 | self.page_refresh_interval = self.app.page_refresh_interval 24 | self.file_refresh_interval = self.app.file_refresh_interval 25 | self.job_interval = Node.JOB_INTERVAL 26 | self.should_run_jobs = True 27 | self.app_data = None 28 | self.name = self.app.node_name 29 | 30 | self.register_pages() 31 | self.register_files() 32 | 33 | self.destination.set_link_established_callback(self.peer_connected) 34 | 35 | if self.name == None: 36 | self.name = self.app.peer_settings["display_name"]+"'s Node" 37 | 38 | RNS.log("Node \""+self.name+"\" ready for incoming connections on "+RNS.prettyhexrep(self.destination.hash), RNS.LOG_VERBOSE) 39 | 40 | if self.app.node_announce_at_start: 41 | def delayed_announce(): 42 | time.sleep(Node.START_ANNOUNCE_DELAY) 43 | self.announce() 44 | 45 | da_thread = threading.Thread(target=delayed_announce) 46 | da_thread.setDaemon(True) 47 | da_thread.start() 48 | 49 | job_thread = threading.Thread(target=self.__jobs) 50 | job_thread.setDaemon(True) 51 | job_thread.start() 52 | 53 | 54 | def register_pages(self): 55 | # TODO: Deregister previously registered pages 56 | # that no longer exist. 57 | self.servedpages = [] 58 | self.scan_pages(self.app.pagespath) 59 | 60 | if not self.app.pagespath+"index.mu" in self.servedpages: 61 | self.destination.register_request_handler( 62 | "/page/index.mu", 63 | response_generator = self.serve_default_index, 64 | allow = RNS.Destination.ALLOW_ALL) 65 | 66 | for page in self.servedpages: 67 | request_path = "/page"+page.replace(self.app.pagespath, "") 68 | self.destination.register_request_handler( 69 | request_path, 70 | response_generator = self.serve_page, 71 | allow = RNS.Destination.ALLOW_ALL) 72 | 73 | def register_files(self): 74 | # TODO: Deregister previously registered files 75 | # that no longer exist. 76 | self.servedfiles = [] 77 | self.scan_files(self.app.filespath) 78 | 79 | for file in self.servedfiles: 80 | request_path = "/file"+file.replace(self.app.filespath, "") 81 | self.destination.register_request_handler( 82 | request_path, 83 | response_generator = self.serve_file, 84 | allow = RNS.Destination.ALLOW_ALL, 85 | auto_compress = 32_000_000) 86 | 87 | def scan_pages(self, base_path): 88 | files = [file for file in os.listdir(base_path) if os.path.isfile(os.path.join(base_path, file)) and file[:1] != "."] 89 | directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."] 90 | 91 | for file in files: 92 | if not file.endswith(".allowed"): 93 | self.servedpages.append(base_path+"/"+file) 94 | 95 | for directory in directories: 96 | self.scan_pages(base_path+"/"+directory) 97 | 98 | def scan_files(self, base_path): 99 | files = [file for file in os.listdir(base_path) if os.path.isfile(os.path.join(base_path, file)) and file[:1] != "."] 100 | directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."] 101 | 102 | for file in files: 103 | self.servedfiles.append(base_path+"/"+file) 104 | 105 | for directory in directories: 106 | self.scan_files(base_path+"/"+directory) 107 | 108 | def serve_page(self, path, data, request_id, link_id, remote_identity, requested_at): 109 | RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) 110 | try: 111 | self.app.peer_settings["served_page_requests"] += 1 112 | self.app.save_peer_settings() 113 | 114 | except Exception as e: 115 | RNS.log("Could not increase served page request count", RNS.LOG_ERROR) 116 | 117 | file_path = path.replace("/page", self.app.pagespath, 1) 118 | 119 | allowed_path = file_path+".allowed" 120 | request_allowed = False 121 | 122 | if os.path.isfile(allowed_path): 123 | allowed_list = [] 124 | 125 | try: 126 | if os.access(allowed_path, os.X_OK): 127 | allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE) 128 | allowed_input = allowed_result.stdout 129 | 130 | else: 131 | fh = open(allowed_path, "rb") 132 | allowed_input = fh.read() 133 | fh.close() 134 | 135 | allowed_hash_strs = allowed_input.splitlines() 136 | 137 | for hash_str in allowed_hash_strs: 138 | if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: 139 | try: 140 | allowed_hash = bytes.fromhex(hash_str.decode("utf-8")) 141 | allowed_list.append(allowed_hash) 142 | 143 | except Exception as e: 144 | RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG) 145 | RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) 146 | 147 | except Exception as e: 148 | RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR) 149 | 150 | if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list: 151 | request_allowed = True 152 | else: 153 | request_allowed = False 154 | RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE) 155 | 156 | else: 157 | request_allowed = True 158 | 159 | try: 160 | if request_allowed: 161 | RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) 162 | if not RNS.vendor.platformutils.is_windows() and os.access(file_path, os.X_OK): 163 | env_map = {} 164 | if "PATH" in os.environ: 165 | env_map["PATH"] = os.environ["PATH"] 166 | if link_id != None: 167 | env_map["link_id"] = RNS.hexrep(link_id, delimit=False) 168 | if remote_identity != None: 169 | env_map["remote_identity"] = RNS.hexrep(remote_identity.hash, delimit=False) 170 | 171 | if data != None and isinstance(data, dict): 172 | for e in data: 173 | if isinstance(e, str) and (e.startswith("field_") or e.startswith("var_")): 174 | env_map[e] = data[e] 175 | 176 | generated = subprocess.run([file_path], stdout=subprocess.PIPE, env=env_map) 177 | return generated.stdout 178 | else: 179 | fh = open(file_path, "rb") 180 | response_data = fh.read() 181 | fh.close() 182 | return response_data 183 | else: 184 | RNS.log("Request denied", RNS.LOG_VERBOSE) 185 | return DEFAULT_NOTALLOWED.encode("utf-8") 186 | 187 | except Exception as e: 188 | RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_ERROR) 189 | RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 190 | return None 191 | 192 | # TODO: Improve file handling, this will be slow for large files 193 | def serve_file(self, path, data, request_id, remote_identity, requested_at): 194 | RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) 195 | try: 196 | self.app.peer_settings["served_file_requests"] += 1 197 | self.app.save_peer_settings() 198 | 199 | except Exception as e: 200 | RNS.log("Could not increase served file request count", RNS.LOG_ERROR) 201 | 202 | file_path = path.replace("/file", self.app.filespath, 1) 203 | file_name = path.replace("/file/", "", 1) 204 | try: 205 | RNS.log("Serving file: "+file_path, RNS.LOG_VERBOSE) 206 | return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}] 207 | 208 | except Exception as e: 209 | RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_ERROR) 210 | RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 211 | return None 212 | 213 | def serve_default_index(self, path, data, request_id, remote_identity, requested_at): 214 | RNS.log("Serving default index for request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) 215 | return DEFAULT_INDEX.encode("utf-8") 216 | 217 | def announce(self): 218 | self.app_data = self.name.encode("utf-8") 219 | self.last_announce = time.time() 220 | self.app.peer_settings["node_last_announce"] = self.last_announce 221 | self.destination.announce(app_data=self.app_data) 222 | self.app.message_router.announce_propagation_node() 223 | 224 | def __jobs(self): 225 | while self.should_run_jobs: 226 | now = time.time() 227 | 228 | if now > self.last_announce + self.announce_interval*60: 229 | self.announce() 230 | 231 | if self.page_refresh_interval > 0: 232 | if now > self.last_page_refresh + self.page_refresh_interval*60: 233 | self.register_pages() 234 | self.last_page_refresh = time.time() 235 | 236 | if self.file_refresh_interval > 0: 237 | if now > self.last_file_refresh + self.file_refresh_interval*60: 238 | self.register_files() 239 | self.last_file_refresh = time.time() 240 | 241 | time.sleep(self.job_interval) 242 | 243 | def peer_connected(self, link): 244 | RNS.log("Peer connected to "+str(self.destination), RNS.LOG_VERBOSE) 245 | try: 246 | self.app.peer_settings["node_connects"] += 1 247 | self.app.save_peer_settings() 248 | 249 | except Exception as e: 250 | RNS.log("Could not increase node connection count", RNS.LOG_ERROR) 251 | 252 | link.set_link_closed_callback(self.peer_disconnected) 253 | 254 | def peer_disconnected(self, link): 255 | RNS.log("Peer disconnected from "+str(self.destination), RNS.LOG_VERBOSE) 256 | pass 257 | 258 | DEFAULT_INDEX = '''>Default Home Page 259 | 260 | This node is serving pages, but the home page file (index.mu) was not found in the page storage directory. This is an auto-generated placeholder. 261 | 262 | If you are the node operator, you can define your own home page by creating a file named `*index.mu`* in the page storage directory. 263 | ''' 264 | 265 | DEFAULT_NOTALLOWED = '''>Request Not Allowed 266 | 267 | You are not authorised to carry out the request. 268 | ''' 269 | -------------------------------------------------------------------------------- /nomadnet/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | 4 | from .NomadNetworkApp import NomadNetworkApp 5 | from .Conversation import Conversation 6 | from .Directory import Directory 7 | from .Node import Node 8 | from .ui import * 9 | 10 | 11 | modules = glob.glob(os.path.dirname(__file__)+"/*.py") 12 | __all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] 13 | 14 | def panic(): 15 | os._exit(255) -------------------------------------------------------------------------------- /nomadnet/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.7.0" 2 | -------------------------------------------------------------------------------- /nomadnet/examples/messageboard/README.md: -------------------------------------------------------------------------------- 1 | # lxmf_messageboard 2 | 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. 3 | 4 | ## How Do I Use It? 5 | 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. 6 | 7 | An example message board can be found on the reticulum testnet hosted on the SolarExpress Node `` and the message board peer `` 8 | 9 | ## How Does It Work? 10 | 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. 11 | 12 | ## How Do I Set It Up? 13 | * Turn on node hosting in NomadNet 14 | * Put the `message_board.mu` file into `pages` directory in the config file for `NomadNet`. Edit the file to customise from the default page. 15 | * 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. 16 | 17 | ## Credits 18 | * This example application was written and contributed by @chengtripp -------------------------------------------------------------------------------- /nomadnet/examples/messageboard/messageboard.mu: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | import time 3 | import os 4 | import RNS.vendor.umsgpack as msgpack 5 | 6 | message_board_peer = 'please_replace' 7 | userdir = os.path.expanduser("~") 8 | 9 | if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"): 10 | configdir = "/etc/nomadmb" 11 | elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"): 12 | configdir = userdir+"/.config/nomadmb" 13 | else: 14 | configdir = userdir+"/.nomadmb" 15 | 16 | storagepath = configdir+"/storage" 17 | if not os.path.isdir(storagepath): 18 | os.makedirs(storagepath) 19 | 20 | boardpath = configdir+"/storage/board" 21 | 22 | print('`!`F222`Bddd`cNomadNet Message Board') 23 | 24 | print('-') 25 | print('`a`b`f') 26 | print("") 27 | print("To add a message to the board just converse with the NomadNet Message Board at `[lxmf@{}]".format(message_board_peer)) 28 | time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) 29 | print("Last Updated: {}".format(time_string)) 30 | print("") 31 | print('>Messages') 32 | print(" Date Time Username Message") 33 | f = open(boardpath, "rb") 34 | board_contents = msgpack.unpack(f) 35 | board_contents.reverse() 36 | 37 | for content in board_contents: 38 | print("`a{}".format(content.rstrip())) 39 | print("") 40 | 41 | f.close() 42 | -------------------------------------------------------------------------------- /nomadnet/examples/messageboard/messageboard.py: -------------------------------------------------------------------------------- 1 | # 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. 2 | # https://github.com/chengtripp/lxmf_messageboard 3 | 4 | import RNS 5 | import LXMF 6 | import os, time 7 | from queue import Queue 8 | import RNS.vendor.umsgpack as msgpack 9 | 10 | display_name = "NomadNet Message Board" 11 | max_messages = 20 12 | 13 | def setup_lxmf(): 14 | if os.path.isfile(identitypath): 15 | identity = RNS.Identity.from_file(identitypath) 16 | RNS.log('Loaded identity from file', RNS.LOG_INFO) 17 | else: 18 | RNS.log('No Primary Identity file found, creating new...', RNS.LOG_INFO) 19 | identity = RNS.Identity() 20 | identity.to_file(identitypath) 21 | 22 | return identity 23 | 24 | def lxmf_delivery(message): 25 | # Do something here with a received message 26 | RNS.log("A message was received: "+str(message.content.decode('utf-8'))) 27 | 28 | message_content = message.content.decode('utf-8') 29 | source_hash_text = RNS.hexrep(message.source_hash, delimit=False) 30 | 31 | #Create username (just first 5 char of your addr) 32 | username = source_hash_text[0:5] 33 | 34 | RNS.log('Username: {}'.format(username), RNS.LOG_INFO) 35 | 36 | time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) 37 | new_message = '{} {}: {}\n'.format(time_string, username, message_content) 38 | 39 | # Push message to board 40 | # First read message board (if it exists 41 | if os.path.isfile(boardpath): 42 | f = open(boardpath, "rb") 43 | message_board = msgpack.unpack(f) 44 | f.close() 45 | else: 46 | message_board = [] 47 | 48 | #Check we aren't doubling up (this can sometimes happen if there is an error initially and it then gets fixed) 49 | if new_message not in message_board: 50 | # Append our new message to the list 51 | message_board.append(new_message) 52 | 53 | # Prune the message board if needed 54 | while len(message_board) > max_messages: 55 | RNS.log('Pruning Message Board') 56 | message_board.pop(0) 57 | 58 | # Now open the board and write the updated list 59 | f = open(boardpath, "wb") 60 | msgpack.pack(message_board, f) 61 | f.close() 62 | 63 | # Send reply 64 | message_reply = '{}_{}_Your message has been added to the messageboard'.format(source_hash_text, time.time()) 65 | q.put(message_reply) 66 | 67 | def announce_now(lxmf_destination): 68 | lxmf_destination.announce() 69 | 70 | def send_message(destination_hash, message_content): 71 | try: 72 | # Make a binary destination hash from a hexadecimal string 73 | destination_hash = bytes.fromhex(destination_hash) 74 | 75 | except Exception as e: 76 | RNS.log("Invalid destination hash", RNS.LOG_ERROR) 77 | return 78 | 79 | # Check that size is correct 80 | if not len(destination_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8: 81 | RNS.log("Invalid destination hash length", RNS.LOG_ERROR) 82 | 83 | else: 84 | # Length of address was correct, let's try to recall the 85 | # corresponding Identity 86 | destination_identity = RNS.Identity.recall(destination_hash) 87 | 88 | if destination_identity == None: 89 | # No path/identity known, we'll have to abort or request one 90 | 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) 91 | RNS.Transport.request_path(destination_hash) 92 | 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) 93 | 94 | else: 95 | # We know the identity for the destination hash, let's 96 | # reconstruct a destination object. 97 | lxmf_destination = RNS.Destination(destination_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") 98 | 99 | # Create a new message object 100 | lxm = LXMF.LXMessage(lxmf_destination, local_lxmf_destination, message_content, title="Reply", desired_method=LXMF.LXMessage.DIRECT) 101 | 102 | # You can optionally tell LXMF to try to send the message 103 | # as a propagated message if a direct link fails 104 | lxm.try_propagation_on_fail = True 105 | 106 | # Send it 107 | message_router.handle_outbound(lxm) 108 | 109 | def announce_check(): 110 | if os.path.isfile(announcepath): 111 | f = open(announcepath, "r") 112 | announce = int(f.readline()) 113 | f.close() 114 | else: 115 | RNS.log('failed to open announcepath', RNS.LOG_DEBUG) 116 | announce = 1 117 | 118 | if announce > int(time.time()): 119 | RNS.log('Recent announcement', RNS.LOG_DEBUG) 120 | else: 121 | f = open(announcepath, "w") 122 | next_announce = int(time.time()) + 1800 123 | f.write(str(next_announce)) 124 | f.close() 125 | announce_now(local_lxmf_destination) 126 | RNS.log('Announcement sent, expr set 1800 seconds', RNS.LOG_INFO) 127 | 128 | #Setup Paths and Config Files 129 | userdir = os.path.expanduser("~") 130 | 131 | if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"): 132 | configdir = "/etc/nomadmb" 133 | elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"): 134 | configdir = userdir+"/.config/nomadmb" 135 | else: 136 | configdir = userdir+"/.nomadmb" 137 | 138 | storagepath = configdir+"/storage" 139 | if not os.path.isdir(storagepath): 140 | os.makedirs(storagepath) 141 | 142 | identitypath = configdir+"/storage/identity" 143 | announcepath = configdir+"/storage/announce" 144 | boardpath = configdir+"/storage/board" 145 | 146 | # Message Queue 147 | q = Queue(maxsize = 5) 148 | 149 | # Start Reticulum and print out all the debug messages 150 | reticulum = RNS.Reticulum(loglevel=RNS.LOG_VERBOSE) 151 | 152 | # Create a Identity. 153 | current_identity = setup_lxmf() 154 | 155 | # Init the LXMF router 156 | message_router = LXMF.LXMRouter(identity = current_identity, storagepath = configdir) 157 | 158 | # Register a delivery destination (for yourself) 159 | # In this example we use the same Identity as we used 160 | # to instantiate the LXMF router. It could be a different one, 161 | # but it can also just be the same, depending on what you want. 162 | local_lxmf_destination = message_router.register_delivery_identity(current_identity, display_name=display_name) 163 | 164 | # Set a callback for when a message is received 165 | message_router.register_delivery_callback(lxmf_delivery) 166 | 167 | # Announce node properties 168 | 169 | RNS.log('LXMF Router ready to receive on: {}'.format(RNS.prettyhexrep(local_lxmf_destination.hash)), RNS.LOG_INFO) 170 | announce_check() 171 | 172 | while True: 173 | 174 | # Work through internal message queue 175 | for i in list(q.queue): 176 | message_id = q.get() 177 | split_message = message_id.split('_') 178 | destination_hash = split_message[0] 179 | message = split_message[2] 180 | RNS.log('{} {}'.format(destination_hash, message), RNS.LOG_INFO) 181 | send_message(destination_hash, message) 182 | 183 | # Check whether we need to make another announcement 184 | announce_check() 185 | 186 | #Sleep 187 | time.sleep(10) 188 | -------------------------------------------------------------------------------- /nomadnet/examples/various/input_fields.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | env_string = "" 4 | for e in os.environ: 5 | env_string += "{}={}\n".format(e, os.environ[e]) 6 | 7 | template = """>Fields and Submitting Data 8 | 9 | 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. 10 | 11 | >> Read Environment Variables 12 | 13 | {@ENV} 14 | >>Examples of Fields and Submissions 15 | 16 | The following section contains a simple set of fields, and a few different links that submit the field data in different ways. 17 | 18 | -= 19 | 20 | 21 | >>>Text Fields 22 | An input field : `B444``b 23 | 24 | An masked field : `B444``b 25 | 26 | An small field : `B444`<8|small`test>`b, and some more text. 27 | 28 | Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b 29 | 30 | The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. 31 | 32 | >> Checkbox Fields 33 | 34 | `B444``b Sign me up 35 | 36 | >> Radio group 37 | 38 | Select your favorite color: 39 | 40 | `B900`<^|color|Red`>`b Red 41 | 42 | `B090`<^|color|Green`>`b Green 43 | 44 | `B009`<^|color|Blue`>`b Blue 45 | 46 | 47 | >>> Submitting data 48 | 49 | You can `!`[submit`:/page/input_fields.mu`one|password|small|color]`! other fields, or just `!`[a single one`:/page/input_fields.mu`username]`! 50 | 51 | Or simply `!`[submit them all`:/page/input_fields.mu`*]`!. 52 | 53 | Submission links can also `!`[include pre-configured variables`:/page/input_fields.mu`username|two|entitiy_id=4611|action=view]`!. 54 | 55 | Or take all fields and `!`[pre-configured variables`:/page/input_fields.mu`*|entitiy_id=4611|action=view]`!. 56 | 57 | Or only `!`[pre-configured variables`:/page/input_fields.mu`entitiy_id=4688|task=something]`! 58 | 59 | -= 60 | 61 | """ 62 | print(template.replace("{@ENV}", env_string)) -------------------------------------------------------------------------------- /nomadnet/nomadnet.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from ._version import __version__ 4 | 5 | import io 6 | import argparse 7 | import nomadnet 8 | 9 | 10 | def program_setup(configdir, rnsconfigdir, daemon, console): 11 | app = nomadnet.NomadNetworkApp( 12 | configdir = configdir, 13 | rnsconfigdir = rnsconfigdir, 14 | daemon = daemon, 15 | force_console = console, 16 | ) 17 | 18 | def main(): 19 | try: 20 | parser = argparse.ArgumentParser(description="Nomad Network Client") 21 | parser.add_argument("--config", action="store", default=None, help="path to alternative Nomad Network config directory", type=str) 22 | parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str) 23 | parser.add_argument("-t", "--textui", action="store_true", default=False, help="run Nomad Network in text-UI mode") 24 | parser.add_argument("-d", "--daemon", action="store_true", default=False, help="run Nomad Network in daemon mode") 25 | parser.add_argument("-c", "--console", action="store_true", default=False, help="in daemon mode, log to console instead of file") 26 | parser.add_argument("--version", action="version", version="Nomad Network Client {version}".format(version=__version__)) 27 | 28 | args = parser.parse_args() 29 | 30 | if args.config: 31 | configarg = args.config 32 | else: 33 | configarg = None 34 | 35 | if args.rnsconfig: 36 | rnsconfigarg = args.rnsconfig 37 | else: 38 | rnsconfigarg = None 39 | 40 | console = False 41 | if args.daemon: 42 | daemon = True 43 | if args.console: 44 | console = True 45 | else: 46 | daemon = False 47 | 48 | if args.textui: 49 | daemon = False 50 | 51 | program_setup(configarg, rnsconfigarg, daemon, console) 52 | 53 | except KeyboardInterrupt: 54 | print("") 55 | exit() 56 | 57 | if __name__ == "__main__": 58 | main() -------------------------------------------------------------------------------- /nomadnet/ui/GraphicalUI.py: -------------------------------------------------------------------------------- 1 | import RNS 2 | import nomadnet 3 | 4 | class GraphicalUI: 5 | 6 | def __init__(self): 7 | RNS.log("Graphical UI not implemented", RNS.LOG_ERROR) 8 | nomadnet.panic() -------------------------------------------------------------------------------- /nomadnet/ui/MenuUI.py: -------------------------------------------------------------------------------- 1 | import RNS 2 | import nomadnet 3 | 4 | class MenuUI: 5 | 6 | def __init__(self): 7 | RNS.log("Menu UI not implemented", RNS.LOG_ERROR, _override_destination=True) 8 | nomadnet.panic() -------------------------------------------------------------------------------- /nomadnet/ui/NoneUI.py: -------------------------------------------------------------------------------- 1 | import RNS 2 | import nomadnet 3 | import time 4 | 5 | from nomadnet import NomadNetworkApp 6 | 7 | class NoneUI: 8 | 9 | def __init__(self): 10 | self.app = NomadNetworkApp.get_shared_instance() 11 | self.app.ui = self 12 | 13 | if not self.app.force_console_log: 14 | RNS.log("Nomad Network started in daemon mode, all further messages are logged to "+str(self.app.logfilepath), RNS.LOG_INFO, _override_destination=True) 15 | else: 16 | RNS.log("Nomad Network daemon started", RNS.LOG_INFO) 17 | 18 | while True: 19 | time.sleep(1) -------------------------------------------------------------------------------- /nomadnet/ui/TextUI.py: -------------------------------------------------------------------------------- 1 | import RNS 2 | import urwid 3 | import time 4 | import os 5 | import platform 6 | 7 | import nomadnet 8 | from nomadnet.ui.textui import * 9 | from nomadnet import NomadNetworkApp 10 | 11 | COLORMODE_MONO = 1 12 | COLORMODE_16 = 16 13 | COLORMODE_88 = 88 14 | COLORMODE_256 = 256 15 | COLORMODE_TRUE = 2**24 16 | THEME_DARK = 0x01 17 | THEME_LIGHT = 0x02 18 | 19 | THEMES = { 20 | THEME_DARK: { 21 | "urwid_theme": [ 22 | # Style name # 16-color style # Monochrome style # 88, 256 and true-color style 23 | ("heading", "light gray,underline", "default", "underline", "g93,underline", "default"), 24 | ("menubar", "black", "light gray", "standout", "#111", "#bbb"), 25 | ("scrollbar", "light gray", "default", "default", "#444", "default"), 26 | ("shortcutbar", "black", "light gray", "standout", "#111", "#bbb"), 27 | ("body_text", "light gray", "default", "default", "#ddd", "default"), 28 | ("error_text", "dark red", "default", "default", "dark red", "default"), 29 | ("warning_text", "yellow", "default", "default", "#ba4", "default"), 30 | ("inactive_text", "dark gray", "default", "default", "dark gray", "default"), 31 | ("buttons", "light green,bold", "default", "default", "#00a533", "default"), 32 | ("msg_editor", "black", "light cyan", "standout", "#111", "#0bb"), 33 | ("msg_header_ok", "black", "light green", "standout", "#111", "#6b2"), 34 | ("msg_header_caution", "black", "yellow", "standout", "#111", "#fd3"), 35 | ("msg_header_sent", "black", "light gray", "standout", "#111", "#ddd"), 36 | ("msg_header_propagated", "black", "light blue", "standout", "#111", "#28b"), 37 | ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), 38 | ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), 39 | ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), 40 | ("list_focus", "black", "light gray", "standout", "#111", "#aaa"), 41 | ("list_off_focus", "black", "dark gray", "standout", "#111", "#777"), 42 | ("list_trusted", "dark green", "default", "default", "#6b2", "default"), 43 | ("list_focus_trusted", "black", "light gray", "standout", "#150", "#aaa"), 44 | ("list_unknown", "dark gray", "default", "default", "#bbb", "default"), 45 | ("list_normal", "dark gray", "default", "default", "#bbb", "default"), 46 | ("list_untrusted", "dark red", "default", "default", "#a22", "default"), 47 | ("list_focus_untrusted", "black", "light gray", "standout", "#810", "#aaa"), 48 | ("list_unresponsive", "yellow", "default", "default", "#b92", "default"), 49 | ("list_focus_unresponsive", "black", "light gray", "standout", "#530", "#aaa"), 50 | ("topic_list_normal", "light gray", "default", "default", "#ddd", "default"), 51 | ("browser_controls", "light gray", "default", "default", "#bbb", "default"), 52 | ("progress_full", "black", "light gray", "standout", "#111", "#bbb"), 53 | ("progress_empty", "light gray", "default", "default", "#ddd", "default"), 54 | ("interface_title", "", "", "default", "", ""), 55 | ("interface_title_selected", "bold", "", "bold", "", ""), 56 | ("connected_status", "dark green", "default", "default", "dark green", "default"), 57 | ("disconnected_status", "dark red", "default", "default", "dark red", "default"), 58 | ("placeholder", "dark gray", "default", "default", "dark gray", "default"), 59 | ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"), 60 | ("error", "light red,blink", "default", "blink", "#f44,blink", "default"), 61 | 62 | ], 63 | }, 64 | THEME_LIGHT: { 65 | "urwid_theme": [ 66 | # Style name # 16-color style # Monochrome style # 88, 256 and true-color style 67 | ("heading", "dark gray,underline", "default", "underline", "g93,underline", "default"), 68 | ("menubar", "black", "dark gray", "standout", "#111", "#bbb"), 69 | ("scrollbar", "dark gray", "default", "default", "#444", "default"), 70 | ("shortcutbar", "black", "dark gray", "standout", "#111", "#bbb"), 71 | ("body_text", "dark gray", "default", "default", "#222", "default"), 72 | ("error_text", "dark red", "default", "default", "dark red", "default"), 73 | ("warning_text", "yellow", "default", "default", "#ba4", "default"), 74 | ("inactive_text", "light gray", "default", "default", "dark gray", "default"), 75 | ("buttons", "light green,bold", "default", "default", "#00a533", "default"), 76 | ("msg_editor", "black", "dark cyan", "standout", "#111", "#0bb"), 77 | ("msg_header_ok", "black", "dark green", "standout", "#111", "#6b2"), 78 | ("msg_header_caution", "black", "yellow", "standout", "#111", "#fd3"), 79 | ("msg_header_sent", "black", "dark gray", "standout", "#111", "#ddd"), 80 | ("msg_header_propagated", "black", "light blue", "standout", "#111", "#28b"), 81 | ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), 82 | ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), 83 | ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), 84 | ("list_focus", "black", "dark gray", "standout", "#111", "#aaa"), 85 | ("list_off_focus", "black", "dark gray", "standout", "#111", "#777"), 86 | ("list_trusted", "dark green", "default", "default", "#4a0", "default"), 87 | ("list_focus_trusted", "black", "dark gray", "standout", "#150", "#aaa"), 88 | ("list_unknown", "dark gray", "default", "default", "#444", "default"), 89 | ("list_normal", "dark gray", "default", "default", "#444", "default"), 90 | ("list_untrusted", "dark red", "default", "default", "#a22", "default"), 91 | ("list_focus_untrusted", "black", "dark gray", "standout", "#810", "#aaa"), 92 | ("list_unresponsive", "yellow", "default", "default", "#b92", "default"), 93 | ("list_focus_unresponsive", "black", "light gray", "standout", "#530", "#aaa"), 94 | ("topic_list_normal", "dark gray", "default", "default", "#222", "default"), 95 | ("browser_controls", "dark gray", "default", "default", "#444", "default"), 96 | ("progress_full", "black", "dark gray", "standout", "#111", "#bbb"), 97 | ("progress_empty", "dark gray", "default", "default", "#ddd", "default"), 98 | ("interface_title", "dark gray", "default", "default", "#444", "default"), 99 | ("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"), 100 | ("connected_status", "dark green", "default", "default", "#4a0", "default"), 101 | ("disconnected_status", "dark red", "default", "default", "#a22", "default"), 102 | ("placeholder", "light gray", "default", "default", "#999", "default"), 103 | ("placeholder_text", "light gray", "default", "default", "#999", "default"), 104 | ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), 105 | ], 106 | } 107 | } 108 | 109 | GLYPHSETS = { 110 | "plain": 1, 111 | "unicode": 2, 112 | "nerdfont": 3 113 | } 114 | 115 | if platform.system() == "Darwin": 116 | urm_char = " \uf0e0" 117 | ur_char = "\uf0e0 " 118 | else: 119 | urm_char = " \uf003" 120 | ur_char = "\uf003 " 121 | 122 | GLYPHS = { 123 | # Glyph name # Plain # Unicode # Nerd Font 124 | ("check", "=", "\u2713", "\u2713"), 125 | ("cross", "X", "\u2715", "\u2715"), 126 | ("unknown", "?", "?", "?"), 127 | ("encrypted", "", "\u26BF", "\uf023"), 128 | ("plaintext", "!", "!", "\uf06e "), 129 | ("arrow_r", "->", "\u2192", "\u2192"), 130 | ("arrow_l", "<-", "\u2190", "\u2190"), 131 | ("arrow_u", "/\\", "\u2191", "\u2191"), 132 | ("arrow_d", "\\/", "\u2193", "\u2193"), 133 | ("warning", "!", "\u26a0", "\uf12a"), 134 | ("info", "i", "\u2139", "\U000f064e"), 135 | ("unread", "[!]", "\u2709", ur_char), 136 | ("divider1", "-", "\u2504", "\u2504"), 137 | ("peer", "[P]", "\u24c5 ", "\uf415"), 138 | ("node", "[N]", "\u24c3 ", "\U000f0002"), 139 | ("page", "", "\u25a4 ", "\uf719 "), 140 | ("speed", "", "\u25F7 ", "\U000f04c5 "), 141 | ("decoration_menu", " +", " +", " \U000f043b"), 142 | ("unread_menu", " !", " \u2709", urm_char), 143 | ("globe", "", "", "\uf484"), 144 | ("sent", "/\\", "\u2191", "\U000f0cd8"), 145 | ("papermsg", "P", "\u25a4", "\uf719"), 146 | ("qrcode", "QR", "\u25a4", "\uf029"), 147 | ("selected", "[*] ", "\u25CF", "\u25CF"), 148 | ("unselected", "[ ] ", "\u25CB", "\u25CB"), 149 | } 150 | 151 | class TextUI: 152 | 153 | def __init__(self): 154 | self.restore_ixon = False 155 | 156 | try: 157 | rval = os.system("stty -a | grep \"\\-ixon\"") 158 | if rval == 0: 159 | pass 160 | 161 | else: 162 | os.system("stty -ixon") 163 | self.restore_ixon = True 164 | 165 | except Exception as e: 166 | RNS.log("Could not configure terminal flow control sequences, some keybindings may not work.", RNS.LOG_WARNING) 167 | RNS.log("The contained exception was: "+str(e)) 168 | 169 | self.app = NomadNetworkApp.get_shared_instance() 170 | self.app.ui = self 171 | self.loop = None 172 | 173 | urwid.set_encoding("UTF-8") 174 | 175 | intro_timeout = self.app.config["textui"]["intro_time"] 176 | colormode = self.app.config["textui"]["colormode"] 177 | theme = self.app.config["textui"]["theme"] 178 | mouse_enabled = self.app.config["textui"]["mouse_enabled"] 179 | 180 | self.palette = THEMES[theme]["urwid_theme"] 181 | 182 | if self.app.config["textui"]["glyphs"] == "plain": 183 | glyphset = "plain" 184 | elif self.app.config["textui"]["glyphs"] == "unicode": 185 | glyphset = "unicode" 186 | elif self.app.config["textui"]["glyphs"] == "nerdfont": 187 | glyphset = "nerdfont" 188 | else: 189 | glyphset = "unicode" 190 | 191 | self.glyphs = {} 192 | for glyph in GLYPHS: 193 | self.glyphs[glyph[0]] = glyph[GLYPHSETS[glyphset]] 194 | 195 | self.screen = urwid.raw_display.Screen() 196 | self.screen.register_palette(self.palette) 197 | 198 | self.main_display = Main.MainDisplay(self, self.app) 199 | 200 | if intro_timeout > 0: 201 | self.intro_display = Extras.IntroDisplay(self.app) 202 | initial_widget = self.intro_display.widget 203 | else: 204 | initial_widget = self.main_display.widget 205 | 206 | self.loop = urwid.MainLoop(initial_widget, unhandled_input=self.unhandled_input, screen=self.screen, handle_mouse=mouse_enabled) 207 | 208 | if intro_timeout > 0: 209 | self.loop.set_alarm_in(intro_timeout, self.display_main) 210 | 211 | if "KONSOLE_VERSION" in os.environ: 212 | if colormode > 16: 213 | RNS.log("", RNS.LOG_WARNING, _override_destination = True) 214 | RNS.log("", RNS.LOG_WARNING, _override_destination = True) 215 | RNS.log("You are using the terminal emulator Konsole.", RNS.LOG_WARNING, _override_destination = True) 216 | RNS.log("If you are not seeing the user interface, it is due to a bug in Konsole/urwid.", RNS.LOG_WARNING, _override_destination = True) 217 | RNS.log("", RNS.LOG_WARNING, _override_destination = True) 218 | 219 | RNS.log("To circumvent this, use another terminal emulator, or launch nomadnet within a", RNS.LOG_WARNING, _override_destination = True) 220 | RNS.log("screen session, using a command like the following:", RNS.LOG_WARNING, _override_destination = True) 221 | RNS.log("", RNS.LOG_WARNING, _override_destination = True) 222 | RNS.log("screen nomadnet", RNS.LOG_WARNING, _override_destination = True) 223 | RNS.log("", RNS.LOG_WARNING, _override_destination = True) 224 | RNS.log("Press ctrl-c to exit now and try again.", RNS.LOG_WARNING, _override_destination = True) 225 | 226 | self.set_colormode(colormode) 227 | 228 | self.main_display.start() 229 | self.loop.run() 230 | 231 | def set_colormode(self, colormode): 232 | self.colormode = colormode 233 | self.screen.set_terminal_properties(colormode) 234 | 235 | if self.colormode < 256: 236 | self.screen.reset_default_terminal_palette() 237 | self.restore_palette = True 238 | 239 | def unhandled_input(self, key): 240 | if key == "ctrl q": 241 | raise urwid.ExitMainLoop 242 | elif key == "ctrl e": 243 | pass 244 | 245 | def display_main(self, loop, user_data): 246 | self.loop.widget = self.main_display.widget 247 | -------------------------------------------------------------------------------- /nomadnet/ui/WebUI.py: -------------------------------------------------------------------------------- 1 | import RNS 2 | import nomadnet 3 | 4 | class WebUI: 5 | 6 | def __init__(self): 7 | RNS.log("Web UI not implemented", RNS.LOG_ERROR) 8 | nomadnet.panic() -------------------------------------------------------------------------------- /nomadnet/ui/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import RNS 4 | import nomadnet 5 | 6 | modules = glob.glob(os.path.dirname(__file__)+"/*.py") 7 | __all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] 8 | 9 | 10 | UI_NONE = 0x00 11 | UI_MENU = 0x01 12 | UI_TEXT = 0x02 13 | UI_GRAPHICAL = 0x03 14 | UI_WEB = 0x04 15 | UI_MODES = [UI_NONE, UI_MENU, UI_TEXT, UI_GRAPHICAL, UI_WEB] 16 | 17 | def spawn(uimode): 18 | if uimode in UI_MODES: 19 | if uimode == UI_NONE: 20 | RNS.log("Starting Nomad Network daemon...", RNS.LOG_INFO) 21 | else: 22 | RNS.log("Starting user interface...", RNS.LOG_INFO) 23 | 24 | if uimode == UI_MENU: 25 | from .MenuUI import MenuUI 26 | return MenuUI() 27 | elif uimode == UI_TEXT: 28 | from .TextUI import TextUI 29 | return TextUI() 30 | elif uimode == UI_GRAPHICAL: 31 | from .GraphicalUI import GraphicalUI 32 | return GraphicalUI() 33 | elif uimode == UI_WEB: 34 | from .WebUI import WebUI 35 | return WebUI() 36 | elif uimode == UI_NONE: 37 | from .NoneUI import NoneUI 38 | return NoneUI() 39 | else: 40 | return None 41 | else: 42 | RNS.log("Invalid UI mode", RNS.LOG_ERROR, _override_destination=True) 43 | nomadnet.panic() -------------------------------------------------------------------------------- /nomadnet/ui/textui/Config.py: -------------------------------------------------------------------------------- 1 | import nomadnet 2 | import urwid 3 | import platform 4 | 5 | class ConfigDisplayShortcuts(): 6 | def __init__(self, app): 7 | import urwid 8 | self.app = app 9 | 10 | self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") 11 | 12 | class ConfigFiller(urwid.WidgetWrap): 13 | def __init__(self, widget, app): 14 | self.app = app 15 | self.filler = urwid.Filler(widget, urwid.TOP) 16 | super().__init__(self.filler) 17 | 18 | 19 | def keypress(self, size, key): 20 | if key == "up": 21 | self.app.ui.main_display.frame.focus_position = "header" 22 | 23 | return super(ConfigFiller, self).keypress(size, key) 24 | 25 | class ConfigDisplay(): 26 | def __init__(self, app): 27 | import urwid 28 | self.app = app 29 | 30 | def open_editor(sender): 31 | self.editor_term = EditorTerminal(self.app, self) 32 | self.widget = urwid.LineBox(self.editor_term) 33 | self.app.ui.main_display.update_active_sub_display() 34 | self.app.ui.main_display.frame.focus_position = "body" 35 | self.editor_term.term.change_focus(True) 36 | 37 | pile = urwid.Pile([ 38 | urwid.Text( 39 | ( 40 | "body_text", 41 | "\nTo change the configuration, edit the config file located at:\n\n" 42 | +self.app.configpath 43 | +"\n\nRestart Nomad Network for changes to take effect\n", 44 | ), 45 | align=urwid.CENTER, 46 | ), 47 | urwid.Padding(urwid.Button("Open Editor", on_press=open_editor), width=15, align=urwid.CENTER), 48 | ]) 49 | 50 | self.config_explainer = ConfigFiller(pile, self.app) 51 | self.shortcuts_display = ConfigDisplayShortcuts(self.app) 52 | self.widget = self.config_explainer 53 | 54 | def shortcuts(self): 55 | return self.shortcuts_display 56 | 57 | class EditorTerminal(urwid.WidgetWrap): 58 | def __init__(self, app, parent): 59 | self.app = app 60 | self.parent = parent 61 | editor_cmd = self.app.config["textui"]["editor"] 62 | 63 | # The "editor" alias is unavailable on Darwin, 64 | # so we replace it with nano. 65 | if platform.system() == "Darwin" and editor_cmd == "editor": 66 | editor_cmd = "nano" 67 | 68 | self.term = urwid.Terminal( 69 | (editor_cmd, self.app.configpath), 70 | encoding='utf-8', 71 | main_loop=self.app.ui.loop, 72 | ) 73 | 74 | def quit_term(*args, **kwargs): 75 | self.parent.widget = self.parent.config_explainer 76 | self.app.ui.main_display.update_active_sub_display() 77 | self.app.ui.main_display.show_config(None) 78 | self.app.ui.main_display.request_redraw() 79 | 80 | urwid.connect_signal(self.term, 'closed', quit_term) 81 | 82 | super().__init__(self.term) 83 | 84 | 85 | def keypress(self, size, key): 86 | # TODO: Decide whether there should be a way to get out while editing 87 | #if key == "up": 88 | # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" 89 | return super(EditorTerminal, self).keypress(size, key) -------------------------------------------------------------------------------- /nomadnet/ui/textui/Directory.py: -------------------------------------------------------------------------------- 1 | class DirectoryDisplayShortcuts(): 2 | def __init__(self, app): 3 | import urwid 4 | self.app = app 5 | 6 | self.widget = urwid.AttrMap(urwid.Text("Directory Display Shortcuts"), "shortcutbar") 7 | 8 | class DirectoryDisplay(): 9 | def __init__(self, app): 10 | import urwid 11 | self.app = app 12 | 13 | pile = urwid.Pile([ 14 | urwid.Text(("body_text", "Directory Display \U0001F332")), 15 | ]) 16 | 17 | self.shortcuts_display = DirectoryDisplayShortcuts(self.app) 18 | self.widget = urwid.Filler(pile, urwid.TOP) 19 | 20 | def shortcuts(self): 21 | return self.shortcuts_display -------------------------------------------------------------------------------- /nomadnet/ui/textui/Extras.py: -------------------------------------------------------------------------------- 1 | class IntroDisplay(): 2 | def __init__(self, app): 3 | import urwid 4 | self.app = app 5 | 6 | font = urwid.font.HalfBlock5x4Font() 7 | 8 | big_text = urwid.BigText(("intro_title", self.app.config["textui"]["intro_text"]), font) 9 | big_text = urwid.Padding(big_text, align=urwid.CENTER, width=urwid.CLIP) 10 | 11 | intro = urwid.Pile([ 12 | big_text, 13 | urwid.Text(("Version %s" % (str(self.app.version))), align=urwid.CENTER), 14 | urwid.Divider(), 15 | urwid.Text(("-= Starting =- "), align=urwid.CENTER), 16 | ]) 17 | 18 | self.widget = urwid.Filler(intro) 19 | 20 | -------------------------------------------------------------------------------- /nomadnet/ui/textui/Log.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import itertools 4 | import mmap 5 | import urwid 6 | import nomadnet 7 | 8 | 9 | class LogDisplayShortcuts(): 10 | def __init__(self, app): 11 | import urwid 12 | self.app = app 13 | 14 | self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") 15 | 16 | 17 | class LogDisplay(): 18 | def __init__(self, app): 19 | self.app = app 20 | 21 | self.shortcuts_display = LogDisplayShortcuts(self.app) 22 | self.widget = None 23 | 24 | @property 25 | def log_term(self): 26 | return self.widget 27 | 28 | def show(self): 29 | if self.widget is None: 30 | self.widget = log_widget(self.app) 31 | 32 | def kill(self): 33 | if self.widget is not None: 34 | self.widget.terminate() 35 | self.widget = None 36 | 37 | def shortcuts(self): 38 | return self.shortcuts_display 39 | 40 | 41 | class LogTerminal(urwid.WidgetWrap): 42 | def __init__(self, app): 43 | self.app = app 44 | self.log_term = urwid.Terminal( 45 | ("tail", "-fn50", self.app.logfilepath), 46 | encoding='utf-8', 47 | escape_sequence="up", 48 | main_loop=self.app.ui.loop, 49 | ) 50 | self.widget = urwid.LineBox(self.log_term) 51 | super().__init__(self.widget) 52 | 53 | def terminate(self): 54 | self.log_term.terminate() 55 | 56 | 57 | def keypress(self, size, key): 58 | if key == "up": 59 | nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" 60 | 61 | return super(LogTerminal, self).keypress(size, key) 62 | 63 | 64 | class LogTail(urwid.WidgetWrap): 65 | def __init__(self, app): 66 | self.app = app 67 | self.log_tail = urwid.Text(tail(self.app.logfilepath, 50)) 68 | self.log = urwid.Scrollable(self.log_tail) 69 | self.log.set_scrollpos(-1) 70 | self.log_scrollbar = urwid.ScrollBar(self.log) 71 | # We have this here because ui.textui.Main depends on this field to kill it 72 | self.log_term = None 73 | 74 | super().__init__(self.log_scrollbar) 75 | 76 | def terminate(self): 77 | pass 78 | 79 | 80 | def log_widget(app, platform=sys.platform): 81 | if platform == "win32": 82 | return LogTail(app) 83 | else: 84 | return LogTerminal(app) 85 | 86 | # https://stackoverflow.com/a/34029605/3713120 87 | def _tail(f_name, n, offset=0): 88 | def skip_back_lines(mm: mmap.mmap, numlines: int, startidx: int) -> int: 89 | '''Factored out to simplify handling of n and offset''' 90 | for _ in itertools.repeat(None, numlines): 91 | startidx = mm.rfind(b'\n', 0, startidx) 92 | if startidx < 0: 93 | break 94 | return startidx 95 | 96 | # Open file in binary mode 97 | with open(f_name, 'rb') as binf, mmap.mmap(binf.fileno(), 0, access=mmap.ACCESS_READ) as mm: 98 | # len(mm) - 1 handles files ending w/newline by getting the prior line 99 | startofline = skip_back_lines(mm, offset, len(mm) - 1) 100 | if startofline < 0: 101 | return [] # Offset lines consumed whole file, nothing to return 102 | # If using a generator function (yield-ing, see below), 103 | # this should be a plain return, no empty list 104 | 105 | endoflines = startofline + 1 # Slice end to omit offset lines 106 | 107 | # Find start of lines to capture (add 1 to move from newline to beginning of following line) 108 | startofline = skip_back_lines(mm, n, startofline) + 1 109 | 110 | # Passing True to splitlines makes it return the list of lines without 111 | # removing the trailing newline (if any), so list mimics f.readlines() 112 | # return mm[startofline:endoflines].splitlines(True) 113 | # If Windows style \r\n newlines need to be normalized to \n 114 | return mm[startofline:endoflines].replace(os.linesep.encode(sys.getdefaultencoding()), b'\n').splitlines(True) 115 | 116 | 117 | def tail(f_name, n): 118 | """ 119 | Return the last n lines of a given file name, f_name. 120 | Akin to `tail - ` 121 | """ 122 | def decode(b): 123 | return b.decode(encoding) 124 | 125 | encoding = sys.getdefaultencoding() 126 | lines = map(decode, _tail(f_name=f_name, n=n)) 127 | return ''.join(lines) 128 | -------------------------------------------------------------------------------- /nomadnet/ui/textui/Main.py: -------------------------------------------------------------------------------- 1 | import RNS 2 | 3 | from .Network import * 4 | from .Conversations import * 5 | from .Directory import * 6 | from .Config import * 7 | from .Interfaces import * 8 | from .Map import * 9 | from .Log import * 10 | from .Guide import * 11 | import urwid 12 | 13 | class SubDisplays(): 14 | def __init__(self, app): 15 | self.app = app 16 | self.network_display = NetworkDisplay(self.app) 17 | self.conversations_display = ConversationsDisplay(self.app) 18 | self.directory_display = DirectoryDisplay(self.app) 19 | self.config_display = ConfigDisplay(self.app) 20 | self.interface_display = InterfaceDisplay(self.app) 21 | self.map_display = MapDisplay(self.app) 22 | self.log_display = LogDisplay(self.app) 23 | self.guide_display = GuideDisplay(self.app) 24 | 25 | if app.firstrun: 26 | self.active_display = self.guide_display 27 | else: 28 | self.active_display = self.conversations_display 29 | 30 | def active(self): 31 | return self.active_display 32 | 33 | class MenuButton(urwid.Button): 34 | button_left = urwid.Text('[') 35 | button_right = urwid.Text(']') 36 | 37 | class MainFrame(urwid.Frame): 38 | FOCUS_CHECK_TIMEOUT = 0.25 39 | 40 | def __init__(self, body, header=None, footer=None, delegate=None): 41 | self.delegate = delegate 42 | self.current_focus = None 43 | super().__init__(body, header, footer) 44 | 45 | def keypress_focus_check(self, deferred=False): 46 | current_focus = self.delegate.widget.get_focus_widgets()[-1] 47 | 48 | if deferred: 49 | if current_focus != self.current_focus: 50 | self.focus_changed() 51 | else: 52 | def deferred_focus_check(loop, user_data): 53 | self.keypress_focus_check(deferred=True) 54 | self.delegate.app.ui.loop.set_alarm_in(MainFrame.FOCUS_CHECK_TIMEOUT, deferred_focus_check) 55 | 56 | self.current_focus = current_focus 57 | 58 | def focus_changed(self): 59 | current_focus = self.delegate.widget.get_focus_widgets()[-1] 60 | current_focus_path = self.delegate.widget.get_focus_path() 61 | 62 | if len(current_focus_path) > 1: 63 | if current_focus_path[0] == "body": 64 | self.delegate.update_active_shortcuts() 65 | 66 | if self.delegate.sub_displays.active() == self.delegate.sub_displays.conversations_display: 67 | # Needed to refresh indicativelistbox styles on mouse focus change 68 | self.delegate.sub_displays.conversations_display.focus_change_event() 69 | 70 | def mouse_event(self, size, event, button, col, row, focus): 71 | current_focus = self.delegate.widget.get_focus_widgets()[-1] 72 | if current_focus != self.current_focus: 73 | self.focus_changed() 74 | 75 | self.current_focus = current_focus 76 | return super(MainFrame, self).mouse_event(size, event, button, col, row, focus) 77 | 78 | def keypress(self, size, key): 79 | self.keypress_focus_check() 80 | 81 | #if key == "ctrl q": 82 | # raise urwid.ExitMainLoop 83 | 84 | return super(MainFrame, self).keypress(size, key) 85 | 86 | class MainDisplay(): 87 | def __init__(self, ui, app): 88 | self.ui = ui 89 | self.app = app 90 | 91 | self.menu_display = MenuDisplay(self.app, self) 92 | self.sub_displays = SubDisplays(self.app) 93 | 94 | self.frame = MainFrame(self.sub_displays.active().widget, header=self.menu_display.widget, footer=self.sub_displays.active().shortcuts().widget, delegate=self) 95 | self.widget = self.frame 96 | 97 | def show_network(self, user_data): 98 | self.sub_displays.active_display = self.sub_displays.network_display 99 | self.update_active_sub_display() 100 | self.sub_displays.network_display.start() 101 | 102 | def show_conversations(self, user_data): 103 | self.sub_displays.active_display = self.sub_displays.conversations_display 104 | self.update_active_sub_display() 105 | 106 | def show_directory(self, user_data): 107 | self.sub_displays.active_display = self.sub_displays.directory_display 108 | self.update_active_sub_display() 109 | 110 | def show_map(self, user_data): 111 | self.sub_displays.active_display = self.sub_displays.map_display 112 | self.update_active_sub_display() 113 | 114 | def show_config(self, user_data): 115 | self.sub_displays.active_display = self.sub_displays.config_display 116 | self.update_active_sub_display() 117 | 118 | def show_interfaces(self, user_data): 119 | self.sub_displays.active_display = self.sub_displays.interface_display 120 | self.update_active_sub_display() 121 | self.sub_displays.interface_display.start() 122 | 123 | def show_log(self, user_data): 124 | self.sub_displays.active_display = self.sub_displays.log_display 125 | self.sub_displays.log_display.show() 126 | self.update_active_sub_display() 127 | 128 | def show_guide(self, user_data): 129 | self.sub_displays.active_display = self.sub_displays.guide_display 130 | self.update_active_sub_display() 131 | 132 | def update_active_sub_display(self): 133 | self.frame.contents["body"] = (self.sub_displays.active().widget, None) 134 | self.update_active_shortcuts() 135 | if self.sub_displays.active_display != self.sub_displays.log_display: 136 | self.sub_displays.log_display.kill() 137 | 138 | def update_active_shortcuts(self): 139 | self.frame.contents["footer"] = (self.sub_displays.active().shortcuts().widget, None) 140 | 141 | def request_redraw(self, extra_delay=0.0): 142 | self.app.ui.loop.set_alarm_in(0.25+extra_delay, self.redraw_now) 143 | 144 | def redraw_now(self, sender=None, data=None): 145 | self.app.ui.loop.screen.clear() 146 | #self.app.ui.loop.draw_screen() 147 | 148 | def start(self): 149 | self.menu_display.start() 150 | 151 | def quit(self, sender=None): 152 | logterm_pid = None 153 | if True or RNS.vendor.platformutils.is_android(): 154 | if self.sub_displays.log_display != None and self.sub_displays.log_display.log_term != None: 155 | if self.sub_displays.log_display.log_term.log_term != None: 156 | logterm_pid = self.sub_displays.log_display.log_term.log_term.pid 157 | if logterm_pid != None: 158 | import os, signal 159 | os.kill(logterm_pid, signal.SIGKILL) 160 | 161 | raise urwid.ExitMainLoop 162 | 163 | 164 | class MenuColumns(urwid.Columns): 165 | def keypress(self, size, key): 166 | if key == "tab" or key == "down": 167 | self.handler.frame.focus_position = "body" 168 | 169 | return super(MenuColumns, self).keypress(size, key) 170 | 171 | class MenuDisplay(): 172 | UPDATE_INTERVAL = 2 173 | 174 | def __init__(self, app, handler): 175 | self.app = app 176 | self.update_interval = MenuDisplay.UPDATE_INTERVAL 177 | self.g = self.app.ui.glyphs 178 | 179 | self.menu_indicator = urwid.Text("") 180 | 181 | menu_text = (urwid.PACK, self.menu_indicator) 182 | button_network = (11, MenuButton("Network", on_press=handler.show_network)) 183 | button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) 184 | button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) 185 | button_map = (7, MenuButton("Map", on_press=handler.show_map)) 186 | button_log = (7, MenuButton("Log", on_press=handler.show_log)) 187 | button_config = (10, MenuButton("Config", on_press=handler.show_config)) 188 | button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) 189 | button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) 190 | button_quit = (8, MenuButton("Quit", on_press=handler.quit)) 191 | 192 | # buttons = [menu_text, button_conversations, button_node, button_directory, button_map] 193 | if self.app.config["textui"]["hide_guide"]: 194 | buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_quit] 195 | else: 196 | buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_guide, button_quit] 197 | 198 | columns = MenuColumns(buttons, dividechars=1) 199 | columns.handler = handler 200 | 201 | self.update_display() 202 | 203 | self.widget = urwid.AttrMap(columns, "menubar") 204 | 205 | def start(self): 206 | self.update_display_job() 207 | 208 | def update_display_job(self, event = None, sender = None): 209 | self.update_display() 210 | self.app.ui.loop.set_alarm_in(self.update_interval, self.update_display_job) 211 | 212 | def update_display(self): 213 | if self.app.has_unread_conversations(): 214 | self.indicate_unread() 215 | else: 216 | self.indicate_normal() 217 | 218 | def indicate_normal(self): 219 | self.menu_indicator.set_text(self.g["decoration_menu"]) 220 | 221 | def indicate_unread(self): 222 | self.menu_indicator.set_text(self.g["unread_menu"]) 223 | -------------------------------------------------------------------------------- /nomadnet/ui/textui/Map.py: -------------------------------------------------------------------------------- 1 | class MapDisplayShortcuts(): 2 | def __init__(self, app): 3 | import urwid 4 | self.app = app 5 | 6 | self.widget = urwid.AttrMap(urwid.Text("Map Display Shortcuts"), "shortcutbar") 7 | 8 | class MapDisplay(): 9 | def __init__(self, app): 10 | import urwid 11 | self.app = app 12 | 13 | pile = urwid.Pile([ 14 | urwid.Text(("body_text", "Map Display \U0001F332")), 15 | ]) 16 | 17 | self.shortcuts_display = MapDisplayShortcuts(self.app) 18 | self.widget = urwid.Filler(pile, urwid.TOP) 19 | 20 | def shortcuts(self): 21 | return self.shortcuts_display -------------------------------------------------------------------------------- /nomadnet/ui/textui/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | 4 | modules = glob.glob(os.path.dirname(__file__)+"/*.py") 5 | __all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] 6 | -------------------------------------------------------------------------------- /nomadnet/vendor/AsciiChart.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from math import ceil, floor, isnan 3 | # Derived from asciichartpy | https://github.com/kroitor/asciichart/blob/master/asciichartpy/__init__.py 4 | class AsciiChart: 5 | def __init__(self, glyphset="unicode"): 6 | self.symbols = ['┼', '┤', '╶', '╴', '─', '╰', '╭', '╮', '╯', '│'] 7 | if glyphset == "plain": 8 | self.symbols = ['+', '|', '-', '-', '-', '\'', ',', '.', '`', '|'] 9 | def plot(self, series, cfg=None): 10 | if len(series) == 0: 11 | return '' 12 | if not isinstance(series[0], list): 13 | if all(isnan(n) for n in series): 14 | return '' 15 | else: 16 | series = [series] 17 | cfg = cfg or {} 18 | minimum = cfg.get('min', min(filter(lambda n: not isnan(n), [j for i in series for j in i]))) 19 | maximum = cfg.get('max', max(filter(lambda n: not isnan(n), [j for i in series for j in i]))) 20 | symbols = cfg.get('symbols', self.symbols) 21 | if minimum > maximum: 22 | raise ValueError('The min value cannot exceed the max value.') 23 | interval = maximum - minimum 24 | offset = cfg.get('offset', 3) 25 | height = cfg.get('height', interval) 26 | ratio = height / interval if interval > 0 else 1 27 | 28 | min2 = int(floor(minimum * ratio)) 29 | max2 = int(ceil(maximum * ratio)) 30 | 31 | def clamp(n): 32 | return min(max(n, minimum), maximum) 33 | 34 | def scaled(y): 35 | return int(round(clamp(y) * ratio) - min2) 36 | 37 | rows = max2 - min2 38 | 39 | width = 0 40 | for i in range(0, len(series)): 41 | width = max(width, len(series[i])) 42 | width += offset 43 | 44 | placeholder = cfg.get('format', '{:8.2f} ') 45 | 46 | result = [[' '] * width for i in range(rows + 1)] 47 | 48 | for y in range(min2, max2 + 1): 49 | if callable(placeholder): 50 | label = placeholder(maximum - ((y - min2) * interval / (rows if rows else 1))).rjust(12) 51 | else: 52 | label = placeholder.format(maximum - ((y - min2) * interval / (rows if rows else 1))) 53 | 54 | result[y - min2][max(offset - len(label), 0)] = label 55 | result[y - min2][offset - 1] = symbols[0] if y == 0 else symbols[1] 56 | 57 | d0 = series[0][0] 58 | if not isnan(d0): 59 | result[rows - scaled(d0)][offset - 1] = symbols[0] 60 | 61 | for i in range(0, len(series)): 62 | for x in range(0, len(series[i]) - 1): 63 | d0 = series[i][x + 0] 64 | d1 = series[i][x + 1] 65 | 66 | if isnan(d0) and isnan(d1): 67 | continue 68 | 69 | if isnan(d0) and not isnan(d1): 70 | result[rows - scaled(d1)][x + offset] = symbols[2] 71 | continue 72 | 73 | if not isnan(d0) and isnan(d1): 74 | result[rows - scaled(d0)][x + offset] = symbols[3] 75 | continue 76 | 77 | y0 = scaled(d0) 78 | y1 = scaled(d1) 79 | if y0 == y1: 80 | result[rows - y0][x + offset] = symbols[4] 81 | continue 82 | 83 | result[rows - y1][x + offset] = symbols[5] if y0 > y1 else symbols[6] 84 | result[rows - y0][x + offset] = symbols[7] if y0 > y1 else symbols[8] 85 | 86 | start = min(y0, y1) + 1 87 | end = max(y0, y1) 88 | for y in range(start, end): 89 | result[rows - y][x + offset] = symbols[9] 90 | 91 | return '\n'.join([''.join(row).rstrip() for row in result]) -------------------------------------------------------------------------------- /nomadnet/vendor/Scrollable.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | import urwid 13 | from urwid.widget import BOX, FIXED, FLOW 14 | 15 | # Scroll actions 16 | SCROLL_LINE_UP = 'line up' 17 | SCROLL_LINE_DOWN = 'line down' 18 | SCROLL_PAGE_UP = 'page up' 19 | SCROLL_PAGE_DOWN = 'page down' 20 | SCROLL_TO_TOP = 'to top' 21 | SCROLL_TO_END = 'to end' 22 | 23 | # Scrollbar positions 24 | SCROLLBAR_LEFT = 'left' 25 | SCROLLBAR_RIGHT = 'right' 26 | 27 | class Scrollable(urwid.WidgetDecoration): 28 | def sizing(self): 29 | return frozenset([BOX,]) 30 | 31 | def selectable(self): 32 | return True 33 | 34 | def __init__(self, widget, force_forward_keypress = False): 35 | """Box widget that makes a fixed or flow widget vertically scrollable 36 | 37 | TODO: Focusable widgets are handled, including switching focus, but 38 | possibly not intuitively, depending on the arrangement of widgets. When 39 | switching focus to a widget that is ouside of the visible part of the 40 | original widget, the canvas scrolls up/down to the focused widget. It 41 | would be better to scroll until the next focusable widget is in sight 42 | first. But for that to work we must somehow obtain a list of focusable 43 | rows in the original canvas. 44 | """ 45 | if not any(s in widget.sizing() for s in (FIXED, FLOW)): 46 | raise ValueError('Not a fixed or flow widget: %r' % widget) 47 | self._trim_top = 0 48 | self._scroll_action = None 49 | self._forward_keypress = None 50 | self._old_cursor_coords = None 51 | self._rows_max_cached = 0 52 | self.force_forward_keypress = force_forward_keypress 53 | super().__init__(widget) 54 | 55 | def render(self, size, focus=False): 56 | maxcol, maxrow = size 57 | 58 | # Render complete original widget 59 | ow = self._original_widget 60 | ow_size = self._get_original_widget_size(size) 61 | canv_full = ow.render(ow_size, focus) 62 | 63 | # Make full canvas editable 64 | canv = urwid.CompositeCanvas(canv_full) 65 | canv_cols, canv_rows = canv.cols(), canv.rows() 66 | 67 | if canv_cols <= maxcol: 68 | pad_width = maxcol - canv_cols 69 | if pad_width > 0: 70 | # Canvas is narrower than available horizontal space 71 | canv.pad_trim_left_right(0, pad_width) 72 | 73 | if canv_rows <= maxrow: 74 | fill_height = maxrow - canv_rows 75 | if fill_height > 0: 76 | # Canvas is lower than available vertical space 77 | canv.pad_trim_top_bottom(0, fill_height) 78 | 79 | if canv_cols <= maxcol and canv_rows <= maxrow: 80 | # Canvas is small enough to fit without trimming 81 | return canv 82 | 83 | self._adjust_trim_top(canv, size) 84 | 85 | # Trim canvas if necessary 86 | trim_top = self._trim_top 87 | trim_end = canv_rows - maxrow - trim_top 88 | trim_right = canv_cols - maxcol 89 | if trim_top > 0: 90 | canv.trim(trim_top) 91 | if trim_end > 0: 92 | canv.trim_end(trim_end) 93 | if trim_right > 0: 94 | canv.pad_trim_left_right(0, -trim_right) 95 | 96 | # Disable cursor display if cursor is outside of visible canvas parts 97 | if canv.cursor is not None: 98 | curscol, cursrow = canv.cursor 99 | if cursrow >= maxrow or cursrow < 0: 100 | canv.cursor = None 101 | 102 | # Figure out whether we should forward keypresses to original widget 103 | if canv.cursor is not None: 104 | # Trimmed canvas contains the cursor, e.g. in an Edit widget 105 | self._forward_keypress = True 106 | else: 107 | if canv_full.cursor is not None: 108 | # Full canvas contains the cursor, but scrolled out of view 109 | self._forward_keypress = False 110 | 111 | # Reset cursor position on page/up down scrolling 112 | try: 113 | if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll: 114 | pwi = 0 115 | ch = 0 116 | last_hidden = False 117 | first_visible = False 118 | for w,o in ow.contents: 119 | wcanv = w.render((maxcol,)) 120 | wh = wcanv.rows() 121 | if wh: 122 | ch += wh 123 | 124 | if not last_hidden and ch >= self._trim_top: 125 | last_hidden = True 126 | 127 | elif last_hidden: 128 | if not first_visible: 129 | first_visible = True 130 | 131 | if w.selectable(): 132 | ow.focus_item = pwi 133 | 134 | st = None 135 | nf = ow.get_focus() 136 | if hasattr(nf, "key_timeout"): 137 | st = nf 138 | elif hasattr(nf, "original_widget"): 139 | no = nf.original_widget 140 | if hasattr(no, "original_widget"): 141 | st = no.original_widget 142 | else: 143 | if hasattr(no, "key_timeout"): 144 | st = no 145 | 146 | if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): 147 | st.keypress(None, None) 148 | 149 | break 150 | 151 | pwi += 1 152 | except Exception as e: 153 | pass 154 | 155 | else: 156 | # Original widget does not have a cursor, but may be selectable 157 | 158 | # FIXME: Using ow.selectable() is bad because the original 159 | # widget may be selectable because it's a container widget with 160 | # a key-grabbing widget that is scrolled out of view. 161 | # ow.selectable() returns True anyway because it doesn't know 162 | # how we trimmed our canvas. 163 | # 164 | # To fix this, we need to resolve ow.focus and somehow 165 | # ask canv whether it contains bits of the focused widget. I 166 | # can't see a way to do that. 167 | if ow.selectable(): 168 | self._forward_keypress = True 169 | else: 170 | self._forward_keypress = False 171 | 172 | return canv 173 | 174 | def keypress(self, size, key): 175 | # Maybe offer key to original widget 176 | if self._forward_keypress or self.force_forward_keypress: 177 | ow = self._original_widget 178 | ow_size = self._get_original_widget_size(size) 179 | 180 | # Remember previous cursor position if possible 181 | if hasattr(ow, 'get_cursor_coords'): 182 | self._old_cursor_coords = ow.get_cursor_coords(ow_size) 183 | 184 | key = ow.keypress(ow_size, key) 185 | if key is None: 186 | return None 187 | 188 | # Handle up/down, page up/down, etc 189 | command_map = self._command_map 190 | if command_map[key] == urwid.CURSOR_UP: 191 | self._scroll_action = SCROLL_LINE_UP 192 | elif command_map[key] == urwid.CURSOR_DOWN: 193 | self._scroll_action = SCROLL_LINE_DOWN 194 | 195 | elif command_map[key] == urwid.CURSOR_PAGE_UP: 196 | self._scroll_action = SCROLL_PAGE_UP 197 | elif command_map[key] == urwid.CURSOR_PAGE_DOWN: 198 | self._scroll_action = SCROLL_PAGE_DOWN 199 | 200 | elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home' 201 | self._scroll_action = SCROLL_TO_TOP 202 | elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end' 203 | self._scroll_action = SCROLL_TO_END 204 | 205 | else: 206 | return key 207 | 208 | self._invalidate() 209 | 210 | def mouse_event(self, size, event, button, col, row, focus): 211 | ow = self._original_widget 212 | if hasattr(ow, 'mouse_event'): 213 | ow_size = self._get_original_widget_size(size) 214 | row += self._trim_top 215 | return ow.mouse_event(ow_size, event, button, col, row, focus) 216 | else: 217 | return False 218 | 219 | def _adjust_trim_top(self, canv, size): 220 | """Adjust self._trim_top according to self._scroll_action""" 221 | action = self._scroll_action 222 | self._scroll_action = None 223 | 224 | maxcol, maxrow = size 225 | trim_top = self._trim_top 226 | canv_rows = canv.rows() 227 | 228 | if trim_top < 0: 229 | # Negative trim_top values use bottom of canvas as reference 230 | trim_top = canv_rows - maxrow + trim_top + 1 231 | 232 | if canv_rows <= maxrow: 233 | self._trim_top = 0 # Reset scroll position 234 | return 235 | 236 | def ensure_bounds(new_trim_top): 237 | return max(0, min(canv_rows - maxrow, new_trim_top)) 238 | 239 | if action == SCROLL_LINE_UP: 240 | self._trim_top = ensure_bounds(trim_top - 1) 241 | elif action == SCROLL_LINE_DOWN: 242 | self._trim_top = ensure_bounds(trim_top + 1) 243 | 244 | elif action == SCROLL_PAGE_UP: 245 | self._trim_top = ensure_bounds(trim_top - maxrow + 1) 246 | elif action == SCROLL_PAGE_DOWN: 247 | self._trim_top = ensure_bounds(trim_top + maxrow - 1) 248 | 249 | elif action == SCROLL_TO_TOP: 250 | self._trim_top = 0 251 | elif action == SCROLL_TO_END: 252 | self._trim_top = canv_rows - maxrow 253 | 254 | else: 255 | self._trim_top = ensure_bounds(trim_top) 256 | 257 | # If the cursor was moved by the most recent keypress, adjust trim_top 258 | # so that the new cursor position is within the displayed canvas part. 259 | # But don't do this if the cursor is at the top/bottom edge so we can still scroll out 260 | if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor != None: 261 | self._old_cursor_coords = None 262 | curscol, cursrow = canv.cursor 263 | if cursrow < self._trim_top: 264 | self._trim_top = cursrow 265 | elif cursrow >= self._trim_top + maxrow: 266 | self._trim_top = max(0, cursrow - maxrow + 1) 267 | 268 | def _get_original_widget_size(self, size): 269 | ow = self._original_widget 270 | sizing = ow.sizing() 271 | if FLOW in sizing: 272 | return (size[0],) 273 | elif FIXED in sizing: 274 | return () 275 | 276 | def get_scrollpos(self, size=None, focus=False): 277 | """Current scrolling position 278 | 279 | Lower limit is 0, upper limit is the maximum number of rows with the 280 | given maxcol minus maxrow. 281 | 282 | NOTE: The returned value may be too low or too high if the position has 283 | changed but the widget wasn't rendered yet. 284 | """ 285 | return self._trim_top 286 | 287 | def set_scrollpos(self, position): 288 | """Set scrolling position 289 | 290 | If `position` is positive it is interpreted as lines from the top. 291 | If `position` is negative it is interpreted as lines from the bottom. 292 | 293 | Values that are too high or too low values are automatically adjusted 294 | during rendering. 295 | """ 296 | self._trim_top = int(position) 297 | self._invalidate() 298 | 299 | def rows_max(self, size=None, focus=False): 300 | """Return the number of rows for `size` 301 | 302 | If `size` is not given, the currently rendered number of rows is returned. 303 | """ 304 | if size is not None: 305 | ow = self._original_widget 306 | ow_size = self._get_original_widget_size(size) 307 | sizing = ow.sizing() 308 | if FIXED in sizing: 309 | self._rows_max_cached = ow.pack(ow_size, focus)[1] 310 | elif FLOW in sizing: 311 | self._rows_max_cached = ow.rows(ow_size, focus) 312 | else: 313 | raise RuntimeError('Not a flow/box widget: %r' % self._original_widget) 314 | return self._rows_max_cached 315 | 316 | 317 | class ScrollBar(urwid.WidgetDecoration): 318 | def sizing(self): 319 | return frozenset((BOX,)) 320 | 321 | def selectable(self): 322 | return True 323 | 324 | def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ', 325 | side=SCROLLBAR_RIGHT, width=1): 326 | """Box widget that adds a scrollbar to `widget` 327 | 328 | `widget` must be a box widget with the following methods: 329 | - `get_scrollpos` takes the arguments `size` and `focus` and returns 330 | the index of the first visible row. 331 | - `set_scrollpos` (optional; needed for mouse click support) takes the 332 | index of the first visible row. 333 | - `rows_max` takes `size` and `focus` and returns the total number of 334 | rows `widget` can render. 335 | 336 | `thumb_char` is the character used for the scrollbar handle. 337 | `trough_char` is used for the space above and below the handle. 338 | `side` must be 'left' or 'right'. 339 | `width` specifies the number of columns the scrollbar uses. 340 | """ 341 | if BOX not in widget.sizing(): 342 | raise ValueError('Not a box widget: %r' % widget) 343 | super().__init__(widget) 344 | self._thumb_char = thumb_char 345 | self._trough_char = trough_char 346 | self.scrollbar_side = side 347 | self.scrollbar_width = max(1, width) 348 | self._original_widget_size = (0, 0) 349 | 350 | def render(self, size, focus=False): 351 | maxcol, maxrow = size 352 | 353 | sb_width = self._scrollbar_width 354 | ow_size = (max(0, maxcol - sb_width), maxrow) 355 | sb_width = maxcol - ow_size[0] 356 | 357 | ow = self._original_widget 358 | ow_base = self.scrolling_base_widget 359 | ow_rows_max = ow_base.rows_max(size, focus) 360 | if ow_rows_max <= maxrow: 361 | # Canvas fits without scrolling - no scrollbar needed 362 | self._original_widget_size = size 363 | return ow.render(size, focus) 364 | ow_rows_max = ow_base.rows_max(ow_size, focus) 365 | 366 | ow_canv = ow.render(ow_size, focus) 367 | self._original_widget_size = ow_size 368 | 369 | pos = ow_base.get_scrollpos(ow_size, focus) 370 | posmax = ow_rows_max - maxrow 371 | 372 | # Thumb shrinks/grows according to the ratio of 373 | # / 374 | thumb_weight = min(1, maxrow / max(1, ow_rows_max)) 375 | thumb_height = max(1, round(thumb_weight * maxrow)) 376 | 377 | # Thumb may only touch top/bottom if the first/last row is visible 378 | top_weight = float(pos) / max(1, posmax) 379 | top_height = int((maxrow - thumb_height) * top_weight) 380 | if top_height == 0 and top_weight > 0: 381 | top_height = 1 382 | 383 | # Bottom part is remaining space 384 | bottom_height = maxrow - thumb_height - top_height 385 | assert thumb_height + top_height + bottom_height == maxrow 386 | 387 | # Create scrollbar canvas 388 | # Creating SolidCanvases of correct height may result in "cviews do not 389 | # fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" 390 | # exceptions. Stacking the same SolidCanvas is a workaround. 391 | # https://github.com/urwid/urwid/issues/226#issuecomment-437176837 392 | top = urwid.SolidCanvas(self._trough_char, sb_width, 1) 393 | thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1) 394 | bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1) 395 | sb_canv = urwid.CanvasCombine( 396 | [(top, None, False)] * top_height + 397 | [(thumb, None, False)] * thumb_height + 398 | [(bottom, None, False)] * bottom_height, 399 | ) 400 | 401 | combinelist = [(ow_canv, None, True, ow_size[0]), 402 | (sb_canv, None, False, sb_width)] 403 | if self._scrollbar_side != SCROLLBAR_LEFT: 404 | return urwid.CanvasJoin(combinelist) 405 | else: 406 | return urwid.CanvasJoin(reversed(combinelist)) 407 | 408 | @property 409 | def scrollbar_width(self): 410 | """Columns the scrollbar uses""" 411 | return max(1, self._scrollbar_width) 412 | 413 | @scrollbar_width.setter 414 | def scrollbar_width(self, width): 415 | self._scrollbar_width = max(1, int(width)) 416 | self._invalidate() 417 | 418 | @property 419 | def scrollbar_side(self): 420 | """Where to display the scrollbar; must be 'left' or 'right'""" 421 | return self._scrollbar_side 422 | 423 | @scrollbar_side.setter 424 | def scrollbar_side(self, side): 425 | if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT): 426 | raise ValueError('scrollbar_side must be "left" or "right", not %r' % side) 427 | self._scrollbar_side = side 428 | self._invalidate() 429 | 430 | @property 431 | def scrolling_base_widget(self): 432 | """Nearest `original_widget` that is compatible with the scrolling API""" 433 | def orig_iter(w): 434 | while hasattr(w, 'original_widget'): 435 | w = w.original_widget 436 | yield w 437 | yield w 438 | 439 | def is_scrolling_widget(w): 440 | return hasattr(w, 'get_scrollpos') and hasattr(w, 'rows_max') 441 | 442 | for w in orig_iter(self): 443 | if is_scrolling_widget(w): 444 | return w 445 | raise ValueError('Not compatible to be wrapped by ScrollBar: %r' % w) 446 | 447 | def keypress(self, size, key): 448 | return self._original_widget.keypress(self._original_widget_size, key) 449 | 450 | def mouse_event(self, size, event, button, col, row, focus): 451 | ow = self._original_widget 452 | ow_size = self._original_widget_size 453 | handled = False 454 | if hasattr(ow, 'mouse_event'): 455 | handled = ow.mouse_event(ow_size, event, button, col, row, focus) 456 | 457 | if not handled and hasattr(ow, 'set_scrollpos'): 458 | if button == 4: # scroll wheel up 459 | pos = ow.get_scrollpos(ow_size) 460 | newpos = pos - 1 461 | if newpos < 0: 462 | newpos = 0 463 | ow.set_scrollpos(newpos) 464 | return True 465 | elif button == 5: # scroll wheel down 466 | pos = ow.get_scrollpos(ow_size) 467 | ow.set_scrollpos(pos + 1) 468 | return True 469 | 470 | return False 471 | -------------------------------------------------------------------------------- /nomadnet/vendor/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | 4 | modules = glob.glob(os.path.dirname(__file__)+"/*.py") 5 | __all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')] 6 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/FormWidgets.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | class DialogLineBox(urwid.LineBox): 4 | def __init__(self, body, parent=None, title="?"): 5 | super().__init__(body, title=title) 6 | self.parent = parent 7 | 8 | def keypress(self, size, key): 9 | if key == "esc": 10 | if self.parent and hasattr(self.parent, "dismiss_dialog"): 11 | self.parent.dismiss_dialog() 12 | return None 13 | return super().keypress(size, key) 14 | 15 | class Placeholder(urwid.Edit): 16 | def __init__(self, caption="", edit_text="", placeholder="", **kwargs): 17 | super().__init__(caption, edit_text, **kwargs) 18 | self.placeholder = placeholder 19 | 20 | def render(self, size, focus=False): 21 | if not self.edit_text and not focus: 22 | placeholder_widget = urwid.Text(("placeholder", self.placeholder)) 23 | return placeholder_widget.render(size, focus) 24 | else: 25 | return super().render(size, focus) 26 | 27 | class Dropdown(urwid.WidgetWrap): 28 | signals = ['change'] # emit for urwid.connect_signal fn 29 | 30 | def __init__(self, label, options, default=None): 31 | self.label = label 32 | self.options = options 33 | self.selected = default if default is not None else options[0] 34 | 35 | self.main_text = f"{self.selected}" 36 | self.main_button = urwid.SelectableIcon(self.main_text, 0) 37 | self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus") 38 | 39 | self.option_widgets = [] 40 | for opt in options: 41 | icon = urwid.SelectableIcon(opt, 0) 42 | icon = urwid.AttrMap(icon, "list_normal", "list_focus") 43 | self.option_widgets.append(icon) 44 | 45 | self.options_walker = urwid.SimpleFocusListWalker(self.option_widgets) 46 | self.options_listbox = urwid.ListBox(self.options_walker) 47 | self.dropdown_box = None # will be created on open_dropdown 48 | 49 | self.pile = urwid.Pile([self.main_button]) 50 | self.dropdown_visible = False 51 | 52 | super().__init__(self.pile) 53 | 54 | def open_dropdown(self): 55 | if not self.dropdown_visible: 56 | height = len(self.options) 57 | self.dropdown_box = urwid.BoxAdapter(self.options_listbox, height) 58 | self.pile.contents.append((self.dropdown_box, self.pile.options())) 59 | self.dropdown_visible = True 60 | self.pile.focus_position = 1 61 | self.options_walker.set_focus(0) 62 | 63 | def close_dropdown(self): 64 | if self.dropdown_visible: 65 | self.pile.contents.pop() # remove the dropdown_box 66 | self.dropdown_visible = False 67 | self.pile.focus_position = 0 68 | self.dropdown_box = None 69 | 70 | def keypress(self, size, key): 71 | if not self.dropdown_visible: 72 | if key == "enter": 73 | self.open_dropdown() 74 | return None 75 | return self.main_button.keypress(size, key) 76 | else: 77 | if key == "enter": 78 | focus_result = self.options_walker.get_focus() 79 | if focus_result is not None: 80 | focus_widget = focus_result[0] 81 | new_val = focus_widget.base_widget.text 82 | old_val = self.selected 83 | self.selected = new_val 84 | self.main_button.base_widget.set_text(f"{self.selected}") 85 | 86 | if old_val != new_val: 87 | self._emit('change', new_val) 88 | 89 | self.close_dropdown() 90 | return None 91 | return self.dropdown_box.keypress(size, key) 92 | 93 | def get_value(self): 94 | return self.selected 95 | 96 | class ValidationError(urwid.Text): 97 | def __init__(self, message=""): 98 | super().__init__(("error", message)) 99 | 100 | class FormField: 101 | def __init__(self, config_key, transform=None): 102 | self.config_key = config_key 103 | self.transform = transform or (lambda x: x) 104 | 105 | class FormEdit(Placeholder, FormField): 106 | def __init__(self, config_key, caption="", edit_text="", placeholder="", validation_types=None, transform=None, **kwargs): 107 | Placeholder.__init__(self, caption, edit_text, placeholder, **kwargs) 108 | FormField.__init__(self, config_key, transform) 109 | self.validation_types = validation_types or [] 110 | self.error_widget = urwid.Text("") 111 | self.error = None 112 | 113 | def get_value(self): 114 | return self.transform(self.edit_text.strip()) 115 | 116 | def validate(self): 117 | value = self.edit_text.strip() 118 | self.error = None 119 | 120 | for validation in self.validation_types: 121 | if validation == "required": 122 | if not value: 123 | self.error = "This field is required" 124 | break 125 | elif validation == "number": 126 | if value and not value.replace('-', '').replace('.', '').isdigit(): 127 | self.error = "This field must be a number" 128 | break 129 | elif validation == "float": 130 | try: 131 | if value: 132 | float(value) 133 | except ValueError: 134 | self.error = "This field must be decimal number" 135 | break 136 | 137 | self.error_widget.set_text(("error", self.error or "")) 138 | return self.error is None 139 | 140 | class FormCheckbox(urwid.CheckBox, FormField): 141 | def __init__(self, config_key, label="", state=False, validation_types=None, transform=None, **kwargs): 142 | urwid.CheckBox.__init__(self, label, state, **kwargs) 143 | FormField.__init__(self, config_key, transform) 144 | self.validation_types = validation_types or [] 145 | self.error_widget = urwid.Text("") 146 | self.error = None 147 | 148 | def get_value(self): 149 | return self.transform(self.get_state()) 150 | 151 | def validate(self): 152 | 153 | value = self.get_state() 154 | self.error = None 155 | 156 | for validation in self.validation_types: 157 | if validation == "required": 158 | if not value: 159 | self.error = "This field is required" 160 | break 161 | 162 | self.error_widget.set_text(("error", self.error or "")) 163 | return self.error is None 164 | 165 | class FormDropdown(Dropdown, FormField): 166 | signals = ['change'] 167 | 168 | def __init__(self, config_key, label, options, default=None, validation_types=None, transform=None): 169 | self.options = [str(opt) for opt in options] 170 | 171 | if default is not None: 172 | default_str = str(default) 173 | if default_str in self.options: 174 | default = default_str 175 | elif transform: 176 | try: 177 | default_transformed = transform(default_str) 178 | for opt in self.options: 179 | if transform(opt) == default_transformed: 180 | default = opt 181 | break 182 | except: 183 | default = self.options[0] 184 | else: 185 | default = self.options[0] 186 | else: 187 | default = self.options[0] 188 | 189 | Dropdown.__init__(self, label, self.options, default) 190 | FormField.__init__(self, config_key, transform) 191 | 192 | self.validation_types = validation_types or [] 193 | self.error_widget = urwid.Text("") 194 | self.error = None 195 | 196 | if hasattr(self, 'main_button'): 197 | self.main_button.base_widget.set_text(str(default)) 198 | 199 | def get_value(self): 200 | return self.transform(self.selected) 201 | 202 | def validate(self): 203 | value = self.get_value() 204 | self.error = None 205 | 206 | for validation in self.validation_types: 207 | if validation == "required": 208 | if not value: 209 | self.error = "This field is required" 210 | break 211 | 212 | self.error_widget.set_text(("error", self.error or "")) 213 | return self.error is None 214 | 215 | def open_dropdown(self): 216 | if not self.dropdown_visible: 217 | super().open_dropdown() 218 | try: 219 | current_index = self.options.index(self.selected) 220 | self.options_walker.set_focus(current_index) 221 | except ValueError: 222 | pass 223 | 224 | class FormMultiList(urwid.Pile, FormField): 225 | def __init__(self, config_key, placeholder="", validation_types=None, transform=None, **kwargs): 226 | self.entries = [] 227 | self.error_widget = urwid.Text("") 228 | self.error = None 229 | self.placeholder = placeholder 230 | self.validation_types = validation_types or [] 231 | 232 | first_entry = self.create_entry_row() 233 | self.entries.append(first_entry) 234 | 235 | self.add_button = urwid.Button("+ Add Another", on_press=self.add_entry) 236 | add_button_padded = urwid.Padding(self.add_button, left=2, right=2) 237 | 238 | pile_widgets = [first_entry, add_button_padded] 239 | urwid.Pile.__init__(self, pile_widgets) 240 | FormField.__init__(self, config_key, transform) 241 | 242 | def create_entry_row(self): 243 | edit = urwid.Edit("", "") 244 | entry_row = urwid.Columns([ 245 | ('weight', 1, edit), 246 | (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), 247 | ]) 248 | return entry_row 249 | 250 | def remove_entry(self, button, entry_row): 251 | if len(self.entries) > 1: 252 | self.entries.remove(entry_row) 253 | self.contents = [(w, self.options()) for w in self.get_pile_widgets()] 254 | 255 | def add_entry(self, button): 256 | new_entry = self.create_entry_row() 257 | self.entries.append(new_entry) 258 | 259 | self.contents = [(w, self.options()) for w in self.get_pile_widgets()] 260 | 261 | def get_pile_widgets(self): 262 | return self.entries + [urwid.Padding(self.add_button, left=2, right=2)] 263 | 264 | def get_value(self): 265 | values = [] 266 | for entry in self.entries: 267 | edit_widget = entry.contents[0][0] 268 | value = edit_widget.edit_text.strip() 269 | if value: 270 | values.append(value) 271 | return self.transform(values) 272 | 273 | def validate(self): 274 | values = self.get_value() 275 | self.error = None 276 | 277 | for validation in self.validation_types: 278 | if validation == "required" and not values: 279 | self.error = "At least one entry is required" 280 | break 281 | 282 | self.error_widget.set_text(("error", self.error or "")) 283 | return self.error is None 284 | 285 | 286 | class FormMultiTable(urwid.Pile, FormField): 287 | def __init__(self, config_key, fields, validation_types=None, transform=None, **kwargs): 288 | self.entries = [] 289 | self.fields = fields 290 | self.error_widget = urwid.Text("") 291 | self.error = None 292 | self.validation_types = validation_types or [] 293 | 294 | header_columns = [('weight', 3, urwid.Text(("list_focus", "Name")))] 295 | for field_key, field_config in self.fields.items(): 296 | header_columns.append(('weight', 2, urwid.Text(("list_focus", field_config.get("label", field_key))))) 297 | header_columns.append((4, urwid.Text(("list_focus", "")))) 298 | 299 | self.header_row = urwid.Columns(header_columns) 300 | 301 | first_entry = self.create_entry_row() 302 | self.entries.append(first_entry) 303 | 304 | self.add_button = urwid.Button("+ Add ", on_press=self.add_entry) 305 | add_button_padded = urwid.Padding(self.add_button, left=2, right=2) 306 | 307 | pile_widgets = [ 308 | self.header_row, 309 | urwid.Divider("-"), 310 | first_entry, 311 | add_button_padded 312 | ] 313 | 314 | urwid.Pile.__init__(self, pile_widgets) 315 | FormField.__init__(self, config_key, transform) 316 | 317 | def create_entry_row(self, name="", values=None): 318 | if values is None: 319 | values = {} 320 | 321 | name_edit = urwid.Edit("", name) 322 | 323 | columns = [('weight', 3, name_edit)] 324 | 325 | field_widgets = {} 326 | for field_key, field_config in self.fields.items(): 327 | field_value = values.get(field_key, "") 328 | 329 | if field_config.get("type") == "checkbox": 330 | widget = urwid.CheckBox("", state=bool(field_value)) 331 | elif field_config.get("type") == "dropdown": 332 | # TODO: dropdown in MultiTable 333 | widget = urwid.Edit("", str(field_value)) 334 | else: 335 | widget = urwid.Edit("", str(field_value)) 336 | 337 | field_widgets[field_key] = widget 338 | columns.append(('weight', 2, widget)) 339 | 340 | remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) 341 | columns.append((4, remove_button)) 342 | 343 | entry_row = urwid.Columns(columns) 344 | entry_row.name_edit = name_edit 345 | entry_row.field_widgets = field_widgets 346 | 347 | return entry_row 348 | 349 | def remove_entry(self, button, entry_row): 350 | if len(self.entries) > 1: 351 | self.entries.remove(entry_row) 352 | self.contents = [(w, self.options()) for w in self.get_pile_widgets()] 353 | 354 | def add_entry(self, button): 355 | new_entry = self.create_entry_row() 356 | self.entries.append(new_entry) 357 | 358 | self.contents = [(w, self.options()) for w in self.get_pile_widgets()] 359 | 360 | def get_pile_widgets(self): 361 | return [ 362 | self.header_row, 363 | urwid.Divider("-") 364 | ] + self.entries + [ 365 | urwid.Padding(self.add_button, left=2, right=2) 366 | ] 367 | 368 | def get_value(self): 369 | values = {} 370 | for entry in self.entries: 371 | name = entry.name_edit.edit_text.strip() 372 | if name: 373 | subinterface = {} 374 | subinterface["interface_enabled"] = True 375 | 376 | for field_key, widget in entry.field_widgets.items(): 377 | field_config = self.fields.get(field_key, {}) 378 | 379 | if hasattr(widget, "get_state"): 380 | value = widget.get_state() 381 | elif hasattr(widget, "edit_text"): 382 | value = widget.edit_text.strip() 383 | 384 | transform = field_config.get("transform") 385 | if transform and value: 386 | try: 387 | value = transform(value) 388 | except (ValueError, TypeError): 389 | value = "" 390 | 391 | if value: 392 | subinterface[field_key] = value 393 | 394 | values[name] = subinterface 395 | 396 | return self.transform(values) if self.transform else values 397 | 398 | def set_value(self, value): 399 | self.entries = [] 400 | 401 | if not value: 402 | self.entries.append(self.create_entry_row()) 403 | else: 404 | for name, config in value.items(): 405 | self.entries.append(self.create_entry_row(name=name, values=config)) 406 | 407 | self.contents = [(w, self.options()) for w in self.get_pile_widgets()] 408 | 409 | def validate(self): 410 | values = self.get_value() 411 | self.error = None 412 | 413 | for validation in self.validation_types: 414 | if validation == "required" and not values: 415 | self.error = "At least one subinterface is required" 416 | break 417 | 418 | self.error_widget.set_text(("error", self.error or "")) 419 | return self.error is None 420 | 421 | 422 | class FormKeyValuePairs(urwid.Pile, FormField): 423 | def __init__(self, config_key, validation_types=None, transform=None, **kwargs): 424 | self.entries = [] 425 | self.error_widget = urwid.Text("") 426 | self.error = None 427 | self.validation_types = validation_types or [] 428 | 429 | header_columns = [ 430 | ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Key"), "multitable_header")), 431 | ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Value"), "multitable_header")), 432 | (4, urwid.AttrMap(urwid.Text("Action"), "multitable_header")) 433 | ] 434 | 435 | self.header_row = urwid.AttrMap(urwid.Columns(header_columns), "multitable_header") 436 | 437 | first_entry = self.create_entry_row() 438 | self.entries.append(first_entry) 439 | 440 | self.add_button = urwid.Button("+ Add Parameter", on_press=self.add_entry) 441 | add_button_padded = urwid.Padding(self.add_button, left=2, right=2) 442 | 443 | pile_widgets = [ 444 | self.header_row, 445 | urwid.Divider("-"), 446 | first_entry, 447 | add_button_padded 448 | ] 449 | 450 | urwid.Pile.__init__(self, pile_widgets) 451 | FormField.__init__(self, config_key, transform) 452 | 453 | def create_entry_row(self, key="", value=""): 454 | key_edit = urwid.Edit("", key) 455 | value_edit = urwid.Edit("", value) 456 | 457 | remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) 458 | 459 | entry_row = urwid.Columns([ 460 | ('weight', 1, key_edit), 461 | ('weight', 1, value_edit), 462 | (4, remove_button) 463 | ]) 464 | 465 | entry_row.key_edit = key_edit 466 | entry_row.value_edit = value_edit 467 | 468 | return entry_row 469 | 470 | def remove_entry(self, button, entry_row): 471 | if len(self.entries) > 1: 472 | self.entries.remove(entry_row) 473 | self.contents = [(w, self.options()) for w in self.get_pile_widgets()] 474 | 475 | def add_entry(self, button): 476 | new_entry = self.create_entry_row() 477 | self.entries.append(new_entry) 478 | 479 | self.contents = [(w, self.options()) for w in self.get_pile_widgets()] 480 | 481 | def get_pile_widgets(self): 482 | return [ 483 | self.header_row, 484 | urwid.Divider("-") 485 | ] + self.entries + [ 486 | urwid.Padding(self.add_button, left=2, right=2) 487 | ] 488 | 489 | def get_value(self): 490 | values = {} 491 | for entry in self.entries: 492 | key = entry.key_edit.edit_text.strip() 493 | value = entry.value_edit.edit_text.strip() 494 | 495 | if key: 496 | if value.isdigit(): 497 | values[key] = int(value) 498 | elif value.replace('.', '', 1).isdigit() and value.count('.') <= 1: 499 | values[key] = float(value) 500 | elif value.lower() == 'true': 501 | values[key] = True 502 | elif value.lower() == 'false': 503 | values[key] = False 504 | else: 505 | values[key] = value 506 | 507 | return self.transform(values) if self.transform else values 508 | 509 | def set_value(self, value): 510 | self.entries = [] 511 | 512 | if not value or not isinstance(value, dict): 513 | self.entries.append(self.create_entry_row()) 514 | else: 515 | for key, val in value.items(): 516 | self.entries.append(self.create_entry_row(key=key, value=str(val))) 517 | 518 | self.contents = [(w, self.options()) for w in self.get_pile_widgets()] 519 | 520 | def validate(self): 521 | values = self.get_value() 522 | self.error = None 523 | 524 | keys = [entry.key_edit.edit_text.strip() for entry in self.entries 525 | if entry.key_edit.edit_text.strip()] 526 | if len(keys) != len(set(keys)): 527 | self.error = "Duplicate keys are not allowed" 528 | self.error_widget.set_text(("error", self.error)) 529 | return False 530 | 531 | for validation in self.validation_types: 532 | if validation == "required" and not values: 533 | self.error = "Atleast one parameter is required" 534 | break 535 | 536 | self.error_widget.set_text(("error", self.error or "")) 537 | return self.error is None -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 AFoeee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/__init__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | from .assisting_modules.modifier_key import MODIFIER_KEY 6 | from .widgets.date_picker import DatePicker 7 | from .widgets.indicative_listbox import IndicativeListBox 8 | from .widgets.integer_picker import IntegerPicker 9 | from .widgets.message_dialog import MessageDialog 10 | from .widgets.selectable_row import SelectableRow -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/assisting_modules/__init__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/assisting_modules/modifier_key.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | import enum 6 | 7 | 8 | class MODIFIER_KEY(enum.Enum): 9 | """Represents modifier keys such as 'ctrl', 'shift' and so on. 10 | Not every combination of modifier and input is useful.""" 11 | 12 | NONE = "" 13 | SHIFT = "shift" 14 | ALT = "meta" 15 | CTRL = "ctrl" 16 | SHIFT_ALT = "shift meta" 17 | SHIFT_CTRL = "shift ctrl" 18 | ALT_CTRL = "meta ctrl" 19 | SHIFT_ALT_CTRL = "shift meta ctrl" 20 | 21 | def append_to(self, text, separator=" "): 22 | return (text + separator + self.value) if (self != self.__class__.NONE) else text 23 | 24 | def prepend_to(self, text, separator=" "): 25 | return (self.value + separator + text) if (self != self.__class__.NONE) else text 26 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/assisting_modules/useful_functions.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """A non-thematic collection of useful functions.""" 5 | 6 | 7 | def recursively_replace(original, replacements, include_original_keys=False): 8 | """Clones an iterable and recursively replaces specific values.""" 9 | 10 | # If this function would be called recursively, the parameters 'replacements' and 'include_original_keys' would have to be 11 | # passed each time. Therefore, a helper function with a reduced parameter list is used for the recursion, which nevertheless 12 | # can access the said parameters. 13 | 14 | def _recursion_helper(obj): 15 | #Determine if the object should be replaced. If it is not hashable, the search will throw a TypeError. 16 | try: 17 | if obj in replacements: 18 | return replacements[obj] 19 | except TypeError: 20 | pass 21 | 22 | # An iterable is recursively processed depending on its class. 23 | if hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes, bytearray)): 24 | if isinstance(obj, dict): 25 | contents = {} 26 | 27 | for key, val in obj.items(): 28 | new_key = _recursion_helper(key) if include_original_keys else key 29 | new_val = _recursion_helper(val) 30 | 31 | contents[new_key] = new_val 32 | 33 | else: 34 | contents = [] 35 | 36 | for element in obj: 37 | new_element = _recursion_helper(element) 38 | 39 | contents.append(new_element) 40 | 41 | # Use the same class as the original. 42 | return obj.__class__(contents) 43 | 44 | # If it is not replaced and it is not an iterable, return it. 45 | return obj 46 | 47 | return _recursion_helper(original) 48 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/widgets/date_picker.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | from ..assisting_modules.modifier_key import MODIFIER_KEY # pylint: disable=unused-import 6 | from ..assisting_modules.useful_functions import recursively_replace 7 | from .indicative_listbox import IndicativeListBox 8 | from .integer_picker import IntegerPicker 9 | from .selectable_row import SelectableRow 10 | 11 | import calendar 12 | import datetime 13 | import enum 14 | import urwid 15 | 16 | 17 | class DatePicker(urwid.WidgetWrap): 18 | """Serves as a selector for dates.""" 19 | 20 | _TYPE_ERR_MSG = "type {} was expected for {}, but found: {}." 21 | _VALUE_ERR_MSG = "unrecognized value: {}." 22 | 23 | # These values are interpreted during the creation of the list items for the day picker. 24 | class DAY_FORMAT(enum.Enum): 25 | DAY_OF_MONTH = 1 26 | DAY_OF_MONTH_TWO_DIGIT = 2 27 | WEEKDAY = 3 28 | 29 | # These values are interpreted during the initialization and define the arrangement of the pickers. 30 | class PICKER(enum.Enum): 31 | YEAR = 1 32 | MONTH = 2 33 | DAY = 3 34 | 35 | # Specifies which dates are selectable. 36 | class RANGE(enum.Enum): 37 | ALL = 1 38 | ONLY_PAST = 2 39 | ONLY_FUTURE = 3 40 | 41 | def __init__(self, initial_date=datetime.date.today(), *, date_range=RANGE.ALL, month_names=calendar.month_name, day_names=calendar.day_abbr, 42 | day_format=(DAY_FORMAT.WEEKDAY, DAY_FORMAT.DAY_OF_MONTH), columns=(PICKER.DAY, PICKER.MONTH, PICKER.YEAR), 43 | modifier_key=MODIFIER_KEY.CTRL, return_unused_navigation_input=False, year_jump_len=50, space_between=2, 44 | min_width_each_picker=9, year_align="center", month_align="center", day_align="center", topBar_align="center", 45 | topBar_endCovered_prop=("▲", None, None), topBar_endExposed_prop=("───", None, None), bottomBar_align="center", 46 | bottomBar_endCovered_prop=("▼", None, None), bottomBar_endExposed_prop=("───", None, None), highlight_prop=(None, None)): 47 | assert (type(date_range) == self.__class__.RANGE), self.__class__._TYPE_ERR_MSG.format("", 48 | "'date_range'", 49 | type(date_range)) 50 | 51 | for df in day_format: 52 | assert (type(df) == self.__class__.DAY_FORMAT), self.__class__._TYPE_ERR_MSG.format("", 53 | "all elements of 'day_format'", 54 | type(df)) 55 | 56 | # Relevant for 'RANGE.ONLY_PAST' and 'RANGE.ONLY_FUTURE' to limit the respective choices. 57 | self._initial_year = initial_date.year 58 | self._initial_month = initial_date.month 59 | self._initial_day = initial_date.day 60 | 61 | # The date pool can be limited, so that only past or future dates are selectable. The initial date is included in the 62 | # pool. 63 | self._date_range = date_range 64 | 65 | # The presentation of months and weekdays can be changed by passing alternative values (e.g. abbreviations or numerical 66 | # representations). 67 | self._month_names = month_names 68 | self._day_names = day_names 69 | 70 | # Since there are different needs regarding the appearance of the day picker, an iterable can be passed, which allows a 71 | # customization of the presentation. 72 | self._day_format = day_format 73 | 74 | # Specifies the text alignment of the individual pickers. The year alignment is passed directly to the year picker. 75 | self._month_align = month_align 76 | self._day_align = day_align 77 | 78 | # The default style of a list entry. Since only one list entry will be visible at a time and there is also off focus 79 | # highlighting, the normal value can be 'None' (it is never shown). 80 | self._item_attr = (None, highlight_prop[0]) 81 | 82 | # A full list of months. (From 'January' to 'December'.) 83 | self._month_list = self._generate_months() 84 | 85 | # Set the respective values depending on the date range. 86 | min_year = datetime.MINYEAR 87 | max_year = datetime.MAXYEAR 88 | 89 | month_position = self._initial_month - 1 90 | day_position = self._initial_day - 1 91 | 92 | if date_range == self.__class__.RANGE.ALL: 93 | initial_month_list = self._month_list 94 | 95 | elif date_range == self.__class__.RANGE.ONLY_PAST: 96 | max_year = self._initial_year 97 | 98 | # The months of the very last year may be shorten. 99 | self._shortened_month_list = self._generate_months(end=self._initial_month) 100 | initial_month_list = self._shortened_month_list 101 | 102 | elif date_range == self.__class__.RANGE.ONLY_FUTURE: 103 | min_year = self._initial_year 104 | 105 | # The months of the very first year may be shorten. 106 | self._shortened_month_list = self._generate_months(start=self._initial_month) 107 | initial_month_list = self._shortened_month_list 108 | 109 | # The list may not start at 1 but some other day of month, therefore use the first list item. 110 | month_position = 0 111 | day_position = 0 112 | 113 | else: 114 | raise ValueError(self.__class__._VALUE_ERR_MSG.format(date_range)) 115 | 116 | # Create pickers. 117 | self._year_picker = IntegerPicker(self._initial_year, 118 | min_v=min_year, 119 | max_v=max_year, 120 | jump_len=year_jump_len, 121 | on_selection_change=self._year_has_changed, 122 | modifier_key=modifier_key, 123 | return_unused_navigation_input=return_unused_navigation_input, 124 | topBar_align=topBar_align, 125 | topBar_endCovered_prop=topBar_endCovered_prop, 126 | topBar_endExposed_prop=topBar_endExposed_prop, 127 | bottomBar_align=bottomBar_align, 128 | bottomBar_endCovered_prop=bottomBar_endCovered_prop, 129 | bottomBar_endExposed_prop=bottomBar_endExposed_prop, 130 | display_syntax="{:04}", 131 | display_align=year_align, 132 | display_prop=highlight_prop) 133 | 134 | self._month_picker = IndicativeListBox(initial_month_list, 135 | position=month_position, 136 | on_selection_change=self._month_has_changed, 137 | modifier_key=modifier_key, 138 | return_unused_navigation_input=return_unused_navigation_input, 139 | topBar_align=topBar_align, 140 | topBar_endCovered_prop=topBar_endCovered_prop, 141 | topBar_endExposed_prop=topBar_endExposed_prop, 142 | bottomBar_align=bottomBar_align, 143 | bottomBar_endCovered_prop=bottomBar_endCovered_prop, 144 | bottomBar_endExposed_prop=bottomBar_endExposed_prop, 145 | highlight_offFocus=highlight_prop[1]) 146 | 147 | self._day_picker = IndicativeListBox(self._generate_days(self._initial_year, self._initial_month), 148 | position=day_position, 149 | modifier_key=modifier_key, 150 | return_unused_navigation_input=return_unused_navigation_input, 151 | topBar_align=topBar_align, 152 | topBar_endCovered_prop=topBar_endCovered_prop, 153 | topBar_endExposed_prop=topBar_endExposed_prop, 154 | bottomBar_align=bottomBar_align, 155 | bottomBar_endCovered_prop=bottomBar_endCovered_prop, 156 | bottomBar_endExposed_prop=bottomBar_endExposed_prop, 157 | highlight_offFocus=highlight_prop[1]) 158 | 159 | # To mimic a selection widget, 'IndicativeListbox' is wrapped in a 'urwid.BoxAdapter'. Since two rows are used for the bars, 160 | # size 3 makes exactly one list item visible. 161 | boxed_month_picker = urwid.BoxAdapter(self._month_picker, 3) 162 | boxed_day_picker = urwid.BoxAdapter(self._day_picker, 3) 163 | 164 | # Replace the 'DatePicker.PICKER' elements of the parameter 'columns' with the corresponding pickers. 165 | replacements = {self.__class__.PICKER.YEAR : self._year_picker, 166 | self.__class__.PICKER.MONTH : boxed_month_picker, 167 | self.__class__.PICKER.DAY : boxed_day_picker} 168 | 169 | columns = recursively_replace(columns, replacements) 170 | 171 | # wrap 'urwid.Columns' 172 | super().__init__(urwid.Columns(columns, 173 | min_width=min_width_each_picker, 174 | dividechars=space_between)) 175 | 176 | def __repr__(self): 177 | return "{}(date='{}', date_range='{}', initial_date='{}-{:02}-{:02}', selected_date='{}')".format(self.__class__.__name__, 178 | self.get_date(), 179 | self._date_range, 180 | self._initial_year, 181 | self._initial_month, 182 | self._initial_day, 183 | self.get_date()) 184 | 185 | # The returned widget is used for all list entries. 186 | def _generate_item(self, cols, *, align="center"): 187 | return urwid.AttrMap(SelectableRow(cols, align=align), 188 | self._item_attr[0], 189 | self._item_attr[1]) 190 | 191 | def _generate_months(self, start=1, end=12): 192 | months = [] 193 | 194 | for month in range(start, end+1): 195 | item = self._generate_item([self._month_names[month]], align=self._month_align) 196 | 197 | # Add a new instance variable which holds the numerical value. This makes it easier to get the displayed value. 198 | item._numerical_value = month 199 | 200 | months.append(item) 201 | 202 | return months 203 | 204 | def _generate_days(self, year, month): 205 | start = 1 206 | weekday, end = calendar.monthrange(year, month) # end is included in the range 207 | 208 | # If the date range is 'ONLY_PAST', the last month does not end as usual but on the specified day. 209 | if (self._date_range == self.__class__.RANGE.ONLY_PAST) and (year == self._initial_year) and (month == self._initial_month): 210 | end = self._initial_day 211 | 212 | # If the date range is 'ONLY_FUTURE', the first month does not start as usual but on the specified day. 213 | elif (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year) and (month == self._initial_month): 214 | start = self._initial_day 215 | weekday = calendar.weekday(year, month, start) 216 | 217 | days = [] 218 | 219 | for day in range(start, end+1): 220 | cols = [] 221 | 222 | # The 'DatePicker.DAY_FORMAT' elements of the iterable are translated into columns of the day picker. This allows the 223 | # presentation to be customized. 224 | for df in self._day_format: 225 | if df == self.__class__.DAY_FORMAT.DAY_OF_MONTH: 226 | cols.append(str(day)) 227 | 228 | elif df == self.__class__.DAY_FORMAT.DAY_OF_MONTH_TWO_DIGIT: 229 | cols.append(str(day).zfill(2)) 230 | 231 | elif df == self.__class__.DAY_FORMAT.WEEKDAY: 232 | cols.append(self._day_names[weekday]) 233 | 234 | else: 235 | raise ValueError(self.__class__._VALUE_ERR_MSG.format(df)) 236 | 237 | item = self._generate_item(cols, align=self._day_align) 238 | 239 | # Add a new instance variable which holds the numerical value. This makes it easier to get the displayed value. 240 | item._numerical_value = day 241 | 242 | # Keeps track of the weekday. 243 | weekday = (weekday + 1) if (weekday < 6) else 0 244 | 245 | days.append(item) 246 | 247 | return days 248 | 249 | def _year_has_changed(self, previous_year, current_year): 250 | month_position_before_change = self._month_picker.get_selected_position() 251 | 252 | # Since there are no years in 'RANGE.ALL' that do not have the full month range, the body never needs to be changed after 253 | # initialization. 254 | if self._date_range != self.__class__.RANGE.ALL: 255 | # 'None' stands for trying to keep the old value. 256 | provisional_position = None 257 | 258 | # If the previous year was the specified year, the shortened month range must be replaced by the complete one. If this 259 | # shortened month range does not begin at 'January', then the difference must be taken into account. 260 | if previous_year == self._initial_year: 261 | if self._date_range == self.__class__.RANGE.ONLY_FUTURE: 262 | provisional_position = self._month_picker.get_selected_item()._numerical_value - 1 263 | 264 | self._month_picker.set_body(self._month_list, 265 | alternative_position=provisional_position) 266 | 267 | # If the specified year is selected, the full month range must be replaced with the shortened one. 268 | elif current_year == self._initial_year: 269 | if self._date_range == self.__class__.RANGE.ONLY_FUTURE: 270 | provisional_position = month_position_before_change - (self._initial_month - 1) 271 | 272 | self._month_picker.set_body(self._shortened_month_list, 273 | alternative_position=provisional_position) 274 | 275 | # Since the month has changed, the corresponding method is called. 276 | self._month_has_changed(month_position_before_change, 277 | self._month_picker.get_selected_position(), 278 | previous_year=previous_year) 279 | 280 | def _month_has_changed(self, previous_position, current_position, *, previous_year=None): 281 | # 'None' stands for trying to keep the old value. 282 | provisional_position = None 283 | 284 | current_year = self._year_picker.get_value() 285 | 286 | # Out of range values are changed by 'IndicativeListBox' to the nearest valid values. 287 | 288 | # If the date range is 'ONLY_FUTURE', it may be that a month does not start on the first day. In this case, the value must 289 | # be changed to reflect this difference. 290 | if self._date_range == self.__class__.RANGE.ONLY_FUTURE: 291 | # If the current or previous year is the specified year and the month was the specified month, the value has an offset 292 | # of the specified day. Therefore the deposited numerical value is used. ('-1' because it's an index.) 293 | if ((current_year == self._initial_year) or (previous_year == self._initial_year)) and (previous_position == 0): 294 | provisional_position = self._day_picker.get_selected_item()._numerical_value - 1 295 | 296 | # If the current year is the specified year and the current month is the specified month, the month begins not with 297 | # the first day, but with the specified day. 298 | elif (current_year == self._initial_year) and (current_position == 0): 299 | provisional_position = self._day_picker.get_selected_position() - (self._initial_day - 1) 300 | 301 | self._day_picker.set_body(self._generate_days(current_year, 302 | self._month_picker.get_selected_item()._numerical_value), 303 | alternative_position=provisional_position) 304 | 305 | def get_date(self): 306 | return datetime.date(self._year_picker.get_value(), 307 | self._month_picker.get_selected_item()._numerical_value, 308 | self._day_picker.get_selected_item()._numerical_value) 309 | 310 | def set_date(self, date): 311 | # If the date range is limited, test for the new limit. 312 | if self._date_range != self.__class__.RANGE.ALL: 313 | limit = datetime.date(self._initial_year, self._initial_month, self._initial_day) 314 | 315 | if (self._date_range == self.__class__.RANGE.ONLY_PAST) and (date > limit): 316 | raise ValueError("The passed date is outside the upper bound of the date range.") 317 | 318 | elif (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (date < limit): 319 | raise ValueError("The passed date is outside the lower bound of the date range.") 320 | 321 | year = date.year 322 | month = date.month 323 | day = date.day 324 | 325 | # Set the new values, if needed. 326 | if year != self._year_picker.get_value(): 327 | self._year_picker.set_value(year) 328 | 329 | if month != self._month_picker.get_selected_item()._numerical_value: 330 | month_position = month - 1 # '-1' because it's an index. 331 | 332 | if (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year): 333 | # If the value should be negative, the behavior of 'IndicativeListBox' shows effect and position 0 is selected. 334 | month_position = month_position - (self._initial_month - 1) 335 | 336 | self._month_picker.select_item(month_position) 337 | 338 | if day != self._day_picker.get_selected_item()._numerical_value: 339 | day_position = day - 1 # '-1' because it's an index. 340 | 341 | if (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year) and (month == self._initial_month): 342 | day_position = day_position - (self._initial_day - 1) 343 | 344 | self._day_picker.select_item(day_position) 345 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/widgets/integer_picker.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | from ..assisting_modules.modifier_key import MODIFIER_KEY # pylint: disable=unused-import 6 | from .selectable_row import SelectableRow 7 | 8 | import sys # pylint: disable=unused-import 9 | import urwid 10 | 11 | 12 | class IntegerPicker(urwid.WidgetWrap): 13 | """Serves as a selector for integer numbers.""" 14 | 15 | def __init__(self, value, *, min_v=(-sys.maxsize - 1), max_v=sys.maxsize, step_len=1, jump_len=100, on_selection_change=None, 16 | initialization_is_selection_change=False, modifier_key=MODIFIER_KEY.NONE, ascending=True, 17 | return_unused_navigation_input=True, topBar_align="center", topBar_endCovered_prop=("▲", None, None), 18 | topBar_endExposed_prop=("───", None, None), bottomBar_align="center", bottomBar_endCovered_prop=("▼", None, None), 19 | bottomBar_endExposed_prop=("───", None, None), display_syntax="{}", display_align="center", display_prop=(None, None)): 20 | assert (min_v <= max_v), "'min_v' must be less than or equal to 'max_v'." 21 | 22 | assert (min_v <= value <= max_v), "'min_v <= value <= max_v' must be True." 23 | 24 | self._value = value 25 | 26 | self._minimum = min_v 27 | self._maximum = max_v 28 | 29 | # Specifies how far to move in the respective direction when the keys 'up/down' are pressed. 30 | self._step_len = step_len 31 | 32 | # Specifies how far to jump in the respective direction when the keys 'page up/down' or the mouse events 'wheel up/down' 33 | # are passed. 34 | self._jump_len = jump_len 35 | 36 | # A hook which is triggered when the value changes. 37 | self.on_selection_change = on_selection_change 38 | 39 | # 'MODIFIER_KEY' changes the behavior, so that the widget responds only to modified input. ('up' => 'ctrl up') 40 | self._modifier_key = modifier_key 41 | 42 | # Specifies whether moving upwards represents a decrease or an increase of the value. 43 | self._ascending = ascending 44 | 45 | # If the minimum has been reached and an attempt is made to select an even smaller value, the input is normally not 46 | # swallowed by the widget, but passed on so that other widgets can interpret it. This may result in transferring the focus. 47 | self._return_unused_navigation_input = return_unused_navigation_input 48 | 49 | # The bars are just 'urwid.Text' widgets. 50 | self._top_bar = urwid.AttrMap(urwid.Text("", topBar_align), 51 | None) 52 | 53 | self._bottom_bar = urwid.AttrMap(urwid.Text("", bottomBar_align), 54 | None) 55 | 56 | # During the initialization of 'urwid.AttrMap', the value can be passed as non-dict. After initializing, its value can be 57 | # manipulated by passing a dict. The dicts I create below will be used later to change the appearance of the widgets. 58 | self._topBar_endCovered_markup = topBar_endCovered_prop[0] 59 | self._topBar_endCovered_focus = {None:topBar_endCovered_prop[1]} 60 | self._topBar_endCovered_offFocus = {None:topBar_endCovered_prop[2]} 61 | 62 | self._topBar_endExposed_markup = topBar_endExposed_prop[0] 63 | self._topBar_endExposed_focus = {None:topBar_endExposed_prop[1]} 64 | self._topBar_endExposed_offFocus = {None:topBar_endExposed_prop[2]} 65 | 66 | self._bottomBar_endCovered_markup = bottomBar_endCovered_prop[0] 67 | self._bottomBar_endCovered_focus = {None:bottomBar_endCovered_prop[1]} 68 | self._bottomBar_endCovered_offFocus = {None:bottomBar_endCovered_prop[2]} 69 | 70 | self._bottomBar_endExposed_markup = bottomBar_endExposed_prop[0] 71 | self._bottomBar_endExposed_focus = {None:bottomBar_endExposed_prop[1]} 72 | self._bottomBar_endExposed_offFocus = {None:bottomBar_endExposed_prop[2]} 73 | 74 | # Format the number before displaying it. That way it is easier to read. 75 | self._display_syntax = display_syntax 76 | 77 | # The current value is displayed via this widget. 78 | self._display = SelectableRow([display_syntax.format(value)], 79 | align=display_align) 80 | 81 | display_attr = urwid.AttrMap(self._display, 82 | display_prop[1], 83 | display_prop[0]) 84 | 85 | # wrap 'urwid.Pile' 86 | super().__init__(urwid.Pile([self._top_bar, 87 | display_attr, 88 | self._bottom_bar])) 89 | 90 | # Is 'on_selection_change' triggered during the initialization? 91 | if initialization_is_selection_change and (on_selection_change is not None): 92 | on_selection_change(None, value) 93 | 94 | def __repr__(self): 95 | return "{}(value='{}', min_v='{}', max_v='{}', ascending='{}')".format(self.__class__.__name__, 96 | self._value, 97 | self._minimum, 98 | self._maximum, 99 | self._ascending) 100 | 101 | def render(self, size, focus=False): 102 | # Changes the appearance of the bar at the top depending on whether the upper limit is reached. 103 | if self._value == (self._minimum if self._ascending else self._maximum): 104 | self._top_bar.original_widget.set_text(self._topBar_endExposed_markup) 105 | self._top_bar.set_attr_map(self._topBar_endExposed_focus 106 | if focus else self._topBar_endExposed_offFocus) 107 | else: 108 | self._top_bar.original_widget.set_text(self._topBar_endCovered_markup) 109 | self._top_bar.set_attr_map(self._topBar_endCovered_focus 110 | if focus else self._topBar_endCovered_offFocus) 111 | 112 | # Changes the appearance of the bar at the bottom depending on whether the lower limit is reached. 113 | if self._value == (self._maximum if self._ascending else self._minimum): 114 | self._bottom_bar.original_widget.set_text(self._bottomBar_endExposed_markup) 115 | self._bottom_bar.set_attr_map(self._bottomBar_endExposed_focus 116 | if focus else self._bottomBar_endExposed_offFocus) 117 | else: 118 | self._bottom_bar.original_widget.set_text(self._bottomBar_endCovered_markup) 119 | self._bottom_bar.set_attr_map(self._bottomBar_endCovered_focus 120 | if focus else self._bottomBar_endCovered_offFocus) 121 | 122 | return super().render(size, focus=focus) 123 | 124 | def keypress(self, size, key): 125 | # A keystroke is changed to a modified one ('up' => 'ctrl up'). This prevents the widget from responding when the arrows 126 | # keys are used to navigate between widgets. That way it can be used in a 'urwid.Pile' or similar. 127 | if key == self._modifier_key.prepend_to("up"): 128 | successful = self._change_value(-self._step_len) 129 | 130 | elif key == self._modifier_key.prepend_to("down"): 131 | successful = self._change_value(self._step_len) 132 | 133 | elif key == self._modifier_key.prepend_to("page up"): 134 | successful = self._change_value(-self._jump_len) 135 | 136 | elif key == self._modifier_key.prepend_to("page down"): 137 | successful = self._change_value(self._jump_len) 138 | 139 | elif key == self._modifier_key.prepend_to("home"): 140 | successful = self._change_value(float("-inf")) 141 | 142 | elif key == self._modifier_key.prepend_to("end"): 143 | successful = self._change_value(float("inf")) 144 | 145 | else: 146 | successful = False 147 | 148 | return key if not successful else None 149 | 150 | def mouse_event(self, size, event, button, col, row, focus): 151 | if focus: 152 | # An event is changed to a modified one ('mouse press' => 'ctrl mouse press'). This prevents the original widget from 153 | # responding when mouse buttons are also used to navigate between widgets. 154 | if event == self._modifier_key.prepend_to("mouse press"): 155 | # mousewheel up 156 | if button == 4.0: 157 | result = self._change_value(-self._jump_len) 158 | return result if self._return_unused_navigation_input else True 159 | 160 | # mousewheel down 161 | elif button == 5.0: 162 | result = self._change_value(self._jump_len) 163 | return result if self._return_unused_navigation_input else True 164 | 165 | return False 166 | 167 | # This method tries to change the value depending on the desired arrangement and returns True if this change was successful. 168 | def _change_value(self, summand): 169 | value_before_input = self._value 170 | 171 | if self._ascending: 172 | new_value = self._value + summand 173 | 174 | if summand < 0: 175 | # If the corresponding limit has already been reached, then determine whether the unused input should be 176 | # returned or swallowed. 177 | if self._value == self._minimum: 178 | return not self._return_unused_navigation_input 179 | 180 | # If the new value stays within the permitted range, use it. 181 | elif new_value > self._minimum: 182 | self._value = new_value 183 | 184 | # The permitted range would be exceeded, so the limit is set instead. 185 | else: 186 | self._value = self._minimum 187 | 188 | elif summand > 0: 189 | if self._value == self._maximum: 190 | return not self._return_unused_navigation_input 191 | 192 | elif new_value < self._maximum: 193 | self._value = new_value 194 | 195 | else: 196 | self._value = self._maximum 197 | else: 198 | new_value = self._value - summand 199 | 200 | if summand < 0: 201 | if self._value == self._maximum: 202 | return not self._return_unused_navigation_input 203 | 204 | elif new_value < self._maximum: 205 | self._value = new_value 206 | 207 | else: 208 | self._value = self._maximum 209 | 210 | elif summand > 0: 211 | if self._value == self._minimum: 212 | return not self._return_unused_navigation_input 213 | 214 | elif new_value > self._minimum: 215 | self._value = new_value 216 | 217 | else: 218 | self._value = self._minimum 219 | 220 | # Update the displayed value. 221 | self._display.set_contents([self._display_syntax.format(self._value)]) 222 | 223 | # If the value has changed, execute the hook (if existing). 224 | if (value_before_input != self._value) and (self.on_selection_change is not None): 225 | self.on_selection_change(value_before_input, 226 | self._value) 227 | 228 | return True 229 | 230 | def get_value(self): 231 | return self._value 232 | 233 | def set_value(self, value): 234 | if not (self._minimum <= value <= self._maximum): 235 | raise ValueError("'minimum <= value <= maximum' must be True.") 236 | 237 | if value != self._value: 238 | value_before_change = self._value 239 | self._value = value 240 | 241 | # Update the displayed value. 242 | self._display.set_contents([self._display_syntax.format(self._value)]) 243 | 244 | # Execute the hook (if existing). 245 | if (self.on_selection_change is not None): 246 | self.on_selection_change(value_before_change, self._value) 247 | 248 | def set_to_minimum(self): 249 | self.set_value(self._minimum) 250 | 251 | def set_to_maximum(self): 252 | self.set_value(self._maximum) 253 | 254 | def set_minimum(self, new_min): 255 | if new_min > self._maximum: 256 | raise ValueError("'new_min' must be less than or equal to the maximum value.") 257 | 258 | self._minimum = new_min 259 | 260 | if self._value < new_min: 261 | self.set_to_minimum() 262 | 263 | def set_maximum(self, new_max): 264 | if new_max < self._minimum: 265 | raise ValueError("'new_max' must be greater than or equal to the minimum value.") 266 | 267 | self._maximum = new_max 268 | 269 | if self._value > new_max: 270 | self.set_to_maximum() 271 | 272 | def minimum_is_selected(self): 273 | return self._value == self._minimum 274 | 275 | def maximum_is_selected(self): 276 | return self._value == self._maximum 277 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/widgets/message_dialog.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | import urwid 6 | 7 | 8 | class MessageDialog(urwid.WidgetWrap): 9 | """Wraps 'urwid.Overlay' to show a message and expects a reaction from the user.""" 10 | 11 | def __init__(self, contents, btns, overlay_size, *, contents_align="left", space_between_btns=2, title="", title_align="center", 12 | background=urwid.SolidFill("#"), overlay_align=("center", "middle"), overlay_min_size=(None, None), left=0, right=0, 13 | top=0, bottom=0): 14 | # Message part 15 | texts = [urwid.Text(content, align=contents_align) 16 | for content in contents] 17 | 18 | # Lower part 19 | lower_part = [urwid.Divider(" "), 20 | urwid.Columns(btns, dividechars=space_between_btns)] 21 | 22 | # frame 23 | line_box = urwid.LineBox(urwid.Pile(texts + lower_part), 24 | title=title, 25 | title_align=title_align) 26 | 27 | # Wrap 'urwid.Overlay' 28 | super().__init__(urwid.Overlay(urwid.Filler(line_box), 29 | background, 30 | overlay_align[0], 31 | overlay_size[0], 32 | overlay_align[1], 33 | overlay_size[1], 34 | min_width=overlay_min_size[0], 35 | min_height=overlay_min_size[1], 36 | left=left, 37 | right=right, 38 | top=top, 39 | bottom=bottom)) 40 | -------------------------------------------------------------------------------- /nomadnet/vendor/additional_urwid_widgets/widgets/selectable_row.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | import urwid 6 | 7 | 8 | class SelectableRow(urwid.WidgetWrap): 9 | """Wraps 'urwid.Columns' to make it selectable. 10 | This class has been slightly modified, but essentially corresponds to this class posted on stackoverflow.com: 11 | https://stackoverflow.com/questions/52106244/how-do-you-combine-multiple-tui-forms-to-write-more-complex-applications#answer-52174629""" 12 | 13 | def __init__(self, contents, *, align="left", on_select=None, space_between=2): 14 | # A list-like object, where each element represents the value of a column. 15 | self.contents = contents 16 | 17 | self._columns = urwid.Columns([urwid.Text(c, align=align) for c in contents], 18 | dividechars=space_between) 19 | 20 | # Wrap 'urwid.Columns'. 21 | super().__init__(self._columns) 22 | 23 | # A hook which defines the behavior that is executed when a specified key is pressed. 24 | self.on_select = on_select 25 | 26 | def __repr__(self): 27 | return "{}(contents='{}')".format(self.__class__.__name__, 28 | self.contents) 29 | 30 | def selectable(self): 31 | return True 32 | 33 | def keypress(self, size, key): 34 | if (key == "enter") and (self.on_select is not None): 35 | self.on_select(self) 36 | key = None 37 | 38 | return key 39 | 40 | def set_contents(self, contents): 41 | # Update the list record inplace... 42 | self.contents[:] = contents 43 | 44 | # ... and update the displayed items. 45 | for t, (w, _) in zip(contents, self._columns.contents): 46 | w.set_text(t) 47 | 48 | -------------------------------------------------------------------------------- /nomadnet/vendor/quotes.py: -------------------------------------------------------------------------------- 1 | quotes = [ 2 | ("I want the wisdom that wise men revere. I want more.", "Faithless"), 3 | ("That's enough entropy for you my friend", "Unknown"), 4 | ("Any time two people connect online, it's financed by a third person who believes they can manipulate the first two", "Jaron Lanier"), 5 | ("The landscape of the future is set, but how one will march across it is not determined", "Terence McKenna") 6 | ("Freedom originates in the division of power, despotism in its concentration.", "John Acton") 7 | ] -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | exec(open("nomadnet/_version.py", "r").read()) 4 | 5 | with open("README.md", "r") as fh: 6 | long_description = fh.read() 7 | 8 | package_data = { 9 | "": [ 10 | "examples/messageboard/*", 11 | ] 12 | } 13 | 14 | setuptools.setup( 15 | name="nomadnet", 16 | version=__version__, 17 | author="Mark Qvist", 18 | author_email="mark@unsigned.io", 19 | description="Communicate Freely", 20 | long_description=long_description, 21 | long_description_content_type="text/markdown", 22 | url="https://github.com/markqvist/nomadnet", 23 | packages=setuptools.find_packages(), 24 | package_data=package_data, 25 | classifiers=[ 26 | "Programming Language :: Python :: 3", 27 | "License :: OSI Approved :: MIT License", 28 | "Operating System :: OS Independent", 29 | ], 30 | entry_points= { 31 | 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] 32 | }, 33 | install_requires=["rns>=0.9.6", "lxmf>=0.7.1", "urwid>=2.6.16", "qrcode"], 34 | python_requires=">=3.7", 35 | ) 36 | --------------------------------------------------------------------------------