From 4730f0ee0a9d0f2aa4f35e36bfeb5f23130f56a2 Mon Sep 17 00:00:00 2001 From: AnonDev Date: Mon, 6 Jul 2026 18:15:15 +0200 Subject: [PATCH] Initialization of NomadNet project --- .dockerignore | 1 + .gitignore | 10 + Dockerfile | 26 + Dockerfile.build | 28 + Dockerfile.howto | 6 + FUNDING.yml | 3 + LICENSE | 674 ++++ MIRROR.md | 33 + Makefile | 30 + README.md | 197 + README.mu | 122 + docs/screenshots/1.png | Bin 0 -> 80551 bytes docs/screenshots/2.png | Bin 0 -> 83305 bytes docs/screenshots/3.png | Bin 0 -> 87591 bytes docs/screenshots/4.png | Bin 0 -> 86223 bytes docs/screenshots/5.png | Bin 0 -> 70156 bytes nomadnet/Conversation.py | 1009 ++++++ nomadnet/Directory.py | 434 +++ nomadnet/Node.py | 266 ++ nomadnet/NomadNetworkApp.py | 1644 +++++++++ nomadnet/RRC.py | 1501 ++++++++ nomadnet/__init__.py | 17 + nomadnet/_version.py | 1 + nomadnet/examples/messageboard/README.md | 18 + .../examples/messageboard/messageboard.mu | 41 + .../examples/messageboard/messageboard.py | 187 + nomadnet/examples/various/input_fields.py | 62 + nomadnet/nomadnet.py | 58 + nomadnet/ui/GraphicalUI.py | 8 + nomadnet/ui/MenuUI.py | 8 + nomadnet/ui/NoneUI.py | 19 + nomadnet/ui/TextUI.py | 269 ++ nomadnet/ui/WebUI.py | 8 + nomadnet/ui/__init__.py | 47 + nomadnet/ui/textui/Browser.py | 1848 ++++++++++ nomadnet/ui/textui/Channels.py | 2285 ++++++++++++ nomadnet/ui/textui/Config.py | 89 + nomadnet/ui/textui/Conversations.py | 3093 ++++++++++++++++ nomadnet/ui/textui/Directory.py | 21 + nomadnet/ui/textui/Extras.py | 19 + nomadnet/ui/textui/Guide.py | 1937 ++++++++++ nomadnet/ui/textui/Helpers.py | 31 + nomadnet/ui/textui/Interfaces.py | 3215 +++++++++++++++++ nomadnet/ui/textui/Log.py | 127 + nomadnet/ui/textui/Main.py | 230 ++ nomadnet/ui/textui/Map.py | 21 + nomadnet/ui/textui/MicronParser.py | 1049 ++++++ nomadnet/ui/textui/Network.py | 1974 ++++++++++ nomadnet/ui/textui/ReadlineEdit.py | 150 + nomadnet/ui/textui/__init__.py | 7 + nomadnet/util.py | 188 + nomadnet/vendor/AsciiChart.py | 91 + nomadnet/vendor/Scrollable.py | 562 +++ nomadnet/vendor/__init__.py | 7 + .../additional_urwid_widgets/FormWidgets.py | 539 +++ .../vendor/additional_urwid_widgets/LICENSE | 21 + .../additional_urwid_widgets/__init__.py | 10 + .../assisting_modules/__init__.py | 2 + .../assisting_modules/modifier_key.py | 26 + .../assisting_modules/useful_functions.py | 47 + .../widgets/__init__.py | 2 + .../widgets/date_picker.py | 345 ++ .../widgets/indicative_listbox.py | 452 +++ .../widgets/integer_picker.py | 277 ++ .../widgets/message_dialog.py | 40 + .../widgets/selectable_row.py | 47 + nomadnet/vendor/cbor.py | 430 +++ nomadnet/vendor/quotes.py | 7 + setup.py | 40 + tools/colorgen.py | 321 ++ tools/rrc-log-dump.py | 118 + 71 files changed, 26395 insertions(+) create mode 120000 .dockerignore create mode 100755 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.build create mode 100644 Dockerfile.howto create mode 100644 FUNDING.yml create mode 100644 LICENSE create mode 100644 MIRROR.md create mode 100644 Makefile create mode 100755 README.md create mode 100644 README.mu create mode 100644 docs/screenshots/1.png create mode 100644 docs/screenshots/2.png create mode 100644 docs/screenshots/3.png create mode 100644 docs/screenshots/4.png create mode 100644 docs/screenshots/5.png create mode 100644 nomadnet/Conversation.py create mode 100644 nomadnet/Directory.py create mode 100644 nomadnet/Node.py create mode 100644 nomadnet/NomadNetworkApp.py create mode 100644 nomadnet/RRC.py create mode 100644 nomadnet/__init__.py create mode 100644 nomadnet/_version.py create mode 100644 nomadnet/examples/messageboard/README.md create mode 100644 nomadnet/examples/messageboard/messageboard.mu create mode 100644 nomadnet/examples/messageboard/messageboard.py create mode 100644 nomadnet/examples/various/input_fields.py create mode 100644 nomadnet/nomadnet.py create mode 100644 nomadnet/ui/GraphicalUI.py create mode 100644 nomadnet/ui/MenuUI.py create mode 100644 nomadnet/ui/NoneUI.py create mode 100644 nomadnet/ui/TextUI.py create mode 100644 nomadnet/ui/WebUI.py create mode 100644 nomadnet/ui/__init__.py create mode 100644 nomadnet/ui/textui/Browser.py create mode 100644 nomadnet/ui/textui/Channels.py create mode 100644 nomadnet/ui/textui/Config.py create mode 100644 nomadnet/ui/textui/Conversations.py create mode 100644 nomadnet/ui/textui/Directory.py create mode 100644 nomadnet/ui/textui/Extras.py create mode 100644 nomadnet/ui/textui/Guide.py create mode 100644 nomadnet/ui/textui/Helpers.py create mode 100644 nomadnet/ui/textui/Interfaces.py create mode 100644 nomadnet/ui/textui/Log.py create mode 100644 nomadnet/ui/textui/Main.py create mode 100644 nomadnet/ui/textui/Map.py create mode 100644 nomadnet/ui/textui/MicronParser.py create mode 100644 nomadnet/ui/textui/Network.py create mode 100644 nomadnet/ui/textui/ReadlineEdit.py create mode 100644 nomadnet/ui/textui/__init__.py create mode 100644 nomadnet/util.py create mode 100644 nomadnet/vendor/AsciiChart.py create mode 100644 nomadnet/vendor/Scrollable.py create mode 100644 nomadnet/vendor/__init__.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/FormWidgets.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/LICENSE create mode 100644 nomadnet/vendor/additional_urwid_widgets/__init__.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/assisting_modules/__init__.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/assisting_modules/modifier_key.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/assisting_modules/useful_functions.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/widgets/__init__.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/widgets/date_picker.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/widgets/integer_picker.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/widgets/message_dialog.py create mode 100644 nomadnet/vendor/additional_urwid_widgets/widgets/selectable_row.py create mode 100644 nomadnet/vendor/cbor.py create mode 100644 nomadnet/vendor/quotes.py create mode 100644 setup.py create mode 100644 tools/colorgen.py create mode 100755 tools/rrc-log-dump.py diff --git a/.dockerignore b/.dockerignore new file mode 120000 index 0000000..3e4e48b --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..fc8f7d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.DS_Store +*.pyc +testutils +TODO +NOTES +RNS +LXMF +build +dist +nomadnet*.egg-info diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e9920e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-alpine as build + +RUN apk add --no-cache build-base linux-headers libffi-dev cargo + +# Create a virtualenv that we'll copy to the published image +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip3 install setuptools-rust pyopenssl cryptography + +COPY . /app/ +RUN cd /app/ && pip3 install . + +# Use multi-stage build, as we don't need rust compilation on the final image +FROM python:3.12-alpine + +LABEL org.opencontainers.image.documentation="https://github.com/markqvist/NomadNet#nomad-network-daemon-with-docker" + +ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED="yes" +COPY --from=build /opt/venv /opt/venv + +VOLUME /root/.reticulum +VOLUME /root/.nomadnetwork + +ENTRYPOINT ["nomadnet"] +CMD ["--daemon"] diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..e28e9bd --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,28 @@ +FROM python:alpine +LABEL authors="Petr Blaha petr.blaha@cleverdata.cz" +USER root +RUN apk update +RUN apk add build-base libffi-dev cargo pkgconfig linux-headers py3-virtualenv + +RUN addgroup -S myuser && adduser -S -G myuser myuser +USER myuser +WORKDIR /home/myuser + +RUN pip install --upgrade pip +RUN pip install setuptools-rust pyopenssl cryptography + + +ENV PATH="/home/myuser/.local/bin:${PATH}" + +################### BEGIN NomadNet ########################################### + + +COPY --chown=myuser:myuser . . + +#Python create virtual environment +RUN virtualenv /home/myuser/NomadNet/venv +RUN source /home/myuser/NomadNet/venv/bin/activate + +RUN make all + +################### END NomadNet ########################################### diff --git a/Dockerfile.howto b/Dockerfile.howto new file mode 100644 index 0000000..cfd4ee8 --- /dev/null +++ b/Dockerfile.howto @@ -0,0 +1,6 @@ +# Run docker command one by one(all four), it will build NomadNet artifact and copy to dist directory. +# No need to build locally and install dependencies +docker build -t nomadnetdockerimage -f Dockerfile.build . +docker run -d -it --name nomadnetdockercontainer nomadnetdockerimage /bin/sh +docker cp nomadnetdockercontainer:/home/myuser/dist . +docker rm -f nomadnetdockercontainer \ No newline at end of file diff --git a/FUNDING.yml b/FUNDING.yml new file mode 100644 index 0000000..d125d55 --- /dev/null +++ b/FUNDING.yml @@ -0,0 +1,3 @@ +liberapay: Reticulum +ko_fi: markqvist +custom: "https://unsigned.io/donate" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/MIRROR.md b/MIRROR.md new file mode 100644 index 0000000..ef2fdd4 --- /dev/null +++ b/MIRROR.md @@ -0,0 +1,33 @@ +This repository is a public mirror. All potential future development is happening elsewhere. + +I am stepping back from all public-facing interaction with this project. Reticulum has always been primarily my work, and continuing in the current public, internet-facing model is no longer sustainable. + +The software remains available for use as-is. Occasional updates may appear at unpredictable intervals, but there will be no support, no responses to issues, no discussions, and no community management in this or any other public venue. If it doesn't work for you, it doesn't work. That is the entire extent of available troubleshooting assistance I can offer you. + +If you've followed this project for a while, you already know what this means. You know who designed, wrote and tested this, and you know how many years of my life it took. You'll also know about both my particular challenges and strengths, and how I believe anything worth building needs to be built and maintained with our own hands. + +Seven months ago, I said I needed to step back, that I was exhausted, and that I needed to recover. I believed a public resolve would be enough to effectuate that, but while striving to get just a few more useful features and protocols out, the unproductive requests and demands also ramped up, and I got pulled back into the same patterns and draining interactions that I'd explicitly said I couldn't sustain anymore. + +So here's what you might have already guessed: I'm done playing the game by rules I can't win at. + +Everything you need is right here, and by any sensible measure, it's done. Anyone who wants to invest the time, skill and persistence can build on it, or completely re-imagine it with different priorities. That was always the point. + +The people who actually contributed - you know who you are, and you know I mean it when I say: Thank you. All of you who've used this to build something real - that was the goal, and you did it without needing me to hold your hand. + +The rest of you: You have what you need. Use it or don't. I am not going to be the person who explains it to you anymore. + +This is not a temporary break. It's not "see you after some rest", but a recognition that the current model is fundamentally incompatible with my life, my health, and my reality. + +If you want to support continued work, you can do so at the donation links listed in this repository. But please understand, that this is not purchasing support or guaranteeing updates. It is support for work that happens on my timeline, according to my capacity, which at the moment is not what it was. + +If you want Reticulum to continue evolving, you have the power to make that happen. The protocol is public domain. The code is open source. Everything you need is right here. I've provided the tools, but building what comes next is not my responsibility anymore. It's yours. + +To the small group of people who has actually been here, and understood what this work was and what it cost - you already know where to find me if it actually matters. + +To everyone else: This is where we part ways. No hard feelings. It's just time. + +--- + +असतो मा सद्गमय +तमसो मा ज्योतिर्गमय +मृत्योर्मा अमृतं गमय diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5626de8 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +all: release + +clean: + @echo Cleaning... + -rm -r ./build + -rm -r ./dist + +remove_symlinks: + @echo Removing symlinks for build... + -rm ./LXMF + -rm ./RNS + +create_symlinks: + @echo Creating symlinks... + -ln -s ../Reticulum/RNS ./ + -ln -s ../LXMF/LXMF ./ + +build_wheel: + python3 setup.py sdist bdist_wheel + +release: remove_symlinks build_wheel create_symlinks + +upload: + @echo Ready to publish release over Reticulum + @read VOID + rngit release rns://7649a50d84610232d1416b41d2896aff/reticulum/nomadnet create $$(python setup.py --getversion):dist --name nomadnet + +upload-pip: + @echo Uploading to PyPi... + twine upload dist/*.whl dist/*.tar.gz diff --git a/README.md b/README.md new file mode 100755 index 0000000..5d53d01 --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# Nomad Network - Communicate Freely + +*This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.* + +Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. + +![Screenshot](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) + +Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. + +Nomad Network is build on [LXMF](https://github.com/markqvist/LXMF) and [Reticulum](https://github.com/markqvist/Reticulum), which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. + +Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over *extremely* low-bandwidth medium, and has been succesfully used over 300bps radio links. + +If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android and macOS. + +## Notable Features + - Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports. + - Zero-configuration, minimal-infrastructure mesh communication + - Distributed and encrypted message store holds messages for offline users + - Connectable nodes that can host pages and files + - Node-side generated pages with PHP, Python, bash or others + - Built-in text-based browser for interacting with contents on nodes + - An easy to use and bandwidth efficient markup language for writing pages + - Page caching in browser + +## How do I get started? +The easiest way to install Nomad Network is via pip: + +```bash +# Install Nomad Network and dependencies +pip install nomadnet + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +``` + +If you are using an operating system that blocks normal user package installation via `pip`, you can return `pip` to normal behaviour by editing the `~/.config/pip/pip.conf` file, and adding the following directive in the `[global]` section: + +```text +[global] +break-system-packages = true +``` + +Alternatively, you can use the `pipx` tool to install Nomad Network in an isolated environment: + +```bash +# Install Nomad Network +pipx install nomadnet + +# Optionally install Reticulum utilities +pipx install rns + +# Optionally install standalone LXMF utilities +pipx install lxmf + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +``` + +**Please Note**: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + +The first time the program is running, you will be presented with the **Guide section**, which contains all the information you need to start using Nomad Network. + +To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/). + +If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. If you 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: + + - `abb3ebcd03cb2388a838e70c001291f9` Dublin Hub Testnet Node + - `ea6a715f814bdc37e56f80c34da6ad51` Frankfurt Hub Testnet Node + +To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node. + +### Install on Android +You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. + +For a native Android application with a graphical user interface, have a look at [Sideband](https://github.com/markqvist/Sideband). + +### Docker Images + +Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the very latest commit) or the version number (eg `0.2.0`) for a specific release. + +```sh +$ docker pull ghcr.io/markqvist/nomadnet:master + +# Run nomadnet interactively in a container +$ docker run -it ghcr.io/markqvist/nomadnet:master --textui + +# Run nomadnet as a daemon, using config stored on the host machine in specified +# directories, and connect the containers network to the host network (which will +# allow the default AutoInterface to automatically peer with other discovered +# Reticulum instances). +$ docker run -d \ + -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ + -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ + --network host + ghcr.io/markqvist/nomadnet:master + +# You can also keep the network of the container isolated from the host, but you +# will need to manually configure one or more Reticulum interfaces to reach other +# nodes in a network, by editing the Reticulum configuration file. +$ docker run -d \ + -v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \ + -v /local/path/reticulumconfigdir/:/root/.reticulum/ \ + ghcr.io/markqvist/nomadnet:master + +# Send daemon log output to console instead of file +$ docker run -i ghcr.io/markqvist/nomadnet:master --daemon --console +``` + +## Tools & Extensions + +Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist: + +- [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum)) +- [NomadForecast](https://github.com/faragher/NomadForecast) +- [micron-blog](https://github.com/randogoth/micron-blog) +- [md2mu](https://github.com/randogoth/md2mu) +- [Any2MicronConverter](https://github.com/SebastianObi/Any2MicronConverter) +- [Some nomadnet page examples](https://github.com/SebastianObi/NomadNet-Pages) +- [More nomadnet page examples](https://github.com/epenguins/NomadNet_pages) +- [LXMF-Bot](https://github.com/randogoth/lxmf-bot) +- [LXMF Messageboard](https://github.com/chengtripp/lxmf_messageboard) +- [LXMEvent](https://github.com/faragher/LXMEvent) +- [POPR](https://github.com/faragher/POPR) +- [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools) + +## Help & Discussion + +For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network). + +## Support Nomad Network +You can help support the continued development of open, free and private communications systems by donating via one of the following channels: + +- Monero: + ``` + 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w + ``` +- Bitcoin + ``` + bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6 + ``` +- Ethereum + ``` + 0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E + ``` +- Liberapay: https://liberapay.com/Reticulum/ + +- Ko-Fi: https://ko-fi.com/markqvist + + +## Development Roadmap + +- New major features + - Network-wide propagated bulletins and discussion threads + - Collaborative maps and geospatial information sharing +- Minor improvements and fixes + - Link status (RSSI and SNR) in conversation or conv list + - Ctrl-M shorcut for jumping to menu + - Share node with other users / send node info to user + - Fix internal editor failing on some OSes with no "editor" alias + - Possibly add a required-width header + - Improve browser handling of remote link close + - Better navigation handling when requests fail (also because of closed links) + - Retry failed messages mechanism + - Re-arrange buttons to be more consistent + - Term compatibility notice in readme + - Selected icon in conversation list + - Possibly a Search Local Nodes function + - Possibly add via entry in node info box, next to distance + +## Caveat Emptor +Nomad Network is beta software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch. + +## Screenshots + +![Screenshot 1](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/1.png) + +![Screenshot 2](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/2.png) + +![Screenshot 3](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/3.png) + +![Screenshot 4](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/4.png) + +![Screenshot 5](https://github.com/markqvist/NomadNet/raw/master/docs/screenshots/5.png) \ No newline at end of file diff --git a/README.mu b/README.mu new file mode 100644 index 0000000..332b95a --- /dev/null +++ b/README.mu @@ -0,0 +1,122 @@ +>> Nomad Network - Communicate Freely + +Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. + +Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. + +Nomad Network is build on `_`!`[LXMF`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=lxmf]`!`_ and `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_, which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. + +Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over `*extremely`* low-bandwidth medium, and has been succesfully used over 300bps radio links. + +If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_, which is available for Linux, Android, Windows and macOS. + +>> Notable Features + + • Encrypted messaging over packet-radio, LoRa, WiFi or anything else `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_ supports. + • Zero-configuration, minimal-infrastructure mesh communication + • Distributed and encrypted message store holds messages for offline users + • Connectable nodes that can host pages and files + • Node-side generated pages with PHP, Python, bash or others + • Built-in text-based browser for interacting with contents on nodes + • Built-in RRC client for live, many-to-many chat + • An easy to use and bandwidth efficient markup language for writing pages + • Page caching in browser + +>> How do I get started? + +The easiest way to install Nomad Network is via `B333pip`b: + +`B333 +`= +# Install Nomad Network and dependencies +pip install nomadnet + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +`= +`b + +If you are using an operating system that blocks normal user package installation via `B333pip`b, you can return `B333pip`b to normal behaviour by editing the `B333~/.config/pip/pip.conf`b file, and adding the following directive in the `B333[global]`b section: + +`B333 +`= +[global] +break-system-packages = true +`= +`b + +Alternatively, you can use the `B333pipx`b tool to install Nomad Network in an isolated environment: + +`B333 +`= +# Install Nomad Network +pipx install nomadnet + +# Optionally install Reticulum utilities +pipx install rns + +# Optionally install standalone LXMF utilities +pipx install lxmf + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +`= +`b + +`!Please Note`!: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + +The first time the program is running, you will be presented with the `!Guide section`!, which contains all the information you need to start using Nomad Network. + +To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the `_`!`[Reticulum documentation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/index.md]`!`_ for info. + +If you want to try Nomad Network without building your own physical network, you can connect to the `_`!`[distributed RNS backbone`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=connect-to-the-distributed-backbone]`!`_ over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: + + • `!`_`[9ce92808be498e9e05590ff27cbfdfe4]`_`! The rns.recipes forum + • `!`_`[a4a5e861626ce97c9aa544d9ecdf6d22]`_`! rmap.world + +To browse pages on a node that is not currently known, open the URL dialog in the `!Network`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!Go`! or press enter. Nomadnet will attempt to discover and connect to the requested node. + +>>> Install on Android + +You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the `_`!`[Android Installation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=reticulum-on-android]`!`_ section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. + +For a native Android application with a graphical user interface, have a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_. + +>> Help & Discussion + +For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the `_`!`[rns.recipes forum`9ce92808be498e9e05590ff27cbfdfe4]`!`_. + +>> Support Nomad Network + +For this to be possible, I need your help. Please support the continued development of open, free and private communications systems by donating via one of the following channels: + +• `!Monero`! + 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w + +• `!Bitcoin`! + bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6 + +• `!Ethereum`! + 0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E + +• `!Liberapay`! + `[https://liberapay.com/Reticulum/] + +• `!Ko-Fi`! + `[https://ko-fi.com/markqvist] + +>> Caveat Emptor + +Nomad Network is experimental software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it `!has not`! been externally security audited, and there could very well be privacy-breaking bugs. Use at your own risk and responsibility. diff --git a/docs/screenshots/1.png b/docs/screenshots/1.png new file mode 100644 index 0000000000000000000000000000000000000000..ee38e564345d52480f8170cfc66ab6cbe503c9ac GIT binary patch literal 80551 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

0tZ z98UBG_`IC4gpW98i%W2Y(Hme%#nr0#%2m;lbgXTgx|fiPCx0gPOU`r`p?vUl5e<&o zsJ;Nv9$$e-bUe(z0g zcyOu8cexdVz}^O#)1k9Lm!}3l?P*knYpAyD9BAbfeu{T!&in4mL9y(%FmJW0xh-aF zCpTmK7ZhV!?z9ZY|0G!D0D7tzF(*9cKqfA0u+yZA7|*HTT4C@ZmYNd@JtwW&-q{W{{{_u=GxPjBVyh9$U@;AZK4rs_H+>N@3TU^Ii2 zxrwZwle!-XGAu{;JpOL>KMo7|so%Gr*{WB5_8_cA6it0uv@+i_bmL!f%=NZ=IN>${ zU**&eC&0Z?X;^V+v?9zSw*B#V7u|<2Nr!Pjs$^Na)=>A|J3j%BMb%79W3WKSf(Ea9=^mG?@|TW5usc4g?0UAm6Q`EypmI8fwPcJC?V@7dK8O+b?$C* zW5VUqC?NapGFACoQeeE$IZi^IE|LbgyMwJnL1R8vY$WB?;g&BCe{Dz81wIr4h+)KjzX{ikUmWK=vZ)Ud`*aWaQYjA*FGn zEWikwJ8SVp|RTMX1KEv%oJcVd!Nv%b}pU$8@xyrC(-`z)^|ikz@V!;Y5#(;U5OC=BW{0u=vY zI^HVT704+h{+w9KJh$zqjpI??zk+j7NfnNpQ;om54GUa;jTV(n^oc)`csaYB23XA7 zprcZ$cBgSs;Nj@Kr_Yx}Sbr1@z5yt#&h?gY%*oHB?z*-?ZD~x@U|$JDEr{7_-Lm+? z=VA5oxLgUWUMGf;djCeS1;&5J%p1coF5$DU$A;;d%1ipjy&W@dqz-zwoQqTG)|1`1 zCSL*O?tM{w(uk=YqMLUqTB z=7|Mo`YxXEhE(v;|ND|jlKX=ezOXnc6#edhBOxl~;eTr4{m*{sW6sdtJ&oV_4Q_l= zDrzK&m@2O;_Y?@D(HMzcZh6+?li%NL3XU4fZypG;nqAf8N;o&Gn*L^Lw?%5 zI3%wa)KEt!&DgErd0=!89!Qn=FiOgJN%C6X)Q2oD%|yYqPhERz5Sn=Hp!c2*RXTU? zQ4d?|INI_XZR+T1M6>=Pbj!_vN&iXN2`d{Nxay68Nk5}NH0)qg2m4xbe`;)psIlVD zs1WiEZk@x$t9eaLS^Q-mYdy(DdN2sMm!nMBptf!te49ucPy{5#)s$B}{Ja;qIpR>> z_3ZTq6b?}zvhcP(#`;p8H}vb%oJ>8G>^7OiOe8Fg2^inJrnR-ZmLI`AcvN3b;2b_? zu0}eE6n{%oR83pRHcY}Qyv8u3b2}b77kwvwqDxnG9vB99Z` zQjxs`JL_jb53*cbrqjA#K|4bUxL5;UUc$A*e3<0nahx+Wuc3~HL%Dtix#4XvSRW2) zI99d;+HmC!0y{)RyQ@cN$I_L?nxmYIjt$-wOy&U`NU1`&q>gcY{!go_IQ@GgjO>lW z*;$rXKQ%97bP|=q!lh`RlJF_I8knV&LKmJqHo@^17nfz0nZ}b#oT1ZS`ZI9^hN&{7 z@=hr-K1B`1-XgYgqY!#_u*QKN4q(AnT%-eYN}iRsyoq(fH<+IA#2Zpe49 zk}B?z^r)M_5I(qdWHZi5d8JIc2nYWY-#nP=;SXaePM+mccs8h6sA~79zb;NkE&1zW zsduC8NK5}fqzg2U(Kbz!&4PpL6k500l>b1d>ckECB;3h8ZFV$^d?e>A_c0`ceU9h$ zi{{OrZWu_KyuzUbbtgKdB;m%+H_-jATd#3VX1uHra3 z5!We>WjC@zONVbq-|8wRKHO}-zgEwAJx}Y@SjNJhT6$4f%srLaXbD6DV&SJj=U9H- zPG}Y?L|<2X`)!WOcj+7cm-OpYUZjJ|3w}Ve7Fwc>j*ZHviqfyoUv-JgtDTbJ*7N4n z$}+A6+|VAr3iq+I|>acF$=Aa@f&A9exZvro; z(_ZpgSbtPg2eHsQB#G&GJKOY~+{8QS-aLhmLElFeoWCEphc;Sl$lm>q-|xrZ()KfN zSTM!9@$*$dFxe;apo0=-2*%2o_~=UIA^-#RkVx;;@_tW$_7veR2yd!AS6FncHd}QTdTTP?1oG z6y@!^I4(aMAZRFebA>)VOn5>f*46o#!Xq>}r-Y(yN0U=eB$_pF*mL58`qnv7gzg%fp4>j zgvK$6#+2+12lz1zGlP}RssnqIJ?y*PSf)zZjqn4Q%*{pXUX4xp3+!DjN}vo)ShuvR zpa>ihs?VwRohGV+GgqVt96Wipn807Fk|LM@y4a zqN=yd&E(8{#xE;$+N7@)(o`2hBS+YASsMAxnyP5uNy}$!ttb@rcboB1zyjF?=gb)H z5AOJP(>zT)$ZTCYKtzvRCEm{`3HhhyXDiwve}j^`il(OYC+@>r9as+%(cFz5q?+HZ zcuAB7b!y^s6mUDgQU3Kcw|Ytx0L($wz2h-iV`n*lppKbngWWE@_fR1R?hr_;fYNJ} zuGy{hf;H0*^}V|FsE}QVsW6J7NnF>L!bEDbSHGnfITQfFYa%s7u{}`=GJdwV4|z^A z_^^RbF?&67J^D?K%B+qv^eH?$XBf`+!;J4Lc)Q);^>$WJ5|eV&L1wg8fh$DygHqJ5 zRm7;%*XnU)I$afe0Eir@UjP+EOfej<$bn<=%EP4bXKh zlM@Ak<3Fxq*Ov~uga)*s7{WGty7?z9A8twldm9AN7GjA73+R?LMUoA8gagTmNlmT) z1R#QDU*f$v^6ud_P^%q0VG*<``O4PeLXW?$Kem|hEI#i#LbpwPBmsX$;L>m^YAIpPU#pJ;`Zk*@P-pL4yeEV*2U%E(j*dYu zjC0I%C3ppu$1UY5Iu(;|DwHBmfX9`gMKt~zBbO40Jhi%uhgrk&!I%u#!r;oM~zO-G$Si8}UO9)gjws7|_;Xo6U zl--S;j@sOSJjKDe0`L~okyED-g%O!Pg~&#+=`IND4z)@?wS+`vsb>AunZi=jopxw} z*Qr&entTIpLu~??U@E_*r5-%`R8ne26p)sra29thzhJw(suyPuw%vsP%;)FO=MEMM1Wt~0I^(^%TB5)QM8EMSiZq`*Eo%Q!|7aLKF>mScIO@0A z@ZDTVohp1O*G8SM2QJ>F(BoM`yVGi9U2|ih{O&0HGAZ6757%c%7DVSi|I7@b{C*#a zw@TCjqod(-=);rMv$oNdL|ZQ79hI-&ZPb{)htPAta{v_s5(n!lhT-;0eC@%Fav4o$ zo~j+-Y_G2KP$6Kxy{A)UAAd{Ncgls|;}kIdd*w<*ucS>Tjmx#3on=vkMx@wdw73Zl-T`6)UE$9Ba6Ja zsD9|-6)fRjQ(DYS*6>R#grxpr_ve^XV*Qb$P<41!$W_@#oia4jcwojMdes?_ov;E; zodYb+_392_TLI@Go}0!Z<*X1%Me~=sdXQS)PnbJok>}ZaLp4j-#1kKXB|j>qwPeR= zol46h;V2;4NvfBa1~%twb9X;8Sv_Tk19}z2?e4bw^R$_a!1#v~r`pvk;SED|%R7Zj zN!y>8%y=A}Yw~Llx~IRQru+V%RzS0s*#h+I)`t~7B9sz_m_d7TW{>S{atF$ya+Q>E z8(&Tk79MKtE`T(bFq91z10r%GfHSAd#3XT@<<2Qu=MT&};9cbMkt;$liDVRmmA+Qa z2pN7VzwDu;oe>HWbNq7_fYLF>QoFu3oBz2I6r;)5FGxfR1+$uP8a)y>$*FI4eQq+( zLdkw;Oh^XvFt?oIe7*o!lfSf|WYYFK&e{(N1JL$D7d_2sqm(#h!c%?sB}R^^2QSCq zw5iYJxx~UU!1HQQrKXx(SSqx%eAw~C>1Ay9L-RIIY$YVVtvZQMIjnQ;OZ-DXjnOpR z5TJW z`H#wWeYgVhaC!A^fDW3jc+x*?QPICk{^K|ysF~v4Gd7;oJ>2Tl4@qP+$R%Ry zW^2vSy_kTHsjzkb^TMAHZv=ZHIirS<4B7i}UR3`iI>NS%hU>rj9pRU?@c(*msu)!- zeuXd}l~jxIWDHB#G!W4xYYrFDCo1V5LW|J+0K?nT?Y?VI-CMfMY&Y~dVfJr22U`ml zxVjD|LoWY}5P`oLa?g10jX?)>H?zePR%1BL{+$7(nV`DiHR6ULz7_eA z(rD5@>lGJ!{r)A8CcpwB7G$($$%a3w-Ncy%t}r7Z5R{qN=6d>>Myc0vjCNT=_#hL^~dr2(tpN}AYNjJFwLQc&GtH1*&1{rE-p4A5@xY? z@q)o$L{QdSc6TO!4=^VzOVC8q+bm_AtF}GOSIQfFUAy4&9Hu{oEB$I~Pw}*ke=rza zsI-y;S`)2{L#mYz=^pLj8#C2zusR=WCtFI+*fu0SC_ffh#mY8#=FP;vrxEK?lqL05 zY*^7|J;qq1bsKmOnpvYbALHAblN1BjNnaCsfQNm*6ar(C+T~91=}_< zaZ~aLP=`gHDisB+k7?j+hqm}PCr$msQ(=qmp~@z?H70dejI0+@BdyobE9V`wwjt!& zB!5P#LF?8|%)zPyG&yJ;>I;!vs``cGKatMvz3zFw{+ZE+{b_PYECje08RpSSbC^@s z4~dG^H-pLCx6JWtH&G$w1r=Fn)2{S9Y7c34et_*d%vJiS|Dpm%$GTxZy-0M&9I!vo z5OhiN`q2)ee%;Zo$RNOCASAb%=QO?K!F-oNBloq;=fX7|Ex`l7yAJCjqH5#h29X1E zc*E5vZi_5~hvRu=bil4sbrw?54%@r~yX-1{bgg?N0^0B?c~N+WZ6?FiNrN19;Op|S z*=c;$=gZI7iQ&mJfA?(tHr3NVcAxcE^?T4x<(C78DcQ0cv_k#xwfO`9L43!N`uV8T zto>B5eE(xC@NWQf8LZFbo?5*i)eW*VXA;nJlAap)#{<)E59~2HWjKcq~X!uy7NEf${OQpO~3JLR|P7R3r_nn50Vn9#aPDjZ0b`a ztFz2qld>UDF-%XYkPDLWktffDrU0sx%+NX7xboef ze?OtGEMBKGU?*a+EHbYzwrxJDYIG4H^fOLP1~V zB&4fpms!Y!;WMitGD!{CNY`}y<1b@dlIJmf07uIU_bAg+oI14|IJVwC6BQ)q{bfA= z^U)CH#H5nJ{Buo*25PZvkL|L@Ar?JMx+dziHH@~D#QG-|{j#Ye5u%)CQWYO-JJ4vk zwpar{xp%RzNafIKnHo8pUyryh_~q>QWS&$yDW}?H!A*MR+%aeDwD44?>)zQcbnV1h z|Ga`J6-f`?*x+HB&}(1R^!GDkk6sriM!44ciK=Ks z5yYj`28>}(D(sn@U=YN>C(30i(!ORB^NFQg;auIdLc)1n@etDRm|W+cpP*9jsn0Fj zEfcq*hIFdql}2mApEVxSxc<{$*DX@JOUwf=qwL668}9RTJrzzjw2Y6NHo%qX)u@;e z>6anh;z~D`;;fSxys%$d#vwXClR8Pb_cRUFi`~jiq43Xh_t10d;~(tMB-8lW_^dj^ z?4tPhWrtro{n+{P_S2*CHtk~#KZNtcQ!=|<(+Qh<)hGFCmomd)NO&dT)_jQtoNSSH zTDZuN-MIH$S$e|!W2@c^9S0BckEpI_(*sr8)t-hT{Y)$##OFJwiBaj8rwk%wk9kih zDCT;ENol7&`Y;0%%Hf;4ddk$*{)BoiMf}Z~9DVNgRgxuRq)NA-)QsG?VQ%X(wPGf3pXmV4X zqr@N+=vNo7=;*Hkt!5`1A7t=E6*tE0l}$xD4@R#Oy1rgQeJ%G1{ceq$%bP%8k0eeW zl(|&n2yW?d&!$ze)dTe0{M>Am=?cXHSEXymdh&S> zUz06>T`D~mCqoLXQjj>Scv4`H*2K|kU>Qy6$%ss@_rv%0M!}9_+LE=&76LEdeyQ&i zIu1;|*SoTrr&8jQN@P{}#=$_<(sVTG0nXw)&uU>Rl+6o$gn%)O8vD4Nmoi zvy{|K%rAByDE8Q}UHgV8m^aGZ6r1@U72^I044jiJ0S_Nh@e zwY++znoZrxDvg((CCUZ&B-l+fYM9hD_QtXzZ_eBJVeloqH;Csl&%hEj{g|K5h*IR!lm(=-8);@qhEL0y#2lCVt}7~_oP&s zT^FZ63+7@*wd4K|(p|D9q-amsU}v5$GD&ywK?XNAZR%q&_Yb2Iq}26~g$9>H7~T}t zo5ovQJ5*ZDQhFN{L&^xz77KexiYL=ZJKb@kihGgdPYoo0a~^R`Age9eRWn_J;%h4T zmqQkn0IZU)$=FO04Kc!eB|jhmw>lI!@|ROEY^G)z?v^*$rc{1+bASiji{;~bdbmWn zZ*W@Lz#wxkFGtn5zQiItn53*yW}oP=6=7i+EdZ}pw7 zFPt@X%UY!9XSajegn21>gfGWW?^l!xmJf~@oy$|WU6WayuT=%dQD@K?6+;5A1nY5EOtGPx1wn_)LNoURSEiYxaj&R{4_23a7pBbx!B)zVDElcQY zV{>n9ii5o%ndoIX>umf+hu0#G{*?~l{AaZI(eQ7$*qreRtqfw4kGEsqD<$PNrfSYv zyQxf1`#e`8+M-?D>)W1)%}CxXsvEE}=Oez^WI}Wr1UA)}s}>%sA3k?izoU+7MCtFk z=$jG86sFla?rEtt|A1||1#{1KKlhdfr7ypyJPy$vzEiME6P!y*EyDR7nw_56TOrMO z>2bt`C!2^%C0;p%TkjNhmsr*?d7ZSC!w{E1$hn_H9zy zIJ|egL*un!l_wHOcTZ_`zCYF~$b6!pP|GsSLACc~xFh-MM~*Fxy^kA%$)0b0je^>y zdqq*iew|+RKVcGtDHoetZ6nm80|#O~Gc^itvsp@MH?6j8iFrlRRqX>Mg`4n?`@vt1 zi*`Inc}HooT2sOZ8U5|W-f+Ibdhw!W8sWHXDN*5dT5L$9^q{TmT4J-_z;j8Rjy?U6 zM>uKWtdx!ZdD7L(8aE6HaOA~+TiHwbb|Jk_FL3`|iC~29%c;sUMH-K@n&np7zw6?6 zeWcy`h76$Rz<^ihR7U@+}}{sw1^g`}d7q zxzc|JbH(>+{MpI=oRkq%?u_-HU#&Xh#?MrrJVhi7>?z$-YC9aQeEDv9dpXivhGQ(@ zi1`L5dDkD{JoXOW&&vm1XfK+DI0G3(cYiSUpQ}IA^T7KViJm<+HwW*Z(eK3By6lq( z{QF8A?in}#ZO~#ECf&a^%D$0*c=aA}jcKG1e0~s{&9&ZRZpgZ|D%=_Lm;C+Gr!p7$ z_>;oUcB-moN{b5d_9cmrOMCBugZGB@ce@>&TNhvREOBsmNv5m^Y_*>lN_G@Wl0T*L zg6)n?>s|;3KjTcIwhLpukA-r%w8crnmMU3ba-h;Xu!~?}ScC?WHd5hS?3S0O-#ctM z99l17MzD=-(Hmuxk37xKp=#OcnY+K{ z*K6q(nXjMTKzGk;h^B1_h2_aaX~$cW`ZO|R1c!SGc>>n{SL5KX*E`%CuB+C^W4@*5 z#8MFZ<}Sjw^$9_*-%g97FU0kQ4w5^4V_7NM3a&SO?>^_D;-oq|I~0s5;;9c)nJ{A5nUv@jbW$1w+?)R}(LWnC}L`-i3?B>6>R` zxsQ^|=rkFfgi-g2mf6|bSx8aP`(z#&tNsMF$x+b`Is@79J||B^Mh?hvCtrGq!&s1H zdoNCc2dt-}c=Z!~)%w(^V7}Fr_UlC{nnLv7MX^uN@23U5BBa^8jdtV;U7_{43rX8O zD7My771hryuhmLlFsekDv*Rxkd-~XqdbxekXb?st4TTZ<_^W5uzx5s8B**c7s#hJ( zvQr%eq8l_($#Y$ED~HTjC9e^Vhgav{w-HUgKdY_Uz}=@D z_Ng!w3T=l5drgjyoSlyK+9tasi)9DtBtnx!>@#$Q`Vaq5eDAU<9(~s5-wokiKu`8h z=RaB-UB}9xIYL5|>b$-=&0N$ROYf&iR1|orCET+WgcY-tTSfR_9}fq$&B0J_kJqMxu5#uQ*++#r6c8I%`U9{tji@w%CK$+l7 z`IHR5HhA25f23K7Jv*+hwnuRSyAS+uGk1$VYrt(jj!e=^`GC^(8~Xby4!KWjtEotR z=1z!lb0>@f2^*<)eTr!oZ>;$ie=8%+E|woQsx%Mn(M?ZBdui^L$*Qy(a`uwBnxp<% zY-O<6)9Hukk0YK4goH%bw<@Bl9J=y_{yMpN8+aBff>){D++I7zB0Q)HZZI8>R=`+P zQlfIKQletIIz4FXOSSk9VhT>Gag_%D)}5_nXZ;+QLvt9R_5;`k?^XkNp7E4%>sDB5 zS?Y_-7CO?XSMZsLUuW77?Ecu`!TAlcZ3+>0FKaccdf0R zRejX6zJA@u}}TcKIQ6i%On}0ShLmS#!9o<+K&y+ zZL`T;M8WoxA9n3|bXMW5JM~=if~!~whF10(8zSdTa7vdOI{D5?jrY?my|y+%Pq4C2 ztAeV{i{b%H($zPZBTpBRbE60sntmB>38#W>0^IIby>yujYiU7)RHG^li>1s|$r*{y zm&=BtF$NIzQ*qJ~5*kE$`SD+-`A%=9x9S5&z!rD@p&pnR4UVXf&scAk>iApRu?kmJ zAE^stcZej^$+OlqW&F9s@!&+PSi?osC9uz##CEXR=EgjtHT{dHSQoLro}If&sk4B< zFR!VUf7HUrD+T34MQk!ub$%?X{A6h;7(QsuKjrD=+`J}}XN|U|O1XIGf5JaULUz2$ z-FB~#NgcOu?4mq94SfL4=p*9x;Kw}WUG`H+*4@cRHc6RD4(40m1}JRX;G3hcR&3#; ztQygf&A84wd;J>`{pbH2*U%^O&(6g!^t}(6vydnIlAGbe$ww<( z2TIcnaoF7K;%(Fy=E66^aW*|xHixUSFwG)Ltwk$SjmOg2Q08>rRFw$ha}Y?RnWCiX zjWv%VZ(Y7N&G&rs{o4NFJ}vPv>2bI@Mv|e`L>{kpJVkyHqPKc`pgdhq+hKF2mWq6N za}wqXHlyDisyIxeWkKVFrpv!(wt(oA_OvsX~$a&u~0-Bbg=54fYP-6Paozy6Q3 z1jvzM)`k5XRS9>43$g-flWy;6!D`p)osGNo4n94tB*7x+a1%106k)Q;^(avfQiUAQ zeaynr&1C(RCuK!ABX)gwr07y^Qs3?FHgpXhv?8&MNyF61M4M@&#EY9xSCB36;@u;f z{>~oa4w5$N7Wpn#Ra^7&Due@yFmJjBYN1PqQ(xw_sGw>a@@h=6qq9}t<|_|s8Fd+E zk{%pnkYJ_hl9$CbHj1Kd(1S*HdiK2P^@{c8A_195UEf{{aGb}(Q)Jzl5!WJ0b6h=w z`MR|e?iJD%jBaq@E_OHuKPJGIEU`=BjiCKaQYCb?FkMBT%(fmUAK8AHE)O{LxnElOQY{fX##tLJ;SM!TU+PLlk7{Ow3j(QwW{*qeC2NM z4W5tBleZ*Za2abFj}Yi{rKLK=O|2@FvuON&g;A2t`BJU!!cxl_u@`ntB_jrq9X;s;|*zlo@pn**HvLHg_$+^vHA zf1RCrgNrTbB#({V=vfU)7;Ih?@qB3yTWG zm0B%1cD7Po@)lnZi=7VS8Xmj$g-YvhIJsP)Po_0AihRsw#W`o6S${-cy2GQ`QOHAI z+Dv%Cmcy+{iHb_|!XfK!wS}IcUWMY;!3n;_7Awu8tXov*c2m*Qbh!%#r9_Ar?Iq9t zbi^U$Imluzy!v|c^7C+&^UC)@%yBuP$ba;iTcvwQQUj-+vdWK-6*(K*`~((MA#Xcw zpHh`A^?SHD2WAfcRkVBSaxbO3i@{c-XTi3to1c2Y=g#e4#ev1)S3&VafHr#<9*rKH7PyIbRLoiAynz3ocRQ%Y(3M1 z)HRAC$ZPr1*xC^itjd>Xh5DBZ`{^j7S*5qGPaNs++bH!8c6#ekW4*^H6mp=xdwv&| zKEbvW%3MKM|C!xC>ZwY1L-$G5>~_1xlCR5AISx{^u3)8rpjL(yGWs^$$73M$yzY^p z%HPZKt~~(K0>jqE7Ij%Ebx=4(J8nmkpK_1>`V0%}!zJ%zoJ1T+b{pHV3$KCwPC1Bv zjHj$haSrkEC>|%gOX8_WMnpeJ_yI&8?QqyL1hQZrcsJ&B8+hL81Gznjj)uczMT9xb!|17ZJKW8 z4$s!q3eIV)1(O(C+$C7GeZtdTZsFsNXww|A)s{{ki=1(Sq_;t<$A^(_{gYL>K9;~i zwyslNcAThTA+iGut<2N)prxnh^OUv+9-g!%lJ@`UKKfzT&f?I6ONiv)oPv#l}39+=D24d7Pd&Ajcu6MFcx(i(5;S+ebpyFmVNc7=_+lsKtgE7cmeKQq|*CVWtW~5W{y|&wY;%qVuwd zAp|U^-#4hjgbJvyH$A6pntvjD(HZa@NcFofN=`2iTd(<&*Gal|ma+fRiqe7ny} zrnk;F%fowfA2>SzpVfGvZmjXk<{)42dQO#h{ou2Ea$)*;`u@efQs9NtH>Zal_PHiY zO6;fR6Vjo7rA5Ebk!&dAW5z|F2dWBE|*XZGT5~PeWyR6fgNQXl(h>P!Pxg^G7tUEeF@GtU;4gmsj)mv zl{h$X?2XiY^Y^7zIg=V%a!>DzT}gPhe{cVCd*{--Z&&glTt=}k$e}?VR>sCYv5mS#P7>oc^CA!z3HFcf-6_5pJV({ z&|JCFBH{7E4mEQ@m6P*_js@>AdvojmgoXfsn1E$<-obQt2o_^$ZAgLh`?kJ~NPAd3 zOSe_TgX$QnEA2~^R2Mg%v3e*)avbqcrWe09Zil+y;*#YKP!)_%spU*)eKg7STvHG} zr|Q0QD)mWinS@N~n|kpR%Z|y?(joNF%%+0(3u$tUCR66EuTGoYE5d9FHIOfuiBpfG zX3s$Iw0F?gwqBo1&Fs(z0 zb#+ekI!4aOSWwP|i+%4C&3@tV#Jx}PVHndI$yoFPQ3p|r*NH9`teUuLx1>%bS~<1G zUuYa2cfomyurK?5+xA32&U8v5`|c2w_eJIRVOKQ+(a4~nj0t~q`Q@bBPU(bML_n)4 z^GE_WmvElw%NxQ%H9h)eT~ZSE?s^f$l7Q|j98Pc-Q_sHbE)`7;AYt&(zP)kW&B&uP zi-**mFTXDyX&yUMpj6YQzbj>|;|6})`7(zs=Th0}L3b|^{_FWOSajguANSnVB3l?6 zVoQ5>?=*3gj7eRK;N~S@24)}9_F%>9SOV@QelscCV|I6pae9L~GlHEH!E;rgU|Nnq z|3__CBNWjwaU+6}_vj!ks`=3m+7M_(=ix<`Z*Q=i{~hC5a1)h?k}@9oqilRyKg?!5LaY%4`^fW5$>7!OO{gGdwX{(~JJ80X7 z)S62lW|LLa6HJiR+P8JdrsZC*_5@(wFgu0(=}Q87wJPKclTM1mG=?ly3=7HV&s+ z1Y{(^!x0Qdd7@n*@Zya#JxOT8g{*g{OSnm`H1v*{Nv)v<4^v@RVdCu3A*SNyOKJAK z6RRPuSelWNu6?D8>rZjrtv`Sz$(jy{%V^9wvWo(^KLx^UyyRf~db7ruU_+N(qGU8O zq@>K>uM5y&x2p2fmJ)0FJ9b9L4|Dx4A?q-@Q3){d3sYw2}b!RLe zTXTO7g^ZnK?z|UWYNmf0{CK72ji6mSCp>pz=-{oQRl(LEa*Zq9=#!phsCvcB=mT8Y z!SCaT^bpMi4)%D0h*H3BTAEao>9+-UDhFJ+oy?joo8hob?%VE z-H*f@dVrm%xkHhZXwxJl{j+l&^@7xU{$&XCD;W`A=Coo1Xl&_97ITjZp4(ndKYhw| zrr)W(7K`Ktf1_%<7Cbe8WC<5q{>sx1xOb4FOyWeHDLe>P&@QBQ=)yPe;ewGmqL#9t%H}CFYKkFf-)~_^)s>g;6LJP zPr@_vrql_2n%LBC#RBlh%jJ11G^{+`w8H@-D>&WLDjGR=R2#zOS4kF@f7oe!n=^H~ zk8$ga>E4)W(&yDjG-*-Anx-mCr)%kh$EopQ_1|Y1@1P_GJ;h!~jAQZQ0@51$7D>1K zafLRVv5VYS($X>-hR91gDz>rC0k$3U6jS#oR?wUwD^NIRl;vydsxC$%X}?y=gqD<% zE%7Thlytx=M2}!aoZB1QuFZN+Mi?XN6S@O>N3JmHoA;?#Yr`ySj~MeN`%`_*MV}Z6 zd=@&2;W>hRJaD8S^{)rKXnXGq3{%lwf{vzU4dfZJd8wBAc*NB!no%VHY&$`LAAQy* zWgE{nJz}TZOOS7O1W%l`0TDH=<{7#B^K$W~4kjhp2?Ywj#3EHUI9>Qlo;%Y>!i^Y5 zW0BWu6S4Mlm9IK{@uN$Wex9V5938_gwz(O*d@ZN3O{L#qk_D=F=$kl z&V#;`D2Pc6*se#!;U~IS_D)SN0aB-rjHbDynKUC~{BB?ZkZXV|6%HpMMLU=f&=IhZ zXEt6Ka!Qm|_{-&%9CQgRJLkhL29B-A)+ky~n*MfcaP00GqG^wDtj3m!67CC37 z6_U4qxr`$wh>ix4ADE4?-K7bi-}QqkZHwcD#(OUVgdM+@%s=GiBz}PV zv1^3+tGkh@fTtIxfHvsrT*jq!ZiGK(9ndcCiBn#n&Ab-5bk8Px;X!gi7l;6CWslrR zMBp={uniF6bCQuX8jaK@(@Utq%n-V*#E&?9qY0N>e)ZrRa#{<0Ux+S?)^^yeY+A=t z`aXJYGdR*4S)8RHVU@rLudh0Fw*Dp@6}xE@$9(Xz>XvoU#{}Y_)2Uq?O96cd zB3eP^Qk`T3aKQ8yv`?&SlG)#SMs)Ugd3=Xs5gh~B0j6URYeqef?7X_}q3+VSXL#U+ zsQ5&$+*Ibp=fLTm3Xv7zbY@dc6QGCCf>~x_S_Wk7eNsPkI*v9y>>C#Z$%_s~tzCZV zZ0VuW`h0xJn|#Ek?$C_WZ~p3=q-_yr&E3f2x`7ns1>rI^;xPk((&FIq54Gdh{k;WU zO+TXAAHi6hu_modhqTH!v*Uv9?vu}T&46IzSBRBesv2VTt!^N**s0Ptj?iwGR7Be; z$9ovj(xmibc`(L?Um>&^65zjg9~U?FO%;rfd1V9dkO{lgxtn+i12fv>rTVl_AR4{j&x5A;+|b_66Y@jjJ^(_DCz zDf4`-t!L%?xzYUf6x49)TF1~5@Mu;+11i`?bnGdKGB;1VOcSF4P*v%hG6v2TpF}Qa z;ScS~mO)=FuLVur1nmZ$HU|vg0w&Zm7~8Sty<|tNaRmhCMNcQg_dX}5Z!}=??aRh< zPHTX*i}P2k&j^qEU_O~IvM;+dV?iRj%FiZvR<*IG7T;FCF$y7}T;E=91-M$_>tA?B; z(N}R7(~7nD;D@6`O9N=ywsf5%7+luHO1tL(SrlE?=)=&kRdQ20&~0be&wL{eNY;F6 zMC=ZlQ_Mc;A!W@sgxJg zYB8UsHm;3dc$+z`>M46v(8$(rZ8_lj(IcrQCM|86|(*319$_V+* z+g|zhvW4!du8VkyL4DSyuG+}g)1-^?x0ZfFE2Pwp9i^4CV@k3j^$l_7=%!W^fzKQi zBzr}$I6(?ZvbPUFIwOqwdKq@IGasONOxkI)Lb0av9XH3=gBJUj5=mo>Ba3B6cQs9i zY6|(XZ03b^(#4RNGjb^P!pP1j9|F;12SwA1lJd&B92>ViuhePaY8#&~?cWLwURg*^ zk|_-WrN^f)A8ZPvJR<}_p6d`J@vEhnOj}>O zP7O@aM1IIdepqycZ``9ef2@W+NJnywwE&e60+(78>)GEkX}*mpC*lA6H?7knzQkY2 z-FxV8PO)9<;t3XH-2CO{BrXU>*}zCwiS=_GL6|Z;zJVWY5h9(q%tavsb+Y}zr&NMu z3KP_c2y!6aaj?(I}{+@F0ouv6i)b-7-o zX)CakC*)f|C8JN(u!$f@=Z(`d#qVg0#%ssD5b-&0)%4qzf70$5QTg2Gggsyl4!$2} zbZjSV%EzL@c_B+{s{cnSd7D3yrN7sHiOr5qo~`vk_pcF=f4y|P`fC`Kgqu|B8!c=< z0o4W9E9>{L<5#fQ7$EcMmUlgPJKJ9mq;??H{;{>Q*stYbAIVCcPycK=;*%VF*!5L} zqJ6hVmPH7Ihcfq&DxznBk6E#R)8WT!dE0J*0Z`Ea>6YA(?1pN@z!7vD$@~Q|@_6WG zCUJS84q+int~l2IY&3KF4^Ao-C!fT=vj_p6^OUGg79q5P3FC${eJ{-z?0-^MNf3-RpN7>h*{BX_?XMoIQH#o8k#6nHCPJ!2 z;SEKyBT7s}4^8CVJ(-&XR{+R>KkBaj4=N1yJ`<& z@a*=nMGX?r-t$HGT0q@nmGC1mBcJy;0jDz(E&NI6Gce3UXq8S67a=+*PoW=TP1D_ zvswG-Hkk)H7aTZ}M~f2}7OFXIs(}hvkX1D<>OZt##0s|v!L{7J(G&ksepQ00mXm%6 zh5ns55u5D)(0&yklE+ev6%B{WcCq;*%SXawfK?YWBHlwCYjA zPeO0i>lux`?|NIZ`uS~~F$BLB2xjvUPsoi#&?PYkH?LjM$0xu1T=xf}lm>%s|9WL{ z0f_RgG^IRUmD&mni$UP`MXp>S!Iyc~B)J|-#W32e<5|LZ^sqI}uq+_Y*>6j%_)o9k zyQj}E9NkhXU zpC|gN5>fb~rt&tZy$6#$jzp7)_reRHw_8U6lwuLCsk#*rUJTMJTiZ2Nfw}u6BHH$W zIhM9k+xF%i2)peHuN+CyR{lY{jx`EHM-YzV< zXRiGRPktBr3`l73)a-_D(kgwUL^(ps*S&Og{~&orRV)C*)zE&*B^=(nEK%+ZvR9+T z@OuNmZOT>>Ww7iF6>*)WdIdA_{gT6lgLVwdu7dnujSrIwWh+R}hz=qlJ05t)P>tZK z_jg+C_XcK5SO5@RKam?-Wg`73!9 z_uZ-zB|D2i0gvcbQ&5?MY}q)C3iHn(%6G5L3x!_Rb+r)tJny}Md@mP2 z#=QsyF72(VGk}89Pw5H|w6rTSZ?p~v$z);=LwReu8t2(fBOPv&8!~H}jC9%Y)<@wd z1Esp%J8ByGQ_@AwkHCIsV<6=*Avm4OiBxBEQJX}vUVFu+}DY|dvbWu`$szaXr}ETRPjm%v&fAiNbpmPqE|aF^bEf}0z-fZ^k7|>|7icFr>YdH2O%RMLvxk(S zV=P{5fsco-?-3uTPe7^Tt2e|IMjw#n!jAFC@e*i$;W#%5|H5%@#9V(Az<=0of7^W0 zuro(>pW44%mE2P@6N`vt_0FvXV4Xk@4HZ0JF%^V0iQah>*%!nSoex$c^-`PqBnYRV z^gfC#dr`6uRKl=&vh;UHul-BM4BkHlV&K$zIR*A533jcu#YD+^0FQ$p21Lxs2XB1{0qR6aGL28Vzd157p2XX zW!46f7q5?d`QM?9?>t8OqM}fBZZeY&=r)LIWOg_k&0GI}$Of;3mJY}aOFR0JES4VB zx5i9=`}%~17N*$FozV`#ZqN_`PU{g*`^PH!9;`JBUB41n0J;}oT*LjBS{xX>t=&1m z9oybyXJ`JVDTHsU=c&t!=q8-)3NvQiO)BFXI$i<2M7rkb&Lz7$_yMJz&vAy&B3@+Q z{xta$>8noe>O%Fe+(@FU4PMC)HLn$sNu$ij5-;jHZr0rvr9B#txnR7_cM1|B(9Q+Y zh_U;5Ipi{r_4&A>@9rh`a?*~O(pX&bl^beE{e)D%kx>t>vIx(=tT<^aujQ!n`4eyG zfBrA!xT?(tsn?{_OWZvM2x8MFh5{MSh2|-QH7ZE_Zv{EIV=9e9vx|zBXQVyK@h7+| z2liFSI)**wO17QOz*C>72t=Ilwlsx(9xmvvXyzhm0`)ad+=*I1tBD5u#@xPk99IY; z7>UWritHU3H2KwHldehB)_6ni3`8cc%1L9qA<>-3lz*m^akPiaV$^N~=rwssBjE*5 zVbZ3FVPADC>!@48v_^{jFdm_E#`e}@h(m+#$tXyUa<7F{xC#jI{CEnym**pq@ArPW5~~38Cg8pS#t(LfnWyhZt}Pwij760M+)`Fle0WAFIg<;Qs`< z`W~UWXJPhgb4rHAoeD%=S!&!X=o3qmv|jM-7n{uRj4H6NH?Z~D{-p$ovA}TJdR

}X;f`ihI*aT#xp2&uGE^On(ww8HIpH8 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/3.png b/docs/screenshots/3.png new file mode 100644 index 0000000000000000000000000000000000000000..4a8c0def256d572bd5c76100f7e84f7176c12b9f GIT binary patch literal 87591 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 Dwd+|h literal 0 HcmV?d00001 diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py new file mode 100644 index 0000000..c543d9c --- /dev/null +++ b/nomadnet/Conversation.py @@ -0,0 +1,1009 @@ +import os +import re +import RNS +import LXMF +import shutil +import RNS.vendor.umsgpack as msgpack +import nomadnet +from nomadnet.Directory import DirectoryEntry +from LXMF import display_name_from_app_data + +class Conversation: + cached_conversations = {} + unread_conversations = {} + failed_conversations = {} + created_callback = None + + aspect_filter = "lxmf.delivery" + @staticmethod + def received_announce(destination_hash, announced_identity, app_data): + app = nomadnet.NomadNetworkApp.get_shared_instance() + + if not display_name_from_app_data(app_data): + RNS.log("Ignored invalid lxmf.delivery announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + + if not destination_hash in app.ignored_list: + destination_hash_text = RNS.hexrep(destination_hash, delimit=False) + # Notify if the announced destination is one of our + # existing conversations. Checking the directory directly + # avoids scanning and opening every conversation on disk + # for each announce received. + if os.path.isdir(app.conversationpath + "/" + destination_hash_text): + if Conversation.created_callback != None: + Conversation.created_callback() + + # This reformats the new v0.5.0 announce data back to the expected format + # for nomadnets storage and other handling functions. + dn = LXMF.display_name_from_app_data(app_data) + app_data = b"" + if dn != None: app_data = dn.encode("utf-8") + + # Add the announce to the directory announce + # stream logger + app.directory.lxmf_announce_received(destination_hash, app_data) + + else: + RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + + @staticmethod + def query_for_peer(source_hash): + try: + RNS.Transport.request_path(bytes.fromhex(source_hash)) + except Exception as e: + RNS.log("Error while querying network for peer identity. The contained exception was: "+str(e), RNS.LOG_ERROR) + + @staticmethod + def ingest(lxmessage, app, originator = False, delegate = None): + if originator: + source_hash = lxmessage.destination_hash + else: + source_hash = lxmessage.source_hash + + source_hash_path = RNS.hexrep(source_hash, delimit=False) + + conversation_path = app.conversationpath + "/" + source_hash_path + + if not os.path.isdir(conversation_path): + os.makedirs(conversation_path) + if Conversation.created_callback != None: + Conversation.created_callback() + + ingested_path = lxmessage.write_to_directory(conversation_path) + + try: + ConversationMessage.extract_attachments_from_lxm(lxmessage, app) + except Exception as e: + RNS.log("Error extracting attachments: "+str(e), RNS.LOG_ERROR) + + if RNS.hexrep(source_hash, delimit=False) in Conversation.cached_conversations: + conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)] + conversation.scan_storage() + + lxm_state = getattr(lxmessage, "state", None) + is_failed = lxm_state in (LXMF.LXMessage.FAILED, LXMF.LXMessage.REJECTED) + + if not originator: + if source_hash in Conversation.unread_conversations: + Conversation.unread_conversations[source_hash] += 1 + else: + Conversation.unread_conversations[source_hash] = 1 + + try: + dirname = RNS.hexrep(source_hash, delimit=False) + with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf: + uf.write(str(Conversation.unread_conversations[source_hash])) + except Exception: + pass + + elif is_failed: + if source_hash in Conversation.failed_conversations: + Conversation.failed_conversations[source_hash] += 1 + else: + Conversation.failed_conversations[source_hash] = 1 + + try: + dirname = RNS.hexrep(source_hash, delimit=False) + with open(app.conversationpath + "/" + dirname + "/failed", "w") as ff: + ff.write(str(Conversation.failed_conversations[source_hash])) + except Exception: + pass + + if Conversation.created_callback != None: + Conversation.created_callback() + + return ingested_path + + @staticmethod + def conversation_list(app): + conversations = [] + for dirname in os.listdir(app.conversationpath): + if len(dirname) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2 and os.path.isdir(app.conversationpath + "/" + dirname): + try: + source_hash_text = dirname + source_hash = bytes.fromhex(dirname) + app_data = RNS.Identity.recall_app_data(source_hash) + display_name = app.directory.display_name(source_hash) + + unread = 0 + if source_hash in Conversation.unread_conversations: + unread = Conversation.unread_conversations[source_hash] + elif os.path.isfile(app.conversationpath + "/" + dirname + "/unread"): + try: + with open(app.conversationpath + "/" + dirname + "/unread", "r") as uf: + content = uf.read().strip() + unread = int(content) if content else 1 + except Exception: + unread = 1 + Conversation.unread_conversations[source_hash] = unread + + failed = 0 + if source_hash in Conversation.failed_conversations: + failed = Conversation.failed_conversations[source_hash] + elif os.path.isfile(app.conversationpath + "/" + dirname + "/failed"): + try: + with open(app.conversationpath + "/" + dirname + "/failed", "r") as ff: + content = ff.read().strip() + failed = int(content) if content else 1 + except Exception: + failed = 1 + Conversation.failed_conversations[source_hash] = failed + + if display_name == None and app_data: + display_name = LXMF.display_name_from_app_data(app_data) + + if display_name == None: + sort_name = "" + else: + sort_name = display_name + + trust_level = app.directory.trust_level(source_hash, display_name) + + conversation_dir = app.conversationpath + "/" + dirname + try: + last_activity = os.path.getmtime(conversation_dir) + except Exception: + last_activity = 0 + + entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity, failed) + conversations.append(entry) + + except Exception as e: + RNS.log("Error while loading conversation "+str(dirname)+", skipping it. The contained exception was: "+str(e), RNS.LOG_ERROR) + + conversations.sort(key=lambda e: e[5], reverse=True) + + return conversations + + @staticmethod + def cache_conversation(conversation): + Conversation.cached_conversations[conversation.source_hash] = conversation + + @staticmethod + def delete_conversation(source_hash_path, app): + conversation_path = app.conversationpath + "/" + source_hash_path + + try: + if os.path.isdir(conversation_path): + shutil.rmtree(conversation_path) + except Exception as e: + RNS.log("Could not remove conversation at "+str(conversation_path)+". The contained exception was: "+str(e), RNS.LOG_ERROR) + + def __init__(self, source_hash, app, initiator=False): + self.app = app + self.source_hash = source_hash + self.send_destination = None + self.messages = [] + self.messages_path = app.conversationpath + "/" + source_hash + self.messages_load_time = None + self.source_known = False + self.source_trusted = False + self.source_blocked = False + self.unread = False + + self.__changed_callback = None + + if not RNS.Identity.recall(bytes.fromhex(self.source_hash)): + RNS.Transport.request_path(bytes.fromhex(source_hash)) + + self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) + + if self.source_identity: + self.source_known = True + self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + + if initiator: + if not os.path.isdir(self.messages_path): + os.makedirs(self.messages_path) + if Conversation.created_callback != None: + Conversation.created_callback() + + self.scan_storage() + + self.trust_level = app.directory.trust_level(bytes.fromhex(self.source_hash)) + + Conversation.cache_conversation(self) + + def scan_storage(self): + old_len = len(self.messages) + existing = {} + for msg in self.messages: + existing[msg.file_path] = msg + + index = ConversationMessage.read_index(self.messages_path) + + self.messages = [] + for filename in os.listdir(self.messages_path): + if len(filename) == RNS.Identity.HASHLENGTH//8*2: + message_path = self.messages_path + "/" + filename + if message_path in existing: + old_msg = existing[message_path] + try: + current_mtime = os.path.getmtime(message_path) + if current_mtime > old_msg.sort_timestamp: + old_msg._cached_state = None + old_msg._cached_method = None + old_msg.sort_timestamp = current_mtime + except Exception: + pass + self.messages.append(old_msg) + else: + msg = ConversationMessage(message_path) + if filename in index: + msg.restore_from_index(index[filename]) + self.messages.append(msg) + + new_len = len(self.messages) + + needs_index_update = [] + for msg in self.messages: + filename = os.path.basename(msg.file_path) + if msg._cached_state is not None: + if filename not in index: + needs_index_update.append(msg) + elif "content" not in index[filename]: + needs_index_update.append(msg) + if needs_index_update: + ConversationMessage.write_index(self.messages_path, needs_index_update) + + if new_len > old_len: + self.unread = True + + if self.__changed_callback != None: + self.__changed_callback(self) + + def purge_failed(self): + purged_messages = [] + for conversation_message in self.messages: + if conversation_message.get_state() == LXMF.LXMessage.FAILED: + purged_messages.append(conversation_message) + conversation_message.purge() + + for purged_message in purged_messages: + self.messages.remove(purged_message) + + def clear_history(self): + purged_messages = [] + for conversation_message in self.messages: + purged_messages.append(conversation_message) + conversation_message.purge() + + for purged_message in purged_messages: + self.messages.remove(purged_message) + + def register_changed_callback(self, callback): + self.__changed_callback = callback + + def send(self, content="", title="", fields=None): + if self.send_destination: + dest = self.send_destination + source = self.app.lxmf_destination + desired_method = LXMF.LXMessage.DIRECT + if self.app.directory.preferred_delivery(dest.hash) == DirectoryEntry.PROPAGATED: + if self.app.message_router.get_outbound_propagation_node() != None: + desired_method = LXMF.LXMessage.PROPAGATED + else: + if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None: + RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG) + desired_method = LXMF.LXMessage.OPPORTUNISTIC + + dest_is_trusted = False + if self.app.directory.trust_level(dest.hash) == DirectoryEntry.TRUSTED: + dest_is_trusted = True + + lxm = LXMF.LXMessage(dest, source, content, title=title, fields=fields, desired_method=desired_method, include_ticket=dest_is_trusted) + lxm.register_delivery_callback(self.message_notification) + lxm.register_failed_callback(self.message_notification) + + if self.app.message_router.get_outbound_propagation_node() != None: + lxm.try_propagation_on_fail = self.app.try_propagation_on_fail + + self.app.message_router.handle_outbound(lxm) + + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + + return True + else: + RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) + return False + + def paper_output(self, content="", title="", mode="print_qr"): + if self.send_destination: + try: + dest = self.send_destination + source = self.app.lxmf_destination + desired_method = LXMF.LXMessage.PAPER + + lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method) + + if mode == "print_qr": + qr_code = lxm.as_qr() + qr_tmp_path = self.app.tmpfilespath+"/"+str(RNS.hexrep(lxm.hash, delimit=False)) + qr_code.save(qr_tmp_path) + + print_result = self.app.print_file(qr_tmp_path) + os.unlink(qr_tmp_path) + + if print_result: + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + + return print_result + + elif mode == "save_qr": + qr_code = lxm.as_qr() + qr_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".png") + qr_code.save(qr_save_path) + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + return qr_save_path + + elif mode == "save_uri": + lxm_uri = lxm.as_uri()+"\n" + uri_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".txt") + with open(uri_save_path, "wb") as f: + f.write(lxm_uri.encode("utf-8")) + + message_path = Conversation.ingest(lxm, self.app, originator=True) + self.messages.append(ConversationMessage(message_path)) + return uri_save_path + + elif mode == "return_uri": + return lxm.as_uri() + + except Exception as e: + RNS.log("An error occurred while generating paper message, the contained exception was: "+str(e), RNS.LOG_ERROR) + return False + + else: + RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE) + return False + + def message_notification(self, message): + if message.state == LXMF.LXMessage.FAILED and hasattr(message, "try_propagation_on_fail") and message.try_propagation_on_fail: + if hasattr(message, "stamp_generation_failed") and message.stamp_generation_failed == True: + RNS.log(f"Could not send {message} due to a stamp generation failure", RNS.LOG_ERROR) + else: + RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) + message.try_propagation_on_fail = None + message.delivery_attempts = 0 + if hasattr(message, "next_delivery_attempt"): + del message.next_delivery_attempt + message.packed = None + message.desired_method = LXMF.LXMessage.PROPAGATED + self.app.message_router.handle_outbound(message) + else: + message_path = Conversation.ingest(message, self.app, originator=True) + + def __str__(self): + string = self.source_hash + + # TODO: Remove this + # if self.source_identity: + # if self.source_identity.app_data: + # # TODO: Sanitise for viewing, or just clean this + # string += " | "+self.source_identity.app_data.decode("utf-8") + + return string + + + +class ConversationMessage: + # Sentinel marking the renderer as not yet cached, so that a real + # cached value of None (no renderer field) can be told apart from a + # cache miss without forcing a disk load. + _RENDERER_UNSET = object() + + def __init__(self, file_path): + self.file_path = file_path + self.loaded = False + self.timestamp = None + self.lxm = None + + self._cached_renderer = ConversationMessage._RENDERER_UNSET + self._cached_hash = None + self._cached_state = None + self._cached_title = None + self._cached_content = None + self._cached_source_hash = None + self._cached_transport_encrypted = None + self._cached_transport_encryption = None + self._cached_signature_validated = None + self._cached_unverified_reason = None + self._cached_method = None + self._cached_has_attachments = None + self._cached_attachment_names = None + + self.sort_timestamp = os.path.getmtime(file_path) if os.path.isfile(file_path) else 0 + + filename = os.path.basename(file_path) + if len(filename) == RNS.Identity.HASHLENGTH//8*2: + try: + self._cached_hash = bytes.fromhex(filename) + except Exception: + pass + + def load(self): + try: + with open(self.file_path, "rb") as lxm_file: + self.lxm = LXMF.LXMessage.unpack_from_file(lxm_file) + self.loaded = True + self.timestamp = self.lxm.timestamp + self.sort_timestamp = os.path.getmtime(self.file_path) + + + if self.lxm.state > LXMF.LXMessage.GENERATING and self.lxm.state < LXMF.LXMessage.SENT: + found = False + + for pending in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_outbound: + if pending.hash == self.lxm.hash: + found = True + + for pending_id in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_deferred_stamps: + if pending_id == self.lxm.hash: + found = True + + if not found: + self.lxm.state = LXMF.LXMessage.FAILED + + if self._cached_hash is None: + self._cached_hash = self.lxm.hash + self._cached_state = self.lxm.state + if self._cached_source_hash is None: + self._cached_source_hash = self.lxm.source_hash + self._cached_transport_encrypted = self.lxm.transport_encrypted + self._cached_transport_encryption = self.lxm.transport_encryption + if self._cached_signature_validated is None: + self._cached_signature_validated = self.lxm.signature_validated + self._cached_method = self.lxm.method + if hasattr(self.lxm, "get_fields"): + _fields = self.lxm.get_fields() + if _fields and isinstance(_fields, dict) and LXMF.FIELD_RENDERER in _fields: + self._cached_renderer = _fields[LXMF.FIELD_RENDERER] + else: + self._cached_renderer = None + else: + self._cached_renderer = None + if self._cached_unverified_reason is None and hasattr(self.lxm, "unverified_reason"): + self._cached_unverified_reason = self.lxm.unverified_reason + self._cached_title = self.lxm.title_as_string() + self._cached_content = self.lxm.content_as_string() + + if self._cached_has_attachments is None: + found_in_fields = False + fields = None + if hasattr(self.lxm, "get_fields"): + fields = self.lxm.get_fields() + if fields and isinstance(fields, dict): + found_in_fields = ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + if found_in_fields: + names = [] + file_atts = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + for idx, att in enumerate(file_atts): + if isinstance(att, list) and len(att) >= 2: + size = len(att[1]) if isinstance(att[1], bytes) else 0 + safe = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx)) + names.append(("file", safe, size)) + if LXMF.FIELD_IMAGE in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) + if data: + size = len(data) + ext = ConversationMessage._ext_from_media_format(fmt, data) + safe = ConversationMessage.safe_attachment_name("image"+ext, fallback="image") + names.append(("file", safe, size)) + if LXMF.FIELD_AUDIO in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) + if data: + size = len(data) + ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) + safe = ConversationMessage.safe_attachment_name("audio"+ext, fallback="audio") + names.append(("file", safe, size)) + self._cached_has_attachments = True + self._cached_attachment_names = names + + if not found_in_fields: + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest and len(manifest["files"]) > 0: + names = [] + for entry in manifest["files"]: + names.append(("file", entry["name"], entry.get("size", 0))) + self._cached_has_attachments = True + self._cached_attachment_names = names + else: + self._cached_has_attachments = False + self._cached_attachment_names = [] + else: + self._cached_has_attachments = False + self._cached_attachment_names = [] + + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() + ConversationMessage.extract_attachments_from_lxm(self.lxm, app) + ConversationMessage.strip_attachments_from_file(self.file_path, app) + except Exception: + pass + + except Exception as e: + RNS.log("Error while loading LXMF message "+str(self.file_path)+" from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) + + def unload(self): + self.loaded = False + self.lxm = None + + def purge(self): + self.unload() + if os.path.isfile(self.file_path): + os.unlink(self.file_path) + + def get_timestamp(self): + if self.timestamp is not None: + return self.timestamp + if not self.loaded: + self.load() + return self.timestamp + + def get_title(self): + if self._cached_title is not None: + return self._cached_title + if not self.loaded: + self.load() + return self._cached_title if self._cached_title is not None else "" + + def get_content(self): + if self._cached_content is not None: + return self._cached_content + if not self.loaded: + self.load() + return self._cached_content if self._cached_content is not None else "" + + def get_hash(self): + if self._cached_hash is not None: + return self._cached_hash + if not self.loaded: + self.load() + return self._cached_hash + + def get_state(self): + if self._cached_state is not None: + return self._cached_state + if not self.loaded: + self.load() + return self._cached_state + + def get_transport_encryption(self): + if self._cached_transport_encryption is not None: + return self._cached_transport_encryption + if not self.loaded: + self.load() + return self._cached_transport_encryption + + def get_transport_encrypted(self): + if self._cached_transport_encrypted is not None: + return self._cached_transport_encrypted + if not self.loaded: + self.load() + return self._cached_transport_encrypted + + def signature_validated(self): + if self._cached_signature_validated is not None: + return self._cached_signature_validated + if not self.loaded: + self.load() + return self._cached_signature_validated + + def get_signature_description(self): + if self.signature_validated(): + return "Signature Verified" + else: + reason = self._cached_unverified_reason + if reason == LXMF.LXMessage.SOURCE_UNKNOWN: + return "Unknown Origin" + elif reason == LXMF.LXMessage.SIGNATURE_INVALID: + return "Invalid Signature" + else: + return "Unknown signature validation failure" + + def get_fields(self): + if not self.loaded: + self.load() + + if self.lxm and hasattr(self.lxm, "get_fields"): + fields = self.lxm.get_fields() + if fields and isinstance(fields, dict): + return fields + + return {} + + def content_renderer(self): + if self._cached_renderer is not ConversationMessage._RENDERER_UNSET: + return self._cached_renderer + if not self.loaded: + self.load() + # load() sets _cached_renderer on success; if it failed, lxm is None + if self._cached_renderer is ConversationMessage._RENDERER_UNSET: + return None + return self._cached_renderer + + def has_attachments(self): + if self._cached_has_attachments is not None: + return self._cached_has_attachments + fields = self.get_fields() + return ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + + def get_file_attachments(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest: + result = [] + for entry in manifest["files"]: + fpath = os.path.join(att_dir, entry["stored_name"]) + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + result.append([entry["name"], f.read()]) + return result + + fields = self.get_fields() + return fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + + def get_image(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + fpath = os.path.join(att_dir, "image") + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + return f.read() + + fields = self.get_fields() + return fields.get(LXMF.FIELD_IMAGE, None) + + def get_audio(self): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + fpath = os.path.join(att_dir, "audio") + if os.path.isfile(fpath): + with open(fpath, "rb") as f: + return f.read() + + fields = self.get_fields() + return fields.get(LXMF.FIELD_AUDIO, None) + + def get_attachment_file_path(self, field_type, field_index=0): + att_dir = self._attachment_dir() + if att_dir and os.path.isdir(att_dir): + manifest = self._read_attachment_manifest(att_dir) + if manifest and "files" in manifest and field_index < len(manifest["files"]): + return os.path.join(att_dir, manifest["files"][field_index]["stored_name"]) + # Fallback for old extraction format + if field_type == "image": + fpath = os.path.join(att_dir, "image") + if os.path.isfile(fpath): + return fpath + elif field_type == "audio": + fpath = os.path.join(att_dir, "audio") + if os.path.isfile(fpath): + return fpath + return None + + def _attachment_dir(self): + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() + msg_hash = self.get_hash() + if msg_hash: + return os.path.join(app.attachmentpath, RNS.hexrep(msg_hash, delimit=False)) + except Exception: + pass + return None + + def _read_attachment_manifest(self, att_dir): + manifest_path = os.path.join(att_dir, "manifest") + if os.path.isfile(manifest_path): + try: + with open(manifest_path, "rb") as f: + manifest = msgpack.unpackb(f.read(), raw=False) + safe_files = [] + for idx, entry in enumerate(manifest.get("files", [])): + if not isinstance(entry, dict): + continue + entry["name"] = ConversationMessage.safe_attachment_name(entry.get("name"), fallback="attachment_"+str(idx)) + stored = entry.get("stored_name") + if not isinstance(stored, str) or not re.fullmatch(r"file_\d+", stored): + continue + safe_files.append(entry) + manifest["files"] = safe_files + return manifest + + except Exception as e: + RNS.log(f"Error loading attachment manifest: {e}") + return None + + return None + + @staticmethod + def extract_attachments_from_lxm(lxmessage, app): + if not hasattr(lxmessage, "get_fields"): + return + fields = lxmessage.get_fields() + if not fields or not isinstance(fields, dict): + return + + has_any = ( + LXMF.FIELD_FILE_ATTACHMENTS in fields + or LXMF.FIELD_IMAGE in fields + or LXMF.FIELD_AUDIO in fields + ) + if not has_any: + return + + msg_hash_hex = RNS.hexrep(lxmessage.hash, delimit=False) + att_dir = os.path.join(app.attachmentpath, msg_hash_hex) + if os.path.isdir(att_dir): + return + + os.makedirs(att_dir) + manifest = {"files": []} + + file_attachments = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) + if file_attachments: + for idx, att in enumerate(file_attachments): + try: + if isinstance(att, list) and len(att) >= 2: + filename = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx)) + data = att[1] if isinstance(att[1], bytes) else b"" + stored_name = "file_"+str(idx) + with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + except Exception as e: + RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR) + continue + + if LXMF.FIELD_IMAGE in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) + if data: + ext = ConversationMessage._ext_from_media_format(fmt, data) + filename = ConversationMessage.safe_attachment_name("image" + ext, fallback="image") + stored_name = "file_"+str(len(manifest["files"])) + with open(os.path.join(att_dir, stored_name), "wb") as f: + f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + if LXMF.FIELD_AUDIO in fields: + fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) + if data: + ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) + filename = ConversationMessage.safe_attachment_name("audio" + ext, fallback="audio") + stored_name = "file_"+str(len(manifest["files"])) + with open(os.path.join(att_dir, stored_name), "wb") as f: + f.write(data) + manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) + + with open(os.path.join(att_dir, "manifest"), "wb") as f: + f.write(msgpack.packb(manifest)) + + @staticmethod + def safe_attachment_name(name, fallback="attachment"): + try: + if isinstance(name, bytes): + name = name.decode("utf-8", errors="replace") + elif not isinstance(name, str): + name = str(name) if name is not None else "" + except Exception: + name = "" + name = re.sub(r"[\x00-\x1f\x7f]", "", name) + parts = re.split(r"[/\\]", name) + name = parts[-1] if parts else "" + if ":" in name: + name = name.split(":")[-1] + name = name.lstrip(".") + if not name or name in (".", ".."): + return fallback + if len(name) > 200: + base, ext = os.path.splitext(name) + ext = ext[:16] + name = base[:200 - len(ext)] + ext + return name + + @staticmethod + def _unpack_media_field(field_data): + """Normalize FIELD_IMAGE/FIELD_AUDIO which can be raw bytes or [format, bytes]. + Format element can be a string ('webp') or integer (LXMF audio mode constant).""" + if isinstance(field_data, bytes): + return None, field_data + elif isinstance(field_data, list) and len(field_data) >= 2 and isinstance(field_data[1], bytes): + return field_data[0], field_data[1] + return None, None + + @staticmethod + def _detect_image_ext(data): + if not isinstance(data, bytes) or len(data) < 12: + return ".bin" + if data[:8] == b'\x89PNG\r\n\x1a\n': + return ".png" + elif data[:3] == b'\xff\xd8\xff': + return ".jpg" + elif data[:4] == b'GIF8': + return ".gif" + elif data[:4] == b'RIFF' and data[8:12] == b'WEBP': + return ".webp" + elif data[:4] == b'\x00\x00\x00\x1c' or data[:4] == b'\x00\x00\x00\x18': + return ".heic" + return ".bin" + + @staticmethod + def _detect_audio_ext(data): + if not isinstance(data, bytes) or len(data) < 12: + return ".bin" + if data[:4] == b'OggS': + return ".ogg" + elif data[:2] == b'\xff\xfb' or data[:3] == b'ID3': + return ".mp3" + elif data[:4] == b'RIFF' and data[8:12] == b'WAVE': + return ".wav" + elif data[:4] == b'fLaC': + return ".flac" + return ".bin" + + @staticmethod + def _ext_from_media_format(fmt, data, is_audio=False): + if isinstance(fmt, str) and len(fmt) > 0: + safe = re.sub(r"[^A-Za-z0-9]", "", fmt).lower()[:8] + if safe: + return "." + safe + if isinstance(fmt, int) and is_audio: + if fmt >= 16 and fmt <= 25: + return ".ogg" + elif fmt >= 1 and fmt <= 9: + return ".c2" + if is_audio: + return ConversationMessage._detect_audio_ext(data) + return ConversationMessage._detect_image_ext(data) + + @staticmethod + def strip_attachments_from_file(file_path, app): + try: + with open(file_path, "rb") as f: + container = msgpack.unpackb(f.read(), strict_map_key=False) + + lxmf_bytes = container[b"lxmf_bytes"] if b"lxmf_bytes" in container else container.get("lxmf_bytes") + if lxmf_bytes is None: + return + + dest_len = LXMF.LXMessage.DESTINATION_LENGTH + sig_len = LXMF.LXMessage.SIGNATURE_LENGTH + header_len = 2 * dest_len + sig_len + + header = lxmf_bytes[:header_len] + payload_bytes = lxmf_bytes[header_len:] + payload = msgpack.unpackb(payload_bytes, strict_map_key=False) + + if len(payload) < 4 or not isinstance(payload[3], dict): + return + + fields = payload[3] + attachment_keys = [LXMF.FIELD_FILE_ATTACHMENTS, LXMF.FIELD_IMAGE, LXMF.FIELD_AUDIO] + if not any(k in fields for k in attachment_keys): + return + + # Compute message hash matching LXMF's logic + if len(payload) > 4: + hash_payload = msgpack.packb(payload[:4]) + else: + hash_payload = payload_bytes + msg_hash = RNS.Identity.full_hash(lxmf_bytes[:2*dest_len] + hash_payload) + msg_hash_hex = RNS.hexrep(msg_hash, delimit=False) + + att_dir = os.path.join(app.attachmentpath, msg_hash_hex) + if not os.path.isdir(att_dir): + return + + for k in attachment_keys: + if k in fields: + del fields[k] + + new_lxmf_bytes = header + msgpack.packb(payload) + key = b"lxmf_bytes" if b"lxmf_bytes" in container else "lxmf_bytes" + container[key] = new_lxmf_bytes + + with open(file_path, "wb") as f: + f.write(msgpack.packb(container)) + + except Exception as e: + RNS.log("Error stripping attachments from LXM file: "+str(e), RNS.LOG_ERROR) + + def to_index_entry(self): + return { + "timestamp": self.timestamp, + "sort_timestamp": self.sort_timestamp, + "state": self._cached_state, + "title": self._cached_title, + "content": self._cached_content, + "source_hash": self._cached_source_hash, + "transport_encrypted": self._cached_transport_encrypted, + "transport_encryption": self._cached_transport_encryption, + "signature_validated": self._cached_signature_validated, + "unverified_reason": self._cached_unverified_reason, + "method": self._cached_method, + "renderer": None if self._cached_renderer is ConversationMessage._RENDERER_UNSET else self._cached_renderer, + "has_attachments": self._cached_has_attachments, + "attachment_names": self._cached_attachment_names, + } + + def restore_from_index(self, entry): + self.timestamp = entry.get("timestamp") + self.sort_timestamp = entry.get("sort_timestamp", self.sort_timestamp) + self._cached_state = entry.get("state") + self._cached_title = entry.get("title") + self._cached_content = entry.get("content") + self._cached_source_hash = entry.get("source_hash") + self._cached_transport_encrypted = entry.get("transport_encrypted") + self._cached_transport_encryption = entry.get("transport_encryption") + self._cached_signature_validated = entry.get("signature_validated") + self._cached_unverified_reason = entry.get("unverified_reason") + self._cached_method = entry.get("method") + self._cached_renderer = entry.get("renderer", ConversationMessage._RENDERER_UNSET) + self._cached_has_attachments = entry.get("has_attachments") + self._cached_attachment_names = entry.get("attachment_names") + + @staticmethod + def read_index(conversation_path): + index_path = os.path.join(conversation_path, ".index") + if os.path.isfile(index_path): + try: + with open(index_path, "rb") as f: + return msgpack.unpackb(f.read(), raw=False) + except Exception: + pass + return {} + + @staticmethod + def write_index(conversation_path, messages): + index_path = os.path.join(conversation_path, ".index") + index = {} + if os.path.isfile(index_path): + try: + with open(index_path, "rb") as f: + index = msgpack.unpackb(f.read(), raw=False) + except Exception: + index = {} + + for msg in messages: + if msg._cached_state is not None: + filename = os.path.basename(msg.file_path) + index[filename] = msg.to_index_entry() + + try: + with open(index_path, "wb") as f: + f.write(msgpack.packb(index)) + except Exception as e: + RNS.log("Error writing conversation index: "+str(e), RNS.LOG_ERROR) diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py new file mode 100644 index 0000000..f850909 --- /dev/null +++ b/nomadnet/Directory.py @@ -0,0 +1,434 @@ +import os +import RNS +import LXMF +import time +import nomadnet +import threading +import RNS.vendor.umsgpack as msgpack + +from LXMF import pn_announce_data_is_valid +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name + +class PNAnnounceHandler: + def __init__(self, owner): + self.aspect_filter = "lxmf.propagation" + self.owner = owner + + def received_announce(self, destination_hash, announced_identity, app_data): + try: + if pn_announce_data_is_valid(app_data): + data = msgpack.unpackb(app_data) + + if data[2] == True: + RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + + associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) + associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity) + + self.owner.app.directory.pn_announce_received(destination_hash, app_data, associated_peer, associated_node) + self.owner.app.autoselect_propagation_node() + + except Exception as e: + RNS.log("Error while evaluating propagation node announce, ignoring announce.", RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + +class Directory: + ANNOUNCE_STREAM_MAXLENGTH = 256 + + aspect_filter = "nomadnetwork.node" + @staticmethod + def received_announce(destination_hash, announced_identity, app_data): + try: + app = nomadnet.NomadNetworkApp.get_shared_instance() + + if not destination_hash in app.ignored_list: + associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity) + + app.directory.node_announce_received(destination_hash, app_data, associated_peer) + app.autoselect_propagation_node() + + else: + RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while evaluating LXMF destination announce, ignoring announce.", RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + + + @property + def announce_stream(self): + return self._node_announces+self._peer_announces+self._pn_announces + + def _clean_node_announces(self): + while len(self._node_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._node_announces.pop() + + def _clean_peer_announces(self): + while len(self._peer_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._peer_announces.pop() + + def _clean_pn_announces(self): + while len(self._pn_announces) > Directory.ANNOUNCE_STREAM_MAXLENGTH: + self._pn_announces.pop() + + def __init__(self, app): + self.directory_entries = {} + self._node_announces = [] + self._peer_announces = [] + self._pn_announces = [] + self.app = app + self.announce_lock = threading.Lock() + self.load_from_disk() + + self.pn_announce_handler = PNAnnounceHandler(self) + RNS.Transport.register_announce_handler(self.pn_announce_handler) + + + def save_to_disk(self): + try: + packed_list = [] + for source_hash in self.directory_entries: + e = self.directory_entries[source_hash] + packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank, getattr(e, "notes", ""))) + + directory = { + "entry_list": packed_list, + "announce_stream": self.announce_stream + } + + with open(self.app.directorypath, "wb") as file: + file.write(msgpack.packb(directory)) + + except Exception as e: + RNS.log("Could not write directory to disk. Then contained exception was: "+str(e), RNS.LOG_ERROR) + + def load_from_disk(self): + if os.path.isfile(self.app.directorypath): + try: + with open(self.app.directorypath, "rb") as file: + unpacked_directory = msgpack.unpackb(file.read()) + unpacked_list = unpacked_directory["entry_list"] + + entries = {} + for e in unpacked_list: + + if e[1] == None: + e[1] = "Undefined" + + if len(e) > 3: + hosts_node = e[3] + else: + hosts_node = False + + if len(e) > 4: + preferred_delivery = e[4] + else: + preferred_delivery = None + + if len(e) > 5: + identify = e[5] + else: + identify = False + + if len(e) > 6: + sort_rank = e[6] + else: + sort_rank = None + + if len(e) > 7: + notes = e[7] + else: + notes = None + + entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank, notes=notes) + + self.directory_entries = entries + + astream = unpacked_directory["announce_stream"] + self._node_announces = [e for e in astream if e[3] == "node"] + self._peer_announces = [e for e in astream if e[3] == "peer"] + self._pn_announces = [e for e in astream if e[3] == "pn"] + + except Exception as e: + RNS.log("Could not load directory from disk. The contained exception was: "+str(e), RNS.LOG_ERROR) + + def lxmf_announce_received(self, source_hash, app_data): + with self.announce_lock: + if app_data != None: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self._peer_announces: + if announce[1] == source_hash: remove_announces.append(announce) + + for a in remove_announces: self._peer_announces.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self._peer_announces.insert(0, (timestamp, source_hash, app_data, "peer")) + self._clean_peer_announces() + + if hasattr(self.app, "ui") and self.app.ui != None: + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + def node_announce_received(self, source_hash, app_data, associated_peer): + with self.announce_lock: + if app_data != None: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self._node_announces: + if announce[1] == source_hash: + remove_announces.append(announce) + + for a in remove_announces: + self._node_announces.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self._node_announces.insert(0, (timestamp, source_hash, app_data, "node")) + self._clean_node_announces() + + if self.trust_level(associated_peer) == DirectoryEntry.TRUSTED: + existing_entry = self.find(source_hash) + if not existing_entry: + node_entry = DirectoryEntry(source_hash, display_name=app_data.decode("utf-8"), trust_level=DirectoryEntry.TRUSTED, hosts_node=True) + self.remember(node_entry) + + if hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + def pn_announce_received(self, source_hash, app_data, associated_peer, associated_node): + with self.announce_lock: + found_node = None + for sh in self.directory_entries: + if sh == associated_node: + found_node = True + break + + for e in self._pn_announces: + if e[1] == associated_node: + found_node = True + break + + # TODO: Remove debug and rethink this (needs way to set PN when node is saved) + if True or not found_node: + if self.app.compact_stream: + try: + remove_announces = [] + for announce in self._pn_announces: + if announce[1] == source_hash: + remove_announces.append(announce) + + for a in remove_announces: + self._pn_announces.remove(a) + + except Exception as e: + RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR) + + timestamp = time.time() + self._pn_announces.insert(0, (timestamp, source_hash, app_data, "pn")) + self._clean_pn_announces() + + if hasattr(self.app, "ui") and hasattr(self.app.ui, "main_display"): + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + def remove_announce_with_timestamp(self, timestamp): + selected_announce = None + for announce in self._node_announces: + if announce[0] == timestamp: + selected_announce = announce + break + + if selected_announce != None: + self._node_announces.remove(selected_announce) + return + + for announce in self._peer_announces: + if announce[0] == timestamp: + selected_announce = announce + break + + if selected_announce != None: + self._peer_announces.remove(selected_announce) + return + + for announce in self._pn_announces: + if announce[0] == timestamp: + selected_announce = announce + break + + if selected_announce != None: + self._pn_announces.remove(selected_announce) + return + + def display_name(self, source_hash): + if source_hash in self.directory_entries: + return strip_modifiers(self.directory_entries[source_hash].display_name) + else: + return None + + def simplest_display_str(self, source_hash, san=True): + def s(name): + if self.app.config["textui"]["sanitize_names"] and san: return sanitize_name(name) + else: return strip_modifiers(name) + + trust_level = self.trust_level(source_hash) + if trust_level == DirectoryEntry.WARNING or trust_level == DirectoryEntry.UNTRUSTED: + if source_hash in self.directory_entries: + dn = s(self.directory_entries[source_hash].display_name) + if not dn: return RNS.prettyhexrep(source_hash) + else: return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">" + + else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" + + else: + if source_hash in self.directory_entries: + dn = s(self.directory_entries[source_hash].display_name) + if not dn: return RNS.prettyhexrep(source_hash) + else: return dn + + else: return "<"+RNS.hexrep(source_hash, delimit=False)+">" + + def alleged_display_str(self, source_hash): + if source_hash in self.directory_entries: + return strip_modifiers(self.directory_entries[source_hash].display_name) + else: + return None + + + def trust_level(self, source_hash, announced_display_name=None): + if source_hash in self.directory_entries: + if announced_display_name == None: + return self.directory_entries[source_hash].trust_level + else: + if not self.directory_entries[source_hash].trust_level == DirectoryEntry.TRUSTED: + for entry in self.directory_entries: + e = self.directory_entries[entry] + if e.display_name == announced_display_name: + if e.source_hash != source_hash: + return DirectoryEntry.WARNING + + return self.directory_entries[source_hash].trust_level + else: + return DirectoryEntry.UNKNOWN + + def pn_trust_level(self, source_hash): + recalled_identity = RNS.Identity.recall(source_hash) + if recalled_identity != None: + associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", recalled_identity) + return self.trust_level(associated_node) + + def sort_rank(self, source_hash): + if source_hash in self.directory_entries: + return self.directory_entries[source_hash].sort_rank + else: + return None + + def preferred_delivery(self, source_hash): + if source_hash in self.directory_entries: + return self.directory_entries[source_hash].preferred_delivery + else: + return DirectoryEntry.DIRECT + + def remember(self, entry): + self.directory_entries[entry.source_hash] = entry + + identity = RNS.Identity.recall(entry.source_hash) + if identity != None: + associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", identity) + if associated_node in self.directory_entries: + node_entry = self.directory_entries[associated_node] + node_entry.trust_level = entry.trust_level + + self.save_to_disk() + + def forget(self, source_hash): + if source_hash in self.directory_entries: + self.directory_entries.pop(source_hash) + + def find(self, source_hash): + if source_hash in self.directory_entries: + return self.directory_entries[source_hash] + else: + return None + + def is_known(self, source_hash): + try: + self.source_identity = RNS.Identity.recall(source_hash) + + if self.source_identity: + return True + else: + return False + + except Exception as e: + return False + + def should_identify_on_connect(self, source_hash): + if source_hash in self.directory_entries: + entry = self.directory_entries[source_hash] + return entry.identify + else: + return False + + def set_identify_on_connect(self, source_hash, state): + if source_hash in self.directory_entries: + entry = self.directory_entries[source_hash] + entry.identify = state + + def known_nodes(self): + node_list = [] + for eh in self.directory_entries: + e = self.directory_entries[eh] + if e.hosts_node: + node_list.append(e) + + node_list.sort(key = lambda e: (e.sort_rank if e.sort_rank != None else 2^32, DirectoryEntry.TRUSTED-e.trust_level, e.display_name if e.display_name != None else "_")) + return node_list + + def number_of_known_nodes(self): + return len(self.known_nodes()) + + def number_of_known_peers(self, lookback_seconds=None): + unique_hashes = [] + cutoff_time = time.time()-lookback_seconds + for entry in self.announce_stream: + if not entry[1] in unique_hashes: + if lookback_seconds == None or entry[0] > cutoff_time: + unique_hashes.append(entry[1]) + + return len(unique_hashes) + +class DirectoryEntry: + WARNING = 0x00 + UNTRUSTED = 0x01 + UNKNOWN = 0x02 + TRUSTED = 0xFF + + DIRECT = 0x01 + PROPAGATED = 0x02 + + def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None, notes=None): + if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: + self.source_hash = source_hash + self.display_name = display_name + self.sort_rank = sort_rank + + if preferred_delivery == None: + self.preferred_delivery = DirectoryEntry.DIRECT + else: + self.preferred_delivery = preferred_delivery + + self.trust_level = trust_level + self.hosts_node = hosts_node + self.identify = identify_on_connect + self.notes = notes or "" + else: + raise TypeError("Attempt to add invalid source hash to directory") diff --git a/nomadnet/Node.py b/nomadnet/Node.py new file mode 100644 index 0000000..acf54ff --- /dev/null +++ b/nomadnet/Node.py @@ -0,0 +1,266 @@ +import os +import sys + +import RNS +import time +import threading +import subprocess +import RNS.vendor.umsgpack as msgpack + +class Node: + JOB_INTERVAL = 5 + START_ANNOUNCE_DELAY = 6 + + def __init__(self, app): + RNS.log("Nomad Network Node starting...", RNS.LOG_VERBOSE) + self.app = app + self.identity = self.app.identity + self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, "nomadnetwork", "node") + self.last_announce = time.time() + self.last_file_refresh = time.time() + self.last_page_refresh = time.time() + self.announce_interval = self.app.node_announce_interval + self.page_refresh_interval = self.app.page_refresh_interval + self.file_refresh_interval = self.app.file_refresh_interval + self.job_interval = Node.JOB_INTERVAL + self.should_run_jobs = True + self.app_data = None + self.name = self.app.node_name + + self.register_pages() + self.register_files() + + self.destination.set_link_established_callback(self.peer_connected) + + if self.name == None: + self.name = self.app.peer_settings["display_name"]+"'s Node" + + RNS.log("Node \""+self.name+"\" ready for incoming connections on "+RNS.prettyhexrep(self.destination.hash), RNS.LOG_VERBOSE) + + if self.app.node_announce_at_start: + def delayed_announce(): + time.sleep(Node.START_ANNOUNCE_DELAY) + self.announce() + + da_thread = threading.Thread(target=delayed_announce) + da_thread.setDaemon(True) + da_thread.start() + + job_thread = threading.Thread(target=self.__jobs) + job_thread.setDaemon(True) + job_thread.start() + + + def register_pages(self): + # TODO: Deregister previously registered pages + # that no longer exist. + self.servedpages = [] + self.scan_pages(self.app.pagespath) + + if not self.app.pagespath+"index.mu" in self.servedpages: + self.destination.register_request_handler( + "/page/index.mu", + response_generator = self.serve_default_index, + allow = RNS.Destination.ALLOW_ALL) + + for page in self.servedpages: + request_path = "/page"+page.replace(self.app.pagespath, "") + self.destination.register_request_handler( + request_path, + response_generator = self.serve_page, + allow = RNS.Destination.ALLOW_ALL) + + def register_files(self): + # TODO: Deregister previously registered files + # that no longer exist. + self.servedfiles = [] + self.scan_files(self.app.filespath) + + for file in self.servedfiles: + request_path = "/file"+file.replace(self.app.filespath, "") + self.destination.register_request_handler( + request_path, + response_generator = self.serve_file, + allow = RNS.Destination.ALLOW_ALL, + auto_compress = 32_000_000) + + def scan_pages(self, base_path): + files = [file for file in os.listdir(base_path) if os.path.isfile(os.path.join(base_path, file)) and file[:1] != "."] + directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."] + + for file in files: + if not file.endswith(".allowed"): + self.servedpages.append(base_path+"/"+file) + + for directory in directories: + self.scan_pages(base_path+"/"+directory) + + def scan_files(self, base_path): + files = [file for file in os.listdir(base_path) if os.path.isfile(os.path.join(base_path, file)) and file[:1] != "."] + directories = [file for file in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, file)) and file[:1] != "."] + + for file in files: + self.servedfiles.append(base_path+"/"+file) + + for directory in directories: + self.scan_files(base_path+"/"+directory) + + def serve_page(self, path, data, request_id, link_id, remote_identity, requested_at): + RNS.log("Page request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) + try: + self.app.peer_settings["served_page_requests"] += 1 + self.app.save_peer_settings() + + except Exception as e: + RNS.log("Could not increase served page request count", RNS.LOG_ERROR) + + file_path = path.replace("/page", self.app.pagespath, 1) + + allowed_path = file_path+".allowed" + request_allowed = False + + if os.path.isfile(allowed_path): + allowed_list = [] + + try: + if os.access(allowed_path, os.X_OK): + allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE) + allowed_input = allowed_result.stdout + + else: + with open(allowed_path, "rb") as fh: + allowed_input = fh.read() + + allowed_hash_strs = allowed_input.splitlines() + + for hash_str in allowed_hash_strs: + if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: + try: + allowed_hash = bytes.fromhex(hash_str.decode("utf-8")) + allowed_list.append(allowed_hash) + + except Exception as e: + RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR) + + if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list: + request_allowed = True + else: + request_allowed = False + RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE) + + else: + request_allowed = True + + try: + if request_allowed: + RNS.log("Serving page: "+file_path, RNS.LOG_VERBOSE) + if not RNS.vendor.platformutils.is_windows() and os.access(file_path, os.X_OK): + env_map = {} + if "PATH" in os.environ: + env_map["PATH"] = os.environ["PATH"] + if link_id != None: + env_map["link_id"] = RNS.hexrep(link_id, delimit=False) + if remote_identity != None: + env_map["remote_identity"] = RNS.hexrep(remote_identity.hash, delimit=False) + + if data != None and isinstance(data, dict): + for e in data: + if isinstance(e, str) and (e.startswith("field_") or e.startswith("var_")): + env_map[e] = data[e] + + generated = subprocess.run([file_path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env_map) + return generated.stdout + else: + with open(file_path, "rb") as fh: + response_data = fh.read() + return response_data + else: + RNS.log("Request denied", RNS.LOG_VERBOSE) + return DEFAULT_NOTALLOWED.encode("utf-8") + + except Exception as e: + RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + return None + + # TODO: Improve file handling, this will be slow for large files + def serve_file(self, path, data, request_id, remote_identity, requested_at): + RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) + try: + self.app.peer_settings["served_file_requests"] += 1 + self.app.save_peer_settings() + + except Exception as e: + RNS.log("Could not increase served file request count", RNS.LOG_ERROR) + + file_path = path.replace("/file", self.app.filespath, 1) + file_name = path.replace("/file/", "", 1) + try: + RNS.log("Serving file: "+file_path, RNS.LOG_VERBOSE) + return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}] + + except Exception as e: + RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + return None + + def serve_default_index(self, path, data, request_id, remote_identity, requested_at): + RNS.log("Serving default index for request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE) + return DEFAULT_INDEX.encode("utf-8") + + def announce(self): + self.app_data = self.name.encode("utf-8") + self.last_announce = time.time() + self.app.peer_settings["node_last_announce"] = self.last_announce + self.destination.announce(app_data=self.app_data) + self.app.message_router.announce_propagation_node() + + def __jobs(self): + while self.should_run_jobs: + now = time.time() + + if now > self.last_announce + self.announce_interval*60: + self.announce() + + if self.page_refresh_interval > 0: + if now > self.last_page_refresh + self.page_refresh_interval*60: + self.register_pages() + self.last_page_refresh = time.time() + + if self.file_refresh_interval > 0: + if now > self.last_file_refresh + self.file_refresh_interval*60: + self.register_files() + self.last_file_refresh = time.time() + + time.sleep(self.job_interval) + + def peer_connected(self, link): + RNS.log("Peer connected to "+str(self.destination), RNS.LOG_VERBOSE) + try: + self.app.peer_settings["node_connects"] += 1 + self.app.save_peer_settings() + + except Exception as e: + RNS.log("Could not increase node connection count", RNS.LOG_ERROR) + + link.set_link_closed_callback(self.peer_disconnected) + + def peer_disconnected(self, link): + RNS.log("Peer disconnected from "+str(self.destination), RNS.LOG_VERBOSE) + pass + +DEFAULT_INDEX = '''>Default Home Page + +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. + +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. +''' + +DEFAULT_NOTALLOWED = '''>Request Not Allowed + +You are not authorised to carry out the request. +''' diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py new file mode 100644 index 0000000..522a77c --- /dev/null +++ b/nomadnet/NomadNetworkApp.py @@ -0,0 +1,1644 @@ +import os +import io +import sys +import time +import shlex +import atexit +import threading +import traceback +import subprocess +import contextlib + +import RNS +import LXMF +import nomadnet + +from nomadnet.Directory import DirectoryEntry +from datetime import datetime + +import RNS.vendor.umsgpack as msgpack + +from ._version import __version__ +from RNS.vendor.configobj import ConfigObj + +class NomadNetworkApp: + time_format = "%Y-%m-%d %H:%M:%S" + _shared_instance = None + + userdir = os.path.expanduser("~") + if os.path.isdir("/etc/nomadnetwork") and os.path.isfile("/etc/nomadnetwork/config"): + configdir = "/etc/nomadnetwork" + elif os.path.isdir(userdir+"/.config/nomadnetwork") and os.path.isfile(userdir+"/.config/nomadnetwork/config"): + configdir = userdir+"/.config/nomadnetwork" + else: + configdir = userdir+"/.nomadnetwork" + + START_ANNOUNCE_DELAY = 3 + + def exit_handler(self): + self.should_run_jobs = False + + RNS.log("Saving directory...", RNS.LOG_VERBOSE) + self.directory.save_to_disk() + + if hasattr(self, "rrc") and self.rrc is not None: + try: + self.rrc.save() + self.rrc.shutdown() + except Exception: + pass + + if hasattr(self.ui, "restore_ixon"): + if self.ui.restore_ixon: + try: + os.system("stty ixon") + + except Exception as e: + RNS.log("Could not restore flow control sequences. The contained exception was: "+str(e), RNS.LOG_WARNING) + + if hasattr(self.ui, "restore_palette"): + if self.ui.restore_palette: + try: + self.ui.screen.write("\x1b]104\x07") + + except Exception as e: + RNS.log("Could not restore terminal color palette. The contained exception was: "+str(e), RNS.LOG_WARNING) + + RNS.log("Nomad Network Client exiting now", RNS.LOG_VERBOSE) + + def exception_handler(self, e_type, e_value, e_traceback): + RNS.log("An unhandled exception occurred, the details of which will be dumped below", RNS.LOG_ERROR) + RNS.log("Type : "+str(e_type), RNS.LOG_ERROR) + RNS.log("Value : "+str(e_value), RNS.LOG_ERROR) + t_string = "" + for line in traceback.format_tb(e_traceback): + t_string += line + RNS.log("Trace : \n"+t_string, RNS.LOG_ERROR) + + if issubclass(e_type, KeyboardInterrupt): + sys.__excepthook__(e_type, e_value, e_traceback) + + def __init__(self, configdir = None, rnsconfigdir = None, daemon = False, force_console = False): + self.version = __version__ + self.enable_client = False + self.enable_node = False + self.identity = None + + self.uimode = None + + self.announce_interval = 6*60*60 + + if configdir == None: + self.configdir = NomadNetworkApp.configdir + else: + self.configdir = configdir + + if force_console: + self.force_console_log = True + else: + self.force_console_log = False + + if NomadNetworkApp._shared_instance == None: + NomadNetworkApp._shared_instance = self + + self.rns = RNS.Reticulum(configdir = rnsconfigdir) + + self.configpath = self.configdir+"/config" + self.ignoredpath = self.configdir+"/ignored" + self.logfilepath = self.configdir+"/logfile" + self.errorfilepath = self.configdir+"/errors" + self.pnannouncedpath = self.configdir+"/pnannounced" + self.storagepath = self.configdir+"/storage" + self.identitypath = self.configdir+"/storage/identity" + self.cachepath = self.configdir+"/storage/cache" + self.resourcepath = self.configdir+"/storage/resources" + self.conversationpath = self.configdir+"/storage/conversations" + self.directorypath = self.configdir+"/storage/directory" + self.peersettingspath = self.configdir+"/storage/peersettings" + self.tmpfilespath = self.configdir+"/storage/tmp" + self.attachmentpath = self.configdir+"/storage/attachments" + + self.pagespath = self.configdir+"/storage/pages" + self.filespath = self.configdir+"/storage/files" + self.cachepath = self.configdir+"/storage/cache" + self.examplespath = self.configdir+"/examples" + + self.downloads_path = os.path.expanduser("~/Downloads") + self.attachment_save_path = None + + self.firstrun = False + self.should_run_jobs = True + self.job_interval = 5 + self.defer_jobs = 90 + self.page_refresh_interval = 0 + self.file_refresh_interval = 0 + + self.static_peers = [] + self.peer_announce_at_start = True + self.try_propagation_on_fail = True + self.disable_propagation = True + self.notify_on_new_message = True + self.compose_markdown = True + + self.lxmf_max_propagation_size = None + self.lxmf_max_sync_size = None + self.lxmf_max_incoming_size = None + self.node_propagation_cost = LXMF.LXMRouter.PROPAGATION_COST + + self.periodic_lxmf_sync = True + self.lxmf_sync_interval = 360*60 + self.lxmf_sync_limit = 8 + self.compact_stream = False + + self.required_stamp_cost = None + self.accept_invalid_stamps = False + + self.rrc_history_per_room_cap = 500 + self.rrc_filter_loaded_history = True + self.rrc_ephemeral_notices = 600 + self.rrc_nick_colors = True + self.rrc_nick_colors_theme = None + self.rrc_mention_color = None + self.rrc_color_mention_timestamps = True + self.rrc_ui_justify_msgs = True + self.rrc_ui_space_msgs = False + self.rrc_ui_render_markdown = True + self.rrc_ui_render_micron = True + self.rrc_show_gutters = False + self.rrc_enable_esoterics = False + + if not os.path.isdir(self.storagepath): + os.makedirs(self.storagepath) + + if not os.path.isdir(self.cachepath): + os.makedirs(self.cachepath) + + if not os.path.isdir(self.resourcepath): + os.makedirs(self.resourcepath) + + if not os.path.isdir(self.conversationpath): + os.makedirs(self.conversationpath) + + if not os.path.isdir(self.pagespath): + os.makedirs(self.pagespath) + + if not os.path.isdir(self.filespath): + os.makedirs(self.filespath) + + if not os.path.isdir(self.cachepath): + os.makedirs(self.cachepath) + + if not os.path.isdir(self.tmpfilespath): + os.makedirs(self.tmpfilespath) + + if not os.path.isdir(self.attachmentpath): + os.makedirs(self.attachmentpath) + else: + self.clear_tmp_dir() + + if os.path.isfile(self.configpath): + try: + self.config = ConfigObj(self.configpath) + try: + self.applyConfig() + except Exception as e: + RNS.log("The configuration file is invalid. The contained exception was: "+str(e), RNS.LOG_ERROR) + nomadnet.panic() + + RNS.log("Configuration loaded from "+self.configpath) + except Exception as e: + RNS.log("Could not parse the configuration at "+self.configpath, RNS.LOG_ERROR) + RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR) + nomadnet.panic() + else: + if not os.path.isdir(self.examplespath): + try: + import shutil + examplespath = os.path.join(os.path.dirname(__file__), "examples") + shutil.copytree(examplespath, self.examplespath, ignore=shutil.ignore_patterns("__pycache__")) + + except Exception as e: + RNS.log("Could not copy examples into the "+self.examplespath+" directory.", RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + + RNS.log("Could not load config file, creating default configuration file...") + self.createDefaultConfig() + self.firstrun = True + + if os.path.isfile(self.identitypath): + try: + self.identity = RNS.Identity.from_file(self.identitypath) + if self.identity != None: + RNS.log("Loaded Primary Identity %s from %s" % (str(self.identity), self.identitypath)) + else: + RNS.log("Could not load the Primary Identity from "+self.identitypath, RNS.LOG_ERROR) + nomadnet.panic() + except Exception as e: + RNS.log("Could not load the Primary Identity from "+self.identitypath, RNS.LOG_ERROR) + RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) + nomadnet.panic() + else: + try: + RNS.log("No Primary Identity file found, creating new...") + self.identity = RNS.Identity() + self.identity.to_file(self.identitypath) + RNS.log("Created new Primary Identity %s" % (str(self.identity))) + except Exception as e: + RNS.log("Could not create and save a new Primary Identity", RNS.LOG_ERROR) + RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) + nomadnet.panic() + + if os.path.isfile(self.peersettingspath): + try: + file = open(self.peersettingspath, "rb") + self.peer_settings = msgpack.unpackb(file.read()) + file.close() + + if not "node_last_announce" in self.peer_settings: + self.peer_settings["node_last_announce"] = None + + if not "propagation_node" in self.peer_settings: + self.peer_settings["propagation_node"] = None + + if not "last_lxmf_sync" in self.peer_settings: + self.peer_settings["last_lxmf_sync"] = 0 + + if not "node_connects" in self.peer_settings: + self.peer_settings["node_connects"] = 0 + + if not "served_page_requests" in self.peer_settings: + self.peer_settings["served_page_requests"] = 0 + + if not "served_file_requests" in self.peer_settings: + self.peer_settings["served_file_requests"] = 0 + + self.peer_settings["announce_interval"] = self.announce_interval + + except Exception as e: + RNS.logdest = RNS.LOG_STDOUT + RNS.log(f"Could not load local peer settings from {self.peersettingspath}", RNS.LOG_ERROR) + RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR) + RNS.log(f"This likely means that the peer settings file has become corrupt.", RNS.LOG_ERROR) + RNS.log(f"You can try deleting the file at {self.peersettingspath} and restarting nomadnet.", RNS.LOG_ERROR) + nomadnet.panic() + else: + try: + RNS.log("No peer settings file found, creating new...") + self.peer_settings = { + "display_name": "Anonymous Peer", + "announce_interval": self.announce_interval, + "last_announce": None, + "node_last_announce": None, + "propagation_node": None, + "last_lxmf_sync": 0, + "node_connects": 0, + "served_page_requests": 0, + "served_file_requests": 0 + } + self.save_peer_settings() + RNS.log("Created new peer settings file") + except Exception as e: + RNS.log("Could not create and save a new peer settings file", RNS.LOG_ERROR) + RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR) + nomadnet.panic() + + self.ignored_list = [] + if os.path.isfile(self.ignoredpath): + try: + fh = open(self.ignoredpath, "rb") + ignored_input = fh.read() + fh.close() + + ignored_hash_strs = ignored_input.splitlines() + + for hash_str in ignored_hash_strs: + if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2: + try: + ignored_hash = bytes.fromhex(hash_str.decode("utf-8")) + self.ignored_list.append(ignored_hash) + + except Exception as e: + RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG) + RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while loading list of ignored destinations: "+str(e), RNS.LOG_ERROR) + + self.directory = nomadnet.Directory(self) + + from nomadnet.RRC import RRCManager + self.rrc = RRCManager(self) + self.rrc.load() + + static_peers = [] + for static_peer in self.static_peers: + try: + dh = bytes.fromhex(static_peer) + if len(dh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + raise ValueError("Invalid destination length") + static_peers.append(dh) + except Exception as e: + RNS.log(f"Could not decode static peer destination hash {static_peer}: {e}", RNS.LOG_ERROR) + + self.message_router = LXMF.LXMRouter( + identity = self.identity, storagepath = self.storagepath, autopeer = True, + propagation_limit = self.lxmf_max_propagation_size, sync_limit = self.lxmf_max_sync_size, delivery_limit = self.lxmf_max_incoming_size, + max_peers = self.max_peers, static_peers = static_peers, propagation_cost=self.node_propagation_cost + ) + + self.message_router.register_delivery_callback(self.lxmf_delivery) + + for destination_hash in self.ignored_list: + self.message_router.ignore_destination(destination_hash) + + self.lxmf_destination = self.message_router.register_delivery_identity(self.identity, display_name=self.peer_settings["display_name"], stamp_cost=self.required_stamp_cost) + if not self.accept_invalid_stamps: + self.message_router.enforce_stamps() + + RNS.Identity.remember( + packet_hash=None, + destination_hash=self.lxmf_destination.hash, + public_key=self.identity.get_public_key(), + app_data=None + ) + + RNS.log("LXMF Router ready to receive on: "+RNS.prettyhexrep(self.lxmf_destination.hash)) + + if self.enable_node: + self.message_router.set_message_storage_limit(megabytes=self.message_storage_limit) + for dest_str in self.prioritised_lxmf_destinations: + try: + dest_hash = bytes.fromhex(dest_str) + if len(dest_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + self.message_router.prioritise(dest_hash) + + except Exception as e: + RNS.log("Cannot prioritise "+str(dest_str)+", it is not a valid destination hash", RNS.LOG_ERROR) + + if self.disable_propagation: + if os.path.isfile(self.pnannouncedpath): + try: + RNS.log("Sending indication to peered LXMF Propagation Node that this node is no longer participating", RNS.LOG_DEBUG) + self.message_router.disable_propagation() + os.unlink(self.pnannouncedpath) + except Exception as e: + RNS.log("An error ocurred while indicating that this LXMF Propagation Node is no longer participating. The contained exception was: "+str(e), RNS.LOG_ERROR) + else: + self.message_router.enable_propagation() + try: + with open(self.pnannouncedpath, "wb") as pnf: + pnf.write(msgpack.packb(time.time())) + pnf.close() + + except Exception as e: + RNS.log("An error ocurred while writing Propagation Node announce timestamp. The contained exception was: "+str(e), RNS.LOG_ERROR) + + if not self.disable_propagation: + RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) + + self.node = nomadnet.Node(self) + else: + self.node = None + if os.path.isfile(self.pnannouncedpath): + try: + RNS.log("Sending indication to peered LXMF Propagation Node that this node is no longer participating", RNS.LOG_DEBUG) + self.message_router.disable_propagation() + os.unlink(self.pnannouncedpath) + except Exception as e: + RNS.log("An error ocurred while indicating that this LXMF Propagation Node is no longer participating. The contained exception was: "+str(e), RNS.LOG_ERROR) + + RNS.Transport.register_announce_handler(nomadnet.Conversation) + RNS.Transport.register_announce_handler(nomadnet.Directory) + + self.autoselect_propagation_node() + + if self.peer_announce_at_start: + def delayed_announce(): + time.sleep(NomadNetworkApp.START_ANNOUNCE_DELAY) + self.announce_now() + + da_thread = threading.Thread(target=delayed_announce) + da_thread.setDaemon(True) + da_thread.start() + + atexit.register(self.exit_handler) + sys.excepthook = self.exception_handler + + job_thread = threading.Thread(target=self.__jobs) + job_thread.setDaemon(True) + job_thread.start() + + # Override UI choice from config on --daemon switch + if daemon: + self.uimode = nomadnet.ui.UI_NONE + + # This stderr redirect is needed to stop urwid + # from spewing KeyErrors to the console and thus, + # messing up the UI. A pull request to fix the + # bug in urwid was submitted, but until it is + # merged, this hack will mitigate it. + strio = io.StringIO() + with contextlib.redirect_stderr(strio): + nomadnet.ui.spawn(self.uimode) + + if strio.tell() > 0: + try: + strio.seek(0) + err_file = open(self.errorfilepath, "w") + err_file.write(strio.read()) + err_file.close() + + except Exception as e: + RNS.log("Could not write stderr output to error log file at "+str(self.errorfilepath)+".", RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + + + def __jobs(self): + RNS.log("Deferring scheduled jobs for "+str(self.defer_jobs)+" seconds...", RNS.LOG_DEBUG) + time.sleep(self.defer_jobs) + + RNS.log("Starting job scheduler now", RNS.LOG_DEBUG) + while self.should_run_jobs: + now = time.time() + + if now > self.peer_settings["last_lxmf_sync"] + self.lxmf_sync_interval: + RNS.log("Initiating automatic LXMF sync", RNS.LOG_VERBOSE) + self.request_lxmf_sync(limit=self.lxmf_sync_limit) + + if now > self.peer_settings["last_announce"] + self.peer_settings["announce_interval"]: + self.announce_now() + + time.sleep(self.job_interval) + + def set_display_name(self, display_name): + self.peer_settings["display_name"] = display_name + self.lxmf_destination.display_name = display_name + self.save_peer_settings() + + def get_display_name(self): + return self.peer_settings["display_name"] + + def get_display_name_bytes(self): + return self.peer_settings["display_name"].encode("utf-8") + + def get_sync_status(self): + if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE: + return "Idle" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_PATH_REQUESTED: + return "Path requested" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHING: + return "Establishing link" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHED: + return "Link established" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_REQUEST_SENT: + return "Sync request sent" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RECEIVING: + return "Receiving messages" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: + return "Messages received" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_PATH: + return "No path to node" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_FAILED: + return "Link establisment failed" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_TRANSFER_FAILED: + return "Sync request failed" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_IDENTITY_RCVD: + return "Remote got no identity" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_NO_ACCESS: + return "Node rejected request" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_FAILED: + return "Sync failed" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + new_msgs = self.message_router.propagation_transfer_last_result + if new_msgs == 0: + return "Done, no new messages" + else: + return "Downloaded "+str(new_msgs)+" new messages" + else: + return "Unknown" + + def sync_status_show_percent(self): + if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE: + return False + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_PATH_REQUESTED: + return False + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHING: + return False + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHED: + return False + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_REQUEST_SENT: + return False + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RECEIVING: + return True + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: + return True + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + return False + else: + return False + + def get_sync_progress(self): + return self.message_router.propagation_transfer_progress + + def request_lxmf_sync(self, limit = None): + if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE or self.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: + self.peer_settings["last_lxmf_sync"] = time.time() + self.save_peer_settings() + self.message_router.request_messages_from_propagation_node(self.identity, max_messages = limit) + + def cancel_lxmf_sync(self): + if self.message_router.propagation_transfer_state != LXMF.LXMRouter.PR_IDLE: + self.message_router.cancel_propagation_node_requests() + + def _persist_ignored_list(self): + try: + with open(self.ignoredpath, "wb") as fh: + for h in self.ignored_list: + fh.write((RNS.hexrep(h, delimit=False)+"\n").encode("utf-8")) + except Exception as e: + RNS.log("Could not persist ignored list: "+str(e), RNS.LOG_ERROR) + + def block_destination(self, dest_hash, reason=None): + if not isinstance(dest_hash, (bytes, bytearray)): + return False + dest_hash = bytes(dest_hash) + try: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + RNS.Transport.blackhole_identity(identity.hash, reason=reason) + except Exception as e: + RNS.log("Could not blackhole identity: "+str(e), RNS.LOG_ERROR) + if dest_hash not in self.ignored_list: + self.ignored_list.append(dest_hash) + self._persist_ignored_list() + try: + self.message_router.ignore_destination(dest_hash) + except Exception: + pass + return True + + def unblock_destination(self, dest_hash): + if not isinstance(dest_hash, (bytes, bytearray)): + return False + dest_hash = bytes(dest_hash) + try: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + RNS.Transport.unblackhole_identity(identity.hash) + except Exception as e: + RNS.log("Could not lift blackhole on identity: "+str(e), RNS.LOG_ERROR) + if dest_hash in self.ignored_list: + self.ignored_list.remove(dest_hash) + self._persist_ignored_list() + try: + self.message_router.unignore_destination(dest_hash) + except Exception: + pass + return True + + def announce_now(self): + RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE) + self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost) + self.lxmf_destination.display_name = self.peer_settings["display_name"] + self.message_router.announce(self.lxmf_destination.hash) + self.peer_settings["last_announce"] = time.time() + self.save_peer_settings() + + def autoselect_propagation_node(self): + selected_node = None + + if "propagation_node" in self.peer_settings and self.peer_settings["propagation_node"] != None: + selected_node = self.peer_settings["propagation_node"] + else: + nodes = self.directory.known_nodes() + trusted_nodes = [] + + best_hops = RNS.Transport.PATHFINDER_M+1 + + for node in nodes: + if node.trust_level == DirectoryEntry.TRUSTED: + hops = RNS.Transport.hops_to(node.source_hash) + + if hops < best_hops: + best_hops = hops + selected_node = node.source_hash + + if selected_node == None: + RNS.log("Could not autoselect a propagation node! LXMF propagation will not be available until a trusted node announces on the network, or a propagation node is manually selected.", RNS.LOG_WARNING) + else: + pn_name_str = "" + RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_DEBUG) + self.message_router.set_outbound_propagation_node(selected_node) + + def get_user_selected_propagation_node(self): + if "propagation_node" in self.peer_settings: + return self.peer_settings["propagation_node"] + else: + return None + + def set_user_selected_propagation_node(self, node_hash): + self.peer_settings["propagation_node"] = node_hash + self.save_peer_settings() + self.autoselect_propagation_node() + + def get_default_propagation_node(self): + return self.message_router.get_outbound_propagation_node() + + def save_peer_settings(self): + tmp_path = f"{self.peersettingspath}.tmp" + with open(tmp_path, "wb") as file: file.write(msgpack.packb(self.peer_settings)) + os.replace(tmp_path, self.peersettingspath) + + def lxmf_delivery(self, message): + time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) + signature_string = "Signature is invalid, reason undetermined" + if message.signature_validated: + signature_string = "Validated" + else: + if message.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID: + signature_string = "Invalid signature" + if message.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN: + signature_string = "Cannot verify, source is unknown" + + nomadnet.Conversation.ingest(message, self) + + if self.notify_on_new_message: + self.notify_message_recieved() + + if self.should_print(message): + self.print_message(message) + + def should_print(self, message): + if self.print_messages: + if self.print_all_messages: + return True + + else: + source_hash_text = RNS.hexrep(message.source_hash, delimit=False) + + if self.print_trusted_messages: + trust_level = self.directory.trust_level(message.source_hash) + if trust_level == DirectoryEntry.TRUSTED: + return True + + if type(self.allowed_message_print_destinations) is list: + if source_hash_text in self.allowed_message_print_destinations: + return True + + return False + + def print_file(self, filename): + print_command = self.print_command+" "+filename + + try: + return_code = subprocess.call(shlex.split(print_command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + except Exception as e: + RNS.log("An error occurred while executing print command: "+str(print_command), RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + return False + + if return_code == 0: + RNS.log("Successfully printed "+str(filename)+" using print command: "+print_command, RNS.LOG_DEBUG) + return True + + else: + RNS.log("Printing "+str(filename)+" failed using print command: "+print_command, RNS.LOG_DEBUG) + return False + + + def print_message(self, message, received = None): + try: + template = self.printing_template_msg + + if received == None: + received = time.time() + + g = self.ui.glyphs + + m_rtime = datetime.fromtimestamp(message.timestamp) + stime = m_rtime.strftime(self.time_format) + + message_time = datetime.fromtimestamp(received) + rtime = message_time.strftime(self.time_format) + + display_name = self.directory.simplest_display_str(message.source_hash) + title = message.title_as_string() + if title == "": + title = "None" + + output = template.format( + origin=display_name, + stime=stime, + rtime=rtime, + mtitle=title, + mbody=message.content_as_string(), + ) + + filename = "/tmp/"+RNS.hexrep(RNS.Identity.full_hash(output.encode("utf-8")), delimit=False) + with open(filename, "wb") as f: + f.write(output.encode("utf-8")) + f.close() + + self.print_file(filename) + + os.unlink(filename) + + except Exception as e: + RNS.log("Error while printing incoming LXMF message. The contained exception was: "+str(e)) + + def conversations(self): + return nomadnet.Conversation.conversation_list(self) + + def has_unread_conversations(self): + if len(nomadnet.Conversation.unread_conversations) > 0: + return True + else: + return False + + def conversation_is_unread(self, source_hash): + src_bytes = bytes.fromhex(source_hash) + if src_bytes in nomadnet.Conversation.unread_conversations: + return True + if src_bytes in nomadnet.Conversation.failed_conversations: + return True + return False + + def mark_conversation_read(self, source_hash): + src_bytes = bytes.fromhex(source_hash) + if src_bytes in nomadnet.Conversation.unread_conversations: + nomadnet.Conversation.unread_conversations.pop(src_bytes) + if os.path.isfile(self.conversationpath + "/" + source_hash + "/unread"): + os.unlink(self.conversationpath + "/" + source_hash + "/unread") + if src_bytes in nomadnet.Conversation.failed_conversations: + nomadnet.Conversation.failed_conversations.pop(src_bytes) + if os.path.isfile(self.conversationpath + "/" + source_hash + "/failed"): + try: os.unlink(self.conversationpath + "/" + source_hash + "/failed") + except Exception: pass + + def notify_message_recieved(self): + if self.uimode == nomadnet.ui.UI_TEXT: + sys.stdout.write("\a") + sys.stdout.flush() + + def clear_tmp_dir(self): + if os.path.isdir(self.tmpfilespath): + for file in os.listdir(self.tmpfilespath): + fpath = self.tmpfilespath+"/"+file + os.unlink(fpath) + + def createDefaultConfig(self): + self.config = ConfigObj(__default_nomadnet_config__) + self.config.filename = self.configpath + + if not os.path.isdir(self.configdir): + os.makedirs(self.configdir) + self.config.write() + self.applyConfig() + + + def applyConfig(self): + if "logging" in self.config: + for option in self.config["logging"]: + value = self.config["logging"][option] + if option == "loglevel": + RNS.loglevel = int(value) + if RNS.loglevel < 0: + RNS.loglevel = 0 + if RNS.loglevel > 7: + RNS.loglevel = 7 + if option == "destination": + if value.lower() == "file" and not self.force_console_log: + RNS.logdest = RNS.LOG_FILE + if "logfile" in self.config["logging"]: + self.logfilepath = self.config["logging"]["logfile"] + RNS.logfile = self.logfilepath + else: + RNS.logdest = RNS.LOG_STDOUT + + if "client" in self.config: + for option in self.config["client"]: + value = self.config["client"][option] + + if option == "enable_client": + value = self.config["client"].as_bool(option) + self.enable_client = value + + if option == "downloads_path": + value = self.config["client"]["downloads_path"] + self.downloads_path = os.path.expanduser(value) + + if option == "attachment_save_path": + value = self.config["client"]["attachment_save_path"] + self.attachment_save_path = os.path.expanduser(value) + + if option == "announce_at_start": + value = self.config["client"].as_bool(option) + self.peer_announce_at_start = value + + if option == "announce_interval": + value = self.config["client"].as_int(option) + if value < 30: value = 30 + self.announce_interval = value*60 + + if option == "try_propagation_on_send_fail": + value = self.config["client"].as_bool(option) + self.try_propagation_on_fail = value + + if option == "periodic_lxmf_sync": + value = self.config["client"].as_bool(option) + self.periodic_lxmf_sync = value + + if option == "lxmf_sync_interval": + value = self.config["client"].as_int(option)*60 + + if value >= 60: + self.lxmf_sync_interval = value + + if option == "lxmf_sync_limit": + value = self.config["client"].as_int(option) + + if value > 0: + self.lxmf_sync_limit = value + else: + self.lxmf_sync_limit = None + + if option == "required_stamp_cost": + value = self.config["client"][option] + if value.lower() == "none": + self.required_stamp_cost = None + else: + value = self.config["client"].as_int(option) + + if value > 0: + if value > 255: + value = 255 + self.required_stamp_cost = value + else: + self.required_stamp_cost = None + + if option == "accept_invalid_stamps": + value = self.config["client"].as_bool(option) + self.accept_invalid_stamps = value + + if option == "max_accepted_size": + value = self.config["client"].as_float(option) + + if value > 0: + self.lxmf_max_incoming_size = value + else: + self.lxmf_max_incoming_size = 500 + + if option == "compact_announce_stream": + value = self.config["client"].as_bool(option) + self.compact_stream = value + + if option == "notify_on_new_message": + value = self.config["client"].as_bool(option) + self.notify_on_new_message = value + + if option == "compose_in_markdown": + value = self.config["client"].as_bool(option) + self.compose_markdown = value + + if option == "user_interface": + value = value.lower() + if value == "none": + self.uimode = nomadnet.ui.UI_NONE + if value == "menu": + self.uimode = nomadnet.ui.UI_MENU + if value == "text": + self.uimode = nomadnet.ui.UI_TEXT + if "textui" in self.config: + if not "intro_time" in self.config["textui"]: + self.config["textui"]["intro_time"] = 1 + else: + self.config["textui"]["intro_time"] = self.config["textui"].as_float("intro_time") + + if not "intro_text" in self.config["textui"]: + self.config["textui"]["intro_text"] = "Nomad Network" + + if not "editor" in self.config["textui"]: + self.config["textui"]["editor"] = "nano" + + if not "glyphs" in self.config["textui"]: + self.config["textui"]["glyphs"] = "unicode" + + if not "mouse_enabled" in self.config["textui"]: + self.config["textui"]["mouse_enabled"] = True + else: + self.config["textui"]["mouse_enabled"] = self.config["textui"].as_bool("mouse_enabled") + + if not "hide_guide" in self.config["textui"]: + self.config["textui"]["hide_guide"] = False + else: + self.config["textui"]["hide_guide"] = self.config["textui"].as_bool("hide_guide") + + if not "sanitize_names" in self.config["textui"]: + self.config["textui"]["sanitize_names"] = True + else: + self.config["textui"]["sanitize_names"] = self.config["textui"].as_bool("sanitize_names") + + if not "clipboard_copy" in self.config["textui"]: + self.config["textui"]["clipboard_copy"] = False + else: + self.config["textui"]["clipboard_copy"] = self.config["textui"].as_bool("clipboard_copy") + + if not "animation_interval" in self.config["textui"]: + self.config["textui"]["animation_interval"] = 1 + else: + self.config["textui"]["animation_interval"] = self.config["textui"].as_int("animation_interval") + + if not "colormode" in self.config["textui"]: + self.config["textui"]["colormode"] = nomadnet.ui.COLORMODE_16 + else: + if self.config["textui"]["colormode"].lower() == "monochrome": + self.config["textui"]["colormode"] = nomadnet.ui.TextUI.COLORMODE_MONO + elif self.config["textui"]["colormode"].lower() == "16": + self.config["textui"]["colormode"] = nomadnet.ui.TextUI.COLORMODE_16 + elif self.config["textui"]["colormode"].lower() == "88": + self.config["textui"]["colormode"] = nomadnet.ui.TextUI.COLORMODE_88 + elif self.config["textui"]["colormode"].lower() == "256": + self.config["textui"]["colormode"] = nomadnet.ui.TextUI.COLORMODE_256 + elif self.config["textui"]["colormode"].lower() == "24bit": + self.config["textui"]["colormode"] = nomadnet.ui.TextUI.COLORMODE_TRUE + else: + raise ValueError("The selected Text UI color mode is invalid") + + if not "theme" in self.config["textui"]: + self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_DARK + else: + if self.config["textui"]["theme"].lower() == "dark": + self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_DARK + elif self.config["textui"]["theme"].lower() == "light": + self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_LIGHT + else: + raise ValueError("The selected Text UI theme is invalid") + else: + raise KeyError("Text UI selected in configuration file, but no [textui] section found") + if value == "graphical": + self.uimode = nomadnet.ui.UI_GRAPHICAL + if value == "web": + self.uimode = nomadnet.ui.UI_WEB + + if "rrc" in self.config: + for option in self.config["rrc"]: + if option == "history_per_room_cap": + try: value = self.config["rrc"].as_int(option) + except Exception: value = None + if value is not None and value >= 0: + self.rrc_history_per_room_cap = value + + if option == "filter_loaded_history": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_filter_loaded_history = value + + if option == "ephemeral_notices": + try: value = self.config["rrc"].as_float(option) + except Exception: value = 0 + self.rrc_ephemeral_notices = value*60 + + if option == "justify_msgs": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_ui_justify_msgs = value + + if option == "space_msgs": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_ui_space_msgs = value + + if option == "nick_colors": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_nick_colors = value + + if option == "mention_color": + value = self.config["rrc"][option] + if len(value) == 6: + try: bytes.fromhex(value) + except Exception: value = None + self.rrc_mention_color = value + + if option == "color_mention_timestamps": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_color_mention_timestamps = value + + if option == "nick_colors_theme": + try: + colors = self.config["rrc"].as_list(option) + nick_colors_theme = [] + for c in colors: + if len(c) == 6: + try: bytes.fromhex(c) + except: continue + nick_colors_theme.append(c) + self.rrc_nick_colors_theme = nick_colors_theme + except Exception as e: + RNS.log(f"Could not load custom nick colors theme: {e}", RNS.LOG_WARNING) + self.rrc_nick_colors_theme = None + + if option == "render_markdown": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_ui_render_markdown = value + + if option == "render_micron": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_ui_render_micron = value + + if option == "show_gutters": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_show_gutters = value + + if option == "enable_esoterics": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = False + self.rrc_enable_esoterics = value + + if "node" in self.config: + if not "enable_node" in self.config["node"]: + self.enable_node = False + else: + self.enable_node = self.config["node"].as_bool("enable_node") + + if not "node_name" in self.config["node"]: + self.node_name = None + else: + value = self.config["node"]["node_name"] + if value.lower() == "none": + self.node_name = None + else: + self.node_name = self.config["node"]["node_name"] + + if not "disable_propagation" in self.config["node"]: + self.disable_propagation = True + else: + self.disable_propagation = self.config["node"].as_bool("disable_propagation") + + if not "max_transfer_size" in self.config["node"]: + self.lxmf_max_propagation_size = 256 + else: + value = self.config["node"].as_float("max_transfer_size") + if value < 1: + value = 1 + self.lxmf_max_propagation_size = value + + if not "max_sync_size" in self.config["node"]: + self.lxmf_max_sync_size = 256*40 + else: + value = self.config["node"].as_float("max_sync_size") + if value < self.lxmf_max_propagation_size: + value = self.lxmf_max_propagation_size + self.lxmf_max_sync_size = value + + if not "announce_at_start" in self.config["node"]: + self.node_announce_at_start = False + else: + value = self.config["node"].as_bool("announce_at_start") + self.node_announce_at_start = value + + if not "announce_interval" in self.config["node"]: + self.node_announce_interval = 720 + else: + value = self.config["node"].as_int("announce_interval") + if value < 1: + value = 1 + self.node_announce_interval = value + + if not "propagation_cost" in self.config["node"]: + self.node_propagation_cost = 16 + else: + value = self.config["node"].as_int("propagation_cost") + if value < 13: value = 13 + self.node_propagation_cost = value + + if "pages_path" in self.config["node"]: + self.pagespath = self.config["node"]["pages_path"] + + if not "page_refresh_interval" in self.config["node"]: + self.page_refresh_interval = 0 + else: + value = self.config["node"].as_int("page_refresh_interval") + if value < 0: + value = 0 + self.page_refresh_interval = value + + + if "files_path" in self.config["node"]: + self.filespath = self.config["node"]["files_path"] + + if not "file_refresh_interval" in self.config["node"]: + self.file_refresh_interval = 0 + else: + value = self.config["node"].as_int("file_refresh_interval") + if value < 0: + value = 0 + self.file_refresh_interval = value + + + if "prioritise_destinations" in self.config["node"]: + self.prioritised_lxmf_destinations = self.config["node"].as_list("prioritise_destinations") + else: + self.prioritised_lxmf_destinations = [] + + if "static_peers" in self.config["node"]: + self.static_peers = self.config["node"].as_list("static_peers") + else: + self.static_peers = [] + + if not "max_peers" in self.config["node"]: + self.max_peers = None + else: + value = self.config["node"].as_int("max_peers") + if value < 0: + value = 0 + self.max_peers = value + + if not "message_storage_limit" in self.config["node"]: + self.message_storage_limit = 2000 + else: + value = self.config["node"].as_float("message_storage_limit") + if value < 0.005: + value = 0.005 + self.message_storage_limit = value + + self.print_command = "lp" + self.print_messages = False + self.print_all_messages = False + self.print_trusted_messages = False + if "printing" in self.config: + if not "print_messages" in self.config["printing"]: + self.print_messages = False + else: + self.print_messages = self.config["printing"].as_bool("print_messages") + + if "print_command" in self.config["printing"]: + self.print_command = self.config["printing"]["print_command"] + + if self.print_messages: + if not "print_from" in self.config["printing"]: + self.allowed_message_print_destinations = None + else: + if type(self.config["printing"]["print_from"]) == str: + self.allowed_message_print_destinations = [] + if self.config["printing"]["print_from"].lower() == "everywhere": + self.print_all_messages = True + + if self.config["printing"]["print_from"].lower() == "trusted": + + self.print_all_messages = False + self.print_trusted_messages = True + + if len(self.config["printing"]["print_from"]) == (RNS.Identity.TRUNCATED_HASHLENGTH//8)*2: + self.allowed_message_print_destinations.append(self.config["printing"]["print_from"]) + + if type(self.config["printing"]["print_from"]) == list: + self.allowed_message_print_destinations = self.config["printing"].as_list("print_from") + for allowed_entry in self.allowed_message_print_destinations: + if allowed_entry.lower() == "trusted": + self.print_trusted_messages = True + + + if not "message_template" in self.config["printing"]: + self.printing_template_msg = __printing_template_msg__ + else: + mt_path = os.path.expanduser(self.config["printing"]["message_template"]) + if os.path.isfile(mt_path): + template_file = open(mt_path, "rb") + self.printing_template_msg = template_file.read().decode("utf-8") + else: + template_file = open(mt_path, "wb") + template_file.write(__printing_template_msg__.encode("utf-8")) + self.printing_template_msg = __printing_template_msg__ + + + @staticmethod + def get_shared_instance(): + if NomadNetworkApp._shared_instance != None: + return NomadNetworkApp._shared_instance + else: + raise UnboundLocalError("No Nomad Network applications have been instantiated yet") + + + def quit(self): + RNS.log("Nomad Network Client shutting down...") + os._exit(0) + + +# Default configuration file: +__default_nomadnet_config__ = '''# This is the default Nomad Network config file. +# You should probably edit it to suit your needs and use-case, + +[logging] +# Valid log levels are 0 through 7: +# 0: Log only critical information +# 1: Log errors and lower log levels +# 2: Log warnings and lower log levels +# 3: Log notices and lower log levels +# 4: Log info and lower (this is the default) +# 5: Verbose logging +# 6: Debug logging +# 7: Extreme logging + +loglevel = 4 +destination = file + +[client] + +enable_client = yes +user_interface = text +downloads_path = ~/Downloads + +# Where to save received attachments. If not set, +# attachments will be saved to the downloads path. +# attachment_save_path = ~/Downloads +notify_on_new_message = yes + +# By default, the peer is announced at startup +# to let other peers reach it immediately. +announce_at_start = yes + +# Automatic announce interval in minutes for +# your LXMF address. 6 hours by default. +announce_interval = 360 + +# By default, the client will try to deliver a +# message via the LXMF propagation network, if +# a direct delivery to the recipient is not +# possible. +try_propagation_on_send_fail = yes + +# Nomadnet will periodically sync messages from +# LXMF propagation nodes by default, if any are +# present. You can disable this if you want to +# only sync when manually initiated. +periodic_lxmf_sync = yes + +# The sync interval in minutes. This value is +# equal to 6 hours (360 minutes) by default. +lxmf_sync_interval = 360 + +# By default, automatic LXMF syncs will only +# download 8 messages at a time. You can change +# this number, or set the option to 0 to disable +# the limit, and download everything every time. +lxmf_sync_limit = 8 + +# You can specify a required stamp cost for +# inbound messages to be accepted. Specifying +# a stamp cost will require untrusted senders +# that message you to include a cryptographic +# stamp in their messages. Performing this +# operation takes the sender an amount of time +# proportional to the stamp cost. As a rough +# estimate, a stamp cost of 8 will take less +# than a second to compute, and a stamp cost +# of 20 could take several minutes, even on +# a fast computer. +required_stamp_cost = None + +# You can signal stamp requirements to senders, +# but still accept messages with invalid stamps +# by setting this option to True. +accept_invalid_stamps = False + +# The maximum accepted unpacked size for mes- +# sages received directly from other peers, +# specified in kilobytes. Messages larger than +# this will be rejected before the transfer +# begins. +max_accepted_size = 500 + +# The announce stream will only show one entry +# per destination or node by default. You can +# change this to show as many announces as have +# been received, for every destination. +compact_announce_stream = yes + +# You can choose whether or not to compose +# messages in markdown format. +compose_in_markdown = yes + +[textui] + +# Amount of time to show intro screen +intro_time = 1 + +# You can specify the display theme. +# theme = light +theme = dark + +# Specify the number of colors to use +# valid colormodes are: +# monochrome, 16, 88, 256 and 24bit +# +# The default is a conservative 256 colors. +# If your terminal does not support this, +# you can lower it. Some terminals support +# 24 bit color. + +# colormode = monochrome +# colormode = 16 +# colormode = 88 +# colormode = 256 +colormode = 24bit + +# By default, unicode glyphs are used. If +# you have a Nerd Font installed, you can +# enable this for a better user interface. +# You can also enable plain text glyphs if +# your terminal doesn't support unicode. + +# glyphs = plain +# glyphs = unicode +glyphs = nerdfont + +# You can specify whether mouse events +# should be considered as input to the +# application. On by default. +mouse_enabled = True + +# What editor to use for editing text. +editor = nano + +# If you don't want the Guide section to +# show up in the menu, you can disable it. +hide_guide = no + +# You can select whether or not to perform +# text sanitization on names received in +# announces. +sanitize_names = yes + +# You can enable clipboard features. This +# relies on your terminal supporting OSC52 +# escape sequences. +clipboard_copy = no + +[rrc] + +# Maximum number of messages retained per +# room in the in-memory scrollback buffer, +# and the number of messages restored from +# on-disk history at startup. The on-disk +# log itself is appended to indefinitely; +# this cap only controls how much backlog +# is visible. Set to 0 to keep every message +# in memory (full history). +history_per_room_cap = 500 + +# You can choose whether to filter system +# and notice events when room history is +# loaded. +filter_loaded_history = yes + +# You can choose whether notices and sys +# messages persist indefinitely, or are +# removed from the message history after +# the specified time in minutes. Set to 0 +# to disable and keep forever. +ephemeral_notices = 10 + +# Other display and formatting options: +color_mention_timestamps = yes +render_markdown = yes +render_micron = yes +nick_colors = yes +justify_msgs = yes +space_msgs = no +show_gutters = yes + +# You can configure your own color theme +# for nick color assignment +# nick_colors_theme = f68787, 00c394, d59e00, ... + +# You can set a specific color for mentions +# of your nick +# mention_color = FFBB44 + + +[node] + +# Whether to enable node hosting +enable_node = no + +# The node name will be visible to other +# peers on the network, and included in +# announces. +node_name = None + +# Automatic announce interval in minutes. +# 6 hours by default. +announce_interval = 360 + +# Whether to announce when the node starts. +announce_at_start = Yes + +# When Nomad Network is hosting a page-serving +# node, it can also act as an LXMF propagation +# node. This is a convenient feature that lets +# you easily set up and run a propagation node +# on the network, but it is not as fully +# featured as using the lxmd program to host a +# propagation node. For complete control and +# flexibility, use lxmd to run a PN. For a +# small local system or network, the built-in +# PN functionality will suffice for most cases. +# +# If there is already a large amount of +# propagation nodes on the network, or you +# simply want to run a pageserving-only node, +# you should disable running a propagation node. +# Due to lots of propagation nodes being +# available, this is currently the default. + +disable_propagation = Yes + +# For clients and other propagation nodes +# delivering messages via this node, you can +# configure the minimum required propagation +# stamp costs. All messages delivered to the +# propagation node network must have a valid +# propagation stamp, or they will be rejected. +# Clients automatically detect the stamp cost +# for the node they are delivering to, and +# compute a corresponding stamp before trying +# to deliver the message to the propagation +# node. +# +# Propagation stamps are easier to verify in +# large batches, and therefore also somewhat +# easier to compute for the senders. As such, +# a reasonable propagation stamp cost should +# be a bit higher than the normal peer-to-peer +# stamp costs. +# +# Propagation stamps does not incur any extra +# load for propagation nodes processing them, +# since they are only required to verify that +# they are correct, and only the generation +# is computationally costly. Setting a sensible +# propagation stamp cost (and periodically +# checking the average network consensus) helps +# keep spam and misuse out of the propagation +# node network. + +propagation_cost = 16 + +# The maximum amount of storage to use for +# the LXMF Propagation Node message store, +# specified in megabytes. When this limit +# is reached, LXMF will periodically remove +# messages in its message store. By default, +# LXMF prioritises keeping messages that are +# new and small. Large and old messages will +# be removed first. This setting is optional +# and defaults to 2 gigabytes. + +# message_storage_limit = 2000 + +# The maximum accepted transfer size per in- +# coming propagation message, in kilobytes. +# This sets the upper limit for the size of +# single messages accepted onto this node. + +max_transfer_size = 256 + +# The maximum accepted transfer size per in- +# coming propagation node sync. +# +# If a node wants to propagate a larger number +# of messages to this node, than what can fit +# within this limit, it will prioritise sending +# the smallest messages first, and try again +# with any remaining messages at a later point. + +max_sync_size = 10240 + +# You can tell the LXMF message router to +# prioritise storage for one or more +# destinations. If the message store reaches +# the specified limit, LXMF will prioritise +# keeping messages for destinations specified +# with this option. This setting is optional, +# and generally you do not need to use it. + +# prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf + +# You can configure the maximum number of other +# propagation nodes that this node will peer +# with automatically. The default is 20. + +# max_peers = 20 + +# You can configure a list of static propagation +# node peers, that this node will always be +# peered with, by specifying a list of +# destination hashes. + +# static_peers = e17f833c4ddf8890dd3a79a6fea8161d, 5a2d0029b6e5ec87020abaea0d746da4 + +# You can specify the interval in minutes for +# rescanning the hosted pages path. By default, +# this option is disabled, and the pages path +# will only be scanned on startup. + +# page_refresh_interval = 0 + +# You can specify the interval in minutes for +# rescanning the hosted files path. By default, +# this option is disabled, and the files path +# will only be scanned on startup. + +# file_refresh_interval = 0 + +[printing] + +# You can configure Nomad Network to print +# various kinds of information and messages. + +# Printing messages is disabled by default + +print_messages = No + +# You can configure a custom template for +# message printing. If you uncomment this +# option, set a path to the template and +# restart Nomad Network, a default template +# will be created that you can edit. + +# message_template = ~/.nomadnetwork/print_template_msg.txt + +# You can configure Nomad Network to only +# print messages from trusted destinations. + +# print_from = trusted + +# Or specify the source LXMF addresses that +# will automatically have messages printed +# on arrival. + +# print_from = 76fe5751a56067d1e84eef3e88eab85b, 0e70b5848eb57c13154154feaeeb89b7 + +# Or allow printing from anywhere, if you +# are feeling brave and adventurous. + +# print_from = everywhere + +# You can configure the printing command. +# This will use the default CUPS printer on +# your system. + +print_command = lp + +# You can specify what printer to use +# print_command = lp -d [PRINTER_NAME] + +# Or specify more advanced options. This +# example works well for small thermal- +# roll printers: +# print_command = lp -d [PRINTER_NAME] -o cpi=16 -o lpi=8 + +# This one is more suitable for full-sheet +# printers. It will print a QR code at the center of any media +# your printer will accept, print in portrait mode, and move the message to +# the top of the print queue: +# print_command = lp -d [PRINTER_NAME] -o job-priority=100 -o media=Custom.75x75mm -o orientation-requested=3 + +# But you can modify the size to fit your needs. +# The custom media option accepts millimeters, centimeters, and +# inches in a width by length format like so: +# -o media=Custom.[WIDTH]x[LENGTH][mm,cm,in] +# +# The job priority option accepts 1-100, though you can remove it +# entirely if you aren't concerned with a print queue: +# -o job-priority=[1-100] +# +# Finally, the orientation option allows for 90 degree rotations beginning with 3, so: +# -o orientation-requested=4 (landscape, 90 degrees) +# -o orientation-requested=5 (reverse portrait, 180 degrees) +# +# Here is the full command with the recommended customizable variables: +# print_command = lp -d [PRINTER_NAME] -o job-priority=[N] -o media=[MEDIA_SIZE] -o orientation-requested=[N] -o sides=one-sided + +# For example, here's a configuration for USB thermal printer that uses the POS-58 PPD driver +# with rolls 47.98x209.9mm in size: +# print_command = lp -d [PRINTER_NAME] -o job-priority=100 -o media=custom_47.98x209.9mm_47.98x209.9mm -o sides=one-sided + +'''.splitlines() + +__printing_template_msg__ = """ +--------------------------- +From: {origin} +Sent: {stime} +Rcvd: {rtime} +Title: {mtitle} + +{mbody} +--------------------------- +""" diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py new file mode 100644 index 0000000..6a58845 --- /dev/null +++ b/nomadnet/RRC.py @@ -0,0 +1,1501 @@ +import os +import re +import time +import threading +import hashlib +from collections import deque + +import RNS + +from nomadnet.vendor import cbor + + +HISTORY_DIR_NAME = "rrc_history" +HISTORY_FILENAME_SANITIZE_RE = re.compile(r"[^a-z0-9._-]+") + +H_KIND = "k" +H_SRC = "s" +H_NICK = "n" +H_TEXT = "t" +H_TS = "ts" +H_MENTION = "m" + + +_MENTION_RE_CACHE = {} + + +def _mention_re(nick): + if not isinstance(nick, str) or not nick: + return None + pat = _MENTION_RE_CACHE.get(nick) + if pat is None: + pat = re.compile(r"(? 32: + _MENTION_RE_CACHE.clear() + _MENTION_RE_CACHE[nick] = pat + return pat + +# https://github.com/kc1awv/rrcd/blob/main/rrcd/constants.py +RRC_VERSION = 1 + +K_V = 0 +K_T = 1 +K_ID = 2 +K_TS = 3 +K_SRC = 4 +K_ROOM = 5 +K_BODY = 6 +K_NICK = 7 + +T_HELLO = 1 +T_WELCOME = 2 + +T_JOIN = 10 +T_JOINED = 11 +T_PART = 12 +T_PARTED = 13 + +T_MSG = 20 +T_NOTICE = 21 +T_ACTION = 22 + +T_PING = 30 +T_PONG = 31 + +T_ERROR = 40 + +T_RESOURCE_ENVELOPE = 50 + +B_HELLO_NAME = 0 +B_HELLO_VER = 1 +B_HELLO_CAPS = 2 + +B_WELCOME_HUB = 0 +B_WELCOME_VER = 1 +B_WELCOME_CAPS = 2 +B_WELCOME_LIMITS = 3 + +L_MAX_NICK_BYTES = 0 +L_MAX_ROOM_NAME_BYTES = 1 +L_MAX_MSG_BODY_BYTES = 2 +L_MAX_ROOMS_PER_SESSION = 3 +L_RATE_LIMIT_MSGS_PER_MINUTE= 4 + +CAP_RESOURCE_ENVELOPE = 0 +CAP_ACTION = 1 + +B_RES_ID = 0 +B_RES_KIND = 1 +B_RES_SIZE = 2 +B_RES_SHA256 = 3 +B_RES_ENCODING = 4 + +RES_KIND_NOTICE = "notice" +RES_KIND_MOTD = "motd" +RES_KIND_BLOB = "blob" + +DEFAULT_DEST_NAME = "rrc.hub" +DEFAULT_MAX_NICK_BYTES = 32 +DEFAULT_MAX_ROOM_BYTES = 64 +DEFAULT_MAX_MSG_BYTES = 350 +DEFAULT_MAX_ROOMS = 32 +DEFAULT_RATE_PER_MINUTE = 240 + + + + + + +def _now_ms(): + return int(time.time()*1000) + + +def _msg_id(): + return os.urandom(8) + + +# greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)" +_WHO_ENTRY_RE = re.compile( + r"(?:^|,\s)" + r"(?:(?P[0-9a-fA-F]{32})|(?P.+?)\s\((?P[0-9a-fA-F]{12})\))" + r"(?=,\s|$)" +) + + +# hub /who response format: "members in : nick1 (hex12), nick2 (hex12), , ..." +# nicked users carry only a 12-hex prefix of their identity hash; un-nicked users appear as the full hex +def _parse_who_notice(text): + if not isinstance(text, str): + return None + prefix = "members in " + if not text.startswith(prefix): + return None + sep_idx = text.find(": ", len(prefix)) + if sep_idx < 0: + return None + room = text[len(prefix):sep_idx].strip().lower() + if not room: + return None + body = text[sep_idx+2:].strip() + entries = [] + if body and body != "(none)": + for m in _WHO_ENTRY_RE.finditer(body): + if m.group("nick") is not None: + entries.append((m.group("nick").strip(), m.group("np").lower())) + elif m.group("bh") is not None: + entries.append((None, m.group("bh").lower())) + return (room, entries) + + +def _parse_room_list_notice(text): + if not isinstance(text, str): + return None + stripped = text.strip() + if stripped == "No public rooms registered": + return {} + lines = text.split("\n") + if not lines or not lines[0].lstrip().startswith("Registered public rooms"): + return None + rooms = {} + for line in lines[1:]: + s = line.strip() + if not s: + continue + if " - " in s: + name, topic = s.split(" - ", 1) + rooms[name.strip().lower()] = topic.strip() or None + else: + rooms[s.strip().lstrip("#").lower()] = None + return rooms + + +def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None): + env = { + K_V: RRC_VERSION, + K_T: int(msg_type), + K_ID: mid or _msg_id(), + K_TS: ts or _now_ms(), + K_SRC: src, + } + if room is not None: + env[K_ROOM] = room + if body is not None: + env[K_BODY] = body + if nick is not None and nick != "": + env[K_NICK] = nick + return env + + +class RRCMessage: + def __init__(self, kind, room, src, nick, text, ts): + self.kind = kind + self.room = room + self.src = src + self.nick = nick + self.text = text + self.ts = ts + self.mention = False + + +class RRCHub: + STATUS_DISCONNECTED = 0 + STATUS_CONNECTING = 1 + STATUS_CONNECTED = 2 + STATUS_FAILED = 3 + + CLEAN_HISTORY_INTERVAL = 5 + SYS_NOTICE_TIMEOUT = 600 + + def __init__(self, manager, hub_hash, dest_name=None, name=None): + self.manager = manager + self.hub_hash = hub_hash + self.dest_name = dest_name or DEFAULT_DEST_NAME + self.name = name or RNS.prettyhexrep(hub_hash) + + self.link = None + self.status = RRCHub.STATUS_DISCONNECTED + self.status_text = "Disconnected" + self.welcomed = False + self.hub_name = None + self.hub_version = None + self.hub_caps = {} + self.motd = None + + self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES + self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES + self.max_msg_body_bytes = DEFAULT_MAX_MSG_BYTES + self.max_rooms_per_session = DEFAULT_MAX_ROOMS + self.rate_limit_msgs_per_minute = DEFAULT_RATE_PER_MINUTE + + self.rooms = set() + self.messages = {} + self.notices = [] + self.unread_rooms = set() + self.mention_rooms = set() + self.members = {} + self.nicks = {} + + self.auto_reconnect = False + self.auto_list = False + self.auto_who = False + + self._lock = threading.RLock() + self._resource_expectations = {} + self._sent_ids = deque(maxlen=256) + + self._hello_thread = None + self._stop_hello = threading.Event() + self._manual_disconnect = False + self._reconnect_attempts = 0 + self._reconnect_timer = None + self._pending_pings = {} + self._last_history_clean = 0 + self.clean_last_removed = 0 + + self.available_rooms = {} + self._silent_list_pending = 0 + self._silent_who_rooms = set() + + self.nick_override = None + self._pending_joins = set() + self._pending_parts = set() + self._silent_joins = set() + + self._history_write_failed = False + + def _log(self, msg, level=None): + if level is None: + level = RNS.LOG_INFO + RNS.log("[RRC "+self.name+"] "+msg, level) + + def add_room(self, room): + room_n = self._normalize_room(room) + with self._lock: + self.rooms.add(room_n) + if room_n not in self.messages: + self.messages[room_n] = [] + self.manager.save() + self.manager._notify_change(self) + return room_n + + def remove_room(self, room): + r = self._normalize_room(room) + with self._lock: + self.rooms.discard(r) + self.messages.pop(r, None) + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self.members.pop(r, None) + self._delete_history(r) + self.manager.save() + self.manager._notify_change(self) + + def clear_messages(self, room): + r = self._normalize_room(room) + with self._lock: + if r in self.messages: + self.messages[r] = [] + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self._delete_history(r) + self.manager._notify_change(self) + + def get_members(self, room): + with self._lock: + return list(self.members.get(room, set())) + + def display_name_for(self, peer): + if not isinstance(peer, (bytes, bytearray)): + return "" + ph = bytes(peer) + with self._lock: + nick = self.nicks.get(ph) + if nick: + return nick + return ph.hex()[:12] + + def mark_read(self, room): + r = self._normalize_room(room) + with self._lock: + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self.manager._notify_change(self) + + def _normalize_room(self, room): + r = (room or "").strip().lower() + if not r: + raise ValueError("room must not be empty") + return r + + def _set_status(self, status, text=None): + self.status = status + if text is not None: + self.status_text = text + self.manager._notify_change(self) + + def connect(self): + with self._lock: + if self.status in (RRCHub.STATUS_CONNECTING, RRCHub.STATUS_CONNECTED): + return + self._manual_disconnect = False + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + if self._reconnect_attempts > 0: + text = "Reconnecting (attempt "+str(self._reconnect_attempts)+")" + else: + text = "Connecting" + self._set_status(RRCHub.STATUS_CONNECTING, text) + + t = threading.Thread(target=self._connect_worker, daemon=True) + t.start() + + def _connect_worker(self): + try: + timeout_s = 20.0 + if not RNS.Transport.has_path(self.hub_hash): + RNS.Transport.request_path(self.hub_hash) + deadline = time.monotonic() + min(5.0, timeout_s) + while time.monotonic() < deadline: + if RNS.Transport.has_path(self.hub_hash): + break + time.sleep(0.1) + + hub_identity = None + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + hub_identity = RNS.Identity.recall(self.hub_hash) + if hub_identity is not None: + break + time.sleep(0.2) + + if hub_identity is None: + self._set_status(RRCHub.STATUS_FAILED, "Hub identity unknown") + return + + app_name, aspects = RNS.Destination.app_and_aspects_from_name(self.dest_name) + hub_dest = RNS.Destination( + hub_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + app_name, + *aspects, + ) + + if hub_dest.hash != self.hub_hash: + self._set_status(RRCHub.STATUS_FAILED, "Hash/destination name mismatch") + return + + self._stop_hello.clear() + link = RNS.Link(hub_dest, established_callback=self._on_established, closed_callback=self._on_closed) + link.set_packet_callback(lambda data, pkt: self._on_packet(data)) + with self._lock: + self.link = link + + except Exception as e: + self._set_status(RRCHub.STATUS_FAILED, "Connect error: "+str(e)) + + def _on_established(self, link): + try: + link.set_resource_strategy(RNS.Link.ACCEPT_APP) + link.set_resource_callback(self._resource_advertised) + link.set_resource_started_callback(self._resource_advertised) + link.set_resource_concluded_callback(self._resource_concluded) + except Exception: + pass + + try: + link.identify(self.manager.identity) + except Exception as e: + self._log("identify failed: "+str(e), RNS.LOG_ERROR) + try: link.teardown() + except Exception: pass + return + + self._set_status(RRCHub.STATUS_CONNECTING, "Identified, sending HELLO") + + def hello_loop(): + attempts = 0 + while not self._stop_hello.is_set() and not self.welcomed and attempts < 5: + with self._lock: + cur_link = self.link + if cur_link is None or cur_link.status != RNS.Link.ACTIVE: + return + try: + self._send_hello(cur_link) + except Exception as e: + self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR) + attempts += 1 + self._stop_hello.wait(timeout=3.0) + if not self.welcomed and not self._stop_hello.is_set(): + self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout") + try: + with self._lock: + if self.link is not None: + self.link.teardown() + except Exception: + pass + + self._hello_thread = threading.Thread(target=hello_loop, daemon=True) + self._hello_thread.start() + + def _send_hello(self, link): + body = { + B_HELLO_NAME: "nomadnet", + B_HELLO_VER: "0.1", + B_HELLO_CAPS: { + CAP_RESOURCE_ENVELOPE: True, + CAP_ACTION: True, + }, + } + env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + payload = cbor.encode(env) + RNS.Packet(link, payload).send() + + def _on_closed(self, link): + self._stop_hello.set() + with self._lock: + self.link = None + self.welcomed = False + self.motd = None + self.members.clear() + self._resource_expectations.clear() + self._pending_joins.clear() + self._pending_parts.clear() + self._silent_joins.clear() + self._silent_who_rooms.clear() + should_reconnect = self.auto_reconnect and not self._manual_disconnect + self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") + if should_reconnect: + self._schedule_reconnect() + + def _schedule_reconnect(self): + with self._lock: + self._reconnect_attempts += 1 + backoff = min(60.0, max(1.0, 2.0 ** min(self._reconnect_attempts, 6))) + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + + def fire(): + with self._lock: + self._reconnect_timer = None + if self._manual_disconnect or not self.auto_reconnect: + return + self.connect() + + self._reconnect_timer = threading.Timer(backoff, fire) + self._reconnect_timer.daemon = True + self._reconnect_timer.start() + self._set_status(RRCHub.STATUS_DISCONNECTED, "Reconnect in "+str(int(backoff))+"s") + + + def disconnect(self): + self._stop_hello.set() + with self._lock: + self._manual_disconnect = True + self._reconnect_attempts = 0 + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + link = self.link + self.link = None + if link is not None: + try: link.teardown() + except Exception: pass + self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") + + def set_auto_reconnect(self, enabled, save=True): + with self._lock: + self.auto_reconnect = bool(enabled) + if not enabled and self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + if save: + self.manager.save() + self.manager._notify_change(self) + + def set_auto_list(self, enabled, save=True): + with self._lock: + self.auto_list = bool(enabled) + if save: + self.manager.save() + self.manager._notify_change(self) + + def set_auto_who(self, enabled, save=True): + with self._lock: + self.auto_who = bool(enabled) + if save: + self.manager.save() + self.manager._notify_change(self) + + def get_effective_nick(self): + if isinstance(self.nick_override, str) and self.nick_override: + return self.nick_override + return self.manager.get_nickname() + + def set_nick_override(self, nick): + with self._lock: + if nick is None or (isinstance(nick, str) and nick == ""): + self.nick_override = None + else: + self.nick_override = str(nick) + self.manager.save() + self.manager._notify_change(self) + + def _packet_would_fit(self, link, payload): + try: + pkt = RNS.Packet(link, payload) + pkt.pack() + return True + except Exception: + return False + + def _send_env(self, env): + with self._lock: + link = self.link + if link is None or link.status != RNS.Link.ACTIVE: + raise RuntimeError("not connected") + payload = cbor.encode(env) + if not self._packet_would_fit(link, payload): + raise RuntimeError("message exceeds link MTU") + RNS.Packet(link, payload).send() + + def join_room(self, room, key=None, silent=False): + r = self._normalize_room(room) + body = key if (isinstance(key, str) and key) else None + env = _make_envelope(T_JOIN, src=self.manager.identity.hash, room=r, body=body) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + with self._lock: + self._pending_joins.add(r) + if silent: + self._silent_joins.add(r) + self._send_env(env) + with self._lock: + if r not in self.messages: + self.messages[r] = [] + self.manager._notify_change(self) + + def send_command(self, text, room=None): + if not isinstance(text, str) or not text.startswith("/"): + raise ValueError("command must start with /") + env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=room, body=text) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + self._send_env(env) + + def send_ping(self, room=None): + body = os.urandom(8) + env = _make_envelope(T_PING, src=self.manager.identity.hash, body=body) + with self._lock: + now_ms = _now_ms() + self._pending_pings[body] = (now_ms, room) + expired = [k for k, v in self._pending_pings.items() if now_ms - v[0] > 15000] + for k in expired: + self._pending_pings.pop(k, None) + self._send_env(env) + return body + + def part_room(self, room): + room_n = self._normalize_room(room) + env = _make_envelope(T_PART, src=self.manager.identity.hash, room=room_n) + with self._lock: + self._pending_parts.add(room_n) + try: + self._send_env(env) + except Exception: + pass + with self._lock: + self.rooms.discard(room_n) + self.manager.save() + self.manager._notify_change(self) + + def send_message(self, room, text): + r = self._normalize_room(room) + if not isinstance(text, str) or not text.strip(): + raise ValueError("message text must be non-empty") + if len(text.encode("utf-8")) > self.max_msg_body_bytes: + raise ValueError("message too long for hub limit") + env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=r, body=text) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + mid = env[K_ID] + if isinstance(mid, (bytes, bytearray)): + self._sent_ids.append(bytes(mid)) + self._send_env(env) + self._record_message(RRCMessage("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) + return mid + + def send_action(self, room, text): + r = self._normalize_room(room) + if not isinstance(text, str) or not text.strip(): + raise ValueError("action text must be non-empty") + if len(text.encode("utf-8")) > self.max_msg_body_bytes: + raise ValueError("action too long for hub limit") + env = _make_envelope(T_ACTION, src=self.manager.identity.hash, room=r, body=text) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + mid = env[K_ID] + if isinstance(mid, (bytes, bytearray)): + self._sent_ids.append(bytes(mid)) + self._send_env(env) + self._record_message(RRCMessage("action", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) + return mid + + def _per_room_cap(self): + try: + v = int(getattr(self.manager.app, "rrc_history_per_room_cap", 0)) + except Exception: + return None + return v if v > 0 else None + + def _filter_history(self): + try: v = getattr(self.manager.app, "rrc_filter_loaded_history", True) + except Exception: return True + return v + + def _ephemeral_notices_history(self): + try: v = getattr(self.manager.app, "rrc_ephemeral_notices", self.SYS_NOTICE_TIMEOUT) + except Exception: return self.SYS_NOTICE_TIMEOUT + return v + + def _entry_for(self, msg): + return { + H_KIND: msg.kind, + H_SRC: bytes(msg.src) if isinstance(msg.src, (bytes, bytearray)) else None, + H_NICK: msg.nick if isinstance(msg.nick, str) else None, + H_TEXT: msg.text if isinstance(msg.text, str) else "", + H_TS: int(msg.ts) if isinstance(msg.ts, int) else _now_ms(), + H_MENTION: bool(getattr(msg, "mention", False)), + } + + def _msg_from_entry(self, room, entry): + if not isinstance(entry, dict): + return None + m = RRCMessage( + entry.get(H_KIND) if isinstance(entry.get(H_KIND), str) else "msg", + room, + entry.get(H_SRC) if isinstance(entry.get(H_SRC), (bytes, bytearray)) else None, + entry.get(H_NICK) if isinstance(entry.get(H_NICK), str) else None, + entry.get(H_TEXT) if isinstance(entry.get(H_TEXT), str) else "", + entry.get(H_TS) if isinstance(entry.get(H_TS), int) else 0, + ) + m.mention = bool(entry.get(H_MENTION, False)) + return m + + def _persistable_room(self, room): + return isinstance(room, str) and room and room != "*" + + def _append_history(self, room, msg): + if not self._persistable_room(room): + return + try: + self.manager._ensure_history_dir(self) + path = self.manager._history_path(self, room) + with open(path, "ab") as f: + f.write(cbor.encode(self._entry_for(msg))) + self._history_write_failed = False + except Exception as e: + if not self._history_write_failed: + self._history_write_failed = True + self._log("history persistence failed, suppressing further warnings until recovery: "+str(e), RNS.LOG_ERROR) + + def _delete_history(self, room): + if not self._persistable_room(room): + return + path = self.manager._history_path(self, room) + try: + if os.path.isfile(path): + os.unlink(path) + except Exception: + pass + + def _load_history(self): + with self._lock: + rooms = list(self.messages.keys()) + for room in rooms: + if not self._persistable_room(room): + continue + path = self.manager._history_path(self, room) + if not os.path.isfile(path): + continue + window = deque(maxlen=self._per_room_cap()) + decode_error = None + try: + with open(path, "rb") as f: + while True: + try: + window.append(cbor.load(f)) + except EOFError: + break + except Exception as ex: + decode_error = ex + break + except OSError as ex: + self._log("history load failed for #"+room+": "+str(ex), RNS.LOG_ERROR) + continue + if decode_error is not None: + self._log("history file for #"+room+" is corrupt, truncating to last "+str(len(window))+" valid messages: "+str(decode_error), RNS.LOG_ERROR) + msgs = [] + filter_msgs = self._filter_history() + for e in window: + m = self._msg_from_entry(room, e) + if m is not None: + if filter_msgs: + should_filter = False + if m.kind == "system": should_filter = True + elif m.kind == "notice": should_filter = True + if should_filter: continue + + msgs.append(m) + with self._lock: + self.messages[room] = msgs + + def _clean_history(self): + now = time.time() + cleaned = False + remove_after = self._ephemeral_notices_history() + if now > self._last_history_clean + self.CLEAN_HISTORY_INTERVAL: + RNS.log(f"Cleaning loaded message history", RNS.LOG_DEBUG) + with self._lock: + try: + for r in self.messages: + old = set() + for m in self.messages[r]: + age = now-m.ts/1000.0 + should_filter = False + if m.kind == "system": should_filter = True + elif m.kind == "notice": should_filter = True + if should_filter and age > remove_after: old.add(m) + + for m in old: + self.messages[r].remove(m) + cleaned = True + + except Exception as e: RNS.trace_exception(e) + + self._last_history_clean = time.time() + if cleaned: self.clean_last_removed = time.time() + + def _record_message(self, msg, local=False): + cap = self._per_room_cap() + with self._lock: + buf = self.messages.setdefault(msg.room or "*", []) + buf.append(msg) + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] + if not local and msg.room: + if msg.room != self.manager.active_room_for(self): + self.unread_rooms.add(msg.room) + if msg.mention: + self.mention_rooms.add(msg.room) + self.manager._notify_messages(self, msg) + self._append_history(msg.room, msg) + self._clean_history() + + def _record_system(self, room, text): + if not room: + return + msg = RRCMessage("system", room, None, None, text, _now_ms()) + cap = self._per_room_cap() + with self._lock: + buf = self.messages.setdefault(room, []) + buf.append(msg) + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] + self.manager._notify_messages(self, msg) + self._append_history(room, msg) + self._clean_history() + + def _record_notice(self, msg): + target_room = msg.room + if not target_room: + target_room = self.manager.active_room_for(self) + if target_room: + msg.room = target_room + + cap = self._per_room_cap() + with self._lock: + self.notices.append(msg) + if len(self.notices) > 200: + del self.notices[:len(self.notices)-200] + if target_room: + buf = self.messages.setdefault(target_room, []) + buf.append(msg) + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] + if target_room != self.manager.active_room_for(self): + self.unread_rooms.add(target_room) + self.manager._notify_messages(self, msg) + if target_room: + self._append_history(target_room, msg) + self._clean_history() + + def get_messages(self, room, take_lock=True): + if take_lock: + with self._lock: buf = list(self.messages.get(room, [])) + else: buf = list(self.messages.get(room, [])) + + return buf + + def _on_packet(self, data): + try: + env = cbor.decode(data) + except Exception as e: + self._log("decode failed: "+str(e), RNS.LOG_DEBUG) + return + if not isinstance(env, dict): + return + try: + t = env.get(K_T) + except Exception: + return + + if t == T_PING: + try: + pong = _make_envelope(T_PONG, src=self.manager.identity.hash, body=env.get(K_BODY)) + self._send_env(pong) + except Exception: + pass + return + + if t == T_PONG: + body = env.get(K_BODY) + if isinstance(body, (bytes, bytearray)): + key = bytes(body) + with self._lock: + pending = self._pending_pings.pop(key, None) + if pending is not None: + sent_ms, room = pending + rtt_ms = max(0, _now_ms() - sent_ms) + self._record_system(room, "Pong from hub: "+str(rtt_ms)+" ms") + return + + if t == T_WELCOME: + self.welcomed = True + body = env.get(K_BODY) + if isinstance(body, dict): + hub_name = body.get(B_WELCOME_HUB) + if isinstance(hub_name, str): + self.hub_name = hub_name + ver = body.get(B_WELCOME_VER) + if isinstance(ver, str): + self.hub_version = ver + caps = body.get(B_WELCOME_CAPS) + if isinstance(caps, dict): + self.hub_caps = dict(caps) + limits = body.get(B_WELCOME_LIMITS) + if isinstance(limits, dict): + if L_MAX_NICK_BYTES in limits: + self.max_nick_bytes = int(limits[L_MAX_NICK_BYTES]) + if L_MAX_ROOM_NAME_BYTES in limits: + self.max_room_name_bytes = int(limits[L_MAX_ROOM_NAME_BYTES]) + if L_MAX_MSG_BODY_BYTES in limits: + self.max_msg_body_bytes = int(limits[L_MAX_MSG_BODY_BYTES]) + if L_MAX_ROOMS_PER_SESSION in limits: + self.max_rooms_per_session = int(limits[L_MAX_ROOMS_PER_SESSION]) + if L_RATE_LIMIT_MSGS_PER_MINUTE in limits: + self.rate_limit_msgs_per_minute = int(limits[L_RATE_LIMIT_MSGS_PER_MINUTE]) + self._set_status(RRCHub.STATUS_CONNECTED, "Connected") + with self._lock: + self._reconnect_attempts = 0 + self.manager._on_welcome(self) + if self.auto_list: + try: + with self._lock: + self._silent_list_pending += 1 + self.send_command("/list", room=None) + except Exception: + with self._lock: + if self._silent_list_pending > 0: + self._silent_list_pending -= 1 + return + + if t == T_JOINED: + room = env.get(K_ROOM) + if isinstance(room, str) and room: + r = room.strip().lower() + body = env.get(K_BODY) + joiner_nick = env.get(K_NICK) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + + body_hashes = [] + if isinstance(body, list): + body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))] + + with self._lock: + self_join = r in self._pending_joins + silent = r in self._silent_joins + if self_join: + self._pending_joins.discard(r) + if silent: + self._silent_joins.discard(r) + + self.rooms.add(r) + if r not in self.messages: + self.messages[r] = [] + members = self.members.setdefault(r, set()) + for h in body_hashes: + members.add(h) + if own_hash is not None: + members.add(own_hash) + + # rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it + # so display_name_for() can render the nick instead of a hash prefix. + if (not self_join) and isinstance(joiner_nick, str) and joiner_nick and len(body_hashes) == 1: + jh = body_hashes[0] + if own_hash is None or jh != own_hash: + self.nicks[jh] = joiner_nick + + if self_join: + if not silent: + self._record_system(r, "You joined #"+r) + if self.auto_who: + try: + with self._lock: + self._silent_who_rooms.add(r) + self.send_command("/who "+r, room=r) + except Exception: + with self._lock: + self._silent_who_rooms.discard(r) + self.manager.save() + else: + joiner = None + if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash): + joiner = body_hashes[0] + if joiner is not None: + self._record_system(r, self.display_name_for(joiner)+" joined") + self.manager._notify_change(self) + return + + if t == T_PARTED: + room = env.get(K_ROOM) + if isinstance(room, str) and room: + r = room.strip().lower() + body = env.get(K_BODY) + parter_nick = env.get(K_NICK) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + + body_hashes = [] + if isinstance(body, list): + body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))] + + with self._lock: + self_part = r in self._pending_parts + if self_part: + self._pending_parts.discard(r) + + # rrcd 0.3.2 attaches advisory K_NICK to fanout PARTED; learn it + # before forgetting the member so " left" renders correctly. + if (not self_part) and isinstance(parter_nick, str) and parter_nick and len(body_hashes) == 1: + ph = body_hashes[0] + if own_hash is None or ph != own_hash: + self.nicks[ph] = parter_nick + + members = self.members.get(r) + if members is not None: + for h in body_hashes: + members.discard(h) + if self_part: + self.rooms.discard(r) + self.members.pop(r, None) + + if self_part: + self.manager.save() + else: + parter = None + if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash): + parter = body_hashes[0] + if parter is not None: + self._record_system(r, self.display_name_for(parter)+" left") + self.manager._notify_change(self) + return + + if t == T_MSG: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + nick = env.get(K_NICK) + mid = env.get(K_ID) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash: + if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids: + return + if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick: + with self._lock: + self.nicks[bytes(src)] = nick + if isinstance(room, str) and room: + self.members.setdefault(room.strip().lower(), set()).add(bytes(src)) + if isinstance(body, str): + msg = RRCMessage( + "msg", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + nick if isinstance(nick, str) else None, + body, + _now_ms(), + ) + is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + if not is_own: + own_nick = self.get_effective_nick() + pat = _mention_re(own_nick) + if pat is not None and pat.search(body): + msg.mention = True + self._record_message(msg) + return + + if t == T_ACTION: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + nick = env.get(K_NICK) + mid = env.get(K_ID) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash: + if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids: + return + if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick: + with self._lock: + self.nicks[bytes(src)] = nick + if isinstance(room, str) and room: + self.members.setdefault(room.strip().lower(), set()).add(bytes(src)) + if isinstance(body, str): + msg = RRCMessage( + "action", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + nick if isinstance(nick, str) else None, + body, + _now_ms(), + ) + is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + if not is_own: + own_nick = self.get_effective_nick() + pat = _mention_re(own_nick) + if pat is not None and pat.search(body): + msg.mention = True + self._record_message(msg) + return + + if t == T_NOTICE: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + if isinstance(body, str): + parsed = _parse_room_list_notice(body) + if parsed is not None: + with self._lock: + self.available_rooms = parsed + silent = self._silent_list_pending > 0 + if silent: + self._silent_list_pending -= 1 + self.manager._notify_change(self) + if silent: + return + parsed_who = _parse_who_notice(body) + if parsed_who is not None: + who_room, who_entries = parsed_who + with self._lock: + members = self.members.setdefault(who_room, set()) + for nick, hash_hex in who_entries: + try: + hash_bytes = bytes.fromhex(hash_hex) + except Exception: + continue + if nick is None: + members.add(hash_bytes) + continue + for ph in members: + if ph.startswith(hash_bytes): + self.nicks[ph] = nick + break + silent_who = who_room in self._silent_who_rooms + if silent_who: + self._silent_who_rooms.discard(who_room) + self.manager._notify_change(self) + if silent_who: + return + room_n = room.strip().lower() if isinstance(room, str) else None + if room_n is None and isinstance(body, str) and body.strip(): + with self._lock: + self.motd = body + self.manager._notify_change(self) + msg = RRCMessage( + "notice", + room_n, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + None, + body, + _now_ms(), + ) + self._record_notice(msg) + return + + if t == T_ERROR: + body = env.get(K_BODY) + room = env.get(K_ROOM) + text = body if isinstance(body, str) else "(error)" + r = room.strip().lower() if isinstance(room, str) else None + rollback_join = False + if r: + with self._lock: + if r in self._pending_joins: + rollback_join = True + self._pending_joins.discard(r) + self._silent_joins.discard(r) + self._pending_parts.discard(r) + if rollback_join: + self.rooms.discard(r) + if rollback_join: + self.manager.save() + msg = RRCMessage( + "error", + r, + None, + None, + text, + _now_ms(), + ) + self._record_notice(msg) + return + + if t == T_RESOURCE_ENVELOPE: + body = env.get(K_BODY) + if not isinstance(body, dict): + return + try: + rid = body.get(B_RES_ID) + kind = body.get(B_RES_KIND) + size = body.get(B_RES_SIZE) + sha256 = body.get(B_RES_SHA256) + encoding = body.get(B_RES_ENCODING) + if not isinstance(rid, (bytes, bytearray)): return + if not isinstance(kind, str): return + if not isinstance(size, int) or size <= 0: return + room = env.get(K_ROOM) + with self._lock: + self._resource_expectations[bytes(rid)] = { + "kind": kind, + "size": size, + "sha256": bytes(sha256) if isinstance(sha256, (bytes, bytearray)) else None, + "encoding": encoding if isinstance(encoding, str) else "utf-8", + "room": room.strip().lower() if isinstance(room, str) else None, + "expires": time.monotonic()+30.0, + } + except Exception: + pass + return + + def _resource_advertised(self, resource): + try: + if hasattr(resource, "get_data_size"): + size = resource.get_data_size() + elif hasattr(resource, "total_size"): + size = resource.total_size + else: + size = getattr(resource, "size", 0) + except Exception: + return False + if size > 262144: + return False + return True + + def _resource_concluded(self, resource): + try: + if resource.status != RNS.Resource.COMPLETE: + try: + if hasattr(resource, "data") and resource.data: + resource.data.close() + except Exception: + pass + return + try: + size = resource.total_size if hasattr(resource, "total_size") else getattr(resource, "size", 0) + except Exception: + size = 0 + data = None + try: + data = resource.data.read() + finally: + try: + if hasattr(resource, "data") and resource.data: + resource.data.close() + except Exception: + pass + if data is None: + return + + now = time.monotonic() + matched = None + with self._lock: + expired = [k for k, v in self._resource_expectations.items() if v["expires"] < now] + for k in expired: + self._resource_expectations.pop(k, None) + for k, exp in list(self._resource_expectations.items()): + if exp["size"] == len(data): + matched = exp + self._resource_expectations.pop(k, None) + break + + kind = matched["kind"] if matched else RES_KIND_BLOB + room = matched["room"] if matched else None + encoding = matched["encoding"] if matched else "utf-8" + sha = matched["sha256"] if matched else None + if sha is not None: + if hashlib.sha256(data).digest() != sha: + return + if kind in (RES_KIND_NOTICE, RES_KIND_MOTD): + try: + text = data.decode(encoding, errors="replace") + except Exception: + return + if kind == RES_KIND_MOTD: + with self._lock: + self.motd = text + self.manager._notify_change(self) + msg = RRCMessage("notice", room, None, None, text, _now_ms()) + self._record_notice(msg) + except Exception as e: + self._log("resource handling failed: "+str(e), RNS.LOG_ERROR) + + +class RRCManager: + def __init__(self, app): + self.app = app + self.hubs = [] + self._lock = threading.RLock() + self._change_callback = None + self._message_callback = None + self._active_hub = None + self._active_room = None + self._loaded = False + self._loading = False + self._save_lock = threading.Lock() + + @property + def identity(self): + return self.app.identity + + def get_nickname(self): + try: + n = self.app.peer_settings.get("display_name") + if isinstance(n, str): + return n + except Exception: + pass + return None + + def set_change_callback(self, cb): + self._change_callback = cb + + def set_message_callback(self, cb): + self._message_callback = cb + + def _notify_change(self, hub=None): + try: + if self._change_callback is not None: + self._change_callback(hub) + except Exception: + pass + + def _notify_messages(self, hub, msg): + try: + if self._message_callback is not None: + self._message_callback(hub, msg) + except Exception: + pass + + def _on_welcome(self, hub): + for r in list(hub.rooms): + try: + hub.join_room(r, silent=True) + except Exception: + pass + + def set_active(self, hub, room): + self._active_hub = hub + self._active_room = room + if hub is not None and room is not None: + hub.mark_read(room) + + def active_room_for(self, hub): + if self._active_hub is hub: + return self._active_room + return None + + def has_unread(self): + with self._lock: + for hub in self.hubs: + if hub.unread_rooms: + return True + return False + + def add_hub(self, hub_hash, dest_name=None, name=None): + with self._lock: + for h in self.hubs: + if h.hub_hash == hub_hash and (h.dest_name == (dest_name or DEFAULT_DEST_NAME)): + return h + hub = RRCHub(self, hub_hash, dest_name=dest_name, name=name) + self.hubs.append(hub) + self.save() + self._notify_change() + return hub + + def remove_hub(self, hub): + with self._lock: + if hub in self.hubs: + self.hubs.remove(hub) + try: + hub.disconnect() + except Exception: + pass + self.save() + self._notify_change() + + def find_hub(self, hub_hash, dest_name=None): + dn = dest_name or DEFAULT_DEST_NAME + with self._lock: + for h in self.hubs: + if h.hub_hash == hub_hash and h.dest_name == dn: + return h + return None + + def _store_path(self): + return os.path.join(self.app.storagepath, "rrc_hubs") + + def _history_root(self): + return os.path.join(self.app.storagepath, HISTORY_DIR_NAME) + + def _history_dir(self, hub): + hub_key = hub.hub_hash.hex() + if hub.dest_name and hub.dest_name != DEFAULT_DEST_NAME: + suffix = hashlib.sha256(hub.dest_name.encode("utf-8")).hexdigest()[:8] + hub_key = hub_key + "__" + suffix + return os.path.join(self._history_root(), hub_key) + + def _history_path(self, hub, room): + sanitized = HISTORY_FILENAME_SANITIZE_RE.sub("_", room or "")[:64] + room_hash = hashlib.sha256((room or "").encode("utf-8")).hexdigest()[:8] + filename = (sanitized + "_" + room_hash + ".log") if sanitized else (room_hash + ".log") + return os.path.join(self._history_dir(hub), filename) + + def _ensure_history_dir(self, hub): + d = self._history_dir(hub) + os.makedirs(d, exist_ok=True) + return d + + def load(self): + if self._loaded: + return + self._loaded = True + path = self._store_path() + if not os.path.isfile(path): + return + self._loading = True + try: + with open(path, "rb") as f: + data = f.read() + obj = cbor.decode(data) + if not isinstance(obj, dict): + return + entries = obj.get("hubs") + if not isinstance(entries, list): + return + for e in entries: + if not isinstance(e, dict): + continue + hh = e.get("hash") + if not isinstance(hh, (bytes, bytearray)): + continue + dn = e.get("dest_name") + nm = e.get("name") + hub = self.add_hub(bytes(hh), dest_name=dn if isinstance(dn, str) else None, name=nm if isinstance(nm, str) else None) + rooms = e.get("rooms") + if isinstance(rooms, list): + for r in rooms: + if isinstance(r, str): + hub.add_room(r) + parted = e.get("parted_rooms") + if isinstance(parted, list): + for r in parted: + if isinstance(r, str): + try: + rn = hub._normalize_room(r) + with hub._lock: + hub.messages.setdefault(rn, []) + except Exception: + pass + ar = e.get("auto_reconnect") + if isinstance(ar, bool): + hub.auto_reconnect = ar + al = e.get("auto_list") + if isinstance(al, bool): + hub.auto_list = al + aw = e.get("auto_who") + if isinstance(aw, bool): + hub.auto_who = aw + no = e.get("nick") + if isinstance(no, str) and no: + hub.nick_override = no + try: + hub._load_history() + except Exception as ex: + RNS.log("Failed to load RRC history for "+hub.name+": "+str(ex), RNS.LOG_ERROR) + except Exception as e: + RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR) + finally: + self._loading = False + + def save(self): + if self._loading: + return + path = self._store_path() + tmp_path = path + ".tmp" + with self._save_lock: + try: + entries = [] + with self._lock: + for h in self.hubs: + joined = set(h.rooms) + parted = set(h.messages.keys()) - joined + entry = { + "hash": h.hub_hash, + "dest_name": h.dest_name, + "name": h.name, + "rooms": sorted(joined), + "parted_rooms": sorted(parted), + "auto_reconnect": bool(h.auto_reconnect), + "auto_list": bool(h.auto_list), + "auto_who": bool(h.auto_who), + } + if isinstance(h.nick_override, str) and h.nick_override: + entry["nick"] = h.nick_override + entries.append(entry) + data = cbor.encode({"hubs": entries}) + with open(tmp_path, "wb") as f: + f.write(data) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + os.replace(tmp_path, path) + except Exception as e: + # + # + # + # + try: os.unlink(tmp_path) + except Exception: pass + + def shutdown(self): + for h in list(self.hubs): + try: + h.disconnect() + except Exception: + pass diff --git a/nomadnet/__init__.py b/nomadnet/__init__.py new file mode 100644 index 0000000..eb90424 --- /dev/null +++ b/nomadnet/__init__.py @@ -0,0 +1,17 @@ +import os +import glob + +from .NomadNetworkApp import NomadNetworkApp +from .Conversation import Conversation +from .Directory import Directory +from .Node import Node +from .ui import * + + +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) + +def panic(): + os._exit(255) \ No newline at end of file diff --git a/nomadnet/_version.py b/nomadnet/_version.py new file mode 100644 index 0000000..10aa336 --- /dev/null +++ b/nomadnet/_version.py @@ -0,0 +1 @@ +__version__ = "1.2.3" diff --git a/nomadnet/examples/messageboard/README.md b/nomadnet/examples/messageboard/README.md new file mode 100644 index 0000000..16e35e0 --- /dev/null +++ b/nomadnet/examples/messageboard/README.md @@ -0,0 +1,18 @@ +# lxmf_messageboard +Simple message board that can be hosted on a NomadNet node, messages can be posted by 'conversing' with a unique peer, all messages are then forwarded to the message board. + +## How Do I Use It? +A user can submit messages to the message board by initiating a chat with the message board peer, they are assigned a username (based on the first 5 characters of their address) and their messages are added directly to the message board. The message board can be viewed on a page hosted by a NomadNet node. + +An example message board can be found on the reticulum testnet hosted on the SolarExpress Node `` and the message board peer `` + +## How Does It Work? +The message board page itself is hosted on a NomadNet node, you can place the message_board.mu into the pages directory. You can then run the message_board.py script which provides the peer that the users can send messages to. The two parts are joined together using umsgpack and a flat file system similar to NomadNet and Reticulum and runs in the background. + +## How Do I Set It Up? +* Turn on node hosting in NomadNet +* Put the `message_board.mu` file into `pages` directory in the config file for `NomadNet`. Edit the file to customise from the default page. +* Run the `message_board.py` script (`python3 message_board.py` either in a `screen` or as a system service), this script uses `NomadNet` and `RNS` libraries and has no additional libraries that need to be installed. Take a note of the message boards address, it is printed on starting the board, you can then place this address in `message_board.mu` file to make it easier for users to interact the board. + +## Credits +* This example application was written and contributed by @chengtripp \ No newline at end of file diff --git a/nomadnet/examples/messageboard/messageboard.mu b/nomadnet/examples/messageboard/messageboard.mu new file mode 100644 index 0000000..24993d0 --- /dev/null +++ b/nomadnet/examples/messageboard/messageboard.mu @@ -0,0 +1,41 @@ +#!/bin/python3 +import time +import os +import RNS.vendor.umsgpack as msgpack + +message_board_peer = 'please_replace' +userdir = os.path.expanduser("~") + +if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"): + configdir = "/etc/nomadmb" +elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"): + configdir = userdir+"/.config/nomadmb" +else: + configdir = userdir+"/.nomadmb" + +storagepath = configdir+"/storage" +if not os.path.isdir(storagepath): + os.makedirs(storagepath) + +boardpath = configdir+"/storage/board" + +print('`!`F222`Bddd`cNomadNet Message Board') + +print('-') +print('`a`b`f') +print("") +print("To add a message to the board just converse with the NomadNet Message Board at `[lxmf@{}]".format(message_board_peer)) +time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) +print("Last Updated: {}".format(time_string)) +print("") +print('>Messages') +print(" Date Time Username Message") +f = open(boardpath, "rb") +board_contents = msgpack.unpack(f) +board_contents.reverse() + +for content in board_contents: + print("`a{}".format(content.rstrip())) + print("") + +f.close() diff --git a/nomadnet/examples/messageboard/messageboard.py b/nomadnet/examples/messageboard/messageboard.py new file mode 100644 index 0000000..8a71cd1 --- /dev/null +++ b/nomadnet/examples/messageboard/messageboard.py @@ -0,0 +1,187 @@ +# Simple message board that can be hosted on a NomadNet node, messages can be posted by 'conversing' with a unique peer, all messages are then forwarded to the message board. +# https://github.com/chengtripp/lxmf_messageboard + +import RNS +import LXMF +import os, time +from queue import Queue +import RNS.vendor.umsgpack as msgpack + +display_name = "NomadNet Message Board" +max_messages = 20 + +def setup_lxmf(): + if os.path.isfile(identitypath): + identity = RNS.Identity.from_file(identitypath) + RNS.log('Loaded identity from file', RNS.LOG_INFO) + else: + RNS.log('No Primary Identity file found, creating new...', RNS.LOG_INFO) + identity = RNS.Identity() + identity.to_file(identitypath) + + return identity + +def lxmf_delivery(message): + # Do something here with a received message + RNS.log("A message was received: "+str(message.content.decode('utf-8'))) + + message_content = message.content.decode('utf-8') + source_hash_text = RNS.hexrep(message.source_hash, delimit=False) + + #Create username (just first 5 char of your addr) + username = source_hash_text[0:5] + + RNS.log('Username: {}'.format(username), RNS.LOG_INFO) + + time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) + new_message = '{} {}: {}\n'.format(time_string, username, message_content) + + # Push message to board + # First read message board (if it exists + if os.path.isfile(boardpath): + f = open(boardpath, "rb") + message_board = msgpack.unpack(f) + f.close() + else: + message_board = [] + + #Check we aren't doubling up (this can sometimes happen if there is an error initially and it then gets fixed) + if new_message not in message_board: + # Append our new message to the list + message_board.append(new_message) + + # Prune the message board if needed + while len(message_board) > max_messages: + RNS.log('Pruning Message Board') + message_board.pop(0) + + # Now open the board and write the updated list + f = open(boardpath, "wb") + msgpack.pack(message_board, f) + f.close() + + # Send reply + message_reply = '{}_{}_Your message has been added to the messageboard'.format(source_hash_text, time.time()) + q.put(message_reply) + +def announce_now(lxmf_destination): + lxmf_destination.announce() + +def send_message(destination_hash, message_content): + try: + # Make a binary destination hash from a hexadecimal string + destination_hash = bytes.fromhex(destination_hash) + + except Exception as e: + RNS.log("Invalid destination hash", RNS.LOG_ERROR) + return + + # Check that size is correct + if not len(destination_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + RNS.log("Invalid destination hash length", RNS.LOG_ERROR) + + else: + # Length of address was correct, let's try to recall the + # corresponding Identity + destination_identity = RNS.Identity.recall(destination_hash) + + if destination_identity == None: + # No path/identity known, we'll have to abort or request one + RNS.log("Could not recall an Identity for the requested address. You have probably never received an announce from it. Try requesting a path from the network first. In fact, let's do this now :)", RNS.LOG_ERROR) + RNS.Transport.request_path(destination_hash) + RNS.log("OK, a path was requested. If the network knows a path, you will receive an announce with the Identity data shortly.", RNS.LOG_INFO) + + else: + # We know the identity for the destination hash, let's + # reconstruct a destination object. + lxmf_destination = RNS.Destination(destination_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + + # Create a new message object + lxm = LXMF.LXMessage(lxmf_destination, local_lxmf_destination, message_content, title="Reply", desired_method=LXMF.LXMessage.DIRECT) + + # You can optionally tell LXMF to try to send the message + # as a propagated message if a direct link fails + lxm.try_propagation_on_fail = True + + # Send it + message_router.handle_outbound(lxm) + +def announce_check(): + if os.path.isfile(announcepath): + f = open(announcepath, "r") + announce = int(f.readline()) + f.close() + else: + RNS.log('failed to open announcepath', RNS.LOG_DEBUG) + announce = 1 + + if announce > int(time.time()): + RNS.log('Recent announcement', RNS.LOG_DEBUG) + else: + f = open(announcepath, "w") + next_announce = int(time.time()) + 1800 + f.write(str(next_announce)) + f.close() + announce_now(local_lxmf_destination) + RNS.log('Announcement sent, expr set 1800 seconds', RNS.LOG_INFO) + +#Setup Paths and Config Files +userdir = os.path.expanduser("~") + +if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"): + configdir = "/etc/nomadmb" +elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"): + configdir = userdir+"/.config/nomadmb" +else: + configdir = userdir+"/.nomadmb" + +storagepath = configdir+"/storage" +if not os.path.isdir(storagepath): + os.makedirs(storagepath) + +identitypath = configdir+"/storage/identity" +announcepath = configdir+"/storage/announce" +boardpath = configdir+"/storage/board" + +# Message Queue +q = Queue(maxsize = 5) + +# Start Reticulum and print out all the debug messages +reticulum = RNS.Reticulum(loglevel=RNS.LOG_VERBOSE) + +# Create a Identity. +current_identity = setup_lxmf() + +# Init the LXMF router +message_router = LXMF.LXMRouter(identity = current_identity, storagepath = configdir) + +# Register a delivery destination (for yourself) +# In this example we use the same Identity as we used +# to instantiate the LXMF router. It could be a different one, +# but it can also just be the same, depending on what you want. +local_lxmf_destination = message_router.register_delivery_identity(current_identity, display_name=display_name) + +# Set a callback for when a message is received +message_router.register_delivery_callback(lxmf_delivery) + +# Announce node properties + +RNS.log('LXMF Router ready to receive on: {}'.format(RNS.prettyhexrep(local_lxmf_destination.hash)), RNS.LOG_INFO) +announce_check() + +while True: + + # Work through internal message queue + for i in list(q.queue): + message_id = q.get() + split_message = message_id.split('_') + destination_hash = split_message[0] + message = split_message[2] + RNS.log('{} {}'.format(destination_hash, message), RNS.LOG_INFO) + send_message(destination_hash, message) + + # Check whether we need to make another announcement + announce_check() + + #Sleep + time.sleep(10) diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py new file mode 100644 index 0000000..5d1eccc --- /dev/null +++ b/nomadnet/examples/various/input_fields.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +import os +env_string = "" +for e in os.environ: + env_string += "{}={}\n".format(e, os.environ[e]) + +template = """>Fields and Submitting Data + +Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. This page contains a few examples. + +>> Read Environment Variables + +{@ENV} +>>Examples of Fields and Submissions + +The following section contains a simple set of fields, and a few different links that submit the field data in different ways. + +-= + + +>>>Text Fields +An input field : `B444``b + +An masked field : `B444``b + +An small field : `B444`<8|small`test>`b, and some more text. + +Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b + +The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. + +>> Checkbox Fields + +`B444``b Sign me up + +>> Radio group + +Select your favorite color: + +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue + + +>>> Submitting data + +You can `!`[submit`:/page/input_fields.mu`one|password|small|color]`! other fields, or just `!`[a single one`:/page/input_fields.mu`username]`! + +Or simply `!`[submit them all`:/page/input_fields.mu`*]`!. + +Submission links can also `!`[include pre-configured variables`:/page/input_fields.mu`username|two|entitiy_id=4611|action=view]`!. + +Or take all fields and `!`[pre-configured variables`:/page/input_fields.mu`*|entitiy_id=4611|action=view]`!. + +Or only `!`[pre-configured variables`:/page/input_fields.mu`entitiy_id=4688|task=something]`! + +-= + +""" +print(template.replace("{@ENV}", env_string)) \ No newline at end of file diff --git a/nomadnet/nomadnet.py b/nomadnet/nomadnet.py new file mode 100644 index 0000000..cb8591f --- /dev/null +++ b/nomadnet/nomadnet.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 + +from ._version import __version__ + +import io +import argparse +import nomadnet + + +def program_setup(configdir, rnsconfigdir, daemon, console): + app = nomadnet.NomadNetworkApp( + configdir = configdir, + rnsconfigdir = rnsconfigdir, + daemon = daemon, + force_console = console, + ) + +def main(): + try: + parser = argparse.ArgumentParser(description="Nomad Network Client") + parser.add_argument("--config", action="store", default=None, help="path to alternative Nomad Network config directory", type=str) + parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str) + parser.add_argument("-t", "--textui", action="store_true", default=False, help="run Nomad Network in text-UI mode") + parser.add_argument("-d", "--daemon", action="store_true", default=False, help="run Nomad Network in daemon mode") + parser.add_argument("-c", "--console", action="store_true", default=False, help="in daemon mode, log to console instead of file") + parser.add_argument("--version", action="version", version="Nomad Network Client {version}".format(version=__version__)) + + args = parser.parse_args() + + if args.config: + configarg = args.config + else: + configarg = None + + if args.rnsconfig: + rnsconfigarg = args.rnsconfig + else: + rnsconfigarg = None + + console = False + if args.daemon: + daemon = True + if args.console: + console = True + else: + daemon = False + + if args.textui: + daemon = False + + program_setup(configarg, rnsconfigarg, daemon, console) + + except KeyboardInterrupt: + print("") + exit() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nomadnet/ui/GraphicalUI.py b/nomadnet/ui/GraphicalUI.py new file mode 100644 index 0000000..ee51ae7 --- /dev/null +++ b/nomadnet/ui/GraphicalUI.py @@ -0,0 +1,8 @@ +import RNS +import nomadnet + +class GraphicalUI: + + def __init__(self): + RNS.log("Graphical UI not implemented", RNS.LOG_ERROR) + nomadnet.panic() \ No newline at end of file diff --git a/nomadnet/ui/MenuUI.py b/nomadnet/ui/MenuUI.py new file mode 100644 index 0000000..0d7bad9 --- /dev/null +++ b/nomadnet/ui/MenuUI.py @@ -0,0 +1,8 @@ +import RNS +import nomadnet + +class MenuUI: + + def __init__(self): + RNS.log("Menu UI not implemented", RNS.LOG_ERROR, _override_destination=True) + nomadnet.panic() \ No newline at end of file diff --git a/nomadnet/ui/NoneUI.py b/nomadnet/ui/NoneUI.py new file mode 100644 index 0000000..40a58cc --- /dev/null +++ b/nomadnet/ui/NoneUI.py @@ -0,0 +1,19 @@ +import RNS +import nomadnet +import time + +from nomadnet import NomadNetworkApp + +class NoneUI: + + def __init__(self): + self.app = NomadNetworkApp.get_shared_instance() + self.app.ui = self + + if not self.app.force_console_log: + RNS.log("Nomad Network started in daemon mode, all further messages are logged to "+str(self.app.logfilepath), RNS.LOG_INFO, _override_destination=True) + else: + RNS.log("Nomad Network daemon started", RNS.LOG_INFO) + + while True: + time.sleep(1) \ No newline at end of file diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py new file mode 100644 index 0000000..15abd7b --- /dev/null +++ b/nomadnet/ui/TextUI.py @@ -0,0 +1,269 @@ +import RNS +import urwid +import time +import os +import platform + +import nomadnet +from nomadnet.ui import THEME_DARK, THEME_LIGHT +from nomadnet.ui.textui import * +from nomadnet import NomadNetworkApp + +COLORMODE_MONO = 1 +COLORMODE_16 = 16 +COLORMODE_88 = 88 +COLORMODE_256 = 256 +COLORMODE_TRUE = 2**24 + +THEMES = { + THEME_DARK: { + "urwid_theme": [ + # Style name # 16-color style # Monochrome style # 88, 256 and true-color style + ("heading", "light gray,underline", "default", "underline", "g93,underline", "default"), + ("menubar", "black", "light gray", "standout", "#111", "#bbb"), + ("scrollbar", "light gray", "default", "default", "#444", "default"), + ("shortcutbar", "black", "light gray", "standout", "#111", "#bbb"), + ("body_text", "light gray", "default", "default", "#ddd", "default"), + ("error_text", "dark red", "default", "default", "dark red", "default"), + ("warning_text", "yellow", "default", "default", "#ba4", "default"), + ("inactive_text", "dark gray", "default", "default", "dark gray", "default"), + ("browser_inactive", "dark gray", "default", "default", "#444", "default"), + ("buttons", "light green,bold", "default", "default", "#00a533", "default"), + ("msg_editor", "black", "light cyan", "standout", "#111", "#0bb"), + ("msg_header_ok", "black", "light green", "standout", "#111", "#6b2"), + ("msg_header_caution", "black", "yellow", "standout", "#111", "#fd3"), + ("msg_header_sent", "black", "light gray", "standout", "#111", "#ddd"), + ("msg_header_propagated", "black", "light blue", "standout", "#111", "#28b"), + ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), + ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), + ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), + ("msg_notice_unread", "light blue", "default", "standout", "#28b", "default"), + ("msg_notice_caution", "yellow", "default", "standout", "#fd3", "default"), + ("list_focus", "black", "light gray", "standout", "#111", "#aaa"), + ("list_off_focus", "black", "dark gray", "standout", "#111", "#777"), + ("list_trusted", "dark green", "default", "default", "#6b2", "default"), + ("list_focus_trusted", "black", "light gray", "standout", "#150", "#aaa"), + ("list_unknown", "dark gray", "default", "default", "#bbb", "default"), + ("list_normal", "dark gray", "default", "default", "#bbb", "default"), + ("list_untrusted", "dark red", "default", "default", "#a22", "default"), + ("list_focus_untrusted", "black", "light gray", "standout", "#810", "#aaa"), + ("list_unresponsive", "yellow", "default", "default", "#b92", "default"), + ("list_focus_unresponsive", "black", "light gray", "standout", "#530", "#aaa"), + ("topic_list_normal", "light gray", "default", "default", "#ddd", "default"), + ("browser_controls", "light gray", "default", "default", "#bbb", "default"), + ("progress_full", "black", "light gray", "standout", "#111", "#bbb"), + ("progress_empty", "light gray", "default", "default", "#ddd", "default"), + ("interface_title", "", "", "default", "", ""), + ("interface_title_selected", "bold", "", "bold", "", ""), + ("connected_status", "dark green", "default", "default", "dark green", "default"), + ("disconnected_status", "dark red", "default", "default", "dark red", "default"), + ("placeholder", "dark gray", "default", "default", "dark gray", "default"), + ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"), + ("error", "light red,blink", "default", "blink", "#f44,blink", "default"), + ("irc_ts", "dark gray", "default", "default", "#888", "default"), + ("irc_nick_self", "light green", "default", "default", "#6c5", "default"), + ("irc_nick_peer", "light cyan", "default", "default", "#3cd", "default"), + ("irc_notice", "yellow", "default", "default", "#fd3", "default"), + ("irc_error", "light red", "default", "default", "#f55", "default"), + ("irc_system", "dark gray", "default", "default", "#888", "default"), + ("irc_mention", "light red,bold", "default", "bold", "#fb4,bold", "default"), + + ], + }, + THEME_LIGHT: { + "urwid_theme": [ + # Style name # 16-color style # Monochrome style # 88, 256 and true-color style + ("heading", "dark gray,underline", "default", "underline", "g93,underline", "default"), + ("menubar", "black", "dark gray", "standout", "#111", "#bbb"), + ("scrollbar", "dark gray", "default", "default", "#444", "default"), + ("shortcutbar", "black", "dark gray", "standout", "#111", "#bbb"), + ("body_text", "dark gray", "default", "default", "#222", "default"), + ("error_text", "dark red", "default", "default", "dark red", "default"), + ("warning_text", "yellow", "default", "default", "#ba4", "default"), + ("inactive_text", "light gray", "default", "default", "dark gray", "default"), + ("buttons", "light green,bold", "default", "default", "#00a533", "default"), + ("msg_editor", "black", "dark cyan", "standout", "#111", "#0bb"), + ("msg_header_ok", "black", "dark green", "standout", "#111", "#6b2"), + ("msg_header_caution", "black", "yellow", "standout", "#111", "#fd3"), + ("msg_header_sent", "black", "dark gray", "standout", "#111", "#ddd"), + ("msg_header_propagated", "black", "light blue", "standout", "#111", "#28b"), + ("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"), + ("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"), + ("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"), + ("msg_notice_unread", "dark blue", "default", "standout", "#069", "default"), + ("msg_notice_caution", "yellow", "default", "standout", "#fd3", "default"), + ("list_focus", "black", "dark gray", "standout", "#111", "#aaa"), + ("list_off_focus", "black", "dark gray", "standout", "#111", "#777"), + ("list_trusted", "dark green", "default", "default", "#4a0", "default"), + ("list_focus_trusted", "black", "dark gray", "standout", "#150", "#aaa"), + ("list_unknown", "dark gray", "default", "default", "#444", "default"), + ("list_normal", "dark gray", "default", "default", "#444", "default"), + ("list_untrusted", "dark red", "default", "default", "#a22", "default"), + ("list_focus_untrusted", "black", "dark gray", "standout", "#810", "#aaa"), + ("list_unresponsive", "yellow", "default", "default", "#b92", "default"), + ("list_focus_unresponsive", "black", "light gray", "standout", "#530", "#aaa"), + ("topic_list_normal", "dark gray", "default", "default", "#222", "default"), + ("browser_controls", "dark gray", "default", "default", "#444", "default"), + ("progress_full", "black", "dark gray", "standout", "#111", "#bbb"), + ("progress_empty", "dark gray", "default", "default", "#ddd", "default"), + ("interface_title", "dark gray", "default", "default", "#444", "default"), + ("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"), + ("connected_status", "dark green", "default", "default", "#4a0", "default"), + ("disconnected_status", "dark red", "default", "default", "#a22", "default"), + ("placeholder", "light gray", "default", "default", "#999", "default"), + ("placeholder_text", "light gray", "default", "default", "#999", "default"), + ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), + ("irc_ts", "dark gray", "default", "default", "#888", "default"), + ("irc_nick_self", "dark green", "default", "default", "#3a0", "default"), + ("irc_nick_peer", "dark cyan", "default", "default", "#077", "default"), + ("irc_notice", "brown", "default", "default", "#a70", "default"), + ("irc_error", "dark red", "default", "default", "#a22", "default"), + ("irc_system", "dark gray", "default", "default", "#888", "default"), + ("irc_mention", "dark red,bold", "default", "bold", "#c50,bold", "default"), + ], + } +} + +GLYPHSETS = { + "plain": 1, + "unicode": 2, + "nerdfont": 3 +} + +if platform.system() == "Darwin": + urm_char = " \uf0e0" + ur_char = "\uf0e0 " +else: + urm_char = " \uf003" + ur_char = "\uf003 " + +GLYPHS = { + # Glyph name # Plain # Unicode # Nerd Font + ("check", "=", "\u2713", "\u2713"), + ("cross", "X", "\u2715", "\u2715"), + ("unknown", "?", "?", "?"), + ("encrypted", "", "\u26BF", "\uf023"), + ("plaintext", "!", "!", "\uf06e "), + ("arrow_r", "->", "\u2192", "\u2192"), + ("arrow_l", "<-", "\u2190", "\u2190"), + ("arrow_u", "/\\", "\u2191", "\u2191"), + ("arrow_d", "\\/", "\u2193", "\u2193"), + ("warning", "!", "\u26a0", "\uf12a"), + ("info", "i", "\u2139", "\U000f064e"), + ("unread", "[!]", "\u2709", ur_char), + ("divider1", "-", "\u2504", "\u2504"), + ("peer", "[P]", "\u24c5 ", "\uf415"), + ("node", "[N]", "\u24c3 ", "\U000f0002"), + ("page", "", "\u25a4 ", "\uf719 "), + ("speed", "", "\u25F7 ", "\U000f04c5 "), + ("decoration_menu", " +", " +", " \U000f043b"), + ("unread_menu", " !", " \u2709", urm_char), + ("globe", "", "", "\uf484"), + ("sent", "/\\", "\u2191", "\U000f0cd8"), + ("papermsg", "P", "\u25a4", "\uf719"), + ("qrcode", "QR", "\u25a4", "\uf029"), + ("selected", "[*] ", "\u25CF", "\u25CF"), + ("unselected", "[ ] ", "\u25CB", "\u25CB"), + ("file", "[F]", "\u25a4", "\uf15b"), + ("image", "[I]", "\u25a3", "\uf1c5"), + ("audio", "[~]", "\u266b", "\uf1c7"), + ("pin", "*", "\u2605", "\uf08d"), + ("copy", "[C]", "\u29c9", "\uf0c5"), +} + +class TextUI: + + def __init__(self): + self.restore_ixon = False + + try: + rval = os.system("stty -a | grep \"\\-ixon\"") + if rval == 0: + pass + + else: + os.system("stty -ixon") + self.restore_ixon = True + + except Exception as e: + RNS.log("Could not configure terminal flow control sequences, some keybindings may not work.", RNS.LOG_WARNING) + RNS.log("The contained exception was: "+str(e)) + + self.app = NomadNetworkApp.get_shared_instance() + self.app.ui = self + self.loop = None + + urwid.set_encoding("UTF-8") + + intro_timeout = self.app.config["textui"]["intro_time"] + colormode = self.app.config["textui"]["colormode"] + theme = self.app.config["textui"]["theme"] + mouse_enabled = self.app.config["textui"]["mouse_enabled"] + + self.palette = THEMES[theme]["urwid_theme"] + + if self.app.config["textui"]["glyphs"] == "plain": + glyphset = "plain" + elif self.app.config["textui"]["glyphs"] == "unicode": + glyphset = "unicode" + elif self.app.config["textui"]["glyphs"] == "nerdfont": + glyphset = "nerdfont" + else: + glyphset = "unicode" + + self.glyphs = {} + for glyph in GLYPHS: + self.glyphs[glyph[0]] = glyph[GLYPHSETS[glyphset]] + + self.screen = urwid.raw_display.Screen() + self.screen.register_palette(self.palette) + + self.main_display = Main.MainDisplay(self, self.app) + + if intro_timeout > 0: + self.intro_display = Extras.IntroDisplay(self.app) + initial_widget = self.intro_display.widget + else: + initial_widget = self.main_display.widget + + self.loop = urwid.MainLoop(initial_widget, unhandled_input=self.unhandled_input, screen=self.screen, handle_mouse=mouse_enabled) + + if intro_timeout > 0: + self.loop.set_alarm_in(intro_timeout, self.display_main) + + if "KONSOLE_VERSION" in os.environ: + if colormode > 16: + RNS.log("", RNS.LOG_WARNING, _override_destination = True) + RNS.log("", RNS.LOG_WARNING, _override_destination = True) + RNS.log("You are using the terminal emulator Konsole.", RNS.LOG_WARNING, _override_destination = True) + 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) + RNS.log("", RNS.LOG_WARNING, _override_destination = True) + + RNS.log("To circumvent this, use another terminal emulator, or launch nomadnet within a", RNS.LOG_WARNING, _override_destination = True) + RNS.log("screen session, using a command like the following:", RNS.LOG_WARNING, _override_destination = True) + RNS.log("", RNS.LOG_WARNING, _override_destination = True) + RNS.log("screen nomadnet", RNS.LOG_WARNING, _override_destination = True) + RNS.log("", RNS.LOG_WARNING, _override_destination = True) + RNS.log("Press ctrl-c to exit now and try again.", RNS.LOG_WARNING, _override_destination = True) + + self.set_colormode(colormode) + + self.main_display.start() + self.loop.run() + + def set_colormode(self, colormode): + self.colormode = colormode + self.screen.set_terminal_properties(colormode) + + if self.colormode < 256: + self.screen.reset_default_terminal_palette() + self.restore_palette = True + + def unhandled_input(self, key): + if key == "ctrl q": + raise urwid.ExitMainLoop + elif key == "ctrl e": + pass + + def display_main(self, loop, user_data): + self.loop.widget = self.main_display.widget diff --git a/nomadnet/ui/WebUI.py b/nomadnet/ui/WebUI.py new file mode 100644 index 0000000..1e00807 --- /dev/null +++ b/nomadnet/ui/WebUI.py @@ -0,0 +1,8 @@ +import RNS +import nomadnet + +class WebUI: + + def __init__(self): + RNS.log("Web UI not implemented", RNS.LOG_ERROR) + nomadnet.panic() \ No newline at end of file diff --git a/nomadnet/ui/__init__.py b/nomadnet/ui/__init__.py new file mode 100644 index 0000000..cb06cf8 --- /dev/null +++ b/nomadnet/ui/__init__.py @@ -0,0 +1,47 @@ +import os +import glob +import RNS +import nomadnet + +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) + +THEME_DARK = 0x01 +THEME_LIGHT = 0x02 + +UI_NONE = 0x00 +UI_MENU = 0x01 +UI_TEXT = 0x02 +UI_GRAPHICAL = 0x03 +UI_WEB = 0x04 +UI_MODES = [UI_NONE, UI_MENU, UI_TEXT, UI_GRAPHICAL, UI_WEB] + +def spawn(uimode): + if uimode in UI_MODES: + if uimode == UI_NONE: + RNS.log("Starting Nomad Network daemon...", RNS.LOG_INFO) + else: + RNS.log("Starting user interface...", RNS.LOG_INFO) + + if uimode == UI_MENU: + from .MenuUI import MenuUI + return MenuUI() + elif uimode == UI_TEXT: + from .TextUI import TextUI + return TextUI() + elif uimode == UI_GRAPHICAL: + from .GraphicalUI import GraphicalUI + return GraphicalUI() + elif uimode == UI_WEB: + from .WebUI import WebUI + return WebUI() + elif uimode == UI_NONE: + from .NoneUI import NoneUI + return NoneUI() + else: + return None + else: + RNS.log("Invalid UI mode", RNS.LOG_ERROR, _override_destination=True) + nomadnet.panic() \ No newline at end of file diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py new file mode 100644 index 0000000..90eaff4 --- /dev/null +++ b/nomadnet/ui/textui/Browser.py @@ -0,0 +1,1848 @@ +import RNS +import LXMF +import io +import os +import time +import urwid +import shutil +import nomadnet +import subprocess +import threading +from threading import Lock +from .MicronParser import markup_to_attrmaps, make_style, default_state +from nomadnet.Directory import DirectoryEntry +from nomadnet.vendor.Scrollable import * +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name +from .Helpers import ClickableIcon, osc52_copy +from .ReadlineEdit import ReadlineMixin, ReadlineEdit + +class BrowserFrame(urwid.Frame): + def keypress(self, size, key): + if key == "ctrl w": + self.delegate.disconnect() + elif key == "ctrl d": + self.delegate.back() + elif key == "ctrl f": + self.delegate.forward() + elif key == "ctrl r": + self.delegate.reload() + elif key == "ctrl u": + self.delegate.url_dialog() + elif key == "ctrl s": + self.delegate.save_node_dialog() + elif key == "ctrl b": + self.delegate.save_node_dialog() + elif key == "ctrl g": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen() + elif key == "ctrl y": + if self.delegate.app.config["textui"]["clipboard_copy"]: + self.delegate.copy_url() + elif self.focus_position == "body": + if key == "down" or key == "up": + try: + if hasattr(self.delegate, "page_pile") and self.delegate.page_pile: + def df(loop, user_data): + st = None + if self.delegate.page_pile: + nf = self.delegate.page_pile.focus + if hasattr(nf, "key_timeout"): + st = nf + elif hasattr(nf, "original_widget"): + no = nf.original_widget + if hasattr(no, "original_widget"): + st = no.original_widget + else: + if hasattr(no, "key_timeout"): + st = no + + if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): + st.keypress(None, None) + + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.25, df) + + except Exception as e: + RNS.log("Error while setting up cursor timeout. The contained exception was: "+str(e), RNS.LOG_ERROR) + + return super(BrowserFrame, self).keypress(size, key) + + else: + return super(BrowserFrame, self).keypress(size, key) + +class Browser: + DEFAULT_PATH = "/page/index.mu" + DEFAULT_TIMEOUT = 10 + DEFAULT_CACHE_TIME = 12*60*60 + + NO_PATH = 0x00 + PATH_REQUESTED = 0x01 + ESTABLISHING_LINK = 0x02 + LINK_TIMEOUT = 0x03 + LINK_ESTABLISHED = 0x04 + REQUESTING = 0x05 + REQUEST_SENT = 0x06 + REQUEST_FAILED = 0x07 + REQUEST_TIMEOUT = 0x08 + RECEIVING_RESPONSE = 0x09 + DISCONECTED = 0xFE + DONE = 0xFF + + def __init__(self, app, app_name, aspects, destination_hash = None, path = None, auth_identity = None, delegate = None): + self.app = app + self.g = self.app.ui.glyphs + self.delegate = delegate + self.app_name = app_name + self.aspects = aspects + self.destination_hash = destination_hash + self.path = path + self.request_data = None + self.timeout = Browser.DEFAULT_TIMEOUT + self.last_keypress = None + + self.link = None + self.loopback = None + self.status = Browser.DISCONECTED + self.progress_updated_at = None + self.previous_progress = 0 + self.response_progress = 0 + self.response_speed = None + self.response_size = None + self.response_transfer_size = None + self.page_background_color = None + self.page_foreground_color = None + self.saved_file_name = None + self.saved_file_size = 0 + self.file_saved_at = 0 + self.file_save_notice_timeout = 3 + self.page_data = None + self.displayed_page_data = None + self.auth_identity = auth_identity + self.display_widget = None + self.link_status_showing = False + self.link_target = None + self.frame = None + self.attr_maps = [] + self.page_pile = None + self.page_partials = {} + self.updater_running = False + self.partial_updater_lock = Lock() + self.build_display() + + self.history = [] + self.history_ptr = 0 + self.history_inc = False + self.history_dec = False + self.reloading = False + self.loaded_from_cache = False + + if self.path == None: + self.path = Browser.DEFAULT_PATH + + if self.destination_hash != None: + self.load_page() + + self.clean_cache() + + def current_url(self): + if self.destination_hash == None: + return "" + path = "" if self.path == None else self.path + url = RNS.hexrep(self.destination_hash, delimit=False)+":"+path + + if isinstance(self.request_data, dict) and self.request_data: + parts = [] + for k, v in self.request_data.items(): + if not isinstance(k, str): + continue + if not k.startswith("var_"): + continue + parts.append(k[4:]+"="+str(v)) + if parts: + url += "`" + "|".join(parts) + + return url + + def url_hash(self, url): + if url == None: + return None + else: + url = url.encode("utf-8") + return RNS.hexrep(RNS.Identity.full_hash(url), delimit=False) + + + def marked_link(self, link_target, link_fields=None): + if link_fields: + fields_str = "|".join(link_fields) + link_target = f"{link_target}`{fields_str}" + if self.status == Browser.DONE: + self.link_target = link_target + self.app.ui.loop.set_alarm_in(0.1, self.marked_link_job) + + def marked_link_job(self, sender, event): + link_target = self.link_target + can_display = time.time() > self.file_saved_at + self.file_save_notice_timeout + + if link_target == None: + if self.link_status_showing: + self.browser_footer = self.make_status_widget() + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + self.link_status_showing = False + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) + else: + if can_display: + self.link_status_showing = True + lt_str = str(link_target) + lmax = self._content_cols() + lstr = "Link to "+lt_str + if len(lstr) > lmax: lstr = lstr[:lmax-1]+"…" + self.browser_footer = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text(lstr)]), "browser_controls") + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) + + def expand_shorthands(self, destination_type): + if destination_type == "nnn": + return "nomadnetwork.node" + elif destination_type == "lxmf": + return "lxmf.delivery" + elif destination_type == "rrc": + return "rrc.hub.session" + else: + return destination_type + + def handle_link(self, link_target, link_data = None): + partial_ids = None + request_data = None + if link_data != None: + link_fields = [] + request_data = {} + all_fields = True if "*" in link_data else False + + for e in link_data: + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + else: + link_fields.append(e) + + def recurse_down(w): + if isinstance(w, list): + for t in w: + recurse_down(t) + elif isinstance(w, tuple): + for t in w: + recurse_down(t) + elif hasattr(w, "contents"): + recurse_down(w.contents) + elif hasattr(w, "original_widget"): + recurse_down(w.original_widget) + elif hasattr(w, "_original_widget"): + recurse_down(w._original_widget) + else: + if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields): + field_key = "field_" + w.field_name + if isinstance(w, urwid.Edit): + request_data[field_key] = w.edit_text + elif isinstance(w, urwid.RadioButton): + if w.state: + user_data = getattr(w, "field_value", None) + if user_data is not None: + request_data[field_key] = user_data + elif isinstance(w, urwid.CheckBox): + user_data = getattr(w, "field_value", "1") + if w.state: + existing_value = request_data.get(field_key, '') + if existing_value: + # Concatenate the new value with the existing one + request_data[field_key] = existing_value + ',' + user_data + else: + # Initialize the field with the current value + request_data[field_key] = user_data + else: + pass # do nothing if checkbox is not check + + recurse_down(self.attr_maps) + RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + + # In-document anchor link (#name or empty #) + if link_target.startswith("#"): + def df(loop, user_data): self._jump_to_anchor(link_target[1:]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + return + + # rrc://[:]/ URL form + if link_target.startswith("rrc://"): + self.handle_rrc_link(link_target[6:]) + return + + components = link_target.split("@") + destination_type = None + + if len(components) == 2: + destination_type = self.expand_shorthands(components[0]) + link_target = components[1] + elif link_target.startswith("p:"): + comps = link_target.split(":") + if len(comps) > 1: partial_ids = comps[1:] + destination_type = "partial" + else: + destination_type = "nomadnetwork.node" + link_target = components[0] + + if destination_type == "nomadnetwork.node": + if self.status >= Browser.DISCONECTED: + RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG) + self.browser_footer = urwid.Text("Opening link to: "+str(link_target)) + try: + self.retrieve_url(link_target, request_data) + except Exception as e: + self.browser_footer = urwid.Text("Could not open link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + else: + RNS.log("Browser already handling link, cannot handle link to: "+str(link_target), RNS.LOG_DEBUG) + + elif destination_type == "lxmf.delivery": + RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG) + self.handle_lxmf_link(link_target) + + elif destination_type == "rrc.hub.session": + RNS.log("Passing RRC link to handler", RNS.LOG_DEBUG) + self.handle_rrc_link(link_target) + + elif destination_type == "partial": + if partial_ids != None and len(partial_ids) > 0: self.handle_partial_updates(partial_ids) + + else: + RNS.log("No known handler for destination type "+str(destination_type), RNS.LOG_DEBUG) + self.browser_footer = urwid.Text("Could not open link: "+"No known handler for destination type "+str(destination_type)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + def _jump_to_anchor(self, name): + anchors = getattr(self.attr_maps, "anchors", None) or {} + header_rows = getattr(self.attr_maps, "header_rows", None) or [] + + cols = self._content_cols() + + target_idx = None + if name: + target_idx = anchors.get(name) + if target_idx is None: + self.browser_footer = urwid.Text("Unknown anchor: #"+name) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + return + else: + current = 0 + try: + current = self.browser_body.original_widget.original_widget.get_scrollpos() + except Exception: + current = 0 + for hr in header_rows: + if self._rows_above(hr, cols) > current: + target_idx = hr + break + if target_idx is None: + return + + row_offset = self._rows_above(int(target_idx), cols) + + try: + scrollable = self.browser_body.original_widget.original_widget + scrollable.anchor_cursor_update = True + scrollable.set_scrollpos(row_offset) + + except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) + + def _content_cols(self): + try: + lw = self.delegate.given_list_width + screen_cols = self.app.ui.loop.screen.get_cols_rows()[0] + browser_cols = screen_cols-lw-2 + cols = browser_cols + + except Exception: + RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING) + RNS.trace_exception(e) + cols = 100 + + return max(40, cols) + + def _rows_above(self, index, cols): + if index <= 0 or not self.attr_maps: return 0 + + total = 0 + for i in range(min(index, len(self.attr_maps))): + try: total += self.attr_maps[i].rows((cols,)) + except Exception: total += 1 + + return total + + def handle_lxmf_link(self, link_target): + try: + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + + if not type(link_target) is str: + raise ValueError("Invalid data type for LXMF link") + + if len(link_target) != (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + raise ValueError("Invalid length for LXMF link") + + try: + bytes.fromhex(link_target) + + except Exception as e: + raise ValueError("Could not decode destination hash from LXMF link") + + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + source_hash_text = link_target + display_name_data = RNS.Identity.recall_app_data(bytes.fromhex(source_hash_text)) + + display_name = None + if display_name_data != None: + display_name = san(LXMF.display_name_from_app_data(display_name_data)) + + if not source_hash_text in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(bytes.fromhex(source_hash_text), display_name=display_name) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list() + + self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text) + self.app.ui.main_display.show_conversations(None) + + except Exception as e: + RNS.log("Error while starting conversation from link. The contained exception was: "+str(e), RNS.LOG_ERROR) + self.browser_footer = urwid.Text("Could not open LXMF link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + + def handle_rrc_link(self, link_target): + try: + if not isinstance(link_target, str): + raise ValueError("invalid RRC link payload") + rest = link_target.strip() + if rest.startswith("/"): + rest = rest[1:] + hub_part, _, room = rest.partition("/") + hex_part, _, dest = hub_part.partition(":") + hex_part = hex_part.strip() + dest = dest.strip() or None + try: + hub_hash = bytes.fromhex(hex_part) + except Exception: + raise ValueError("invalid hub hash") + expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8 + if len(hub_hash) != expected_len: + raise ValueError("hub hash must be "+str(expected_len)+" bytes") + + room = room.strip().lstrip("#").strip() + room_norm = room.lower() if room else None + + existing = self.app.rrc.find_hub(hub_hash, dest_name=dest) + self.app.ui.main_display.show_channels(None) + channels = self.app.ui.main_display.sub_displays.channels_display + + if existing is not None: + channels.update_list() + if room_norm: + channels._select_room(None, (existing, room_norm)) + else: + channels._select_hub(None, existing) + return + + + channels.confirm_new_hub_dialog(hub_hash, dest, room_norm) + + except Exception as e: + RNS.log("Could not open RRC link: "+str(e), RNS.LOG_ERROR) + self.browser_footer = urwid.Text("Could not open RRC link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + + def micron_released_focus(self): + if self.delegate != None: + self.delegate.focus_lists() + + def build_display(self): + self.browser_header = urwid.Text("") + self.browser_footer = urwid.Text("") + + self.page_pile = None + self.page_partials = {} + self.browser_body = urwid.Filler( + urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), + urwid.MIDDLE, + ) + + self.frame = BrowserFrame(self.browser_body, header=self.browser_header, footer=self.browser_footer) + self.frame.delegate = self + self.linebox = urwid.LineBox(self.frame, title="Remote Node") + self.display_widget = urwid.AttrMap(self.linebox, "browser_inactive") + + def make_status_widget(self): + if self.response_progress > 0: + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + style_name_inverted = make_style(default_state(bg=self.page_foreground_color, fg=self.page_background_color)) + else: + style_name = "progress_empty" + style_name_inverted = "progress_full" + + pb = ResponseProgressBar(style_name , style_name_inverted, current=self.response_progress, done=1.0, satt=None, owner=self) + widget = urwid.Pile([urwid.Divider(self.g["divider1"]), pb]) + else: + widget = urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text(self.status_text())]) + + return urwid.AttrMap(widget, "browser_controls") + + def make_control_widget(self): + clipboard_copy_enabled = self.app.config["textui"]["clipboard_copy"] + copy_glyph = self.g.get("copy", "[C]") + lstr = self.g["node"]+" "+self.current_url() + if clipboard_copy_enabled: + lmax = self._content_cols()-len(copy_glyph)-len(copy_glyph)-1 + else: + lmax = self._content_cols()-1 + if len(lstr) > lmax: lstr = lstr[:lmax-1]+"…" + url_text = urwid.Text(lstr) + if clipboard_copy_enabled: + copy_icon = ClickableIcon(copy_glyph, on_click=lambda: self.copy_url()) + copy_width = len(copy_glyph) + 2 + header_row = urwid.Columns([ + ("weight", 1, url_text), + (copy_width, urwid.Padding(copy_icon, left=1, right=1)), + ]) + else: + header_row = url_text + return urwid.AttrMap(urwid.Pile([header_row, urwid.Divider(self.g["divider1"])]), "browser_controls") + + def make_request_failed_widget(self): + def back_action(sender): + self.status = Browser.DONE + self.destination_hash = self.previous_destination_hash + self.path = self.previous_path + self.update_display() + + columns = urwid.Columns([ + (urwid.WEIGHT, 0.5, urwid.Text(" ")), + (8, urwid.Button("Back", on_press=back_action)), + (urwid.WEIGHT, 0.5, urwid.Text(" ")), + ]) + + if len(self.attr_maps) > 0: + pile = urwid.Pile([ + urwid.Text("!\n\n"+self.status_text()+"\n", align=urwid.CENTER), + columns + ]) + else: + pile = urwid.Pile([urwid.Text("!\n\n"+self.status_text(), align=urwid.CENTER)]) + + return urwid.Filler(pile, urwid.MIDDLE) + + def update_display(self): + if self.status == Browser.DISCONECTED: + self.display_widget.set_attr_map({None: "browser_inactive"}) + self.page_pile = None + self.page_partials = {} + self.browser_body = urwid.Filler( + urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER), + urwid.MIDDLE, + ) + self.browser_footer = urwid.Text("") + self.browser_header = urwid.Text("") + self.linebox.set_title("Remote Node") + else: + self.display_widget.set_attr_map({None: "body_text"}) + self.browser_header = self.make_control_widget() + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_header.set_attr_map({None: style_name}) + + if self.destination_hash != None: + remote_display_string = self.app.directory.simplest_display_str(self.destination_hash) + else: + remote_display_string = "" + + if self.loopback != None and remote_display_string == RNS.prettyhexrep(self.loopback): + remote_display_string = self.app.node.name + + self.linebox.set_title(remote_display_string) + + if self.status == Browser.DONE: + self.browser_footer = self.make_status_widget() + self.update_page_display() + + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_body.set_attr_map({None: style_name}) + self.browser_footer.set_attr_map({None: style_name}) + self.browser_header.set_attr_map({None: style_name}) + self.display_widget.set_attr_map({None: style_name}) + + elif self.status == Browser.LINK_TIMEOUT: + self.browser_body = self.make_request_failed_widget() + self.browser_footer = urwid.Text("") + + elif self.status <= Browser.REQUEST_SENT: + if len(self.attr_maps) == 0: + self.browser_body = urwid.Filler( + urwid.Text("Retrieving\n["+self.current_url()+"]", align=urwid.CENTER), + urwid.MIDDLE, + ) + + self.browser_footer = self.make_status_widget() + + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + self.browser_footer.set_attr_map({None: style_name}) + self.browser_header.set_attr_map({None: style_name}) + self.display_widget.set_attr_map({None: style_name}) + + elif self.status == Browser.REQUEST_FAILED: + self.browser_body = self.make_request_failed_widget() + self.browser_footer = urwid.Text("") + + elif self.status == Browser.REQUEST_TIMEOUT: + self.browser_body = self.make_request_failed_widget() + self.browser_footer = urwid.Text("") + + else: + pass + + self.frame.contents["body"] = (self.browser_body, self.frame.options()) + self.frame.contents["header"] = (self.browser_header, self.frame.options()) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + def update_page_display(self): + pile = urwid.Pile(self.attr_maps) + pile.automove_cursor_on_scroll = True + self.page_pile = pile + self.page_partials = {} + self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") + self.detect_partials() + + def parse_url(self, url): + path = None + destination_hash = None + components = url.split(":") + if len(components) == 1: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") + path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + elif len(components) == 2: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") + path = components[1] + if len(path) == 0: path = Browser.DEFAULT_PATH + else: + if len(components[0]) == 0: + if self.destination_hash != None: + destination_hash = self.destination_hash + path = components[1] + if len(path) == 0: path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + + return destination_hash, path + + def detect_partials(self): + for w in self.attr_maps: + o = w._original_widget + if hasattr(o, "partial_hash"): + RNS.log(f"Found partial: {o.partial_hash} / {o.partial_url} / {o.partial_refresh}") + partial = {"hash": o.partial_hash, "id": o.partial_id, "url": o.partial_url, "fields": o.partial_fields, + "refresh": o.partial_refresh, "content": None, "updated": None, "update_requested": None, "request_id": None, + "destination": None, "link": None, "pile": o, "attr_maps": None, "failed": False, "pr_throttle": 0} + + self.page_partials[o.partial_hash] = partial + + if len(self.page_partials) > 0: self.start_partial_updater() + + def partial_failed(self, request_receipt): + RNS.log("Loading page partial failed", RNS.LOG_ERROR) + for pid in self.page_partials: + partial = self.page_partials[pid] + if partial["request_id"] == request_receipt.request_id: + try: + partial["updated"] = time.time() + partial["request_id"] = None + partial["content"] = None + partial["attr_maps"] = None + url = partial["url"] + pile = partial["pile"] + pile.contents = [(urwid.Text(f"Could not load partial {url}: The resource transfer failed"), pile.options())] + except Exception as e: + RNS.log(f"Error in partial failed callback: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + + def partial_progressed(self, request_receipt): + pass + + def partial_received(self, request_receipt): + for pid in self.page_partials: + partial = self.page_partials[pid] + if partial["request_id"] == request_receipt.request_id: + try: + partial["updated"] = partial["update_requested"] + partial["request_id"] = None + partial["content"] = request_receipt.response.decode("utf-8").rstrip() + partial["attr_maps"] = markup_to_attrmaps(strip_modifiers(partial["content"]), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + pile = partial["pile"] + pile.contents = [(e, pile.options()) for e in partial["attr_maps"]] + + except Exception as e: + RNS.trace_exception(e) + + def __load_partial(self, partial): + if partial["failed"] == True: return + try: partial_destination_hash, path = self.parse_url(partial["url"]) + except Exception as e: + RNS.log(f"Could not parse partial URL: {e}", RNS.LOG_ERROR) + partial["failed"] = True + pile = partial["pile"] + url = partial["url"] + pile.contents = [(urwid.Text(f"Could not load partial {url}: {e}"), pile.options())] + return + + if partial_destination_hash != self.loopback and not RNS.Transport.has_path(partial_destination_hash): + if time.time() <= partial["pr_throttle"]: return + else: + partial["pr_throttle"] = time.time()+15 + RNS.log(f"Requesting path for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + RNS.Transport.request_path(partial_destination_hash) + pr_time = time.time()+RNS.Transport.first_hop_timeout(partial_destination_hash) + while not RNS.Transport.has_path(partial_destination_hash): + now = time.time() + if now > pr_time+self.timeout: return + time.sleep(0.25) + + for pid in self.page_partials: + other_partial = self.page_partials[pid] + if other_partial["link"]: + existing_link = other_partial["link"] + if existing_link.destination.hash == partial_destination_hash and existing_link.status == RNS.Link.ACTIVE: + RNS.log(f"Re-using existing link: {existing_link}", RNS.LOG_EXTREME) + partial["link"] = existing_link + break + + if not partial["link"] or partial["link"].status == RNS.Link.CLOSED: + RNS.log(f"Establishing link for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + identity = RNS.Identity.recall(partial_destination_hash) + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects) + + def established(link): + RNS.log(f"Link established for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + + def closed(link): + RNS.log(f"Link closed for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + partial["link"] = None + + partial["link"] = RNS.Link(destination, established_callback = established, closed_callback = closed) + timeout = time.time()+self.timeout + while partial["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1) + + if partial["link"] and partial["link"].status == RNS.Link.ACTIVE and partial["request_id"] == None: + RNS.log(f"Sending request for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME) + receipt = partial["link"].request(path, data=self.__get_partial_request_data(partial), response_callback = self.partial_received, + failed_callback = self.partial_failed, progress_callback = self.partial_progressed) + + if receipt: partial["request_id"] = receipt.request_id + else: RNS.log(f"Partial request failed", RNS.LOG_ERROR) + + def __get_partial_request_data(self, partial): + request_data = None + if partial["fields"] != None: + link_data = partial["fields"] + link_fields = [] + request_data = {} + all_fields = True if "*" in link_data else False + + for e in link_data: + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + else: + link_fields.append(e) + + def recurse_down(w): + if isinstance(w, list): + for t in w: + recurse_down(t) + elif isinstance(w, tuple): + for t in w: + recurse_down(t) + elif hasattr(w, "contents"): + recurse_down(w.contents) + elif hasattr(w, "original_widget"): + recurse_down(w.original_widget) + elif hasattr(w, "_original_widget"): + recurse_down(w._original_widget) + else: + if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields): + field_key = "field_" + w.field_name + if isinstance(w, urwid.Edit): + request_data[field_key] = w.edit_text + elif isinstance(w, urwid.RadioButton): + if w.state: + user_data = getattr(w, "field_value", None) + if user_data is not None: + request_data[field_key] = user_data + elif isinstance(w, urwid.CheckBox): + user_data = getattr(w, "field_value", "1") + if w.state: + existing_value = request_data.get(field_key, '') + if existing_value: + # Concatenate the new value with the existing one + request_data[field_key] = existing_value + ',' + user_data + else: + # Initialize the field with the current value + request_data[field_key] = user_data + else: + pass # do nothing if checkbox is not check + + recurse_down(self.attr_maps) + RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + + return request_data + + def start_partial_updater(self): + if not self.updater_running: self.update_partials() + + def handle_partial_updates(self, partial_ids): + RNS.log(f"Update partials: {partial_ids}") + def job(): + for pid in self.page_partials: + try: + partial = self.page_partials[pid] + if partial["id"] in partial_ids: + partial["update_requested"] = time.time() + self.__load_partial(partial) + except Exception as e: RNS.log(f"Error updating page partial: {e}", RNS.LOG_ERROR) + + threading.Thread(target=job, daemon=True).start() + + def update_partials(self, loop=None, user_data=None): + with self.partial_updater_lock: + def job(): + for pid in self.page_partials: + try: + partial = self.page_partials[pid] + if partial["failed"]: continue + if not partial["updated"] or (partial["refresh"] != None and time.time() > partial["updated"]+partial["refresh"]): + partial["update_requested"] = time.time() + self.__load_partial(partial) + except Exception as e: RNS.log(f"Error updating page partial: {e}", RNS.LOG_ERROR) + + threading.Thread(target=job, daemon=True).start() + + if len(self.page_partials) > 0: + self.updater_running = True + self.app.ui.loop.set_alarm_in(1, self.update_partials) + else: + self.updater_running = False + + def identify(self): + if self.link != None: + if self.link.status == RNS.Link.ACTIVE: + self.link.identify(self.auth_identity) + + + def disconnect(self): + if self.link != None: + self.link.teardown() + + self.request_data = None + self.attr_maps = [] + self.status = Browser.DISCONECTED + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + + self.history = [] + self.history_ptr = 0 + self.history_inc = False + self.history_dec = False + + self.update_display() + + + def retrieve_url(self, url, request_data = None): + components = url.split("`") + if len(components) == 2: + url = components[0] + try: + link_fields_str = components[1] + if link_fields_str != "": + if not request_data: request_data = {} + for e in link_fields_str.split("|"): + if "=" in e: + c = e.split("=") + if len(c) == 2: + request_data["var_"+str(c[0])] = str(c[1]) + + except Exception as e: + RNS.log(f"Malformed URL: {e}", RNS.LOG_WARNING) + raise ValueError(f"Malformed URL: {e}") + + self.previous_destination_hash = self.destination_hash + self.previous_path = self.path + + destination_hash = None + path = None + + components = url.split(":") + if len(components) == 1: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") + path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + elif len(components) == 2: + if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: + try: destination_hash = bytes.fromhex(components[0]) + except Exception as e: raise ValueError("Malformed URL") + path = components[1] + if len(path) == 0: path = Browser.DEFAULT_PATH + else: + if len(components[0]) == 0: + if self.destination_hash != None: + destination_hash = self.destination_hash + path = components[1] + if len(path) == 0: path = Browser.DEFAULT_PATH + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + else: raise ValueError("Malformed URL") + + if destination_hash != None and path != None: + if path.startswith("/file/"): + if destination_hash != self.loopback: + if destination_hash == self.destination_hash: + self.download_file(destination_hash, path, data=request_data) + else: + RNS.log("Cannot request file download from a node that is not currently connected.", RNS.LOG_ERROR) + RNS.log("The requested URL was: "+str(url), RNS.LOG_ERROR) + else: + self.download_local_file(path) + else: + self.set_destination_hash(destination_hash) + self.set_path(path) + self.set_request_data(request_data) + self.load_page() + + def set_destination_hash(self, destination_hash): + if len(destination_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: + self.destination_hash = destination_hash + return True + else: + return False + + + def set_path(self, path): + self.path = path + + def set_request_data(self, request_data): + self.request_data = request_data + + def set_timeout(self, timeout): + self.timeout = timeout + + def download_local_file(self, path): + try: + file_path = self.app.filespath+path.replace("/file", "", 1) + if os.path.isfile(file_path): + file_name = os.path.basename(file_path) + file_destination = self.app.downloads_path+"/"+file_name + + counter = 0 + while os.path.isfile(file_destination): + counter += 1 + file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter) + + fs = open(file_path, "rb") + fd = open(file_destination, "wb") + fd.write(fs.read()) + fd.close() + fs.close() + + self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 + + self.update_display() + else: + RNS.log("The requested local download file does not exist: "+str(file_path), RNS.LOG_ERROR) + + self.status = Browser.DONE + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + + except Exception as e: + RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR) + + def download_file(self, destination_hash, path, data=None): + if self.link == None or self.link.destination.hash != self.destination_hash: + if not RNS.Transport.has_path(self.destination_hash): + self.status = Browser.NO_PATH + self.update_display() + + RNS.Transport.request_path(self.destination_hash) + self.status = Browser.PATH_REQUESTED + self.update_display() + + pr_time = time.time()+RNS.Transport.first_hop_timeout(self.destination_hash) + while not RNS.Transport.has_path(self.destination_hash): + now = time.time() + if now > pr_time+self.timeout: + self.request_timeout() + return + + time.sleep(0.25) + + self.status = Browser.ESTABLISHING_LINK + self.update_display() + + identity = RNS.Identity.recall(self.destination_hash) + destination = RNS.Destination( + identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + self.app_name, + self.aspects + ) + + self.link = RNS.Link(destination, established_callback = self.link_established, closed_callback = self.link_closed) + + while self.status == Browser.ESTABLISHING_LINK: + time.sleep(0.1) + + if self.status != Browser.LINK_ESTABLISHED: + return + + self.update_display() + + if self.link != None and self.link.destination.hash == self.destination_hash: + # Send the request + self.status = Browser.REQUESTING + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + self.saved_file_name = None + self.saved_file_size = 0 + + self.update_display() + receipt = self.link.request( + path, + data = data, + response_callback = self.file_received, + failed_callback = self.request_failed, + progress_callback = self.response_progressed + ) + + if receipt: + self.last_request_receipt = receipt + self.last_request_id = receipt.request_id + self.status = Browser.REQUEST_SENT + self.update_display() + else: + self.link.teardown() + + def write_history(self): + entry = [self.destination_hash, self.path, self.request_data] + self.history.insert(self.history_ptr, entry) + self.history_ptr += 1 + + if len(self.history) > self.history_ptr: + self.history = self.history[:self.history_ptr] + + def back(self): + target_ptr = self.history_ptr-1 + if not self.history_inc and not self.history_dec: + if target_ptr > 0: + self.history_dec = True + entry = self.history[target_ptr-1] + url = RNS.hexrep(entry[0], delimit=False)+":"+entry[1] + self.history_ptr = target_ptr + self.retrieve_url(url, request_data=entry[2]) + + def forward(self): + target_ptr = self.history_ptr+1 + if not self.history_inc and not self.history_dec: + if target_ptr <= len(self.history): + self.history_dec = True + entry = self.history[target_ptr-1] + url = RNS.hexrep(entry[0], delimit=False)+":"+entry[1] + self.history_ptr = target_ptr + self.retrieve_url(url, request_data=entry[2]) + + def reload(self): + if not self.reloading and self.status == Browser.DONE: + self.reloading = True + self.uncache_page(self.current_url()) + self.load_page() + + def copy_url(self): + url = self.current_url() + if not url: + return + + if not osc52_copy(url): + return + + try: + notice = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Copied URL to clipboard")]), "browser_controls") + if self.page_background_color != None or self.page_foreground_color != None: + style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color)) + notice.set_attr_map({None: style_name}) + self.browser_footer = notice + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + def restore_footer(loop, user_data): + if self.status == Browser.DONE: + self.browser_footer = self.make_status_widget() + else: + self.browser_footer = urwid.Text("") + if self.frame is not None: + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + self.app.ui.loop.set_alarm_in(2.0, restore_footer) + except Exception as e: + RNS.log("Could not update footer after URL copy: "+str(e), RNS.LOG_ERROR) + + def close_dialogs(self): + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) + self.delegate.columns.contents[1] = (self.display_widget, options) + + def url_dialog(self): + e_url = UrlEdit(caption="URL : ", edit_text=self.current_url()) + + def dismiss_dialog(sender): + self.close_dialogs() + + def confirmed(sender): + try: + entered_url = e_url.get_edit_text().strip() + if not "`" in entered_url and ":" in entered_url: + pos = entered_url.find("|") + if pos > 0: entered_url = entered_url[:pos]+"`"+entered_url[pos+1:] + self.retrieve_url(entered_url) + except Exception as e: + self.browser_footer = urwid.Text("Could not open link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + self.close_dialogs() + + dialog = UrlDialogLineBox( + urwid.Pile([ + e_url, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Go", on_press=confirmed)), + ]) + ]), title="Enter URL" + ) + e_url.confirmed = confirmed + dialog.confirmed = confirmed + dialog.delegate = self + bottom = self.display_widget + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=(urwid.RELATIVE, 65), + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) + self.delegate.columns.contents[1] = (overlay, options) + self.delegate.columns.focus_position = 1 + + def save_node_dialog(self): + if self.destination_hash != None: + try: + def dismiss_dialog(sender): + self.close_dialogs() + + display_name = RNS.Identity.recall_app_data(self.destination_hash) + disp_str = "" + if display_name != None: + display_name = display_name.decode("utf-8") + disp_str = " \""+display_name+"\"" + + def confirmed(sender): + node_entry = DirectoryEntry(self.destination_hash, display_name=display_name, hosts_node=True) + self.app.directory.remember(node_entry) + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + self.close_dialogs() + + dialog = UrlDialogLineBox( + urwid.Pile([ + urwid.Text("Save connected node"+disp_str+" "+RNS.prettyhexrep(self.destination_hash)+" to Known Nodes?\n"), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + ]) + ]), title="Save Node" + ) + + dialog.confirmed = confirmed + dialog.delegate = self + bottom = self.display_widget + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=(urwid.RELATIVE, 50), + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width) + self.delegate.columns.contents[1] = (overlay, options) + self.delegate.columns.focus_position = 1 + + except Exception as e: + pass + + def load_page(self): + if self.request_data == None: + cached = self.get_cached(self.current_url()) + else: + cached = None + + if cached: + self.status = Browser.DONE + self.page_data = cached + self.markup = self.page_data.decode("utf-8") + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos >= 0: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos >= 0: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] + + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + self.saved_file_name = None + self.saved_file_size = 0 + self.loaded_from_cache = True + + self.update_display() + + if not self.history_inc and not self.history_dec and not self.reloading: + self.write_history() + else: + self.history_dec = False + self.history_inc = False + self.reloading = False + + if self.request_data and "var_anchor" in self.request_data: + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + + else: + if self.destination_hash != self.loopback: + load_thread = threading.Thread(target=self.__load) + load_thread.setDaemon(True) + load_thread.start() + else: + RNS.log("Browser handling local page: "+str(self.path), RNS.LOG_DEBUG) + page_path = self.app.pagespath+self.path.replace("/page", "", 1) + + page_data = b"The requested local page did not exist in the file system" + if os.path.isfile(page_path): + if os.access(page_path, os.X_OK): + if self.request_data != None: + env_map = self.request_data + else: + env_map = {} + + if "PATH" in os.environ: + env_map["PATH"] = os.environ["PATH"] + + generated = subprocess.run([page_path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env_map) + page_data = generated.stdout + else: + file = open(page_path, "rb") + page_data = file.read() + file.close() + + self.status = Browser.DONE + self.page_data = page_data + self.markup = self.page_data.decode("utf-8") + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos >= 0: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos >= 0: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] + + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + self.saved_file_name = None + self.saved_file_size = 0 + self.loaded_from_cache = False + + self.update_display() + + if not self.history_inc and not self.history_dec and not self.reloading: + self.write_history() + else: + self.history_dec = False + self.history_inc = False + self.reloading = False + + if self.request_data and "var_anchor" in self.request_data: + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + + + def __load(self): + # If an established link exists, but it doesn't match the target + # destination, we close and clear it. + if self.link != None and (self.link.destination.hash != self.destination_hash or self.link.status != RNS.Link.ACTIVE): + self.link.teardown() + self.link = None + + # If no link to the destination exists, we create one. + if self.link == None: + if not RNS.Transport.has_path(self.destination_hash): + self.status = Browser.NO_PATH + self.update_display() + + RNS.Transport.request_path(self.destination_hash) + self.status = Browser.PATH_REQUESTED + self.update_display() + + pr_time = time.time()+RNS.Transport.first_hop_timeout(self.destination_hash) + while not RNS.Transport.has_path(self.destination_hash): + now = time.time() + if now > pr_time+self.timeout: + self.request_timeout() + return + + time.sleep(0.25) + + self.status = Browser.ESTABLISHING_LINK + self.update_display() + + identity = RNS.Identity.recall(self.destination_hash) + destination = RNS.Destination( + identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + self.app_name, + self.aspects + ) + + self.link = RNS.Link(destination, established_callback = self.link_established, closed_callback = self.link_closed) + + while self.status == Browser.ESTABLISHING_LINK: + time.sleep(0.1) + + if self.status != Browser.LINK_ESTABLISHED: + return + + self.update_display() + + # Send the request + self.status = Browser.REQUESTING + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + self.saved_file_name = None + self.saved_file_size = 0 + + + self.update_display() + receipt = self.link.request( + self.path, + data = self.request_data, + response_callback = self.response_received, + failed_callback = self.request_failed, + progress_callback = self.response_progressed + ) + + if receipt: + self.last_request_receipt = receipt + self.last_request_id = receipt.request_id + self.status = Browser.REQUEST_SENT + self.update_display() + else: + self.link.teardown() + + + + def link_established(self, link): + self.status = Browser.LINK_ESTABLISHED + + if self.app.directory.should_identify_on_connect(self.destination_hash): + RNS.log("Link established, identifying to remote system...", RNS.LOG_VERBOSE) + self.link.identify(self.app.identity) + + + def link_closed(self, link): + if self.status == Browser.DISCONECTED or self.status == Browser.DONE: + self.link = None + elif self.status == Browser.ESTABLISHING_LINK: + self.link_establishment_timeout() + else: + self.link = None + self.status = Browser.REQUEST_FAILED + self.update_display() + + def link_establishment_timeout(self): + self.status = Browser.LINK_TIMEOUT + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + self.link = None + + self.update_display() + + + def response_received(self, request_receipt): + try: + self.status = Browser.DONE + self.page_data = request_receipt.response + self.markup = self.page_data.decode("utf-8") + + self.page_background_color = None + bgpos = self.markup.find("#!bg=") + if bgpos >= 0: + endpos = self.markup.find("\n", bgpos) + if endpos-(bgpos+5) == 3: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + elif endpos-(bgpos+5) == 6: + bg = self.markup[bgpos+5:endpos] + self.page_background_color = bg + + self.page_foreground_color = None + fgpos = self.markup.find("#!fg=") + if fgpos >= 0: + endpos = self.markup.find("\n", fgpos) + if endpos-(fgpos+5) == 3: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + elif endpos-(fgpos+5) == 6: + fg = self.markup[fgpos+5:endpos] + self.page_foreground_color = fg + + try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")] + + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.loaded_from_cache = False + + # Simple header handling. Should be expanded when more + # header tags are added. + cache_time = Browser.DEFAULT_CACHE_TIME + if self.markup[:4] == "#!c=": + try: + endpos = self.markup.find("\n") + if endpos == -1: + endpos = len(self.markup) + cache_time = int(self.markup[4:endpos]) + except Exception as e: RNS.log(f"Invalid page cache time header: {e}", RNS.LOG_DEBUG) + + self.update_display() + + if not self.history_inc and not self.history_dec and not self.reloading: + self.write_history() + else: + self.history_dec = False + self.history_inc = False + self.reloading = False + + if cache_time == 0: + RNS.log("Received page "+str(self.current_url())+", not caching due to header.", RNS.LOG_DEBUG) + else: + RNS.log("Received page "+str(self.current_url())+", caching for %.3f hours." % (cache_time/60/60), RNS.LOG_DEBUG) + self.cache_page(cache_time) + + if self.request_data and "var_anchor" in self.request_data: + def df(loop, user_data): self._jump_to_anchor(self.request_data["var_anchor"]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + + except Exception as e: + RNS.log("An error occurred while handling response. The contained exception was: "+str(e)) + + def uncache_page(self, url): + url_hash = self.url_hash(url) + files = os.listdir(self.app.cachepath) + for file in files: + if file.startswith(url_hash): + cachefile = self.app.cachepath+"/"+file + os.unlink(cachefile) + RNS.log("Removed "+str(cachefile)+" from cache.", RNS.LOG_DEBUG) + + def get_cached(self, url): + url_hash = self.url_hash(url) + files = os.listdir(self.app.cachepath) + for file in files: + cachepath = self.app.cachepath+"/"+file + try: + components = file.split("_") + if len(components) == 2 and len(components[0]) == 64 and len(components[1]) > 0: + expires = float(components[1]) + + if time.time() > expires: + RNS.log("Removing stale cache entry "+str(file), RNS.LOG_DEBUG) + os.unlink(cachepath) + else: + if file.startswith(url_hash): + RNS.log("Found "+str(file)+" in cache.", RNS.LOG_DEBUG) + RNS.log("Returning cached page", RNS.LOG_DEBUG) + file = open(cachepath, "rb") + data = file.read() + file.close() + return data + + except Exception as e: + RNS.log("Error while parsing cache entry "+str(cachepath)+", removing it.", RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + try: + os.unlink(cachepath) + except Exception as e: + RNS.log("Additionally, an exception occurred while unlinking the entry: "+str(e), RNS.LOG_ERROR) + RNS.log("You will probably need to remove this entry manually by deleting the file: "+str(cachepath), RNS.LOG_ERROR) + + + return None + + def clean_cache(self): + files = os.listdir(self.app.cachepath) + for file in files: + cachepath = self.app.cachepath+"/"+file + try: + components = file.split("_") + if len(components) == 2 and len(components[0]) == 64 and len(components[1]) > 0: + expires = float(components[1]) + + if time.time() > expires: + RNS.log("Removing stale cache entry "+str(file), RNS.LOG_DEBUG) + os.unlink(cachepath) + + except Exception as e: + pass + + + def cache_page(self, cache_time): + url_hash = self.url_hash(self.current_url()) + if url_hash == None: + RNS.log("Could not cache page "+str(self.current_url()), RNS.LOG_ERROR) + else: + try: + self.uncache_page(self.current_url()) + cache_expires = time.time()+cache_time + filename = url_hash+"_"+str(cache_expires) + cachefile = self.app.cachepath+"/"+filename + file = open(cachefile, "wb") + file.write(self.page_data) + file.close() + RNS.log("Cached page "+str(self.current_url())+" to "+str(cachefile), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Could not write cache file for page "+str(self.current_url()), RNS.LOG_ERROR) + RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) + + + def file_received(self, request_receipt): + try: + if type(request_receipt.response) == io.BufferedReader: + if request_receipt.metadata != None: + file_name = os.path.basename(request_receipt.metadata["name"].decode("utf-8")) + file_handle = request_receipt.response + file_destination = self.app.downloads_path+"/"+file_name + + counter = 0 + while os.path.isfile(file_destination): + counter += 1 + file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter) + + shutil.move(file_handle.name, file_destination) + + self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 + self.file_saved_at = time.time() + + else: + file_name = request_receipt.response[0] + file_data = request_receipt.response[1] + file_destination_name = os.path.basename(file_name) + file_destination = self.app.downloads_path+"/"+file_destination_name + + counter = 0 + while os.path.isfile(file_destination): + counter += 1 + file_destination = self.app.downloads_path+"/"+file_destination_name+"."+str(counter) + + fh = open(file_destination, "wb") + fh.write(file_data) + fh.close() + + self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1) + try: self.saved_file_size = os.path.getsize(file_destination) + except: self.saved_file_size = 0 + + self.status = Browser.DONE + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + + self.update_display() + + except Exception as e: + RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR) + + + def request_failed(self, request_receipt=None): + if request_receipt != None: + if request_receipt.request_id == self.last_request_id: + self.status = Browser.REQUEST_FAILED + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + + self.update_display() + if self.link != None: + try: + self.link.teardown() + except Exception as e: + pass + else: + self.status = Browser.REQUEST_FAILED + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + + self.update_display() + if self.link != None: + try: + self.link.teardown() + except Exception as e: + pass + + + def request_timeout(self, request_receipt=None): + self.status = Browser.REQUEST_TIMEOUT + self.response_progress = 0 + self.response_speed = None + self.progress_updated_at = None + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + + self.update_display() + if self.link != None: + try: + self.link.teardown() + except Exception as e: + pass + + + def response_progressed(self, request_receipt): + self.response_progress = request_receipt.progress + self.response_time = request_receipt.get_response_time() + self.response_size = request_receipt.response_size + self.response_transfer_size = request_receipt.response_transfer_size + + now = time.time() + if self.progress_updated_at == None: self.progress_updated_at = now + if now > self.progress_updated_at+0.5: + td = now - self.progress_updated_at + pd = self.response_progress - self.previous_progress + bd = pd*self.response_transfer_size + self.response_speed = (bd/td)*8 + self.previous_progress = self.response_progress + self.progress_updated_at = now + + self.update_display() + + + def status_text(self): + if self.status == Browser.DONE and self.response_transfer_size != None: + if self.response_time != None: response_time_str = "{:.2f}".format(self.response_time) + else: response_time_str = "None" + + if self.saved_file_name == None: resp_size = self.response_size + else: resp_size = self.saved_file_size + + if resp_size: + stats_string = " "+self.g["page"]+size_str(resp_size) + if self.response_transfer_size: + stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str + if self.response_transfer_size and self.response_time: + stats_string += "s "+self.g["speed"]+size_str(self.response_transfer_size/self.response_time, suffix="b")+"/s" + + elif self.loaded_from_cache: + stats_string = " (cached)" + else: + stats_string = "" + + if self.status == Browser.NO_PATH: + return "No path to destination known" + elif self.status == Browser.PATH_REQUESTED: + return "Path requested, waiting for path..." + elif self.status == Browser.ESTABLISHING_LINK: + return "Establishing link..." + elif self.status == Browser.LINK_TIMEOUT: + return "Link establishment timed out" + elif self.status == Browser.LINK_ESTABLISHED: + return "Link established" + elif self.status == Browser.REQUESTING: + return "Sending request..." + elif self.status == Browser.REQUEST_SENT: + return "Request sent, awaiting response..." + elif self.status == Browser.REQUEST_FAILED: + return "Request failed" + elif self.status == Browser.REQUEST_TIMEOUT: + return "Request timed out" + elif self.status == Browser.RECEIVING_RESPONSE: + return "Receiving response..." + elif self.status == Browser.DONE: + if self.saved_file_name == None: return "Done"+stats_string + else: return "Saved "+str(self.saved_file_name)+stats_string + elif self.status == Browser.DISCONECTED: + return "Disconnected" + else: + return "Browser Status Unknown" + + +class ResponseProgressBar(urwid.ProgressBar): + def __init__(self, empty, full, current=None, done=None, satt=None, owner=None): + super().__init__(empty, full, current=current, done=done, satt=satt) + self.owner = owner + + def get_text(self): + if self.owner.response_speed: speed_str = " "+RNS.prettyspeed(self.owner.response_speed) + else: speed_str = "" + return "Receiving response "+super().get_text().replace(" %", "%")+speed_str + +# A convenience function for printing a human- +# readable file size +def size_str(num, suffix='B'): + units = ['','K','M','G','T','P','E','Z'] + last_unit = 'Y' + + if suffix == 'b': + num *= 8 + units = ['','K','M','G','T','P','E','Z'] + last_unit = 'Y' + + for unit in units: + if abs(num) < 1000.0: + if unit == "": + return "%.0f%s%s" % (num, unit, suffix) + else: + return "%.2f%s%s" % (num, unit, suffix) + num /= 1000.0 + + return "%.2f%s%s" % (num, last_unit, suffix) + +class UrlDialogLineBox(urwid.LineBox): + def keypress(self, size, key): + if key == "esc": + self.delegate.close_dialogs() + else: + return super(UrlDialogLineBox, self).keypress(size, key) + +class UrlEdit(ReadlineMixin, urwid.Edit): + def keypress(self, size, key): + if key == "enter": + self.confirmed(self) + else: + return super(UrlEdit, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py new file mode 100644 index 0000000..24bba5b --- /dev/null +++ b/nomadnet/ui/textui/Channels.py @@ -0,0 +1,2285 @@ +import collections +import os +import re +import time + +import RNS +import urwid + +import nomadnet +from nomadnet.RRC import RRCHub +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox +from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec +from RNS.Utilities.rngit.util import MarkdownToMicron +from RNS.Utilities.rngit.highlight import SyntaxHighlighter +from .MicronParser import markup_to_attrmaps, default_state, make_style +from .ReadlineEdit import ReadlineMixin, ReadlineEdit +from nomadnet.util import sanitize_name, strip_modifiers, strip_micron +from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags +from nomadnet.vendor.Scrollable import Scrollable, ScrollBar + + +theme_dark = { "text": "ddd", + "ts": "888", + "nick_self": "6c5", + "nick_peer": "3cd", + "notice": "fd3", + "error": "f55", + "system": "888", + "mention": "fb4", + "link": "79d", + # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 1.4 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 + "nick_colors": ["f68787", "00c394", "d59e00", "62be00", "a1ac76", "95b600", "76a9ee", "81b385", "7eb1a1", "e89264", "7cb0b0", "00c0c0", "8cacbb", "32b4db", "98a8c3", "bbab00", "95a0fd", "a9a2ca", "ad98fe", "c58ffa", "df83f4", "c49abf", "f380c7", "f484a7"], + } + +theme_light = { "text": "111", + "ts": "888", + "nick_self": "3a0", + "nick_peer": "077", + "notice": "a70", + "error": "a22", + "system": "888", + "mention": "c50", + "link": "79d", + # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 0.2 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 > ~/.nomadnetwork/storage/pages/index.mu + "nick_colors": ["ca0000", "008000", "9d1c00", "007800", "2c5200", "006800", "004ac0", "006100", "005d2c", "b70000", "005b5b", "007b7a", "005071", "0064a5", "004580", "714f00", "0026d3", "48318c", "5200d5", "8400cf", "aa00c8", "820079", "c60086", "c80043"], + } + + +class _ChatLinkableText(LinkableText): + def render(self, size, focus=False): + c = urwid.Text.render(self, size, focus) + if focus: + c = urwid.CompositeCanvas(c) + c.cursor = self.get_cursor_coords(size) + if self.delegate is not None: + self.peek_link() + return c + + +_LINK_RE = re.compile( + r"(?P(?(?(? 0 and last_space >= len(chunk) // 2: + chunk = chunk[:last_space] + if not chunk: + chunk = remaining[:1] + chunks.append(chunk.rstrip()) + remaining = remaining[len(chunk):].lstrip() + return chunks + + +def _split_message(text, max_bytes): + if not text: + return [text] + parts = [text] + for _attempt in range(10): + K_guess = max(1, len(parts)) + prefix_bytes = len(("({}/{}) ".format(K_guess, K_guess)).encode("utf-8")) + budget = max_bytes - prefix_bytes + if budget <= 0: + return None + parts = _chunk_by_bytes(text, budget) + if len(parts) == K_guess: + break + K = len(parts) + return ["({}/{}) ".format(i+1, K) + p for i, p in enumerate(parts)] + + +def _scan_mentions(text, own_nick): + if not own_nick or not text: + return + pat = re.compile(r"(? r_start: return True + return False + + spans = [(s, e, k, t) for s, e, k, t in spans if not overlaps_code(s, e)] + + spans.sort(key=lambda s: s[0]) + filtered = [] + last_end = 0 + for s in spans: + if s[0] >= last_end: + filtered.append(s) + last_end = s[1] + spans = filtered + + if not spans: + return [(body_attr, body)], False + + out = [] + pos = 0 + has_links = False + for start, end, kind, target in spans: + if start > pos: + out.append((body_attr, body[pos:start])) + if kind == "mention": + out.append(("irc_mention", body[start:end])) + elif kind == "nick_mention": + out.append(("nick_mention", body[start:end])) + else: + base = _LINK_ATTRS[kind] + if check_links: + out.append((LinkSpec(kind+":"+target, base, cm=256), body[start:end])) + has_links = True + else: + out.append((f"link_{kind}", body[start:end])) + has_links = True + pos = end + if pos < len(body): + out.append((body_attr, body[pos:])) + return out, has_links + + +def _short_hash(b, n=12): + if isinstance(b, (bytes, bytearray)): + return bytes(b).hex()[:n] + return "?" + + +def _format_ts(ts_ms): + try: + return time.strftime("%H:%M:%S", time.localtime(ts_ms/1000.0)) + except Exception: + return "" + + +class ChannelsListShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar") + + +class ChannelsRoomShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-x] Leave [F8] Collapse [Tab] Complete Nick"), "shortcutbar") + + +class ChannelsRoomBodyShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-x] Leave [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] ↓ Editor"), "shortcutbar") + + +class ChannelsDialogLineBox(urwid.LineBox): + def keypress(self, size, key): + if key == "esc": + if hasattr(self.delegate, "close_dialog"): + self.delegate.close_dialog() + else: + return super(ChannelsDialogLineBox, self).keypress(size, key) + + +class ChannelListEntry(urwid.Text): + _selectable = True + signals = ["click"] + + def keypress(self, size, key): + if self._command_map[key] != urwid.ACTIVATE: + return key + self._emit("click") + + def mouse_event(self, size, event, button, x, y, focus): + if button != 1 or not urwid.util.is_mouse_press(event): + return False + self._emit("click") + return True + + +class ChannelsExpandGutter(urwid.WidgetWrap): + def __init__(self, app, delegate): + self.app = app + self.delegate = delegate + glyph = app.ui.glyphs.get("arrow_r", ">") + if len(glyph) > 1: + glyph = ">" + self._inner = urwid.SolidFill(glyph) + super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus")) + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event): + try: + self.delegate.toggle_channel_list() + return True + except Exception: + pass + return False + + def selectable(self): + return False + + +class UsersExpandGutter(urwid.WidgetWrap): + def __init__(self, app, delegate): + self.app = app + self.delegate = delegate + glyph = app.ui.glyphs.get("arrow_l", "<") + if len(glyph) > 1: + glyph = "<" + self._inner = urwid.SolidFill(glyph) + super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus")) + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event): + try: + self.delegate.toggle_users() + return True + except Exception: + pass + return False + + def selectable(self): + return False + + +class UsersBox(urwid.LineBox): + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event) and row == 0: + try: + self.delegate.toggle_users() + return True + except Exception: + pass + return super().mouse_event(size, event, button, col, row, focus) + + def keypress(self, size, key): + if key == "tab": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.columns.focus_position = 0 + rw.frame.focus_position = "footer" + return None + except Exception: + pass + if key == "ctrl u": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.toggle_users() + return None + except Exception: + pass + if key == "ctrl y": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.display.toggle_channel_list() + return None + except Exception: + pass + return super().keypress(size, key) + + +class ChannelsListArea(urwid.LineBox): + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event) and row == 0: + try: + self.delegate.toggle_channel_list() + return True + except Exception: + pass + return super().mouse_event(size, event, button, col, row, focus) + + def keypress(self, size, key): + if key == "ctrl n": + self.delegate.new_hub_dialog() + elif key == "ctrl a": + self.delegate.join_room_dialog() + elif key == "ctrl r": + self.delegate.connect_selected() + elif key == "ctrl w": + self.delegate.disconnect_selected() + elif key == "ctrl t": + self.delegate.toggle_auto_reconnect_selected() + elif key == "ctrl e": + self.delegate.edit_hub_dialog() + elif key == "ctrl x": + self.delegate.remove_selected_dialog() + elif key == "ctrl y": + self.delegate.toggle_channel_list() + return None + elif key == "f8": + self.delegate.toggle_join_part_collapse() + return None + elif key == "tab": + self.delegate.app.ui.main_display.frame.focus_position = "header" + elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): + self.delegate.app.ui.main_display.frame.focus_position = "header" + else: + return super(ChannelsListArea, self).keypress(size, key) + + +class HubInfoArea(urwid.LineBox): + def keypress(self, size, key): + if key == "ctrl n": + self.delegate.new_hub_dialog() + return None + if key == "ctrl a": + self.delegate.join_room_dialog() + return None + if key == "ctrl r": + self.delegate.connect_selected() + return None + if key == "ctrl w": + self.delegate.disconnect_selected() + return None + if key == "ctrl t": + self.delegate.toggle_auto_reconnect_selected() + return None + if key == "ctrl e": + self.delegate.edit_hub_dialog() + return None + if key == "ctrl x": + self.delegate.remove_selected_dialog() + return None + if key == "ctrl y": + self.delegate.toggle_channel_list() + return None + if key == "f8": + self.delegate.toggle_join_part_collapse() + return None + return super(HubInfoArea, self).keypress(size, key) + + +class RoomMessageEdit(ReadlineMixin, urwid.Edit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tab_state = None + + def keypress(self, size, key): + if key == "tab": + if self._try_tab_complete(): + return None + return key + self._tab_state = None + if key == "ctrl d": + self.delegate.send_message() + elif key == "ctrl x": + self.delegate.leave_room() + elif key == "f8": + self.delegate.display.toggle_join_part_collapse() + elif key == "up": + y = self.get_cursor_coords(size)[1] + if y == 0: + self.delegate.frame.focus_position = "body" + else: + return super(RoomMessageEdit, self).keypress(size, key) + else: + return super(RoomMessageEdit, self).keypress(size, key) + + def _candidates(self, prefix_lower): + delegate = getattr(self, "delegate", None) + if delegate is None or delegate.hub is None or delegate.room is None: + return [] + members = delegate.hub.get_members(delegate.room) + own_hash = None + try: + if delegate.app.identity is not None: + own_hash = delegate.app.identity.hash + except Exception: + pass + names = set() + for m in members: + if own_hash is not None and m == own_hash: + continue + names.add(delegate.hub.display_name_for(m)) + return sorted([n for n in names if n.lower().startswith(prefix_lower)], + key=str.lower) + + def _try_tab_complete(self): + text = self.get_edit_text() + pos = self.edit_pos + state = self._tab_state + + if state is not None and state.get("cursor_after") == pos: + prefix_lower = state["prefix"] + token_start = state["token_start"] + has_at = state["has_at"] + matches = self._candidates(prefix_lower) + if not matches: + self._tab_state = None + return False + idx = (state["idx"] + 1) % len(matches) + else: + start = pos + while start > 0 and (text[start-1].isalnum() or text[start-1] in "_-"): + start -= 1 + has_at = start > 0 and text[start-1] == "@" + token_start = start - 1 if has_at else start + token = text[start:pos] + if not token: + return False + prefix_lower = token.lower() + matches = self._candidates(prefix_lower) + if not matches: + return False + idx = 0 + + selected = matches[idx] + if has_at: + replacement = "@" + selected + elif token_start == 0: + replacement = selected + ": " + else: + replacement = selected + + new_text = text[:token_start] + replacement + text[pos:] + new_cursor = token_start + len(replacement) + self.set_edit_text(new_text) + self.set_edit_pos(new_cursor) + self._tab_state = { + "prefix": prefix_lower, + "token_start": token_start, + "has_at": has_at, + "cursor_after": new_cursor, + "idx": idx, + } + return True + + +class RoomFrame(urwid.Frame): + @property + def focus_position(self): + return urwid.Frame.focus_position.fget(self) + + @focus_position.setter + def focus_position(self, part): + urwid.Frame.focus_position.fset(self, part) + try: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts() + except Exception: + pass + + def keypress(self, size, key): + if key in ("ctrl u", "ctrl x", "ctrl y", "f8", "tab"): + result = super(RoomFrame, self).keypress(size, key) + if result != key: + return result + if key == "ctrl u": + self.delegate.toggle_users() + return None + if key == "ctrl x": + self.delegate.leave_room() + return None + if key == "ctrl y": + self.delegate.display.toggle_channel_list() + return None + if key == "f8": + self.delegate.display.toggle_join_part_collapse() + return None + if self.focus_position != "footer": + self.focus_position = "footer" + return None + elif self.focus_position == "body": + if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible: + self.focus_position = "footer" + elif key == "up" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.top_is_visible: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + else: + return super(RoomFrame, self).keypress(size, key) + else: + return super(RoomFrame, self).keypress(size, key) + + +class _StickyMessageListBox(IndicativeListBox): + # Tracks whether the user is scrolled to the bottom and re-asserts that + # position on resize. urwid.ListBox stores an inset computed at the prior + # size, so without re-asserting, the focused widget can render only partly + # visible at narrower widths. + def __init__(self, body, **kwargs): + self.sticky_bottom = True + self._last_render_size = None + super().__init__(body, on_selection_change=self._track_sticky, **kwargs) + + def _track_sticky(self, old_pos, new_pos): + if new_pos is None: + self.sticky_bottom = True + else: + try: + self.sticky_bottom = (new_pos == self.rearmost_position()) + except Exception: + pass + + def render(self, size, focus=False): + if (self._last_render_size is not None + and self._last_render_size != size + and self.sticky_bottom): + try: + inner_body = self._listbox.body + if len(inner_body) > 0: + self._listbox.set_focus(len(inner_body)-1) + self._listbox.set_focus_valign("bottom") + except Exception: + pass + self._last_render_size = size + canvas = super().render(size, focus=focus) + if getattr(self, "bottom_is_visible", False): + self.sticky_bottom = True + return canvas + + +class RoomWidget(urwid.WidgetWrap): + USERS_PANE_WIDTH = 22 + + def __init__(self, display, hub, room): + self.display = display + self.hub = hub + self.room = room + self.app = nomadnet.NomadNetworkApp.get_shared_instance() + self.theme = theme_dark if self.app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light + + self.messagelist = None + self.last_history_clean = 0 + self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") + self._update_peer_info() + + editor = RoomMessageEdit(caption="", edit_text="", multiline=True) + editor.delegate = self + self.editor = editor + urwid.connect_signal(editor, "postchange", self._on_editor_change) + editor_attr = urwid.AttrMap(editor, "msg_editor") + + self.link_delegate = _ChatLinkDelegate(self.display, self.hub) + self.update_messages() + + self.frame = RoomFrame( + self.messagelist, + header=self.peer_info_widget, + footer=editor_attr, + focus_part="footer", + ) + self.frame.delegate = self + + self.chat_box = urwid.LineBox(self.frame) + self.users_walker = urwid.SimpleFocusListWalker([urwid.Text("")]) + self.users_listbox = urwid.ListBox(self.users_walker) + self.users_box = UsersBox(self.users_listbox, title="Users") + self.users_box.delegate = self + self.users_gutter = UsersExpandGutter(self.app, self) + self.show_gutters = self.app.rrc_show_gutters + self._refresh_users_pane() + + self.users_visible = self.display.users_visible + self.columns = urwid.Columns([(urwid.WEIGHT, 1, self.chat_box)], dividechars=0, focus_column=0) + self._apply_users_visibility() + super().__init__(self.columns) + + def toggle_users(self): + self.users_visible = not self.users_visible + self.display.users_visible = self.users_visible + self._apply_users_visibility() + + def _apply_users_visibility(self): + if self.users_visible: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_box, self.columns.options(urwid.GIVEN, RoomWidget.USERS_PANE_WIDTH)), + ] + else: + if self.show_gutters: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_gutter, self.columns.options(urwid.GIVEN, 1)), + ] + else: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)) + ] + + self.columns.focus_position = 0 + + def _refresh_users_pane(self): + g = self.app.ui.glyphs + walker = self.users_walker + if self.hub is None or self.room is None: + walker[:] = [urwid.Text("")] + return + members = self.hub.get_members(self.room) + own_hash = self.app.identity.hash if self.app.identity is not None else None + def _safe_name(raw): + if not raw: return "" + try: + if self.app.config["textui"]["sanitize_names"]: + return sanitize_name(str(raw)) or "" + return strip_modifiers(str(raw)) or "" + except Exception: + return str(raw or "") + + entries = [] + for m in members: + safe_name = _safe_name(self.hub.display_name_for(m)) + full_name = safe_name + safe_name = safe_name[:15]+"…" if len(safe_name) > 16 else safe_name + entries.append((safe_name, m, own_hash is not None and m == own_hash, full_name)) + entries.sort(key=lambda x: x[0].lower()) + + prev_focus_key = None + try: + prev_idx = walker.focus + if prev_idx is not None and 0 <= prev_idx < len(walker): + prev_focus_key = getattr(walker[prev_idx], "user_hash", None) + except Exception: + prev_focus_key = None + + rows = [urwid.Text(" "+str(len(entries))+" user"+("s" if len(entries) != 1 else ""))] + for name, peer_hash, is_self, full_name in entries: + if self.app.rrc_nick_colors: + style_state = default_state() + style_state["fg_color"] = get_nick_color(peer_hash, self.theme, self.app) + if is_self: + label = " "+g["arrow_r"]+" "+name + style = make_style(style_state) + else: + label = " "+g["peer"]+" "+name + style = make_style(style_state) + + else: + if is_self: + label = " "+g["arrow_r"]+" "+name + style = "list_trusted" + else: + label = " "+g["peer"]+" "+name + style = "connected_status" + entry = ChannelListEntry(label) + urwid.connect_signal(entry, "click", self.display.show_user_info, (self.hub, peer_hash, full_name)) + row = urwid.AttrMap(entry, style, "list_focus") + row.user_hash = peer_hash + rows.append(row) + if not entries: + rows.append(urwid.Text(" (no members)")) + + walker[:] = rows + + new_focus = None + if prev_focus_key is not None: + for idx, w in enumerate(walker): + if getattr(w, "user_hash", None) == prev_focus_key: + new_focus = idx + break + if new_focus is None: + for idx, w in enumerate(walker): + if hasattr(w, "user_hash"): + new_focus = idx + break + if new_focus is not None: + try: walker.set_focus(new_focus) + except Exception: pass + + def _update_peer_info(self): + if self.hub is None or self.room is None: + self.peer_info_widget.original_widget.set_text("") + return + + status_label = { + RRCHub.STATUS_DISCONNECTED: "Disconnected", + RRCHub.STATUS_CONNECTING: "Connecting", + RRCHub.STATUS_CONNECTED: "Connected", + RRCHub.STATUS_FAILED: "Failed", + }.get(self.hub.status, "") + + server = "" + if self.hub.hub_name: + server = " "+self.app.ui.glyphs["divider1"]+" "+self.hub.hub_name + if self.hub.hub_version: + server += " v"+self.hub.hub_version + left = " #"+self.room+server+" ("+self.hub.name+")" + right = status_label+" " + self.peer_info_widget.original_widget.set_text(left+" | "+right) + + def update_messages(self, replace=False): + msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] + widgets = [] + collapse = getattr(self.display, "collapse_join_part", False) + run = [] + + def flush_run(): + if not run: + return + widgets.append(_collapsed_joinpart_widget(self.app, len(run))) + run.clear() + + for m in msgs: + if collapse and _is_joinpart_system(m): + run.append(m) + continue + flush_run() + widgets.append(_message_widget(self.app, self.hub, self.room, m, link_delegate=self.link_delegate)) + flush_run() + + if not widgets: + widgets = [urwid.Text([("irc_system", " "+self.app.ui.glyphs["info"]+" No messages yet")])] + self._empty_placeholder = True + else: + self._empty_placeholder = False + + self.messagelist = _StickyMessageListBox(widgets, position=len(widgets)-1) + self.messagelist.name = "messagelist" + try: + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass + if replace and hasattr(self, "frame"): + self.frame.contents["body"] = (self.messagelist, None) + if hasattr(self, "users_walker"): + self._refresh_users_pane() + + def append_message(self, msg): + if self.messagelist is None: + self.update_messages(replace=True) + return + try: + widget = _message_widget(self.app, self.hub, self.room, msg, link_delegate=self.link_delegate) + wrapped = urwid.AttrMap(widget, None) + body = self.messagelist.get_body() + was_at_bottom = (self.messagelist.sticky_bottom + or getattr(self.messagelist, "bottom_is_visible", True)) + self.messagelist.sticky_bottom = was_at_bottom + if getattr(self, "_empty_placeholder", False): + del body[:] + self._empty_placeholder = False + + if self.hub.clean_last_removed > self.last_history_clean: + try: + with self.hub._lock: + hub_msgs = self.hub.get_messages(self.room, take_lock=False) if (self.hub is not None and self.room is not None) else [] + self.last_history_clean = time.time() + c = self.messagelist.body_len() + old = set() + for i in range(0, c): + msg = None + w = self.messagelist.get_item(i) + if hasattr(w, "_original_widget"): o = w._original_widget + else: o = None + if hasattr(o, "msg"): msg = o.msg + elif hasattr(w, "msg"): msg = w.msg + if msg and not msg in hub_msgs: old.add(w) + + list_body = self.messagelist.get_body() + for w in list(old): + try: list_body.remove(w) + except: RNS.log(f"Could not remove expired message widget {w}: {e}", RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while cleaning room history", RNS.LOG_ERROR) + RNS.trace_exception(e) + + body.append(wrapped) + cap = getattr(self.app, "rrc_history_per_room_cap", 0) + if cap and cap > 0: + while len(body) > cap: + del body[0] + if was_at_bottom: + try: + self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass + except Exception as e: + RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG) + self.update_messages(replace=True) + if hasattr(self, "users_walker"): + self._refresh_users_pane() + + def _on_editor_change(self, editor, old_text): + if self.messagelist is None: + return + try: + body = self.messagelist._listbox.body + if len(body) > 0: + self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") + self.messagelist.sticky_bottom = True + except Exception: + pass + + def send_message(self): + text = self.editor.get_edit_text() + if not text.strip(): + return + self.messagelist.sticky_bottom = True + if text.lstrip().startswith("/"): + self._handle_slash_command(text.lstrip()) + self.editor.set_edit_text("") + return + if self.hub.status != RRCHub.STATUS_CONNECTED: + try: + self.hub.connect() + except Exception: + pass + return + limit = self.hub.max_msg_body_bytes or 350 + if len(text.encode("utf-8")) > limit: + self._open_split_dialog(text, limit) + return + try: + self.hub.send_message(self.room, text) + self.editor.set_edit_text("") + except Exception as e: + RNS.log("Failed to send RRC message: "+str(e), RNS.LOG_ERROR) + + def _open_split_dialog(self, text, limit): + body_bytes = len(text.encode("utf-8")) + parts = _split_message(text, limit) + if not parts: + self._local_message("error", + "Message is "+str(body_bytes)+" bytes but per-message limit is too small to split.") + return + K = len(parts) + preview = parts[0] + if len(preview) > 70: + preview = preview[:70] + "…" + preview = preview.replace("\n", " ").replace("\t", " ") + + error_text = urwid.Text("") + + def cancel(sender): + self.display.close_dialog() + + def send_split(sender): + try: + self.messagelist.sticky_bottom = True + for p in parts: + self.hub.send_message(self.room, p) + self.editor.set_edit_text("") + self.display.close_dialog() + except Exception as e: + error_text.set_text(("error_text", "Send failed: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text(" Message is "+str(body_bytes)+" bytes."), + urwid.Text(" Hub limit : "+str(limit)+" bytes per message."), + urwid.Text(""), + urwid.Text(" Split into "+str(K)+" message"+("s" if K != 1 else "")+"."), + urwid.Text(" Preview of part 1:"), + urwid.AttrMap(urwid.Text(" "+preview), "irc_system"), + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Send Split", on_press=send_split)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel)), + ]) + ]), title="Message Too Long" + ) + dialog.delegate = self.display + self.display._show_dialog_overlay(dialog) + + def _local_message(self, kind, text): + from nomadnet.RRC import RRCMessage + msg = RRCMessage(kind, self.room, None, None, text, int(time.time()*1000)) + with self.hub._lock: + buf = self.hub.messages.setdefault(self.room, []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + self.hub.manager._notify_messages(self.hub, msg) + # printed /help + SLASH_HELP = [ + "/help - show this list", + "/ping - measure round-trip to hub", + "/list - list public rooms on this hub", + "/join - join a room on this hub", + "/part [room] - leave a room (default: current)", + "/leave [room] - alias for /part", + "/me - send an action (e.g. /me waves)", + "/nick - set your nick on this hub only", + "/who [room] - list users (current room if omitted)", + "/names [room] - alias for /who", + "/clear - clear local messages in this room", + "/connect - connect this hub", + "/disconnect - disconnect this hub", + "/quit - alias for /disconnect", + "", + "Server-side commands (auth enforced by hub):", + "/topic [text] - view or set room topic", + "/mode [+-flags] [arg] - view or set room modes", + "/register - register the current room", + "/unregister - unregister the current room", + "/kick - remove user from room", + "/ban add|del|list [target] - room ban list", + "/invite add|del|list [target] - room invite list", + "/op - grant op", + "/deop - revoke op", + "/voice - grant voice", + "/devoice - revoke voice", + "/kline add|del|list [target] - global ban", + "/stats - server statistics", + "/reload - reload server config", + ] + + # commands that we forward to the server verbatim + SERVER_SLASH_COMMANDS = { + "who", "names", + "topic", "mode", "kick", "kline", + "ban", "invite", "kline", + "op", "deop", "voice", "devoice", + "register", "unregister", + "stats", "reload", + } + + def _require_connected(self): + if self.hub.status != RRCHub.STATUS_CONNECTED: + self._local_message("error", "Not connected to hub") + return False + return True + + def _handle_slash_command(self, text): + parts = text[1:].split(None, 1) + if not parts or not parts[0]: + self._local_message("error", "Empty command") + return + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + + if cmd == "help": + for line in self.SLASH_HELP: + self._local_message("system", line) + return + + if cmd == "ping": + if not self._require_connected(): + return + try: + self.hub.send_ping(room=self.room) + self._local_message("system", "Ping sent") + except Exception as e: + self._local_message("error", "Ping failed: "+str(e)) + return + + if cmd == "list": + if not self._require_connected(): + return + try: + self.hub.send_command("/list", room=self.room) + except Exception as e: + self._local_message("error", "/list failed: "+str(e)) + return + + if cmd in ("join", "j"): + if not arg: + self._local_message("error", "Usage: /join ") + return + target = arg.lstrip("#").strip() + try: + self.hub.add_room(target) + if self.hub.status == RRCHub.STATUS_CONNECTED: + self.hub.join_room(target) + self.display.update_list() + self.display._select_room(None, (self.hub, target.lower())) + except Exception as e: + self._local_message("error", "Join failed: "+str(e)) + return + + if cmd in ("part", "leave"): + target = (arg.lstrip("#").strip().lower()) if arg else self.room + try: + self.hub.part_room(target) + self.display.update_list() + if target == self.room: + self.display.show_placeholder() + except Exception as e: + self._local_message("error", "Part failed: "+str(e)) + return + + if cmd == "me": + if not self._require_connected(): + return + if not arg: + self._local_message("error", "Usage: /me ") + return + limit = self.hub.max_msg_body_bytes or 350 + if len(arg.encode("utf-8")) > limit: + self._local_message("error", "Action too long (max "+str(limit)+" bytes)") + return + try: + self.hub.send_action(self.room, arg) + except Exception as e: + self._local_message("error", "/me failed: "+str(e)) + return + + if cmd == "nick": + if not arg: + cur = self.hub.get_effective_nick() or " unset" + src = "nick: " if (isinstance(self.hub.nick_override, str) and self.hub.nick_override) else "global" + self._local_message("system", "Nick on this hub: "+cur+" ("+src+")") + return + limit = self.hub.max_nick_bytes or 32 + if len(arg.encode("utf-8")) > limit: + self._local_message("error", "Nick too long (max "+str(limit)+" bytes)") + return + try: + self.hub.set_nick_override(arg) + self._local_message("system", "Nick on this hub set to "+arg+ + " (use /nick with no argument to view)") + except Exception as e: + self._local_message("error", "Nick change failed: "+str(e)) + return + + if cmd == "clear": + self.hub.clear_messages(self.room) + self.update_messages(replace=True) + return + + if cmd == "connect": + try: + self.hub.connect() + self._local_message("system", "Connecting...") + except Exception as e: + self._local_message("error", "Connect failed: "+str(e)) + return + + if cmd in ("disconnect", "quit"): + try: + self.hub.disconnect() + except Exception as e: + self._local_message("error", "Disconnect failed: "+str(e)) + return + + if cmd in self.SERVER_SLASH_COMMANDS: + if not self._require_connected(): + return + try: + self.hub.send_command("/"+cmd+(" "+arg if arg else ""), room=self.room) + except Exception as e: + self._local_message("error", "/"+cmd+" failed: "+str(e)) + return + + self._local_message("error", "Unknown command: /"+cmd+" (try /help)") + + def leave_room(self): + try: + self.hub.part_room(self.room) + except Exception: + pass + self.display.update_list() + self.display.show_placeholder() + + +def _ts_prefix(ts_ms): + t = _format_ts(ts_ms) if ts_ms else " " + return ("irc_ts", " ["+t+"] ") + +def _ts_prefix_raw(ts_ms): + t = _format_ts(ts_ms) if ts_ms else " " + return "["+t+"] " + + +class _ChatLinkDelegate: + def __init__(self, display, hub): + self.display = display + self.hub = hub + self.app = display.app + self.last_keypress = 0 + + def marked_link(self, target, fields=None): + pass + + def micron_released_focus(self): + pass + + def handle_link(self, target, fields=None): + if target is None: return + try: + components = target.split("://") + if len(components) < 2: return + kind = components[0] + payload = components[1] + if kind == "room": self._open_room(payload.lstrip("#")) + elif kind == "lxmf": self._open_lxmf(payload.lstrip("lxmf@")) + elif kind == "page": + final_url = payload + if fields: + final_url += "`" + for f in fields: final_url += f"{f}|" + final_url.rstrip("|") + self._open_page(final_url) + else: RNS.log(f"Invalid URL: {target}", RNS.LOG_WARNING) + except Exception as e: RNS.log("Chat link handler failed: "+str(e), RNS.LOG_ERROR) + + def _open_room(self, room): + room = (room or "").strip().lower() + if not room: + return + if room not in self.hub.rooms and self.hub.status == RRCHub.STATUS_CONNECTED: + try: self.hub.join_room(room) + except Exception: pass + self.hub.add_room(room) + self.display.update_list() + self.display._select_room(None, (self.hub, room)) + + def _open_lxmf(self, hash_hex): + try: + bytes.fromhex(hash_hex) + except Exception: + return + from nomadnet.Directory import DirectoryEntry + existing = [c[0] for c in nomadnet.Conversation.conversation_list(self.app)] + if hash_hex not in existing: + display_name = None + try: + data = RNS.Identity.recall_app_data(bytes.fromhex(hash_hex)) + if data is not None: + import LXMF + display_name = LXMF.display_name_from_app_data(data) + except Exception: + pass + try: + self.app.directory.remember(DirectoryEntry(bytes.fromhex(hash_hex), display_name=display_name)) + except Exception: + pass + try: + nomadnet.Conversation(hash_hex, self.app, initiator=True) + except Exception: + pass + conversations = self.app.ui.main_display.sub_displays.conversations_display + try: + trust_level = self.app.directory.trust_level(bytes.fromhex(hash_hex)) + except Exception: + trust_level = DirectoryEntry.UNKNOWN + target_filter = (conversations.LIST_FILTER_TRUSTED if trust_level == DirectoryEntry.TRUSTED + else conversations.LIST_FILTER_UNTRUSTED) + if conversations.list_filter != target_filter: + conversations._set_filter(target_filter) + else: + conversations.update_conversation_list() + conversations.display_conversation(None, hash_hex) + self.app.ui.main_display.show_conversations(None) + + def _open_page(self, url): + if not url: + return + self.app.ui.main_display.show_network(None) + try: + self.app.ui.main_display.sub_displays.network_display.browser.retrieve_url(url) + except Exception as e: + RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) + + +_MOTD_ROOM_RE = re.compile(r"(?", "#"] +mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) +def _message_widget(app, hub, room, m, link_delegate=None): + t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light + g = app.ui.glyphs + own_nick = None + try: + if hub is not None: + own_nick = hub.get_effective_nick() + else: + own_nick = app.rrc.get_nickname() + except Exception: + pass + + if m.kind == "system": + evt_icon = g["arrow_l"] if m.text.endswith(" left") else g["arrow_r"] + spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick) + if m.text.endswith(" left") or m.text.endswith(" joined"): spans = [(s[0], sanitize_name(s[1])) for s in spans] + markup = [_ts_prefix(m.ts), ("irc_system", evt_icon+" ")] + spans + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget + + if m.kind == "notice": + spans, has_links = _body_markup(m.text or "", body_attr="irc_notice", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_notice", g["info"]+" ")] + spans + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget + + if m.kind == "error": + spans, has_links = _body_markup(m.text or "", body_attr="irc_error", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_error", g["warning"]+" ")] + spans + final_widget = _wrap_text(markup, link_delegate if has_links else None) + final_widget.msg = m + return final_widget + + own = False + try: + if hub is not None and m.src is not None and app.identity is not None: + own = bytes(m.src) == app.identity.hash + except Exception: + pass + + if m.nick: sender = sanitize_name(m.nick) + elif isinstance(m.src, (bytes, bytearray)): sender = _short_hash(m.src) + else: sender = "?" + + if isinstance(m.src, (bytes, bytearray)): + if not str(room) in room_nick_src_cache: room_nick_src_cache[str(room)] = {} + room_nick_src_cache[str(room)][str(m.nick)] = m.src + + nick_attr = "irc_nick_self" if own else "irc_nick_peer" + body = m.text or "" + spans, has_links = _body_markup(body, body_attr="body_text", own_nick=own_nick, check_links=False) + ld = link_delegate if has_links else None + + irc_ts = f"`F{t['ts']}" + message_body = "" + for span in spans: + ms = span[0] + mb = span[1] + if ms.startswith("irc_mention"): + if not app.rrc_nick_colors: + if app.rrc_mention_color: mention_color = f"`FT{app.rrc_mention_color}" + else: mention_color = f"`F{t['mention']}" + message_body += f"`!{mention_color}{mb}`f`!" + if app.rrc_color_mention_timestamps: irc_ts = f"`F{t['mention']}" + + else: + try: + if not app.rrc_mention_color: own_nick_color = get_nick_color(app.identity.hash, t, app) + else: own_nick_color = app.rrc_mention_color + message_body += f"`!`FT{own_nick_color}{mb}`f`!" + if app.rrc_color_mention_timestamps: irc_ts = f"`FT{own_nick_color}" + + except: message_body += f"`!`F{t['mention']}{mb}`f`!" + + elif ms.startswith("nick_mention"): + if not app.rrc_nick_colors: message_body += f"{mb}" + else: + mentioned_nick = mb[1:] + nick_src = get_nick_src(hub, room, mentioned_nick) + if not nick_src: message_body += f"{mb}" + else: + nick_color = get_nick_color(nick_src, t, app) + message_body += f"`!`FT{nick_color}{mb}`f`!" + + elif ms.startswith("link_"): + kind = ms[len("link_"):] + comps = mb.split("`") + label = comps[0] + fields = "|"+comps[1].replace("`", "") if len(comps) > 1 else "" + url = f"{kind}://{mb}" + link_mu = f"`_`F{t['link']}`[{label}{fields}`{url}]`f`_" + link_md = f"[{label}]({url})" + message_body += link_mu + + else: + # This is a hack for now to avoid start-of-span + # being interpreted as start-of-line by the micron + # parser, because the link-detection logic splits + # up lines into spans for link detection. Since + # We're now using the micron engine, we could + # probably avoid this alltogether. But it needs + # another kind of link handling, and I'm not going + # to write that right now, so little hack it is. + if mb[0:1] in invalid_span_starts: yanked = mb[0:1]; mb = mb[1:] + else: yanked = "" + + if app.rrc_ui_render_micron: + mbo = mdc.format_block(mb) if app.rrc_ui_render_markdown else mb + mbo = unescape_micron(mbo) + message_body += yanked+strip_non_formatting_tags(mbo) + + else: + mbo = mdc.format_block(strip_escaped_micron(mb)) if app.rrc_ui_render_markdown else strip_escaped_micron(mb) + message_body += yanked+strip_non_formatting_tags(mbo) + + if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t, app)}" + else: nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" + + prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}`f" + if m.kind == "action": + nick_micron = f" `*`f{nick_attr}{sender}`f`* " + message_body = f"`*`f{nick_attr}{strip_micron(message_body)}`f`*" + else: + nick_micron = f"`f{nick_attr}<{sender}>`f " + + if app.rrc_ui_justify_msgs: + prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"]) + body_rendered = _render_body(f"{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) + if app.rrc_ui_space_msgs: body_rendered.append(urwid.Text("")) + columns = urwid.Columns([(urwid.PACK, urwid.Pile(prefix_rendered)), urwid.Pile(body_rendered)], dividechars=1) + final_widget = urwid.Padding(columns, left=1) + + else: + rendered = _render_body(f"{prefix_micron}{nick_micron}{message_body}", link_delegate=ld, fg=t["text"]) + if app.rrc_ui_space_msgs: rendered.append(urwid.Text("")) + final_widget = urwid.Padding(urwid.Pile(rendered), left=1) + + final_widget.msg = m + return final_widget + +def _render_body(markup, link_delegate=None, fg="bbb"): + try: return markup_to_attrmaps(strip_modifiers(markup), url_delegate=link_delegate, link_class=_ChatLinkableText, fg_color=fg) + except Exception as e: + RNS.trace_exception(e) + return [] + +def _wrap_text(markup, link_delegate): + if link_delegate is not None: + return _ChatLinkableText(markup, align="left", delegate=link_delegate) + return urwid.Text(markup) + + +class ChannelsDisplay(): + list_width = 0.33 + given_list_width = 36 + + def __init__(self, app): + self.app = app + self.dialog_open = False + self.list_widgets = [] + self.selected_key = None + self.current_room_widget = None + self.users_visible = True + self.channel_list_visible = True + self.collapse_join_part = False + self._room_drafts = {} + + self._build_listbox() + self.gutter = ChannelsExpandGutter(self.app, self) + self.show_gutters = self.app.rrc_show_gutters + + self.list_shortcuts = ChannelsListShortcuts(self.app) + self.room_shortcuts = ChannelsRoomShortcuts(self.app) + self.room_body_shortcuts = ChannelsRoomBodyShortcuts(self.app) + self.shortcuts_display = self.list_shortcuts + + self.placeholder = urwid.LineBox(urwid.Filler(urwid.Text("\n Select or add a hub to begin", align=urwid.CENTER), "top")) + self.right = self.placeholder + + self.columns_widget = urwid.Columns( + [ + (ChannelsDisplay.given_list_width, self.listbox), + (urwid.WEIGHT, 1, self.right), + ], + dividechars=0, focus_column=0, box_columns=[0], + ) + self.widget = urwid.WidgetPlaceholder(self.columns_widget) + + self._pending_actions = collections.deque() + self._wake_fd = None + try: + self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending) + except Exception: + pass + + self._mention_bell_last = {} + + self.app.rrc.set_change_callback(self._on_rrc_change) + self.app.rrc.set_message_callback(self._on_rrc_message) + + def _set_right_widget(self, widget): + prev = getattr(self, "right", None) + if isinstance(prev, RoomWidget) and prev is not widget: + self._save_room_draft(prev) + self.right = widget + if widget is self.placeholder: + # The placeholder has nothing to interact with, so always keep the + # hub/channel pane visible (and focused) beside it. + self.channel_list_visible = True + self._apply_channel_list_visibility(focus_right=False) + try: + self.columns_widget.focus_position = 0 + except Exception: + pass + else: + self._apply_channel_list_visibility(focus_right=True) + + def _draft_key(self, hub, room): + try: + return (hub.hub_hash, hub.dest_name, room) + except Exception: + return None + + def _save_room_draft(self, room_widget): + try: + key = self._draft_key(room_widget.hub, room_widget.room) + if key is None: + return + text = room_widget.editor.get_edit_text() + if text: + self._room_drafts[key] = text + else: + self._room_drafts.pop(key, None) + except Exception: + pass + + def _restore_room_draft(self, room_widget): + try: + key = self._draft_key(room_widget.hub, room_widget.room) + if key is None: + return + text = self._room_drafts.get(key) + if text: + room_widget.editor.set_edit_text(text) + room_widget.editor.set_edit_pos(len(text)) + except Exception: + pass + + def toggle_channel_list(self): + if self.channel_list_visible and self.right is self.placeholder: + return + self.channel_list_visible = not self.channel_list_visible + self._apply_channel_list_visibility() + + def toggle_join_part_collapse(self): + self.collapse_join_part = not self.collapse_join_part + if self.current_room_widget is not None: + try: + self.current_room_widget.update_messages(replace=True) + except Exception: + pass + + def _apply_channel_list_visibility(self, focus_right=False): + list_opts = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width) + gutter_opts = self.columns_widget.options(urwid.GIVEN, 1) + right_opts = self.columns_widget.options(urwid.WEIGHT, 1) + if self.channel_list_visible: + self.columns_widget.contents = [ + (self.listbox, list_opts), + (self.right, right_opts), + ] + if focus_right: + try: self.columns_widget.focus_position = 1 + except Exception: pass + else: + if self.show_gutters: + self.columns_widget.contents = [ + (self.gutter, gutter_opts), + (self.right, right_opts), + ] + else: + self.columns_widget.contents = [ + (self.right, right_opts), + ] + try: self.columns_widget.focus_position = 1 + except Exception: pass + + def start(self): + self.update_list() + + def shortcuts(self): + try: + focus_path = self.columns_widget.get_focus_path() + except Exception: + focus_path = None + if focus_path and focus_path[0] == 1 and self.current_room_widget is not None: + try: + frame = self.current_room_widget.frame + if frame is not None and frame.focus_position == "body": + return self.room_body_shortcuts + except Exception: + pass + return self.room_shortcuts + return self.list_shortcuts + + def _build_listbox(self): + self._compose_list_widgets() + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=lambda a, b: None, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels") + self.listbox.delegate = self + + def _compose_list_widgets(self): + widgets = [] + manager = self.app.rrc + + if not manager.hubs: + entry = urwid.AttrMap(urwid.Text("\n No hubs yet. Press Ctrl-N to add one."), "list_unknown") + widgets.append(entry) + self.list_widgets = widgets + return + + g = self.app.ui.glyphs + for hub_idx, hub in enumerate(manager.hubs): + if hub_idx > 0: + spacer = urwid.Text("") + spacer.row_kind = "spacer" + widgets.append(spacer) + if hub.status == RRCHub.STATUS_CONNECTED: + status_glyph = g["check"] + style = "list_trusted" + elif hub.status == RRCHub.STATUS_CONNECTING: + status_glyph = g["info"] + style = "list_unresponsive" + elif hub.status == RRCHub.STATUS_FAILED: + status_glyph = g["cross"] + style = "list_untrusted" + else: + status_glyph = " " + style = "list_unknown" + + entry = ChannelListEntry(status_glyph+" "+hub.name) + urwid.connect_signal(entry, "click", self._select_hub, hub) + attr = urwid.AttrMap(entry, style, "list_focus") + attr.row_kind = "hub" + attr.hub = hub + attr.room = None + widgets.append(attr) + + for room in sorted(list(hub.rooms | set(hub.messages.keys()))): + if not room: + continue + is_joined = room in hub.rooms + mentioned = room in hub.mention_rooms + unread = room in hub.unread_rooms + if mentioned: + marker = g["warning"] + room_style = "irc_mention" + elif unread: + marker = g["unread"] + room_style = "list_unresponsive" + elif not is_joined: + marker = " " + room_style = "list_unknown" + else: + marker = " " + room_style = "list_trusted" if hub.status == RRCHub.STATUS_CONNECTED else "list_unknown" + room_entry = ChannelListEntry(" "+marker+" #"+room) + urwid.connect_signal(room_entry, "click", self._select_room, (hub, room)) + room_attr = urwid.AttrMap(room_entry, room_style, "list_focus") + room_attr.row_kind = "room" + room_attr.hub = hub + room_attr.room = room + widgets.append(room_attr) + + self.list_widgets = widgets + + def update_list(self): + prev_key = self.selected_key + self._compose_list_widgets() + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=lambda a, b: None, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels") + self.listbox.delegate = self + + if not self.dialog_open and self.channel_list_visible: + options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width) + self.columns_widget.contents[0] = (self.listbox, options) + + if prev_key is not None: + for idx, w in enumerate(self.list_widgets): + key = self._row_key(w) + if key == prev_key: + try: self.ilb.select_item(idx) + except Exception: pass + break + + self._refresh_active_header() + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _row_key(self, w): + if not hasattr(w, "row_kind"): + return None + if w.row_kind == "hub": + return ("hub", w.hub.hub_hash, w.hub.dest_name) + if w.row_kind == "room": + return ("room", w.hub.hub_hash, w.hub.dest_name, w.room) + return None + + def _refresh_active_header(self): + if self.current_room_widget is not None: + try: + self.current_room_widget._update_peer_info() + except Exception: + pass + return + if self.selected_key and self.selected_key[0] == "hub": + for h in self.app.rrc.hubs: + if h.hub_hash == self.selected_key[1] and h.dest_name == self.selected_key[2]: + self._show_hub_info(h) + break + + def _select_hub(self, sender, hub): + self.selected_key = ("hub", hub.hub_hash, hub.dest_name) + self.app.rrc.set_active(hub, None) + self._maybe_autoconnect(hub) + self._show_hub_info(hub) + + def _select_room(self, sender, payload): + hub, room = payload + self.selected_key = ("room", hub.hub_hash, hub.dest_name, room) + self.app.rrc.set_active(hub, room) + self._maybe_autoconnect(hub) + if room not in hub.rooms: + if hub.status == RRCHub.STATUS_CONNECTED: + try: hub.join_room(room) + except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR) + else: + try: hub.add_room(room) + except Exception as e: RNS.log("Pending join queue failed: "+str(e), RNS.LOG_ERROR) + self._show_room(hub, room) + + def _maybe_autoconnect(self, hub): + if hub.status in (RRCHub.STATUS_DISCONNECTED, RRCHub.STATUS_FAILED): + try: + hub.connect() + except Exception as e: + RNS.log("Auto-connect failed: "+str(e), RNS.LOG_ERROR) + + def _show_hub_info(self, hub): + g = self.app.ui.glyphs + status_label = { + RRCHub.STATUS_DISCONNECTED: "Disconnected", + RRCHub.STATUS_CONNECTING: "Connecting", + RRCHub.STATUS_CONNECTED: "Connected", + RRCHub.STATUS_FAILED: "Failed", + }.get(hub.status, "") + status_attr = { + RRCHub.STATUS_DISCONNECTED: "list_unknown", + RRCHub.STATUS_CONNECTING: "list_unresponsive", + RRCHub.STATUS_CONNECTED: "connected_status", + RRCHub.STATUS_FAILED: "list_untrusted", + }.get(hub.status, "list_unknown") + + lines = [ + urwid.Text(""), + urwid.Text(" Hub : "+hub.name), + urwid.Text(" Address : "+hub.hub_hash.hex()), + urwid.AttrMap(urwid.Text(" Status : "+status_label+" ("+hub.status_text+")"), status_attr), + ] + if hub.hub_name: + ver = " v"+str(hub.hub_version) if hub.hub_version else "" + lines.append(urwid.Text(" Server : "+str(hub.hub_name)+ver)) + + ar_glyph = g["check"] if hub.auto_reconnect else g["cross"] + ar_attr = "list_trusted" if hub.auto_reconnect else "list_unknown" + ar_text = "On" if hub.auto_reconnect else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoRcn : "+ar_glyph+" "+ar_text+" (Ctrl-T to toggle)"), ar_attr)) + + al_glyph = g["check"] if hub.auto_list else g["cross"] + al_attr = "list_trusted" if hub.auto_list else "list_unknown" + al_text = "On" if hub.auto_list else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoList : "+al_glyph+" "+al_text+" (Ctrl-E to edit)"), al_attr)) + + aw_glyph = g["check"] if hub.auto_who else g["cross"] + aw_attr = "list_trusted" if hub.auto_who else "list_unknown" + aw_text = "On" if hub.auto_who else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoWho : "+aw_glyph+" "+aw_text+" (Ctrl-E to edit)"), aw_attr)) + + lines.append(urwid.Divider(g["divider1"])) + + if hub.status == RRCHub.STATUS_CONNECTED: + lines.append(urwid.Text(" Connected. Use Ctrl-A to add a room.")) + elif hub.status == RRCHub.STATUS_CONNECTING: + lines.append(urwid.AttrMap(urwid.Text(" Connecting..."), "list_unresponsive")) + else: + lines.append(urwid.Text(" Use Ctrl-R to connect.")) + + if hub.motd: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" MOTD:")) + motd_delegate = _ChatLinkDelegate(self, hub) + try: + motd_widgets = markup_to_attrmaps(_linkify_motd(hub.motd), url_delegate=motd_delegate) + except Exception: + motd_widgets = [urwid.Text(hub.motd)] + for w in motd_widgets: + lines.append(urwid.Padding(w, left=2)) + + if hub.rooms: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" Joined rooms:")) + for r in sorted(hub.rooms): + entry = ChannelListEntry(" #"+r) + urwid.connect_signal(entry, "click", self._select_room, (hub, r)) + lines.append(urwid.AttrMap(entry, "list_trusted", "list_focus")) + + available = sorted( + (name, topic) for name, topic in hub.available_rooms.items() + if name and name not in hub.rooms + ) + if available: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" Available rooms:")) + for name, topic in available: + label = " #"+name + if topic: + label += " "+g["arrow_r"]+" "+topic + entry = ChannelListEntry(label) + urwid.connect_signal(entry, "click", self._select_room, (hub, name)) + lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus")) + + body = ScrollBar(Scrollable(urwid.Pile(lines)), thumb_char="┃", trough_char=" ") + info = HubInfoArea(urwid.AttrMap(body, "scrollbar"), title=hub.name) + info.delegate = self + self.current_room_widget = None + self._set_right_widget(info) + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def show_placeholder(self): + self.current_room_widget = None + self.selected_key = None + self._set_right_widget(self.placeholder) + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def _show_room(self, hub, room): + widget = RoomWidget(self, hub, room) + self.current_room_widget = widget + self._set_right_widget(widget) + self._restore_room_draft(widget) + self.columns_widget.focus_position = len(self.columns_widget.contents)-1 + self.shortcuts_display = self.room_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def _selected_row(self): + item = self.ilb.get_selected_item() + if item is None: + return None + return item + + def connect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + try: + item.hub.connect() + except Exception as e: + RNS.log("Connect failed: "+str(e), RNS.LOG_ERROR) + + def disconnect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + try: + item.hub.disconnect() + except Exception: + pass + + def toggle_auto_reconnect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + item.hub.set_auto_reconnect(not item.hub.auto_reconnect) + if self.current_room_widget is None: + self._show_hub_info(item.hub) + + def remove_selected_dialog(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + hub = item.hub + room = getattr(item, "room", None) + + def confirmed(sender): + self.close_dialog() + if room is not None: + try: hub.part_room(room) + except Exception: pass + hub.remove_room(room) + else: + self.app.rrc.remove_hub(hub) + self.update_list() + self.show_placeholder() + + def dismiss(sender): + self.close_dialog() + + if room is not None: + prompt = "Leave and remove room\n#"+room+"\non hub "+hub.name+"?" + else: + prompt = "Remove hub\n"+hub.name+"\nfrom this client?\n All Message history will be discarded." + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(prompt+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss)), + ]) + ]), title="?" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def new_hub_dialog(self): + e_hash = ReadlineEdit(caption="Hub address : ", edit_text="") + e_name = ReadlineEdit(caption="Display name: ", edit_text="") + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + hh_text = e_hash.get_edit_text().strip().lower() + if hh_text.startswith("0x"): + hh_text = hh_text[2:] + hh = bytes.fromhex(hh_text) + if len(hh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + raise ValueError("Hash length must be "+str(RNS.Reticulum.TRUNCATED_HASHLENGTH//8)+" bytes") + nm = e_name.get_edit_text().strip() or None + self.app.rrc.add_hub(hh, name=nm) + self.close_dialog() + self.update_list() + except Exception as e: + error_text.set_text(("error_text", "Could not add hub: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + e_hash, + e_name, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Add", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="New Hub" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def confirm_new_hub_dialog(self, hub_hash, dest_name, room): + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + hub = self.app.rrc.add_hub(hub_hash, dest_name=dest_name) + self.close_dialog() + self.update_list() + if room: + self._select_room(None, (hub, room)) + else: + self._select_hub(None, hub) + except Exception as e: + error_text.set_text(("error_text", "Could not add hub: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text(" A page is requesting to open an RRC hub."), + urwid.Text(""), + urwid.Text(" Address : "+hub_hash.hex()), + urwid.Text(" Aspect : "+(dest_name or "rrc.hub")), + urwid.Text(" Room : "+("#"+room if room else "(none)")), + urwid.Text(""), + urwid.AttrMap(urwid.Text( + " Opening will add this hub to your client,"), "list_unknown"), + urwid.AttrMap(urwid.Text( + " and reveal your identity hash to the hub"), "list_unknown"), + urwid.AttrMap(urwid.Text( + " to the hub operator."), "list_unknown"), + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Open", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]) + ]), title="Open RRC hub?" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def edit_hub_dialog(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + hub = item.hub + + e_name = ReadlineEdit(caption="Display name : ", edit_text=hub.name or "") + cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect) + cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list) + cb_autowho = urwid.CheckBox("Auto-fetch members on room join", state=hub.auto_who) + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + nm = e_name.get_edit_text().strip() or hub.name + hub.name = nm + hub.set_auto_reconnect(cb_autorcn.get_state(), save=False) + hub.set_auto_list(cb_autolist.get_state(), save=False) + hub.set_auto_who(cb_autowho.get_state(), save=False) + self.app.rrc.save() + self.close_dialog() + self.update_list() + if self.selected_key and self.selected_key[0] == "hub" and self.selected_key[1] == hub.hub_hash: + self._show_hub_info(hub) + except Exception as e: + error_text.set_text(("error_text", "Could not save: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(" Address : "+hub.hub_hash.hex()), + urwid.Text(" Server : "+(hub.hub_name or "(unknown until connected)")), + urwid.Divider(self.app.ui.glyphs["divider1"]), + e_name, + urwid.Text(""), + cb_autorcn, + cb_autolist, + cb_autowho, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="Edit Hub" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def join_room_dialog(self): + item = self._selected_row() + hub = None + if item is not None and hasattr(item, "hub"): + hub = item.hub + if hub is None: + if self.app.rrc.hubs: + hub = self.app.rrc.hubs[0] + else: + return + + e_room = ReadlineEdit(caption="Room : #", edit_text="") + e_key = ReadlineEdit(caption="Key : ", edit_text="", mask="*") + error_text = urwid.Text("") + + key_section_placeholder = urwid.WidgetPlaceholder(urwid.Text("")) + + def update_key_visibility(checkbox, state): + if state: + key_section_placeholder.original_widget = e_key + else: + key_section_placeholder.original_widget = urwid.Text("") + + cb_key = urwid.CheckBox("Keyed room (+k)", state=False, on_state_change=update_key_visibility) + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + room = e_room.get_edit_text().strip() + if not room: + raise ValueError("Room name is required") + key = e_key.get_edit_text().strip() if cb_key.get_state() else None + key = key or None + hub.add_room(room) + if hub.status == RRCHub.STATUS_CONNECTED: + hub.join_room(room, key=key) + self.close_dialog() + self.update_list() + self._select_room(None, (hub, room.lower())) + except Exception as e: + error_text.set_text(("error_text", "Could not join: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(" Hub : "+hub.name), + e_room, + cb_key, + key_section_placeholder, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Join", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="Add Room" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def show_user_info(self, sender, payload): + try: + hub, peer_hash, display_name = payload + except Exception: + return + if not isinstance(peer_hash, (bytes, bytearray)): + return + + peer_hash = bytes(peer_hash) + identity_hex = RNS.hexrep(peer_hash, delimit=False) + own_hash = self.app.identity.hash if self.app.identity is not None else None + is_self = (own_hash is not None and peer_hash == own_hash) + + lxmf_hex = None + try: + peer_identity = RNS.Identity.recall(peer_hash, from_identity_hash=True) + if peer_identity is not None: + lxmf_dest = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", peer_identity) + lxmf_hex = RNS.hexrep(lxmf_dest, delimit=False) + except Exception: + pass + + def on_close(_b): + self.close_dialog() + + def on_open(_b): + self.close_dialog() + if lxmf_hex is None: + return + try: + _ChatLinkDelegate(self, hub)._open_lxmf(lxmf_hex) + except Exception as e: + RNS.log("Could not open conversation: "+str(e), RNS.LOG_ERROR) + + safe_name = "" + try: + if display_name: + if self.app.config["textui"]["sanitize_names"]: + safe_name = sanitize_name(str(display_name)) or "" + else: + safe_name = strip_modifiers(str(display_name)) or "" + except Exception: + safe_name = "" + + lines = [ + urwid.Text(""), + urwid.Text(" Nick : "+safe_name), + urwid.Text(" Identity : "+identity_hex), + ] + if lxmf_hex: + lines.append(urwid.Text(" LXMF : "+lxmf_hex)) + + if is_self: + lines.append(urwid.Text("")) + lines.append(urwid.Text(" (This is you)", align=urwid.CENTER)) + lines.append(urwid.Text("")) + lines.append(urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Button("Close", on_press=on_close)), + ])) + else: + if lxmf_hex is None: + lines.append(urwid.Text("")) + lines.append(urwid.Text(" Identity not in local cache;", align=urwid.CENTER)) + lines.append(urwid.Text(" conversation can't be opened until", align=urwid.CENTER)) + lines.append(urwid.Text(" the peer announces.", align=urwid.CENTER)) + lines.append(urwid.Text("")) + lines.append(urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Button("Close", on_press=on_close)), + ])) + else: + lines.append(urwid.Text("")) + lines.append(urwid.Columns([ + (urwid.WEIGHT, 0.55, urwid.Button("Open Conversation", on_press=on_open)), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.40, urwid.Button("Close", on_press=on_close)), + ])) + + dialog = ChannelsDialogLineBox(urwid.Pile(lines), title="User Info") + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def _show_dialog_overlay(self, dialog): + self.dialog_open = True + overlay = urwid.Overlay( + dialog, + self.columns_widget, + align=urwid.CENTER, + width=(urwid.RELATIVE, 60), + min_width=40, + valign=urwid.MIDDLE, + height=urwid.PACK, + ) + self.widget.original_widget = overlay + + def close_dialog(self): + self.dialog_open = False + self.widget.original_widget = self.columns_widget + + def _process_pending(self, data): + while True: + try: + action = self._pending_actions.popleft() + except IndexError: + break + try: + action() + except Exception as e: + RNS.log("RRC UI action failed: "+str(e), RNS.LOG_ERROR) + return True + + def _wake(self, action): + self._pending_actions.append(action) + if self._wake_fd is not None: + try: + os.write(self._wake_fd, b".") + return + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None)) + except Exception: + pass + + def _on_rrc_change(self, hub): + def action(): + self.update_list() + if (self.current_room_widget is not None + and self.current_room_widget.hub is hub): + try: + self.current_room_widget._refresh_users_pane() + except Exception: + pass + elif (self.selected_key + and self.selected_key[0] == "hub" + and self.selected_key[1] == hub.hub_hash + and self.selected_key[2] == hub.dest_name): + try: + self._show_hub_info(hub) + except Exception: + pass + self._wake(action) + + def _on_rrc_message(self, hub, msg): + def action(): + is_active = (self.current_room_widget is not None + and self.current_room_widget.hub is hub + and self.current_room_widget.room == msg.room) + if getattr(msg, "mention", False) and not is_active: + self._ring_mention_bell(hub, msg.room) + if is_active: + self.current_room_widget.append_message(msg) + self.update_list() + self._wake(action) + + def _ring_mention_bell(self, hub, room): + key = (hub.hub_hash, room or "") + now = time.monotonic() + last = self._mention_bell_last.get(key, 0.0) + if now - last < 5.0: + return + self._mention_bell_last[key] = now + try: + import sys + sys.stdout.write("\x07") + sys.stdout.flush() + except Exception: + pass diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py new file mode 100644 index 0000000..36d92a5 --- /dev/null +++ b/nomadnet/ui/textui/Config.py @@ -0,0 +1,89 @@ +import nomadnet +import urwid +import platform + +class ConfigDisplayShortcuts(): + def __init__(self, app): + import urwid + self.app = app + + self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") + +class ConfigFiller(urwid.WidgetWrap): + def __init__(self, widget, app): + self.app = app + self.filler = urwid.Filler(widget, urwid.TOP) + super().__init__(self.filler) + + + def keypress(self, size, key): + if key == "up": + self.app.ui.main_display.frame.focus_position = "header" + + return super(ConfigFiller, self).keypress(size, key) + +class ConfigDisplay(): + def __init__(self, app): + import urwid + self.app = app + + def open_editor(sender): + self.editor_term = EditorTerminal(self.app, self) + self.widget = urwid.LineBox(self.editor_term) + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.editor_term.term.change_focus(True) + + pile = urwid.Pile([ + urwid.Text( + ( + "body_text", + "\nTo change the configuration, edit the config file located at:\n\n" + +self.app.configpath + +"\n\nRestart Nomad Network for changes to take effect\n", + ), + align=urwid.CENTER, + ), + urwid.Padding(urwid.Button("Open Editor", on_press=open_editor), width=15, align=urwid.CENTER), + ]) + + self.config_explainer = ConfigFiller(pile, self.app) + self.shortcuts_display = ConfigDisplayShortcuts(self.app) + self.widget = self.config_explainer + + def shortcuts(self): + return self.shortcuts_display + +class EditorTerminal(urwid.WidgetWrap): + def __init__(self, app, parent): + self.app = app + self.parent = parent + editor_cmd = self.app.config["textui"]["editor"] + + # The "editor" alias is unavailable on Darwin, + # so we replace it with nano. + if platform.system() == "Darwin" and editor_cmd == "editor": + editor_cmd = "nano" + + self.term = urwid.Terminal( + (editor_cmd, self.app.configpath), + encoding='utf-8', + main_loop=self.app.ui.loop, + ) + + def quit_term(*args, **kwargs): + self.parent.widget = self.parent.config_explainer + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.show_config(None) + self.app.ui.main_display.request_redraw() + + urwid.connect_signal(self.term, 'closed', quit_term) + + super().__init__(self.term) + + + def keypress(self, size, key): + # TODO: Decide whether there should be a way to get out while editing + #if key == "up": + # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return super(EditorTerminal, self).keypress(size, key) \ No newline at end of file diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py new file mode 100644 index 0000000..f022206 --- /dev/null +++ b/nomadnet/ui/textui/Conversations.py @@ -0,0 +1,3093 @@ +import RNS +import RNS.vendor.umsgpack as msgpack +import collections +import os +import shutil +import time +import nomadnet +import LXMF + +import urwid + +from datetime import datetime, timedelta +from nomadnet.Directory import DirectoryEntry +from LXMF import pn_announce_data_is_valid, PN_META_NAME +from nomadnet.Conversation import ConversationMessage + +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name + +from RNS.Utilities.rngit.util import MarkdownToMicron +from RNS.Utilities.rngit.highlight import SyntaxHighlighter +from .MicronParser import markup_to_attrmaps +from .Helpers import ClickableIcon, osc52_copy +from .ReadlineEdit import ReadlineMixin, ReadlineEdit +from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags +from nomadnet.ui import THEME_DARK, THEME_LIGHT + +def relative_time(timestamp): + now = time.time() + delta = now - timestamp + if delta < 0: + return "just now" + elif delta < 60: + return "just now" + elif delta < 3600: + m = int(delta / 60) + return str(m)+"m ago" + elif delta < 86400: + h = int(delta / 3600) + return str(h)+"h ago" + elif delta < 172800: + return "yesterday" + elif delta < 604800: + d = int(delta / 86400) + return str(d)+"d ago" + elif delta < 2592000: + w = int(delta / 604800) + return str(w)+"w ago" + else: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d") + + +def _format_size(size): + if size < 1024: + return str(size)+" B" + elif size < 1048576: + return str(round(size/1024, 1))+" KB" + else: + return str(round(size/1048576, 1))+" MB" + + +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox + +class ConversationListDisplayShortcuts(): + def __init__(self, app): + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-o] Sort [C-p] My LXMF [C-g] Fullscreen"), "shortcutbar") + +class ConversationDisplayShortcuts(): + def __init__(self, app): + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-s] Save [Tab] ↑ Messages"), "shortcutbar") + +class ConversationBodyShortcuts(): + def __init__(self, app): + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("[C-s] Save [C-u] Purge [C-o] Sort [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar") + +class TabButton(urwid.Button): + button_left = urwid.Text("[") + button_right = urwid.Text("]") + + +class ConversationsArea(urwid.LineBox): + def keypress(self, size, key): + if key == "ctrl e": + self.delegate.edit_selected_in_directory() + elif key == "ctrl x": + self.delegate.delete_selected_conversation() + elif key == "ctrl n": + self.delegate.new_conversation() + elif key == "ctrl u": + self.delegate.ingest_lxm_uri() + elif key == "ctrl r": + self.delegate.sync_conversations() + elif key == "ctrl g": + self.delegate.toggle_fullscreen() + elif key == "ctrl o": + self.delegate.toggle_list_sort() + elif key == "ctrl p": + self.delegate.show_my_qr() + elif key == "tab": + self.delegate.app.ui.main_display.frame.focus_position = "header" + elif key == "up": + if self.delegate.ilb.body_is_empty(): + self.delegate.app.ui.main_display.frame.focus_position = "header" + return None + result = super(ConversationsArea, self).keypress(size, key) + if result == "up": + self.delegate.app.ui.main_display.frame.focus_position = "header" + return None + return result + else: + return super(ConversationsArea, self).keypress(size, key) + +class PropNodePicker(urwid.WidgetWrap): + def __init__(self, options, current_hash, on_change): + self._options = list(options) + self._current = current_hash + self._on_change = on_change + self._pile = urwid.Pile([]) + super().__init__(self._pile) + self._show_collapsed() + + def _label_for(self, h): + for ph, label in self._options: + if ph == h: + return label + if h is not None: + return "<"+RNS.hexrep(h, delimit=False)+">" + return "(select propagation node)" + + def _show_collapsed(self): + btn = urwid.Button("▾ "+self._label_for(self._current)) + urwid.connect_signal(btn, "click", self._on_expand_click) + self._pile.contents = [ + (urwid.AttrMap(btn, None, focus_map="list_focus"), self._pile.options()), + ] + + def _on_expand_click(self, _btn): + self._show_expanded() + + def _on_back_click(self, _btn): + self._show_collapsed() + + def _make_row_click(self, picked_hash): + def _click(_btn): + try: + self._current = picked_hash + self._on_change(picked_hash) + except Exception as e: + RNS.log("Propagation node change handler failed: "+str(e), RNS.LOG_ERROR) + self._show_collapsed() + return _click + + def _show_expanded(self): + rows = [] + for ph, label in self._options: + marker = "● " if ph == self._current else "○ " + row = urwid.Button(marker+label) + urwid.connect_signal(row, "click", self._make_row_click(ph)) + rows.append(urwid.AttrMap(row, None, focus_map="list_focus")) + + if not rows: + rows.append(urwid.Text(" (no propagation nodes seen yet)")) + + list_height = min(10, max(3, len(rows))) + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(rows)) + boxed = urwid.BoxAdapter(listbox, list_height) + + back = urwid.Button("◀ Back") + urwid.connect_signal(back, "click", self._on_back_click) + + self._pile.contents = [ + (urwid.AttrMap(back, None, focus_map="list_focus"), self._pile.options()), + (boxed, self._pile.options()), + ] + + +class DialogLineBox(urwid.LineBox): + def keypress(self, size, key): + if key == "esc": + if hasattr(self.delegate, "update_conversation_list"): + self.delegate.update_conversation_list() + elif hasattr(self.delegate, "dialog_active"): + self.delegate.dialog_active = False + self.delegate.conversation_changed(None) + else: + return super(DialogLineBox, self).keypress(size, key) + +class ConversationsDisplay(): + list_width = 0.33 + given_list_width = 52 + cached_conversation_widgets = {} + + SORT_RECENT = 0 + SORT_NAME = 1 + + LIST_FILTER_TRUSTED = "trusted" + LIST_FILTER_UNTRUSTED = "untrusted" + + def __init__(self, app): + self.app = app + self.dialog_open = False + self.sync_dialog = None + self.currently_displayed_conversation = None + self.list_sort_mode = ConversationsDisplay.SORT_RECENT + self.list_filter = ConversationsDisplay.LIST_FILTER_TRUSTED + self.show_blocked = False + + def disp_list_shortcuts(sender, arg1, arg2): + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + self._build_persistent_listbox() + self.update_listbox() + + self.columns_widget = urwid.Columns( + [ + # (urwid.WEIGHT, ConversationsDisplay.list_width, self.listbox), + # (urwid.WEIGHT, 1-ConversationsDisplay.list_width, self.make_conversation_widget(None)) + (ConversationsDisplay.given_list_width, self.listbox), + (urwid.WEIGHT, 1, self.make_conversation_widget(None)) + ], + dividechars=0, focus_column=0, box_columns=[0] + ) + + self.list_shortcuts = ConversationListDisplayShortcuts(self.app) + self.editor_shortcuts = ConversationDisplayShortcuts(self.app) + self.body_shortcuts = ConversationBodyShortcuts(self.app) + + self.shortcuts_display = self.list_shortcuts + self.widget = urwid.WidgetPlaceholder(self.columns_widget) + + self._pending_actions = collections.deque() + self._wake_fd = None + try: + self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending) + except Exception: + pass + + nomadnet.Conversation.created_callback = lambda: self._wake(self.update_conversation_list) + + try: + self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) + except Exception: + pass + + def _process_pending(self, data): + while True: + try: + action = self._pending_actions.popleft() + except IndexError: + break + try: + action() + except Exception as e: + RNS.log("Conversations UI action failed: "+str(e), RNS.LOG_ERROR) + return True + + def _wake(self, action): + self._pending_actions.append(action) + if self._wake_fd is not None: + try: + os.write(self._wake_fd, b".") + return + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None)) + except Exception: + pass + + def focus_change_event(self): + if not self.dialog_open: + self.update_conversation_list() + + def toggle_list_sort(self): + if self.list_sort_mode == ConversationsDisplay.SORT_RECENT: + self.list_sort_mode = ConversationsDisplay.SORT_NAME + else: + self.list_sort_mode = ConversationsDisplay.SORT_RECENT + self.update_conversation_list() + + def _conversation_filter_predicate(self, conversation): + try: + trust_level = conversation[2] + except Exception: + return False + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED: + return trust_level in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) + return trust_level == DirectoryEntry.TRUSTED + + def _set_filter(self, key): + if self.list_filter == key: + return + self.list_filter = key + try: + self.update_conversation_list() + except Exception as e: + RNS.log("Failed to apply conversation filter: "+str(e), RNS.LOG_ERROR) + + def _on_show_blocked_change(self, _cb, new_state): + self.show_blocked = new_state + try: + self.update_conversation_list() + except Exception as e: + RNS.log("Failed to toggle show-blocked: "+str(e), RNS.LOG_ERROR) + + def _apply_pile_layout(self): + pack_opts = self.pile.options('pack') + weight_opts = self.pile.options('weight', 1) + items = [(self.tab_bar, pack_opts)] + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED: + items.append((self.show_blocked_checkbox, pack_opts)) + items.append((self.ilb, weight_opts)) + items.append((self.sync_status_text, pack_opts)) + try: + prev_focus = self.pile.focus_position + except Exception: + prev_focus = None + self.pile.contents = items + try: + if prev_focus is not None and prev_focus < len(items): + self.pile.focus_position = prev_focus + except Exception: + pass + + def _blocked_row_widget(self, dest_hash): + g = self.app.ui.glyphs + entry = self.app.directory.find(dest_hash) + display_name = None + if entry is not None and getattr(entry, "display_name", None): + display_name = strip_modifiers(entry.display_name) + if not display_name: + display_name = RNS.prettyhexrep(dest_hash) + label = " "+g["cross"]+" [blocked] "+display_name+" <"+RNS.hexrep(dest_hash, delimit=False)+">" + widget = ListEntry(label) + urwid.connect_signal(widget, "click", self._unblock_dialog, dest_hash) + attr = urwid.AttrMap(widget, "list_untrusted", "list_focus_untrusted") + attr.blocked_dest_hash = dest_hash + return attr + + def _unblock_dialog(self, _sender, dest_hash): + self.dialog_open = True + + def dismiss(_b): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(_b): + self.dialog_open = False + try: + self.app.unblock_destination(dest_hash) + except Exception as e: + RNS.log("Unblock failed: "+str(e), RNS.LOG_ERROR) + self.update_conversation_list() + + try: + who = self.app.directory.simplest_display_str(dest_hash) + except Exception: + who = RNS.hexrep(dest_hash, delimit=False) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Unblock "+str(who)+"?\n\nThis lifts the RNS blackhole on the peer's identity\nand removes them from your ignored list.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, unblock", on_press=confirmed)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]), + ]), title="Confirm unblock" + ) + dialog.delegate = self + + bottom = self.listbox + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.columns_widget.contents[0] = ( + overlay, + self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.columns_widget.focus_position = 0 + except Exception: + pass + + def _build_persistent_listbox(self): + self.tab_trusted = TabButton("Trusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED)) + self.tab_untrusted = TabButton("Untrusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED)) + + self.tab_bar = urwid.Columns([ + ('weight', 1, self.tab_trusted), + ('weight', 1, self.tab_untrusted), + ], dividechars=1) + + self.show_blocked_checkbox = urwid.CheckBox("Show blocked (0)", state=self.show_blocked) + urwid.connect_signal(self.show_blocked_checkbox, "change", self._on_show_blocked_change) + + self.ilb = IndicativeListBox( + [urwid.Text("")], + on_selection_change=self.conversation_list_selection, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + + self.sync_status_text = urwid.AttrMap(urwid.Text(self._sync_status_line(), align=urwid.LEFT), "shortcutbar") + + self.pile = urwid.Pile([ + ('pack', self.tab_bar), + ('weight', 1, self.ilb), + ('pack', self.sync_status_text), + ]) + try: self.pile.focus_position = 1 + except Exception: pass + + self.listbox = ConversationsArea(self.pile, title="Conversations") + self.listbox.delegate = self + + def update_listbox(self): + if not hasattr(self, "pile"): + self._build_persistent_listbox() + + try: + conversations = self.app.conversations() + except Exception as e: + RNS.log("Failed to enumerate conversations: "+str(e), RNS.LOG_ERROR) + conversations = [] + + try: + if self.list_sort_mode == ConversationsDisplay.SORT_NAME: + conversations.sort(key=lambda e: (e[3].lower(), e[0])) + except Exception: + pass + + def _is_pinned(c): + try: + entry = self.app.directory.find(bytes.fromhex(c[0])) + return entry is not None and entry.sort_rank is not None + except Exception: + return False + try: + conversations = sorted(conversations, key=lambda c: 0 if _is_pinned(c) else 1) + except Exception: + pass + + glyphs = self.app.ui.glyphs + def _alerts(c): + return bool(c[4]) or (len(c) > 6 and bool(c[6])) + trusted_count = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED) + untrusted_count = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN)) + trusted_unread = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED and _alerts(c)) + untrusted_unread = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) and _alerts(c)) + + def _label(name, total, unread): + if unread: + return f"{name} ({total}) {glyphs['unread']} {unread}" + return f"{name} ({total})" + + self.tab_trusted.set_label(_label("Trusted", trusted_count, trusted_unread)) + self.tab_untrusted.set_label(_label("Untrusted", untrusted_count, untrusted_unread)) + + filtered = [c for c in conversations if self._conversation_filter_predicate(c)] + + conversation_list_widgets = [] + for conversation in filtered: + try: + conversation_list_widgets.append(self.conversation_list_widget(conversation)) + except Exception as e: + try: hh = conversation[0] + except Exception: hh = "?" + RNS.log("Skipping conversation row for "+str(hh)+": "+str(e), RNS.LOG_ERROR) + + blocked_count = len(self.app.ignored_list) if hasattr(self.app, "ignored_list") else 0 + try: + self.show_blocked_checkbox.set_label(f"Show blocked ({blocked_count})") + except Exception: + pass + + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED and self.show_blocked: + for blocked_hash in list(self.app.ignored_list): + try: + conversation_list_widgets.append(self._blocked_row_widget(blocked_hash)) + except Exception as e: + RNS.log("Skipping blocked row: "+str(e), RNS.LOG_ERROR) + + empty_placeholder = False + if not conversation_list_widgets: + empty_label = { + ConversationsDisplay.LIST_FILTER_TRUSTED: "No trusted conversations", + ConversationsDisplay.LIST_FILTER_UNTRUSTED: "No untrusted conversations", + }.get(self.list_filter, "No conversations") + conversation_list_widgets = [urwid.Text(empty_label, align='center')] + empty_placeholder = True + + self.list_widgets = conversation_list_widgets + + try: + self.ilb.set_body(conversation_list_widgets) + except Exception as e: + RNS.log("Failed to populate conversation list: "+str(e), RNS.LOG_ERROR) + + self._apply_pile_layout() + + if empty_placeholder: + try: self.pile.focus_position = 0 + except Exception: pass + + try: + self.sync_status_text.original_widget.set_text(self._sync_status_line()) + except Exception: + pass + + def _sync_status_line(self): + try: + last = self.app.peer_settings.get("last_lxmf_sync") + except Exception: + last = None + if not last: + when = "never" + else: + try: + when = relative_time(float(last)) + except Exception: + when = "unknown" + + node_label = None + try: + pn_hash = self.app.get_default_propagation_node() + if pn_hash is not None: + pn_ident = RNS.Identity.recall(pn_hash) + if pn_ident is not None: + node_dest = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) + entry = self.app.directory.find(node_dest) + if entry is not None and getattr(entry, "display_name", None): + node_label = strip_modifiers(str(entry.display_name)) or None + if node_label is None: + node_label = "<"+RNS.hexrep(pn_hash, delimit=False)[:8]+"…>" + except Exception: + node_label = None + + line = " Last sync: "+when + if node_label: + line += " ("+node_label+")" + return line + + def _refresh_sync_status(self, _loop=None, _data=None): + try: + if hasattr(self, "sync_status_text") and self.sync_status_text is not None: + self.sync_status_text.original_widget.set_text(self._sync_status_line()) + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) + except Exception: + pass + + def delete_selected_conversation(self): + self.dialog_open = True + item = self.ilb.get_selected_item() + if item == None: + return + source_hash = item.source_hash + + def dismiss_dialog(sender): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(sender): + self.dialog_open = False + self.delete_conversation(source_hash) + nomadnet.Conversation.delete_conversation(source_hash, self.app) + self.update_conversation_list() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text( + "Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) + ]), title="?" + ) + dialog.delegate = self + bottom = self.listbox + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + def _refresh_open_conversation_widget(self, source_hash_text): + widget = ConversationsDisplay.cached_conversation_widgets.get(source_hash_text) + if widget is None: + return + try: + widget._trust_banner_dismissed = False + except Exception: + pass + try: + widget._refresh_trust_banner() + except Exception: + pass + try: + widget._update_peer_info() + except Exception: + pass + try: + widget.update_message_widgets(replace=True) + except Exception: + pass + + def show_my_qr(self): + try: + addr = RNS.hexrep(self.app.lxmf_destination.hash, delimit=False) + except Exception: + return + try: + display = self.app.peer_settings.get("display_name") or "My LXMF" + except Exception: + display = "My LXMF" + self.show_qr_dialog(addr, title=display) + + def show_qr_dialog(self, data, title=None): + qr_text = None + try: + import qrcode + try: + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) + qr.add_data(data) + qr.make() + import io + buf = io.StringIO() + qr.print_ascii(out=buf, invert=False) + qr_text = buf.getvalue().rstrip("\n") + except Exception as e: + RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) + qr_text = None + except Exception: + qr_text = None + + def dismiss(_b): + self._restore_listbox_pane() + + rows = [urwid.Text("")] + if qr_text is not None: + rows.append(urwid.Text(qr_text, align=urwid.CENTER)) + rows.append(urwid.Text("")) + else: + rows.append(urwid.Text("LXMF destination address:", align=urwid.CENTER)) + rows.append(urwid.Text("")) + rows += [ + urwid.Text("< "+data+" >", align=urwid.CENTER), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Text("")), + (12, urwid.Button("Close", on_press=dismiss)), + (urwid.WEIGHT, 1, urwid.Text("")), + ]), + urwid.Text(""), + ] + dialog_title = "LXMF Address" if qr_text is None else "QR Code" + dialog = DialogLineBox(urwid.Pile(rows), title=dialog_title) + dialog.delegate = self + self._overlay_dialog(dialog) + + def _overlay_dialog(self, dialog): + overlay = urwid.Overlay( + dialog, self.columns_widget, + align=urwid.CENTER, width=(urwid.RELATIVE, 70), + valign=urwid.MIDDLE, height=urwid.PACK, + min_width=44, + ) + try: + self.widget.original_widget = overlay + self.dialog_open = True + except Exception: + pass + + def _restore_listbox_pane(self): + try: + self.widget.original_widget = self.columns_widget + self.dialog_open = False + self.update_conversation_list() + except Exception: + pass + + def _ping_peer_from_dialog(self, source_hash_text, status_widget, ping_button): + try: + dest = bytes.fromhex(source_hash_text) + except Exception: + status_widget.set_text(("error_text", "Invalid address")) + return + + identity = RNS.Identity.recall(dest) + if identity is None: + status_widget.set_text(("error_text", "Identity unknown; query first")) + return + + if not RNS.Transport.has_path(dest): + status_widget.set_text("No path; requesting…") + try: RNS.Transport.request_path(dest) + except Exception: pass + + status_widget.set_text("Pinging…") + ping_button.set_label("Pinging…") + started_at = time.time() + + def schedule_ui(fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *_: fn()) + except Exception: + try: fn() + except Exception: pass + + def on_established(link): + elapsed_ms = int((time.time() - started_at) * 1000) + try: + hops = RNS.Transport.hops_to(dest) + if hops is None or hops >= RNS.Transport.PATHFINDER_M: + hops_str = "" + else: + hops_str = f" ({hops} hop{'s' if hops != 1 else ''})" + except Exception: + hops_str = "" + def update(): + status_widget.set_text(f"Pong in {elapsed_ms} ms{hops_str}") + ping_button.set_label("Ping") + schedule_ui(update) + try: link.teardown() + except Exception: pass + + def on_closed(link): + try: + if getattr(link, "status", None) == RNS.Link.ACTIVE: + return + except Exception: + pass + def update(): + if status_widget.text.strip() in ("Pinging…", ""): + status_widget.set_text(("error_text", "Ping failed (no link)")) + ping_button.set_label("Ping") + schedule_ui(update) + + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + RNS.Link(destination, established_callback=on_established, closed_callback=on_closed) + except Exception as e: + status_widget.set_text(("error_text", f"Ping init failed: {e}")) + ping_button.set_label("Ping") + + def _block_peer_from_dialog(self, source_hash_text): + try: + dest = bytes.fromhex(source_hash_text) + except Exception: + return + + def cancel_block(_b): + self.dialog_open = False + self.update_conversation_list() + + def confirm_block(_b): + try: + self.app.block_destination(dest, reason="user-blocked from peer info dialog") + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + try: + self.delete_conversation(source_hash_text) + nomadnet.Conversation.delete_conversation(source_hash_text, self.app) + except Exception: + pass + self.dialog_open = False + self.update_conversation_list() + + try: + who = self.app.directory.simplest_display_str(dest) + except Exception: + who = source_hash_text + + confirm_dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Block "+str(who)+"?\n\nThis blackholes the peer's identity in Reticulum,\nadds them to your ignored list, and deletes any\nconversation with them.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirm_block)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel_block)), + ]), + ]), title="Confirm block" + ) + confirm_dialog.delegate = self + bottom = self.listbox + overlay = urwid.Overlay(confirm_dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.columns_widget.contents[0] = ( + overlay, + self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.columns_widget.focus_position = 0 + self.dialog_open = True + except Exception: + pass + + def edit_selected_in_directory(self): + g = self.app.ui.glyphs + self.dialog_open = True + item = self.ilb.get_selected_item() + if item == None: + self.dialog_open = False + return + source_hash_text = getattr(item, "source_hash", None) + if source_hash_text is None: + blocked = getattr(item, "blocked_dest_hash", None) + if isinstance(blocked, (bytes, bytearray)): + source_hash_text = RNS.hexrep(blocked, delimit=False) + if source_hash_text is None: + self.dialog_open = False + return + display_name = getattr(item, "display_name", None) + if display_name is None: + try: + display_name = self.app.directory.display_name(bytes.fromhex(source_hash_text)) + except Exception: + display_name = None + if display_name is None: + display_name = "" + + e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash_text) + t_id = urwid.Text("Addr : "+source_hash_text) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_name) + e_copy = ReadlineEdit(caption="Copy : ", edit_text=source_hash_text) + + selected_id_widget = t_id + + untrusted_selected = False + unknown_selected = True + trusted_selected = False + + direct_selected = True + propagated_selected = False + + pinned_initial = False + notes_initial = "" + + try: + existing_entry = self.app.directory.find(bytes.fromhex(source_hash_text)) + if existing_entry: + trust_level = self.app.directory.trust_level(bytes.fromhex(source_hash_text)) + if trust_level == DirectoryEntry.UNTRUSTED: + untrusted_selected = True + unknown_selected = False + trusted_selected = False + elif trust_level == DirectoryEntry.UNKNOWN: + untrusted_selected = False + unknown_selected = True + trusted_selected = False + elif trust_level == DirectoryEntry.TRUSTED: + untrusted_selected = False + unknown_selected = False + trusted_selected = True + + if self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) == DirectoryEntry.PROPAGATED: + direct_selected = False + propagated_selected = True + + pinned_initial = existing_entry.sort_rank is not None + notes_initial = getattr(existing_entry, "notes", "") or "" + + except Exception as e: + pass + + e_notes = ReadlineEdit(caption="Notes: ", edit_text=notes_initial, multiline=True) + cb_pin = urwid.CheckBox("Pin to top", state=pinned_initial) + + trust_button_group = [] + r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted", state=untrusted_selected) + r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected) + r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) + + method_button_group = [] + r_direct = urwid.RadioButton(method_button_group, "Deliver directly", state=direct_selected) + r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected) + + def dismiss_dialog(sender): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(sender): + try: + display_name = e_name.get_edit_text() + source_hash = bytes.fromhex(e_id.get_edit_text()) + trust_level = DirectoryEntry.UNTRUSTED + if r_unknown.state == True: + trust_level = DirectoryEntry.UNKNOWN + elif r_trusted.state == True: + trust_level = DirectoryEntry.TRUSTED + + delivery = DirectoryEntry.DIRECT + if r_propagated.state == True: + delivery = DirectoryEntry.PROPAGATED + + sort_rank = 0 if cb_pin.state else None + notes_value = e_notes.get_edit_text() + entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery, sort_rank=sort_rank, notes=notes_value) + self.app.directory.remember(entry) + self._refresh_open_conversation_widget(source_hash_text) + self.dialog_open = False + self.update_conversation_list() + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + except Exception as e: + RNS.log("Could not save directory entry. The contained exception was: "+str(e), RNS.LOG_VERBOSE) + if not dialog_pile.error_display: + dialog_pile.error_display = True + options = dialog_pile.options(height_type=urwid.PACK) + dialog_pile.contents.append((urwid.Text(""), options)) + dialog_pile.contents.append(( + urwid.Text(("error_text", "Could not save entry. Check your input."), align=urwid.CENTER), + options,) + ) + + source_is_known = self.app.directory.is_known(bytes.fromhex(source_hash_text)) + if source_is_known: + known_section = urwid.Divider(g["divider1"]) + else: + def query_action(sender, user_data): + self.close_conversation_by_hash(user_data) + nomadnet.Conversation.query_for_peer(user_data) + options = dialog_pile.options(height_type=urwid.PACK) + dialog_pile.contents = [ + (urwid.Text("Query sent"), options), + (urwid.Button("OK", on_press=dismiss_dialog), options) + ] + query_button = urwid.Button("Query network for keys", on_press=query_action, user_data=source_hash_text) + known_section = urwid.Pile([ + urwid.Divider(g["divider1"]), + urwid.Text(g["info"]+"\n", align=urwid.CENTER), + urwid.Text( + "The identity of this peer is not known, and you cannot currently send messages to it. " + "You can query the network to obtain the identity.\n", + align=urwid.CENTER, + ), + query_button, + urwid.Divider(g["divider1"]), + ]) + + action_status = urwid.Text("", align=urwid.CENTER) + ping_button = urwid.Button("Ping") + block_button = urwid.Button("Block") + qr_button = urwid.Button("LXMF") + urwid.connect_signal(ping_button, "click", lambda _b: self._ping_peer_from_dialog(source_hash_text, action_status, ping_button)) + urwid.connect_signal(block_button, "click", lambda _b: self._block_peer_from_dialog(source_hash_text)) + urwid.connect_signal(qr_button, "click", lambda _b: self.show_qr_dialog(source_hash_text, title=display_name or source_hash_text)) + + actions_row = urwid.Columns([ + (urwid.WEIGHT, 0.32, ping_button), + (urwid.WEIGHT, 0.02, urwid.Text("")), + (urwid.WEIGHT, 0.32, block_button), + (urwid.WEIGHT, 0.02, urwid.Text("")), + (urwid.WEIGHT, 0.32, qr_button), + ]) + + dialog_pile = urwid.Pile([ + selected_id_widget, + e_name, + e_copy, + urwid.Divider(g["divider1"]), + r_untrusted, + r_unknown, + r_trusted, + urwid.Divider(g["divider1"]), + r_direct, + r_propagated, + urwid.Divider(g["divider1"]), + cb_pin, + e_notes, + known_section, + actions_row, + action_status, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) + ]) + dialog_pile.error_display = False + + dialog = DialogLineBox(dialog_pile, title="Peer Info") + dialog.delegate = self + bottom = self.listbox + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + def new_conversation(self): + self.dialog_open = True + source_hash = "" + display_name = "" + + e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_name) + + trust_button_group = [] + r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted") + r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=True) + r_trusted = urwid.RadioButton(trust_button_group, "Trusted") + + def dismiss_dialog(sender): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(sender): + try: + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + display_name = e_name.get_edit_text() + source_hash_text = e_id.get_edit_text().strip() + source_hash = bytes.fromhex(source_hash_text) + trust_level = DirectoryEntry.UNTRUSTED + if r_unknown.state == True: + trust_level = DirectoryEntry.UNKNOWN + elif r_trusted.state == True: + trust_level = DirectoryEntry.TRUSTED + + if not source_hash in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(source_hash, display_name, trust_level) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + + self.update_conversation_list() + + if trust_level != DirectoryEntry.TRUSTED: + if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED: + self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED) + self.display_conversation(source_hash_text) + self.dialog_open = False + self.update_conversation_list() + + except Exception as e: + RNS.log("Could not start conversation. The contained exception was: "+str(e), RNS.LOG_VERBOSE) + if not dialog_pile.error_display: + dialog_pile.error_display = True + options = dialog_pile.options(height_type=urwid.PACK) + dialog_pile.contents.append((urwid.Text(""), options)) + dialog_pile.contents.append(( + urwid.Text( + ("error_text", "Could not start conversation. Check your input."), + align=urwid.CENTER, + ), + options, + )) + + dialog_pile = urwid.Pile([ + e_id, + e_name, + urwid.Text(""), + r_untrusted, + r_unknown, + r_trusted, + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) + ]) + dialog_pile.error_display = False + + dialog = DialogLineBox(dialog_pile, title="New Conversation") + dialog.delegate = self + bottom = self.listbox + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + def ingest_lxm_uri(self): + self.dialog_open = True + lxm_uri = "" + e_uri = ReadlineEdit(caption="URI : ",edit_text=lxm_uri) + + def dismiss_dialog(sender): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(sender): + try: + local_delivery_signal = "local_delivery_occurred" + duplicate_signal = "duplicate_lxm" + lxm_uri = e_uri.get_edit_text().strip() + + ingest_result = self.app.message_router.ingest_lxm_uri( + lxm_uri, + signal_local_delivery=local_delivery_signal, + signal_duplicate=duplicate_signal + ) + + if ingest_result == False: + raise ValueError("The URI contained no decodable messages") + + elif ingest_result == local_delivery_signal: + rdialog_pile = urwid.Pile([ + urwid.Text("Message was decoded, decrypted successfully, and added to your conversation list."), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + elif ingest_result == duplicate_signal: + rdialog_pile = urwid.Pile([ + urwid.Text("The decoded message has already been processed by the LXMF Router, and will not be ingested again."), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + else: + if self.app.enable_node: + propagation_text = "The decoded message was not addressed to this LXMF address, but has been added to the propagation node queues, and will be distributed on the propagation network." + else: + propagation_text = "The decoded message was not addressed to this LXMF address, and has been discarded." + + rdialog_pile = urwid.Pile([ + urwid.Text(propagation_text), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]) + rdialog_pile.error_display = False + + rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI") + rdialog.delegate = self + bottom = self.listbox + + roverlay = urwid.Overlay( + rdialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (roverlay, options) + + except Exception as e: + RNS.log("Could not ingest LXM URI. The contained exception was: "+str(e), RNS.LOG_VERBOSE) + if not dialog_pile.error_display: + dialog_pile.error_display = True + options = dialog_pile.options(height_type=urwid.PACK) + dialog_pile.contents.append((urwid.Text(""), options)) + dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URI data. Check your input."), align=urwid.CENTER), options)) + + dialog_pile = urwid.Pile([ + e_uri, + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Ingest", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)), + ]) + ]) + dialog_pile.error_display = False + + dialog = DialogLineBox(dialog_pile, title="Ingest message URI") + dialog.delegate = self + bottom = self.listbox + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + def delete_conversation(self, source_hash): + if source_hash in ConversationsDisplay.cached_conversation_widgets: + conversation = ConversationsDisplay.cached_conversation_widgets[source_hash] + self.close_conversation(conversation) + + def toggle_fullscreen(self): + if ConversationsDisplay.given_list_width != 0: + self.saved_list_width = ConversationsDisplay.given_list_width + ConversationsDisplay.given_list_width = 0 + else: + ConversationsDisplay.given_list_width = self.saved_list_width + + self.update_conversation_list() + + def _decode_pn_app_data(self, app_data): + try: + if not pn_announce_data_is_valid(app_data): + return None + data = msgpack.unpackb(app_data) + enabled = bool(data[2]) + name = None + try: + meta = data[6] + if isinstance(meta, dict) and PN_META_NAME in meta: + raw = meta[PN_META_NAME] + if isinstance(raw, (bytes, bytearray)): + name = raw.decode("utf-8", errors="replace") + except Exception: + name = None + return {"enabled": enabled, "name": name} + except Exception: + return None + + def _pn_dropdown_label(self, pn_hash, meta): + name = (meta or {}).get("name") + if name: + label = sanitize_name(name) or "" + label = " ".join(label.split()) + else: + label = "" + if not label: + label = RNS.prettyhexrep(pn_hash) + max_len = 40 + if len(label) > max_len: + label = label[:max_len-1]+"…" + tail = "<"+RNS.hexrep(pn_hash, delimit=False)+">" + if tail not in label: + label = label+" "+tail + if meta is None: + status = "[?]" + elif meta.get("enabled"): + status = "[E]" + else: + status = "[D]" + return status+" "+label + + def _build_pn_options(self): + options = [] + seen = set() + + try: + pn_announces = list(self.app.directory._pn_announces) + except Exception: + pn_announces = [] + for tup in pn_announces: + if len(tup) < 3: continue + pn_hash = tup[1] + app_data = tup[2] + if pn_hash in seen: continue + seen.add(pn_hash) + meta = self._decode_pn_app_data(app_data) + options.append((pn_hash, self._pn_dropdown_label(pn_hash, meta))) + + for extra in (self.app.get_user_selected_propagation_node(), self.app.get_default_propagation_node()): + if extra is None or extra in seen: + continue + seen.add(extra) + meta = None + try: + cached = RNS.Identity.recall_app_data(extra) + if cached is not None: + meta = self._decode_pn_app_data(cached) + except Exception: + meta = None + options.append((extra, self._pn_dropdown_label(extra, meta))) + + return options + + def sync_conversations(self): + g = self.app.ui.glyphs + self.dialog_open = True + + def dismiss_dialog(sender): + self.dialog_open = False + self.sync_dialog = None + self.update_conversation_list() + if self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: + self.app.cancel_lxmf_sync() + + max_messages_group = [] + r_mall = urwid.RadioButton(max_messages_group, "Download all", state=True) + r_mlim = urwid.RadioButton(max_messages_group, "Limit to", state=False) + ie_lim = urwid.IntEdit("", 5) + rbs = urwid.GridFlow([r_mlim, ie_lim], 12, 1, 0, align=urwid.LEFT) + + def sync_now(sender): + limit = None + if r_mlim.get_state(): + limit = ie_lim.value() + self.app.request_lxmf_sync(limit) + self.update_sync_dialog() + + def cancel_sync(sender): + self.app.cancel_lxmf_sync() + self.update_sync_dialog() + + cancel_button = urwid.Button("Close", on_press=dismiss_dialog) + sync_progress = SyncProgressBar("progress_empty" , "progress_full", current=self.app.get_sync_progress(), done=1.0, satt=None) + + real_sync_button = urwid.Button("Sync Now", on_press=sync_now) + hidden_sync_button = urwid.Button("Cancel Sync", on_press=cancel_sync) + + if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: + sync_button = real_sync_button + else: + sync_button = hidden_sync_button + + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, sync_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, cancel_button), + ]) + real_sync_button.bc = button_columns + + current_default = self.app.get_default_propagation_node() + user_selected = self.app.get_user_selected_propagation_node() + + pn_options = self._build_pn_options() + + selected_target = user_selected if user_selected is not None else current_default + + def on_pn_picked(picked_hash): + try: + self.app.set_user_selected_propagation_node(picked_hash) + except Exception as e: + RNS.log("Could not update propagation node: "+str(e), RNS.LOG_ERROR) + + def show_set_pn_dialog(_sender): + current_pn = self.app.get_user_selected_propagation_node() + current_str = RNS.hexrep(current_pn, delimit=False) if current_pn is not None else "" + pn_edit = ReadlineEdit(caption="Hash : ", edit_text=current_str) + status_text = urwid.Text("", align=urwid.CENTER) + + def reopen_sync(_b=None): + self.sync_conversations() + + def save_pn(_b): + text = pn_edit.get_edit_text().strip().replace(":", "").replace(" ", "") + expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8 + if text == "": + self.app.set_user_selected_propagation_node(None) + else: + try: + node_hash = bytes.fromhex(text) + except ValueError: + status_text.set_text("Invalid hex") + return + if len(node_hash) != expected_len: + status_text.set_text("Must be "+str(expected_len)+" bytes ("+str(expected_len*2)+" hex chars)") + return + self.app.set_user_selected_propagation_node(node_hash) + reopen_sync() + + def clear_pn(_b): + pn_edit.set_edit_text("") + self.app.set_user_selected_propagation_node(None) + reopen_sync() + + inner = DialogLineBox( + urwid.Pile([ + urwid.Text("Enter an LXMF propagation\ndestination hash as hex.", align=urwid.CENTER), + urwid.Divider(), + pn_edit, + urwid.Divider(), + status_text, + urwid.Columns([ + (urwid.WEIGHT, 0.3, urwid.Button("Save", on_press=save_pn)), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, urwid.Button("Clear", on_press=clear_pn)), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, urwid.Button("Close", on_press=reopen_sync)), + ]) + ]), title="Set Propagation Node", + ) + inner.delegate = self + self.sync_dialog = None + overlay = urwid.Overlay( + inner, self.listbox, + align=urwid.CENTER, width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, height=urwid.PACK, + left=2, right=2, + ) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + set_pn_button = urwid.Button("Custom Node...", on_press=show_set_pn_dialog) + + if pn_options: + node_picker = PropNodePicker(pn_options, selected_target, on_pn_picked) + node_selector = urwid.Pile([ + urwid.Text("Propagation node:"), + node_picker, + set_pn_button, + ]) + else: + node_selector = None + + pn_ident = None + if current_default is not None: + pn_ident = RNS.Identity.recall(current_default) + if pn_ident is None: + RNS.log("Propagation node identity is unknown, requesting from network...", RNS.LOG_DEBUG) + RNS.Transport.request_path(current_default) + + if pn_ident is not None or node_selector is not None: + header_str = "" + if pn_ident is not None: + node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) + pn_entry = self.app.directory.find(node_hash) + if pn_entry is not None and getattr(pn_entry, "display_name", None): + header_str = " "+strip_modifiers(str(pn_entry.display_name)) + else: + header_str = " "+RNS.prettyhexrep(current_default) + else: + header_str = " (no default)" + + pile_items = [ + urwid.Text(""+g["node"]+header_str, align=urwid.CENTER), + urwid.Divider(g["divider1"]), + sync_progress, + urwid.Divider(g["divider1"]), + ] + pile_items += [button_columns, urwid.Text(""), r_mall, rbs] + if node_selector is not None: + pile_items += [urwid.Divider(g["divider1"]), node_selector] + + dialog = DialogLineBox(urwid.Pile(pile_items), title="Message Sync") + else: + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Text("" )), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, cancel_button), + ]) + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("No trusted nodes found, cannot sync!\n", align=urwid.CENTER), + urwid.Text( + "To synchronise messages from the network, " + "one or more nodes must be marked as trusted in the Known Nodes list, " + "or a node must manually be selected as the default propagation node. " + "Nomad Network will then automatically sync from the nearest trusted node, " + "or the manually selected one.", + align=urwid.LEFT, + ), + urwid.Text(""), + button_columns + ]), title="Message Sync" + ) + + dialog.delegate = self + dialog.sync_progress = sync_progress + dialog.cancel_button = cancel_button + dialog.real_sync_button = real_sync_button + dialog.hidden_sync_button = hidden_sync_button + dialog.bc = button_columns + + self.sync_dialog = dialog + bottom = self.listbox + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width) + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + self.columns_widget.contents[0] = (overlay, options) + + def update_sync_dialog(self, loop = None, sender = None): + if self.dialog_open and self.sync_dialog != None: + self.sync_dialog.sync_progress.set_completion(self.app.get_sync_progress()) + + if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE: + self.sync_dialog.bc.contents[0] = (self.sync_dialog.real_sync_button, self.sync_dialog.bc.options(urwid.WEIGHT, 0.45)) + else: + self.sync_dialog.bc.contents[0] = (self.sync_dialog.hidden_sync_button, self.sync_dialog.bc.options(urwid.WEIGHT, 0.45)) + + self.app.ui.loop.set_alarm_in(0.2, self.update_sync_dialog) + + + def conversation_list_selection(self, arg1, arg2): + pass + + def update_conversation_list(self): + selected_hash = None + selected_item = self.ilb.get_selected_item() + if selected_item is not None: + if hasattr(selected_item, "source_hash"): + selected_hash = selected_item.source_hash + + self.update_listbox() + options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) + if not self.dialog_open: + self.columns_widget.contents[0] = (self.listbox, options) + elif self.sync_dialog is not None: + bottom = self.listbox + overlay = urwid.Overlay( + self.sync_dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + self.columns_widget.contents[0] = (overlay, options) + # else: another dialog (peer info, new conversation, block confirm, etc.) is + # open as an overlay in contents[0]; leave it alone so an incoming message + # doesn't dismiss it. The underlying listbox is a persistent widget and was + # already refreshed by update_listbox() above. + + if selected_hash is not None: + for idx, widget in enumerate(self.list_widgets): + if widget.source_hash == selected_hash: + self.ilb.select_item(idx) + break + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() + + if self.app.ui.main_display.sub_displays.active_display == self.app.ui.main_display.sub_displays.conversations_display: + if self.currently_displayed_conversation != None: + if self.app.conversation_is_unread(self.currently_displayed_conversation): + self.app.mark_conversation_read(self.currently_displayed_conversation) + try: + if os.path.isfile(self.app.conversationpath + "/" + self.currently_displayed_conversation + "/unread"): + os.unlink(self.app.conversationpath + "/" + self.currently_displayed_conversation + "/unread") + except Exception as e: + raise e + + + + + def display_conversation(self, sender=None, source_hash=None): + if self.currently_displayed_conversation != None: + if self.app.conversation_is_unread(self.currently_displayed_conversation): + self.app.mark_conversation_read(self.currently_displayed_conversation) + + self.currently_displayed_conversation = source_hash + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (self.make_conversation_widget(source_hash), options) + if source_hash == None: + self.columns_widget.focus_position = 0 + else: + if self.app.conversation_is_unread(source_hash): + self.app.mark_conversation_read(source_hash) + self.update_conversation_list() + + self.columns_widget.focus_position = 1 + conversation_position = None + index = 0 + for widget in self.list_widgets: + if widget.source_hash == source_hash: + conversation_position = index + index += 1 + + if conversation_position != None: + self.ilb.select_item(conversation_position) + + + def make_conversation_widget(self, source_hash): + if source_hash in ConversationsDisplay.cached_conversation_widgets: + conversation_widget = ConversationsDisplay.cached_conversation_widgets[source_hash] + if source_hash != None: + conversation_widget.update_message_widgets(replace=True) + + conversation_widget.check_editor_allowed() + return conversation_widget + else: + widget = ConversationWidget(source_hash, delegate=self) + ConversationsDisplay.cached_conversation_widgets[source_hash] = widget + + widget.check_editor_allowed() + return widget + + def close_conversation_by_hash(self, conversation_hash): + if conversation_hash in ConversationsDisplay.cached_conversation_widgets: + ConversationsDisplay.cached_conversation_widgets.pop(conversation_hash) + + if self.currently_displayed_conversation == conversation_hash: + self.display_conversation(sender=None, source_hash=None) + + def close_conversation(self, conversation): + if conversation.source_hash in ConversationsDisplay.cached_conversation_widgets: + ConversationsDisplay.cached_conversation_widgets.pop(conversation.source_hash) + + if self.currently_displayed_conversation == conversation.source_hash: + self.display_conversation(sender=None, source_hash=None) + + + def conversation_list_widget(self, conversation): + trust_level = conversation[2] + display_name = conversation[1] + source_hash = conversation[0] + unread = conversation[4] + last_activity = conversation[5] + failed = conversation[6] if len(conversation) > 6 else 0 + + g = self.app.ui.glyphs + + if trust_level == DirectoryEntry.UNTRUSTED: + symbol = g["cross"] + style = "list_untrusted" + focus_style = "list_focus_untrusted" + elif trust_level == DirectoryEntry.UNKNOWN: + symbol = "?" + style = "list_unknown" + focus_style = "list_focus" + elif trust_level == DirectoryEntry.TRUSTED: + symbol = g["check"] + style = "list_normal" + focus_style = "list_focus" + elif trust_level == DirectoryEntry.WARNING: + symbol = g["warning"] + style = "list_warning" + focus_style = "list_focus" + else: + symbol = g["warning"] + style = "list_untrusted" + focus_style = "list_focus_untrusted" + + is_pinned = False + try: + entry = self.app.directory.find(bytes.fromhex(source_hash)) + is_pinned = entry is not None and entry.sort_rank is not None + except Exception: + is_pinned = False + + head = symbol + if is_pinned: + head = g.get("pin", "*") + " " + head + + if display_name != None and display_name != "": + head += " "+display_name + + if trust_level != DirectoryEntry.TRUSTED: + head += " <"+source_hash+">" + + markup = [head] + if failed and source_hash != self.currently_displayed_conversation: + badge_text = " "+g["warning"]+" ("+str(failed)+")" + # markup.append(("msg_notice_caution", badge_text)) + markup.append(badge_text) + elif unread and source_hash != self.currently_displayed_conversation: + badge_text = " "+g["unread"]+" ("+str(unread)+")" + # Good idea with having the badges here colored, but + # using the bg color for it is a bit much, I think. + # I set fg color attrmap styles, but that messes up + # the bg on list focus. If there's a way to handle + # that, we can re-enable this. + # markup.append(("msg_notice_unread", badge_text)) + markup.append(badge_text) + + if trust_level == DirectoryEntry.TRUSTED and unread and source_hash != self.currently_displayed_conversation: + style = "msg_notice_unread" + + if last_activity > 0: + markup.append("\n "+relative_time(last_activity)) + + widget = ListEntry(markup) + urwid.connect_signal(widget, "click", self.display_conversation, conversation[0]) + display_widget = urwid.AttrMap(widget, style, focus_style) + display_widget.source_hash = source_hash + display_widget.display_name = display_name + + return display_widget + + + def shortcuts(self): + try: + focus_path = self.columns_widget.get_focus_path() + except Exception: + return self.list_shortcuts + if not focus_path or focus_path[0] != 1: + return self.list_shortcuts + try: + cw = self.columns_widget.contents[1][0] + frame = cw.base_widget.frame + if frame is None: + return self.editor_shortcuts + if frame.focus_position == "footer": + return self.editor_shortcuts + return self.body_shortcuts + except Exception: + return self.editor_shortcuts + +class ListEntry(urwid.Text): + _selectable = True + + signals = ["click"] + + def keypress(self, size, key): + """ + Send 'click' signal on 'activate' command. + """ + if self._command_map[key] != urwid.ACTIVATE: + return key + + self._emit('click') + + def mouse_event(self, size, event, button, x, y, focus): + """ + Send 'click' signal on button 1 press. + """ + if button != 1 or not urwid.util.is_mouse_press(event): + return False + + self._emit('click') + return True + +class MessageEdit(ReadlineMixin, urwid.Edit): + def keypress(self, size, key): + if key == "ctrl d": + self.delegate.send_message() + elif key == "ctrl p": + self.delegate.paper_message() + elif key == "ctrl f": + self.delegate.attach_file() + elif key == "ctrl s": + self.delegate.save_focused_attachments() + elif key == "up": + y = self.get_cursor_coords(size)[1] + if y == 0: + if self.delegate.full_editor_active and self.name == "title_editor": + self.delegate.frame.focus_position = "body" + elif not self.delegate.full_editor_active and self.name == "content_editor": + self.delegate.frame.focus_position = "body" + else: + return super(MessageEdit, self).keypress(size, key) + else: + return super(MessageEdit, self).keypress(size, key) + else: + return super(MessageEdit, self).keypress(size, key) + + +class ConversationFrame(urwid.Frame): + @property + def focus_position(self): + return urwid.Frame.focus_position.fget(self) + + @focus_position.setter + def focus_position(self, part): + urwid.Frame.focus_position.fset(self, part) + try: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts() + except Exception: + pass + + def keypress(self, size, key): + if self.focus_position == "header": + result = super(ConversationFrame, self).keypress(size, key) + if result == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + if result == "down": + self.focus_position = "body" + return None + return result + if self.focus_position == "body": + if getattr(self.delegate, "dialog_active", False) or getattr(self.delegate, "dialog_open", False): + return super(ConversationFrame, self).keypress(size, key) + elif key == "up" and self.delegate.messagelist.top_is_visible: + if getattr(self.delegate, "has_visible_trust_banner", lambda: False)(): + try: + self.delegate._header_pile.focus_position = 1 + self.focus_position = "header" + return None + except Exception: + pass + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + elif key == "down" and self.delegate.messagelist.bottom_is_visible: + self.focus_position = "footer" + else: + return super(ConversationFrame, self).keypress(size, key) + else: + return super(ConversationFrame, self).keypress(size, key) + +class ConversationWidget(urwid.WidgetWrap): + def __init__(self, source_hash, delegate): + self.app = nomadnet.NomadNetworkApp.get_shared_instance() + g = self.app.ui.glyphs + self.delegate = delegate + if source_hash == None: + self.frame = None + display_widget = urwid.LineBox(urwid.Filler(urwid.Text("\n No conversation selected"), "top")) + super().__init__(display_widget) + else: + if source_hash in ConversationsDisplay.cached_conversation_widgets: + return ConversationsDisplay.cached_conversation_widgets[source_hash] + else: + self.source_hash = source_hash + self.conversation = nomadnet.Conversation(source_hash, nomadnet.NomadNetworkApp.get_shared_instance()) + self.message_widgets = [] + self.sort_by_timestamp = False + self.pending_attachments = [] + self.dialog_active = False + + self.update_message_widgets() + + self.conversation.register_changed_callback(self._on_conversation_changed_from_callback) + + #title_editor = MessageEdit(caption="\u270E", edit_text="", multiline=False) + title_editor = MessageEdit(caption="", edit_text="", multiline=False) + title_editor.delegate = self + title_editor.name = "title_editor" + + #msg_editor = MessageEdit(caption="\u270E", edit_text="", multiline=True) + msg_editor = MessageEdit(caption="", edit_text="", multiline=True) + msg_editor.delegate = self + msg_editor.name = "content_editor" + + self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") + self._update_peer_info() + + self._trust_banner_dismissed = False + self._header_pile = urwid.Pile([self.peer_info_widget]) + self._refresh_trust_banner() + header = self._header_pile + + self.minimal_editor = urwid.AttrMap(msg_editor, "msg_editor") + self.minimal_editor.name = "minimal_editor" + + title_columns = urwid.Columns([ + (8, urwid.Text("Title")), + urwid.AttrMap(title_editor, "msg_editor"), + ]) + + content_columns = urwid.Columns([ + (8, urwid.Text("Content")), + urwid.AttrMap(msg_editor, "msg_editor") + ]) + + self.full_editor = urwid.Pile([ + title_columns, + content_columns + ]) + self.full_editor.name = "full_editor" + + self.content_editor = msg_editor + self.title_editor = title_editor + self.full_editor_active = False + + self.frame = ConversationFrame( + self.messagelist, + header=header, + footer=self.minimal_editor, + focus_part="footer" + ) + self.frame.delegate = self + + self.display_widget = urwid.LineBox( + self.frame + ) + + super().__init__(self.display_widget) + + def has_visible_trust_banner(self): + if self._trust_banner_dismissed: + return False + try: + tl = self.app.directory.trust_level(bytes.fromhex(self.source_hash)) + except Exception: + tl = DirectoryEntry.UNKNOWN + return tl != DirectoryEntry.TRUSTED + + def _refresh_trust_banner(self): + contents = [(self.peer_info_widget, self._header_pile.options())] + if self.has_visible_trust_banner(): + banner = self._build_trust_banner() + contents.append((banner, self._header_pile.options())) + self._header_pile.contents = contents + if len(contents) > 1: + try: self._header_pile.focus_position = 1 + except Exception: pass + + def _build_trust_banner(self): + g = self.app.ui.glyphs + msg = urwid.Text(" "+g["warning"]+" This peer isn't trusted yet.") + btn_trust = urwid.Button("Trust", on_press=self._on_trust_click) + btn_block = urwid.Button("Block", on_press=self._on_block_click) + btn_nothing = urwid.Button("Do nothing", on_press=self._on_ignore_click) + row = urwid.Columns([ + ('weight', 1, msg), + ('pack', btn_trust), + (1, urwid.Text(" ")), + ('pack', btn_block), + (1, urwid.Text(" ")), + ('pack', btn_nothing), + (1, urwid.Text(" ")), + ], dividechars=0) + return urwid.AttrMap(row, "msg_warning_untrusted") + + def _on_trust_click(self, _btn): + try: + src = bytes.fromhex(self.source_hash) + existing = self.app.directory.find(src) + display_name = getattr(existing, "display_name", None) if existing is not None else None + preferred = getattr(existing, "preferred_delivery", None) if existing is not None else None + entry = DirectoryEntry(src, display_name, DirectoryEntry.TRUSTED, preferred_delivery=preferred) + self.app.directory.remember(entry) + except Exception as e: + RNS.log("Could not mark peer as trusted: "+str(e), RNS.LOG_ERROR) + self._refresh_trust_banner() + try: + self.frame.focus_position = "footer" + except Exception: + pass + try: + if self.delegate.list_filter != ConversationsDisplay.LIST_FILTER_TRUSTED: + self.delegate._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED) + else: + self.delegate.update_conversation_list() + except Exception as e: + RNS.log("Trust UI refresh failed: "+str(e), RNS.LOG_ERROR) + + def _on_ignore_click(self, _btn): + self._trust_banner_dismissed = True + self._refresh_trust_banner() + + def _on_block_click(self, _btn): + def dismiss(_b): + self.dialog_active = False + try: self.delegate.dialog_open = False + except Exception: pass + try: self.delegate.update_conversation_list() + except Exception: pass + + def confirmed(_b): + self.dialog_active = False + try: self.delegate.dialog_open = False + except Exception: pass + try: + self._block_peer() + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + try: self.delegate.update_conversation_list() + except Exception: pass + + try: + who = self.app.directory.simplest_display_str(bytes.fromhex(self.source_hash)) + except Exception: + who = self.source_hash + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Block "+str(who)+"?\n\nThis will blackhole the peer's identity in Reticulum,\nadd them to your ignored list, and delete this conversation.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirmed)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]), + ]), title="Confirm block" + ) + dialog.delegate = self.delegate + + bottom = self.delegate.listbox + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.delegate.columns_widget.contents[0] = ( + overlay, + self.delegate.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.delegate.columns_widget.focus_position = 0 + self.dialog_active = True + try: self.delegate.dialog_open = True + except Exception: pass + except Exception: + pass + + def _block_peer(self): + try: + src = bytes.fromhex(self.source_hash) + except Exception: + return + + try: + self.app.block_destination(src, reason="user-blocked from nomadnet conversation") + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + + try: + self.delegate.delete_conversation(self.source_hash) + nomadnet.Conversation.delete_conversation(self.source_hash, self.app) + except Exception as e: + RNS.log("Could not delete blocked conversation: "+str(e), RNS.LOG_ERROR) + + def _update_peer_info(self): + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + + g = self.app.ui.glyphs + source_hash_bytes = bytes.fromhex(self.source_hash) + + display_name = self.app.directory.display_name(source_hash_bytes) + app_data = None + if display_name is None or self.app.message_router.get_outbound_stamp_cost(source_hash_bytes) is None: + app_data = RNS.Identity.recall_app_data(source_hash_bytes) + + if display_name is None: + if app_data: + display_name = san(LXMF.display_name_from_app_data(app_data)) + if display_name is None: + display_name = RNS.prettyhexrep(source_hash_bytes) + + stamp_cost = self.app.message_router.get_outbound_stamp_cost(source_hash_bytes) + if stamp_cost is None and app_data: + stamp_cost = LXMF.stamp_cost_from_app_data(app_data) + + hops = RNS.Transport.hops_to(source_hash_bytes) + if hops >= RNS.Transport.PATHFINDER_M: + hops_str = "unknown" + else: + hops_str = str(hops)+" hop" + ("s" if hops != 1 else "") + + right_parts = [] + if stamp_cost is not None: + right_parts.append("Stamp: "+str(stamp_cost)) + right_parts.append(g["speed"]+hops_str) + + left = " "+display_name + right = " ".join(right_parts)+" " + self.peer_info_widget.original_widget.set_text(left+" | "+right) + + def clear_history_dialog(self): + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + def confirmed(sender): + self.dialog_open = False + self.conversation.clear_history() + self.conversation_changed(None) + + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("Clear conversation history\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) + ]), title="?" + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=34, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def _build_footer(self): + g = self.app.ui.glyphs + if self.full_editor_active: + editor = self.full_editor + else: + editor = self.minimal_editor + + if self.pending_attachments: + attachment_texts = [] + for path in self.pending_attachments: + attachment_texts.append(os.path.basename(path)) + indicator = urwid.AttrMap( + urwid.Text(g["file"]+" "+str(len(self.pending_attachments))+" file(s): "+", ".join(attachment_texts)), + "msg_header_sent", + ) + return urwid.Pile([indicator, editor]) + else: + return editor + + def toggle_editor(self): + if self.full_editor_active: + self.full_editor_active = False + else: + self.full_editor_active = True + self.frame.contents["footer"] = (self._build_footer(), None) + + def check_editor_allowed(self): + g = self.app.ui.glyphs + if self.frame: + allowed = nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)) + if allowed: + self.frame.contents["footer"] = (self._build_footer(), None) + else: + warning = urwid.AttrMap( + urwid.Padding(urwid.Text( + "\n"+g["info"]+"\n\nYou cannot currently message this peer, since its identity keys are not known. " + "The keys have been requested from the network and should arrive shortly, if available. " + "Close this conversation and reopen it to try again.\n\n" + "To query the network manually, select this conversation in the conversation list, " + "press Ctrl-E, and use the query button.\n", + align=urwid.CENTER, + )), + "msg_header_caution", + ) + self.frame.contents["footer"] = (warning, None) + + def toggle_focus_area(self): + name = "" + try: + name = self.frame.get_focus_widgets()[0].name + except Exception as e: + pass + + if name == "messagelist": + self.frame.focus_position = "footer" + elif name == "minimal_editor" or name == "full_editor": + self.frame.focus_position = "body" + + def keypress(self, size, key): + if key == "tab": + self.toggle_focus_area() + return None + key = super(ConversationWidget, self).keypress(size, key) + if key is None: + return None + if key == "ctrl w": + self.close() + elif key == "ctrl u": + self.conversation.purge_failed() + self.conversation_changed(None) + elif key == "ctrl t": + self.toggle_editor() + elif key == "ctrl x": + self.clear_history_dialog() + elif key == "ctrl g": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.conversations_display.toggle_fullscreen() + elif key == "ctrl o": + self.sort_by_timestamp ^= True + self.conversation_changed(None) + elif key == "ctrl a": + self.attach_file() + elif key == "ctrl s": + self.save_focused_attachments() + else: + return key + + def _on_conversation_changed_from_callback(self, conversation): + self.delegate._wake(lambda: self.conversation_changed(conversation)) + + def conversation_changed(self, conversation): + if hasattr(self, "peer_info_widget"): + self._update_peer_info() + self.update_message_widgets(replace = True) + + def update_message_widgets(self, replace = False): + self.message_widgets = [] + added_hashes = set() + needs_index = [] + for message in self.conversation.messages: + message_hash = message.get_hash() + if not message_hash in added_hashes: + added_hashes.add(message_hash) + was_loaded = message.loaded + try: + message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"], conversation_widget=self) + except Exception as e: + RNS.log("Skipping message loading for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_WARNING) + message.unload() + continue + self.message_widgets.append(message_widget) + if not was_loaded and message.loaded: + needs_index.append(message) + message.unload() + + if needs_index: + try: + ConversationMessage.write_index( + self.conversation.messages_path, needs_index) + except Exception: + pass + + if self.sort_by_timestamp: + self.message_widgets.sort(key=lambda m: m.timestamp, reverse=False) + else: + self.message_widgets.sort(key=lambda m: m.sort_timestamp, reverse=False) + + from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox + self.messagelist = IndicativeListBox(self.message_widgets, position = len(self.message_widgets)-1) + self.messagelist.name = "messagelist" + if replace: + self.frame.contents["body"] = (self.messagelist, None) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() + + + def clear_editor(self): + self.content_editor.set_edit_text("") + self.title_editor.set_edit_text("") + self.pending_attachments = [] + self.frame.contents["footer"] = (self._build_footer(), None) + + def _collect_attachment_refs(self): + g = self.app.ui.glyphs + refs = [] + sorted_messages = sorted(self.conversation.messages, key=lambda m: m.sort_timestamp, reverse=True) + for conv_message in sorted_messages: + if not conv_message.has_attachments(): + continue + + cached_names = conv_message._cached_attachment_names or [] + att_file_idx = 0 + for atype, aname, *arest in cached_names: + asize = arest[0] if arest else 0 + glyph = g["file"] if atype == "file" else g[atype] + label = glyph+" "+aname + if asize > 0: + label += " ("+_format_size(asize)+")" + if atype == "file": + refs.append((label, aname, conv_message, "file", att_file_idx)) + att_file_idx += 1 + else: + refs.append((label, aname, conv_message, atype, 0)) + + return refs + + def save_focused_attachments(self): + g = self.app.ui.glyphs + self.dialog_active = True + + try: + attachment_items = self._collect_attachment_refs() + except Exception as e: + RNS.log("Error collecting attachments: "+str(e), RNS.LOG_ERROR) + attachment_items = [] + + save_dir = self.app.attachment_save_path if self.app.attachment_save_path else self.app.downloads_path + + def dismiss_dialog(sender): + self.dialog_active = False + self.conversation_changed(None) + + if not attachment_items: + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("No attachments in this conversation.\n"), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), title="Attachments" + ) + dialog.delegate = self + bottom = self.messagelist + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=45, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + return + + checkboxes = [] + for label, filename, conv_msg, field_type, field_index in attachment_items: + cb = urwid.CheckBox(label, state=False) + cb._attachment_filename = filename + cb._conv_message = conv_msg + cb._field_type = field_type + cb._field_index = field_index + checkboxes.append(cb) + + status_text = urwid.Text("") + + def do_save(sender): + saved = [] + errors = [] + for cb in checkboxes: + if cb.get_state(): + try: + src_path = cb._conv_message.get_attachment_file_path(cb._field_type, cb._field_index) + if src_path and os.path.isfile(src_path): + path = _copy_attachment_to_dest(cb._attachment_filename, src_path) + saved.append(path) + except Exception as e: + errors.append(str(e)) + + if saved: + lines = [g["check"]+" Copied "+str(len(saved))+" file(s) to "+save_dir+":"] + for p in saved: + lines.append(" "+os.path.basename(p)) + if errors: + lines.append(g["cross"]+" "+str(len(errors))+" failed") + status_text.set_text("\n".join(lines)) + elif errors: + status_text.set_text(g["cross"]+" Failed: "+errors[0]) + else: + status_text.set_text("No files selected") + + dialog_widgets = list(checkboxes) + dialog_widgets.append(urwid.Divider(g["divider1"])) + dialog_widgets.append(urwid.Text("Copy to: "+save_dir)) + dialog_widgets.append(status_text) + dialog_widgets.append(urwid.Text("")) + dialog_widgets.append(urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Copy to Downloads", on_press=do_save)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Close", on_press=dismiss_dialog)), + ])) + + dialog = DialogLineBox(urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)), title="Attachments") + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=("relative", 80), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def send_message(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + fields = None + if self.pending_attachments: + file_attachments = [] + for file_path in self.pending_attachments: + try: + with open(file_path, "rb") as af: + file_data = af.read() + file_name = os.path.basename(file_path) + file_attachments.append([file_name, file_data]) + except Exception as e: + RNS.log("Error reading attachment "+str(file_path)+": "+str(e), RNS.LOG_ERROR) + + if file_attachments: + fields = {LXMF.FIELD_FILE_ATTACHMENTS: file_attachments} + + if self.app.compose_markdown: + if not fields: fields = {} + fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN + + if self.conversation.send(content, title, fields=fields): + self.clear_editor() + + def attach_file(self): + self.dialog_active = True + browser = FileBrowserDialog(self) + bottom = self.messagelist + overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def file_browser_closed(self): + self.dialog_active = False + self.frame.contents["footer"] = (self._build_footer(), None) + self.conversation_changed(None) + + def paper_message_saved(self, path): + g = self.app.ui.glyphs + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("The paper message was saved to:\n\n"+str(path)+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), title=g["papermsg"].replace(" ", "") + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def print_paper_message_qr(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + if self.conversation.paper_output(content, title): + self.clear_editor() + else: + self.paper_message_failed() + + def save_paper_message_qr(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + output_result = self.conversation.paper_output(content, title, mode="save_qr") + if output_result != False: + self.clear_editor() + self.paper_message_saved(output_result) + else: + self.paper_message_failed() + + def save_paper_message_uri(self): + content = self.content_editor.get_edit_text() + title = self.title_editor.get_edit_text() + if not content == "": + output_result = self.conversation.paper_output(content, title, mode="save_uri") + if output_result != False: + self.clear_editor() + self.paper_message_saved(output_result) + else: + self.paper_message_failed() + + def paper_message(self): + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + def print_qr(sender): + dismiss_dialog(self) + self.print_paper_message_qr() + + def save_qr(sender): + dismiss_dialog(self) + self.save_paper_message_qr() + + def save_uri(sender): + dismiss_dialog(self) + self.save_paper_message_uri() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text( + "Select the desired paper message output method.\nSaved files will be written to:\n\n"+str(self.app.downloads_path)+"\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.5, urwid.Button("Print QR", on_press=print_qr)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Save QR", on_press=save_qr)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Save URI", on_press=save_uri)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.5, urwid.Button("Cancel", on_press=dismiss_dialog)) + ]) + ]), title="Create Paper Message" + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def paper_message_failed(self): + def dismiss_dialog(sender): + self.dialog_open = False + self.conversation_changed(None) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text( + "Could not output paper message,\ncheck your settings. See the log\nfile for any error messages.\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.6, urwid.Text("")), + (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), title="!" + ) + dialog.delegate = self + bottom = self.messagelist + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=34, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + self.frame.contents["body"] = (overlay, self.frame.options()) + self.frame.focus_position = "body" + + def close(self): + self.delegate.close_conversation(self) + + +class LXMessageWidget(urwid.WidgetWrap): + mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) + + def __init__(self, message, theme=THEME_DARK, conversation_widget=None): + app = nomadnet.NomadNetworkApp.get_shared_instance() + g = app.ui.glyphs + self._conversation_widget = conversation_widget + self.timestamp = message.get_timestamp() + self.sort_timestamp = message.sort_timestamp + self.transfer_done = False + self._live_lxm = None + + msg_hash = message.get_hash() + msg_state = message.get_state() + msg_source_hash = message._cached_source_hash + msg_method = message._cached_method + time_format = app.time_format + message_time = datetime.fromtimestamp(self.timestamp) + renderer = message.content_renderer() + encryption_string = "" + if message.get_transport_encrypted(): + encryption_string = " "+g["encrypted"] + else: + encryption_string = " "+g["plaintext"] + + title_string = relative_time(self.timestamp)+" | "+message_time.strftime(time_format)+encryption_string + + is_outbound = False + if msg_source_hash is None: + header_style = "msg_header_failed" + title_string = g["warning"]+" "+title_string + elif app.lxmf_destination.hash == msg_source_hash: + is_outbound = True + if msg_state == LXMF.LXMessage.DELIVERED: + header_style = "msg_header_delivered" + title_string = g["check"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.FAILED: + header_style = "msg_header_failed" + title_string = g["cross"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.REJECTED: + header_style = "msg_header_failed" + title_string = g["cross"]+" "+g["arrow_r"]+" Rejected "+title_string + elif msg_method == LXMF.LXMessage.PROPAGATED and msg_state == LXMF.LXMessage.SENT: + header_style = "msg_header_propagated" + title_string = g["sent"]+" "+g["arrow_r"]+" "+title_string + elif msg_method == LXMF.LXMessage.PAPER and msg_state == LXMF.LXMessage.PAPER: + header_style = "msg_header_propagated" + title_string = g["papermsg"]+" "+g["arrow_r"]+" "+title_string + elif msg_state == LXMF.LXMessage.SENT: + header_style = "msg_header_sent" + title_string = g["sent"]+" "+g["arrow_r"]+" "+title_string + else: + header_style = "msg_header_sent" + title_string = g["arrow_r"]+" "+title_string + else: + if message.signature_validated(): + header_style = "msg_header_ok" + title_string = g["check"]+" "+g["arrow_l"]+" "+title_string + else: + header_style = "msg_header_caution" + title_string = g["warning"]+" "+g["arrow_l"]+" "+message.get_signature_description() + "\n " + title_string + + if message.get_title() != "": + title_string += " | " + message.get_title() + + inbound_untrusted = False + if not is_outbound and msg_source_hash is not None: + try: + sender_trust = app.directory.trust_level(msg_source_hash) + if sender_trust in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN): + inbound_untrusted = True + except Exception: + inbound_untrusted = False + + has_attachments = message.has_attachments() + cached_names = message._cached_attachment_names or [] + + if has_attachments and cached_names: + attachment_strings = [] + for atype, aname, *arest in cached_names: + attachment_strings.append(g[atype if atype != "file" else "file"]+" "+aname) + title_string += " | " + " ".join(attachment_strings) + + content_text = message.get_content() + + if content_text and app.config["textui"]["clipboard_copy"]: + copy_glyph = g.get("copy", "[C]") + check_glyph = g.get("check", "v").center(len(copy_glyph)) + copy_icon = ClickableIcon(copy_glyph) + + conv_widget = self._conversation_widget + def on_copy_click(icon=copy_icon, content=content_text, cg=copy_glyph, kg=check_glyph, cw=conv_widget): + osc52_copy(content) + icon.set_text(kg) + def _restore(loop, user_data): + icon.set_text(cg) + try: + app.ui.loop.set_alarm_in(2.0, _restore) + except Exception: + icon.set_text(cg) + if cw is not None and cw.frame is not None: + def _refocus(loop, user_data): + try: + cw.frame.focus_position = "footer" + except Exception: + pass + try: + app.ui.loop.set_alarm_in(0, _refocus) + except Exception: + pass + copy_icon._on_click = on_copy_click + + copy_width = len(copy_glyph) + 2 + title_row = urwid.Columns([ + ("weight", 1, urwid.Text(title_string)), + (copy_width, urwid.Padding(copy_icon, left=1, right=1)), + ]) + title = urwid.AttrMap(title_row, header_style) + else: + title = urwid.AttrMap(urwid.Text(title_string), header_style) + + self.progress_widget = urwid.Text("") + self.progress_attr = urwid.AttrMap(self.progress_widget, "progress_full") + + content_lines = content_text.split("\n") + markdown = renderer == LXMF.RENDERER_MARKDOWN + + default_fg = "bbb" if theme == THEME_DARK else "444" + if markdown: + formatted = self.mdc.format_block(content_text) + message_body = strip_non_formatting_tags(formatted) + rendered = markup_to_attrmaps(strip_modifiers(message_body), url_delegate=None, fg_color=default_fg, bg_color=None) + content_pile = urwid.Padding(urwid.Pile(rendered), left=2, right=2) + + else: indented = "\n".join(" "+line for line in content_lines) + + pile_widgets = [title] + + if is_outbound and msg_state is not None and msg_state < LXMF.LXMessage.SENT and msg_hash is not None: + try: + for pending in app.message_router.pending_outbound: + if pending.hash == msg_hash: + if pending.representation == LXMF.LXMessage.RESOURCE: + self._live_lxm = pending + break + except Exception: + pass + + if self._live_lxm is not None: + pct = int(self._live_lxm.progress * 100) + bar_width = 20 + filled = int(bar_width * self._live_lxm.progress) + if app.ui.colormode >= 256: + bar = "\u2588" * filled + "\u2591" * (bar_width - filled) + else: + bar = "#" * filled + "-" * (bar_width - filled) + self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") + pile_widgets.append(self.progress_attr) + self._start_progress_poll() + + if markdown: pile_widgets.append(content_pile) + else: pile_widgets.append(urwid.Text(indented)) + + if has_attachments and cached_names: + if inbound_untrusted: + pile_widgets.append(urwid.AttrMap( + urwid.Text(" "+g["warning"]+" This attachment came from a peer that's untrusted. Be careful when opening it."), + "list_untrusted", + )) + + att_file_idx = 0 + for atype, aname, *arest in cached_names: + glyph = g["file"] if atype == "file" else g[atype] + asize = arest[0] if arest else 0 + label = " "+glyph+" "+aname + if asize > 0: + label += " ("+_format_size(asize)+")" + if atype == "file": + pile_widgets.append(ClickableAttachment(label, aname, message, "file", att_file_idx)) + att_file_idx += 1 + else: + pile_widgets.append(ClickableAttachment(label, aname, message, atype)) + + pile_widgets.append(urwid.Text("")) + + super().__init__(urwid.Pile(pile_widgets)) + + def _start_progress_poll(self): + try: + loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop + if loop: + loop.set_alarm_in(0.3, self._poll_progress) + except Exception: + pass + + def _poll_progress(self, loop=None, user_data=None): + if self.transfer_done: + return + + if self._live_lxm is None: + self.transfer_done = True + return + + app = nomadnet.NomadNetworkApp.get_shared_instance() + g = app.ui.glyphs + progress = self._live_lxm.progress + state = self._live_lxm.state + pct = int(progress * 100) + + if state == LXMF.LXMessage.FAILED: + self.progress_widget.set_text(" "+g["cross"]+" Transfer failed") + self.transfer_done = True + self._live_lxm = None + elif state == LXMF.LXMessage.REJECTED: + self.progress_widget.set_text(" "+g["cross"]+" Rejected: too large or not accepted") + self.transfer_done = True + self._live_lxm = None + elif state >= LXMF.LXMessage.SENT: + self.progress_widget.set_text("") + self.transfer_done = True + self._live_lxm = None + else: + bar_width = 20 + filled = int(bar_width * progress) + if app.ui.colormode >= 256: + bar = "\u2588" * filled + "\u2591" * (bar_width - filled) + else: + bar = "#" * filled + "-" * (bar_width - filled) + self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") + + if not self.transfer_done: + try: + ui_loop = app.ui.loop + if ui_loop: + ui_loop.set_alarm_in(0.3, self._poll_progress) + ui_loop.draw_screen() + except Exception: + pass + + +class ClickableAttachment(urwid.Text): + def __init__(self, label, filename, conv_message, field_type, field_index=0): + self.filename = filename + self.conv_message = conv_message + self.field_type = field_type + self.field_index = field_index + self.saved = False + super().__init__(label) + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._save() + return True + return False + + def _save(self): + if self.saved: + return + app = nomadnet.NomadNetworkApp.get_shared_instance() + g = app.ui.glyphs + try: + src_path = self.conv_message.get_attachment_file_path(self.field_type, self.field_index) + if src_path and os.path.isfile(src_path): + save_path = _copy_attachment_to_dest(self.filename, src_path) + else: + if self.field_type == "file": + attachments = self.conv_message.get_file_attachments() + if self.field_index < len(attachments): + att = attachments[self.field_index] + if isinstance(att, list) and len(att) >= 2: + data = att[1] if isinstance(att[1], bytes) else b"" + else: + data = b"" + else: + data = b"" + elif self.field_type == "image": + data = self.conv_message.get_image() + data = data if isinstance(data, bytes) else b"" + elif self.field_type == "audio": + data = self.conv_message.get_audio() + data = data if isinstance(data, bytes) else b"" + else: + data = b"" + self.conv_message.unload() + if not data: + return + save_path = _save_attachment_to_disk(self.filename, data) + + self.saved = True + self.set_text(" "+g["check"]+" Copied to: "+save_path) + except Exception as e: + RNS.log("Error saving attachment: "+str(e), RNS.LOG_ERROR) + self.set_text(" "+g["cross"]+" Save failed: "+str(e)) + + +def _resolve_attachment_save_path(filename): + app = nomadnet.NomadNetworkApp.get_shared_instance() + save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path + if not os.path.isdir(save_dir): + os.makedirs(save_dir) + safe_name = ConversationMessage.safe_attachment_name(filename) + base_dir = os.path.realpath(save_dir) + os.sep + candidate = os.path.realpath(os.path.join(save_dir, safe_name)) + if not (candidate + os.sep).startswith(base_dir): + raise OSError(13, os.strerror(13)) + counter = 0 + base, ext = os.path.splitext(safe_name) + while os.path.isfile(candidate): + counter += 1 + candidate = os.path.realpath(os.path.join(save_dir, base+"_"+str(counter)+ext)) + if not (candidate + os.sep).startswith(base_dir): + raise OSError(13, os.strerror(13)) + return candidate + + +def _copy_attachment_to_dest(filename, src_path): + save_path = _resolve_attachment_save_path(filename) + shutil.copy2(src_path, save_path) + return save_path + + +def _save_attachment_to_disk(filename, data): + save_path = _resolve_attachment_save_path(filename) + with open(save_path, "wb") as f: + f.write(data) + return save_path + + +class FileBrowserEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False): + self.full_path = full_path + self.name = name + self.is_dir = is_dir + self.is_parent = is_parent + self.selected = selected + g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs + if is_parent: + display = g["arrow_l"]+" .." + elif is_dir: + display = g["arrow_r"]+" "+name+"/" + elif selected: + display = g["check"]+" "+name + else: + display = " "+name + self.text_widget = urwid.SelectableIcon(display, 0) + if is_dir or is_parent: + style = "list_trusted" + focus_style = "list_focus" + elif selected: + style = "list_trusted" + focus_style = "list_focus_trusted" + else: + style = "list_unknown" + focus_style = "list_focus" + display_widget = urwid.AttrMap(self.text_widget, style, focus_style) + super().__init__(display_widget) + + def keypress(self, size, key): + if key == "enter": + self._emit("click") + else: + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class FileBrowserDialog(urwid.WidgetWrap): + def __init__(self, delegate): + self.delegate = delegate + app = nomadnet.NomadNetworkApp.get_shared_instance() + self.g = app.ui.glyphs + self.current_path = os.path.expanduser("~") + + self.path_label = urwid.Text("") + self.status_label = urwid.Text("") + self.file_walker = urwid.SimpleFocusListWalker([]) + self.file_listbox = urwid.ListBox(self.file_walker) + + self.button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)), + ]) + + header_pile = urwid.Pile([ + self.path_label, + self.status_label, + urwid.Divider(self.g["divider1"]), + ]) + + footer_pile = urwid.Pile([ + urwid.Divider(self.g["divider1"]), + self.button_columns, + ]) + + self._populate() + + self.browser_frame = urwid.Frame( + self.file_listbox, + header=header_pile, + footer=footer_pile, + ) + + linebox = urwid.LineBox(self.browser_frame, title="Attach File") + super().__init__(linebox) + + def _update_status(self): + pending = self.delegate.pending_attachments + if pending: + names = [os.path.basename(p) for p in pending] + self.status_label.set_text(" "+self.g["file"]+" "+str(len(pending))+" selected: "+", ".join(names)) + else: + self.status_label.set_text(" No files selected") + + def _populate(self): + self.path_label.set_text(" "+self.current_path) + self._update_status() + + focus_pos = None + try: + focus_pos = self.file_listbox.focus_position + except Exception: + pass + + entries = [] + parent = os.path.dirname(self.current_path) + if parent != self.current_path: + entry = FileBrowserEntry("..", parent, is_parent=True) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + try: + items = sorted(os.listdir(self.current_path)) + except PermissionError: + entries.append(urwid.Text(("error_text", " Permission denied"))) + self.file_walker[:] = entries + return + + dirs = [] + files = [] + for item in items: + if item.startswith("."): + continue + full = os.path.join(self.current_path, item) + if os.path.isdir(full): + dirs.append((item, full)) + elif os.path.isfile(full): + files.append((item, full)) + + for name, full in dirs: + entry = FileBrowserEntry(name, full, is_dir=True) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + for name, full in files: + is_selected = full in self.delegate.pending_attachments + entry = FileBrowserEntry(name, full, selected=is_selected) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + if not dirs and not files: + entries.append(urwid.Text(("inactive_text", " (empty)"))) + + self.file_walker[:] = entries + if focus_pos is not None and focus_pos < len(entries): + self.file_listbox.set_focus(focus_pos) + elif entries: + self.file_listbox.set_focus(0) + + def _entry_clicked(self, entry_widget, user_data=None): + entry = user_data if user_data else entry_widget + if entry.is_dir or entry.is_parent: + self.current_path = entry.full_path + self._populate() + else: + if entry.full_path in self.delegate.pending_attachments: + self.delegate.pending_attachments.remove(entry.full_path) + else: + self.delegate.pending_attachments.append(entry.full_path) + self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None) + self._populate() + + def _dismiss(self, sender): + self.delegate.file_browser_closed() + + def _cancel(self, sender): + self.delegate.pending_attachments.clear() + self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None) + self.delegate.file_browser_closed() + + def keypress(self, size, key): + if key == "esc": + self.delegate.file_browser_closed() + return + result = super().keypress(size, key) + if result == "down" and self.browser_frame.focus_position == "body": + self.browser_frame.focus_position = "footer" + return + elif result == "up" and self.browser_frame.focus_position == "footer": + self.browser_frame.focus_position = "body" + return + return result + + +class SyncProgressBar(urwid.ProgressBar): + def get_text(self): + status = nomadnet.NomadNetworkApp.get_shared_instance().get_sync_status() + show_percent = nomadnet.NomadNetworkApp.get_shared_instance().sync_status_show_percent() + if show_percent: + return status+" "+super().get_text() + else: + return status diff --git a/nomadnet/ui/textui/Directory.py b/nomadnet/ui/textui/Directory.py new file mode 100644 index 0000000..3ff090b --- /dev/null +++ b/nomadnet/ui/textui/Directory.py @@ -0,0 +1,21 @@ +class DirectoryDisplayShortcuts(): + def __init__(self, app): + import urwid + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("Directory Display Shortcuts"), "shortcutbar") + +class DirectoryDisplay(): + def __init__(self, app): + import urwid + self.app = app + + pile = urwid.Pile([ + urwid.Text(("body_text", "Directory Display \U0001F332")), + ]) + + self.shortcuts_display = DirectoryDisplayShortcuts(self.app) + self.widget = urwid.Filler(pile, urwid.TOP) + + def shortcuts(self): + return self.shortcuts_display \ No newline at end of file diff --git a/nomadnet/ui/textui/Extras.py b/nomadnet/ui/textui/Extras.py new file mode 100644 index 0000000..54b4fec --- /dev/null +++ b/nomadnet/ui/textui/Extras.py @@ -0,0 +1,19 @@ +class IntroDisplay(): + def __init__(self, app): + import urwid + self.app = app + + font = urwid.font.HalfBlock5x4Font() + + big_text = urwid.BigText(("intro_title", self.app.config["textui"]["intro_text"]), font) + big_text = urwid.Padding(big_text, align=urwid.CENTER, width=urwid.CLIP) + + intro = urwid.Pile([ + big_text, + urwid.Text(("Version %s" % (str(self.app.version))), align=urwid.CENTER), + urwid.Divider(), + urwid.Text(("-= Starting =- "), align=urwid.CENTER), + ]) + + self.widget = urwid.Filler(intro) + diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py new file mode 100644 index 0000000..4316266 --- /dev/null +++ b/nomadnet/ui/textui/Guide.py @@ -0,0 +1,1937 @@ +import RNS +import urwid +import nomadnet +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY +from .MicronParser import markup_to_attrmaps +from nomadnet.vendor.Scrollable import * + +class GuideDisplayShortcuts(): + def __init__(self, app): + self.app = app + g = app.ui.glyphs + + self.widget = urwid.AttrMap(urwid.Padding(urwid.Text(""), align=urwid.LEFT), "shortcutbar") + +class ListEntry(urwid.Text): + _selectable = True + + signals = ["click"] + + def keypress(self, size, key): + """ + Send 'click' signal on 'activate' command. + """ + if self._command_map[key] != urwid.ACTIVATE: + return key + + self._emit('click') + + def mouse_event(self, size, event, button, x, y, focus): + """ + Send 'click' signal on button 1 press. + """ + if button != 1 or not urwid.util.is_mouse_press(event): + return False + + self._emit('click') + return True + +class SelectText(urwid.Text): + _selectable = True + + signals = ["click"] + + def keypress(self, size, key): + """ + Send 'click' signal on 'activate' command. + """ + if self._command_map[key] != urwid.ACTIVATE: + return key + + self._emit('click') + + def mouse_event(self, size, event, button, x, y, focus): + """ + Send 'click' signal on button 1 press. + """ + if button != 1 or not urwid.util.is_mouse_press(event): + return False + + self._emit('click') + return True + +def _rows_above(attrmaps, index, cols): + if index <= 0 or not attrmaps: + return 0 + total = 0 + for i in range(min(index, len(attrmaps))): + try: + total += attrmaps[i].rows((cols,)) + except Exception: + total += 1 + return total + + +class GuideColumns(urwid.Columns): + def keypress(self, size, key): + # TODO: Scroll handling is currently very weird + # in the guide reader. When using page up/down + # or mouse scroll, the actual cursor position + # does not follow the scroll, as it does in the + # browser. If you scroll down, and then use the + # arrow keys, the position jumps back up to + # where you "left" the cursor. + # There's handling for this in the Browser code + # but I don't have time to port it over here at + # this point. If someone else takes that one up + # it's :) :) :) + return super().keypress(size, key) + + +class GuideLinkDelegate: + def __init__(self, app, reader=None): + self.app = app + self.reader = reader + self.last_keypress = 0 + + def marked_link(self, target, fields=None): + pass + + def micron_released_focus(self): + self.reader.focus_topics() + + def handle_link(self, target, fields=None): + if not target: + return + if target.startswith("#"): + if self.reader is not None: + try: + def df(loop, user_data): self.reader.jump_to_anchor(target[1:]) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df) + except Exception as e: + RNS.log("Guide anchor jump failed: "+str(e), RNS.LOG_ERROR) + return + try: + self.app.ui.main_display.show_network(None) + self.app.ui.main_display.sub_displays.network_display.browser.handle_link(target, fields) + except Exception as e: + RNS.log("Could not open guide link: "+str(e), RNS.LOG_ERROR) + + +class GuideEntry(urwid.WidgetWrap): + def __init__(self, app, parent, reader, topic_name): + self.app = app + self.parent = parent + self.reader = reader + self.last_keypress = None + self.topic_name = topic_name + g = self.app.ui.glyphs + + widget = ListEntry(topic_name) + urwid.connect_signal(widget, "click", self.display_topic, self.topic_name) + + style = "topic_list_normal" + focus_style = "list_focus" + self.display_widget = urwid.AttrMap(widget, style, focus_style) + super().__init__(self.display_widget) + + def display_topic(self, event, topic): + markup = TOPICS[topic] + attrmaps = markup_to_attrmaps(markup, url_delegate=GuideLinkDelegate(self.app, reader=self.reader)) + + topic_position = None + index = 0 + for topic in self.parent.topic_list: + widget = topic._original_widget + if widget.topic_name == self.topic_name: + topic_position = index + index += 1 + + if topic_position != None: + self.parent.ilb.select_item(topic_position) + + self.reader.set_content_widgets(attrmaps) + self.reader.focus_reader() + + + def micron_released_focus(self): + self.reader.focus_topics() + +class TopicList(urwid.WidgetWrap): + def __init__(self, app, guide_display): + self.app = app + g = self.app.ui.glyphs + + self.first_run_entry = GuideEntry(self.app, self, guide_display, "First Run") + + self.topic_list = [ + GuideEntry(self.app, self, guide_display, "Introduction"), + GuideEntry(self.app, self, guide_display, "Concepts & Terminology"), + GuideEntry(self.app, self, guide_display, "Channels & RRC"), + GuideEntry(self.app, self, guide_display, "Interfaces"), + GuideEntry(self.app, self, guide_display, "Hosting a Node"), + GuideEntry(self.app, self, guide_display, "Configuration Options"), + GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"), + GuideEntry(self.app, self, guide_display, "Markup"), + self.first_run_entry, + GuideEntry(self.app, self, guide_display, "Network Configuration"), + GuideEntry(self.app, self, guide_display, "Display Test"), + GuideEntry(self.app, self, guide_display, "Credits & Licenses"), + ] + + self.ilb = IndicativeListBox( + self.topic_list, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus" + ) + + super().__init__(urwid.LineBox(self.ilb, title="Topics")) + + + def keypress(self, size, key): + if key == "up" and (self.ilb.first_item_is_selected()): + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + + return super(TopicList, self).keypress(size, key) + +class GuideDisplay(): + list_width = 0.33 + + def __init__(self, app): + self.app = app + g = self.app.ui.glyphs + + topic_text = urwid.Text("\n No topic selected", align=urwid.LEFT) + + self.left_area = TopicList(self.app, self) + self.right_area = urwid.LineBox(urwid.Filler(topic_text, urwid.TOP)) + + + self.columns = GuideColumns( + [ + (urwid.WEIGHT, GuideDisplay.list_width, self.left_area), + (urwid.WEIGHT, 1-GuideDisplay.list_width, self.right_area) + ], + dividechars=0, focus_column=0 + ) + self.columns.guide_display = self + + self.shortcuts_display = GuideDisplayShortcuts(self.app) + self.widget = self.columns + + if self.app.firstrun: + entry = self.left_area.first_run_entry + entry.display_topic(entry.display_topic, entry.topic_name) + + def set_content_widgets(self, new_content): + options = self.columns.options(width_type=urwid.WEIGHT, width_amount=1-GuideDisplay.list_width, box_widget=True) + pile = urwid.Pile(new_content) + self._content_attrmaps = new_content + self._content_scrollable = Scrollable(pile) + pile.automove_cursor_on_scroll = True + content = urwid.LineBox(urwid.AttrMap(ScrollBar(self._content_scrollable, thumb_char="\u2503", trough_char=" "), "scrollbar")) + + self.columns.contents[1] = (content, options) + + def jump_to_anchor(self, name): + scrollable = getattr(self, "_content_scrollable", None) + attrmaps = getattr(self, "_content_attrmaps", None) + if scrollable is None or attrmaps is None: + return + anchors = getattr(attrmaps, "anchors", None) or {} + header_rows = getattr(attrmaps, "header_rows", None) or [] + cols = self._content_cols() + target_idx = None + if name: + target_idx = anchors.get(name) + if target_idx is None: + return + else: + try: current = scrollable.get_scrollpos() + except Exception: current = 0 + for hr in header_rows: + if _rows_above(attrmaps, hr, cols) > current: + target_idx = hr + break + if target_idx is None: + return + row_offset = _rows_above(attrmaps, target_idx, cols) + try: + scrollable.force_cursor_update = True + scrollable.anchor_cursor_update = True + scrollable.set_scrollpos(int(row_offset)) + except Exception: + pass + + def _content_cols(self): + try: cols = self.app.ui.loop.screen.get_cols_rows()[0] + except Exception: cols = 100 + + try: + widths = self.columns.column_widths((cols,)) + if len(widths) > 1 and widths[1] > 0: return max(40, widths[1] - 3) + except Exception: pass + return max(40, int(cols * (1 - GuideDisplay.list_width)) - 3) + + def shortcuts(self): + return self.shortcuts_display + + def focus_topics(self): + self.columns.focus_position = 0 + + def focus_reader(self): + self.columns.focus_position = 1 + + +TOPIC_INTRODUCTION = '''>Nomad Network + +`c`*Communicate Freely.`* +`a + +The intention with this program is to provide a tool to that allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. + +Nomad Network is build on LXMF and 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. + +Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded Reticulum networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. + +The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project at `_`F79d`[Aleph git`a8d24177d946de4f1f0a0fe1af9a1338:/page/index.mu]`f`_. + +''' + +TOPIC_SHORTCUTS = '''>Keyboard Shortcuts + +The different sections of the program has a number of keyboard shortcuts mapped, that makes operating and navigating the program easier. The following lists details all mapped shortcuts. + +>>`!Conversations Window`! +>>>Conversation List + - Ctrl-N Start a new conversation + - Ctrl-E Display and edit selected peer info + - Ctrl-X Delete conversation + - Ctrl-R Open LXMF syncronisation dialog + - Ctrl-U Ingest LXMF URI + - Ctrl-O Toggle sort mode + - Ctrl-P Display own LXMF address + - Ctrl-G Toggle fullscreen + +>>>Message Editor + - Ctrl-D Send message + - Ctrl-P Compose paper message + - Ctrl-T Toggle message title field + - Ctrl-F Attach file + - Ctrl-S Save focused attachment + - Tab Switch focus to message list + +>>>Message List + - Ctrl-W Close conversation + - Ctrl-U Purge failed messages + - Ctrl-O Toggle sort mode + - Ctrl-X Clear conversation history + - Ctrl-G Toggle fullscreen conversation + - Ctrl-S Save focused attachment + - Tab Switch focus to message editor + +>>`!Channels Window`! +>>>Channel List + - Ctrl-N Add a new hub + - Ctrl-A Add (join) a room + - Ctrl-R Connect to selected hub + - Ctrl-W Disconnect from selected hub + - Ctrl-T Toggle auto-reconnect for selected hub + - Ctrl-E Edit selected hub + - Ctrl-X Remove selected hub + - F8 Toggle join/part collapse + +>>>Message Editor + - Ctrl-D Send message + - Ctrl-X Leave current room + - F8 Toggle join/part collapse + - Tab Complete nickname + +>>>Message List + - Ctrl-X Leave current room + - Ctrl-U Toggle users pane + - Ctrl-Y Toggle channel list + - F8 Toggle join/part collapse + - Tab Switch focus to message editor + +>>`!Input Field Editing`! +All text input fields support readline-style editing shortcuts. When an input +field is focused, these take precedence over any window shortcut mapped to the +same key (so, for example, Ctrl-U edits the line rather than toggling a pane): + - Ctrl-A Move to beginning of line + - Ctrl-E Move to end of line + - Ctrl-U Delete from cursor to beginning of line + - Ctrl-K Delete from cursor to end of line + - Ctrl-W Delete previous word (whitespace-delimited) + - Ctrl-L Delete the entire buffer + - Ctrl-Y Paste (yank) most recently deleted text + - Ctrl-Left Move backward one word + - Ctrl-Right Move forward one word + +Text deleted with Ctrl-U, Ctrl-K, Ctrl-W or Ctrl-L is placed in a shared yank +buffer that Ctrl-Y pastes back, so text can be moved between input fields. + +>>`!Network Window`! +>>>Browser + - Ctrl-D Back + - Ctrl-F Forward + - Ctrl-R Reload page + - Ctrl-U Open URL entry dialog + - Ctrl-S Save connected node + - Ctrl-G Toggle fullscreen browser window + - Ctrl-W Disconnect from node + +>>>Announce Stream + - Ctrl-L Switch to Known Nodes list + - Ctrl-X Delete selected announce + - Ctrl-P Display peered LXMF Propagation Nodes + +>>>Known Nodes + - Ctrl-L Switch to Announce Stream + - Ctrl-X Delete selected node entry + - Ctrl-P Display peered LXMF Propagation Nodes + +>>>Peered LXMF Propagation Nodes + - Ctrl-L Switch to Announce Stream or Known Nodes + - Ctrl-X Break peering with selected node entry + - Ctrl-R Request immediate delivery sync of unhandled LXMs +''' + +TOPIC_CONCEPTS = '''>Concepts and Terminology + +The following section will briefly introduce various concepts and terms used in the program. + +>>Peer + +A `*peer`* refers to another Nomad Network client, which will generally be operated by another person. But since Nomad Network is a general LXMF client, it could also be any other LXMF client, program, automated system or machine that can communicate over LXMF. + +All peers (and nodes) are identified by their `*address`* (which is, technically speaking, a Reticulum destination hash). An address consist of 32 hexadecimal characters (16 bytes), and looks like this: + +`c +`l + +Anyone can choose whatever display name they want, but addresses are always unique, and generated from the unique cryptographic keys of the peer. This is an important point to understand. Since there is not anyone controlling naming or address spaces in Nomad Network, you can easily come across another user with the same display name as you. + +Your addresses will always be unique though, and you must always verify that the address you are communicating with is matching the address of the peer you expect to be in the other end. + +To make this easier, Nomad Network allows you to mark peers and nodes as either `*trusted`*, `*unknown`* or `*untrusted`*. In this way, you can mark the peers and nodes that you know to be legitimate, and easily spot peers with similar names as unrelated. + +>>Announces + +An `*announce`* can be sent by any peer or node on the network, which will notify other peers of its existence, and contains the cryptographic keys that allows other peers to communicate with it. + +In the `![ Network ]`! section of the program, you can monitor announces on the network, initiate conversations with announced peers, and announce your own peer on the network. You can also connect to nodes on the network and browse information shared by them. + +>>Conversations + +Nomad Network uses the term `*conversation`* to signify both direct peer-to-peer messaging threads, and also discussion threads with an arbitrary number of participants that might change over time. + +Both things like discussion forums and chat threads can be encapsulated as conversations in Nomad Network. The user interface will indicate the different characteristics a conversation can take, and also what form of transport encryption was used for messages within. + +In the `![ Conversations ]`! part of the program you can view and interact with all currently active conversations. You can also edit nickname and trust settings for peers belonging to these conversations here. To edit settings for a peer, select it in the conversation list, and press `!Ctrl-E`!. + +By default, Nomad Network will attempt to deliver messages to a peer directly. This happens by first establishing an encrypted link directly to the peer, and then delivering the message over it. + +If the desired peer is not available because it has disconnected from the network, this method will obviously fail. In this case, Nomad Network will attempt to deliver the message to a node, which will store and forward it over the network, for later retrieval by the destination peer. The message is encrypted before being transmitted to the network, and is only readable by the intended recipient. + +For propagated delivery to work, one or more nodes must be available on the network. If one or more trusted nodes are available, Nomad Network will automatically select the most suitable node to send the message via, but you can also manually specify what node to use. + +To select a node manually, go to the `![ Network ]`! part of the program, choose the desired node in the `*Known Nodes`* list, and select the `!< Info >`! button. In the `!Node Info`! dialog, you can specify the selected node as the default propagation node. + +By default, Nomad Network will check in with propagation nodes, and download any available messages every 6 hours. You can change this interval, or disable automatic syncronisation completely, by editing the configuration file. + +You can always initiate a sync manually, by pressing `!Ctrl-R`! in the `![ Conversations ]`! part of the program, which will open the syncronisation window. + +>>Node + +A Nomad Network `*node`* is an instance of the Nomad Network program that has been configured to host information for other peers and help propagate messages and information on the network. + +Nodes can host pages (similar to webpages) written in a markup-language called `*micron`*, as well as make files and other resources available for download for peers on the network. Nodes also form a distributed message store for offline users, and allows messages to be exchanged between peers that are not online at the same time. + +If no nodes exist on a network, all peers will still be able to communicate directly peer-to-peer, but both endpoints of a conversation will need to be available at the same time to converse. When nodes exist on the network, messages will be held and syncronised between nodes for deferred delivery if the destination peer is unavailable. Nodes will automatically discover and peer with each other, and handle syncronisation of message stores. + +To learn how to host your own node, read the `*Hosting a Node`* section of this guide. + +''' + +TOPIC_HOSTING = '''>Hosting a Node + +To host a node on the network, you must enable it in the configuration file, by setting the `*enable_node`* directive to `*yes`*. You should also configure the other node-related parameters such as the node name and announce interval settings. Once node hosting has been enabled in the configuration, Nomad Network will start hosting your node as soon as the program is launched, and other peers on the network will be able to connect and interact with content on your node. + +By default, no content is defined, apart from a short placeholder home page. To learn how to add your own content, read on. + +>>Distributed Message Store + +All nodes on the network will automatically participate in a distributed message store that allows users to exchange messages, even when they are not connected to the network at the same time. + +When Nomad Network is configured to host a node, by default it also configures itself as an LXMF Propagation Node, and automatically discovers and peers with other propagation nodes on the network. This process is completely automatic and requires no configuration from the node operator. + +`!However`!, if there is already an abundance of Propagation Nodes on the network, or the operator simply wishes to host a pageserving-only node, Propagation Node hosting can be disabled in the configuration file. + +To view LXMF Propagation nodes that are currently peered with your node, go to the `![ Network ]`! part of the program and press `!Ctrl-P`!. In the list of peered Propagation Nodes, it is possible to: + + - Immediately break peering with a node by pressing `!Ctrl-X`! + - Request an immediate delivery sync of all unhandled messages for a node, by pressing `!Ctrl-R`! + +The distributed message store is resilient to intermittency, and will remain functional as long as at least one node remains on the network. Nodes that were offline for a time will automatically be synced up to date when they regain connectivity. + +>>Pages + +Nomad Network nodes can host pages similar to web pages, that other peers can read and interact with. Pages are written in a compact markup language called `*micron`*. To learn how to write formatted pages with micron, see the `*Markup`* section of this guide (which is, itself, written in micron). Pages can be linked together with hyperlinks, that can also link to pages (or other resources) on other nodes. + +To add pages to your node, place micron files in the `*pages`* directory of your Nomad Network programs `*storage`* directory. By default, the path to this will be `!~/.nomadnetwork/storage/pages`!. You should probably create the file `!index.mu`! first, as this is the page that will get served by default to a connecting peer. + +You can control how long a peer will cache your pages by including the cache header in a page. To do so, the first line of your page must start with `!#!c=X`!, where `!X`! is the cache time in seconds. To tell the peer to always load the page from your node, and never cache it, set the cache time to zero. You should only do this if there is a real need, for example if your page displays dynamic content that `*must`* be updated at every page view. The default caching time is 12 hours. In most cases, you should not need to include the cache control header in your pages. + +>> Dynamic Pages + +You can use a preprocessor such as PHP, bash, Python (or whatever you prefer) to generate dynamic pages and fully interactive applications running over Nomad Network. To do so, just set executable permissions on the relevant page file, and be sure to include the interpreter at the beginning of the file, for example `!#!/usr/bin/python3`!. + +Data from fields and link variables will be passed to these scipts or programs as environment variables, and can simply be read by any method for accessing such. + +In the `!examples`! directory, you can find various small examples for the use of this feature. The currently included examples are: + + - A messageboard that receives messages over LXMF, contributed by trippcheng + - A simple demonstration on how to create fields and read entered data in node-side scripts + +By default, you can find the examples in `!~/.nomadnetwork/examples`!. If you build something neat, that you feel would fit here, you are more than welcome to contribute it. + +>>Authenticating Users + +Sometimes, you don't want everyone to be able to view certain pages or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests. + +To enable authentication for any page, simply add a new file to your pages directory with ".allowed" added to the file-name of the page. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". + +For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this: + +`Faaa +`= +d454bcdac0e64fb68ba8e267543ae110 +2b9ff3fb5902c9ca5ff97bdfb239ef50 +7106d5abbc7208bfb171f2dd84b36490 +`= +`` + +You can also dynamically generate this list, by making the file executable, and writing a script (in whatever language you want), that prints the list to stdout. Every time someone tries to request the page, Nomad Network will check the allowed identities list, and only grant access to allowed users. + +By default, Nomad Network connects anonymously to all nodes. To be able to identify, and access restricted pages, you must allow identifying on a per-node basis. To allow identifying when connecting to a node, you must go to the `!Known Nodes`! list in the `![ Network ]`! part of the program, and enable the `!Identify When Connecting`! checkbox under `!Node Info`!. + +>>Files + +Like pages, you can place files you want to make available in the `!~/.nomadnetwork/storage/files`! directory. To let a peer download a file, you should create a link to it in one of your pages. + +>>Links and URLs + +Links to pages and resources in Nomad Network use a simple URL format. Here is an example: + +`!18176ffddcc8cce1ddf8e3f72068f4a6:/page/index.mu`! + +The first part is the 10 byte destination address of the node (represented as readable hexadecimal), followed by the `!:`! character. Everything after the `!:`! represents the request path. + +By convention, Nomad Network nodes maps all hosted pages under the `!/page`! path, and all hosted files under the `!/file`! path. You can create as many subdirectories for pages and files as you please, and they will be automatically mapped to corresponding request paths. + +You can omit the destination address of the node, if you are reffering to a local page or file. You must still keep the `!:`! character. In such a case, the URL to a page could look like this: + +`!:/page/other_page.mu`! + +The URL to a local file could look like this: + +`!:/file/document.pdf`! + +Links can be inserted into micron documents. See the `*Markup`* section of this guide for info on how to do so. + +''' + +TOPIC_INTERFACES = '''>Interfaces + +Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. + +The number of distinct network topologies you can create with Reticulum is more or less endless, but common to them all is that you will need to define one or more interfaces for Reticulum to use. + +The `![ Interfaces ]`! section of NomadNet lets you add, monitor, and update interfaces configured for your Reticulum instance. + +If you are starting NomadNet for the first time you will find that an `!AutoInterface`! has been added by default. This interface will try to use your available network device to communicate with other peers discovered on your local network. + +Interfaces come in many different types and can interact with physical mediums like LoRa radios or standard IP networks. + +>>Viewing Interfaces + +To view more info about an interface, navigate using the `!Up`! and `!Down`! arrow keys or by clicking with the mouse. Pressing `! < Enter >`! on a selected interface will bring you to a detailed interface view, which will show configuration parameters and realtime charts. From here you can also disable or edit the interface. To change the orientation of the TX/RX charts, press `!< V >`! for a vertical layout, and `!< H >`! for a horizontal view. + +>>Updating Interfaces + +To edit an interface, select the interface and press `!< Ctrl + E >`!. + +To remove an interface, select the interface and press `!< Ctrl + X >`!. You can also perform both of these actions from the details view. + +>>Adding Interfaces + +To add a new interface, press `!< Ctrl + A >`!. From here you can select which type of interface you want to add. Each unique interface type will have different configuration options. + +`Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b + + +>Interface Types + +>>AutoInterface + +The Auto Interface enables communication with other discoverable Reticulum nodes over autoconfigured IPv6 and UDP. It does not need any functional IP infrastructure like routers or DHCP servers, but will require at least some sort of switching medium between peers (a wired switch, a hub, a WiFi access point or similar). + +``` +Required Parameters: +Interface Name + +Optional Parameters: +Devices: Specific network devices to use +Ignored Devices: Network devices to exclude +Group ID: Create isolated networks on the same physical LAN +Discovery Scope: Can set to link, admin, site, organisation or global +``` + +The AutoInterface is ideal for quickly connecting to other Reticulum nodes on your local network without complex configuration. + +>>TCPClientInterface + +The TCP Client interface connects to remote TCP server interfaces, enabling communication over the Internet or private networks. + +``` +Required Parameters: +Target Host: Hostname or IP address of the server +Target Port: Port number to connect to + +Optional Parameters: +I2P Tunneled: Enable for connecting through I2P +KISS Framing: Enable for KISS framing for software modems +``` + +This interface is commonly used to connect to Reticulum gateways or other persistent nodes on the Internet. + +>>TCPServerInterface + +The TCP Server interface listens for incoming connections, allowing other Reticulum peers to connect to your node using TCPClientInterface. + +``` +Required Parameters: +Listen IP: IP address to bind to (0.0.0.0 for all interfaces) +Listen Port: Port number to listen on + +Optional Parameters: +Prefer IPv6: Bind to IPv6 address if available +I2P Tunneled: Enable for I2P tunnel support +Device: Specific network device to use (e,g +``` + +Useful when you want other nodes to be able to connect to your Transport instance over TCP/IP. + +>>UDPInterface + +The UDP interface allows communication over IP networks using UDP packets. + +``` +Required Parameters: +Listen IP: IP address to bind to +Listen Port: Port to listen on +Forward IP: IP address to forward to (Can be broadcast address) +Forward Port: Port to forward to + +Optional Parameters: +Device: Specific network device to use +``` + +>>I2PInterface + +The I2P interface enables connections over the Invisible Internet Protocol. The I2PInterface requires an I2P daemon to be running on your system, such as `!i2pd`! + +``` +Optional Parameters: +Peers: I2P addresses to connect to (Can be left as none if running as a Transport) +``` + + +>>RNodeInterface + +The RNode interface allows using LoRa transceivers running RNode firmware as Reticulum network interfaces. + +``` +Required Parameters: +Port: Serial port or BLE device path +Frequency: Operating frequency in MHz +Bandwidth: Channel bandwidth +TX Power: Transmit power in dBm +Spreading Factor: LoRa spreading factor (7-12) +Coding Rate: LoRa coding rate (4:5-4:8) + +Optional Parameters: +ID Callsign: Station identification +ID Interval: Identification interval in seconds +Airtime Limits: Control duty cycle +``` + +The interface includes a parameter calculator to estimate link budget, sensitivity, and data rate on the air based on your settings. + +>>RNodeMultiInterface + +The RNode Multi Interface is designed for use with specific hardware platforms that support multiple subinterfaces or virtual radios. For most LoRa hardware platforms, you will want to use the standard `!RNodeInterface`! instead. + +``` +Required Parameters: +Port: Serial port or BLE device path +Subinterfaces: Configuration for each subinterface / virtual radio port + +``` + +>>SerialInterface + +The Serial interface enables using direct serial connections as Reticulum interfaces. + +``` +Required Parameters: +Port: Serial port path +Speed: Baud rate of serial device +Databits: Number of data bits +Parity: Parity setting +Stopbits: Number of stop bits +``` + +>>KISSInterface + +The KISS interface supports packet radio modems and TNCs using the KISS protocol. + +``` +Required Parameters: + +Port: Serial port path +Speed: Baud rate of serial device +Databits: Number of data bits +Parity: Parity setting +Stopbits: Number of stop bits +Preamble: Modem preamble in milliseconds +TX Tail: Transmit tail in milliseconds +Slottime: CSMA slottime in milliseconds +Persistence: CSMA persistence value + +Optional Parameters: + +ID Callsign: Station identification +ID Interval: Identification interval in seconds +Flow Control: Enable packet flow control +``` + +>>PipeInterface + +The Pipe interface allows using external programs as Reticulum interfaces via stdin and stdout. + +``` +Required Parameters: + +Command: External command to execute + +Optional Parameters: +Respawn Delay: Seconds to wait before restarting after failure +``` + +< +>> +-∿ + For more information and to view the full Interface documentation consult the Reticulum manual or visit https://reticulum.network/manual/interfaces.html (Requires external Internet connection) + + +>Interface Access Code (IFAC) Settings + +Interface Access Codes create private networks and securely segment network traffic. Under `!Show more options`!, you'll find these IFAC settings: + +`!Virtual Network Name`! (network_name): +When added, creates a logical network that only communicates with other interfaces using the same name. This allows multiple separate Reticulum networks to exist on the same physical channel or medium. + +`!IFAC Passphrase`! (passphrase): +Sets an authentication passphrase for the interface. Only interfaces configured with the same passphrase will be able to communicate. Can be used with or without a network name. + +`!IFAC Size`! (ifac_size): +Controls the length of Interface Authentication Codes (8-512 bits). Larger sizes provide stronger security but add overhead to each packet. The default of `!8`! is usually appropriate for most uses. + +>Interface Modes + +When running a Transport node, you can configure interface modes that affect how Reticulum selects paths, propagates announces, and discovers routes: + +`!full`!: Default mode with all discovery, meshing and transport functionality +`!gateway`!: Discovers paths on behalf of other nodes +`!access_point`!: Operates as a network access point +`!roaming`!: For physically mobile interfaces +`!boundary`!: For interfaces connecting different network segments + +''' + +TOPIC_CONVERSATIONS = '''>Conversations + +Conversations in Nomad Network +''' + + +TOPIC_CHANNELS = '''>Channels & RRC + +NomadNet includes a built-in client for `*RRC`* (Reticulum Relay Chat), a real-time text chat protocol that runs over Reticulum. The reference RRC server implementation lives at https://github.com/kc1awv/rrcd. Each RRC server is called a `!hub`!, and each hub hosts one or more `!rooms`! (channels) you can join. Hubs are addressed by a Reticulum destination hash, just like nodes and conversations. + +The Channels section of NomadNet (accessible from the main menu, or by pressing the corresponding shortcut) is where you manage your hubs, view connection status, and chat in rooms. + +>>Joining a hub + +To start chatting on a hub you must first add it to your hub list: + +>>> + - Open the `![ Channels ]`! section. + - Press the shortcut to open the `*New Hub`* dialog (shown in the shortcut bar at the bottom). + - Enter the `!hub address`! (the destination hash of the hub, typically 16 hex characters / 8 bytes) and an optional display name. + - Confirm the dialog. The hub will appear in your hub list. +< + +You can sometimes receive a hub link from another user or from a node page. Activating such a link will pre-fill the New Hub dialog (and optionally a room name) for you. + +>>Connecting and listing rooms + +Once a hub is added: + +>>> + - Select the hub in the list and connect to it. Hubs can also be set to auto-reconnect. + - When connected, run `*/list`* to see public rooms hosted on this hub. + - Run `*/join `* to join a room. Joined rooms appear under the hub in your channel list. +< + +>>Chatting + +Inside a room, anything you type that does not start with a `*/`* is sent as a message to everyone in the room. The most common in-room commands are: + +>>> + - `*/help`* show the full list of commands + - `*/who`* list users in the current room + - `*/nick `* set your display name on this hub + - `*/topic [text]`* view or set the room topic + - `*/part [room]`* leave a room (defaults to the current one) + - `*/quit`* disconnect from the hub +< + +Messages support both `*markdown`* and `*micron`* formatting, depending on your render settings (see the `*Configuration Options`* topic). Because the backtick is the micron control character, you must use the `!broken-bar`! character `*¦`* in place of `*\\``* when typing micron formatting in a message. The renderer converts `*¦`* back to `*\\``* for display. + +>>Mentions and privacy + +You will be notified (and the bell may ring, depending on your configuration) when another user mentions your nick with `*@yournick`*. RRC traffic is end-to-end encrypted by Reticulum between you and the hub, but other users in the same room can read what you write there. Treat rooms as semi-public spaces. + +>>Hub etiquette + +Each hub is operated independently and may have its own rules, MOTD, ban list and operator team. Be respecful, follow the hub's MOTD, and remember that operators can kick, ban and set modes on rooms they own. + +''' + + +TOPIC_FIRST_RUN = '''>First Time Information + +Hi there. This first run message will only appear once. It contains a few pointers on getting started with Nomad Network, and getting the most out of the program. + +You're currently located in the guide section of the program. I'm sorry I had to drag you here by force, but it will only happen this one time, I promise. If you ever get lost, return here and peruse the list of topics you see on the left. I will do my best to fill it with answers to mostly anything about Nomad Network. + +To get the most out of Nomad Network, you will need a terminal that supports UTF-8 and at least 256 colors, ideally true-color. If your terminal supports true-color, you can go to the `![ Config ]`! menu item, launch the editor and change the configuration. + +It is recommended to use a terminal size of at least 135x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped. + +Nerd Fonts and true-color are enabled by default. To get the full visual experience you should have a Nerd Font installed in your terminal. You can verify this by visiting the `*Display Test`* topic in this guide and checking the `!Nerd Font Rendering Test`! section. If those glyphs render correctly, you are good to go. + +If they do not render, install a Nerd Font and configure your terminal to use it. A few common approaches: + +>>> + - `!Download from nerdfonts.com`! + Visit https://www.nerdfonts.com/font-downloads, pick a font you like + (Jet Brains Mono, Hack and Meslo are popular choices), unzip it + into `*~/.local/share/fonts`* (Linux) or install via Font Book (macOS), + then refresh the font cache with `*fc-cache -fv`* on Linux. + + - `!Package manager`! + Many distros ship Nerd Font packages. On Arch: `*pacman -S ttf-nerd-fonts-symbols`* + or one of the per-family packages. On Debian/Ubuntu look for `*fonts-firacode`* + plus the symbols-only Nerd Font package, or install manually from the + nerdfonts.com release. + + - `!Homebrew (macOS)`! + `*brew install --cask font-fira-code-nerd-font`* (or any other family). + + - `!Configure your terminal`! + After installing, set your terminal emulator's font to the Nerd Font + variant (it usually has "Nerd Font" in the name). Restart NomadNet + so the new font are picked up. +< + +If for some reason you cannot or do not want to use a Nerd Font, you can disable Nerd Font glyphs in the `![ Config ]`! menu and NomadNet will fall back to plain unicode symbols. + +Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default one that Reticulum auto-generates on your local ethernet/WiFi network, or something much more complex. This short guide won't go into any details on building networks, but you will find other entries in the guide that deal with network setup and configuration. + +At least, if Nomad Network launches, it means that it is connected to a running Reticulum instance, that should in turn be connected to `*something`*, which should get you started. + +For more some more information, you can also read the `*Introduction`* section of this guide. + +Now go out there and explore. This is still early days. See what you can find and create. + +>>>>>>>>>>>>>>> +-\u223f +< + +''' + +TOPIC_CONFIG = '''>Configuration Options + +To change the configuration of Nomad Network, you must edit the configuration file. If you did not manually specify a config path when you started the program, Nomad Net will look for a configuration in the folllowing directories: + + `!/etc/nomadnetwork`! + `!~/.config/nomadnetwork`! + `!~/.nomadnetwork`! + +If no existing configuration file is found, one will be created at `!~/.nomadnetwork/config`! by default. The default configuration file contains comments on all the different configuration options present, and explains their possible settings. + +You can open the configuration file in any text-editor, and change the options. You can also use the editor built in to this program, under the `![ Config ]`! menu item. If the built-in editor does not gain focus, and your navigation keys are not working, try hitting enter or space, which should focus the editor and let you navigate the text. + +For reference, all the configuration options are listed and explained here as well. The configuration is divided into different sections, each with their own options. + +>> Logging Section + +This section hold configuration directives related to logging output, and is delimited by the `![logging]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!loglevel = 4`! +>>>> +Sets the verbosity of the log output. Must be an integer from 0 through 7. +>>>>> +0: Log only critical information +1: Log errors and lower log levels +2: Log warnings and lower log levels +3: Log notices and lower log levels +4: Log info and lower (this is the default) +5: Verbose logging +6: Debug logging +7: Extreme logging +< + +>>> +`!destination = file`! +>>>> +Determines the output destination of logged information. Must be `!file`! or `!console`!. +< + +>>> +`!logfile = ~/.nomadnetwork/logfile`! +>>>> +Path to the log file. Must be a writable filesystem path. +< + +>> Client Section + +This section hold configuration directives related to the client behaviour and user interface of the program. It is delimited by the `![client]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!enable_client = yes`! +>>>> +Determines whether the client part of the program should be started on launch. Must be a boolean value. +< + +>>> +`!user_interface = text`! +>>>> +Selects which interface to use. Currently, only the `!text`! interface is available. +< + +>>> +`!downloads_path = ~/Downloads`! +>>>> +Sets the filesystem path to store downloaded files in. +< + +>>> +`!notify_on_new_message = yes`! +>>>> +Sets whether to output a notification character (bell or flash) to the terminal when a new message is received. +< + +>>> +`!announce_at_start = yes`! +>>>> +Determines whether your LXMF address is automatically announced when the program starts. Must be a boolean value. +< + +>>> +`!announce_interval = 360`! +>>>> +Determines how often, in minutes, your LXMF address is announced on the network. Defaults to 6 hours. +< + +>>> +`!try_propagation_on_send_fail = yes`! +>>>> +When this option is enabled, and sending a message directly to a peer fails, Nomad Network will instead deliver the message to the propagation network, for later retrieval by the recipient. +< + +>>> +`!periodic_lxmf_sync = yes`! +>>>> +Whether the program should periodically download messages from available propagation nodes in the background. +< + +>>> +`!lxmf_sync_interval = 360`! +>>>> +The number of minutes between each automatic sync. The default is equal to 6 hours. +< + +>>> +`!lxmf_sync_limit = 8`! +>>>> +On low-bandwidth networks, it can be useful to limit the amount of messages downloaded in each sync. The default is 8. Set to 0 to download all available messages every time a sync occurs. +< + +>>> +`!required_stamp_cost = None`! +>>>> +You can specify a required stamp cost for inbound messages to be accepted. Specifying a stamp cost will require untrusted senders that message you to include a cryptographic stamp in their messages. Performing this operation takes the sender an amount of time proportional to the stamp cost. As a rough estimate, a stamp cost of 8 will take less than a second to compute, and a stamp cost of 20 could take several minutes, even on a fast computer. +< + +>>> +`!accept_invalid_stamps = False`! +>>>> +You can signal stamp requirements to senders, but still accept messages with invalid stamps by setting this option to True. +< + +>>> +`!max_accepted_size = 500`! +>>>> +The maximum accepted unpacked size for messages received directly from other peers, specified in kilobytes. Messages larger than this will be rejected before the transfer begins. +< + +>>> +`!compact_announce_stream = yes`! +>>>> +With this option enabled, Nomad Network will only display one entry in the announce stream per destination. Older announces are culled when a new one arrives. +< + +>>> +`!compose_in_markdown = yes`! +>>>> +Configures whether sent messages are composed in markdown format. +< + +>> RRC Section + +This section hold configuration directives related to the RRC client behaviour. It is delimited by the `![rrc]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!history_per_room_cap = 500`! +>>>> +Maximum number of messages retained per room in the in-memory scrollback buffer, and the number of messages restored from on-disk history at startup. The on-disk log itself is appended to indefinitely; this cap only controls how much backlog is visible. Set to 0 to keep every message in memory. +< + +>>> +`!filter_loaded_history = yes`! +>>>> +Whether to filter notices and system message when initially loading room history. +< + +>>> +`!ephemeral_notices = 10`! +>>>> +If enabled, notices and system messages will disappear from the history after a while. 0 disables, otherwise sets the timeout in minutes. +< + +>>> +`!render_markdown = yes`! +>>>> +Whether or not to render markdown formatting in messages. +< + +>>> +`!render_micron = yes`! +>>>> +Whether or not to render micron formatting in messages. When using micron in messages, use the ¦ character in place of backticks. +< + +>>> +`!nick_colors = yes`! +>>>> +Whether or not to render RRC nicks in distinct colors based on identity hash. +< + +>>> +`!mention_color = None`! +>>>> +Sets a specific color for mentions of your nick, for example FFBB44. +< + +>>> +`!color_mention_timestamps = yes`! +>>>> +Whether or not to color timestamps of messages you were mentioned in. +< + +>>> +`!justify_msgs = yes`! +`!space_msgs = no`! +`!show_gutters = no`! +>>>> +Rendering layout options for messages. +< + +>> Text UI Section + +This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!intro_time = 1`! +>>>> +Number of seconds to display the intro screen. Set to 0 to disable the intro screen. +< + +>>> +`!intro_text = Nomad Network`! +>>>> +The text to display on the intro screen. +< + +>>> +`!editor = editor`! +>>>> +What editor program to use when launching a text editor from within the program. Defaults to the `!editor`! alias, which in turn will use the default editor of the operating system. +< + +>>> +`!glyphs = unicode`! +>>>> +Determines what set of glyphs the program uses for rendering the user interface. +>>>>> +The `!plain`! set only uses ASCII characters, and should work on all terminals, but is rather boring. +The `!unicode`! set uses more interesting glyphs and icons, and should work on most terminals. This is the default. +The `!nerdfont`! set allows using a much wider range of glyphs, icons and graphics, and should be enabled if you are using a Nerd Font in your terminal. +< + +>>> +`!mouse_enabled = yes`! +>>>> +Determines whether the program should react to mouse/touch input. Must be a boolean value. +< + +>>> +`!sanitize_names = yes`! +>>>> +This option allows sanitizing announced names before they are displayed in the user interface. +< + +>>> +`!clipboard_copy = no`! +>>>> +If your terminal supports it, you can enable copy-to-clipboard functionality using OSC52 escape sequences. +< + +>>> +`!hide_guide = no`! +>>>> +This option allows hiding the `![ Guide ]`! section of the program. +< + +>>> +`!animation_interval = 1`! +>>>> +Sets the animation refresh rate for certain animations and graphics in the program. Must be an integer. +< + +>>> +`!colormode = 256`! +>>>> +Tells the program what color palette is supported by the terminal. Most terminals support `!256`! colors. If your terminal supports full-color / RGB-mode, set to `!24bit`!. Available options: +>>>>> +`!monochrome`! Single-color (black/white) palette, for monochrome displays +`!16`! Low-color mode for really old-school terminals +`!88`! Standard palletised color-mode for terminals +`!256`! Almost all modern terminals support this mode +`!24bit`! Most new terminals support this full-color mode +< + +>>> +`!theme = dark`! +>>>> +What color theme to use. Set it to match your terminal theme. Can be either `!dark`! or `!light`!. +< + +>> Node Section + +This section holds configuration directives related to the node hosting. It is delimited by the `![node]`! header in the configuration file. Available directives, along with example values, are as follows: + +>>> +`!enable_node = no`! +>>>> +Determines whether the node server should be started on launch. Must be a boolean value, and is turned off by default. +< + +>>> +`!node_name = DisplayName's Node`! +>>>> +Defines what the announced name of the node should be. +< + +>>> +`!announce_at_start = yes`! +>>>> +Determines whether your node is automatically announced on the network when the program starts. Must be a boolean value. +< + +>>> +`!announce_interval = 360`! +>>>> +Determines how often, in minutes, your node is announced on the network. Defaults to 6 hours. +< + +>>> +`!pages_path = ~/.nomadnetwork/storage/pages`! +>>>> +Determines where the node server will look for hosted pages. Must be a readable filesystem path. +< + +>>> +`!page_refresh_interval = 0`! +>>>> +Determines the interval in minutes for rescanning the hosted pages path. By default, this option is disabled, and the pages path will only be scanned on startup. +< + +>>> +`!files_path = ~/.nomadnetwork/storage/files`! +>>>> +Determines where the node server will look for downloadable files. Must be a readable filesystem path. +< + +>>> +`!file_refresh_interval = 0`! +>>>> +Determines the interval in minutes for rescanning the hosted files path. By default, this option is disabled, and the files path will only be scanned on startup. +< + +>>> +`!disable_propagation = yes`! +>>>> +When Nomad Network is hosting a node, it can also run an LXMF propagation node. If there is already a large amount of propagation nodes on the network, or you simply want to run a pageserving-only node, you can disable running a propagation node. +< + +>>> +`!message_storage_limit = 2000`! +>>>> +Configures the maximum amount of storage, in megabytes, that the LXMF Propagation Node will use to store messages. +< + +>>> +`!max_transfer_size = 256`! +>>>> +The maximum accepted transfer size per incoming propagation transfer, in kilobytes. This also sets the upper limit for the size of single messages accepted onto this propagation node. If a node wants to propagate a larger number of messages to this node, than what can fit within this limit, it will prioritise sending the smallest, newest messages first, and try with any remaining messages at a later point. +< + +>>> +`!prioritise_destinations = 41d20c727598a3fbbdf9106133a3a0ed, d924b81822ca24e68e2effea99bcb8cf`! +>>>> +Configures the LXMF Propagation Node to prioritise storing messages for certain destinations. If the message store reaches the specified limit, LXMF will prioritise keeping messages for destinations specified with this option. This setting is optional, and generally you do not need to use it. +< + +>>> +`!max_peers = 25`! +>>>> +Configures the maximum number of other nodes the LXMF Propagation Node will automatically peer with. The default is 50, but can be lowered or increased according to available resources. +< + +>>> +`!static_peers = e17f833c4ddf8890dd3a79a6fea8161d, 5a2d0029b6e5ec87020abaea0d746da4`! +>>>> +Configures the LXMF Propagation Node to always maintain propagation node peering with the specified list of destination hashes. +< + +>> Printing Section + +This section holds configuration directives related to printing. It is delimited by the `![printing]`! header in the configuration file. Available directives, along with example values, are as follows: + +>>> +`!print_messages = no`! +>>>> +Determines whether messages should be printed upon arrival. Must be a boolean value, and is turned off by default. +< + +>>> +`!message_template = ~/.nomadnetwork/print_template_msg.txt`! +>>>> +Determines where the template for printed messages is found. Must be a filesystem path. If you set this path to a non-existing file, an example will be generated in the specified location. +< + +>>> +`!print_from = 76fe5751a56067d1e84eef3e88eab85b, trusted`! +>>>> +Determines from which destinations messages are printed. Can be a list of destinations hashes, the keyword "trusted", or "everywhere". +< + +>>> +`!print_command = lp -d PRINTER_NAME -o cpi=16 -o lpi=8`! +>>>> +Specifies the command that Nomad Network uses to print the message. Defaults to "lp". The above example works well for small thermal-roll printers. +< + +>Ignoring Destinations + +If you encounter peers or nodes on the network, that you would rather not see in your client, you can add them to the `!~/.nomadnetwork/ignored`! file. To ignore nodes or peers, add one 32-character hexadecimal destination hash per line to the file. To unignore one again, simply remove the corresponding entry from the file and restart Nomad Network. +''' + +TOPIC_NETWORKS = '''>Network Configuration + +Nomad Network uses the Reticulum Network Stack for communication and encryption. This means that it will use any interfaces and communications channels already defined in your Reticulum configuration. + +Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. The number of distinct network topologies you can create with Reticulum is more or less endless, but common to them all is that you will need to define one or more interfaces for Reticulum to use. + +If you have not changed the default Reticulum configuration, which should be located at `!~/.reticulum/config`!, you will have one interface active right now. With it, you should be able to communicate with any other peers and systems that exist on your local ethernet or WiFi network, if your computer is connected to one, and most probably nothing else outside of that. + +To learn how to configure your Reticulum setup to use LoRa radios, packet radio or other interfaces, or connect to other Reticulum networks via the Internet, the best places to start is to read the relevant parts of the Reticulum Manual, which can be found on GitHub: + +`c`_https://markqvist.github.io/Reticulum/manual/interfaces.html`_ +`l + +If you don't currently have access to the Internet, you can generate a configuration file full of examples of all the supported interface types, by using the command `!rnsd --exampleconfig`!. Using those examples, it should be possible to get a working setup going. + +For future reference, you can download the Reticulum Manual in PDF format here: + +`c`_https://github.com/markqvist/Reticulum/raw/master/docs/Reticulum%20Manual.pdf`_ +`l + +It might be nice to keep that handy when you are not connected to the Internet, as it is full of information and examples that are also very relevant to Nomad Network. + +>The Distributed Backbone + +If you have Internet access, and just want to get started experimenting, you can connected to the distributed global Reticulum backbone. For more information about this, see the Reticulum Manual, or sites such as https://directory.rns.recips or https://rmap.world. + +If you connect to the public backbone, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: + +To browse pages on a node that is not currently known, open the URL dialog in the `![ Network ]`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!< Go >`! or press enter. Nomadnet will attempt to discover and connect to the requested node. You can save the currently connected node by pressing `!Ctrl+S`!. +''' + +TOPIC_DISPLAYTEST = '''>Markup & Color Display Test + + +`cYou can use this section to gauge how well your terminal reproduces the various types of formatting used by Nomad Network. +`` + + +>>>>>>>>>>>>>>>>>>>> +-\u223f +< + + +>> +`a`!This line should be bold, and aligned to the left`! + +`c`*This one should be italic and centered`* + +`r`_And this one should be underlined, aligned right`_ +`` + +The following line should contain a red gradient bar: +`B100 `B200 `B300 `B400 `B500 `B600 `B700 `B800 `B900 `Ba00 `Bb00 `Bc00 `Bd00 `Be00 `Bf00`b + +The following line should contain a green gradient bar: +`B010 `B020 `B030 `B040 `B050 `B060 `B070 `B080 `B090 `B0a0 `B0b0 `B0c0 `B0d0 `B0e0 `B0f0`b + +The following line should contain a blue gradient bar: +`B001 `B002 `B003 `B004 `B005 `B006 `B007 `B008 `B009 `B00a `B00b `B00c `B00d `B00e `B00f`b + +The following line should contain a grayscale gradient bar: +`Bg06 `Bg13 `Bg20 `Bg26 `Bg33 `Bg40 `Bg46 `Bg53 `Bg59 `Bg66 `Bg73 `Bg79 `Bg86 `Bg92 `Bg99`b + +Unicode Glyphs : \u2713 \u2715 \u26a0 \u24c3 \u2193 + +Nerd Font Glyphs : \uf484 \U000f04c5 \U000f0219 \U000f0002 \uf415 \uf023 \uf06e + + +>>Nerd Font Rendering Test + +`cSince Nerd Font glyphs and true-color are enabled by default, the rows below should render as crisp icons rather than empty squares, question marks, or fallback ASCII. If any glyph appears blank or boxed, your terminal is not using a Nerd Font, or the font is missing the required glyph range. +`` + +>>> +Common UI icons : \uf484 \uf415 \uf023 \uf06e \uf055 \uf056 \uf059 +Status / state : \U000f04c5 \U000f0219 \U000f0002 \U000f1397 \U000f12fc \U000f0156 +Network / radio : \U000f05a9 \U000f05aa \U000f05ab \U000f05ac \uf1eb \uf012 +People / messaging: \uf0c0 \uf007 \uf2bd \uf0e0 \uf27a \uf086 +Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0 +< + +`cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions. +`` +''' + + +TOPIC_LICENSES = '''>Thanks, Acknowledgements and Licenses + +This program uses various other software components, without which Nomad Network would not have been possible. Sincere thanks to the authors and contributors of the following projects + +>>> + - `!Cryptography.io`! by `*pyca`* + https://cryptography.io/ + BSD License + + - `!Urwid`! by `*Ian Ward`* + http://urwid.org/ + LGPL-2.1 License + + - `!Additional Urwid Widgets`! by `*AFoeee`* + https://github.com/AFoeee/additional_urwid_widgets + MIT License + + - `!Scrollable`! by `*rndusr`* + https://github.com/rndusr/stig/blob/master/stig/tui/scroll.py + GPLv3 License + + - `!Configobj`! by `*Michael Foord`* + https://github.com/DiffSK/configobj + BSD License + + - `!Reticulum Network Stack`! by `*unsignedmark`* + https://github.com/markqvist/Reticulum + MIT License + + - `!LXMF`! by `*unsignedmark`* + https://github.com/markqvist/LXMF + MIT License +''' + + +TOPIC_MARKUP = '''>Outputting Formatted Text + + +>>>>>>>>>>>>>>> +-\u223f +< + +`c`!Hello!`! This is output from `*micron`* +Micron generates formatted text for your terminal +`a + +>>>>>>>>>>>>>>> +-\u223f +< + + +Nomad Network supports a simple and functional markup language called `*micron`*. If you are familiar with `*markdown`* or `*HTML`*, you will feel right at home writing pages with micron. + +With micron you can easily create structured documents and pages with formatting, colors, glyphs and icons, ideal for display in terminals. + +>Table of Contents + + `F79d`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f + `F79d`_`[Micron Tags`#micron-tags]`_`f + `F79d`_`[High Level Stuff`#high-level-stuff]`_`f + `F79d`_`[Colors`#colors]`_`f + `F79d`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f + `F79d`_`[Links`#links]`_`f + `F79d`_`[Anchors`#anchors]`_`f + `F79d`_`[Tables`#tables]`_`f + `F79d`_`[Fields & Requests`#fields-requests]`_`f + `F79d`_`[Comments`#comments]`_`f + `F79d`_`[Partials`#partials]`_`f + `F79d`_`[Literals`#literals]`_`f + `F79d`_`[Closing Remarks`#closing-remarks]`_`f + +>>Recommendations and Requirements + +While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal `*must`* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better. + +Formatting such as `_underline`_, `!bold`! or `*italics`* will be displayed if your terminal supports it. + +If you are having trouble getting micron output to display correctly, try using `*gnome-terminal`* or `*alacritty`*, which should work with all formatting options out of the box. Most other terminals will work fine as well, but you might have to change some settings to get certain formatting to display correctly. + +>>>Encoding + +All micron sources are intepreted as UTF-8, and micron assumes it can output UTF-8 characters to the terminal. If your terminal does not support UTF-8, output will be faulty. + +>>>Colors + +Shading and coloring text and backgrounds is integral to micron output, and while micron will attempt to gracefully degrade output even to 1-bit terminals, you will get the best output with terminals supporting at least 256 colors. True-color support is recommended. + +>>>Terminal Font + +While any unicode capable font can be used with micron, it's highly recommended to use a `*"Nerd Font"`* (see https://www.nerdfonts.com/), which will add a lot of extra glyphs and icons to your output. + +> A Few Demo Outputs + +`F222`Bddd + +`cWith micron, you can control layout and presentation +`a + +`` + +`B33f + +You can change background ... + +`` + +`B393 + +`r`F320... and foreground colors`f +`a + +`b + +If you want to make a break, horizontal dividers can be inserted. They can be plain, like the one below this text, or you can style them with unicode characters and glyphs, like the wavy divider in the beginning of this document. + +- + +`cText can be `_underlined`_, `!bold`! or `*italic`*. + +You can also `_`*`!`B5d5`F222combine`f`b`_ `_`Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft`F43fi`F70fn`Fe0fg`` for some fabulous effects. +`a + + +>>>Sections and Headings + +You can define an arbitrary number of sections and sub sections, each with their own named headings. Text inside sections will be automatically indented. + +- + +If you place a divider inside a section, it will adhere to the section indents. + +>>>>> +If no heading text is defined, the section will appear as a sub-section without a header. This can be useful for creating indented blocks of text, like this one. + +>Micron tags + +Tags are used to format text with micron. Some tags can appear anywhere in text, and some must appear at the beginning of a line. If you need to write text that contains a sequence that would be interpreted as a tag, you can escape it with the character \\. + +In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\`\\` tag anywhere in the markup, which will immediately remove any formatting previously specified. + +>>Alignment + +To control text alignment use the tag \\`c to center text, \\`l to left-align, \\`r to right-align, and \\`a to return to the default alignment of the document. Alignment tags must appear at the beginning of a line. Here is an example: + +`Faaa +`= +`cThis line will be centered. +So will this. +`aThe alignment has now been returned to default. +`rThis will be aligned to the right +`` +`= +`` + +The above markup produces the following output: + +`Faaa`B333 + +`cThis line will be centered. +So will this. + +`aThe alignment has now been returned to default. + +`rThis will be aligned to the right + +`` + + +>>Formatting + +Text can be formatted as `!bold`! by using the \\`! tag, `_underline`_ by using the \\`_ tag and `*italic`* by using the \\`* tag. + +Here's an example of formatting text: + +`Faaa +`= +We shall soon see `!bold`! paragraphs of text decorated with `_underlines`_ and `*italics`*. Some even dare `!`*`_combine`` them! +`= +`` + +The above markup produces the following output: + +`Faaa`B333 + +We shall soon see `!bold`! paragraphs of text decorated with `_underlines`_ and `*italics`*. Some even dare `!`*`_combine`!`*`_ them! + +`` + + +>>Sections + +To create sections and subsections, use the > tag. This tag must be placed at the beginning of a line. To specify a sub-section of any level, use any number of > tags. If text is placed after a > tag, it will be used as a heading. + +Here is an example of sections: + +`Faaa +`= +>High Level Stuff +This is a section. It contains this text. + +>>Another Level +This is a sub section. + +>>>Going deeper +A sub sub section. We could continue, but you get the point. + +>>>> +Wait! It's worth noting that we can also create sections without headings. They look like this. +`= +`` + +The above markup produces the following output: + +`Faaa`B333 +>High Level Stuff +This is a section. It contains this text. + +>>Another Level +This is a sub section. + +>>>Going deeper +A sub sub section. We could continue, but you get the point. + +>>>> +Wait! It's worth noting that we can also create sections without headings. They look like this. +`` + + +>Colors + +Foreground colors can be specified with the \\`F tag, followed by three hexadecimal characters. To return to the default foreground color, use the \\`f tag. Background color is specified in the same way, but by using the \\`B and \\`b tags. + +Here's a few examples: + +`Faaa +`= +You can use `B5d5`F222 color `f`b `Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft`F43fi`F70fn`Fe0fg`f for some fabulous effects. +`= +`` + +The above markup produces the following output: + +`Faaa`B333 + +You can use `B5d5`F222 color `f`B333 `Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft`F43fi`F70fn`Fe0fg`f for some fabulous effects. + +`` + +>Page Foreground and Background Colors + +To specify a background color for the entire page, place the `!#!bg=X`! header on one of the first lines of your page, where `!X`! is the color you want to use, for example `!444`!. If you're also using the cache control header, the background specifier must come `*after`* the cache control header. Likewise, you can specify the default text color by using the `!#!fg=X`! header. + +>Links + +Links to pages, files or other resources can be created with the \\`[ tag, which should always be terminated with a closing ]. You can create links with and without labels, it is up to you to control the formatting of links with other tags. Although not strictly necessary, it is good practice to at least format links with underlining. + +Here's a few examples: + +`Faaa +`= +Here is a link without any label: `[72914442a3689add83a09a767963f57c:/page/index.mu] + +This is a `[labeled link`72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it + +Here is `F79d`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f +`= +`` + +The above markup produces the following output: + +`Faaa`B333 + +Here is a link without any label: `[72914442a3689add83a09a767963f57c:/page/index.mu] + +This is a `[labeled link`72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it + +Here is `F79d`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/index.mu]`_`f + +`` + +When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL. + +>Anchors + +Anchors let you create jump points within a single page similar to anchors in HTML. You declare a position in the page with a name, then link to it from anywhere on the same page. + +>>Auto-anchors from headers + +Every section heading you write also becomes an anchor automatically. The anchor name is the heading text after `*slugifying`*: lowercased, with any run of non-alphanumeric characters replaced by a single hyphen, and leading or trailing hyphens stripped. So `*>Hello World`* becomes the anchor `*hello-world`*, and `*>Introduction & Setup`* becomes `*introduction-setup`*. + +>>Explicit anchors + +If you want an anchor, that isn't tied to a heading, place one anywhere in your text with the \\`: tag, followed by a name. Names may contain the characters `*A-Z`*, `*a-z`*, `*0-9`*, `*_`* and `*-`*, and end at any other character (a space, a newline, or punctuation). + +The anchor itself takes up no space and does not render. Is's just a position marker. An explicit anchor declared on an otherwise empty line binds to that line's position. + +`Faaa +`= +`:install-notes +Some installation notes for the user. + +You can also drop one mid-line. Example: see `:tip-3 ⚠ tip 3 below for caveats. +`= +`` + +>>Linking to an anchor + +Reuse the standard link syntax, with a `*#`*-prefixed URL: + +`Faaa +`= +`[Jump to Install Notes`#install-notes] +`= +`` + +When the user activates the link the browser scrolls the current page to the anchor's row. + +>>Jumping to the next section + +If the URL is just `*#`* with no name after it, the link jumps to the next \\`> header that appears after the link's own position in the document. This is convenient for "Continue ↓" buttons after a long paragraph, without having to name every section: + +`Faaa +`= +`[Continue`#] +`= +`` + +>>Anchors in external links + +If you want to link to an anchor on another page, you can include it as a request variable: + +`Faaa +`= +`[Conclusion`a8d24177d946de4f1f0a0fe1af9a1338:/page/document.mu`anchor=conclusion] +`= +`` + +>>Notes on namespaces and collisions + +Auto-anchors from headings and explicit \\`: anchors share a single namespace per page. If an explicit anchor collides with a heading slug, the first one declared is where it will jump to. + +>Tables + +You can include rendered tables by enclosing them in \\`t tags. Optionally, you can also specify alignment and max rendering width by adding these properties to the opening \\`t tag, like \\`tc30. Here's an example: + +`Faaa +`= +`t +| Name | Price | Qty | +| ---- | :---: | --: | +| `F3a3Apple`f | Free | `!5`! | +| Orange | Ask, nicely | 3 | +`t +`= +`` + +The above markup produces the following table: + +`t +| Name | Price | Qty | +| ---- | :---: | --: | +| `F3a3Apple`f | Free | `!5`! | +| Orange | Ask, nicely | 3 | +`t + +>Fields & Requests + +Nomad Network lets you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. + +>>Request Links + +Links can contain request variables and a list of fields to submit to the node-side application. You can include all fields on the page, only specific ones, and any number of request variables. To simply submit all fields on a page to a specified node-side page, create a link like this: + +`Faaa +`= +`[Submit Fields`:/page/fields.mu`*] +`= +`` + +Note the `!*`! following the extra `!\\``! at the end of the path. This `!*`! denotes `*all fields`*. You can also specify a list of fields to include: + +`Faaa +`= +`[Submit Fields`:/page/fields.mu`username|auth_token] +`= +`` + +If you want to include pre-set variables, you can do it like this: + +`Faaa +`= +`[Query the System`:/page/fields.mu`username|auth_token|action=view|amount=64] +`= +`` + +>> Fields + +Here's an example of creating a field. We'll create a field named `!user_input`! and fill it with the text `!Pre-defined data`!. Note that we are using background color tags to make the field more visible to the user: + +`Faaa +`= +A simple input field: `B444``b +`= +`` + +You must always set a field `*name`*, but you can of course omit the pre-defined value of the field: + +`Faaa +`= +An empty input field: `B444``b +`= +`` + +You can set the size of the field like this: + +`Faaa +`= +A sized input field: `B444`<16|with_size`>`b +`= +`` + +It is possible to mask fields, for example for use with passwords and similar: + +`Faaa +`= +A masked input field: `B444``b +`= +`` + +And you can of course control all parameters at the same time: + +`Faaa +`= +Full control: `B444``b +`= +`` + +Collecting the above markup produces the following output: + +`Faaa`B333 + +A simple input field: `B444``B333 + +An empty input field: `B444``B333 + +A sized input field: `B444`<16|with_size`>`B333 + +A masked input field: `B444``B333 + +Full control: `B444``B333 +`b + +>>> Checkboxes + +In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options. + +`Faaa +`= +``b Label Text` +`= +When the checkbox is checked, it's field will be set to the provided value. If there are multiple checkboxes that share the same field name, the checked values will be concatenated when they are sent to the node by a comma. +`` + +`B444``b Sign me up` + +You can also pre-check both checkboxes and radio groups by appending a |* after the field value. + +`B444``b Pre-checked checkbox` + +>>> Radio groups + +Radio groups are another input that lets the user chose from a set of options. Unlike checkboxes, radio buttons with the same field name are mutually exclusive. + +Example: + +`= +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue +`= + +will render: + +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue + +In this example, when the data is submitted, `B444` field_color`b will be set to whichever value from the list was selected. + +`` + +>Comments + +You can insert comments that will not be displayed in the output by starting a line with the # character. + +Here's an example: + +`Faaa +`= +# This line will not be displayed +This line will +`= +`` + +The above markup produces the following output: + +`Faaa`B333 + +# This line will not be displayed +This line will + +`` + + +>Partials + +You can include partials in pages, which will load asynchronously once the page itself has loaded. + +`Faaa +`= +`{f64a846313b874ee4a357040807f8c77:/page/partial_1.mu} +`= +`` + +It's also possible to set an auto-refresh interval for partials. Omit or set to 0 to disable. The following partial will update every 10 seconds. + +`Faaa +`= +`{f64a846313b874ee4a357040807f8c77:/page/refreshing_partial.mu`10} +`= +`` + +You can include field values and variables in partial updates, and by setting the `!pid`! variable, you can create links that update one or more specific partials. + +`Faaa +`= +Name: `B444``b + +`F38a`[Say hello`p:32]`f + +`{f64a846313b874e84a357039807f8c77:/page/hello_partial.mu`0`pid=32|user_name} +`= +`` + +>Literals + +To display literal content, for example source-code, or blocks of text that should not be interpreted by micron, you can use literal blocks, specified by the \\`= tag. Below is the source code of this entire document, presented as a literal block. + +- + +`= +''' +TOPIC_MARKUP += TOPIC_MARKUP.replace("`=", "\\`=") + "[ micron source for document goes here, we don't want infinite recursion now, do we? ]\n\\`=" +TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, you should be well equipped to write documents, pages and applications using micron and Nomad Network. Thank you for staying with me.\n" + + +TOPICS = { + "Introduction": TOPIC_INTRODUCTION, + "Concepts & Terminology": TOPIC_CONCEPTS, + "Channels & RRC": TOPIC_CHANNELS, + "Conversations": TOPIC_CONVERSATIONS, + "Interfaces": TOPIC_INTERFACES, + "Hosting a Node": TOPIC_HOSTING, + "Configuration Options": TOPIC_CONFIG, + "Keyboard Shortcuts": TOPIC_SHORTCUTS, + "Markup": TOPIC_MARKUP, + "Display Test": TOPIC_DISPLAYTEST, + "Network Configuration": TOPIC_NETWORKS, + "Credits & Licenses": TOPIC_LICENSES, + "First Run": TOPIC_FIRST_RUN, +} diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py new file mode 100644 index 0000000..ec0816e --- /dev/null +++ b/nomadnet/ui/textui/Helpers.py @@ -0,0 +1,31 @@ +import sys +import base64 +import urwid +import RNS + + +def osc52_copy(text): + if not text: + return False + try: + encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") + sys.stdout.write("\x1b]52;c;" + encoded + "\x07") + sys.stdout.flush() + return True + except Exception as e: + RNS.log("Could not emit clipboard escape sequence: "+str(e), RNS.LOG_ERROR) + return False + + +class ClickableIcon(urwid.Text): + _selectable = False + + def __init__(self, text, on_click=None): + super().__init__(text) + self._on_click = on_click + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and "press" in event and self._on_click is not None: + self._on_click() + return True + return False diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py new file mode 100644 index 0000000..fe7d87b --- /dev/null +++ b/nomadnet/ui/textui/Interfaces.py @@ -0,0 +1,3215 @@ +import RNS +import time +import nomadnet +import threading +from math import log10, pow + +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * +from nomadnet.vendor.AsciiChart import AsciiChart +from .ReadlineEdit import ReadlineEdit + +### GYLPHS ### +INTERFACE_GLYPHS = { + # Glyph name # Plain # Unicode # Nerd Font + ("NetworkInterfaceType", "(IP)", "\U0001f5a7", "\U000f0200"), + ("SerialInterfaceType", "(<->)", "\u2194", "\U000f065c"), + ("RNodeInterfaceType", "(R)" , "\u16b1", "\U000f043a"), + ("OtherInterfaceType", "(#)" , "\U0001f67e", "\ued95"), +} + +### HELPER ### +PLATFORM_IS_LINUX = False +try: + PLATFORM_IS_LINUX = (RNS.vendor.platformutils.is_android() or + RNS.vendor.platformutils.is_linux()) +except Exception: + pass + +def _get_interface_icon(glyphset, iface_type): + glyphset_index = 1 # Default to unicode + if glyphset == "plain": + glyphset_index = 0 # plain + elif glyphset == "nerdfont": + glyphset_index = 2 # nerdfont + + type_to_glyph_tuple = { + "BackboneInterface": "NetworkInterfaceType", + "AutoInterface": "NetworkInterfaceType", + "TCPClientInterface": "NetworkInterfaceType", + "TCPServerInterface": "NetworkInterfaceType", + "UDPInterface": "NetworkInterfaceType", + "I2PInterface": "NetworkInterfaceType", + + "RNodeInterface": "RNodeInterfaceType", + "RNodeMultiInterface": "RNodeInterfaceType", + + "SerialInterface": "SerialInterfaceType", + "KISSInterface": "SerialInterfaceType", + "AX25KISSInterface": "SerialInterfaceType", + + "PipeInterface": "OtherInterfaceType" + } + + glyph_tuple_name = type_to_glyph_tuple.get(iface_type, "OtherInterfaceType") + + for glyph_tuple in INTERFACE_GLYPHS: + if glyph_tuple[0] == glyph_tuple_name: + return glyph_tuple[glyphset_index + 1] + + # Fallback + return "(#)" if glyphset == "plain" else "\U0001f67e" if glyphset == "unicode" else "\ued95" + +def format_bytes(bytes_value): + units = ['bytes', 'KB', 'MB', 'GB', 'TB'] + size = float(bytes_value) + unit_index = 0 + + while size >= 1024.0 and unit_index < len(units) - 1: + size /= 1024.0 + unit_index += 1 + + if unit_index == 0: + return f"{int(size)} {units[unit_index]}" + else: + return f"{size:.1f} {units[unit_index]}" + +def _get_cols_rows(): + return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() + + +### PORT FUNCTIONS ### +PYSERIAL_AVAILABLE = False # If NomadNet is installed on environments with rnspure instead of rns, pyserial won't be available +try: + import serial.tools.list_ports + PYSERIAL_AVAILABLE = True +except ImportError: + class DummyPort: + def __init__(self, device, description=None, manufacturer=None, hwid=None): + self.device = device + self.description = description or device + self.manufacturer = manufacturer + self.hwid = hwid + self.vid = None + self.pid = None + +def get_port_info(): + if not PYSERIAL_AVAILABLE: + return [] + + try: + ports = serial.tools.list_ports.comports() + port_info = [] + + # Ports are sorted into categories for dropdown, priority ports appear first + priority_ports = [] # USB, ACM, bluetooth, etc + standard_ports = [] # COM, tty/s ports + + for port in ports: + desc = f"{port.device}" + if port.description and port.description != port.device: + desc += f" ({port.description})" + if port.manufacturer: + desc += f" - {port.manufacturer}" + + is_standard = ( + port.device.startswith("COM") or # windows + "/dev/ttyS" in port.device or # Linux + "Serial" in port.description + ) + + port_data = { + 'device': port.device, + 'description': desc, + 'hwid': port.hwid, + 'vid': port.vid, + 'pid': port.pid, + 'is_standard': is_standard + } + + if is_standard: + standard_ports.append(port_data) + else: + priority_ports.append(port_data) + + priority_ports.sort(key=lambda x: x['device']) + standard_ports.sort(key=lambda x: x['device']) + + return priority_ports + standard_ports + except Exception as e: + RNS.log(f"error accessing serial ports: {str(e)}", RNS.LOG_ERROR) + return [] + +def get_port_field(): + if not PYSERIAL_AVAILABLE: + return { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "/dev/ttyUSB0 or COM port (pyserial not installed)", + "validation": ["required"], + "transform": lambda x: x.strip() + } + + port_info = get_port_info() + + if len(port_info) > 1: + options = [p['description'] for p in port_info] + device_map = {p['description']: p['device'] for p in port_info} + + return { + "config_key": "port", + "type": "dropdown", + "label": "Port: ", + "options": options, + "default": options[0] if options else "", + "validation": ["required"], + "transform": lambda x: device_map[x] + } + else: + # single or no ports - use text field + default = port_info[0]['device'] if port_info else "" + placeholder = "/dev/ttyXXX (or COM port on Windows)" + + return { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": default, + "placeholder": placeholder, + "validation": ["required"], + "transform": lambda x: x.strip() + } + +### RNODE #### +def calculate_rnode_parameters(bandwidth, spreading_factor, coding_rate, noise_floor=6, antenna_gain=0, + transmit_power=17): + crn = { + 5: 1, + 6: 2, + 7: 3, + 8: 4, + } + coding_rate_n = crn.get(coding_rate, 1) + + sfn = { + 5: -2.5, + 6: -5, + 7: -7.5, + 8: -10, + 9: -12.5, + 10: -15, + 11: -17.5, + 12: -20 + } + + data_rate = spreading_factor * ( + (4 / (4 + coding_rate_n)) / (pow(2, spreading_factor) / (bandwidth / 1000))) * 1000 + + sensitivity = -174 + 10 * log10(bandwidth) + noise_floor + (sfn.get(spreading_factor, 0)) + + if bandwidth == 203125 or bandwidth == 406250 or bandwidth > 500000: + sensitivity = -165.6 + 10 * log10(bandwidth) + noise_floor + (sfn.get(spreading_factor, 0)) + + link_budget = (transmit_power - sensitivity) + antenna_gain + + if data_rate < 1000: + data_rate_str = f"{data_rate:.0f} bps" + else: + data_rate_str = f"{(data_rate / 1000):.2f} kbps" + + return { + "data_rate": data_rate_str, + "link_budget": f"{link_budget:.1f} dB", + "sensitivity": f"{sensitivity:.1f} dBm", + "raw_data_rate": data_rate, + "raw_link_budget": link_budget, + "raw_sensitivity": sensitivity + } + +class RNodeCalculator(urwid.WidgetWrap): + def __init__(self, parent_view): + self.parent_view = parent_view + self.update_alarm = None + + self.data_rate_widget = urwid.Text("Data Rate: Calculating...") + self.link_budget_widget = urwid.Text("Link Budget: Calculating...") + self.sensitivity_widget = urwid.Text("Sensitivity: Calculating...") + + self.noise_floor_edit = ReadlineEdit("", "0") + self.antenna_gain_edit = ReadlineEdit("", "0") + + layout = urwid.Pile([ + urwid.Divider("-"), + + urwid.Columns([ + (28, urwid.Text(("key", "Enter Noise Floor (dB): "), align="right")), + self.noise_floor_edit + ]), + + urwid.Columns([ + (28, urwid.Text(("key", "Enter Antenna Gain (dBi): "), align="right")), + self.antenna_gain_edit + ]), + + urwid.Divider(), + + urwid.Text(("connected_status", "On-Air Calculations:"), align="left"), + self.data_rate_widget, + self.link_budget_widget, + self.sensitivity_widget, + urwid.Divider(), + + urwid.Text([ + "These calculations will update as you change RNode parameters" + ]) + ]) + + super().__init__(layout) + + self.connect_all_field_signals() + + self.update_calculation() + + def connect_all_field_signals(self): + urwid.connect_signal(self.noise_floor_edit, 'change', self._queue_update) + urwid.connect_signal(self.antenna_gain_edit, 'change', self._queue_update) + rnode_fields = ['bandwidth', 'spreadingfactor', 'codingrate', 'txpower'] + + for field_name in rnode_fields: + if field_name in self.parent_view.fields: + + field_widget = self.parent_view.fields[field_name]['widget'] + + if hasattr(field_widget, 'edit_text'): + urwid.connect_signal(field_widget, 'change', self._queue_update) + elif hasattr(field_widget, '_emit') and 'change' in getattr(field_widget, 'signals', []): + urwid.connect_signal(field_widget, 'change', self._queue_update) + + def _queue_update(self, widget, new_text): + if self.update_alarm is not None: + try: + self.parent_view.parent.app.ui.loop.remove_alarm(self.update_alarm) + except: + pass + + self.update_alarm = self.parent_view.parent.app.ui.loop.set_alarm_in( + 0.3, self._delayed_update) + + def _delayed_update(self, loop, user_data): + self.update_alarm = None + self.update_calculation() + + def update_calculation(self): + try: + + try: + bandwidth_widget = self.parent_view.fields.get('bandwidth', {}).get('widget') + bandwidth = int(bandwidth_widget.get_value()) if bandwidth_widget else 125000 + except (ValueError, AttributeError): + bandwidth = 125000 + + try: + sf_widget = self.parent_view.fields.get('spreadingfactor', {}).get('widget') + spreading_factor = int(sf_widget.get_value()) if sf_widget else 7 + except (ValueError, AttributeError): + spreading_factor = 7 + + try: + cr_widget = self.parent_view.fields.get('codingrate', {}).get('widget') + coding_rate = int(cr_widget.get_value()) if cr_widget else 5 + if isinstance(coding_rate, str) and ":" in coding_rate: + coding_rate = int(coding_rate.split(":")[1]) + except (ValueError, AttributeError): + coding_rate = 5 + + try: + txpower_widget = self.parent_view.fields.get('txpower', {}).get('widget') + if hasattr(txpower_widget, 'edit_text'): + txpower_text = txpower_widget.edit_text.strip() + txpower = int(txpower_text) if txpower_text else 17 + else: + txpower = int(txpower_widget.get_value()) if txpower_widget else 17 + except (ValueError, AttributeError): + txpower = 17 + + try: + noise_floor_text = self.noise_floor_edit.edit_text.strip() + noise_floor = int(noise_floor_text) if noise_floor_text else 0 + except (ValueError, AttributeError): + noise_floor = 0 + + try: + antenna_gain_text = self.antenna_gain_edit.edit_text.strip() + antenna_gain = int(antenna_gain_text) if antenna_gain_text else 0 + except (ValueError, AttributeError): + antenna_gain = 0 + + result = calculate_rnode_parameters( + bandwidth=bandwidth, + spreading_factor=spreading_factor, + coding_rate=coding_rate, + noise_floor=noise_floor, + antenna_gain=antenna_gain, + transmit_power=txpower + ) + + self.data_rate_widget.set_text(f"Data Rate: {result['data_rate']}") + self.link_budget_widget.set_text(f"Link Budget: {result['link_budget']}") + self.sensitivity_widget.set_text(f"Sensitivity: {result['sensitivity']}") + + except (ValueError, KeyError, TypeError) as e: + self.data_rate_widget.set_text(f"Data Rate: Waiting for parameters...") + self.link_budget_widget.set_text(f"Link Budget: Waiting for valid parameters...") + self.sensitivity_widget.set_text(f"Sensitivity: Waiting for parameters...") + +### INTERFACE FIELDS ### +COMMON_INTERFACE_OPTIONS = [ + { + "config_key": "network_name", + "type": "edit", + "label": "Virtual Network Name: ", + "placeholder": "Optional virtual network name", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "passphrase", + "type": "edit", + "label": "IFAC Passphrase: ", + "placeholder": "IFAC authentication passphrase", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "ifac_size", + "type": "edit", + "label": "IFAC Size: ", + "placeholder": "8 - 512", + "default": "", + "validation": ['number'], + "transform": lambda x: x.strip() + }, + { + "config_key": "bitrate", + "type": "edit", + "label": "Inferred Bitrate: ", + "placeholder": "Automatically determined", + "default": "", + "validation": ['number'], + "transform": lambda x: x.strip() + }, +] + +INTERFACE_FIELDS = { + "BackboneInterface": [ + { + "config_key": "listen_on", + "type": "edit", + "label": "Listen On: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "default": "", + "placeholder": "e.g., eth0", + "transform": lambda x: x.strip() + }, + { + "config_key": "remote", + "type": "edit", + "label": "Remote: ", + "default": "", + "placeholder": "e.g., a remote TCPServerInterface location", + "transform": lambda x: x.strip() + }, + { + "config_key": "target_host", + "type": "edit", + "label": "Target Host: ", + "default": "", + "placeholder": "e.g., 201:5d78:af73:5caf:a4de:a79f:3278:71e5", + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Target Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "config_key": "prefer_ipv6", + "type": "checkbox", + "label": "", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + ], + "AutoInterface": [ + { + + }, + { + "additional_options": [ + { + "config_key": "devices", + "type": "multilist", + "label": "Devices: ", + "validation": [], + "transform": lambda x: ",".join(x) + }, + { + "config_key": "ignored_devices", + "type": "multilist", + "label": "Ignored Devices: ", + "validation": [], + "transform": lambda x: ",".join(x) + }, + { + "config_key": "group_id", + "type": "edit", + "label": "Group ID: ", + "default": "", + "placeholder": "e.g., my_custom_network", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "discovery_scope", + "type": "dropdown", + "label": "Discovery Scope: ", + "options": ["None", "link", "admin", "site", "organisation", "global"], + "default": "None", + "validation": [], + "transform": lambda x: "" if x == "None" else x.strip() + } + ] + }, + ], + "I2PInterface": [ + { + "config_key": "peers", + "type": "multilist", + "label": "Peers: ", + "placeholder": "", + "validation": ["required"], + "transform": lambda x: ",".join(x) + } + ], + "TCPServerInterface": [ + { + "config_key": "listen_ip", + "type": "edit", + "label": "Listen IP: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "listen_port", + "type": "edit", + "label": "Listen Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "additional_options": [ + { + "config_key": "prefer_ipv6", + "type": "checkbox", + "label": "Prefer IPv6?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "i2p_tunneled", + "type": "checkbox", + "label": "I2P Tunneled?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "placeholder": "A specific network device to listen on - e.g. eth0", + "default": "", + "validation": [], + "transform": lambda x: x.strip() if x.strip() else None + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + ] + } + ], + "TCPClientInterface": [ + { + "config_key": "target_host", + "type": "edit", + "label": "Target Host: ", + "default": "", + "placeholder": "e.g., 127.0.0.1", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "target_port", + "type": "edit", + "label": "Target Port: ", + "default": "", + "placeholder": "e.g., 8080", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "additional_options": [ + { + "config_key": "i2p_tunneled", + "type": "checkbox", + "label": "I2P Tunneled?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "kiss_framing", + "type": "checkbox", + "label": "KISS Framing?", + "default": False, + "validation": [], + "transform": lambda x: bool(x) + } + ] + } + ], + "UDPInterface": [ + { + "config_key": "listen_ip", + "type": "edit", + "label": "Listen IP: ", + "default": "", + "placeholder": "e.g., 0.0.0.0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "listen_port", + "type": "edit", + "label": "Listen Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "config_key": "forward_ip", + "type": "edit", + "label": "Forward IP: ", + "default": "", + "placeholder": "e.g., 255.255.255.255", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "forward_port", + "type": "edit", + "label": "Forward Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + { + "additional_options": [ + { + "config_key": "device", + "type": "edit", + "label": "Device: ", + "placeholder": "A specific network device to listen on - e.g. eth0", + "default": "", + "validation": [], + "transform": lambda x: x.strip() + }, + { + "config_key": "port", + "type": "edit", + "label": "Port: ", + "default": "", + "placeholder": "e.g., 4242", + "validation": ["number"], + "transform": lambda x: int(x.strip()) if x.strip() else None + }, + ] + } + ], + "RNodeInterface": [ + get_port_field(), + { + "config_key": "frequency", + "type": "edit", + "label": "Frequency (MHz): ", + "default": "", + "placeholder": "868.5", + "validation": ["required", "float"], + "transform": lambda x: int(float(x.strip()) * 1000000) if x.strip() else 868500000 + }, + { + "config_key": "txpower", + "type": "edit", + "label": "Transmit Power (dBm): ", + "default": "", + "placeholder": "17", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) if x.strip() else 17 + }, + { + "config_key": "bandwidth", + "type": "dropdown", + "label": "Bandwidth (Hz): ", + "options": ["7800", "10400", "15600", "20800", "31250", "41700", "62500", "125000", "250000", + "500000", "1625000"], + "default": "7800", + "validation": ["required"], + "transform": lambda x: int(x) + }, + { + "config_key": "spreadingfactor", + "type": "dropdown", + "label": "Spreading Factor: ", + "options": ["7", "8", "9", "10", "11", "12"], + "default": "7", + "validation": ["required"], + "transform": lambda x: int(x) + }, + { + "config_key": "codingrate", + "type": "dropdown", + "label": "Coding Rate: ", + "options": ["4:5", "4:6", "4:7", "4:8"], + "default": "4:5", + "validation": ["required"], + "transform": lambda x: int(x.split(":")[1]) + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "airtime_limit_long", + "type": "edit", + "label": "Airtime Limit Long (Seconds): ", + "placeholder": "e.g. 1.5", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "airtime_limit_short", + "type": "edit", + "label": "Airtime Limit Short (Seconds): ", + "placeholder": "e.g. 33", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + ] + } + ], + "RNodeMultiInterface": [ + get_port_field(), + { + "config_key": "subinterfaces", + "type": "multitable", + "fields": { + "frequency": { + "label": "Freq (Hz)", + "type": "edit", + "validation": ["required", "float"], + "transform": lambda x: int(x) if x else None + }, + "bandwidth": { + "label": "BW (Hz)", + "type": "edit", + "options": ["7800", "10400", "15600", "20800", "31250", "41700", "62500", "125000", "250000", "500000", "1625000"], + "transform": lambda x: int(x) if x else None + }, + "txpower": { + "label": "TX (dBm)", + "type": "edit", + "validation": ["required", "number"], + "transform": lambda x: int(x) if x else None + }, + "vport": { + "label": "V.Port", + "type": "edit", + "validation": ["required", "number"], + "transform": lambda x: int(x) if x else None + }, + "spreadingfactor": { + "label": "SF", + "type": "edit", + "transform": lambda x: int(x) if x else None + }, + "codingrate": { + "label": "CR", + "type": "edit", + "transform": lambda x: int(x) if x else None + } + }, + "validation": ["required"], + "transform": lambda x: x + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + } + ] + } + ], + "SerialInterface": [ + get_port_field(), + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + ], + "PipeInterface": [ + { + "config_key": "command", + "type": "edit", + "label": "Command: ", + "default": "", + "placeholder": "e.g. netcat -l 5757", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "respawn_delay", + "type": "edit", + "label": "Respawn Delay (seconds): ", + "default": "", + "placeholder": "e.g. 5", + "validation": ["number"], + "transform": lambda x: x.strip() + }, + ], + "KISSInterface": [ + get_port_field(), + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "preamble", + "type": "edit", + "label": "Preamble (miliseconds): ", + "default": "", + "placeholder": "e.g. 150", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "txtail", + "type": "edit", + "label": "TX Tail (miliseconds): ", + "default": "", + "placeholder": "e.g. 10", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "slottime", + "type": "edit", + "label": "slottime (miliseconds): ", + "default": "", + "placeholder": "e.g. 20", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "persistence", + "type": "edit", + "label": "Persistence (miliseconds): ", + "default": "", + "placeholder": "e.g. 200", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "additional_options": [ + { + "config_key": "id_callsign", + "type": "edit", + "label": "ID Callsign: ", + "default": "", + "placeholder": "e.g. MYCALL-0", + "validation": [""], + "transform": lambda x: x.strip() + }, + { + "config_key": "id_interval", + "type": "edit", + "label": "ID Interval (Seconds): ", + "placeholder": "e.g. 600", + "default": "", + "validation": ['number'], + "transform": lambda x: "" if x == "" else int(x) + }, + { + "config_key": "flow_control", + "type": "checkbox", + "label": "Flow Control ", + "validation": [], + "transform": lambda x: "" if x == "" else bool(x) + }, + ] + } + ], + "AX25KISSInterface": [ + get_port_field(), + { + "config_key": "callsign", + "type": "edit", + "label": "Callsign: ", + "default": "", + "placeholder": "e.g. NO1CLL", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "ssid", + "type": "edit", + "label": "SSID: ", + "default": "", + "placeholder": "e.g. 0", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "speed", + "type": "edit", + "label": "Speed (bps): ", + "default": "", + "placeholder": "e.g. 115200", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "databits", + "type": "edit", + "label": "Databits: ", + "default": "", + "placeholder": "e.g. 8", + "validation": ["required", "number"], + "transform": lambda x: int(x.strip()) + }, + { + "config_key": "parity", + "type": "edit", + "label": "Parity: ", + "default": "", + "placeholder": "", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "stopbits", + "type": "edit", + "label": "Stopbits: ", + "default": "", + "placeholder": "e.g. 1", + "validation": ["number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "preamble", + "type": "edit", + "label": "Preamble (miliseconds): ", + "default": "", + "placeholder": "e.g. 150", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "txtail", + "type": "edit", + "label": "TX Tail (miliseconds): ", + "default": "", + "placeholder": "e.g. 10", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "slottime", + "type": "edit", + "label": "Slottime (miliseconds): ", + "default": "", + "placeholder": "e.g. 20", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "config_key": "persistence", + "type": "edit", + "label": "Persistence (miliseconds): ", + "default": "", + "placeholder": "e.g. 200", + "validation": ["required", "number"], + "transform": lambda x: "" if x == "" else int(x.strip()) + }, + { + "additional_options": [ + { + "config_key": "flow_control", + "type": "checkbox", + "label": "Flow Control ", + "validation": [], + "transform": lambda x: "" if x == "" else bool(x) + }, + ] + } + ], + "CustomInterface": [ + { + "config_key": "type", + "type": "edit", + "label": "Interface Type: ", + "default": "", + "placeholder": "Name of custom interface class", + "validation": ["required"], + "transform": lambda x: x.strip() + }, + { + "config_key": "custom_parameters", + "type": "keyvaluepairs", + "label": "Parameters: ", + "validation": [], + "transform": lambda x: x + }, + ], + "default": [ + { + + }, + ] +} + +### INTERFACE WIDGETS #### +class SelectableInterfaceItem(urwid.WidgetWrap): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None): + self.parent = parent + self._selectable = True + self.icon = icon + self.name = name + self.is_connected = is_connected + self.is_enabled = is_enabled + self.iface_options = iface_options + + + if is_enabled: + enabled_txt = ("connected_status", "Enabled") + else: + enabled_txt = ("disconnected_status", "Disabled") + + if is_connected: + connected_txt = ("connected_status", "Connected") + else: + connected_txt = ("disconnected_status", "Disconnected") + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text(("interface_title", f"{icon} {name}")) + + title_content = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + + self.tx_widget = urwid.Text(("value", format_bytes(tx))) + self.rx_widget = urwid.Text(("value", format_bytes(rx))) + + self.status_widget = urwid.Text(enabled_txt) + self.connection_widget = urwid.Text(connected_txt) + + rows = [ + urwid.Columns([ + (10, urwid.Text(("key", "Status: "))), + (10, self.status_widget), + (3, urwid.Text(" | ")), + self.connection_widget, + ]), + + urwid.Columns([ + (10, urwid.Text(("key", "Type:"))), + urwid.Text(("value", iface_type)), + ]), + + urwid.Divider("-"), + + urwid.Columns([ + (10, urwid.Text(("key", "TX:"))), + (15, self.tx_widget), + (10, urwid.Text(("key", "RX:"))), + self.rx_widget, + ]), + ] + + pile_contents = [title_content] + rows + + pile = urwid.Pile(pile_contents) + + padded_body = urwid.Padding(pile, left=2, right=2) + + box = urwid.LineBox( + padded_body, + title=None, + #todo + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + super().__init__(box) + + def update_status_display(self): + if self.is_enabled: + self.status_widget.set_text(("connected_status", "Enabled")) + else: + self.status_widget.set_text(("disconnected_status", "Disabled")) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + + if focus: + self.title_widget.set_text( + ("interface_title_selected", f"{self.icon} {self.name}")) + else: + self.title_widget.set_text(("interface_title", f"{self.icon} {self.name}")) + + return super().render(size, focus=focus) + + def keypress(self, size, key): + if key == "up": + listbox = self.parent.box_adapter._original_widget + walker = listbox.body + + interface_items = [i for i, item in enumerate(walker) + if isinstance(item, SelectableInterfaceItem)] + + if interface_items and walker[listbox.focus_position] is self and \ + listbox.focus_position == interface_items[0]: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": + self.parent.switch_to_show_interface(self.name) + return None + return key + + def update_stats(self, tx, rx): + self.tx_widget.set_text(("value", format_bytes(tx))) + self.rx_widget.set_text(("value", format_bytes(rx))) + +class InterfaceOptionItem(urwid.WidgetWrap): + def __init__(self, parent_display, label, value): + self.parent_display = parent_display + self.label = label + self.value = value + self._selectable = True + + text_widget = urwid.Text(label, align="left") + super().__init__(urwid.AttrMap(text_widget, "list_normal", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == "enter": + self.parent_display.dismiss_dialog() + self.parent_display.switch_to_add_interface(self.value) + return None + return super().keypress(size, key) + +class InterfaceBandwidthChart: + + def __init__(self, history_length=60, glyphset="unicode"): + self.history_length = history_length + self.glyphset = glyphset + self.rx_rates = [0] * history_length + self.tx_rates = [0] * history_length + + self.prev_rx = None + self.prev_tx = None + self.prev_time = None + + self.max_rx_rate = 1 + self.max_tx_rate = 1 + + self.first_update = True + self.initialization_complete = False + self.stabilization_updates = 2 + self.update_count = 0 + + self.peak_rx_for_display = 0 + self.peak_tx_for_display = 0 + + def update(self, rx_bytes, tx_bytes): + current_time = time.time() + + if self.prev_rx is None or self.first_update: + self.prev_rx = rx_bytes + self.prev_tx = tx_bytes + self.prev_time = current_time + self.first_update = False + return + + time_delta = max(0.1, current_time - self.prev_time) + + rx_delta = max(0, rx_bytes - self.prev_rx) / time_delta + tx_delta = max(0, tx_bytes - self.prev_tx) / time_delta + + self.prev_rx = rx_bytes + self.prev_tx = tx_bytes + self.prev_time = current_time + + self.update_count += 1 + + self.rx_rates.pop(0) + self.tx_rates.pop(0) + self.rx_rates.append(rx_delta*8) + self.tx_rates.append(tx_delta*8) + + if self.update_count >= self.stabilization_updates: + self.initialization_complete = True + + self.peak_rx_for_display = max(self.peak_rx_for_display, rx_delta) + self.peak_tx_for_display = max(self.peak_tx_for_display, tx_delta) + + + current_rx_max = max(self.rx_rates) + current_tx_max = max(self.tx_rates) + + self.max_rx_rate = max(1, current_rx_max) + self.max_tx_rate = max(1, current_tx_max) + + def get_charts(self, height=8): + chart = AsciiChart(glyphset=self.glyphset) + + rx_data = self.rx_rates.copy() + tx_data = self.tx_rates.copy() + + peak_rx = self.peak_rx_for_display if self.initialization_complete else 0 + peak_tx = self.peak_tx_for_display if self.initialization_complete else 0 + + peak_rx_str = RNS.prettyspeed(peak_rx*8) + peak_tx_str = RNS.prettyspeed(peak_tx*8) + + rx_chart = chart.plot( + [rx_data], + { + 'height': height, + 'format': RNS.prettyspeed, + 'min': 0, + 'max': self.max_rx_rate * 1.1, + } + ) + + tx_chart = chart.plot( + [tx_data], + { + 'height': height, + 'format': RNS.prettyspeed, + 'min': 0, + 'max': self.max_tx_rate * 1.1, + } + ) + + return rx_chart, tx_chart, peak_rx_str, peak_tx_str + + +class ResponsiveChartContainer(urwid.WidgetWrap): + + def __init__(self, rx_box, tx_box, min_cols_for_horizontal=100): + self.rx_box = rx_box + self.tx_box = tx_box + self.min_cols_for_horizontal = min_cols_for_horizontal + + self.horizontal_layout = urwid.Columns([ + (urwid.WEIGHT, 1, self.rx_box), + (urwid.WEIGHT, 1, self.tx_box) + ]) + + self.vertical_layout = urwid.Pile([ + self.rx_box, + self.tx_box + ]) + + self.layout = urwid.WidgetPlaceholder(self.horizontal_layout) + + super().__init__(self.layout) + + def render(self, size, focus=False): + maxcol = size[0] if len(size) > 0 else 0 + + if maxcol >= self.min_cols_for_horizontal and self.layout.original_widget is not self.horizontal_layout: + self.layout.original_widget = self.horizontal_layout + elif maxcol < self.min_cols_for_horizontal and self.layout.original_widget is not self.vertical_layout: + self.layout.original_widget = self.vertical_layout + + return super().render(size, focus) + +### URWID FILLER ### +class InterfaceFiller(urwid.WidgetWrap): + def __init__(self, widget, app): + self.app = app + self.filler = urwid.Filler(widget, urwid.TOP) + super().__init__(self.filler) + + def keypress(self, size, key): + if key == "ctrl a": + # add interface + self.app.ui.main_display.sub_displays.interface_display.add_interface() + return + elif key == "ctrl x": + # remove Interface + self.app.ui.main_display.sub_displays.interface_display.remove_selected_interface() + return + elif key == "ctrl e": + # edit interface + self.app.ui.main_display.sub_displays.interface_display.edit_selected_interface() + return None + elif key == "ctrl w": + # open config file editor + self.app.ui.main_display.sub_displays.interface_display.open_config_editor() + return None + + return super().keypress(size, key) + +### VIEWS ### +class AddInterfaceView(urwid.WidgetWrap): + def __init__(self, parent, iface_type): + self.parent = parent + self.iface_type = iface_type + self.fields = {} + self.port_pile = None + self.additional_fields = {} + self.additional_pile_contents = [] + self.common_fields = {} + + self.parent.shortcuts_display.set_add_interface_shortcuts() + + name_field = FormEdit( + config_key="name", + placeholder="Enter interface name", + validation_types=["required"] + ) + self.fields['name'] = { + 'label': "Name: ", + 'widget': name_field + } + + config = INTERFACE_FIELDS.get(iface_type, INTERFACE_FIELDS["default"]) + iface_fields = [field for field in config if "config_key" in field] + + for field in iface_fields: + self._initialize_field(field) + + self._initialize_additional_fields(config) + + self._initialize_common_fields() + + pile_items = self._build_form_layout(iface_fields) + + form_pile = urwid.Pile(pile_items) + form_filler = urwid.Filler(form_pile, valign="top") + form_box = urwid.LineBox( + form_filler, + title="Add Interface", + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + background = urwid.SolidFill(" ") + self.overlay = urwid.Overlay( + top_w=form_box, + bottom_w=background, + align='center', + width=('relative', 85), + valign='middle', + height=('relative', 85), + ) + super().__init__(self.overlay) + + def _initialize_field(self, field): + if field["type"] == "dropdown": + widget = FormDropdown( + config_key=field["config_key"], + label=field.get("label", ""), + options=field["options"], + default=field.get("default"), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "checkbox": + widget = FormCheckbox( + config_key=field["config_key"], + label=field.get("label", ""), + state=field.get("default", False), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "multilist": + widget = FormMultiList( + config_key=field["config_key"], + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "multitable": + widget = FormMultiTable( + config_key=field["config_key"], + fields=field.get("fields", {}), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + elif field["type"] == "keyvaluepairs": + widget = FormKeyValuePairs( + config_key=field["config_key"], + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + else: + widget = FormEdit( + config_key=field["config_key"], + caption="", + edit_text=field.get("default", ""), + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + transform=field.get("transform") + ) + + label = field.get("label", "") + if not label: + label = " ".join(word.capitalize() for word in field["config_key"].split('_')) + ": " + + self.fields[field["config_key"]] = { + 'label': label, + 'widget': widget + } + + def _initialize_additional_fields(self, config): + for field in config: + if isinstance(field, dict) and "additional_options" in field: + for option in field["additional_options"]: + if option["type"] == "checkbox": + widget = FormCheckbox( + config_key=option["config_key"], + label=option.get("label", ""), + state=option.get("default", False), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "dropdown": + widget = FormDropdown( + config_key=option["config_key"], + label=option.get("label", ""), + options=option["options"], + default=option.get("default"), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "multilist": + widget = FormMultiList( + config_key=option["config_key"], + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + else: + widget = FormEdit( + config_key=option["config_key"], + caption="", + edit_text=str(option.get("default", "")), + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + + label = option.get("label", "") + if not label: + label = " ".join(word.capitalize() for word in option["config_key"].split('_')) + ": " + + self.additional_fields[option["config_key"]] = { + 'label': label, + 'widget': widget, + 'type': option["type"] + } + + def _initialize_common_fields(self): + if self.parent.app.rns.transport_enabled(): + # Transport mode options + COMMON_INTERFACE_OPTIONS.extend([ + { + "config_key": "outgoing", + "type": "checkbox", + "label": "Allow outgoing traffic", + "default": True, + "validation": [], + "transform": lambda x: bool(x) + }, + { + "config_key": "mode", + "type": "dropdown", + "label": "Interface Mode: ", + "options": ["full", "gateway", "access_point", "roaming", "boundary"], + "default": "full", + "validation": [], + "transform": lambda x: x + }, + { + "config_key": "announce_cap", + "type": "edit", + "label": "Announce Cap: ", + "placeholder": "Default: 2.0", + "default": "", + "validation": ["float"], + "transform": lambda x: float(x) if x.strip() else 2.0 + } + ]) + + for option in COMMON_INTERFACE_OPTIONS: + if option["type"] == "checkbox": + widget = FormCheckbox( + config_key=option["config_key"], + label=option["label"], + state=option.get("default", False), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + elif option["type"] == "dropdown": + widget = FormDropdown( + config_key=option["config_key"], + label=option["label"], + options=option["options"], + default=option.get("default"), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + else: + widget = FormEdit( + config_key=option["config_key"], + caption="", + edit_text=str(option.get("default", "")), + placeholder=option.get("placeholder", ""), + validation_types=option.get("validation", []), + transform=option.get("transform") + ) + + self.common_fields[option["config_key"]] = { + 'label': option["label"], + 'widget': widget, + 'type': option["type"] + } + + def _on_rnode_field_change(self, widget, new_value): + if hasattr(self, 'rnode_calculator') and self.calculator_visible: + self.rnode_calculator.update_calculation() + + def _build_form_layout(self, iface_fields): + pile_items = [] + pile_items.append(urwid.Text( + ("form_title", f"Add new {_get_interface_icon(self.parent.glyphset, self.iface_type)} {self.iface_type}"), + align="center")) + pile_items.append(urwid.Divider("─")) + + for key in ["name"] + [f["config_key"] for f in iface_fields]: + field = self.fields[key] + widget = field["widget"] + + # Special case for multitable and keyvaluepairs - they already have their own layout + if isinstance(widget, (FormMultiTable, FormKeyValuePairs)): + pile_items.append(urwid.Text(("key", field["label"]), align="left")) + pile_items.append(widget) + pile_items.append(urwid.Padding(widget.error_widget, left=2)) + continue + + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget, + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + if self.iface_type in ["RNodeInterface", "RNodeMultiInterface", "SerialInterface", "AX25KISSInterface", + "KISSInterface"] and key == "port": + refresh_btn = urwid.Button("Refresh Ports", on_press=self.refresh_ports) + refresh_btn = urwid.AttrMap(refresh_btn, "button_normal", focus_map="button_focus") + refresh_row = urwid.Padding(refresh_btn, left=26, width=20) + field_pile.contents.append((refresh_row, field_pile.options())) + self.port_pile = field_pile + + pile_items.append(field_pile) + + self.more_options_visible = False + self.more_options_button = urwid.Button("Show more options", on_press=self.toggle_more_options) + self.more_options_button = urwid.AttrMap(self.more_options_button, "button_normal", focus_map="button_focus") + self.more_options_widget = urwid.Pile([]) + + self.ifac_options_visible = False + self.ifac_options_button = urwid.Button("Show IFAC options", on_press=self.toggle_ifac_options) + self.ifac_options_button = urwid.AttrMap(self.ifac_options_button, "button_normal", focus_map="button_focus") + self.ifac_options_widget = urwid.Pile([]) + + if self.iface_type in ["RNodeInterface"]: + self.calculator_button = urwid.Button("Show On-Air Calculations", on_press=self.toggle_calculator) + self.calculator_button = urwid.AttrMap(self.calculator_button, "button_normal", focus_map="button_focus") + + save_btn = urwid.Button("Save", on_press=self.on_save) + back_btn = urwid.Button("Cancel", on_press=self.on_back) + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, save_btn), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, back_btn), + ]) + + pile_items.extend([ + urwid.Divider(), + self.more_options_button, + self.more_options_widget, + ]) + if self.iface_type in ["RNodeInterface"]: + self.rnode_calculator = RNodeCalculator(self) + self.calculator_visible = False + self.calculator_widget = urwid.Pile([]) + pile_items.extend([ + self.calculator_button, + self.calculator_widget, + ]) + pile_items.extend([ + urwid.Divider("─"), + button_row, + ]) + + return pile_items + + def toggle_more_options(self, button): + if self.more_options_visible: + self.more_options_widget.contents = [] + button.base_widget.set_label("Show more options") + self.more_options_visible = False + else: + pile_contents = [] + + if self.additional_fields: + for key, field in self.additional_fields.items(): + widget = field['widget'] + + if field['type'] == "checkbox": + centered_widget = urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', widget), + ('weight', 1, urwid.Text("")) + ]) + field_pile = urwid.Pile([ + centered_widget, + urwid.Padding(widget.error_widget, left=24) + ]) + else: + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + pile_contents.append(field_pile) + + if self.additional_fields and self.common_fields: + pile_contents.append(urwid.Divider("─")) + + if self.common_fields: + for key, field in self.common_fields.items(): + widget = field['widget'] + + if field['type'] == "checkbox": + centered_widget = urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', widget), + ('weight', 1, urwid.Text("")) + ]) + field_pile = urwid.Pile([ + centered_widget, + urwid.Padding(widget.error_widget, left=24) + ]) + else: + field_pile = urwid.Pile([ + urwid.Columns([ + (26, urwid.Text(("key", field["label"]), align="right")), + widget + ]), + urwid.Padding(widget.error_widget, left=24) + ]) + + pile_contents.append(field_pile) + + if pile_contents: + self.more_options_widget.contents = [(w, self.more_options_widget.options()) for w in pile_contents] + else: + self.more_options_widget.contents = [( + urwid.Text("No additional options available", align="center"), + self.more_options_widget.options() + )] + + button.base_widget.set_label("Hide more options") + self.more_options_visible = True + + def toggle_ifac_options(self, button): + if self.ifac_options_visible: + self.ifac_options_widget.contents = [] + button.base_widget.set_label("Show IFAC options") + self.ifac_options_visible = False + else: + dummy = urwid.Text("IFAC (Interface Access Codes)", align="left") + self.ifac_options_widget.contents = [(dummy, self.more_options_widget.options())] + button.base_widget.set_label("Hide IFAC options") + self.ifac_options_visible = True + + def toggle_calculator(self, button): + if self.calculator_visible: + self.calculator_widget.contents = [] + button.base_widget.set_label("Show On-Air Calculations") + self.calculator_visible = False + else: + calculator_contents = [self.rnode_calculator] + + self.calculator_widget.contents = [(w, self.calculator_widget.options()) for w in calculator_contents] + + self.rnode_calculator.update_calculation() + + button.base_widget.set_label("Hide On-Air Calculations") + self.calculator_visible = True + + def refresh_ports(self, button): + if self.port_pile is not None: + # Get fresh port config + port_field = get_port_field() + + if port_field["type"] == "dropdown": + widget = FormDropdown( + config_key=port_field["config_key"], + label=port_field["label"], + options=port_field["options"], + default=port_field.get("default"), + validation_types=port_field.get("validation", []), + transform=port_field.get("transform") + ) + else: + widget = FormEdit( + config_key=port_field["config_key"], + caption="", + edit_text=port_field.get("default", ""), + placeholder=port_field.get("placeholder", ""), + validation_types=port_field.get("validation", []), + transform=port_field.get("transform") + ) + + self.fields["port"] = { + 'label': port_field["label"], + 'widget': widget + } + + columns = urwid.Columns([ + (26, urwid.Text(("key", port_field["label"]), align="right")), + widget + ]) + self.port_pile.contents[0] = (columns, self.port_pile.options()) + + self.port_pile.contents[1] = (urwid.Padding(widget.error_widget, left=24), self.port_pile.options()) + + def validate_all(self): + all_valid = True + + # validate main fields + for field in self.fields.values(): + if not field["widget"].validate(): + all_valid = False + + # validate additional iface fields + for field in self.additional_fields.values(): + if not field["widget"].validate(): + all_valid = False + + # validate common fields + for field in self.common_fields.values(): + if not field["widget"].validate(): + all_valid = False + + return all_valid + + def on_save(self, button): + all_valid = self.validate_all() + + name = self.fields['name']["widget"].get_value() or "Untitled interface" + + existing_interfaces = self.parent.app.rns.config['interfaces'] + if name in existing_interfaces: + self.fields['name']["widget"].error = f"Interface name '{name}' already exists" + self.fields['name']["widget"].error_widget.set_text(("error", self.fields['name']["widget"].error)) + all_valid = False + + if not all_valid: + return + + if self.iface_type == "CustomInterface": + custom_type = self.fields.get('type', {}).get('widget').get_value() + interface_config = { + "type": custom_type, + "interface_enabled": True + } + else: + interface_config = { + "type": self.iface_type, + "interface_enabled": True + } + + for field_key, field in self.fields.items(): + if field_key not in ["name", "custom_parameters", "type"]: + widget = field["widget"] + value = widget.get_value() + + if field_key == "subinterfaces" and self.iface_type == "RNodeMultiInterface" and isinstance(value, + dict): + for subname, subconfig in value.items(): + interface_config[f"{subname}"] = subconfig + elif value is not None and value != "": + interface_config[widget.config_key] = value + + if self.iface_type == "CustomInterface" and "custom_parameters" in self.fields: + custom_params = self.fields["custom_parameters"]["widget"].get_value() + if isinstance(custom_params, dict): + for param_key, param_value in custom_params.items(): + interface_config[param_key] = param_value + + for field_key, field in self.additional_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + interface_config[widget.config_key] = value + + for field_key, field in self.common_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + interface_config[widget.config_key] = value + + try: + interfaces = self.parent.app.rns.config['interfaces'] + interfaces[name] = interface_config + self.parent.app.rns.config.write() + + display_type = custom_type if self.iface_type == "CustomInterface" else self.iface_type + + new_item = SelectableInterfaceItem( + parent=self.parent, + name=name, + is_connected=False, # will always be false until restart + is_enabled=True, + iface_type=display_type, + tx=0, + rx=0, + icon=_get_interface_icon(self.parent.glyphset, display_type), + iface_options=interface_config + ) + + self.parent.interface_items.append(new_item) + self.parent._rebuild_list() + + self.show_message(f"Interface {name} added. Restart NomadNet to start using this interface") + + except Exception as e: + print(f"Error saving interface: {str(e)}") + self.show_message(f"Error: {str(e)}", title="Error") + + def on_back(self, button): + self.parent.switch_to_list() + + def show_message(self, message, title="Notice"): + def dismiss_dialog(button): + self.parent.switch_to_list() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title=title + ) + + overlay = urwid.Overlay( + dialog, + self.parent.interfaces_display, + align='center', + width=100, + valign='middle', + height=8, + min_width=1, + min_height=1 + ) + + self.parent.widget = overlay + self.parent.app.ui.main_display.update_active_sub_display() + + +class EditInterfaceView(AddInterfaceView): + def __init__(self, parent, iface_name): + self.parent = parent + self.iface_name = iface_name + + self.interface_config = parent.app.rns.config['interfaces'][iface_name] + config_type = self.interface_config.get("type", "Unknown") + + # check if this is a custom interface type + known_types = list(INTERFACE_FIELDS.keys()) + if config_type not in known_types: + self.original_type = config_type + self.iface_type = "CustomInterface" + else: + self.original_type = None + self.iface_type = config_type + + super().__init__(parent, self.iface_type) + + self.overlay.top_w.title_widget.set_text(f"Edit Interface: {iface_name}") + + self._populate_form_fields() + + def _populate_form_fields(self): + self.fields['name']['widget'].edit_text = self.iface_name + + if self.original_type and self.iface_type == "CustomInterface": + if "type" in self.fields: + self.fields["type"]["widget"].edit_text = self.original_type + + if "custom_parameters" in self.fields: + custom_params = {} + standard_keys = ["type", "interface_enabled", "enabled", "description", + "network_name", "bitrate", "passphrase", "ifac_size", + "mode", "outgoing", "announce_cap"] + + for key, value in self.interface_config.items(): + if key not in standard_keys: + custom_params[key] = value + + self.fields["custom_parameters"]["widget"].set_value(custom_params) + + for key, field in self.fields.items(): + if key not in ['name', 'type', 'custom_parameters']: + widget = field['widget'] + + if key == "subinterfaces" and isinstance(widget, FormMultiTable): + self._populate_subinterfaces(widget) + continue + + if key in self.interface_config: + value = self.interface_config[key] + + if key == 'frequency': + value = float(value) / 1000000 + value = f"{value:.6f}".rstrip('0').rstrip('.') if '.' in f"{value:.6f}" else f"{value}" + + self._set_field_value(widget, value) + + for key, field in self.additional_fields.items(): + if key in self.interface_config: + self._set_field_value(field['widget'], self.interface_config[key]) + + for key, field in self.common_fields.items(): + if key in self.interface_config: + self._set_field_value(field['widget'], self.interface_config[key]) + + def _set_field_value(self, widget, value): + if hasattr(widget, 'edit_text'): + widget.edit_text = str(value) + elif hasattr(widget, 'set_state'): + checkbox_state = value if isinstance(value, bool) else value.strip().lower() not in ( + 'false', 'off', 'no', '0') + widget.set_state(checkbox_state) + elif isinstance(widget, FormDropdown): + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + else: + # Try to match after transform + for opt in widget.options: + try: + if widget.transform(opt) == value: + widget.selected = opt + widget.main_button.base_widget.set_text(opt) + break + except: + pass + elif isinstance(widget, FormMultiList): + self._populate_multilist(widget, value) + + def _populate_multilist(self, widget, value): + items = [] + if isinstance(value, str): + items = [item.strip() for item in value.split(',') if item.strip()] + elif isinstance(value, list): + items = value + + while len(widget.entries) > 1: + widget.remove_entry(None, widget.entries[-1]) + + if items: + first_entry = widget.entries[0] + first_edit = first_entry.contents[0][0] + if len(items) > 0: + first_edit.edit_text = items[0] + + for i in range(1, len(items)): + widget.add_entry(None) + entry = widget.entries[i] + edit_widget = entry.contents[0][0] + edit_widget.edit_text = items[i] + + def _populate_subinterfaces(self, widget): + subinterfaces = {} + + for key, value in self.interface_config.items(): + if isinstance(value, dict): + if key.startswith('[[[') and key.endswith(']]]'): + clean_key = key[3:-3] # removes [[[...]]] + subinterfaces[clean_key] = value + elif key not in ["type", "interface_enabled", "enabled", "port", + "id_callsign", "id_interval"]: + subinterfaces[key] = value + + if subinterfaces: + widget.set_value(subinterfaces) + + def on_save(self, button): + if not self.validate_all(): + return + + new_name = self.fields['name']["widget"].get_value() or self.iface_name + + if new_name != self.iface_name and new_name in self.parent.app.rns.config['interfaces']: + self.fields['name']["widget"].error = f"Interface name '{new_name}' already exists" + self.fields['name']["widget"].error_widget.set_text(("error", self.fields['name']["widget"].error)) + return + + if self.iface_type == "CustomInterface": + interface_type = self.fields.get('type', {}).get('widget').get_value() + else: + interface_type = self.iface_type + + updated_config = { + "type": interface_type, + "interface_enabled": True + } + + for field_key, field in self.fields.items(): + if field_key not in ["name", "custom_parameters", "type", "subinterfaces"]: + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + if self.iface_type == "CustomInterface" and "custom_parameters" in self.fields: + custom_params = self.fields["custom_parameters"]["widget"].get_value() + if isinstance(custom_params, dict): + for param_key, param_value in custom_params.items(): + updated_config[param_key] = param_value + + elif self.iface_type == "RNodeMultiInterface" and "subinterfaces" in self.fields: + subinterfaces = self.fields["subinterfaces"]["widget"].get_value() + for subname, subconfig in subinterfaces.items(): + updated_config[subname] = subconfig + + for field_key, field in self.additional_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + for field_key, field in self.common_fields.items(): + widget = field["widget"] + value = widget.get_value() + if value is not None and value != "": + updated_config[widget.config_key] = value + + try: + interfaces = self.parent.app.rns.config['interfaces'] + + if new_name != self.iface_name: + del interfaces[self.iface_name] + interfaces[new_name] = updated_config + + for i, item in enumerate(self.parent.interface_items): + if item.name == self.iface_name: + self.parent.interface_items[i].name = new_name + break + else: + interfaces[self.iface_name] = updated_config + + self.parent.app.rns.config.write() + + display_type = interface_type + + for item in self.parent.interface_items: + if item.name == new_name: + item.iface_type = display_type + break + + self.parent._rebuild_list() + self.show_message(f"Interface {new_name} updated. Restart NomadNet for these changes to take effect") + + except Exception as e: + print(f"Error saving interface: {str(e)}") + self.show_message(f"Error updating interface: {str(e)}", title="Error") + + +class ShowInterface(urwid.WidgetWrap): + def __init__(self, parent, iface_name): + self.parent = parent + self.iface_name = iface_name + self.started = False + self.g = self.parent.app.ui.glyphs + + # get config + self.interface_config = self.parent.app.rns.config['interfaces'][iface_name] + iface_type = self.interface_config.get("type", "Unknown") + + self.parent.shortcuts_display.set_show_interface_shortcuts() + + self.config_rows = [] + + screen_cols, _ = _get_cols_rows() + margin = 22 + if screen_cols >= 145: + self.history_length = screen_cols//2-margin + else: + self.history_length = screen_cols-(margin+2) + + # get interface stats + interface_stats = self.parent.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + self.stats = stats_lookup.get(iface_name, {}) + + self.tx = self.stats.get("txb", 0) + self.rx = self.stats.get("rxb", 0) + self.is_connected = self.stats.get("status", False) + self.is_enabled = (str(self.interface_config.get("enabled")).lower() != 'false' and + str(self.interface_config.get("interface_enabled")).lower() != 'false') + + header_content = [ + urwid.Text(("interface_title", f"Interface: {iface_name}"), align="center"), + urwid.Divider("=") + ] + header = urwid.Pile(header_content) + + self.edit_button = urwid.Button("Edit", on_press=self.on_edit) + self.back_button = urwid.Button("Back", on_press=self.on_back) + + self.toggle_button = urwid.Button( + "Disable" if self.is_enabled else "Enable", + on_press=self.on_toggle_enabled + ) + + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, self.back_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.toggle_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.edit_button), + ]) + + footer_content = [ + urwid.Divider("="), + button_row + ] + footer = urwid.Pile(footer_content) + + # status widgets + self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status", + "Enabled" if self.is_enabled else "Disabled")) + + self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status", + self.parent.g['selected'] if self.is_enabled else self.parent.g[ + 'unselected'])) + + self.connection_text = urwid.Text(("connected_status" if self.is_connected else "disconnected_status", + "Connected" if self.is_connected else "Disconnected")) + + self.info_rows = [ + urwid.Columns([ + (10, urwid.Text(("key", "Type:"))), + urwid.Text(("value", f"{_get_interface_icon(self.parent.glyphset, iface_type)} {iface_type}")), + ]), + urwid.Columns([ + (10, urwid.Text(("key", "Status:"))), + (4, self.status_indicator), + (8, self.status_text), + (3, urwid.Text(" | ")), + self.connection_text, + ]), + urwid.Divider("-") + ] + + self.tx_text = urwid.Text(("value", format_bytes(self.tx))) + self.rx_text = urwid.Text(("value", format_bytes(self.rx))) + + self.stat_row = urwid.Columns([ + (10, urwid.Text(("key", "TX:"))), + (15, self.tx_text), + (10, urwid.Text(("key", "RX:"))), + self.rx_text, + ]) + + self.info_rows.append(self.stat_row) + self.info_rows.append(urwid.Divider("-")) + + self.bandwidth_chart = InterfaceBandwidthChart(history_length=self.history_length, glyphset=self.parent.glyphset) + self.bandwidth_chart.update(self.rx, self.tx) + + self.rx_chart_text = urwid.Text("Loading RX data...", align='left') + self.tx_chart_text = urwid.Text("Loading TX data...", align='left') + self.rx_peak_text = urwid.Text("Peak: 0 B/s", align='right') + self.tx_peak_text = urwid.Text("Peak: 0 B/s", align='right') + + self.rx_box = urwid.LineBox( + urwid.Pile([ + urwid.AttrMap(self.rx_chart_text, "rx"), + self.rx_peak_text + ]), + title="RX Traffic (60s)" + ) + + self.tx_box = urwid.LineBox( + urwid.Pile([ + urwid.AttrMap(self.tx_chart_text, "tx"), + self.tx_peak_text + ]), + title="TX Traffic (60s)" + ) + + self.horizontal_charts = urwid.Columns([ + (urwid.WEIGHT, 1, self.rx_box), + (urwid.WEIGHT, 1, self.tx_box) + ]) + + self.vertical_charts = urwid.Pile([ + self.rx_box, + self.tx_box + ]) + + self.disconnected_message = urwid.Filler( + urwid.Text(("disconnected_status", + "Charts not available - Interface is not connected"), + align="center"), + valign="top" + ) + self.disconnected_box = urwid.LineBox(self.disconnected_message, title="Bandwidth Charts") + + self.charts_widget = self.vertical_charts + self.is_horizontal = False + + screen_cols, _ = _get_cols_rows() + # RNS.log(screen_cols) + if screen_cols >= 145: + self.charts_widget = self.horizontal_charts + self.is_horizontal = True + + if not self.is_connected: + self.charts_widget = self.disconnected_box + + connection_params = [] + radio_params = [] + network_params = [] + ifac_params = [] + other_params = [] + + # Sort parameters into groups + for key, value in self.interface_config.items(): + # skip empty + if value is None or value == "": + continue + + # Skip these keys as their shown elsewhere + if key in ["type", "interface_enabled", "enabled", 'selected_interface_mode', 'name']: + continue + + # Connection parameters + elif key in ["port", "listen_ip", "listen_port", "target_host", "target_port", "device"]: + connection_params.append((key, value)) + + # Radio parameters + elif key in ["frequency", "bandwidth", "spreadingfactor", "codingrate", "txpower"]: + radio_params.append((key, value)) + + # Network parameters + elif key in ["network_name", "bitrate", "peers", "group_id", "multicast_address_type", + "discovery_scope", "announce_cap", "mode"]: + network_params.append((key, value)) + + # IFAC parameters + elif key in ["passphrase", "ifac_size", "ifac_netname", "ifac_netkey"]: + ifac_params.append((key, value)) + + else: + other_params.append((key, value)) + + def create_param_row(key, value): + if isinstance(value, bool): + value_str = "Yes" if value else "No" + elif key == "frequency": + int_value = int(value) + value_str = f"{int_value / 1000000:.3f} MHz" + elif key == "bandwidth": + int_value = int(value) + value_str = f"{int_value / 1000:.1f} kHz" + elif key == "passphrase": + value_str = len(value)*"*" + else: + value_str = str(value) + # format display keys: "listen_port" => Listen Port + display_key = key.replace('_', ' ').title() + + return urwid.Columns([ + (18, urwid.Text(("key", f"{display_key}:"))), + urwid.Text(("value", value_str)), + ]) + + if connection_params: + connection_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Connection Parameters"), align="left")) + for key, value in connection_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if radio_params: + radio_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Radio Parameters"), align="left")) + for key, value in radio_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if network_params: + network_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Network Parameters"), align="left")) + for key, value in network_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if ifac_params: + ifac_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "IFAC Parameters"), align="left")) + for key, value in ifac_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if other_params: + other_params.sort(key=lambda x: x[0]) + self.config_rows.append(urwid.Text(("interface_title", "Additional Parameters"), align="left")) + for key, value in other_params: + self.config_rows.append(create_param_row(key, value)) + self.config_rows.append(urwid.Divider("-")) + + if not self.config_rows: + self.config_rows.append(urwid.Text("No additional parameters", align="center")) + + body_content = [] + body_content.extend(self.info_rows) + body_content.append( + self.charts_widget) + body_content.append(urwid.Divider("-")) + body_content.extend(self.config_rows) + + body_pile = urwid.Pile(body_content) + body_padding = urwid.Padding(body_pile, left=2, right=2) + + body = urwid.ListBox(urwid.SimpleListWalker([body_padding])) + + self.frame = urwid.Frame( + body=body, + header=header, + footer=footer + ) + + self.content_box = urwid.LineBox(self.frame) + + super().__init__(self.content_box) + + def update_status_display(self): + if self.is_enabled: + self.status_indicator.set_text(("connected_status", self.parent.g['selected'])) + self.status_text.set_text(("connected_status", "Enabled")) + else: + self.status_indicator.set_text(("disconnected_status", self.parent.g['unselected'])) + self.status_text.set_text(("disconnected_status", "Disabled")) + + def update_connection_display(self, is_connected): + old_connection_state = self.is_connected + self.is_connected = is_connected + + self.connection_text.set_text(("connected_status" if self.is_connected else "disconnected_status", + "Connected" if self.is_connected else "Disconnected")) + + if old_connection_state != self.is_connected: + body_pile = self.frame.body.body[0].original_widget + + chart_index = None + for i, (widget, options) in enumerate(body_pile.contents): + if (widget == self.horizontal_charts or + widget == self.vertical_charts or + widget == self.disconnected_box): + chart_index = i + break + + if chart_index is not None: + if self.is_connected: + new_widget = self.horizontal_charts if self.is_horizontal else self.vertical_charts + if not self.started: + self.start() + else: + new_widget = self.disconnected_box + self.started = False + + body_pile.contents[chart_index] = (new_widget, body_pile.options()) + + def on_toggle_enabled(self, button): + action = "disable" if self.is_enabled else "enable" + + def on_confirm_yes(confirm_button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + self.is_enabled = not self.is_enabled + + self.toggle_button.set_label("Disable" if self.is_enabled else "Enable") + + if "interface_enabled" in self.interface_config: + self.interface_config["interface_enabled"] = self.is_enabled + else: + self.interface_config["enabled"] = self.is_enabled + + try: + interfaces = self.parent.app.rns.config['interfaces'] + + interfaces[self.iface_name] = self.interface_config + + self.parent.app.rns.config.write() + + self.update_status_display() + + for item in self.parent.interface_items: + if item.name == self.iface_name: + item.is_enabled = self.is_enabled + item.update_status_display() + + if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None: + self.parent.app.ui.loop.draw_screen() + + self.show_restart_required_message() + + except Exception as e: + self.show_error_message(f"Error updating interface: {str(e)}") + + def on_confirm_no(confirm_button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + confirm_text = urwid.Text(( + "interface_title", + f"Are you sure you want to {action} the {self.iface_name} interface?" + ), align="center") + + yes_button = urwid.Button("Yes", on_press=on_confirm_yes) + no_button = urwid.Button("No", on_press=on_confirm_no) + + buttons_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, yes_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, no_button), + ]) + + pile = urwid.Pile([ + confirm_text, + urwid.Divider(), + buttons_row + ]) + + dialog = DialogLineBox(pile, title="Confirm") + + overlay = urwid.Overlay( + dialog, + self.parent.app.ui.main_display.frame.body, + align='center', + width=50, + valign='middle', + height=7 + ) + + self.parent.app.ui.main_display.frame.body = overlay + + def show_restart_required_message(self): + + def dismiss_dialog(button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text( + f"Interface {self.iface_name} has been " + + ("enabled" if self.is_enabled else "disabled") + + ".\nRestart required for changes to take effect.", + align="center" + ), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title="Notice" + ) + + overlay = urwid.Overlay( + dialog, + self.parent.app.ui.main_display.frame.body, + align='center', + width=50, + valign='middle', + height=8 + ) + + self.parent.app.ui.main_display.frame.body = overlay + + def show_error_message(self, message): + + def dismiss_dialog(button): + self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + title="Error" + ) + + overlay = urwid.Overlay( + dialog, + self.parent.app.ui.main_display.frame.body, + align='center', + width=50, + valign='middle', + height=8 + ) + + self.parent.app.ui.main_display.frame.body = overlay + + def keypress(self, size, key): + if key == 'tab': + if self.frame.focus_position == 'body': + self.frame.focus_position = 'footer' + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + footer_pile.focus_position = 1 # button row + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + button_row.focus_position = 0 + return None + elif self.frame.focus_position == 'footer': + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + # If on first button (Back), move to Toggle button + if button_row.focus_position == 0: + button_row.focus_position = 2 # skip spacer + return None + # If on toggle button, move to Edit button + elif button_row.focus_position == 2: + button_row.focus_position = 4 + return None + # if on edit button wrap back to toggle button + elif button_row.focus_position == 4: + button_row.focus_position = 0 + return None + elif key == 'shift tab': + if self.frame.focus_position == 'footer': + self.frame.focus_position = 'body' + return None + elif self.frame.focus_position == 'footer': + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + if button_row.focus_position == 4: # edit button + button_row.focus_position = 2 # toggle button + return None + elif button_row.focus_position == 2: + button_row.focus_position = 0 # back button + return None + elif button_row.focus_position == 0: # back button + self.frame.focus_position = 'body' + return None + elif key == 'down': + if self.frame.focus_position == 'body': + result = super().keypress(size, key) + # if the key wasn't consumed, we're at the bottom + if result == 'down': + self.frame.focus_position = 'footer' + footer_pile = self.frame.footer + if isinstance(footer_pile, urwid.Pile): + footer_pile.focus_position = 1 # button row + button_row = footer_pile.contents[1][0] + if isinstance(button_row, urwid.Columns): + button_row.focus_position = 0 # focus on back button + return None + return result + elif key == 'up': + if self.frame.focus_position == 'footer': + self.frame.focus_position = 'body' + listbox = self.frame.body + if hasattr(listbox, 'body') and len(listbox.body) > 0: + listbox.focus_position = len(listbox.body) - 1 + return None + elif self.frame.focus_position == 'body': + result = super().keypress(size, key) + # if the key wasn't consumed, we're at the top + if result == 'up': + pass + return result + elif key == "h" and self.is_connected: # horizontal layout + if not self.is_horizontal: + self.switch_to_horizontal() + return None + elif key == "v" and self.is_connected: # vertical layout + if self.is_horizontal: + self.switch_to_vertical() + return None + + return super().keypress(size, key) + + def switch_to_horizontal(self): + if not self.is_connected: + return + + self.is_horizontal = True + + body_pile = self.frame.body.body[0].original_widget + for i, (widget, options) in enumerate(body_pile.contents): + if widget == self.vertical_charts: + body_pile.contents[i] = (self.horizontal_charts, options) + self.charts_widget = self.horizontal_charts + break + + def switch_to_vertical(self): + if not self.is_connected: + return + + self.is_horizontal = False + body_pile = self.frame.body.body[0].original_widget + for i, (widget, options) in enumerate(body_pile.contents): + if widget == self.horizontal_charts: + body_pile.contents[i] = (self.vertical_charts, options) + self.charts_widget = self.vertical_charts + break + + def start(self): + if not self.started and self.is_connected: + self.started = True + self.parent.app.ui.loop.set_alarm_in(1, self.update_bandwidth_charts) + + def update_bandwidth_charts(self, loop, user_data): + if not self.started: + return + + try: + interface_stats = self.parent.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + stats = stats_lookup.get(self.iface_name, {}) + + tx = stats.get("txb", self.tx) + rx = stats.get("rxb", self.rx) + + new_connection_status = stats.get("status", False) + if new_connection_status != self.is_connected: + self.update_connection_display(new_connection_status) + + if not self.is_connected: + return + + self.tx_text.set_text(("value", format_bytes(tx))) + self.rx_text.set_text(("value", format_bytes(rx))) + + self.bandwidth_chart.update(rx, tx) + + rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) + + self.rx_chart_text.set_text(rx_chart) + self.tx_chart_text.set_text(tx_chart) + self.rx_peak_text.set_text(f"Peak: {peak_rx}") + self.tx_peak_text.set_text(f"Peak: {peak_tx}") + + self.tx = tx + self.rx = rx + except Exception as e: + if not hasattr(self.parent, + 'disconnect_overlay') or self.parent.widget is not self.parent.disconnect_overlay: + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text("Waiting to Reconnect...", align="center") + ]) + dialog_content = urwid.Filler(dialog_text) + dialog_box = urwid.LineBox(dialog_content) + + self.parent.disconnect_overlay = urwid.Overlay( + dialog_box, + self, + align='center', + width=35, + valign='middle', + height=4 + ) + + self.parent.widget = self.parent.disconnect_overlay + self.parent.app.ui.main_display.update_active_sub_display() + self.started = False + finally: + if self.started: + loop.set_alarm_in(1, self.update_bandwidth_charts) + + def on_back(self, button): + self.started = False + self.parent.switch_to_list() + + def on_edit(self, button): + self.started = False + self.parent.switch_to_edit_interface(self.iface_name) + +### MAIN DISPLAY ### +class InterfaceDisplay: + def __init__(self, app): + self.app = app + self.started = False + self.poll_scheduler = False + self.size_scheduler = False + self.interface_items = [] + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.terminal_cols, self.terminal_rows = _get_cols_rows() + self.iface_row_offset = 4 + self.list_rows = self.terminal_rows - self.iface_row_offset + + interfaces = app.rns.config['interfaces'] + processed_interfaces = {} + + for interface_name, interface in interfaces.items(): + interface_data = interface.copy() + + # handle sub-interfaces for RNodeMultiInterface + if interface_data.get("type") == "RNodeMultiInterface": + sub_interfaces = [] + for sub_name, sub_config in interface_data.items(): + if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", + "configured_bitrate"}: + if isinstance(sub_config, dict): + sub_config["name"] = sub_name + sub_interfaces.append(sub_config) + + # add sub-interfaces to the main interface data + interface_data["sub_interfaces"] = sub_interfaces + + for sub in sub_interfaces: + del interface_data[sub["name"]] + + processed_interfaces[interface_name] = interface_data + + app.interface_stats = app.rns.get_interface_stats() + interface_stats = app.interface_stats + stats_lookup = {interface['short_name']: interface for interface in interface_stats['interfaces']} + # print(stats_lookup) + for interface_name, interface_data in processed_interfaces.items(): + # configobj false values + is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + iface_type = interface_data.get("type", "Unknown") + icon = _get_interface_icon(self.glyphset, iface_type) + + stats_for_interface = stats_lookup.get(interface_name) + + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + is_connected = stats_for_interface["status"] + else: + tx = 0 + rx = 0 + is_connected = False + + item = SelectableInterfaceItem( + parent=self, + name=interface_data.get("name", interface_name), + is_connected=is_connected, + is_enabled=is_enabled, + iface_type=iface_type, + tx=tx, + rx=rx, + icon=icon + ) + + self.interface_items.append(item) + + interface_header = urwid.Text(("interface_title", "Interfaces"), align="center") + if len(self.interface_items) == 0: + interface_header = urwid.Text( + ("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center") + + + list_contents = [ + interface_header, + urwid.Divider(), + ] + self.interface_items + + self.list_walker = urwid.SimpleFocusListWalker(list_contents) + self.list_box = urwid.ListBox(self.list_walker) + + self.box_adapter = urwid.BoxAdapter(self.list_box, self.list_rows) + + + pile = urwid.Pile([self.box_adapter]) + self.interfaces_display = InterfaceFiller(pile, self.app) + self.shortcuts_display = InterfaceDisplayShortcuts(self.app) + self.widget = self.interfaces_display + + def start(self): + # started from Main.py + self.started = True + if not self.poll_scheduler: self.app.ui.loop.set_alarm_in(1, self.poll_stats) + if not self.size_scheduler: self.app.ui.loop.set_alarm_in(5, self.check_terminal_size) + + def switch_to_edit_interface(self, iface_name): + self.edit_interface_view = EditInterfaceView(self, iface_name) + self.widget = self.edit_interface_view + self.app.ui.main_display.update_active_sub_display() + + def edit_selected_interface(self): + focus_widget, focus_position = self.box_adapter._original_widget.body.get_focus() + + if not isinstance(focus_widget, SelectableInterfaceItem): + return + + selected_item = focus_widget + interface_name = selected_item.name + + self.switch_to_edit_interface(interface_name) + + def check_terminal_size(self, loop, user_data): + self.size_scheduler = True + new_cols, new_rows = _get_cols_rows() + + if new_rows != self.terminal_rows or new_cols != self.terminal_cols: + self.terminal_cols, self.terminal_rows = new_cols, new_rows + self.list_rows = self.terminal_rows - self.iface_row_offset + + self.box_adapter.height = self.list_rows + + loop.draw_screen() + + if self.started: + loop.set_alarm_in(5, self.check_terminal_size) + + def poll_stats(self, loop, user_data): + self.poll_scheduler = True + try: + if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + + def job(): self.app.interface_stats = self.app.rns.get_interface_stats() + threading.Thread(target=job, daemon=True).start() + interface_stats = self.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + for item in self.interface_items: + # use interface name as the key + stats_for_interface = stats_lookup.get(item.name) + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + item.update_stats(tx, rx) + except Exception as e: + if not hasattr(self, 'disconnect_overlay') or self.widget is not self.disconnect_overlay: + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text(("Waiting to Reconnect..."), align="center") + ]) + dialog_content = urwid.Filler(dialog_text) + dialog_box = urwid.LineBox(dialog_content) + + self.disconnect_overlay = urwid.Overlay( + dialog_box, + self.interfaces_display, + align='center', + width=35, + valign='middle', + height=4 + ) + + if self.widget is self.interfaces_display: + self.widget = self.disconnect_overlay + self.app.ui.main_display.update_active_sub_display() + finally: + if self.started: + loop.set_alarm_in(1, self.poll_stats) + + def shortcuts(self): + return self.shortcuts_display + + def switch_to_show_interface(self, iface_name): + show_interface = ShowInterface(self, iface_name) + self.widget = show_interface + self.app.ui.main_display.update_active_sub_display() + + show_interface.start() + + def switch_to_list(self): + self.shortcuts_display.reset_shortcuts() + self.widget = self.interfaces_display + self._rebuild_list() + self.app.ui.main_display.update_active_sub_display() + + def add_interface(self): + dialog_widgets = [] + + def add_heading(txt): + dialog_widgets.append(urwid.Text(("interface_title", txt), align="left")) + + def add_option(label, value): + item = InterfaceOptionItem(self, label, value) + dialog_widgets.append(item) + + # Get the icons based on plain, unicode, nerdfont glyphset + network_icon = _get_interface_icon(self.glyphset, "AutoInterface") + rnode_icon = _get_interface_icon(self.glyphset, "RNodeInterface") + serial_icon = _get_interface_icon(self.glyphset, "SerialInterface") + other_icon = _get_interface_icon(self.glyphset, "PipeInterface") + + add_heading(f"{network_icon} IP Networks") + add_option("Auto Interface", "AutoInterface") + add_option("TCP Client Interface", "TCPClientInterface") + add_option("TCP Server Interface", "TCPServerInterface") + add_option("UDP Interface", "UDPInterface") + add_option("I2P Interface", "I2PInterface") + if PLATFORM_IS_LINUX: + add_option("Backbone Interface", "BackboneInterface") + + if PYSERIAL_AVAILABLE: + add_heading(f"{rnode_icon} RNodes") + add_option("RNode Interface", "RNodeInterface") + add_option("RNode Multi Interface", "RNodeMultiInterface") + + add_heading(f"{serial_icon} Hardware") + add_option("Serial Interface", "SerialInterface") + add_option("KISS Interface", "KISSInterface") + add_option("AX.25 KISS Interface", "AX25KISSInterface") + + add_heading(f"{other_icon} Other") + add_option("Pipe Interface", "PipeInterface") + add_option("Custom Interface", "CustomInterface") + + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)) + dialog = DialogLineBox(listbox, parent=self, title="Select Interface Type") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 50), + valign='middle', + height=('relative', 50), + min_width=20, + min_height=15, + left=2, + right=2 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def switch_to_add_interface(self, iface_type): + self.add_interface_view = AddInterfaceView(self, iface_type) + self.widget = self.add_interface_view + self.app.ui.main_display.update_active_sub_display() + + def remove_selected_interface(self): + focus_widget, focus_position = self.box_adapter._original_widget.body.get_focus() + if not isinstance(focus_widget, SelectableInterfaceItem): + return + + selected_item = focus_widget + interface_name = selected_item.name + + def on_confirm_yes(button): + try: + if interface_name in self.app.rns.config['interfaces']: + del self.app.rns.config['interfaces'][interface_name] + self.app.rns.config.write() + + if selected_item in self.interface_items: + self.interface_items.remove(selected_item) + + self._rebuild_list() + self.dismiss_dialog() + + except Exception as e: + print(e) + + def on_confirm_no(button): + self.dismiss_dialog() + + confirm_text = urwid.Text(("interface_title", f"Remove interface {interface_name}?"), align="center") + yes_button = urwid.Button("Yes", on_press=on_confirm_yes) + no_button = urwid.Button("No", on_press=on_confirm_no) + + buttons_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, yes_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, no_button), + ]) + + pile = urwid.Pile([ + confirm_text, + buttons_row + ]) + + dialog = DialogLineBox(pile, parent=self, title="?") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 35), + valign='middle', + height=(5), + min_width=5, + left=2, + right=2 + ) + dialog.original_widget.focus_position = 1 # columns row + buttons_row = dialog.original_widget.contents[1][0] + buttons_row.focus_position = 2 # second button "No" + + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def dismiss_dialog(self): + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + + def _rebuild_list(self): + interface_header = urwid.Text(("interface_title", f"Interfaces ({len(self.interface_items)})"), align="center") + if len(self.interface_items) == 0: + interface_header = urwid.Text(("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center") + + new_list = [ + interface_header, + urwid.Divider(), + ] + self.interface_items + # RNS.log(f"items: {self.interface_items}") + + walker = urwid.SimpleFocusListWalker(new_list) + self.box_adapter._original_widget.body = walker + self.box_adapter._original_widget.focus_position = len(new_list) - 1 + + def open_config_editor(self): + import platform + + editor_cmd = self.app.config["textui"]["editor"] + + if platform.system() == "Darwin" and editor_cmd == "editor": + editor_cmd = "nano" + + editor_term = urwid.Terminal( + (editor_cmd, self.app.rns.configpath), + encoding='utf-8', + main_loop=self.app.ui.loop, + ) + + def quit_term(*args, **kwargs): + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.request_redraw() + + urwid.connect_signal(editor_term, 'closed', quit_term) + + editor_box = urwid.LineBox(editor_term, title="Editing RNS Config") + self.widget = editor_box + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + editor_term.change_focus(True) + +### SHORTCUTS ### +class InterfaceDisplayShortcuts: + def __init__(self, app): + self.app = app + self.default_shortcuts = "[C-a] Add Interface [C-e] Edit Interface [C-x] Remove Interface [Enter] Show Interface [C-w] Open Text Editor" + self.current_shortcuts = self.default_shortcuts + self.widget = urwid.AttrMap( + urwid.Text(self.current_shortcuts), + "shortcutbar" + ) + + def update_shortcuts(self, new_shortcuts): + self.current_shortcuts = new_shortcuts + self.widget.original_widget.set_text(new_shortcuts) + + def reset_shortcuts(self): + self.update_shortcuts(self.default_shortcuts) + + def set_show_interface_shortcuts(self): + show_shortcuts = "[Up/Down] Navigate [Tab] Switch Focus [h] Horizontal Charts [v] Vertical Charts " + self.update_shortcuts(show_shortcuts) + + def set_add_interface_shortcuts(self): + add_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option" + self.update_shortcuts(add_shortcuts) + + def set_edit_interface_shortcuts(self): + edit_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option" + self.update_shortcuts(edit_shortcuts) \ No newline at end of file diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py new file mode 100644 index 0000000..bea98ef --- /dev/null +++ b/nomadnet/ui/textui/Log.py @@ -0,0 +1,127 @@ +import os +import sys +import itertools +import mmap +import urwid +import nomadnet + + +class LogDisplayShortcuts(): + def __init__(self, app): + import urwid + self.app = app + + self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") + + +class LogDisplay(): + def __init__(self, app): + self.app = app + + self.shortcuts_display = LogDisplayShortcuts(self.app) + self.widget = None + + @property + def log_term(self): + return self.widget + + def show(self): + if self.widget is None: + self.widget = log_widget(self.app) + + def kill(self): + if self.widget is not None: + self.widget.terminate() + self.widget = None + + def shortcuts(self): + return self.shortcuts_display + + +class LogTerminal(urwid.WidgetWrap): + def __init__(self, app): + self.app = app + self.log_term = urwid.Terminal( + ("tail", "-fn50", self.app.logfilepath), + encoding='utf-8', + escape_sequence="up", + main_loop=self.app.ui.loop, + ) + self.widget = urwid.LineBox(self.log_term) + super().__init__(self.widget) + + def terminate(self): + self.log_term.terminate() + + + def keypress(self, size, key): + if key == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + + return super(LogTerminal, self).keypress(size, key) + + +class LogTail(urwid.WidgetWrap): + def __init__(self, app): + self.app = app + self.log_tail = urwid.Text(tail(self.app.logfilepath, 50)) + self.log = urwid.Scrollable(self.log_tail) + self.log.set_scrollpos(-1) + self.log_scrollbar = urwid.ScrollBar(self.log) + # We have this here because ui.textui.Main depends on this field to kill it + self.log_term = None + + super().__init__(self.log_scrollbar) + + def terminate(self): + pass + + +def log_widget(app, platform=sys.platform): + if platform == "win32": + return LogTail(app) + else: + return LogTerminal(app) + +# https://stackoverflow.com/a/34029605/3713120 +def _tail(f_name, n, offset=0): + def skip_back_lines(mm: mmap.mmap, numlines: int, startidx: int) -> int: + '''Factored out to simplify handling of n and offset''' + for _ in itertools.repeat(None, numlines): + startidx = mm.rfind(b'\n', 0, startidx) + if startidx < 0: + break + return startidx + + # Open file in binary mode + with open(f_name, 'rb') as binf, mmap.mmap(binf.fileno(), 0, access=mmap.ACCESS_READ) as mm: + # len(mm) - 1 handles files ending w/newline by getting the prior line + startofline = skip_back_lines(mm, offset, len(mm) - 1) + if startofline < 0: + return [] # Offset lines consumed whole file, nothing to return + # If using a generator function (yield-ing, see below), + # this should be a plain return, no empty list + + endoflines = startofline + 1 # Slice end to omit offset lines + + # Find start of lines to capture (add 1 to move from newline to beginning of following line) + startofline = skip_back_lines(mm, n, startofline) + 1 + + # Passing True to splitlines makes it return the list of lines without + # removing the trailing newline (if any), so list mimics f.readlines() + # return mm[startofline:endoflines].splitlines(True) + # If Windows style \r\n newlines need to be normalized to \n + return mm[startofline:endoflines].replace(os.linesep.encode(sys.getdefaultencoding()), b'\n').splitlines(True) + + +def tail(f_name, n): + """ + Return the last n lines of a given file name, f_name. + Akin to `tail - ` + """ + def decode(b): + return b.decode(encoding) + + encoding = sys.getdefaultencoding() + lines = map(decode, _tail(f_name=f_name, n=n)) + return ''.join(lines) diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py new file mode 100644 index 0000000..9f14c22 --- /dev/null +++ b/nomadnet/ui/textui/Main.py @@ -0,0 +1,230 @@ +import RNS + +from .Network import * +from .Conversations import * +from .Channels import * +from .Directory import * +from .Config import * +from .Interfaces import * +from .Map import * +from .Log import * +from .Guide import * +import urwid + +class SubDisplays(): + def __init__(self, app): + self.app = app + self.network_display = NetworkDisplay(self.app) + self.conversations_display = ConversationsDisplay(self.app) + self.channels_display = ChannelsDisplay(self.app) + self.directory_display = DirectoryDisplay(self.app) + self.config_display = ConfigDisplay(self.app) + self.interface_display = InterfaceDisplay(self.app) + self.map_display = MapDisplay(self.app) + self.log_display = LogDisplay(self.app) + self.guide_display = GuideDisplay(self.app) + + if app.firstrun: + self.active_display = self.guide_display + else: + self.active_display = self.conversations_display + + def active(self): + return self.active_display + +class MenuButton(urwid.Button): + button_left = urwid.Text('[') + button_right = urwid.Text(']') + +class MainFrame(urwid.Frame): + FOCUS_CHECK_TIMEOUT = 0.25 + + def __init__(self, body, header=None, footer=None, delegate=None): + self.delegate = delegate + self.current_focus = None + super().__init__(body, header, footer) + + def keypress_focus_check(self, deferred=False): + current_focus = self.delegate.widget.get_focus_widgets()[-1] + + if deferred: + if current_focus != self.current_focus: + self.focus_changed() + else: + def deferred_focus_check(loop, user_data): + self.keypress_focus_check(deferred=True) + self.delegate.app.ui.loop.set_alarm_in(MainFrame.FOCUS_CHECK_TIMEOUT, deferred_focus_check) + + self.current_focus = current_focus + + def focus_changed(self): + current_focus = self.delegate.widget.get_focus_widgets()[-1] + current_focus_path = self.delegate.widget.get_focus_path() + + if len(current_focus_path) > 1: + if current_focus_path[0] == "body": + self.delegate.update_active_shortcuts() + + if self.delegate.sub_displays.active() == self.delegate.sub_displays.conversations_display: + # Needed to refresh indicativelistbox styles on mouse focus change + self.delegate.sub_displays.conversations_display.focus_change_event() + + def mouse_event(self, size, event, button, col, row, focus): + current_focus = self.delegate.widget.get_focus_widgets()[-1] + if current_focus != self.current_focus: + self.focus_changed() + + self.current_focus = current_focus + return super(MainFrame, self).mouse_event(size, event, button, col, row, focus) + + def keypress(self, size, key): + self.keypress_focus_check() + + #if key == "ctrl q": + # raise urwid.ExitMainLoop + + return super(MainFrame, self).keypress(size, key) + +class MainDisplay(): + def __init__(self, ui, app): + self.ui = ui + self.app = app + + self.menu_display = MenuDisplay(self.app, self) + self.sub_displays = SubDisplays(self.app) + + self.frame = MainFrame(self.sub_displays.active().widget, header=self.menu_display.widget, footer=self.sub_displays.active().shortcuts().widget, delegate=self) + self.widget = self.frame + + def show_network(self, user_data): + self.sub_displays.active_display = self.sub_displays.network_display + self.update_active_sub_display() + self.sub_displays.network_display.start() + + def show_conversations(self, user_data): + self.sub_displays.active_display = self.sub_displays.conversations_display + self.update_active_sub_display() + + def show_channels(self, user_data): + self.sub_displays.active_display = self.sub_displays.channels_display + self.update_active_sub_display() + self.sub_displays.channels_display.start() + + def show_directory(self, user_data): + self.sub_displays.active_display = self.sub_displays.directory_display + self.update_active_sub_display() + + def show_map(self, user_data): + self.sub_displays.active_display = self.sub_displays.map_display + self.update_active_sub_display() + + def show_config(self, user_data): + self.sub_displays.active_display = self.sub_displays.config_display + self.update_active_sub_display() + + def show_interfaces(self, user_data): + self.sub_displays.active_display = self.sub_displays.interface_display + self.update_active_sub_display() + self.sub_displays.interface_display.start() + + def show_log(self, user_data): + self.sub_displays.active_display = self.sub_displays.log_display + self.sub_displays.log_display.show() + self.update_active_sub_display() + + def show_guide(self, user_data): + self.sub_displays.active_display = self.sub_displays.guide_display + self.update_active_sub_display() + + def update_active_sub_display(self): + self.frame.contents["body"] = (self.sub_displays.active().widget, None) + self.update_active_shortcuts() + if self.sub_displays.active_display != self.sub_displays.log_display: + self.sub_displays.log_display.kill() + + def update_active_shortcuts(self): + self.frame.contents["footer"] = (self.sub_displays.active().shortcuts().widget, None) + + def request_redraw(self, extra_delay=0.0): + self.app.ui.loop.set_alarm_in(0.25+extra_delay, self.redraw_now) + + def redraw_now(self, sender=None, data=None): + self.app.ui.loop.screen.clear() + #self.app.ui.loop.draw_screen() + + def start(self): + self.menu_display.start() + + def quit(self, sender=None): + logterm_pid = None + if True or RNS.vendor.platformutils.is_android(): + if self.sub_displays.log_display != None and self.sub_displays.log_display.log_term != None: + if self.sub_displays.log_display.log_term.log_term != None: + logterm_pid = self.sub_displays.log_display.log_term.log_term.pid + if logterm_pid != None: + import os, signal + os.kill(logterm_pid, signal.SIGKILL) + + raise urwid.ExitMainLoop + + +class MenuColumns(urwid.Columns): + def keypress(self, size, key): + if key == "tab" or key == "down": + self.handler.frame.focus_position = "body" + + return super(MenuColumns, self).keypress(size, key) + +class MenuDisplay(): + UPDATE_INTERVAL = 2 + + def __init__(self, app, handler): + self.app = app + self.update_interval = MenuDisplay.UPDATE_INTERVAL + self.g = self.app.ui.glyphs + + self.menu_indicator = urwid.Text("") + + menu_text = (urwid.PACK, self.menu_indicator) + button_network = (11, MenuButton("Network", on_press=handler.show_network)) + button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) + button_channels = (12, MenuButton("Channels", on_press=handler.show_channels)) + button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) + button_map = (7, MenuButton("Map", on_press=handler.show_map)) + button_log = (7, MenuButton("Log", on_press=handler.show_log)) + button_config = (10, MenuButton("Config", on_press=handler.show_config)) + button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) + button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) + button_quit = (8, MenuButton("Quit", on_press=handler.quit)) + + # buttons = [menu_text, button_conversations, button_node, button_directory, button_map] + if self.app.config["textui"]["hide_guide"]: + buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_quit] + else: + buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_guide, button_quit] + + columns = MenuColumns(buttons, dividechars=1) + columns.handler = handler + + self.update_display() + + self.widget = urwid.AttrMap(columns, "menubar") + + def start(self): + self.update_display_job() + + def update_display_job(self, event = None, sender = None): + self.update_display() + self.app.ui.loop.set_alarm_in(self.update_interval, self.update_display_job) + + def update_display(self): + if self.app.has_unread_conversations(): + self.indicate_unread() + else: + self.indicate_normal() + + def indicate_normal(self): + self.menu_indicator.set_text(self.g["decoration_menu"]) + + def indicate_unread(self): + self.menu_indicator.set_text(self.g["unread_menu"]) diff --git a/nomadnet/ui/textui/Map.py b/nomadnet/ui/textui/Map.py new file mode 100644 index 0000000..b2ef4dd --- /dev/null +++ b/nomadnet/ui/textui/Map.py @@ -0,0 +1,21 @@ +class MapDisplayShortcuts(): + def __init__(self, app): + import urwid + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("Map Display Shortcuts"), "shortcutbar") + +class MapDisplay(): + def __init__(self, app): + import urwid + self.app = app + + pile = urwid.Pile([ + urwid.Text(("body_text", "Map Display \U0001F332")), + ]) + + self.shortcuts_display = MapDisplayShortcuts(self.app) + self.widget = urwid.Filler(pile, urwid.TOP) + + def shortcuts(self): + return self.shortcuts_display \ No newline at end of file diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py new file mode 100644 index 0000000..83fb6bf --- /dev/null +++ b/nomadnet/ui/textui/MicronParser.py @@ -0,0 +1,1049 @@ +import nomadnet +import urwid +import random +import re +import time +import RNS +from urwid.util import is_mouse_press +from urwid.text_layout import calc_coords +from .ReadlineEdit import ReadlineEdit +from RNS.Utilities.rngit.util import MarkdownToMicron + +DEFAULT_FG_DARK = "ddd" +DEFAULT_FG_LIGHT = "222" +DEFAULT_BG = "default" + +SELECTED_STYLES = None + +STYLES_DARK = { + "plain": { "fg": DEFAULT_FG_DARK, "bg": DEFAULT_BG, "bold": False, "underline": False, "italic": False }, + "heading1": { "fg": "222", "bg": "bbb", "bold": False, "underline": False, "italic": False }, + "heading2": { "fg": "111", "bg": "999", "bold": False, "underline": False, "italic": False }, + "heading3": { "fg": "000", "bg": "777", "bold": False, "underline": False, "italic": False }, +} + +STYLES_LIGHT = { + "plain": { "fg": DEFAULT_FG_LIGHT, "bg": DEFAULT_BG, "bold": False, "underline": False, "italic": False }, + "heading1": { "fg": "000", "bg": "777", "bold": False, "underline": False, "italic": False }, + "heading2": { "fg": "111", "bg": "aaa", "bold": False, "underline": False, "italic": False }, + "heading3": { "fg": "222", "bg": "ccc", "bold": False, "underline": False, "italic": False }, +} + +SYNTH_STYLES = [] +SYNTH_SPECS = {} + +SECTION_INDENT = 2 +INDENT_RIGHT = 1 +MAX_TABLE_WIDTH = 100 + +def default_state(fg=None, bg=None): + global SELECTED_STYLES + ensure_selected_styles() + if fg == None: fg = SELECTED_STYLES["plain"]["fg"] + if bg == None: bg = DEFAULT_BG + state = { + "literal": False, + "table_mode": False, + "table_buffer": [], + "table_align": None, + "table_maxwidth": MAX_TABLE_WIDTH, + "depth": 0, + "fg_color": fg, + "bg_color": bg, + "formatting": { + "bold": False, + "underline": False, + "italic": False, + "strikethrough": False, + "blink": False, + }, + "default_align": "left", + "align": "left", + "default_fg": fg, + "default_bg": bg, + "anchors": {}, + "pending_anchors": [], + "header_rows": [], + } + return state + + +_MICRON_STRIP_RE = re.compile( + r"`[FB]T[0-9a-fA-F]{6}" + r"|`[FB][0-9a-fA-F]{3}" + r"|`:[A-Za-z0-9_\-]*" + r"|`[!*_=fbacrl`<>{]" +) + +def slugify_micron(text): + if text is None: return "" + stripped = _MICRON_STRIP_RE.sub("", text) + s = re.sub(r"[^A-Za-z0-9]+", "-", stripped).strip("-").lower() + return s + +class _AttrMapList(list): + """list subclass that allows attaching parser metadata like `anchors`.""" + pass + +def ensure_selected_styles(): + global SELECTED_STYLES + theme = nomadnet.NomadNetworkApp.get_shared_instance().config["textui"]["theme"] + if theme == nomadnet.ui.TextUI.THEME_DARK: SELECTED_STYLES = STYLES_DARK + else: SELECTED_STYLES = STYLES_LIGHT + +def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None): + global LINK_CLASS + if link_class: LINK_CLASS = link_class + else: LINK_CLASS = LinkableText + + global SELECTED_STYLES + ensure_selected_styles() + + attrmaps = _AttrMapList() + + fgc = None; bgc = DEFAULT_BG + if bg_color != None: bgc = bg_color + if fg_color != None: fgc = fg_color + + state = default_state(fgc, bgc) + if anchors is None: + anchors = state["anchors"] + else: + state["anchors"] = anchors + + # Split entire document into lines for + # processing. + lines = markup.split("\n"); + + for line in lines: + if len(line) > 0: + display_widgets = parse_line(line, state, url_delegate) + else: + display_widgets = [urwid.Text("")] + + if display_widgets != None and len(display_widgets) != 0: + row_index = len(attrmaps) + pending = state.get("pending_anchors") or [] + if pending: + for name in pending: + if name and name not in anchors: + anchors[name] = row_index + state["pending_anchors"] = [] + + if state.get("_header_pending"): + state["header_rows"].append(row_index) + state["_header_pending"] = False + + for display_widget in display_widgets: + attrmap = urwid.AttrMap(display_widget, make_style(state)) + attrmaps.append(attrmap) + + try: + setattr(attrmaps, "anchors", anchors) + setattr(attrmaps, "header_rows", list(state.get("header_rows", []))) + except Exception: + pass + + return attrmaps + +def parse_partial(line): + try: + endpos = line.find("}") + if endpos == -1: return None + else: + partial_data = line[0:endpos] + + partial_id = None + partial_components = partial_data.split("`") + if len(partial_components) == 1: + partial_url = partial_components[0] + partial_refresh = None + partial_fields = "" + elif len(partial_components) == 2: + partial_url = partial_components[0] + partial_refresh = float(partial_components[1]) + partial_fields = "" + elif len(partial_components) == 3: + partial_url = partial_components[0] + partial_refresh = float(partial_components[1]) + partial_fields = partial_components[2] + else: + partial_url = "" + partial_fields = "" + partial_refresh = None + + if partial_refresh != None and partial_refresh < 1: partial_refresh = None + + pf = partial_fields.split("|") + if len(pf) > 0: + partial_fields = pf + for f in pf: + if f.startswith("pid="): + pcs = f.split("=") + partial_id = pcs[1] + + if len(partial_url): + pile = urwid.Pile([urwid.Text(f"⧖")]) + partial_descriptor = "|".join(partial_components) + pile.partial_id = partial_id + pile.partial_hash = RNS.hexrep(RNS.Identity.full_hash(partial_descriptor.encode("utf-8")), delimit=False) + pile.partial_url = partial_url + pile.partial_fields = partial_fields + pile.partial_refresh = partial_refresh + return [pile] + + except Exception as e: return None + +def render_table(lines, state, url_delegate): + if len(lines) < 2: return None + if state["table_maxwidth"]: max_width = state["table_maxwidth"] + else: max_width = MAX_TABLE_WIDTH + if state["table_align"]: align = state["table_align"] + else: align = None + + converter = MarkdownToMicron(max_width=max_width) + micron_lines = converter.format_table_raw(lines, align=align) + + widgets = [] + for line in micron_lines: + # Disable table mode to avoid recursion + was_table_mode = state["table_mode"] + state["table_mode"] = False + + line_widgets = parse_line(line, state, url_delegate) + state["table_mode"] = was_table_mode + + if line_widgets: widgets.extend(line_widgets) + + return widgets if widgets else None + +def parse_line(line, state, url_delegate): + pre_escape = False + if len(line) > 0: + first_char = line[0] + + # Check for literals + if len(line) == 2 and line == "`=": + state["literal"] ^= True + return None + + # Only parse content if not in literal state + if not state["literal"]: + # Apply markup sanitization + if first_char == ">" and "`<" in line: + # Remove heading status from lines containing fields + line = line.lstrip(">") + first_char = line[0] + + # Check if the command is an escape + if first_char == "\\": + line = line[1:] + pre_escape = True + + # Check for comments + elif first_char == "#": + return None + + # Check for tables + if line.startswith("`t"): + line = line[2:] + align = line[0] if len(line) and line[0] in ["l", "c", "r"] else None + max_width = None + if align: line = line[1:] + if len(line): + try: max_width = int(line) + except: pass + + if state["table_mode"]: + widgets = render_table(state["table_buffer"], state, url_delegate) + state["table_mode"] = False + state["table_buffer"] = [] + state["table_align"] = None + state["table_maxwidth"] = MAX_TABLE_WIDTH + return widgets + + else: + state["table_mode"] = True + state["table_buffer"] = [] + state["table_align"] = align + state["table_maxwidth"] = max_width + return None + + # Buffer the line if in table mode + if state["table_mode"]: + state["table_buffer"].append(line) + return None + + # Check for partials + elif line.startswith("`{"): + return parse_partial(line[2:]) + + # Check for section heading reset + elif first_char == "<": + state["depth"] = 0 + return parse_line(line[1:], state, url_delegate) + + # Check for section headings + elif first_char == ">": + i = 0 + while i < len(line) and line[i] == ">": + i += 1 + state["depth"] = i + + for j in range(1, i+1): + wanted_style = "heading"+str(i) + if wanted_style in SELECTED_STYLES: + style = SELECTED_STYLES[wanted_style] + + line = line[state["depth"]:] + if len(line) > 0: + latched_style = state_to_style(state) + style_to_state(style, state) + + heading_style = make_style(state) + output = make_output(state, line, url_delegate) + + style_to_state(latched_style, state) + + slug = slugify_micron(line) + if slug: + state.setdefault("pending_anchors", []).append(slug) + state["_header_pending"] = True + + if len(output) > 0: + first_style = output[0][0] + + heading_style = first_style + output.insert(0, " "*left_indent(state)) + return [urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)] + else: + return None + else: + return None + + # Check for horizontal dividers + elif first_char == "-": + if len(line) == 2: + divider_char = line[1] + # Control characters don't make sense here and otherwise crash nomadnet + if ord(divider_char) < 32: + divider_char = "\u2500" + else: + divider_char = "\u2500" + if state["depth"] == 0: + return [urwid.Divider(divider_char)] + else: + return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))] + + output = make_output(state, line, url_delegate, pre_escape) + + if output != None: + text_only = True + for o in output: + if not isinstance(o, tuple): + text_only = False + break + + if not text_only: + widgets = [] + for o in output: + if isinstance(o, tuple): + if url_delegate != None: + tw = LINK_CLASS(o, align=state["align"], delegate=url_delegate) + tw.in_columns = True + else: + tw = urwid.Text(o, align=state["align"]) + widgets.append((urwid.PACK, tw)) + else: + if o["type"] == "field": + fw = o["width"] + fd = o["data"] + fn = o["name"] + fs = o["style"] + fmask = "*" if o["masked"] else None + f = ReadlineEdit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) + f.field_name = fn + fa = urwid.AttrMap(f, fs) + widgets.append((fw, fa)) + elif o["type"] == "checkbox": + fn = o["name"] + fv = o["value"] + flabel = o["label"] + fs = o["style"] + fprechecked = o.get("prechecked", False) + f = urwid.CheckBox(flabel, state=fprechecked) + f.field_name = fn + f.field_value = fv + fa = urwid.AttrMap(f, fs) + widgets.append((urwid.PACK, fa)) + elif o["type"] == "radio": + fn = o["name"] + fv = o["value"] + flabel = o["label"] + fs = o["style"] + fprechecked = o.get("prechecked", False) + if "radio_groups" not in state: + state["radio_groups"] = {} + if fn not in state["radio_groups"]: + state["radio_groups"][fn] = [] + group = state["radio_groups"][fn] + f = urwid.RadioButton(group, flabel, state=fprechecked, user_data=fv) + f.field_name = fn + f.field_value = fv + fa = urwid.AttrMap(f, fs) + widgets.append((urwid.PACK, fa)) + + + + + columns_widget = urwid.Columns(widgets, dividechars=0) + text_widget = columns_widget + # text_widget = urwid.Text("<"+output+">", align=state["align"]) + + else: + if url_delegate != None: + text_widget = LINK_CLASS(output, align=state["align"], delegate=url_delegate) + else: + text_widget = urwid.Text(output, align=state["align"]) + + if state["depth"] == 0: + return [text_widget] + else: + return [urwid.Padding(text_widget, left=left_indent(state), right=right_indent(state))] + else: + return None + else: + return None + +def left_indent(state): + return (state["depth"]-1)*SECTION_INDENT + +def right_indent(state): + return (state["depth"]-1)*SECTION_INDENT + +def make_part(state, part): + return (make_style(state), part) + +def state_to_style(state): + return { "fg": state["fg_color"], "bg": state["bg_color"], "bold": state["formatting"]["bold"], "underline": state["formatting"]["underline"], "italic": state["formatting"]["italic"] } + +def style_to_state(style, state): + if style["fg"] != None: + state["fg_color"] = style["fg"] + if style["bg"] != None: + state["bg_color"] = style["bg"] + if style["bold"] != None: + state["formatting"]["bold"] = style["bold"] + if style["underline"] != None: + state["formatting"]["underline"] = style["underline"] + if style["italic"] != None: + state["formatting"]["italic"] = style["italic"] + +def make_style(state): + def mono_color(fg, bg): + return "default" + + def low_color(color): + try: + result = "default" + if color == "default": + result = "default" + + elif len(color) == 6: + r = str(color[0]) + g = str(color[2]) + b = str(color[4]) + color = r+g+b + + if len(color) == 3: + t = 7 + + if color[0] == "g": + val = int(color[1:2]) + if val < 25: result = "black" + elif val < 50: result = "dark gray" + elif val < 75: result = "light gray" + else: result = "white" + + else: + r = int(color[0], 16) + g = int(color[1], 16) + b = int(color[2], 16) + + if r == g == b: + val = int(color[0], 16)*6 + if val < 12: result = "black" + elif val < 50: result = "dark gray" + elif val < 80: result = "light gray" + else: result = "white" + + else: + if r == b: + if r > g: + if r > t: result = "light magenta" + else: result = "dark magenta" + else: + if g > t: result = "light green" + else: result = "dark green" + if b == g: + if b > r: + if b > t: result = "light cyan" + else: result = "dark cyan" + else: + if r > t: result = "light red" + else: result = "dark red" + if g == r: + if g > b: + if g > t: result = "yellow" + else: result = "brown" + else: + if b > t: result = "light blue" + else: result = "dark blue" + + if r > g and r > b: + if r > t: result = "light red" + else: result = "dark red" + if g > r and g > b: + if g > t: result = "light green" + else: result = "dark green" + if b > g and b > r: + if b > t: result = "light blue" + else: result = "dark blue" + + except Exception as e: + result = "default" + + return result + + def high_color(color): + def parseval_hex(char): + return hex(max(0,min(int(char, 16),16)))[2:] + + def parseval_dec(char): + return str(max(0,min(int(char), 9))) + + if color == "default": + return "default" + else: + if len(color) == 6: + try: + v1 = parseval_hex(color[0]) + v2 = parseval_hex(color[1]) + v3 = parseval_hex(color[2]) + v4 = parseval_hex(color[3]) + v5 = parseval_hex(color[4]) + v6 = parseval_hex(color[5]) + color = "#"+v1+v2+v3+v4+v5+v6 + + except Exception as e: + return "default" + + return color + + elif len(color) == 3: + if color[0] == "g": + try: + v1 = parseval_dec(color[1]) + v2 = parseval_dec(color[2]) + + except Exception as e: + return "default" + + return "g"+v1+v2 + + else: + try: + v1 = parseval_hex(color[0]) + v2 = parseval_hex(color[1]) + v3 = parseval_hex(color[2]) + color = v1+v2+v3 + + except Exception as e: + return "default" + + r = color[0] + g = color[1] + b = color[2] + return "#"+r+r+g+g+b+b + + + bold = state["formatting"]["bold"] + underline = state["formatting"]["underline"] + italic = state["formatting"]["italic"] + fg = state["fg_color"] + bg = state["bg_color"] + + format_string = "" + if bold: format_string += ",bold" + if underline: format_string += ",underline" + if italic: format_string += ",italics" + + name = "micron_"+fg+"_"+bg+"_"+format_string + if not name in SYNTH_STYLES: + screen = nomadnet.NomadNetworkApp.get_shared_instance().ui.screen + screen.register_palette_entry(name, low_color(fg)+format_string,low_color(bg),mono_color(fg, bg)+format_string,high_color(fg)+format_string,high_color(bg)) + + synth_spec = screen._palette[name] + SYNTH_STYLES.append(name) + if not name in SYNTH_SPECS: + SYNTH_SPECS[name] = synth_spec + + return name + +def make_output(state, line, url_delegate, pre_escape=False): + output = [] + if state["literal"]: + if line == "\\`=": + line = "`=" + output.append(make_part(state, line)) + else: + part = "" + mode = "text" + escape = pre_escape + skip = 0 + + for i in range(0, len(line)): + c = line[i] + if skip > 0: + skip -= 1 + else: + if mode == "formatting": + if c == "_": + state["formatting"]["underline"] ^= True + elif c == "!": + state["formatting"]["bold"] ^= True + elif c == "*": + state["formatting"]["italic"] ^= True + elif c == "F": + if len(line) >= i+4: + if line[i+1] == "T" and len(line) >= i+8: + color = line[i+2:i+8] + state["fg_color"] = color + skip = 7 + else: + color = line[i+1:i+4] + state["fg_color"] = color + skip = 3 + elif c == "f": + state["fg_color"] = state["default_fg"] + elif c == "B": + if len(line) >= i+4: + if line[i+1] == "T" and len(line) >= i+8: + color = line[i+2:i+8] + state["bg_color"] = color + skip = 7 + else: + color = line[i+1:i+4] + state["bg_color"] = color + skip = 3 + elif c == "b": + state["bg_color"] = state["default_bg"] + elif c == "`": + state["formatting"]["bold"] = False + state["formatting"]["underline"] = False + state["formatting"]["italic"] = False + state["fg_color"] = state["default_fg"] + state["bg_color"] = state["default_bg"] + state["align"] = state["default_align"] + elif c == "c": + if state["align"] != "center": state["align"] = "center" + elif c == "l": + if state["align"] != "left": state["align"] = "left" + elif c == "r": + if state["align"] != "right": state["align"] = "right" + elif c == "a": + state["align"] = state["default_align"] + + elif c == ":": + # Anchor declaration: `:anchor-name (terminated by any non-name char). The anchor is a zero width position narker bound to the current line by markup_to_attrmaps + name_start = i + 1 + name_end = name_start + while name_end < len(line) and (line[name_end].isalnum() or line[name_end] in "_-"): + name_end += 1 + anchor_name = line[name_start:name_end] + if anchor_name: + state.setdefault("pending_anchors", []).append(anchor_name) + skip = (name_end - i) - 1 + if skip < 0: skip = 0 + + elif c == '<': + if len(part) > 0: + output.append(make_part(state, part)) + part = "" + try: + field_start = i + 1 # position after '<' + backtick_pos = line.find('`', field_start) + if backtick_pos == -1: + pass # No '`', invalid field + else: + field_content = line[field_start:backtick_pos] + field_masked = False + field_width = 24 + field_type = "field" + field_name = field_content + field_value = "" + field_data = "" + field_prechecked = False + + # check if field_content contains '|' + if '|' in field_content: + f_components = field_content.split('|') + field_flags = f_components[0] + field_name = f_components[1] + + # handle field type indicators + if '^' in field_flags: + field_type = "radio" + field_flags = field_flags.replace("^", "") + elif '?' in field_flags: + field_type = "checkbox" + field_flags = field_flags.replace("?", "") + elif '!' in field_flags: + field_flags = field_flags.replace("!", "") + field_masked = True + + # Handle field width + if len(field_flags) > 0: + try: + field_width = min(int(field_flags), 256) + except ValueError: + pass # Ignore invalid width + + # Check for value and pre-checked flag + if len(f_components) > 2: + field_value = f_components[2] + else: + field_value = "" + if len(f_components) > 3: + if f_components[3] == '*': + field_prechecked = True + + else: + # No '|', so field_name is field_content + field_name = field_content + field_type = "field" + field_masked = False + field_width = 24 + field_value = "" + field_prechecked = False + + # Find the closing '>' character + field_end = line.find('>', backtick_pos) + if field_end == -1: + pass # No closing '>', invalid field + else: + field_data = line[backtick_pos+1:field_end] + + # Now, we have all field data + if field_type in ["checkbox", "radio"]: + # for checkboxes and radios, field_data is the label + output.append({ + "type": field_type, + "name": field_name, + "value": field_value if field_value else field_data, + "label": field_data, + "prechecked": field_prechecked, + "style": make_style(state) + }) + else: + # For text fields field_data is the initial text + output.append({ + "type": "field", + "name": field_name, + "width": field_width, + "masked": field_masked, + "data": field_data, + "style": make_style(state) + }) + skip = field_end - i + except Exception as e: + pass + + + elif c == "[": + endpos = line[i:].find("]") + if endpos == -1: + pass + else: + link_data = line[i+1:i+endpos] + skip = endpos + + link_components = link_data.split("`") + if len(link_components) == 1: + link_label = "" + link_fields = "" + link_url = link_data + elif len(link_components) == 2: + link_label = link_components[0] + link_url = link_components[1] + link_fields = "" + elif len(link_components) == 3: + link_label = link_components[0] + link_url = link_components[1] + link_fields = link_components[2] + else: + link_url = "" + link_label = "" + link_fields = "" + + if len(link_url) != 0: + if link_label == "": + link_label = link_url + + # First generate output until now + if len(part) > 0: + output.append(make_part(state, part)) + + cm = nomadnet.NomadNetworkApp.get_shared_instance().ui.colormode + + specname = make_style(state) + speclist = SYNTH_SPECS[specname] + + if cm == 1: + orig_spec = speclist[0] + elif cm == 16: + orig_spec = speclist[1] + elif cm == 88: + orig_spec = speclist[2] + elif cm == 256: + orig_spec = speclist[3] + elif cm == 2**24: + orig_spec = speclist[4] + + if url_delegate != None: + linkspec = LinkSpec(link_url, orig_spec, cm=cm) + if link_fields != "": + lf = link_fields.split("|") + if len(lf) > 0: + linkspec.link_fields = lf + + output.append((linkspec, link_label)) + else: + output.append(make_part(state, link_label)) + + mode = "text" + if len(part) > 0: + output.append(make_part(state, part)) + + elif mode == "text": + if c == "\\": + if escape: + part += c + escape = False + else: + escape = True + elif c == "`": + if escape: + part += c + escape = False + else: + mode = "formatting" + if len(part) > 0: + output.append(make_part(state, part)) + part = "" + else: + part += c + escape = False + + if i == len(line)-1: + if len(part) > 0: + output.append(make_part(state, part)) + + if len(output) > 0: + return output + else: + return None + + +class LinkSpec(urwid.AttrSpec): + def __init__(self, link_target, orig_spec, cm=256): + self.link_target = link_target + self.link_fields = None + + super().__init__(orig_spec.foreground, orig_spec.background, colors=cm) + + +class LinkableText(urwid.Text): + ignore_focus = False + _selectable = True + + signals = ["click", "change"] + + def __init__(self, text, align=None, cursor_position=0, delegate=None): + super().__init__(text, align=align) + self.delegate = delegate + self._cursor_position = 0 + self.key_timeout = 2 + self.in_columns = False + if self.delegate != None: + self.delegate.last_keypress = 0 + + def handle_link(self, link_target, link_fields): + if self.delegate != None: + self.delegate.handle_link(link_target, link_fields) + + def find_next_part_pos(self, pos, part_positions): + for position in part_positions: + if position > pos: + return position + return pos + + def find_prev_part_pos(self, pos, part_positions): + nextpos = pos + for position in part_positions: + if position < pos: + nextpos = position + return nextpos + + def find_item_at_pos(self, pos): + total = 0 + text, parts = self.get_text() + for i, info in enumerate(parts): + style, length = info + if total <= pos < length+total: + return style + + total += length + + return None + + def peek_link(self): + item = self.find_item_at_pos(self._cursor_position) + if item != None: + if isinstance(item, LinkSpec): + if self.delegate != None: + self.delegate.marked_link(item.link_target, item.link_fields) + else: + if self.delegate != None: + self.delegate.marked_link(None) + + + def keypress(self, size, key): + part_positions = [0] + parts = [] + total = 0 + text, parts = self.get_text() + for i, info in enumerate(parts): + style_name, length = info + part_positions.append(length+total) + total += length + + + if self.delegate != None: + self.delegate.last_keypress = time.time() + self._invalidate() + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(self.key_timeout, self.kt_event) + + if self._command_map[key] == urwid.ACTIVATE: + item = self.find_item_at_pos(self._cursor_position) + if item != None: + if isinstance(item, LinkSpec): + self.handle_link(item.link_target, item.link_fields) + + elif key == "up": + self._cursor_position = 0 + return key + + elif key == "down": + self._cursor_position = 0 + return key + + elif key == "right": + old = self._cursor_position + self._cursor_position = self.find_next_part_pos(self._cursor_position, part_positions) + + if self._cursor_position == old: + if self.in_columns: + return "right" + else: + self._cursor_position = 0 + return "down" + + self._invalidate() + + elif key == "left": + if self._cursor_position > 0: + if self.in_columns: + return "left" + else: + self._cursor_position = self.find_prev_part_pos(self._cursor_position, part_positions) + self._invalidate() + + else: + if self.delegate != None: + self.delegate.micron_released_focus() + + else: + return key + + def kt_event(self, loop, user_data): + self._invalidate() + + def render(self, size, focus=False): + now = time.time() + c = super().render(size, focus) + + if focus and (self.delegate == None or now < self.delegate.last_keypress+self.key_timeout): + c = urwid.CompositeCanvas(c) + c.cursor = self.get_cursor_coords(size) + if self.delegate != None: + self.peek_link() + + return c + + def get_cursor_coords(self, size): + if self._cursor_position > len(self.text): + return None + + (maxcol,) = size + trans = self.get_line_translation(maxcol) + x, y = calc_coords(self.text, trans, self._cursor_position) + if maxcol <= x: + return None + return x, y + + def mouse_event(self, size, event, button, x, y, focus): + try: + if button != 1 or not is_mouse_press(event): + return False + else: + (maxcol,) = size + translation = self.get_line_translation(maxcol) + line_offset = 0 + + if self.align == "center": + line_offset = translation[y][1][1]-translation[y][0][0] + if x < translation[y][0][0]: + x = translation[y][0][0] + + if x > translation[y][1][0]+translation[y][0][0]: + x = translation[y][1][0]+translation[y][0][0] + + elif self.align == "right": + line_offset = translation[y][1][1]-translation[y][0][0] + if x < translation[y][0][0]: + x = translation[y][0][0] + + else: + line_offset = translation[y][0][1] + if x > translation[y][0][0]: + x = translation[y][0][0] + + pos = line_offset+x + + self._cursor_position = pos + item = self.find_item_at_pos(self._cursor_position) + + if item != None: + if isinstance(item, LinkSpec): + self.handle_link(item.link_target, item.link_fields) + + self._invalidate() + self._emit("change") + + return True + + except Exception as e: + return False + +LINK_CLASS = LinkableText \ No newline at end of file diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py new file mode 100644 index 0000000..0332139 --- /dev/null +++ b/nomadnet/ui/textui/Network.py @@ -0,0 +1,1974 @@ +import RNS +import urwid +import nomadnet +import time +import threading +from datetime import datetime +from nomadnet.Directory import DirectoryEntry +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY +from nomadnet.util import strip_modifiers +from nomadnet.util import sanitize_name + +from .Browser import Browser +from .ReadlineEdit import ReadlineEdit + +class NetworkDisplayShortcuts(): + def __init__(self, app): + self.app = app + g = app.ui.glyphs + + shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL " + if app.config["textui"]["clipboard_copy"]: + shortcut_text += "[C-y] Copy " + shortcut_text += "[C-g] Fullscreen [C-s / C-b] Save Node" + self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar") + +class DialogLineBox(urwid.LineBox): + def keypress(self, size, key): + if key == "esc": + self.delegate.update_conversation_list() + else: + return super(DialogLineBox, self).keypress(size, key) + + +class ListEntry(urwid.Text): + _selectable = True + + signals = ["click"] + + def keypress(self, size, key): + """ + Send 'click' signal on 'activate' command. + """ + if self._command_map[key] != urwid.ACTIVATE: + return key + + self._emit('click') + + def mouse_event(self, size, event, button, x, y, focus): + """ + Send 'click' signal on button 1 press. + """ + if button != 1 or not urwid.util.is_mouse_press(event): + return False + + self._emit('click') + return True + + +class AnnounceInfo(urwid.WidgetWrap): + def keypress(self, size, key): + if key == "esc": + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) + else: + return super(AnnounceInfo, self).keypress(size, key) + + def __init__(self, announce, parent, app): + self.app = nomadnet.NomadNetworkApp.get_shared_instance() + self.parent = self.app.ui.main_display.sub_displays.network_display + g = self.app.ui.glyphs + + source_hash = announce[1] + time_format = app.time_format + dt = datetime.fromtimestamp(announce[0]) + ts_string = dt.strftime(time_format) + trust_level = self.app.directory.trust_level(source_hash) + trust_str = "" + display_str = self.app.directory.simplest_display_str(source_hash) + addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" + info_type = announce[3] + + is_node = False + is_pn = False + if info_type == "node" or info_type == True: + type_string = "Nomad Network Node " + g["node"] + is_node = True + elif info_type == "pn": + type_string = "LXMF Propagation Node " + g["sent"] + is_pn = True + elif info_type == "peer" or info_type == False: + type_string = "Peer " + g["peer"] + + try: + data_str = strip_modifiers(announce[2].decode("utf-8")) + data_style = "" + if trust_level != DirectoryEntry.TRUSTED and len(data_str) > 32: + data_str = data_str[:32]+" [...]" + except Exception as e: + data_str = "Decode failed" + data_style = "list_untrusted" + + + if trust_level == DirectoryEntry.UNTRUSTED: + trust_str = "Untrusted" + symbol = g["cross"] + style = "list_untrusted" + elif trust_level == DirectoryEntry.UNKNOWN: + trust_str = "Unknown" + symbol = g["unknown"] + style = "list_unknown" + elif trust_level == DirectoryEntry.TRUSTED: + trust_str = "Trusted" + symbol = g["check"] + style = "list_trusted" + elif trust_level == DirectoryEntry.WARNING: + trust_str = "Warning" + symbol = g["warning"] + style = "list_warning" + else: + trust_str = "Warning" + symbol = g["warning"] + style = "list_untrusted" + + def show_announce_stream(sender): + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.parent.left_pile.contents[0] = (self.parent.announce_stream_display, options) + + def connect(sender): + self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) + show_announce_stream(None) + + def save_node(sender): + node_entry = DirectoryEntry(source_hash, display_name=data_str, trust_level=trust_level, hosts_node=True) + self.app.directory.remember(node_entry) + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + show_announce_stream(None) + + if is_node: + node_ident = RNS.Identity.recall(source_hash) + if not node_ident: + raise KeyError("Could not recall identity for selected node") + + op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) + op_str = self.app.directory.simplest_display_str(op_hash) + + def msg_op(sender): + show_announce_stream(None) + if is_node: + try: + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + source_hash_text = RNS.hexrep(op_hash, delimit=False) + display_name = op_str + + if not source_hash_text in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(source_hash, display_name, trust_level) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list() + + self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text) + self.app.ui.main_display.show_conversations(None) + + except Exception as e: + RNS.log("Error while starting conversation from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) + + def converse(sender): + show_announce_stream(None) + try: + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + source_hash_text = RNS.hexrep(source_hash, delimit=False) + display_name = data_str + + if not source_hash_text in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(source_hash, display_name, trust_level) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list() + + self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text) + self.app.ui.main_display.show_conversations(None) + + except Exception as e: + RNS.log("Error while starting conversation from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) + + def use_pn(sender): + show_announce_stream(None) + try: + self.app.set_user_selected_propagation_node(source_hash) + except Exception as e: + RNS.log("Error while setting active propagation node from announce. The contained exception was: "+str(e), RNS.LOG_ERROR) + + if is_node: + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Connect", on_press=connect)) + msg_button = (urwid.WEIGHT, 0.45, urwid.Button("Msg Op", on_press=msg_op)) + save_button = (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_node)) + elif is_pn: + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Use as default", on_press=use_pn)) + save_button = None + else: + type_button = (urwid.WEIGHT, 0.45, urwid.Button("Converse", on_press=converse)) + save_button = None + + if is_node: + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=show_announce_stream)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + type_button, + (urwid.WEIGHT, 0.1, urwid.Text("")), + msg_button, + (urwid.WEIGHT, 0.1, urwid.Text("")), + save_button, + ]) + else: + button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=show_announce_stream)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + type_button, + ]) + + pile_widgets = [] + + if is_pn: + pile_widgets = [ + urwid.Text("Time : "+ts_string, align=urwid.LEFT), + urwid.Text("Addr : "+addr_str, align=urwid.LEFT), + urwid.Text("Type : "+type_string, align=urwid.LEFT), + urwid.Divider(g["divider1"]), + button_columns + ] + + else: + pile_widgets = [ + urwid.Text("Time : "+ts_string, align=urwid.LEFT), + urwid.Text("Addr : "+addr_str, align=urwid.LEFT), + urwid.Text("Type : "+type_string, align=urwid.LEFT), + urwid.Text("Name : "+display_str, align=urwid.LEFT), + urwid.Text(["Trust : ", (style, trust_str)], align=urwid.LEFT), + urwid.Divider(g["divider1"]), + urwid.Text(["Announce Data: \n", (data_style, data_str)], align=urwid.LEFT), + urwid.Divider(g["divider1"]), + button_columns + ] + + if is_node: + operator_entry = urwid.Text("Oprtr : "+op_str, align=urwid.LEFT) + pile_widgets.insert(4, operator_entry) + + pile = urwid.Pile(pile_widgets) + + self.display_widget = urwid.Filler(pile, valign=urwid.TOP, height=urwid.PACK) + + super().__init__(urwid.LineBox(self.display_widget, title="Announce Info")) + + +class AnnounceStreamEntry(urwid.WidgetWrap): + def __init__(self, app, announce, delegate, show_destination=False): + full_time_format = "%Y-%m-%d %H:%M:%S" + date_time_format = "%Y-%m-%d" + time_time_format = "%H:%M:%S" + short_time_format = "%Y-%m-%d %H:%M" + date_only_format = "%Y-%m-%d" + + timestamp = announce[0] + source_hash = announce[1] + announce_type = announce[3] + self.app = app + self.delegate = delegate + self.timestamp = timestamp + time_format = app.time_format + dt = datetime.fromtimestamp(self.timestamp) + dtn = datetime.fromtimestamp(time.time()) + g = self.app.ui.glyphs + + if dt.strftime(date_time_format) == dtn.strftime(date_time_format): + ts_string = dt.strftime(time_time_format) + else: + ts_string = dt.strftime(date_only_format) + + trust_level = self.app.directory.trust_level(source_hash) + + def san(name): + if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) + else: return strip_modifiers(name) + + if show_destination: + display_str = RNS.hexrep(source_hash, delimit=False) + else: + try: display_str = san(announce[2].decode("utf-8")) + except: display_str = self.app.directory.simplest_display_str(source_hash) + + if not display_str: display_str = RNS.prettyhexrep(source_hash) + if len(display_str) > 34: display_str = display_str[:34] + "…" + + if trust_level == DirectoryEntry.UNTRUSTED: + symbol = g["cross"] + style = "list_untrusted" + focus_style = "list_focus_untrusted" + elif trust_level == DirectoryEntry.UNKNOWN: + symbol = g["unknown"] + style = "list_unknown" + focus_style = "list_focus" + elif trust_level == DirectoryEntry.TRUSTED: + symbol = g["check"] + style = "list_trusted" + focus_style = "list_focus_trusted" + elif trust_level == DirectoryEntry.WARNING: + symbol = g["warning"] + style = "list_warning" + focus_style = "list_focus" + else: + symbol = g["warning"] + style = "list_untrusted" + focus_style = "list_focus_untrusted" + + if announce_type == "node" or announce_type == True: + type_symbol = g["node"] + elif announce_type == "peer" or announce_type == False: + type_symbol = g["peer"] + elif announce_type == "pn": + type_symbol = g["sent"] + + widget = ListEntry(ts_string+" "+type_symbol+" "+display_str) + urwid.connect_signal(widget, "click", self.display_announce, announce) + + self.display_widget = urwid.AttrMap(widget, style, focus_style) + super().__init__(self.display_widget) + + def display_announce(self, event, announce): + try: + parent = self.app.ui.main_display.sub_displays.network_display + info_widget = AnnounceInfo(announce, parent, self.app) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + parent.left_pile.contents[0] = (info_widget, options) + + except KeyError as e: + def dismiss_dialog(sender): + self.delegate.parent.close_list_dialogs() + + def confirmed(sender): + def close_req(sender): + self.delegate.parent.close_list_dialogs() + + dialog_pile.contents[0] = (urwid.Text("\nKeys requested from network\n", align=urwid.CENTER), options) + RNS.Transport.request_path(announce[1]) + + confirmed_button = urwid.Button("Request keys", on_press=confirmed) + + dialog_pile = urwid.Pile([ + urwid.Text( + "The keys for the announced destination could not be recalled. " + "You can wait for an announce to arrive, or request the keys from the network.\n", + align=urwid.CENTER, + ), + urwid.Columns([ + (urwid.WEIGHT, 0.45, confirmed_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Close", on_press=dismiss_dialog)), + ]) + ]) + + dialog = ListDialogLineBox( + dialog_pile, + title="Keys Unknown" + ) + confirmed_button.dialog_pile = dialog_pile + dialog.delegate = self.delegate.parent + bottom = self.delegate + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.delegate.parent.left_pile.options(urwid.WEIGHT, 1) + self.delegate.parent.left_pile.contents[0] = (overlay, options) + + def timestamp(self): + return self.timestamp + +class TabButton(urwid.Button): + button_left = urwid.Text("[") + button_right = urwid.Text("]") + +class AnnounceStream(urwid.WidgetWrap): + def __init__(self, app, parent): + self.app = app + self.parent = parent + self.started = False + self.timeout = self.app.config["textui"]["animation_interval"]*2 + self.ilb = None + self.no_content = True + self.current_tab = "nodes" + self.show_destination = False + self.search_text = "" + + self.added_entries = [] + self.widget_list = [] + + self.tab_nodes = TabButton("Nodes (0)", on_press=self.show_nodes_tab) + self.tab_peers = TabButton("Peers (0)", on_press=self.show_peers_tab) + self.tab_pn = TabButton("Propagation Nodes (0)", on_press=self.show_pn_tab) + + self.tab_bar = urwid.Columns([ + ('weight', 1, self.tab_nodes), + ('weight', 1, self.tab_peers), + ('weight', 3, self.tab_pn), + ], dividechars=1) + + self.search_edit = ReadlineEdit(caption="Search: ") + urwid.connect_signal(self.search_edit, 'change', self.on_search_change) + + self.display_toggle = TabButton("Show: Name", on_press=self.toggle_display_mode) + + self.filter_bar = urwid.Columns([ + ('weight', 2, self.search_edit), + ('weight', 1, self.display_toggle), + ], dividechars=1) + + self.update_widget_list() + + self.ilb = ExceptionHandlingListBox( + self.widget_list, + on_selection_change=self.list_selection, + initialization_is_selection_change=False, + #modifier_key=MODIFIER_KEY.CTRL, + #highlight_offFocus="list_off_focus" + ) + + self.pile = urwid.Pile([ + ('pack', self.tab_bar), + ('pack', self.filter_bar), + ('weight', 1, self.ilb), + ]) + + self.display_widget = self.pile + super().__init__(urwid.LineBox(self.display_widget, title="Announce Stream")) + + def keypress(self, size, key): + if key == "up" and self.pile.focus == self.tab_bar: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + elif key == "ctrl x": + self.delete_selected_entry() + + return super(AnnounceStream, self).keypress(size, key) + + def on_search_change(self, widget, text): + self.search_text = text.lower() + self.update_widget_list() + + def toggle_display_mode(self, button): + self.show_destination = not self.show_destination + if self.show_destination: + self.display_toggle.set_label("Show: Dest") + else: + self.display_toggle.set_label("Show: Name") + self.update_widget_list() + + def delete_selected_entry(self): + sel = self.ilb.get_selected_item() + if sel != None and hasattr(sel, "original_widget") and sel.original_widget: + if hasattr(sel.original_widget, "timestamp"): + self.app.directory.remove_announce_with_timestamp(sel.original_widget.timestamp) + self.rebuild_widget_list() + + def rebuild_widget_list(self): + self.no_content = True + self.added_entries = [] + self.widget_list = [] + self.update_widget_list() + + def update_widget_list(self): + self.widget_list = [] + new_entries = [] + + node_count = 0 + peer_count = 0 + pn_count = 0 + + for e in self.app.directory.announce_stream: + announce_type = e[3] + + if self.search_text: + try: + announce_data = e[2].decode("utf-8").lower() + except: + announce_data = "" + if self.search_text not in announce_data: + continue + + if announce_type == "node" or announce_type == True: + node_count += 1 + if self.current_tab == "nodes": new_entries.append(e) + + elif announce_type == "peer" or announce_type == False: + peer_count += 1 + if self.current_tab == "peers": new_entries.append(e) + + elif announce_type == "pn": + pn_count += 1 + if self.current_tab == "pn": new_entries.append(e) + + for e in new_entries: + nw = AnnounceStreamEntry(self.app, e, self, show_destination=self.show_destination) + nw.timestamp = e[0] + self.widget_list.append(nw) + + if len(new_entries) > 0: + self.no_content = False + else: + self.no_content = True + self.widget_list = [urwid.Text(f"No {self.current_tab} announces", align='center')] + + self.tab_nodes.set_label(f"Nodes ({node_count})") + self.tab_peers.set_label(f"Peers ({peer_count})") + self.tab_pn.set_label(f"Propagation Nodes ({pn_count})") + + if self.ilb: + self.ilb.set_body(self.widget_list) + + def show_nodes_tab(self, button): + self.current_tab = "nodes" + self.update_widget_list() + + def show_peers_tab(self, button): + self.current_tab = "peers" + self.update_widget_list() + + def show_pn_tab(self, button): + self.current_tab = "pn" + self.update_widget_list() + + def list_selection(self, arg1, arg2): + pass + + def update(self): + self.update_widget_list() + + def update_callback(self, loop=None, user_data=None): + self.update() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_callback() + + def stop(self): + self.started = False + +class SelectText(urwid.Text): + _selectable = True + + signals = ["click"] + + def keypress(self, size, key): + """ + Send 'click' signal on 'activate' command. + """ + if self._command_map[key] != urwid.ACTIVATE: + return key + + self._emit('click') + + def mouse_event(self, size, event, button, x, y, focus): + """ + Send 'click' signal on button 1 press. + """ + if button != 1 or not urwid.util.is_mouse_press(event): + return False + + self._emit('click') + return True + +class ListDialogLineBox(urwid.LineBox): + def keypress(self, size, key): + if key == "esc": + self.delegate.close_list_dialogs() + else: + return super(ListDialogLineBox, self).keypress(size, key) + +class KnownNodeInfo(urwid.WidgetWrap): + def keypress(self, size, key): + if key == "esc": + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) + else: + return super(KnownNodeInfo, self).keypress(size, key) + + def __init__(self, node_hash): + self.app = nomadnet.NomadNetworkApp.get_shared_instance() + self.parent = self.app.ui.main_display.sub_displays.network_display + self.pn_changed = False + g = self.app.ui.glyphs + + source_hash = node_hash + node_ident = RNS.Identity.recall(node_hash) + time_format = self.app.time_format + trust_level = self.app.directory.trust_level(source_hash) + trust_str = "" + node_entry = self.app.directory.find(source_hash) + sort_str = self.app.directory.sort_rank(source_hash) + if sort_str == None: + sort_str = "None" + else: + sort_str = str(sort_str) + + if node_entry == None: + display_str = self.app.directory.simplest_display_str(source_hash) + else: + display_str = strip_modifiers(node_entry.display_name) + + addr_str = "<"+RNS.hexrep(source_hash, delimit=False)+">" + + if display_str == None: + display_str = addr_str + + pn_hash = RNS.Destination.hash_from_name_and_identity("lxmf.propagation", node_ident) + + if node_ident != None: + lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(pn_hash) + else: + lxmf_addr_str = "No associated Propagation Node known" + + + type_string = "Nomad Network Node " + g["node"] + + if trust_level == DirectoryEntry.UNTRUSTED: + trust_str = "Untrusted" + symbol = g["cross"] + style = "list_untrusted" + elif trust_level == DirectoryEntry.UNKNOWN: + trust_str = "Unknown" + symbol = g["unknown"] + style = "list_unknown" + elif trust_level == DirectoryEntry.TRUSTED: + trust_str = "Trusted" + symbol = g["check"] + style = "list_trusted" + elif trust_level == DirectoryEntry.WARNING: + trust_str = "Warning" + symbol = g["warning"] + style = "list_warning" + else: + trust_str = "Warning" + symbol = g["warning"] + style = "list_untrusted" + + if trust_level == DirectoryEntry.UNTRUSTED: + untrusted_selected = True + unknown_selected = False + trusted_selected = False + elif trust_level == DirectoryEntry.UNKNOWN: + untrusted_selected = False + unknown_selected = True + trusted_selected = False + elif trust_level == DirectoryEntry.TRUSTED: + untrusted_selected = False + unknown_selected = False + trusted_selected = True + + trust_button_group = [] + r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted", state=untrusted_selected) + r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected) + r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) + + e_name = ReadlineEdit(caption="Name : ",edit_text=display_str) + e_sort = ReadlineEdit(caption="Sort Rank : ",edit_text=sort_str) + + node_ident = RNS.Identity.recall(source_hash) + op_hash = None + op_str = None + if node_ident != None: + op_hash = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", node_ident) + op_str = self.app.directory.simplest_display_str(op_hash) + else: + op_str = "Unknown" + + def show_known_nodes(sender): + options = self.parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.parent.left_pile.contents[0] = (self.parent.known_nodes_display, options) + + def connect(sender): + self.parent.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) + show_known_nodes(None) + + def msg_op(sender): + show_known_nodes(None) + if node_ident != None: + try: + existing_conversations = nomadnet.Conversation.conversation_list(self.app) + + source_hash_text = RNS.hexrep(op_hash, delimit=False) + display_name = op_str + + if not source_hash_text in [c[0] for c in existing_conversations]: + entry = DirectoryEntry(source_hash, display_name, trust_level) + self.app.directory.remember(entry) + + new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) + self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list() + + self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text) + self.app.ui.main_display.show_conversations(None) + + except Exception as e: + RNS.log("Error while starting conversation from node info. The contained exception was: "+str(e), RNS.LOG_ERROR) + + def pn_change(sender, userdata): + self.pn_changed = True + + def ident_change(sender, userdata): + pass + + propagation_node_checkbox = urwid.CheckBox("Use as default propagation node", state=(self.app.get_user_selected_propagation_node() == pn_hash), on_state_change=pn_change) + connect_identify_checkbox = urwid.CheckBox("Identify when connecting", state=self.app.directory.should_identify_on_connect(source_hash), on_state_change=ident_change) + + def save_node(sender): + if self.pn_changed: + if propagation_node_checkbox.get_state(): + self.app.set_user_selected_propagation_node(pn_hash) + else: + self.app.set_user_selected_propagation_node(None) + + trust_level = DirectoryEntry.UNTRUSTED + if r_unknown.get_state() == True: + trust_level = DirectoryEntry.UNKNOWN + + if r_trusted.get_state() == True: + trust_level = DirectoryEntry.TRUSTED + + display_str = e_name.get_edit_text() + sort_rank = e_sort.get_edit_text() + try: + if int(sort_rank) >= 0: + sort_rank = int(sort_rank) + else: + sort_rank = None + except: + sort_rank = None + + node_entry = DirectoryEntry(source_hash, display_name=display_str, trust_level=trust_level, hosts_node=True, identify_on_connect=connect_identify_checkbox.get_state(), sort_rank=sort_rank) + self.app.directory.remember(node_entry) + self.app.ui.main_display.sub_displays.network_display.directory_change_callback() + + if trust_level == DirectoryEntry.TRUSTED: + self.app.autoselect_propagation_node() + + show_known_nodes(None) + + back_button = (urwid.WEIGHT, 0.2, urwid.Button("Back", on_press=show_known_nodes)) + connect_button = (urwid.WEIGHT, 0.2, urwid.Button("Connect", on_press=connect)) + save_button = (urwid.WEIGHT, 0.2, urwid.Button("Save", on_press=save_node)) + msg_button = (urwid.WEIGHT, 0.2, urwid.Button("Msg Op", on_press=msg_op)) + bdiv = (urwid.WEIGHT, 0.02, urwid.Text("")) + + button_columns = urwid.Columns([back_button, bdiv, connect_button, bdiv, msg_button, bdiv, save_button]) + + pile_widgets = [ + urwid.Text("Type : "+type_string, align=urwid.LEFT), + e_name, + urwid.Text("Node Addr : "+addr_str, align=urwid.LEFT), + e_sort, + urwid.Divider(g["divider1"]), + urwid.Text(lxmf_addr_str, align=urwid.CENTER), + urwid.Divider(g["divider1"]), + propagation_node_checkbox, + connect_identify_checkbox, + urwid.Divider(g["divider1"]), + r_untrusted, + r_unknown, + r_trusted, + urwid.Divider(g["divider1"]), + button_columns + ] + + operator_entry = urwid.Text("Operator : "+op_str, align=urwid.LEFT) + pile_widgets.insert(3, operator_entry) + + hops = RNS.Transport.hops_to(source_hash) + if hops == 1: + str_s = "" + else: + str_s = "s" + + if hops != RNS.Transport.PATHFINDER_M: + hops_str = str(hops)+" hop"+str_s + else: + hops_str = "Unknown" + + operator_entry = urwid.Text("Distance : "+hops_str, align=urwid.LEFT) + pile_widgets.insert(4, operator_entry) + + pile = urwid.Pile(pile_widgets) + + pile.focus_position = len(pile.contents)-1 + button_columns.focus_position = 0 + + + self.display_widget = urwid.Filler(pile, valign=urwid.TOP, height=urwid.PACK) + + super().__init__(urwid.LineBox(self.display_widget, title="Node Info")) + + +# Yes, this is weird. There is a bug in Urwid/ILB that causes +# an indexing exception when the list is very small vertically. +# This mitigates it. +class ExceptionHandlingListBox(IndicativeListBox): + def keypress(self, size, key): + try: + return super(ExceptionHandlingListBox, self).keypress(size, key) + + except Exception as e: + if key == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + elif key == "down": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.left_pile.focus_position = 1 + else: + RNS.log("An error occurred while processing an interface event. The contained exception was: "+str(e), RNS.LOG_ERROR) + + +class KnownNodes(urwid.WidgetWrap): + def __init__(self, app): + self.app = app + self.node_list = app.directory.known_nodes() + g = self.app.ui.glyphs + + self.widget_list = self.make_node_widgets() + + self.ilb = ExceptionHandlingListBox( + self.widget_list, + on_selection_change=self.node_list_selection, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus" + ) + + if len(self.node_list) > 0: + self.display_widget = self.ilb + widget_style = None + self.no_content = False + else: + self.no_content = True + widget_style = "inactive_text" + self.pile = urwid.Pile([ + urwid.Text(("warning_text", g["info"]+"\n"), align=urwid.CENTER), + SelectText( + ( + "warning_text", + "Currently, no nodes are saved\n\nCtrl+L to view the announce stream\n\n", + ), + align=urwid.CENTER, + ), + ]) + self.display_widget = urwid.Filler(self.pile, valign=urwid.TOP, height=urwid.PACK) + + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style)) + + def keypress(self, size, key): + if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + elif key == "ctrl x": + self.delete_selected_entry() + + return super(KnownNodes, self).keypress(size, key) + + + def node_list_selection(self, arg1, arg2): + pass + + def connect_node(self, event, node): + source_hash = node.source_hash + trust_level = node.trust_level + trust_level = self.app.directory.trust_level(source_hash) + display_str = self.app.directory.simplest_display_str(source_hash) + + parent = self.app.ui.main_display.sub_displays.network_display + + def dismiss_dialog(sender): + self.delegate.close_list_dialogs() + + def confirmed(sender): + self.delegate.browser.retrieve_url(RNS.hexrep(source_hash, delimit=False)) + self.delegate.close_list_dialogs() + + def show_info(sender): + info_widget = KnownNodeInfo(source_hash) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + parent.left_pile.contents[0] = (info_widget, options) + + + dialog = ListDialogLineBox( + urwid.Pile([ + urwid.Text("Connect to node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Info", on_press=show_info))]) + ]), title="?" + ) + dialog.delegate = self.delegate + bottom = self + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) + self.delegate.left_pile.contents[0] = (overlay, options) + + def delete_selected_entry(self): + si = self.ilb.get_selected_item() + if si != None: + source_hash = si.original_widget.source_hash + + def dismiss_dialog(sender): + self.delegate.close_list_dialogs() + + def confirmed(sender): + self.app.directory.forget(source_hash) + self.rebuild_widget_list() + self.delegate.close_list_dialogs() + + + dialog = ListDialogLineBox( + urwid.Pile([ + urwid.Text("Delete Node\n"+self.app.directory.simplest_display_str(source_hash)+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)), + ]) + ]), title="?" + ) + dialog.delegate = self.delegate + bottom = self + + overlay = urwid.Overlay( + dialog, + bottom, + align=urwid.CENTER, + width=urwid.RELATIVE_100, + valign=urwid.MIDDLE, + height=urwid.PACK, + left=2, + right=2, + ) + + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) + self.delegate.left_pile.contents[0] = (overlay, options) + + + def rebuild_widget_list(self): + self.node_list = self.app.directory.known_nodes() + self.widget_list = self.make_node_widgets() + self.ilb.set_body(self.widget_list) + if len(self.widget_list) > 0: + self.no_content = False + else: + self.no_content = True + self.delegate.reinit_known_nodes() + + def make_node_widgets(self): + widget_list = [] + for node_entry in self.node_list: + # TODO: Implement this + ne = NodeEntry(self.app, node_entry, self) + ne.source_hash = node_entry.source_hash + widget_list.append(ne) + + # TODO: Sort list + return widget_list + +class NodeEntry(urwid.WidgetWrap): + def __init__(self, app, node, delegate): + source_hash = node.source_hash + trust_level = node.trust_level + + self.app = app + g = self.app.ui.glyphs + + trust_level = self.app.directory.trust_level(source_hash) + display_str = self.app.directory.simplest_display_str(source_hash, san=False) + + if trust_level == DirectoryEntry.UNTRUSTED: + symbol = g["cross"] + style = "list_untrusted" + focus_style = "list_focus_untrusted" + elif trust_level == DirectoryEntry.UNKNOWN: + symbol = g["unknown"] + style = "list_unknown" + focus_style = "list_focus" + elif trust_level == DirectoryEntry.TRUSTED: + symbol = g["check"] + style = "list_trusted" + focus_style = "list_focus_trusted" + elif trust_level == DirectoryEntry.WARNING: + symbol = g["warning"] + style = "list_warning" + focus_style = "list_focus" + else: + symbol = g["warning"] + style = "list_untrusted" + focus_style = "list_focus_untrusted" + + type_symbol = g["node"] + + widget = ListEntry(type_symbol+" "+display_str) + urwid.connect_signal(widget, "click", delegate.connect_node, node) + + self.display_widget = urwid.AttrMap(widget, style, focus_style) + self.display_widget.source_hash = source_hash + super().__init__(self.display_widget) + + +class AnnounceTime(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_time() + + super().__init__(self.display_widget) + + def update_time(self): + self.last_announce_string = "Never" + if self.app.peer_settings["last_announce"] != None: + self.last_announce_string = pretty_date(int(self.app.peer_settings["last_announce"])) + + self.display_widget.set_text("Announced : "+self.last_announce_string) + + def update_time_callback(self, loop=None, user_data=None): + self.update_time() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_time_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_time_callback() + + def stop(self): + self.started = False + + +class NodeAnnounceTime(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_time() + + super().__init__(self.display_widget) + + def update_time(self): + self.last_announce_string = "Never" + if self.app.peer_settings["node_last_announce"] != None: + self.last_announce_string = pretty_date(int(self.app.peer_settings["node_last_announce"])) + + self.display_widget.set_text("Last Announce : "+self.last_announce_string) + + def update_time_callback(self, loop=None, user_data=None): + self.update_time() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_time_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_time_callback() + + def stop(self): + self.started = False + +class NodeActiveConnections(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + super().__init__(self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None: + self.stat_string = str(len(self.app.node.destination.links)) + + self.display_widget.set_text("Connected Now : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False + +class NodeStorageStats(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + super().__init__(self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None and not self.app.disable_propagation: + + limit = self.app.message_router.message_storage_limit + used = self.app.message_router.message_storage_size() + + if limit != None and used != None: + pct = round((used/limit)*100, 1) + pct_str = str(pct)+"%, " + limit_str = " of "+RNS.prettysize(limit) + else: + limit_str = "" + pct_str = "" + + self.stat_string = pct_str+RNS.prettysize(used)+limit_str + + self.display_widget.set_text("LXMF Storage : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False + +class NodeTotalConnections(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + super().__init__(self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None: + self.stat_string = str(self.app.peer_settings["node_connects"]) + + self.display_widget.set_text("Total Connects : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False + + +class NodeTotalPages(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + super().__init__(self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None: + self.stat_string = str(self.app.peer_settings["served_page_requests"]) + + self.display_widget.set_text("Served Pages : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False + + +class NodeTotalFiles(urwid.WidgetWrap): + def __init__(self, app): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"] + self.display_widget = urwid.Text("") + self.update_stat() + + super().__init__(self.display_widget) + + def update_stat(self): + self.stat_string = "None" + if self.app.node != None: + self.stat_string = str(self.app.peer_settings["served_file_requests"]) + + self.display_widget.set_text("Served Files : "+self.stat_string) + + def update_stat_callback(self, loop=None, user_data=None): + self.update_stat() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_stat_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_stat_callback() + + def stop(self): + self.started = False + + +class LocalPeer(urwid.WidgetWrap): + announce_timer = None + + def __init__(self, app, parent): + self.app = app + self.parent = parent + g = self.app.ui.glyphs + self.dialog_open = False + display_name = self.app.lxmf_destination.display_name + if display_name == None: + display_name = "" + + t_id = urwid.Text("LXMF Addr : "+RNS.prettyhexrep(self.app.lxmf_destination.hash)) + i_id = urwid.Text("Identity : "+RNS.prettyhexrep(self.app.identity.hash)) + e_name = ReadlineEdit(caption="Name : ", edit_text=display_name) + + def save_query(sender): + def dismiss_dialog(sender): + self.dialog_open = False + self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) + + self.app.set_display_name(e_name.get_edit_text()) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("\n\n\nSaved\n\n", align=urwid.CENTER), + urwid.Button("OK", on_press=dismiss_dialog) + ]), title=g["info"] + ) + dialog.delegate = self + bottom = self + + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) + overlay = dialog + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.dialog_open = True + self.parent.left_pile.contents[1] = (overlay, options) + + def announce_query(sender): + def dismiss_dialog(sender): + self.dialog_open = False + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) + + self.app.announce_now() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("\n\n\nAnnounce Sent\n\n\n", align=urwid.CENTER), + urwid.Button("OK", on_press=dismiss_dialog) + ]), title=g["info"] + ) + dialog.delegate = self + bottom = self + + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) + overlay = dialog + + self.dialog_open = True + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (overlay, options) + + def node_info_query(sender): + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (self.parent.node_info_display, options) + + if LocalPeer.announce_timer == None: + self.t_last_announce = AnnounceTime(self.app) + LocalPeer.announce_timer = self.t_last_announce + else: + self.t_last_announce = LocalPeer.announce_timer + self.t_last_announce.update_time() + + announce_button = urwid.Button("Announce Now", on_press=announce_query) + + self.display_widget = urwid.Pile( + [ + t_id, + i_id, + e_name, + urwid.Divider(g["divider1"]), + self.t_last_announce, + announce_button, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), + ]), + ] + ) + + super().__init__(urwid.LineBox(self.display_widget, title="Local Peer Info")) + + def start(self): + self.t_last_announce.start() + + +class NodeInfo(urwid.WidgetWrap): + announce_timer = None + links_timer = None + conns_timer = None + pages_timer = None + files_timer = None + storage_timer = None + + def __init__(self, app, parent): + self.app = app + self.parent = parent + g = self.app.ui.glyphs + + self.dialog_open = False + + widget_style = "" + + def show_peer_info(sender): + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (LocalPeer(self.app, self.parent), options) + + if self.app.enable_node: + if self.app.node != None: + display_name = self.app.node.name + else: + display_name = None + + if display_name == None: + display_name = "" + + t_id = urwid.Text("Addr : "+RNS.hexrep(self.app.node.destination.hash, delimit=False)) + e_name = urwid.Text("Name : "+display_name) + + def stats_query(sender): + self.app.peer_settings["node_connects"] = 0 + self.app.peer_settings["served_page_requests"] = 0 + self.app.peer_settings["served_file_requests"] = 0 + self.app.save_peer_settings() + + def announce_query(sender): + def dismiss_dialog(sender): + self.dialog_open = False + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (NodeInfo(self.app, self.parent), options) + + self.app.node.announce() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text("\n\n\nAnnounce Sent\n\n", align=urwid.CENTER), + urwid.Button("OK", on_press=dismiss_dialog) + ]), title=g["info"] + ) + dialog.delegate = self + bottom = self + + #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4) + overlay = dialog + + self.dialog_open = True + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (overlay, options) + + def connect_query(sender): + self.parent.browser.retrieve_url(RNS.hexrep(self.app.node.destination.hash, delimit=False)) + + if NodeInfo.announce_timer == None: + self.t_last_announce = NodeAnnounceTime(self.app) + NodeInfo.announce_timer = self.t_last_announce + else: + self.t_last_announce = NodeInfo.announce_timer + self.t_last_announce.update_time() + + if NodeInfo.links_timer == None: + self.t_active_links = NodeActiveConnections(self.app) + NodeInfo.links_timer = self.t_active_links + else: + self.t_active_links = NodeInfo.links_timer + self.t_active_links.update_stat() + + if NodeInfo.storage_timer == None: + self.t_storage_stats = NodeStorageStats(self.app) + NodeInfo.storage_timer = self.t_storage_stats + else: + self.t_storage_stats = NodeInfo.storage_timer + self.t_storage_stats.update_stat() + + if NodeInfo.conns_timer == None: + self.t_total_connections = NodeTotalConnections(self.app) + NodeInfo.conns_timer = self.t_total_connections + else: + self.t_total_connections = NodeInfo.conns_timer + self.t_total_connections.update_stat() + + if NodeInfo.pages_timer == None: + self.t_total_pages = NodeTotalPages(self.app) + NodeInfo.pages_timer = self.t_total_pages + else: + self.t_total_pages = NodeInfo.pages_timer + self.t_total_pages.update_stat() + + if NodeInfo.files_timer == None: + self.t_total_files = NodeTotalFiles(self.app) + NodeInfo.files_timer = self.t_total_files + else: + self.t_total_files = NodeInfo.files_timer + self.t_total_files.update_stat() + + lxmf_addr_str = g["sent"]+" LXMF Propagation Node Address is "+RNS.prettyhexrep(RNS.Destination.hash_from_name_and_identity("lxmf.propagation", self.app.node.destination.identity)) + e_lxmf = urwid.Text(lxmf_addr_str, align=urwid.CENTER) + + announce_button = urwid.Button("Announce", on_press=announce_query) + connect_button = urwid.Button("Browse", on_press=connect_query) + reset_button = urwid.Button("Rst Stats", on_press=stats_query) + + if not self.app.disable_propagation: + pile = urwid.Pile([ + t_id, + e_name, + urwid.Divider(g["divider1"]), + e_lxmf, + urwid.Divider(g["divider1"]), + self.t_last_announce, + self.t_storage_stats, + self.t_active_links, + self.t_total_connections, + self.t_total_pages, + self.t_total_files, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 6, connect_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 8, reset_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 7, announce_button), + ]) + ]) + else: + pile = urwid.Pile([ + t_id, + e_name, + urwid.Divider(g["divider1"]), + self.t_last_announce, + self.t_storage_stats, + self.t_active_links, + self.t_total_connections, + self.t_total_pages, + self.t_total_files, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 6, connect_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 8, reset_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 7, announce_button), + ]) + ]) + else: + pile = urwid.Pile([ + urwid.Text("\n"+g["info"], align=urwid.CENTER), + urwid.Text("\nThis instance is not hosting a node\n\n", align=urwid.CENTER), + urwid.Padding(urwid.Button("Back", on_press=show_peer_info), urwid.CENTER, urwid.PACK) + ]) + + self.display_widget = pile + + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title="Local Node Info"), widget_style)) + + def start(self): + if self.app.node != None: + self.t_last_announce.start() + self.t_active_links.start() + self.t_total_connections.start() + self.t_total_pages.start() + self.t_total_files.start() + + +class UpdatingText(urwid.WidgetWrap): + def __init__(self, app, title, value_method, append_text=""): + self.started = False + self.app = app + self.timeout = self.app.config["textui"]["animation_interval"]*5 + self.display_widget = urwid.Text("") + self.value = None + self.value_method = value_method + self.title = title + self.append_text = append_text + self.update() + + super().__init__(self.display_widget) + + def update(self): + self.value = self.value_method() + self.display_widget.set_text(self.title+str(self.value)+str(self.append_text)) + + def update_callback(self, loop=None, user_data=None): + self.update() + if self.started: + self.app.ui.loop.set_alarm_in(self.timeout, self.update_callback) + + def start(self): + was_started = self.started + self.started = True + if not was_started: + self.update_callback() + + def stop(self): + self.started = False + +class NetworkStats(urwid.WidgetWrap): + def __init__(self, app, parent): + self.app = app + self.parent = parent + + def get_num_peers(): + return self.app.directory.number_of_known_peers(lookback_seconds=30*60) + + + def get_num_nodes(): + return self.app.directory.number_of_known_nodes() + + self.w_heard_peers = UpdatingText(self.app, "Heard Peers: ", get_num_peers, append_text=" (30m)") + self.w_known_nodes = UpdatingText(self.app, "Known Nodes: ", get_num_nodes) + + pile = urwid.Pile([ + self.w_heard_peers, + self.w_known_nodes, + ]) + + self.display_widget = urwid.LineBox(pile, title="Network Stats") + + super().__init__(self.display_widget) + + def start(self): + self.w_heard_peers.start() + self.w_known_nodes.start() + +class NetworkLeftPile(urwid.Pile): + def keypress(self, size, key): + if key == "ctrl l": + self.parent.toggle_list() + elif key == "ctrl g": + self.parent.toggle_fullscreen() + elif key == "ctrl e": + self.parent.selected_node_info() + elif key == "ctrl p": + self.parent.reinit_lxmf_peers() + self.parent.show_peers() + elif key == "ctrl w": + self.parent.browser.disconnect() + elif key == "ctrl u": + self.parent.browser.url_dialog() + elif key == "ctrl s": + self.parent.browser.save_node_dialog() + + else: + return super(NetworkLeftPile, self).keypress(size, key) + + +class NetworkDisplay(): + list_width = 0.33 + given_list_width = 52 + + def __init__(self, app): + self.app = app + g = self.app.ui.glyphs + + self.browser = Browser(self.app, "nomadnetwork", "node", auth_identity = self.app.identity, delegate = self) + + if self.app.node != None: + self.browser.loopback = self.app.node.destination.hash + + self.known_nodes_display = KnownNodes(self.app) + self.lxmf_peers_display = LXMFPeers(self.app) + self.network_stats_display = NetworkStats(self.app, self) + self.announce_stream_display = AnnounceStream(self.app, self) + self.local_peer_display = LocalPeer(self.app, self) + self.node_info_display = NodeInfo(self.app, self) + + self.known_nodes_display.delegate = self + + self.list_display = 1 + self.left_pile = NetworkLeftPile([ + (urwid.WEIGHT, 1, self.known_nodes_display), + # (urwid.PACK, self.network_stats_display), + (urwid.PACK, self.local_peer_display), + ]) + + self.left_pile.parent = self + + self.left_area = self.left_pile + self.right_area = self.browser.display_widget + self.right_area_width = 1-NetworkDisplay.list_width + + self.columns = urwid.Columns( + [ + # (urwid.WEIGHT, NetworkDisplay.list_width, self.left_area), + # (urwid.WEIGHT, self.right_area_width, self.right_area) + (NetworkDisplay.given_list_width, self.left_area), + (urwid.WEIGHT, 1, self.right_area) + ], + dividechars=0, focus_column=0 + ) + + self.shortcuts_display = NetworkDisplayShortcuts(self.app) + self.widget = self.columns + + def toggle_list(self): + if self.list_display != 0: + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.left_pile.contents[0] = (self.announce_stream_display, options) + self.list_display = 0 + else: + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.left_pile.contents[0] = (self.known_nodes_display, options) + self.list_display = 1 + + def toggle_fullscreen(self): + if NetworkDisplay.given_list_width != 0: + self.saved_list_width = NetworkDisplay.given_list_width + NetworkDisplay.given_list_width = 0 + else: + NetworkDisplay.given_list_width = self.saved_list_width + + options = self.widget.options("given", NetworkDisplay.given_list_width) + self.widget.contents[0] = (self.left_area, options) + + def show_peers(self): + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.left_pile.contents[0] = (self.lxmf_peers_display, options) + + if self.list_display != 0: + self.list_display = 0 + else: + self.list_display = 1 + + def selected_node_info(self): + if self.list_display == 1: + parent = self.app.ui.main_display.sub_displays.network_display + selected_node_entry = parent.known_nodes_display.ilb.get_selected_item() + if selected_node_entry is not None: + selected_node_hash = selected_node_entry.base_widget.display_widget.source_hash + + if selected_node_hash is not None: + info_widget = KnownNodeInfo(selected_node_hash) + options = parent.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + parent.left_pile.contents[0] = (info_widget, options) + + def focus_lists(self): + self.columns.focus_position = 0 + + def reinit_known_nodes(self): + self.known_nodes_display = KnownNodes(self.app) + self.known_nodes_display.delegate = self + self.announce_stream_display.rebuild_widget_list() + + def reinit_lxmf_peers(self): + if self.lxmf_peers_display: + si = self.lxmf_peers_display.ilb.get_selected_position() + else: + si = None + self.lxmf_peers_display = LXMFPeers(self.app) + self.lxmf_peers_display.delegate = self + self.close_list_dialogs() + if si != None: + self.lxmf_peers_display.ilb.select_item(si) + + def close_list_dialogs(self): + if self.list_display == 0: + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.left_pile.contents[0] = (self.announce_stream_display, options) + else: + options = self.left_pile.options(height_type=urwid.WEIGHT, height_amount=1) + self.left_pile.contents[0] = (self.known_nodes_display, options) + + def start(self): + self.local_peer_display.start() + self.node_info_display.start() + self.network_stats_display.start() + + def shortcuts(self): + return self.shortcuts_display + + def directory_change_callback(self): + self.announce_stream_display.rebuild_widget_list() + if self.known_nodes_display.no_content: + self.reinit_known_nodes() + else: + self.known_nodes_display.rebuild_widget_list() + + +class LXMFPeers(urwid.WidgetWrap): + def __init__(self, app): + self.app = app + self.peer_list = app.message_router.peers + # self.peer_list = {} + + g = self.app.ui.glyphs + + self.widget_list = self.make_peer_widgets() + + self.ilb = IndicativeListBox( + self.widget_list, + on_selection_change=self.node_list_selection, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus" + ) + + if len(self.peer_list) > 0: + self.display_widget = self.ilb + widget_style = None + self.no_content = False + else: + self.no_content = True + widget_style = "inactive_text" + self.pile = urwid.Pile([ + urwid.Text(("warning_text", g["info"]+"\n"), align=urwid.CENTER), + SelectText(("warning_text", "Currently, no LXMF nodes are peered\n\n"), align=urwid.CENTER), + ]) + self.display_widget = urwid.Filler(self.pile, valign=urwid.TOP, height=urwid.PACK) + + if hasattr(self, "peer_list") and self.peer_list: + pl = len(self.peer_list) + else: + pl = 0 + super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title=f"LXMF Propagation Peers ({pl})"), widget_style)) + + def keypress(self, size, key): + if key == "up" and (self.no_content or self.ilb.first_item_is_selected()): + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + elif key == "ctrl x": + self.delete_selected_entry() + elif key == "ctrl r": + self.sync_selected_entry() + + return super(LXMFPeers, self).keypress(size, key) + + + def node_list_selection(self, arg1, arg2): + pass + + def delete_selected_entry(self): + si = self.ilb.get_selected_item() + if si != None: + destination_hash = si.original_widget.destination_hash + self.app.message_router.unpeer(destination_hash) + self.delegate.reinit_lxmf_peers() + self.delegate.show_peers() + + def sync_selected_entry(self): + sync_grace = 10 + si = self.ilb.get_selected_item() + if si != None: + destination_hash = si.original_widget.destination_hash + if destination_hash in self.app.message_router.peers: + peer = self.app.message_router.peers[destination_hash] + if time.time() > peer.last_sync_attempt+sync_grace: + peer.next_sync_attempt = time.time()-1 + + def job(): + peer.sync() + threading.Thread(target=job, daemon=True).start() + + time.sleep(0.25) + + def dismiss_dialog(sender): + self.close_list_dialogs() + + dialog = ListDialogLineBox( + urwid.Pile([ + urwid.Text("A delivery sync of all unhandled LXMs was manually requested for the selected node\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("OK", on_press=dismiss_dialog)), + ]) + ]), + title="!", + + ) + dialog.delegate = self.delegate + bottom = self + + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + + options = self.delegate.left_pile.options(urwid.WEIGHT, 1) + self.delegate.left_pile.contents[0] = (overlay, options) + + + def close_list_dialogs(self): + self.delegate.reinit_lxmf_peers() + self.delegate.show_peers() + + def rebuild_widget_list(self): + self.peer_list = self.app.message_router.peers + self.widget_list = self.make_peer_widgets() + self.ilb.set_body(self.widget_list) + if len(self.widget_list) > 0: + self.no_content = False + else: + self.no_content = True + self.delegate.reinit_lxmf_peers() + + def make_peer_widgets(self): + widget_list = [] + sorted_peers = sorted(self.peer_list, key=lambda pid: (self.app.directory.pn_trust_level(pid), self.peer_list[pid].sync_transfer_rate), reverse=True) + for peer_id in sorted_peers: + peer = self.peer_list[peer_id] + trust_level = self.app.directory.pn_trust_level(peer_id) + pe = LXMFPeerEntry(self.app, peer, self, trust_level) + pe.destination_hash = peer.destination_hash + widget_list.append(pe) + + return widget_list + +class LXMFPeerEntry(urwid.WidgetWrap): + def __init__(self, app, peer, delegate, trust_level): + destination_hash = peer.destination_hash + + self.app = app + g = self.app.ui.glyphs + + node_identity = RNS.Identity.recall(destination_hash) + display_str = RNS.prettyhexrep(destination_hash) + if node_identity != None: + node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", node_identity) + display_name = self.app.directory.alleged_display_str(node_hash) + if display_name != None: + display_str = str(display_name)+"\n "+display_str + + sym = g["sent"] + style = "list_unknown" + focus_style = "list_focus" + + alive_string = "Unknown" + if hasattr(peer, "alive"): + if peer.alive: + alive_string = "Available" + if trust_level == DirectoryEntry.TRUSTED: + style = "list_trusted" + focus_style = "list_focus_trusted" + else: + style = "list_normal" + focus_style = "list_focus" + else: + alive_string = "Unresponsive" + style = "list_unresponsive" + focus_style = "list_focus_unresponsive" + + if peer.propagation_transfer_limit: txfer_limit = RNS.prettysize(peer.propagation_transfer_limit*1000) + else: txfer_limit = "No" + + if peer.propagation_sync_limit: sync_limit = RNS.prettysize(peer.propagation_sync_limit*1000) + else: sync_limit = "Unknown" + + if peer.propagation_stamp_cost: sct = peer.propagation_stamp_cost + else: sct = "Unknown" + + if peer.propagation_stamp_cost_flexibility: scf = f" (flex {peer.propagation_stamp_cost_flexibility})" + else: scf = "" + + ar = round(peer.acceptance_rate*100, 2) + peer_info_str = sym+" "+display_str+"\n "+alive_string+", last heard "+pretty_date(int(peer.last_heard)) + peer_info_str += f"\n {sync_limit} sync limit, {txfer_limit} msg limit" + peer_info_str += f"\n {RNS.prettyspeed(peer.sync_transfer_rate)} STR, {RNS.prettyspeed(peer.link_establishment_rate)} LER" + peer_info_str += f"\n Propagation cost {sct}{scf}" + peer_info_str += "\n "+str(peer.unhandled_message_count)+f" unhandled LXMs, {ar}% AR" + widget = ListEntry(peer_info_str) + self.display_widget = urwid.AttrMap(widget, style, focus_style) + self.display_widget.destination_hash = destination_hash + super().__init__(self.display_widget) + + +def pretty_date(time=False): + """ + Get a datetime object or a int() Epoch timestamp and return a + pretty string like 'an hour ago', 'Yesterday', '3 months ago', + 'just now', etc + """ + from datetime import datetime + now = datetime.now() + if type(time) is int: + diff = now - datetime.fromtimestamp(time) + elif isinstance(time,datetime): + diff = now - time + elif not time: + diff = now - now + second_diff = diff.seconds + day_diff = diff.days + + if day_diff < 0: + return '' + + if day_diff == 0: + if second_diff < 10: + return "just now" + if second_diff < 60: + return str(second_diff) + " seconds ago" + if second_diff < 120: + return "a minute ago" + if second_diff < 3600: + return str(int(second_diff / 60)) + " minutes ago" + if second_diff < 7200: + return "an hour ago" + if second_diff < 86400: + return str(int(second_diff / 3600)) + " hours ago" + if day_diff == 1: + return "Yesterday" + if day_diff < 7: + return str(day_diff) + " days ago" + if day_diff < 31: + return str(int(day_diff / 7)) + " weeks ago" + if day_diff < 365: + return str(int(day_diff / 30)) + " months ago" + return str(int(day_diff / 365)) + " years ago" \ No newline at end of file diff --git a/nomadnet/ui/textui/ReadlineEdit.py b/nomadnet/ui/textui/ReadlineEdit.py new file mode 100644 index 0000000..85b8dd9 --- /dev/null +++ b/nomadnet/ui/textui/ReadlineEdit.py @@ -0,0 +1,150 @@ +import urwid + + +_KILL_KEYS = frozenset(("ctrl u", "ctrl k", "ctrl w", "ctrl l")) + + +class _KillRing: + """Module-global kill buffer shared across all ReadlineMixin widgets. + + Mirrors GNU readline: consecutive kills accumulate into the same entry + (forward kills append, backward kills prepend); any non-kill keypress + breaks the chain so the next kill replaces the buffer. + """ + text = "" + last_was_kill = False + + @classmethod + def reset_chain(cls): + cls.last_was_kill = False + + @classmethod + def kill(cls, killed, direction): + if not killed: + return + if cls.last_was_kill: + cls.text = cls.text + killed if direction == "forward" else killed + cls.text + else: + cls.text = killed + cls.last_was_kill = True + + +class ReadlineMixin: + """Mixin adding readline-style editing keys to an urwid.Edit-derived widget. + + Bindings (GNU readline defaults, plus ctrl-l as kill-whole-buffer): + ctrl-a beginning-of-line + ctrl-e end-of-line + ctrl-u unix-line-discard (kill from cursor to beginning of line) + ctrl-k kill-line (kill from cursor to end of line) + ctrl-w unix-word-rubout (kill previous whitespace-delimited word) + ctrl-l kill-whole-buffer (kill the entire edit buffer) + ctrl-y yank (insert most-recently-killed text) + ctrl-left backward-word (alphanumeric boundary) + ctrl-right forward-word (alphanumeric boundary) + + "Line" is the current logical line within the edit buffer, delimited by + newlines -- so on multiline Edits these act on the line under the cursor, + not the entire buffer. + + The kill buffer is shared across all widgets using this mixin, so text + killed in one Edit can be yanked into another. + """ + + def keypress(self, size, key): + if key == "ctrl a": self._rl_beg_of_line() + elif key == "ctrl e": self._rl_end_of_line() + elif key == "ctrl u": self._rl_kill_to_beg() + elif key == "ctrl k": self._rl_kill_to_end() + elif key == "ctrl w": self._rl_kill_word_back() + elif key == "ctrl l": self._rl_kill_whole_buffer() + elif key == "ctrl y": self._rl_yank() + elif key == "ctrl left": self._rl_backward_word() + elif key == "ctrl right": self._rl_forward_word() + else: + result = super().keypress(size, key) + if key not in _KILL_KEYS: + _KillRing.reset_chain() + return result + if key not in _KILL_KEYS: + _KillRing.reset_chain() + return None + + def _rl_line_bounds(self): + text, pos = self.edit_text, self.edit_pos + bol = text.rfind("\n", 0, pos) + bol = 0 if bol == -1 else bol + 1 + eol = text.find("\n", pos) + eol = len(text) if eol == -1 else eol + return bol, eol + + def _rl_delete(self, start, end, kill_direction=None): + if start == end: + return + text = self.edit_text + if kill_direction is not None: + _KillRing.kill(text[start:end], kill_direction) + self.set_edit_text(text[:start] + text[end:]) + self.set_edit_pos(start) + + @staticmethod + def _rl_is_word_char(ch): + return ch.isalnum() or ch == "_" + + def _rl_beg_of_line(self): + bol, _ = self._rl_line_bounds() + self.set_edit_pos(bol) + + def _rl_end_of_line(self): + _, eol = self._rl_line_bounds() + self.set_edit_pos(eol) + + def _rl_kill_to_beg(self): + bol, _ = self._rl_line_bounds() + self._rl_delete(bol, self.edit_pos, kill_direction="backward") + + def _rl_kill_to_end(self): + _, eol = self._rl_line_bounds() + self._rl_delete(self.edit_pos, eol, kill_direction="forward") + + def _rl_kill_word_back(self): + text, pos = self.edit_text, self.edit_pos + p = pos + while p > 0 and text[p - 1].isspace(): + p -= 1 + while p > 0 and not text[p - 1].isspace(): + p -= 1 + self._rl_delete(p, pos, kill_direction="backward") + + def _rl_kill_whole_buffer(self): + self._rl_delete(0, len(self.edit_text), kill_direction="forward") + + def _rl_yank(self): + if not _KillRing.text: + return + pos = self.edit_pos + text = self.edit_text + self.set_edit_text(text[:pos] + _KillRing.text + text[pos:]) + self.set_edit_pos(pos + len(_KillRing.text)) + + def _rl_backward_word(self): + text, pos = self.edit_text, self.edit_pos + while pos > 0 and not self._rl_is_word_char(text[pos - 1]): + pos -= 1 + while pos > 0 and self._rl_is_word_char(text[pos - 1]): + pos -= 1 + self.set_edit_pos(pos) + + def _rl_forward_word(self): + text, pos = self.edit_text, self.edit_pos + n = len(text) + while pos < n and not self._rl_is_word_char(text[pos]): + pos += 1 + while pos < n and self._rl_is_word_char(text[pos]): + pos += 1 + self.set_edit_pos(pos) + + +class ReadlineEdit(ReadlineMixin, urwid.Edit): + """Drop-in urwid.Edit replacement with readline-style editing keys.""" + pass diff --git a/nomadnet/ui/textui/__init__.py b/nomadnet/ui/textui/__init__.py new file mode 100644 index 0000000..72302cb --- /dev/null +++ b/nomadnet/ui/textui/__init__.py @@ -0,0 +1,7 @@ +import os +import glob + +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) diff --git a/nomadnet/util.py b/nomadnet/util.py new file mode 100644 index 0000000..51aa440 --- /dev/null +++ b/nomadnet/util.py @@ -0,0 +1,188 @@ +import re +import unicodedata + +invalid_rendering = ["🕵️", "☝"] + +# Unicode blocks to strip (symbols, dingbats, emoji ranges) +STRIP_BLOCKS_RE = re.compile( + '[' + '\U0001F600-\U0001F64F' # Emoticons + '\U0001F300-\U0001F5FF' # Misc Symbols & Pictographs + '\U0001F680-\U0001F6FF' # Transport & Map Symbols + '\U0001F700-\U0001F77F' # Alchemical Symbols + '\U0001F780-\U0001F7FF' # Geometric Shapes Extended + '\U0001F800-\U0001F8FF' # Supplemental Arrows-C + '\U0001F900-\U0001F9FF' # Supplemental Symbols & Pictographs + '\U0001FA00-\U0001FA6F' # Chess Symbols + '\U0001FA70-\U0001FAFF' # Symbols & Pictographs Extended-A + '\U0001F1E0-\U0001F1FF' # Flags (iOS/regional indicators) + '\u2600-\u26FF' # Misc Symbols (☀, ☁, ☂, etc.) + '\u2700-\u27BF' # Dingbats (✂, ✈, ✉, ✌, etc.) + '\uFE00-\uFE0F' # Variation Selectors + '\U000E0100-\U000E01EF' # Variation Selectors Supplement + '\U0001F3FB-\U0001F3FF' # Emoji modifiers (skin tones) + ']+', + flags=re.UNICODE +) + +# Control characters and zero-width characters to strip +STRIP_CONTROL_RE = re.compile( + '[' + '\x00-\x08' # C0 controls (NUL-BS) + '\x0B\x0C' # VT, FF + '\x0E-\x1F' # C0 controls (SO-US) + '\x7F-\x9F' # DEL and C1 controls + '\u200B-\u200F' # Zero-width chars, LRM, RLM, etc. + '\u202A-\u202E' # Bidi embedding controls + '\u2060-\u206F' # Format chars (word joiner, etc.) + '\uFEFF' # BOM / Zero Width NBSP + '\uFFF0-\uFFF8' # Specials + ']+', + flags=re.UNICODE +) + +# Surrogates and private use areas +# Shouldn't appear in valid UTF-8, but strip just in case +STRIP_PRIVATE_RE = re.compile( + '[' + '\uD800-\uDFFF' # Surrogates + '\uE000-\uF8FF' # Private Use Area + '\uF900-\uFAFF' # CJK Compatibility Ideographs (keep? strip for safety) + '\uFE10-\uFE1F' # Vertical Forms + '\uFE20-\uFE2F' # Combining Half Marks + '\U000F0000-\U000FFFFF' # Supplementary Private Use Area-A + '\U00100000-\U0010FFFF' # Supplementary Private Use Area-B + ']+', + flags=re.UNICODE +) + +def strip_modifiers(text): + def process_characters(text): + result = [] + i = 0 + while i < len(text): + char = text[i] + category = unicodedata.category(char) + + if category.startswith(('L', 'N', 'P', 'S')): + result.append(char) + i += 1 + + elif category.startswith(('M', 'Sk', 'Cf')) or char in '\u200d\u200c': + i += 1 + + else: + result.append(char) + i += 1 + + return ''.join(result) + + if text == None: return None + + for char in invalid_rendering: + text = text.replace(char, " ") + + stripped = process_characters(text) + stripped = re.sub(r'[\uFE00-\uFE0F]', '', stripped) + stripped = re.sub(r'[\U000E0100-\U000E01EF]', '', stripped, flags=re.UNICODE) + stripped = re.sub(r'[\U0001F3FB-\U0001F3FF]', '', stripped, flags=re.UNICODE) + stripped = re.sub(r'[\u200D\u200C]', '', stripped) + stripped = re.sub(r'\r\n?', '\n', stripped) + + return stripped.strip().replace("\x00", "") + +def sanitize_name(name): + if name is None: return None + + # Convert to string and normalize to NFKC + # NFKC: Compatibility decomposition followed by canonical composition + # This handles: ① to 1, Ⅰ to I, etc., while keeping composed forms + name = str(name) + name = unicodedata.normalize('NFKC', name) + + # Build result using category-based filtering + result = [] + for char in name: + cat = unicodedata.category(char) + cat_prefix = cat[0] if cat else 'C' + + # Allow letters (L*), numbers (N*), and punctuation (P*) + if cat_prefix in ('L', 'N', 'P'): result.append(char) + + # Allow space separator, normalize to regular space + elif cat == 'Zs': result.append(' ') + + # Convert line/paragraph separators to space + elif cat in ('Zl', 'Zp'): result.append(' ') + + # Allow spacing combining marks (Mc) for Indic, Hebrew, etc. + elif cat == 'Mc': result.append(char) + + # Allow modifier letters (Lm) - e.g., ʰ, ʱ, ː + elif cat == 'Lm': result.append(char) + + # Strip everything else: + # - Mn (Nonspacing Mark): diacritics, combining marks (Zalgo) + # - Me (Enclosing Mark): enclosing combining marks + # - C* (Controls, Format, Surrogates, Private Use, Unassigned) + # - S* (Symbols: currency, math, modifiers, other) + + name = ''.join(result) + + # Additional block-based stripping for symbols that categories missed + name = STRIP_BLOCKS_RE.sub('', name) + name = STRIP_CONTROL_RE.sub('', name) + name = STRIP_PRIVATE_RE.sub('', name) + + # Collapse multiple whitespace characters + name = re.sub(r'\s+', ' ', name) + + # Strip leading/trailing whitespace + name = name.strip() + + return name + +def strip_micron(text): + text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text) + text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text) + text = re.sub(r'`[!*_=]', '', text) + text = re.sub(r'`f`b', '', text) + text = re.sub(r'`f', '', text) + text = re.sub(r'`b', '', text) + text = re.sub(r'`<', '', text) + text = re.sub(r'`>', '', text) + text = re.sub(r'`{', '', text) + return text + +def strip_escaped_micron(text): + text = re.sub(r'¦[FB][0-9a-fA-F]{3}', '', text) + text = re.sub(r'¦[FB]T[0-9a-fA-F]{6}', '', text) + text = re.sub(r'¦[!*_=]', '', text) + text = re.sub(r'¦f`b', '', text) + text = re.sub(r'¦f', '', text) + text = re.sub(r'¦b', '', text) + text = re.sub(r'¦<', '', text) + text = re.sub(r'¦>', '', text) + text = re.sub(r'¦{', '', text) + return text + +def unescape_micron(text): + text = re.sub(r'¦([FB][0-9a-fA-F]{3})', r'`\1', text) + text = re.sub(r'¦([FB]T[0-9a-fA-F]{6})', r'`\1', text) + text = re.sub(r'¦([!*_=])', r'`\1', text) + text = re.sub(r'¦(f`b)', r'`\1', text) + text = re.sub(r'¦(f)', r'`\1', text) + text = re.sub(r'¦(b)', r'`\1', text) + text = re.sub(r'¦(<)', r'`\1', text) + text = re.sub(r'¦(>)', r'`\1', text) + text = re.sub(r'¦({)', r'`\1', text) + return text + +def strip_non_formatting_tags(text): + text = re.sub(r'`<', '', text) + text = re.sub(r'`>', '', text) + text = re.sub(r'`{', '', text) + text = re.sub(r'`r', '', text) + text = re.sub(r'`c', '', text) + text = re.sub(r'`l', '', text) + return text \ No newline at end of file diff --git a/nomadnet/vendor/AsciiChart.py b/nomadnet/vendor/AsciiChart.py new file mode 100644 index 0000000..cd24d56 --- /dev/null +++ b/nomadnet/vendor/AsciiChart.py @@ -0,0 +1,91 @@ +from __future__ import division +from math import ceil, floor, isnan +# Derived from asciichartpy | https://github.com/kroitor/asciichart/blob/master/asciichartpy/__init__.py +class AsciiChart: + def __init__(self, glyphset="unicode"): + self.symbols = ['┼', '┤', '╶', '╴', '─', '╰', '╭', '╮', '╯', '│'] + if glyphset == "plain": + self.symbols = ['+', '|', '-', '-', '-', '\'', ',', '.', '`', '|'] + def plot(self, series, cfg=None): + if len(series) == 0: + return '' + if not isinstance(series[0], list): + if all(isnan(n) for n in series): + return '' + else: + series = [series] + cfg = cfg or {} + minimum = cfg.get('min', min(filter(lambda n: not isnan(n), [j for i in series for j in i]))) + maximum = cfg.get('max', max(filter(lambda n: not isnan(n), [j for i in series for j in i]))) + symbols = cfg.get('symbols', self.symbols) + if minimum > maximum: + raise ValueError('The min value cannot exceed the max value.') + interval = maximum - minimum + offset = cfg.get('offset', 3) + height = cfg.get('height', interval) + ratio = height / interval if interval > 0 else 1 + + min2 = int(floor(minimum * ratio)) + max2 = int(ceil(maximum * ratio)) + + def clamp(n): + return min(max(n, minimum), maximum) + + def scaled(y): + return int(round(clamp(y) * ratio) - min2) + + rows = max2 - min2 + + width = 0 + for i in range(0, len(series)): + width = max(width, len(series[i])) + width += offset + + placeholder = cfg.get('format', '{:8.2f} ') + + result = [[' '] * width for i in range(rows + 1)] + + for y in range(min2, max2 + 1): + if callable(placeholder): + label = placeholder(maximum - ((y - min2) * interval / (rows if rows else 1))).rjust(12) + else: + label = placeholder.format(maximum - ((y - min2) * interval / (rows if rows else 1))) + + result[y - min2][max(offset - len(label), 0)] = label + result[y - min2][offset - 1] = symbols[0] if y == 0 else symbols[1] + + d0 = series[0][0] + if not isnan(d0): + result[rows - scaled(d0)][offset - 1] = symbols[0] + + for i in range(0, len(series)): + for x in range(0, len(series[i]) - 1): + d0 = series[i][x + 0] + d1 = series[i][x + 1] + + if isnan(d0) and isnan(d1): + continue + + if isnan(d0) and not isnan(d1): + result[rows - scaled(d1)][x + offset] = symbols[2] + continue + + if not isnan(d0) and isnan(d1): + result[rows - scaled(d0)][x + offset] = symbols[3] + continue + + y0 = scaled(d0) + y1 = scaled(d1) + if y0 == y1: + result[rows - y0][x + offset] = symbols[4] + continue + + result[rows - y1][x + offset] = symbols[5] if y0 > y1 else symbols[6] + result[rows - y0][x + offset] = symbols[7] if y0 > y1 else symbols[8] + + start = min(y0, y1) + 1 + end = max(y0, y1) + for y in range(start, end): + result[rows - y][x + offset] = symbols[9] + + return '\n'.join([''.join(row).rstrip() for row in result]) \ No newline at end of file diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py new file mode 100644 index 0000000..dc130d4 --- /dev/null +++ b/nomadnet/vendor/Scrollable.py @@ -0,0 +1,562 @@ +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details +# http://www.gnu.org/licenses/gpl-3.0.txt + +import time +import urwid +from urwid.widget import BOX, FIXED, FLOW +from collections import deque + +# Scroll actions +SCROLL_LINE_UP = 'line up' +SCROLL_LINE_DOWN = 'line down' +SCROLL_PAGE_UP = 'page up' +SCROLL_PAGE_DOWN = 'page down' +SCROLL_TO_TOP = 'to top' +SCROLL_TO_END = 'to end' + +# Scrollbar positions +SCROLLBAR_LEFT = 'left' +SCROLLBAR_RIGHT = 'right' + +class Scrollable(urwid.WidgetDecoration): + def sizing(self): + return frozenset([BOX,]) + + def selectable(self): + return True + + def __init__(self, widget, force_forward_keypress = False): + """Box widget that makes a fixed or flow widget vertically scrollable + + TODO: Focusable widgets are handled, including switching focus, but + possibly not intuitively, depending on the arrangement of widgets. When + switching focus to a widget that is ouside of the visible part of the + original widget, the canvas scrolls up/down to the focused widget. It + would be better to scroll until the next focusable widget is in sight + first. But for that to work we must somehow obtain a list of focusable + rows in the original canvas. + """ + if not any(s in widget.sizing() for s in (FIXED, FLOW)): + raise ValueError('Not a fixed or flow widget: %r' % widget) + self._trim_top = 0 + self._scroll_action = None + self._forward_keypress = None + self._old_cursor_coords = None + self._rows_max_cached = 0 + self.force_forward_keypress = force_forward_keypress + self.force_cursor_update = False + self.anchor_cursor_update = False + self.cursor_update_forced_at = 0 + super().__init__(widget) + + def render(self, size, focus=False): + maxcol, maxrow = size + + # Render complete original widget + ow = self._original_widget + ow_size = self._get_original_widget_size(size) + canv_full = ow.render(ow_size, focus) + + # Make full canvas editable + canv = urwid.CompositeCanvas(canv_full) + canv_cols, canv_rows = canv.cols(), canv.rows() + + if canv_cols <= maxcol: + pad_width = maxcol - canv_cols + if pad_width > 0: + # Canvas is narrower than available horizontal space + canv.pad_trim_left_right(0, pad_width) + + if canv_rows <= maxrow: + fill_height = maxrow - canv_rows + if fill_height > 0: + # Canvas is lower than available vertical space + canv.pad_trim_top_bottom(0, fill_height) + + if canv_cols <= maxcol and canv_rows <= maxrow: + # Canvas is small enough to fit without trimming + return canv + + self._adjust_trim_top(canv, size) + + # Trim canvas if necessary + trim_top = self._trim_top + trim_end = canv_rows - maxrow - trim_top + trim_right = canv_cols - maxcol + if trim_top > 0: + canv.trim(trim_top) + if trim_end > 0: + canv.trim_end(trim_end) + if trim_right > 0: + canv.pad_trim_left_right(0, -trim_right) + + # Disable cursor display if cursor is outside of visible canvas parts + if canv.cursor is not None: + curscol, cursrow = canv.cursor + if cursrow >= maxrow or cursrow < 0: + canv.cursor = None + + # Figure out whether we should forward keypresses to original widget + if canv.cursor is not None: + # Trimmed canvas contains the cursor, e.g. in an Edit widget + self._forward_keypress = True + else: + force_check = self.force_cursor_update and (time.time()-self.cursor_update_forced_at < 0.25) + force_check |= self.anchor_cursor_update + if canv_full.cursor is not None or force_check: + self.force_cursor_update = False + self.anchor_cursor_update = False + # Full canvas contains the cursor, but scrolled out of view + self._forward_keypress = False + + # Reset cursor position on page/up down scrolling + try: + if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll: + pwi = 0 + ch = 0 + last_hidden = False + first_visible = False + for w,o in ow.contents: + wcanv = w.render((maxcol,)) + wh = wcanv.rows() + if wh: + ch += wh + + if not last_hidden and ch >= self._trim_top: + last_hidden = True + + elif last_hidden: + if not first_visible: + first_visible = True + + if w.selectable(): + ow.focus_item = pwi + + st = None + nf = ow.get_focus() + if hasattr(nf, "key_timeout"): + st = nf + elif hasattr(nf, "original_widget"): + no = nf.original_widget + if hasattr(no, "original_widget"): + st = no.original_widget + else: + if hasattr(no, "key_timeout"): + st = no + + if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): + st.keypress(None, None) + + break + + pwi += 1 + except Exception as e: + pass + + else: + # Original widget does not have a cursor, but may be selectable + + # FIXME: Using ow.selectable() is bad because the original + # widget may be selectable because it's a container widget with + # a key-grabbing widget that is scrolled out of view. + # ow.selectable() returns True anyway because it doesn't know + # how we trimmed our canvas. + # + # To fix this, we need to resolve ow.focus and somehow + # ask canv whether it contains bits of the focused widget. I + # can't see a way to do that. + if ow.selectable(): + self._forward_keypress = True + else: + self._forward_keypress = False + + return canv + + def keypress(self, size, key): + # Maybe offer key to original widget + if self._forward_keypress or self.force_forward_keypress: + ow = self._original_widget + ow_size = self._get_original_widget_size(size) + + # Remember previous cursor position if possible + if hasattr(ow, 'get_cursor_coords'): + self._old_cursor_coords = ow.get_cursor_coords(ow_size) + + key = ow.keypress(ow_size, key) + if key is None: + return None + + # Handle up/down, page up/down, etc + command_map = self._command_map + if command_map[key] == urwid.CURSOR_UP: + self._scroll_action = SCROLL_LINE_UP + elif command_map[key] == urwid.CURSOR_DOWN: + self._scroll_action = SCROLL_LINE_DOWN + + elif command_map[key] == urwid.CURSOR_PAGE_UP: + self._scroll_action = SCROLL_PAGE_UP + elif command_map[key] == urwid.CURSOR_PAGE_DOWN: + self._scroll_action = SCROLL_PAGE_DOWN + + elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home' + self._scroll_action = SCROLL_TO_TOP + elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end' + self._scroll_action = SCROLL_TO_END + + else: + return key + + self._invalidate() + + def mouse_event(self, size, event, button, col, row, focus): + ow = self._original_widget + if hasattr(ow, 'mouse_event'): + ow_size = self._get_original_widget_size(size) + row += self._trim_top + return ow.mouse_event(ow_size, event, button, col, row, focus) + else: + return False + + def _adjust_trim_top(self, canv, size): + """Adjust self._trim_top according to self._scroll_action""" + action = self._scroll_action + self._scroll_action = None + + maxcol, maxrow = size + trim_top = self._trim_top + canv_rows = canv.rows() + + if trim_top < 0: + # Negative trim_top values use bottom of canvas as reference + trim_top = canv_rows - maxrow + trim_top + 1 + + if canv_rows <= maxrow: + self._trim_top = 0 # Reset scroll position + return + + def ensure_bounds(new_trim_top): + return max(0, min(canv_rows - maxrow, new_trim_top)) + + if action == SCROLL_LINE_UP: + self._trim_top = ensure_bounds(trim_top - 1) + elif action == SCROLL_LINE_DOWN: + self._trim_top = ensure_bounds(trim_top + 1) + + elif action == SCROLL_PAGE_UP: + self._trim_top = ensure_bounds(trim_top - maxrow + 1) + elif action == SCROLL_PAGE_DOWN: + self._trim_top = ensure_bounds(trim_top + maxrow - 1) + + elif action == SCROLL_TO_TOP: + self._trim_top = 0 + elif action == SCROLL_TO_END: + self._trim_top = canv_rows - maxrow + + else: + self._trim_top = ensure_bounds(trim_top) + + # If the cursor was moved by the most recent keypress, adjust trim_top + # so that the new cursor position is within the displayed canvas part. + # But don't do this if the cursor is at the top/bottom edge so we can still scroll out + if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor != None: + self._old_cursor_coords = None + curscol, cursrow = canv.cursor + if cursrow < self._trim_top: + self._trim_top = cursrow + elif cursrow >= self._trim_top + maxrow: + self._trim_top = max(0, cursrow - maxrow + 1) + + def _get_original_widget_size(self, size): + ow = self._original_widget + sizing = ow.sizing() + if FLOW in sizing: + return (size[0],) + elif FIXED in sizing: + return () + + def get_scrollpos(self, size=None, focus=False): + """Current scrolling position + + Lower limit is 0, upper limit is the maximum number of rows with the + given maxcol minus maxrow. + + NOTE: The returned value may be too low or too high if the position has + changed but the widget wasn't rendered yet. + """ + return self._trim_top + + def set_scrollpos(self, position): + """Set scrolling position + + If `position` is positive it is interpreted as lines from the top. + If `position` is negative it is interpreted as lines from the bottom. + + Values that are too high or too low values are automatically adjusted + during rendering. + """ + self._trim_top = int(position) + self._invalidate() + + def rows_max(self, size=None, focus=False): + """Return the number of rows for `size` + + If `size` is not given, the currently rendered number of rows is returned. + """ + if size is not None: + ow = self._original_widget + ow_size = self._get_original_widget_size(size) + sizing = ow.sizing() + if FIXED in sizing: + self._rows_max_cached = ow.pack(ow_size, focus)[1] + elif FLOW in sizing: + self._rows_max_cached = ow.rows(ow_size, focus) + else: + raise RuntimeError('Not a flow/box widget: %r' % self._original_widget) + return self._rows_max_cached + + +class ScrollBar(urwid.WidgetDecoration): + def sizing(self): + return frozenset((BOX,)) + + def selectable(self): + return True + + def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ', + side=SCROLLBAR_RIGHT, width=1): + """Box widget that adds a scrollbar to `widget` + + `widget` must be a box widget with the following methods: + - `get_scrollpos` takes the arguments `size` and `focus` and returns + the index of the first visible row. + - `set_scrollpos` (optional; needed for mouse click support) takes the + index of the first visible row. + - `rows_max` takes `size` and `focus` and returns the total number of + rows `widget` can render. + + `thumb_char` is the character used for the scrollbar handle. + `trough_char` is used for the space above and below the handle. + `side` must be 'left' or 'right'. + `width` specifies the number of columns the scrollbar uses. + """ + if BOX not in widget.sizing(): + raise ValueError('Not a box widget: %r' % widget) + super().__init__(widget) + self._thumb_char = thumb_char + self._trough_char = trough_char + self.scrollbar_side = side + self.scrollbar_width = max(1, width) + self._original_widget_size = (0, 0) + self._sb_visible = False + self._sb_col_start = 0 + self._sb_col_end = 0 + self._sb_thumb_height = 1 + self._sb_ow_rows_max = 0 + self._sb_maxrow = 0 + self._sb_dragging = False + self._sb_drag_offset = 0 + self._accel = 1 + self._sevt_interval = 0 + self._sevt_start = 0 + self._sfreq_deque = deque(maxlen=5) + + def render(self, size, focus=False): + maxcol, maxrow = size + + sb_width = self._scrollbar_width + ow_size = (max(0, maxcol - sb_width), maxrow) + sb_width = maxcol - ow_size[0] + + ow = self._original_widget + ow_base = self.scrolling_base_widget + ow_rows_max = ow_base.rows_max(size, focus) + if ow_rows_max <= maxrow: + # Canvas fits without scrolling - no scrollbar needed + self._original_widget_size = size + self._sb_visible = False + return ow.render(size, focus) + ow_rows_max = ow_base.rows_max(ow_size, focus) + + ow_canv = ow.render(ow_size, focus) + self._original_widget_size = ow_size + + pos = ow_base.get_scrollpos(ow_size, focus) + posmax = ow_rows_max - maxrow + + # Thumb shrinks/grows according to the ratio of + # / + thumb_weight = min(1, maxrow / max(1, ow_rows_max)) + thumb_height = max(1, round(thumb_weight * maxrow)) + + # Thumb may only touch top/bottom if the first/last row is visible + top_weight = float(pos) / max(1, posmax) + top_height = int((maxrow - thumb_height) * top_weight) + if top_height == 0 and top_weight > 0: + top_height = 1 + + # Bottom part is remaining space + bottom_height = maxrow - thumb_height - top_height + assert thumb_height + top_height + bottom_height == maxrow + + # Create scrollbar canvas + # Creating SolidCanvases of correct height may result in "cviews do not + # fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" + # exceptions. Stacking the same SolidCanvas is a workaround. + # https://github.com/urwid/urwid/issues/226#issuecomment-437176837 + top = urwid.SolidCanvas(self._trough_char, sb_width, 1) + thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1) + bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1) + sb_canv = urwid.CanvasCombine( + [(top, None, False)] * top_height + + [(thumb, None, False)] * thumb_height + + [(bottom, None, False)] * bottom_height, + ) + + combinelist = [(ow_canv, None, True, ow_size[0]), + (sb_canv, None, False, sb_width)] + + + + + + self._sb_visible = True + self._sb_thumb_height = thumb_height + self._sb_ow_rows_max = ow_rows_max + self._sb_maxrow = maxrow + if self._scrollbar_side != SCROLLBAR_LEFT: + self._sb_col_start = ow_size[0] + self._sb_col_end = ow_size[0] + sb_width + return urwid.CanvasJoin(combinelist) + else: + self._sb_col_start = 0 + self._sb_col_end = sb_width + return urwid.CanvasJoin(reversed(combinelist)) + + @property + def scrollbar_width(self): + """Columns the scrollbar uses""" + return max(1, self._scrollbar_width) + + @scrollbar_width.setter + def scrollbar_width(self, width): + self._scrollbar_width = max(1, int(width)) + self._invalidate() + + @property + def scrollbar_side(self): + """Where to display the scrollbar; must be 'left' or 'right'""" + return self._scrollbar_side + + @scrollbar_side.setter + def scrollbar_side(self, side): + if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT): + raise ValueError('scrollbar_side must be "left" or "right", not %r' % side) + self._scrollbar_side = side + self._invalidate() + + @property + def scrolling_base_widget(self): + """Nearest `original_widget` that is compatible with the scrolling API""" + def orig_iter(w): + while hasattr(w, 'original_widget'): + w = w.original_widget + yield w + yield w + + def is_scrolling_widget(w): + return hasattr(w, 'get_scrollpos') and hasattr(w, 'rows_max') + + for w in orig_iter(self): + if is_scrolling_widget(w): + return w + raise ValueError('Not compatible to be wrapped by ScrollBar: %r' % w) + + def keypress(self, size, key): + return self._original_widget.keypress(self._original_widget_size, key) + + def mouse_event(self, size, event, button, col, row, focus): + ow = self._original_widget + ow_size = self._original_widget_size + ow_base = self.scrolling_base_widget if hasattr(self, 'scrolling_base_widget') else ow + on_sb = (self._sb_visible + and self._sb_col_start <= col < self._sb_col_end) + + if self._sb_dragging: + if 'drag' in event: + self._set_scrollpos_from_row(row) + return True + if 'release' in event or 'press' in event: + self._sb_dragging = False + if 'release' in event: + return True + + # Begin a click/drag + if on_sb and 'press' in event and button == 1 and hasattr(ow_base, 'set_scrollpos'): + pos = ow_base.get_scrollpos(ow_size) + posmax = max(1, self._sb_ow_rows_max - self._sb_maxrow) + track_len = max(1, self._sb_maxrow - self._sb_thumb_height) + top_height = int(round(track_len * pos / posmax)) + if top_height <= row < top_height + self._sb_thumb_height: + self._sb_drag_offset = row - top_height + else: + # jump + self._sb_drag_offset = self._sb_thumb_height // 2 + self._set_scrollpos_from_row(row) + self._sb_dragging = True + return True + + handled = False + if hasattr(ow, 'mouse_event'): + handled = ow.mouse_event(ow_size, event, button, col, row, focus) + + if not handled and hasattr(ow, 'set_scrollpos'): + ow.force_cursor_update = True + ow.cursor_update_forced_at = time.time() + self._sevt_interval = time.time()-self._sevt_start + if self._sevt_interval > 0.3: self._sevt_start = 0; self._accel = 1; self._sfreq_deque.clear() + if self._sevt_start == 0: self._sevt_start = time.time() + if self._sevt_interval > 0: + sevt_freq = 1/self._sevt_interval + self._sfreq_deque.append(sevt_freq) + freq_smth = sum(self._sfreq_deque)/len(self._sfreq_deque) + if len(self._sfreq_deque) >= self._sfreq_deque.maxlen: + if freq_smth > 25.0: self._accel = max(self._accel, 3) + elif freq_smth > 12.0: self._accel = max(self._accel, 2) + else: self._accel = max(self._accel, 1) + + # import RNS; RNS.log(f"a={self._accel} s={RNS.prettyfrequency(freq_smth)} f={RNS.prettyfrequency(sevt_freq)}", RNS.LOG_CRITICAL) + + step = self._accel + if button == 4: # scroll wheel up + pos = ow.get_scrollpos(ow_size) + newpos = pos - step + if newpos < 0: newpos = 0 + ow.set_scrollpos(newpos) + return True + elif button == 5: # scroll wheel down + pos = ow.get_scrollpos(ow_size) + ow.set_scrollpos(pos + step) + return True + + return False + + def _set_scrollpos_from_row(self, row): + ow_base = self.scrolling_base_widget + if not hasattr(ow_base, 'set_scrollpos'): + return + posmax = self._sb_ow_rows_max - self._sb_maxrow + if posmax <= 0: + return + track_len = max(1, self._sb_maxrow - self._sb_thumb_height) + top = max(0, min(track_len, row - self._sb_drag_offset)) + new_pos = int(round(top * posmax / track_len)) + new_pos = max(0, min(posmax, new_pos)) + ow_base.set_scrollpos(new_pos) diff --git a/nomadnet/vendor/__init__.py b/nomadnet/vendor/__init__.py new file mode 100644 index 0000000..72302cb --- /dev/null +++ b/nomadnet/vendor/__init__.py @@ -0,0 +1,7 @@ +import os +import glob + +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py new file mode 100644 index 0000000..38a72df --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -0,0 +1,539 @@ +import urwid + +from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit + +class DialogLineBox(urwid.LineBox): + def __init__(self, body, parent=None, title="?"): + super().__init__(body, title=title) + self.parent = parent + + def keypress(self, size, key): + if key == "esc": + if self.parent and hasattr(self.parent, "dismiss_dialog"): + self.parent.dismiss_dialog() + return None + return super().keypress(size, key) + +class Placeholder(ReadlineMixin, urwid.Edit): + def __init__(self, caption="", edit_text="", placeholder="", **kwargs): + super().__init__(caption, edit_text, **kwargs) + self.placeholder = placeholder + + def render(self, size, focus=False): + if not self.edit_text and not focus: + placeholder_widget = urwid.Text(("placeholder", self.placeholder)) + return placeholder_widget.render(size, focus) + else: + return super().render(size, focus) + +class Dropdown(urwid.WidgetWrap): + signals = ['change'] # emit for urwid.connect_signal fn + + def __init__(self, label, options, default=None): + self.label = label + self.options = options + self.selected = default if default is not None else options[0] + + self.main_text = f"{self.selected}" + self.main_button = urwid.SelectableIcon(self.main_text, 0) + self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus") + + self.option_widgets = [] + for opt in options: + icon = urwid.SelectableIcon(opt, 0) + icon = urwid.AttrMap(icon, "list_normal", "list_focus") + self.option_widgets.append(icon) + + self.options_walker = urwid.SimpleFocusListWalker(self.option_widgets) + self.options_listbox = urwid.ListBox(self.options_walker) + self.dropdown_box = None # will be created on open_dropdown + + self.pile = urwid.Pile([self.main_button]) + self.dropdown_visible = False + + super().__init__(self.pile) + + def open_dropdown(self): + if not self.dropdown_visible: + height = len(self.options) + self.dropdown_box = urwid.BoxAdapter(self.options_listbox, height) + self.pile.contents.append((self.dropdown_box, self.pile.options())) + self.dropdown_visible = True + self.pile.focus_position = 1 + self.options_walker.set_focus(0) + + def close_dropdown(self): + if self.dropdown_visible: + self.pile.contents.pop() # remove the dropdown_box + self.dropdown_visible = False + self.pile.focus_position = 0 + self.dropdown_box = None + + def keypress(self, size, key): + if not self.dropdown_visible: + if key == "enter": + self.open_dropdown() + return None + return self.main_button.keypress(size, key) + else: + if key == "enter": + focus_result = self.options_walker.get_focus() + if focus_result is not None: + focus_widget = focus_result[0] + new_val = focus_widget.base_widget.text + old_val = self.selected + self.selected = new_val + self.main_button.base_widget.set_text(f"{self.selected}") + + if old_val != new_val: + self._emit('change', new_val) + + self.close_dropdown() + return None + return self.dropdown_box.keypress(size, key) + + def get_value(self): + return self.selected + +class ValidationError(urwid.Text): + def __init__(self, message=""): + super().__init__(("error", message)) + +class FormField: + def __init__(self, config_key, transform=None): + self.config_key = config_key + self.transform = transform or (lambda x: x) + +class FormEdit(Placeholder, FormField): + def __init__(self, config_key, caption="", edit_text="", placeholder="", validation_types=None, transform=None, **kwargs): + Placeholder.__init__(self, caption, edit_text, placeholder, **kwargs) + FormField.__init__(self, config_key, transform) + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + def get_value(self): + return self.transform(self.edit_text.strip()) + + def validate(self): + value = self.edit_text.strip() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + elif validation == "number": + if value and not value.replace('-', '').replace('.', '').isdigit(): + self.error = "This field must be a number" + break + elif validation == "float": + try: + if value: + float(value) + except ValueError: + self.error = "This field must be decimal number" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + +class FormCheckbox(urwid.CheckBox, FormField): + def __init__(self, config_key, label="", state=False, validation_types=None, transform=None, **kwargs): + urwid.CheckBox.__init__(self, label, state, **kwargs) + FormField.__init__(self, config_key, transform) + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + def get_value(self): + return self.transform(self.get_state()) + + def validate(self): + + value = self.get_state() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + +class FormDropdown(Dropdown, FormField): + signals = ['change'] + + def __init__(self, config_key, label, options, default=None, validation_types=None, transform=None): + self.options = [str(opt) for opt in options] + + if default is not None: + default_str = str(default) + if default_str in self.options: + default = default_str + elif transform: + try: + default_transformed = transform(default_str) + for opt in self.options: + if transform(opt) == default_transformed: + default = opt + break + except: + default = self.options[0] + else: + default = self.options[0] + else: + default = self.options[0] + + Dropdown.__init__(self, label, self.options, default) + FormField.__init__(self, config_key, transform) + + self.validation_types = validation_types or [] + self.error_widget = urwid.Text("") + self.error = None + + if hasattr(self, 'main_button'): + self.main_button.base_widget.set_text(str(default)) + + def get_value(self): + return self.transform(self.selected) + + def validate(self): + value = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required": + if not value: + self.error = "This field is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + def open_dropdown(self): + if not self.dropdown_visible: + super().open_dropdown() + try: + current_index = self.options.index(self.selected) + self.options_walker.set_focus(current_index) + except ValueError: + pass + +class FormMultiList(urwid.Pile, FormField): + def __init__(self, config_key, placeholder="", validation_types=None, transform=None, **kwargs): + self.entries = [] + self.error_widget = urwid.Text("") + self.error = None + self.placeholder = placeholder + self.validation_types = validation_types or [] + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add Another", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [first_entry, add_button_padded] + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self): + edit = ReadlineEdit("", "") + entry_row = urwid.Columns([ + ('weight', 1, edit), + (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), + ]) + return entry_row + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return self.entries + [urwid.Padding(self.add_button, left=2, right=2)] + + def get_value(self): + values = [] + for entry in self.entries: + edit_widget = entry.contents[0][0] + value = edit_widget.edit_text.strip() + if value: + values.append(value) + return self.transform(values) + + def validate(self): + values = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "At least one entry is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + +class FormMultiTable(urwid.Pile, FormField): + def __init__(self, config_key, fields, validation_types=None, transform=None, **kwargs): + self.entries = [] + self.fields = fields + self.error_widget = urwid.Text("") + self.error = None + self.validation_types = validation_types or [] + + header_columns = [('weight', 3, urwid.Text(("list_focus", "Name")))] + for field_key, field_config in self.fields.items(): + header_columns.append(('weight', 2, urwid.Text(("list_focus", field_config.get("label", field_key))))) + header_columns.append((4, urwid.Text(("list_focus", "")))) + + self.header_row = urwid.Columns(header_columns) + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add ", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [ + self.header_row, + urwid.Divider("-"), + first_entry, + add_button_padded + ] + + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self, name="", values=None): + if values is None: + values = {} + + name_edit = ReadlineEdit("", name) + + columns = [('weight', 3, name_edit)] + + field_widgets = {} + for field_key, field_config in self.fields.items(): + field_value = values.get(field_key, "") + + if field_config.get("type") == "checkbox": + widget = urwid.CheckBox("", state=bool(field_value)) + elif field_config.get("type") == "dropdown": + # TODO: dropdown in MultiTable + widget = ReadlineEdit("", str(field_value)) + else: + widget = ReadlineEdit("", str(field_value)) + + field_widgets[field_key] = widget + columns.append(('weight', 2, widget)) + + remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) + columns.append((4, remove_button)) + + entry_row = urwid.Columns(columns) + entry_row.name_edit = name_edit + entry_row.field_widgets = field_widgets + + return entry_row + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return [ + self.header_row, + urwid.Divider("-") + ] + self.entries + [ + urwid.Padding(self.add_button, left=2, right=2) + ] + + def get_value(self): + values = {} + for entry in self.entries: + name = entry.name_edit.edit_text.strip() + if name: + subinterface = {} + subinterface["interface_enabled"] = True + + for field_key, widget in entry.field_widgets.items(): + field_config = self.fields.get(field_key, {}) + + if hasattr(widget, "get_state"): + value = widget.get_state() + elif hasattr(widget, "edit_text"): + value = widget.edit_text.strip() + + transform = field_config.get("transform") + if transform and value: + try: + value = transform(value) + except (ValueError, TypeError): + value = "" + + if value: + subinterface[field_key] = value + + values[name] = subinterface + + return self.transform(values) if self.transform else values + + def set_value(self, value): + self.entries = [] + + if not value: + self.entries.append(self.create_entry_row()) + else: + for name, config in value.items(): + self.entries.append(self.create_entry_row(name=name, values=config)) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def validate(self): + values = self.get_value() + self.error = None + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "At least one subinterface is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None + + +class FormKeyValuePairs(urwid.Pile, FormField): + def __init__(self, config_key, validation_types=None, transform=None, **kwargs): + self.entries = [] + self.error_widget = urwid.Text("") + self.error = None + self.validation_types = validation_types or [] + + header_columns = [ + ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Key"), "multitable_header")), + ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Value"), "multitable_header")), + (4, urwid.AttrMap(urwid.Text("Action"), "multitable_header")) + ] + + self.header_row = urwid.AttrMap(urwid.Columns(header_columns), "multitable_header") + + first_entry = self.create_entry_row() + self.entries.append(first_entry) + + self.add_button = urwid.Button("+ Add Parameter", on_press=self.add_entry) + add_button_padded = urwid.Padding(self.add_button, left=2, right=2) + + pile_widgets = [ + self.header_row, + urwid.Divider("-"), + first_entry, + add_button_padded + ] + + urwid.Pile.__init__(self, pile_widgets) + FormField.__init__(self, config_key, transform) + + def create_entry_row(self, key="", value=""): + key_edit = ReadlineEdit("", key) + value_edit = ReadlineEdit("", value) + + remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row)) + + entry_row = urwid.Columns([ + ('weight', 1, key_edit), + ('weight', 1, value_edit), + (4, remove_button) + ]) + + entry_row.key_edit = key_edit + entry_row.value_edit = value_edit + + return entry_row + + def remove_entry(self, button, entry_row): + if len(self.entries) > 1: + self.entries.remove(entry_row) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def add_entry(self, button): + new_entry = self.create_entry_row() + self.entries.append(new_entry) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def get_pile_widgets(self): + return [ + self.header_row, + urwid.Divider("-") + ] + self.entries + [ + urwid.Padding(self.add_button, left=2, right=2) + ] + + def get_value(self): + values = {} + for entry in self.entries: + key = entry.key_edit.edit_text.strip() + value = entry.value_edit.edit_text.strip() + + if key: + if value.isdigit(): + values[key] = int(value) + elif value.replace('.', '', 1).isdigit() and value.count('.') <= 1: + values[key] = float(value) + elif value.lower() == 'true': + values[key] = True + elif value.lower() == 'false': + values[key] = False + else: + values[key] = value + + return self.transform(values) if self.transform else values + + def set_value(self, value): + self.entries = [] + + if not value or not isinstance(value, dict): + self.entries.append(self.create_entry_row()) + else: + for key, val in value.items(): + self.entries.append(self.create_entry_row(key=key, value=str(val))) + + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + + def validate(self): + values = self.get_value() + self.error = None + + keys = [entry.key_edit.edit_text.strip() for entry in self.entries + if entry.key_edit.edit_text.strip()] + if len(keys) != len(set(keys)): + self.error = "Duplicate keys are not allowed" + self.error_widget.set_text(("error", self.error)) + return False + + for validation in self.validation_types: + if validation == "required" and not values: + self.error = "Atleast one parameter is required" + break + + self.error_widget.set_text(("error", self.error or "")) + return self.error is None \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/LICENSE b/nomadnet/vendor/additional_urwid_widgets/LICENSE new file mode 100644 index 0000000..0bf84bc --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 AFoeee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/nomadnet/vendor/additional_urwid_widgets/__init__.py b/nomadnet/vendor/additional_urwid_widgets/__init__.py new file mode 100644 index 0000000..d7b0867 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/__init__.py @@ -0,0 +1,10 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from .assisting_modules.modifier_key import MODIFIER_KEY +from .widgets.date_picker import DatePicker +from .widgets.indicative_listbox import IndicativeListBox +from .widgets.integer_picker import IntegerPicker +from .widgets.message_dialog import MessageDialog +from .widgets.selectable_row import SelectableRow \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/assisting_modules/__init__.py b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/__init__.py new file mode 100644 index 0000000..b16fa58 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/__init__.py @@ -0,0 +1,2 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/nomadnet/vendor/additional_urwid_widgets/assisting_modules/modifier_key.py b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/modifier_key.py new file mode 100644 index 0000000..53c1e11 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/modifier_key.py @@ -0,0 +1,26 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import enum + + +class MODIFIER_KEY(enum.Enum): + """Represents modifier keys such as 'ctrl', 'shift' and so on. + Not every combination of modifier and input is useful.""" + + NONE = "" + SHIFT = "shift" + ALT = "meta" + CTRL = "ctrl" + SHIFT_ALT = "shift meta" + SHIFT_CTRL = "shift ctrl" + ALT_CTRL = "meta ctrl" + SHIFT_ALT_CTRL = "shift meta ctrl" + + def append_to(self, text, separator=" "): + return (text + separator + self.value) if (self != self.__class__.NONE) else text + + def prepend_to(self, text, separator=" "): + return (self.value + separator + text) if (self != self.__class__.NONE) else text + \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/assisting_modules/useful_functions.py b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/useful_functions.py new file mode 100644 index 0000000..946a2ab --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/useful_functions.py @@ -0,0 +1,47 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""A non-thematic collection of useful functions.""" + + +def recursively_replace(original, replacements, include_original_keys=False): + """Clones an iterable and recursively replaces specific values.""" + + # If this function would be called recursively, the parameters 'replacements' and 'include_original_keys' would have to be + # passed each time. Therefore, a helper function with a reduced parameter list is used for the recursion, which nevertheless + # can access the said parameters. + + def _recursion_helper(obj): + #Determine if the object should be replaced. If it is not hashable, the search will throw a TypeError. + try: + if obj in replacements: + return replacements[obj] + except TypeError: + pass + + # An iterable is recursively processed depending on its class. + if hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes, bytearray)): + if isinstance(obj, dict): + contents = {} + + for key, val in obj.items(): + new_key = _recursion_helper(key) if include_original_keys else key + new_val = _recursion_helper(val) + + contents[new_key] = new_val + + else: + contents = [] + + for element in obj: + new_element = _recursion_helper(element) + + contents.append(new_element) + + # Use the same class as the original. + return obj.__class__(contents) + + # If it is not replaced and it is not an iterable, return it. + return obj + + return _recursion_helper(original) diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/__init__.py b/nomadnet/vendor/additional_urwid_widgets/widgets/__init__.py new file mode 100644 index 0000000..b16fa58 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/__init__.py @@ -0,0 +1,2 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/date_picker.py b/nomadnet/vendor/additional_urwid_widgets/widgets/date_picker.py new file mode 100644 index 0000000..8d53bd8 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/date_picker.py @@ -0,0 +1,345 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from ..assisting_modules.modifier_key import MODIFIER_KEY # pylint: disable=unused-import +from ..assisting_modules.useful_functions import recursively_replace +from .indicative_listbox import IndicativeListBox +from .integer_picker import IntegerPicker +from .selectable_row import SelectableRow + +import calendar +import datetime +import enum +import urwid + + +class DatePicker(urwid.WidgetWrap): + """Serves as a selector for dates.""" + + _TYPE_ERR_MSG = "type {} was expected for {}, but found: {}." + _VALUE_ERR_MSG = "unrecognized value: {}." + + # These values are interpreted during the creation of the list items for the day picker. + class DAY_FORMAT(enum.Enum): + DAY_OF_MONTH = 1 + DAY_OF_MONTH_TWO_DIGIT = 2 + WEEKDAY = 3 + + # These values are interpreted during the initialization and define the arrangement of the pickers. + class PICKER(enum.Enum): + YEAR = 1 + MONTH = 2 + DAY = 3 + + # Specifies which dates are selectable. + class RANGE(enum.Enum): + ALL = 1 + ONLY_PAST = 2 + ONLY_FUTURE = 3 + + def __init__(self, initial_date=datetime.date.today(), *, date_range=RANGE.ALL, month_names=calendar.month_name, day_names=calendar.day_abbr, + day_format=(DAY_FORMAT.WEEKDAY, DAY_FORMAT.DAY_OF_MONTH), columns=(PICKER.DAY, PICKER.MONTH, PICKER.YEAR), + modifier_key=MODIFIER_KEY.CTRL, return_unused_navigation_input=False, year_jump_len=50, space_between=2, + min_width_each_picker=9, year_align="center", month_align="center", day_align="center", topBar_align="center", + topBar_endCovered_prop=("▲", None, None), topBar_endExposed_prop=("───", None, None), bottomBar_align="center", + bottomBar_endCovered_prop=("▼", None, None), bottomBar_endExposed_prop=("───", None, None), highlight_prop=(None, None)): + assert (type(date_range) == self.__class__.RANGE), self.__class__._TYPE_ERR_MSG.format("", + "'date_range'", + type(date_range)) + + for df in day_format: + assert (type(df) == self.__class__.DAY_FORMAT), self.__class__._TYPE_ERR_MSG.format("", + "all elements of 'day_format'", + type(df)) + + # Relevant for 'RANGE.ONLY_PAST' and 'RANGE.ONLY_FUTURE' to limit the respective choices. + self._initial_year = initial_date.year + self._initial_month = initial_date.month + self._initial_day = initial_date.day + + # The date pool can be limited, so that only past or future dates are selectable. The initial date is included in the + # pool. + self._date_range = date_range + + # The presentation of months and weekdays can be changed by passing alternative values (e.g. abbreviations or numerical + # representations). + self._month_names = month_names + self._day_names = day_names + + # Since there are different needs regarding the appearance of the day picker, an iterable can be passed, which allows a + # customization of the presentation. + self._day_format = day_format + + # Specifies the text alignment of the individual pickers. The year alignment is passed directly to the year picker. + self._month_align = month_align + self._day_align = day_align + + # The default style of a list entry. Since only one list entry will be visible at a time and there is also off focus + # highlighting, the normal value can be 'None' (it is never shown). + self._item_attr = (None, highlight_prop[0]) + + # A full list of months. (From 'January' to 'December'.) + self._month_list = self._generate_months() + + # Set the respective values depending on the date range. + min_year = datetime.MINYEAR + max_year = datetime.MAXYEAR + + month_position = self._initial_month - 1 + day_position = self._initial_day - 1 + + if date_range == self.__class__.RANGE.ALL: + initial_month_list = self._month_list + + elif date_range == self.__class__.RANGE.ONLY_PAST: + max_year = self._initial_year + + # The months of the very last year may be shorten. + self._shortened_month_list = self._generate_months(end=self._initial_month) + initial_month_list = self._shortened_month_list + + elif date_range == self.__class__.RANGE.ONLY_FUTURE: + min_year = self._initial_year + + # The months of the very first year may be shorten. + self._shortened_month_list = self._generate_months(start=self._initial_month) + initial_month_list = self._shortened_month_list + + # The list may not start at 1 but some other day of month, therefore use the first list item. + month_position = 0 + day_position = 0 + + else: + raise ValueError(self.__class__._VALUE_ERR_MSG.format(date_range)) + + # Create pickers. + self._year_picker = IntegerPicker(self._initial_year, + min_v=min_year, + max_v=max_year, + jump_len=year_jump_len, + on_selection_change=self._year_has_changed, + modifier_key=modifier_key, + return_unused_navigation_input=return_unused_navigation_input, + topBar_align=topBar_align, + topBar_endCovered_prop=topBar_endCovered_prop, + topBar_endExposed_prop=topBar_endExposed_prop, + bottomBar_align=bottomBar_align, + bottomBar_endCovered_prop=bottomBar_endCovered_prop, + bottomBar_endExposed_prop=bottomBar_endExposed_prop, + display_syntax="{:04}", + display_align=year_align, + display_prop=highlight_prop) + + self._month_picker = IndicativeListBox(initial_month_list, + position=month_position, + on_selection_change=self._month_has_changed, + modifier_key=modifier_key, + return_unused_navigation_input=return_unused_navigation_input, + topBar_align=topBar_align, + topBar_endCovered_prop=topBar_endCovered_prop, + topBar_endExposed_prop=topBar_endExposed_prop, + bottomBar_align=bottomBar_align, + bottomBar_endCovered_prop=bottomBar_endCovered_prop, + bottomBar_endExposed_prop=bottomBar_endExposed_prop, + highlight_offFocus=highlight_prop[1]) + + self._day_picker = IndicativeListBox(self._generate_days(self._initial_year, self._initial_month), + position=day_position, + modifier_key=modifier_key, + return_unused_navigation_input=return_unused_navigation_input, + topBar_align=topBar_align, + topBar_endCovered_prop=topBar_endCovered_prop, + topBar_endExposed_prop=topBar_endExposed_prop, + bottomBar_align=bottomBar_align, + bottomBar_endCovered_prop=bottomBar_endCovered_prop, + bottomBar_endExposed_prop=bottomBar_endExposed_prop, + highlight_offFocus=highlight_prop[1]) + + # To mimic a selection widget, 'IndicativeListbox' is wrapped in a 'urwid.BoxAdapter'. Since two rows are used for the bars, + # size 3 makes exactly one list item visible. + boxed_month_picker = urwid.BoxAdapter(self._month_picker, 3) + boxed_day_picker = urwid.BoxAdapter(self._day_picker, 3) + + # Replace the 'DatePicker.PICKER' elements of the parameter 'columns' with the corresponding pickers. + replacements = {self.__class__.PICKER.YEAR : self._year_picker, + self.__class__.PICKER.MONTH : boxed_month_picker, + self.__class__.PICKER.DAY : boxed_day_picker} + + columns = recursively_replace(columns, replacements) + + # wrap 'urwid.Columns' + super().__init__(urwid.Columns(columns, + min_width=min_width_each_picker, + dividechars=space_between)) + + def __repr__(self): + return "{}(date='{}', date_range='{}', initial_date='{}-{:02}-{:02}', selected_date='{}')".format(self.__class__.__name__, + self.get_date(), + self._date_range, + self._initial_year, + self._initial_month, + self._initial_day, + self.get_date()) + + # The returned widget is used for all list entries. + def _generate_item(self, cols, *, align="center"): + return urwid.AttrMap(SelectableRow(cols, align=align), + self._item_attr[0], + self._item_attr[1]) + + def _generate_months(self, start=1, end=12): + months = [] + + for month in range(start, end+1): + item = self._generate_item([self._month_names[month]], align=self._month_align) + + # Add a new instance variable which holds the numerical value. This makes it easier to get the displayed value. + item._numerical_value = month + + months.append(item) + + return months + + def _generate_days(self, year, month): + start = 1 + weekday, end = calendar.monthrange(year, month) # end is included in the range + + # If the date range is 'ONLY_PAST', the last month does not end as usual but on the specified day. + if (self._date_range == self.__class__.RANGE.ONLY_PAST) and (year == self._initial_year) and (month == self._initial_month): + end = self._initial_day + + # If the date range is 'ONLY_FUTURE', the first month does not start as usual but on the specified day. + elif (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year) and (month == self._initial_month): + start = self._initial_day + weekday = calendar.weekday(year, month, start) + + days = [] + + for day in range(start, end+1): + cols = [] + + # The 'DatePicker.DAY_FORMAT' elements of the iterable are translated into columns of the day picker. This allows the + # presentation to be customized. + for df in self._day_format: + if df == self.__class__.DAY_FORMAT.DAY_OF_MONTH: + cols.append(str(day)) + + elif df == self.__class__.DAY_FORMAT.DAY_OF_MONTH_TWO_DIGIT: + cols.append(str(day).zfill(2)) + + elif df == self.__class__.DAY_FORMAT.WEEKDAY: + cols.append(self._day_names[weekday]) + + else: + raise ValueError(self.__class__._VALUE_ERR_MSG.format(df)) + + item = self._generate_item(cols, align=self._day_align) + + # Add a new instance variable which holds the numerical value. This makes it easier to get the displayed value. + item._numerical_value = day + + # Keeps track of the weekday. + weekday = (weekday + 1) if (weekday < 6) else 0 + + days.append(item) + + return days + + def _year_has_changed(self, previous_year, current_year): + month_position_before_change = self._month_picker.get_selected_position() + + # Since there are no years in 'RANGE.ALL' that do not have the full month range, the body never needs to be changed after + # initialization. + if self._date_range != self.__class__.RANGE.ALL: + # 'None' stands for trying to keep the old value. + provisional_position = None + + # If the previous year was the specified year, the shortened month range must be replaced by the complete one. If this + # shortened month range does not begin at 'January', then the difference must be taken into account. + if previous_year == self._initial_year: + if self._date_range == self.__class__.RANGE.ONLY_FUTURE: + provisional_position = self._month_picker.get_selected_item()._numerical_value - 1 + + self._month_picker.set_body(self._month_list, + alternative_position=provisional_position) + + # If the specified year is selected, the full month range must be replaced with the shortened one. + elif current_year == self._initial_year: + if self._date_range == self.__class__.RANGE.ONLY_FUTURE: + provisional_position = month_position_before_change - (self._initial_month - 1) + + self._month_picker.set_body(self._shortened_month_list, + alternative_position=provisional_position) + + # Since the month has changed, the corresponding method is called. + self._month_has_changed(month_position_before_change, + self._month_picker.get_selected_position(), + previous_year=previous_year) + + def _month_has_changed(self, previous_position, current_position, *, previous_year=None): + # 'None' stands for trying to keep the old value. + provisional_position = None + + current_year = self._year_picker.get_value() + + # Out of range values are changed by 'IndicativeListBox' to the nearest valid values. + + # 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 + # be changed to reflect this difference. + if self._date_range == self.__class__.RANGE.ONLY_FUTURE: + # If the current or previous year is the specified year and the month was the specified month, the value has an offset + # of the specified day. Therefore the deposited numerical value is used. ('-1' because it's an index.) + if ((current_year == self._initial_year) or (previous_year == self._initial_year)) and (previous_position == 0): + provisional_position = self._day_picker.get_selected_item()._numerical_value - 1 + + # If the current year is the specified year and the current month is the specified month, the month begins not with + # the first day, but with the specified day. + elif (current_year == self._initial_year) and (current_position == 0): + provisional_position = self._day_picker.get_selected_position() - (self._initial_day - 1) + + self._day_picker.set_body(self._generate_days(current_year, + self._month_picker.get_selected_item()._numerical_value), + alternative_position=provisional_position) + + def get_date(self): + return datetime.date(self._year_picker.get_value(), + self._month_picker.get_selected_item()._numerical_value, + self._day_picker.get_selected_item()._numerical_value) + + def set_date(self, date): + # If the date range is limited, test for the new limit. + if self._date_range != self.__class__.RANGE.ALL: + limit = datetime.date(self._initial_year, self._initial_month, self._initial_day) + + if (self._date_range == self.__class__.RANGE.ONLY_PAST) and (date > limit): + raise ValueError("The passed date is outside the upper bound of the date range.") + + elif (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (date < limit): + raise ValueError("The passed date is outside the lower bound of the date range.") + + year = date.year + month = date.month + day = date.day + + # Set the new values, if needed. + if year != self._year_picker.get_value(): + self._year_picker.set_value(year) + + if month != self._month_picker.get_selected_item()._numerical_value: + month_position = month - 1 # '-1' because it's an index. + + if (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year): + # If the value should be negative, the behavior of 'IndicativeListBox' shows effect and position 0 is selected. + month_position = month_position - (self._initial_month - 1) + + self._month_picker.select_item(month_position) + + if day != self._day_picker.get_selected_item()._numerical_value: + day_position = day - 1 # '-1' because it's an index. + + if (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year) and (month == self._initial_month): + day_position = day_position - (self._initial_day - 1) + + self._day_picker.select_item(day_position) + \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py new file mode 100644 index 0000000..c0a95b0 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py @@ -0,0 +1,452 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from ..assisting_modules.modifier_key import MODIFIER_KEY # pylint: disable=unused-import + +import enum +import random +import urwid + + +class IndicativeListBox(urwid.WidgetWrap): + """Adds two bars to a 'urwid.ListBox', that make it obvious that due to limited space only a part of the list items is displayed.""" + + _TYPE_ERR_MSG = "type {} was expected for {}, but found: {}." + _VALUE_ERR_MSG = "unrecognized value: {}." + + # These values are translated by 'get_nearest_valid_position()' into the corresponding int values. + class POSITION(enum.Enum): + LAST = 1 + MIDDLE = 2 + RANDOM = 3 + + def __init__(self, body, *, position=0, on_selection_change=None, initialization_is_selection_change=False, + modifier_key=MODIFIER_KEY.NONE, return_unused_navigation_input=True, topBar_align="center", + topBar_endCovered_prop=("▲", None, None), topBar_endExposed_prop=("───", None, None), bottomBar_align="center", + bottomBar_endCovered_prop=("▼", None, None), bottomBar_endExposed_prop=("───", None, None), highlight_offFocus=None): + # If not already done, wrap each item of the body in an 'urwid.AttrMap'. This is necessary to enable off focus highlighting. + body[:] = [urwid.AttrMap(item, None) if not isinstance(item, urwid.AttrMap) else item + for item in body] + + # The body of the 'urwid.Frame' is a 'urwid.ListBox'. + self._listbox = urwid.ListBox(body) + + # Select the specified list position, or the nearest valid one. + nearest_valid_position = self._get_nearest_valid_position(position) + + if nearest_valid_position is not None: + self._listbox.set_focus(nearest_valid_position) + + # The bars are just 'urwid.Text' widgets. + self._top_bar = urwid.AttrMap(urwid.Text("", align=topBar_align), + None) + + self._bottom_bar = urwid.AttrMap(urwid.Text("", align=bottomBar_align), + None) + + # Wrap 'urwid.Frame'. + super().__init__(urwid.Frame(self._listbox, + header=self._top_bar, + footer=self._bottom_bar, + focus_part="body")) + + # During the initialization of 'urwid.AttrMap', the value can be passed as non-dict. After initializing, its value can be + # manipulated by passing a dict. The dicts I create below will be used later to change the appearance of the bars. + self._topBar_endCovered_markup = topBar_endCovered_prop[0] + self._topBar_endCovered_focus = {None:topBar_endCovered_prop[1]} + self._topBar_endCovered_offFocus = {None:topBar_endCovered_prop[2]} + + self._topBar_endExposed_markup = topBar_endExposed_prop[0] + self._topBar_endExposed_focus = {None:topBar_endExposed_prop[1]} + self._topBar_endExposed_offFocus = {None:topBar_endExposed_prop[2]} + + self._bottomBar_endCovered_markup = bottomBar_endCovered_prop[0] + self._bottomBar_endCovered_focus = {None:bottomBar_endCovered_prop[1]} + self._bottomBar_endCovered_offFocus = {None:bottomBar_endCovered_prop[2]} + + self._bottomBar_endExposed_markup = bottomBar_endExposed_prop[0] + self._bottomBar_endExposed_focus = {None:bottomBar_endExposed_prop[1]} + self._bottomBar_endExposed_offFocus = {None:bottomBar_endExposed_prop[2]} + + # This is used to highlight the selected item when the widget does not have the focus. + self._highlight_offFocus = {None:highlight_offFocus} + self._last_focus_state = None + self._original_item_attr_map = None + + # A hook which is triggered when the selection changes. + self.on_selection_change = on_selection_change + + # Initialization can be seen as a special case of selection change. This is interesting in combination with the hook. + self._initialization_is_selection_change = initialization_is_selection_change + + # 'MODIFIER_KEY' changes the behavior of the list box, so that it responds only to modified input. ('up' => 'ctrl up') + self._modifier_key = modifier_key + + # If the list item at the top is selected and you navigate further upwards, the input is normally not swallowed by the + # list box, but passed on so that other widgets can interpret it. This may result in transferring the focus. + self._return_unused_navigation_input = return_unused_navigation_input + + # Is 'on_selection_change' triggered during the initialization? + if initialization_is_selection_change and (on_selection_change is not None): + on_selection_change(None, + self.get_selected_position()) + + def __repr__(self): + return "{}(body='{}', position='{}')".format(self.__class__.__name__, + self.get_body(), + self.get_selected_position()) + + def render(self, size, focus=False): + just_maxcol = (size[0],) + + # The size also includes the two bars, so subtract these. + modified_size = (size[0], # cols + size[1] - self._top_bar.rows(just_maxcol) - self._bottom_bar.rows(just_maxcol)) # rows + + # Evaluates which ends are visible and calculates how many list entries are hidden above/below. This is a modified form + # of 'urwid.ListBox.ends_visible()'. + middle, top, bottom = self._listbox.calculate_visible(modified_size, focus=focus) + + if middle is None: # empty list box + top_is_visible = True + bottom_is_visible = True + + else: + top_is_visible = False + bottom_is_visible = False + + trim_top, above = top + trim_bottom, below = bottom + + if trim_top == 0: + pos = above[-1][1] if (len(above) != 0) else middle[2] + + if self._listbox._body.get_prev(pos) == (None,None): + top_is_visible = True + else: + covered_above = pos + else: + covered_above = self.rearmost_position() + + if trim_bottom == 0: + row_offset, _, pos, rows, _ = middle + + row_offset += rows # Include the selected position. + + for _, pos, rows in below: # 'pos' is overridden + row_offset += rows + + if (row_offset < modified_size[1]) or (self._listbox._body.get_next(pos) == (None, None)): + bottom_is_visible = True + else: + covered_below = self.rearmost_position() - pos + else: + covered_below = self.rearmost_position() + + # Changes the appearance of the bar at the top depending on whether the first list item is visible and the widget has + # the focus. + if top_is_visible: + self._top_bar.original_widget.set_text(self._topBar_endExposed_markup) + self._top_bar.set_attr_map(self._topBar_endExposed_focus + if focus else self._topBar_endExposed_offFocus) + else: + self._top_bar.original_widget.set_text(self._topBar_endCovered_markup.format(covered_above)) + self._top_bar.set_attr_map(self._topBar_endCovered_focus + if focus else self._topBar_endCovered_offFocus) + + # Changes the appearance of the bar at the bottom depending on whether the last list item is visible and the widget + # has the focus. + if bottom_is_visible: + self._bottom_bar.original_widget.set_text(self._bottomBar_endExposed_markup) + self._bottom_bar.set_attr_map(self._bottomBar_endExposed_focus + if focus else self._bottomBar_endExposed_offFocus) + else: + self._bottom_bar.original_widget.set_text(self._bottomBar_endCovered_markup.format(covered_below)) + self._bottom_bar.set_attr_map(self._bottomBar_endCovered_focus + if focus else self._bottomBar_endCovered_offFocus) + + # The highlighting in urwid is bound to the focus. This means that the selected item is only distinguishable as long as + # the widget has the focus. To compensate this, the color scheme of the selected item is otherwiese temporarily changed. + if focus and not self._last_focus_state and (self._original_item_attr_map is not None): + # Resets the appearance of the selected item to its original value. + self._listbox.focus.set_attr_map(self._original_item_attr_map) + + elif (self._highlight_offFocus is not None) \ + and not focus \ + and (self._last_focus_state or (self._last_focus_state is None)) \ + and not self.body_is_empty(): + # Store the 'attr_map' of the selected item and then change it to accomplish off focus highlighting. + self._original_item_attr_map = self._listbox.focus.get_attr_map() + self._listbox.focus.set_attr_map(self._highlight_offFocus) + + # Store the last focus to do/undo the off focus highlighting only if the focus has really changed and not if the + # widget is re-rendered because the terminal size has changed or similar. + self._last_focus_state = focus + + self.top_is_visible = top_is_visible + self.bottom_is_visible = bottom_is_visible + + return super().render(size, focus=focus) + + def keypress(self, size, key): + just_maxcol = (size[0],) + + # The size also includes the two bars, so subtract these. + modified_size = (size[0], # cols + size[1] - self._top_bar.rows(just_maxcol) - self._bottom_bar.rows(just_maxcol)) # rows + + # Store the focus position before passing the keystroke to the contained list box. That way, it can be compared with the + # position after the input is processed. If the list box body is empty, store None. + focus_position_before_input = self.get_selected_position() + + # A keystroke is changed to a modified one ('up' => 'ctrl up'). This prevents the widget from responding when the arrows + # keys are used to navigate between widgets. That way it can be used in a 'urwid.Pile' or similar. + if key == self._modifier_key.prepend_to("up"): + key = self._pass_key_to_contained_listbox(modified_size, "up") + + elif key == self._modifier_key.prepend_to("down"): + key = self._pass_key_to_contained_listbox(modified_size, "down") + + elif key == self._modifier_key.prepend_to("page up"): + key = self._pass_key_to_contained_listbox(modified_size, "page up") + + elif key == self._modifier_key.prepend_to("page down"): + key = self._pass_key_to_contained_listbox(modified_size, "page down") + + elif key == self._modifier_key.prepend_to("home"): + # Check if the first list item is already selected. + if (focus_position_before_input is not None) and (focus_position_before_input != 0): + self.select_first_item() + key = None + elif not self._return_unused_navigation_input: + key = None + + elif key == self._modifier_key.prepend_to("end"): + # Check if the last list item is already selected. + if (focus_position_before_input is not None) and (focus_position_before_input != self.rearmost_position()): + self.select_last_item() + key = None + elif not self._return_unused_navigation_input: + key = None + + elif key not in ("up", "down", "page up", "page down", "home", "end"): + key = self._listbox.keypress(modified_size, key) + + focus_position_after_input = self.get_selected_position() + + # If the focus position has changed, execute the hook (if existing). + if (focus_position_before_input != focus_position_after_input) and (self.on_selection_change is not None): + self.on_selection_change(focus_position_before_input, + focus_position_after_input) + + return key + + def mouse_event(self, size, event, button, col, row, focus): + just_maxcol = (size[0],) + + topBar_rows = self._top_bar.rows(just_maxcol) + bottomBar_rows = self._bottom_bar.rows(just_maxcol) + + # The size also includes the two bars, so subtract these. + modified_size = (size[0], # cols + size[1] - topBar_rows - bottomBar_rows) # rows + + was_handeled = False + + # An event is changed to a modified one ('mouse press' => 'ctrl mouse press'). This prevents the widget from responding + # when mouse buttons are also used to navigate between widgets. + if event == self._modifier_key.prepend_to("mouse press"): + # Store the focus position before passing the input to the contained list box. That way, it can be compared with the + # position after the input is processed. If the list box body is empty, store None. + focus_position_before_input = self.get_selected_position() + + # Also remember the currently selected item itself. A button 1 press makes the contained list box move the focus, but + # the off focus highlighting is only (un)done during 'render()' while the widget has the focus. When the selection is + # changed by mouse while the widget is unfocused, the highlight would otherwise stay stuck on the previous item. + item_before_input = self.get_selected_item() + + # left mouse button, if not top bar or bottom bar. + if (button == 1.0) and (topBar_rows <= row < (size[1] - bottomBar_rows)): + # Because 'row' includes the top bar, the offset must be substracted before passing it to the contained list box. + result = self._listbox.mouse_event(modified_size, event, button, col, (row - topBar_rows), focus) + was_handeled = result if self._return_unused_navigation_input else True + + # mousewheel up + elif button == 4.0: + # was_handeled = self._pass_key_to_contained_listbox(modified_size, "page up") + was_handeled = self._pass_key_to_contained_listbox(modified_size, "up") + + # mousewheel down + elif button == 5.0: + # was_handeled = self._pass_key_to_contained_listbox(modified_size, "page down") + was_handeled = self._pass_key_to_contained_listbox(modified_size, "down") + + focus_position_after_input = self.get_selected_position() + + if focus_position_before_input != focus_position_after_input: + # The mouse moved the selection. If off focus highlighting is currently applied (the widget is unfocused), restore + # the previously selected item and clear the stored state, so the next 'render()' re-applies the highlight to the + # newly selected item instead of leaving it stuck on the old one. + if (not self._last_focus_state) and (self._original_item_attr_map is not None) and (item_before_input is not None): + item_before_input.set_attr_map(self._original_item_attr_map) + self._original_item_attr_map = None + self._last_focus_state = None + + # If the focus position has changed, execute the hook (if existing). + if self.on_selection_change is not None: + self.on_selection_change(focus_position_before_input, + focus_position_after_input) + + return was_handeled + + # Pass the keystroke to the original widget. If it is not used, evaluate the corresponding variable to decide if it gets + # swallowed or not. + def _pass_key_to_contained_listbox(self, size, key): + result = self._listbox.keypress(size, key) + return result if self._return_unused_navigation_input else None + + def get_body(self): + return self._listbox.body + + def body_len(self): + return len(self.get_body()) + + def rearmost_position(self): + return len(self.get_body()) - 1 # last valid index + + def body_is_empty(self): + return self.body_len() == 0 + + def position_is_valid(self, position): + return (position < self.body_len()) and (position >= 0) + + # If the passed position is valid, it is returned. Otherwise, the nearest valid position is returned. This ensures that + # positions which are out of range do not result in an error. + def _get_nearest_valid_position(self, position): + if self.body_is_empty(): + return None + + pos_type = type(position) + + if pos_type == int: + if self.position_is_valid(position): + return position + + elif position < 0: + return 0 + + else: + return self.rearmost_position() + + elif pos_type == self.__class__.POSITION: + if position == self.__class__.POSITION.LAST: + return self.rearmost_position() + + elif position == self.__class__.POSITION.MIDDLE: + return self.body_len() // 2 + + elif position == self.__class__.POSITION.RANDOM: + return random.randint(0, self.rearmost_position()) + + else: + raise ValueError(self.__class__._VALUE_ERR_MSG.format(position)) + + else: + raise TypeError(self.__class__._TYPE_ERR_MSG.format(" or ", + "'position'", + pos_type)) + + def get_item(self, position): + if self.position_is_valid(position): + body = self.get_body() + return body[position] + else: + return None + + def get_first_item(self): + return self.get_item(0) + + def get_last_item(self): + return self.get_item(self.rearmost_position()) + + def get_selected_item(self): + return self._listbox.focus + + def get_selected_position(self): + return self._listbox.focus_position if not self.body_is_empty() else None + + def first_item_is_selected(self): + return self.get_selected_position() == 0 + + def last_item_is_selected(self): + return self.get_selected_position() == self.rearmost_position() + + def _reset_highlighting(self): + # Resets the appearance of the selected item to its original value, if off focus highlighting is active. + if not self._last_focus_state and (self._original_item_attr_map is not None): + self._listbox.focus.set_attr_map(self._original_item_attr_map) + + # The next time the widget is rendered, the highlighting is redone. + self._original_item_attr_map = None + self._last_focus_state = None + + def set_body(self, body, *, alternative_position=None): + focus_position_before_change = self.get_selected_position() + + self._reset_highlighting() + + # Wrap each item in an 'urwid.AttrMap', if not already done. + self._listbox.body[:] = [urwid.AttrMap(item, None) if not isinstance(item, urwid.AttrMap) else item + for item in body] + + # Normally it is tried to hold the focus position. If this is not desired, a position can be passed. + if alternative_position is not None: + nearest_valid_position = self._get_nearest_valid_position(alternative_position) + + if nearest_valid_position is not None: + # Because the widget has been re-rendered, the off focus highlighted item must be restored to its original state. + self._reset_highlighting() + + self._listbox.set_focus(nearest_valid_position) + + # If an initialization is considered a selection change, execute the hook (if existing). + if self._initialization_is_selection_change and (self.on_selection_change is not None): + self.on_selection_change(focus_position_before_change, + self.get_selected_position()) + + def select_item(self, position): + focus_position_before_change = self.get_selected_position() + + nearest_valid_position = self._get_nearest_valid_position(position) + + # Focus the new position, if possible and not already focused. + if (nearest_valid_position is not None) and (nearest_valid_position != focus_position_before_change): + self._reset_highlighting() + + self._listbox.set_focus(nearest_valid_position) + + # Execute the hook (if existing). + if (self.on_selection_change is not None): + self.on_selection_change(focus_position_before_change, + nearest_valid_position) + + def select_first_item(self): + self.select_item(0) + + def select_last_item(self): + self.select_item(self.rearmost_position()) + + def delete_position(self, position): + # The saved properties get reseted, just in case that the appearance of the items differs. + self._reset_highlighting() + + body = self.get_body() + del body[position] + + def delete_selected_position(self): + pos = self.get_selected_position() + + # If the list body is not empty, delete the selected item. + if pos is not None: + self.delete_position(pos) diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/integer_picker.py b/nomadnet/vendor/additional_urwid_widgets/widgets/integer_picker.py new file mode 100644 index 0000000..b52ad6f --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/integer_picker.py @@ -0,0 +1,277 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from ..assisting_modules.modifier_key import MODIFIER_KEY # pylint: disable=unused-import +from .selectable_row import SelectableRow + +import sys # pylint: disable=unused-import +import urwid + + +class IntegerPicker(urwid.WidgetWrap): + """Serves as a selector for integer numbers.""" + + def __init__(self, value, *, min_v=(-sys.maxsize - 1), max_v=sys.maxsize, step_len=1, jump_len=100, on_selection_change=None, + initialization_is_selection_change=False, modifier_key=MODIFIER_KEY.NONE, ascending=True, + return_unused_navigation_input=True, topBar_align="center", topBar_endCovered_prop=("▲", None, None), + topBar_endExposed_prop=("───", None, None), bottomBar_align="center", bottomBar_endCovered_prop=("▼", None, None), + bottomBar_endExposed_prop=("───", None, None), display_syntax="{}", display_align="center", display_prop=(None, None)): + assert (min_v <= max_v), "'min_v' must be less than or equal to 'max_v'." + + assert (min_v <= value <= max_v), "'min_v <= value <= max_v' must be True." + + self._value = value + + self._minimum = min_v + self._maximum = max_v + + # Specifies how far to move in the respective direction when the keys 'up/down' are pressed. + self._step_len = step_len + + # Specifies how far to jump in the respective direction when the keys 'page up/down' or the mouse events 'wheel up/down' + # are passed. + self._jump_len = jump_len + + # A hook which is triggered when the value changes. + self.on_selection_change = on_selection_change + + # 'MODIFIER_KEY' changes the behavior, so that the widget responds only to modified input. ('up' => 'ctrl up') + self._modifier_key = modifier_key + + # Specifies whether moving upwards represents a decrease or an increase of the value. + self._ascending = ascending + + # If the minimum has been reached and an attempt is made to select an even smaller value, the input is normally not + # swallowed by the widget, but passed on so that other widgets can interpret it. This may result in transferring the focus. + self._return_unused_navigation_input = return_unused_navigation_input + + # The bars are just 'urwid.Text' widgets. + self._top_bar = urwid.AttrMap(urwid.Text("", topBar_align), + None) + + self._bottom_bar = urwid.AttrMap(urwid.Text("", bottomBar_align), + None) + + # During the initialization of 'urwid.AttrMap', the value can be passed as non-dict. After initializing, its value can be + # manipulated by passing a dict. The dicts I create below will be used later to change the appearance of the widgets. + self._topBar_endCovered_markup = topBar_endCovered_prop[0] + self._topBar_endCovered_focus = {None:topBar_endCovered_prop[1]} + self._topBar_endCovered_offFocus = {None:topBar_endCovered_prop[2]} + + self._topBar_endExposed_markup = topBar_endExposed_prop[0] + self._topBar_endExposed_focus = {None:topBar_endExposed_prop[1]} + self._topBar_endExposed_offFocus = {None:topBar_endExposed_prop[2]} + + self._bottomBar_endCovered_markup = bottomBar_endCovered_prop[0] + self._bottomBar_endCovered_focus = {None:bottomBar_endCovered_prop[1]} + self._bottomBar_endCovered_offFocus = {None:bottomBar_endCovered_prop[2]} + + self._bottomBar_endExposed_markup = bottomBar_endExposed_prop[0] + self._bottomBar_endExposed_focus = {None:bottomBar_endExposed_prop[1]} + self._bottomBar_endExposed_offFocus = {None:bottomBar_endExposed_prop[2]} + + # Format the number before displaying it. That way it is easier to read. + self._display_syntax = display_syntax + + # The current value is displayed via this widget. + self._display = SelectableRow([display_syntax.format(value)], + align=display_align) + + display_attr = urwid.AttrMap(self._display, + display_prop[1], + display_prop[0]) + + # wrap 'urwid.Pile' + super().__init__(urwid.Pile([self._top_bar, + display_attr, + self._bottom_bar])) + + # Is 'on_selection_change' triggered during the initialization? + if initialization_is_selection_change and (on_selection_change is not None): + on_selection_change(None, value) + + def __repr__(self): + return "{}(value='{}', min_v='{}', max_v='{}', ascending='{}')".format(self.__class__.__name__, + self._value, + self._minimum, + self._maximum, + self._ascending) + + def render(self, size, focus=False): + # Changes the appearance of the bar at the top depending on whether the upper limit is reached. + if self._value == (self._minimum if self._ascending else self._maximum): + self._top_bar.original_widget.set_text(self._topBar_endExposed_markup) + self._top_bar.set_attr_map(self._topBar_endExposed_focus + if focus else self._topBar_endExposed_offFocus) + else: + self._top_bar.original_widget.set_text(self._topBar_endCovered_markup) + self._top_bar.set_attr_map(self._topBar_endCovered_focus + if focus else self._topBar_endCovered_offFocus) + + # Changes the appearance of the bar at the bottom depending on whether the lower limit is reached. + if self._value == (self._maximum if self._ascending else self._minimum): + self._bottom_bar.original_widget.set_text(self._bottomBar_endExposed_markup) + self._bottom_bar.set_attr_map(self._bottomBar_endExposed_focus + if focus else self._bottomBar_endExposed_offFocus) + else: + self._bottom_bar.original_widget.set_text(self._bottomBar_endCovered_markup) + self._bottom_bar.set_attr_map(self._bottomBar_endCovered_focus + if focus else self._bottomBar_endCovered_offFocus) + + return super().render(size, focus=focus) + + def keypress(self, size, key): + # A keystroke is changed to a modified one ('up' => 'ctrl up'). This prevents the widget from responding when the arrows + # keys are used to navigate between widgets. That way it can be used in a 'urwid.Pile' or similar. + if key == self._modifier_key.prepend_to("up"): + successful = self._change_value(-self._step_len) + + elif key == self._modifier_key.prepend_to("down"): + successful = self._change_value(self._step_len) + + elif key == self._modifier_key.prepend_to("page up"): + successful = self._change_value(-self._jump_len) + + elif key == self._modifier_key.prepend_to("page down"): + successful = self._change_value(self._jump_len) + + elif key == self._modifier_key.prepend_to("home"): + successful = self._change_value(float("-inf")) + + elif key == self._modifier_key.prepend_to("end"): + successful = self._change_value(float("inf")) + + else: + successful = False + + return key if not successful else None + + def mouse_event(self, size, event, button, col, row, focus): + if focus: + # An event is changed to a modified one ('mouse press' => 'ctrl mouse press'). This prevents the original widget from + # responding when mouse buttons are also used to navigate between widgets. + if event == self._modifier_key.prepend_to("mouse press"): + # mousewheel up + if button == 4.0: + result = self._change_value(-self._jump_len) + return result if self._return_unused_navigation_input else True + + # mousewheel down + elif button == 5.0: + result = self._change_value(self._jump_len) + return result if self._return_unused_navigation_input else True + + return False + + # This method tries to change the value depending on the desired arrangement and returns True if this change was successful. + def _change_value(self, summand): + value_before_input = self._value + + if self._ascending: + new_value = self._value + summand + + if summand < 0: + # If the corresponding limit has already been reached, then determine whether the unused input should be + # returned or swallowed. + if self._value == self._minimum: + return not self._return_unused_navigation_input + + # If the new value stays within the permitted range, use it. + elif new_value > self._minimum: + self._value = new_value + + # The permitted range would be exceeded, so the limit is set instead. + else: + self._value = self._minimum + + elif summand > 0: + if self._value == self._maximum: + return not self._return_unused_navigation_input + + elif new_value < self._maximum: + self._value = new_value + + else: + self._value = self._maximum + else: + new_value = self._value - summand + + if summand < 0: + if self._value == self._maximum: + return not self._return_unused_navigation_input + + elif new_value < self._maximum: + self._value = new_value + + else: + self._value = self._maximum + + elif summand > 0: + if self._value == self._minimum: + return not self._return_unused_navigation_input + + elif new_value > self._minimum: + self._value = new_value + + else: + self._value = self._minimum + + # Update the displayed value. + self._display.set_contents([self._display_syntax.format(self._value)]) + + # If the value has changed, execute the hook (if existing). + if (value_before_input != self._value) and (self.on_selection_change is not None): + self.on_selection_change(value_before_input, + self._value) + + return True + + def get_value(self): + return self._value + + def set_value(self, value): + if not (self._minimum <= value <= self._maximum): + raise ValueError("'minimum <= value <= maximum' must be True.") + + if value != self._value: + value_before_change = self._value + self._value = value + + # Update the displayed value. + self._display.set_contents([self._display_syntax.format(self._value)]) + + # Execute the hook (if existing). + if (self.on_selection_change is not None): + self.on_selection_change(value_before_change, self._value) + + def set_to_minimum(self): + self.set_value(self._minimum) + + def set_to_maximum(self): + self.set_value(self._maximum) + + def set_minimum(self, new_min): + if new_min > self._maximum: + raise ValueError("'new_min' must be less than or equal to the maximum value.") + + self._minimum = new_min + + if self._value < new_min: + self.set_to_minimum() + + def set_maximum(self, new_max): + if new_max < self._minimum: + raise ValueError("'new_max' must be greater than or equal to the minimum value.") + + self._maximum = new_max + + if self._value > new_max: + self.set_to_maximum() + + def minimum_is_selected(self): + return self._value == self._minimum + + def maximum_is_selected(self): + return self._value == self._maximum + \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/message_dialog.py b/nomadnet/vendor/additional_urwid_widgets/widgets/message_dialog.py new file mode 100644 index 0000000..9100cd9 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/message_dialog.py @@ -0,0 +1,40 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import urwid + + +class MessageDialog(urwid.WidgetWrap): + """Wraps 'urwid.Overlay' to show a message and expects a reaction from the user.""" + + def __init__(self, contents, btns, overlay_size, *, contents_align="left", space_between_btns=2, title="", title_align="center", + background=urwid.SolidFill("#"), overlay_align=("center", "middle"), overlay_min_size=(None, None), left=0, right=0, + top=0, bottom=0): + # Message part + texts = [urwid.Text(content, align=contents_align) + for content in contents] + + # Lower part + lower_part = [urwid.Divider(" "), + urwid.Columns(btns, dividechars=space_between_btns)] + + # frame + line_box = urwid.LineBox(urwid.Pile(texts + lower_part), + title=title, + title_align=title_align) + + # Wrap 'urwid.Overlay' + super().__init__(urwid.Overlay(urwid.Filler(line_box), + background, + overlay_align[0], + overlay_size[0], + overlay_align[1], + overlay_size[1], + min_width=overlay_min_size[0], + min_height=overlay_min_size[1], + left=left, + right=right, + top=top, + bottom=bottom)) + \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/selectable_row.py b/nomadnet/vendor/additional_urwid_widgets/widgets/selectable_row.py new file mode 100644 index 0000000..d7fd644 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/selectable_row.py @@ -0,0 +1,47 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import urwid + + +class SelectableRow(urwid.WidgetWrap): + """Wraps 'urwid.Columns' to make it selectable. + This class has been slightly modified, but essentially corresponds to this class posted on stackoverflow.com: + https://stackoverflow.com/questions/52106244/how-do-you-combine-multiple-tui-forms-to-write-more-complex-applications#answer-52174629""" + + def __init__(self, contents, *, align="left", on_select=None, space_between=2): + # A list-like object, where each element represents the value of a column. + self.contents = contents + + self._columns = urwid.Columns([urwid.Text(c, align=align) for c in contents], + dividechars=space_between) + + # Wrap 'urwid.Columns'. + super().__init__(self._columns) + + # A hook which defines the behavior that is executed when a specified key is pressed. + self.on_select = on_select + + def __repr__(self): + return "{}(contents='{}')".format(self.__class__.__name__, + self.contents) + + def selectable(self): + return True + + def keypress(self, size, key): + if (key == "enter") and (self.on_select is not None): + self.on_select(self) + key = None + + return key + + def set_contents(self, contents): + # Update the list record inplace... + self.contents[:] = contents + + # ... and update the displayed items. + for t, (w, _) in zip(contents, self._columns.contents): + w.set_text(t) + diff --git a/nomadnet/vendor/cbor.py b/nomadnet/vendor/cbor.py new file mode 100644 index 0000000..6c63096 --- /dev/null +++ b/nomadnet/vendor/cbor.py @@ -0,0 +1,430 @@ +# https://github.com/brianolson/cbor_py +# Copyright 2014-2015 Brian Olson +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import re +import struct +from io import BytesIO + + +CBOR_TYPE_MASK = 0xE0 # top 3 bits +CBOR_INFO_BITS = 0x1F # low 5 bits + + +CBOR_UINT = 0x00 +CBOR_NEGINT = 0x20 +CBOR_BYTES = 0x40 +CBOR_TEXT = 0x60 +CBOR_ARRAY = 0x80 +CBOR_MAP = 0xA0 +CBOR_TAG = 0xC0 +CBOR_7 = 0xE0 # float and other types + +CBOR_UINT8_FOLLOWS = 24 # 0x18 +CBOR_UINT16_FOLLOWS = 25 # 0x19 +CBOR_UINT32_FOLLOWS = 26 # 0x1a +CBOR_UINT64_FOLLOWS = 27 # 0x1b +CBOR_VAR_FOLLOWS = 31 # 0x1f + +CBOR_BREAK = 0xFF + +CBOR_FALSE = (CBOR_7 | 20) +CBOR_TRUE = (CBOR_7 | 21) +CBOR_NULL = (CBOR_7 | 22) +CBOR_UNDEFINED = (CBOR_7 | 23) # js 'undefined' value + +CBOR_FLOAT16 = (CBOR_7 | 25) +CBOR_FLOAT32 = (CBOR_7 | 26) +CBOR_FLOAT64 = (CBOR_7 | 27) + +CBOR_TAG_DATE_STRING = 0 # RFC3339 +CBOR_TAG_DATE_ARRAY = 1 # any number, seconds since 1970-01-01T00:00:00 UTC +CBOR_TAG_BIGNUM = 2 # big-endian byte string follows +CBOR_TAG_NEGBIGNUM = 3 # big-endian byte string follows +CBOR_TAG_DECIMAL = 4 +CBOR_TAG_BIGFLOAT = 5 +CBOR_TAG_BASE64URL = 21 +CBOR_TAG_BASE64 = 22 +CBOR_TAG_BASE16 = 23 +CBOR_TAG_CBOR = 24 + +CBOR_TAG_URI = 32 +CBOR_TAG_REGEX = 35 +CBOR_TAG_MIME = 36 +CBOR_TAG_CBOR_FILEHEADER = 55799 # 0xd9d9f7 + +_CBOR_TAG_BIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_BIGNUM) +_CBOR_TAG_NEGBIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_NEGBIGNUM) + + +def _dumps_bignum_to_bytearray(val): + out = [] + while val > 0: + out.insert(0, val & 0x0ff) + val = val >> 8 + return bytes(out) + + +def dumps_int(val): + "return bytes representing int val in CBOR" + if val >= 0: + if val <= 23: + return struct.pack('B', val) + if val <= 0x0ff: + return struct.pack('BB', CBOR_UINT8_FOLLOWS, val) + if val <= 0x0ffff: + return struct.pack('!BH', CBOR_UINT16_FOLLOWS, val) + if val <= 0x0ffffffff: + return struct.pack('!BI', CBOR_UINT32_FOLLOWS, val) + if val <= 0x0ffffffffffffffff: + return struct.pack('!BQ', CBOR_UINT64_FOLLOWS, val) + outb = _dumps_bignum_to_bytearray(val) + return _CBOR_TAG_BIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb + val = -1 - val + return _encode_type_num(CBOR_NEGINT, val) + + +def dumps_float(val): + return struct.pack("!Bd", CBOR_FLOAT64, val) + + +def _encode_type_num(cbor_type, val): + """For some CBOR primary type [0..7] and an auxiliary unsigned number, + return CBOR encoded bytes.""" + assert val >= 0 + if val <= 23: + return struct.pack('B', cbor_type | val) + if val <= 0x0ff: + return struct.pack('BB', cbor_type | CBOR_UINT8_FOLLOWS, val) + if val <= 0x0ffff: + return struct.pack('!BH', cbor_type | CBOR_UINT16_FOLLOWS, val) + if val <= 0x0ffffffff: + return struct.pack('!BI', cbor_type | CBOR_UINT32_FOLLOWS, val) + if (((cbor_type == CBOR_NEGINT) and (val <= 0x07fffffffffffffff)) or + ((cbor_type != CBOR_NEGINT) and (val <= 0x0ffffffffffffffff))): + return struct.pack('!BQ', cbor_type | CBOR_UINT64_FOLLOWS, val) + if cbor_type != CBOR_NEGINT: + raise Exception("value too big for CBOR unsigned number: {0!r}".format(val)) + outb = _dumps_bignum_to_bytearray(val) + return _CBOR_TAG_NEGBIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb + + +def dumps_string(val, is_text=None, is_bytes=None): + if isinstance(val, str): + val = val.encode('utf8') + is_text = True + is_bytes = False + if (is_bytes) or not (is_text == True): + return _encode_type_num(CBOR_BYTES, len(val)) + val + return _encode_type_num(CBOR_TEXT, len(val)) + val + + +def dumps_array(arr, sort_keys=False): + head = _encode_type_num(CBOR_ARRAY, len(arr)) + parts = [dumps(x, sort_keys=sort_keys) for x in arr] + return head + b''.join(parts) + + +def dumps_dict(d, sort_keys=False): + head = _encode_type_num(CBOR_MAP, len(d)) + parts = [head] + if sort_keys: + for k in sorted(d.keys()): + v = d[k] + parts.append(dumps(k, sort_keys=sort_keys)) + parts.append(dumps(v, sort_keys=sort_keys)) + else: + for k, v in d.items(): + parts.append(dumps(k, sort_keys=sort_keys)) + parts.append(dumps(v, sort_keys=sort_keys)) + return b''.join(parts) + + +def dumps_bool(b): + if b: + return struct.pack('B', CBOR_TRUE) + return struct.pack('B', CBOR_FALSE) + + +def dumps_tag(t, sort_keys=False): + return _encode_type_num(CBOR_TAG, t.tag) + dumps(t.value, sort_keys=sort_keys) + + +def dumps(ob, sort_keys=False): + if ob is None: + return struct.pack('B', CBOR_NULL) + if isinstance(ob, bool): + return dumps_bool(ob) + if isinstance(ob, (str, bytes, bytearray)): + if isinstance(ob, bytearray): + ob = bytes(ob) + return dumps_string(ob) + if isinstance(ob, (list, tuple)): + return dumps_array(ob, sort_keys=sort_keys) + if isinstance(ob, dict): + return dumps_dict(ob, sort_keys=sort_keys) + if isinstance(ob, float): + return dumps_float(ob) + if isinstance(ob, int): + return dumps_int(ob) + if isinstance(ob, Tag): + return dumps_tag(ob, sort_keys=sort_keys) + raise Exception("don't know how to cbor serialize object of type %s" % type(ob)) + + +def dump(obj, fp, sort_keys=False): + """obj: Python object to serialize. fp: file-like object capable of .write(bytes).""" + blob = dumps(obj, sort_keys=sort_keys) + fp.write(blob) + + +class Tag(object): + def __init__(self, tag=None, value=None): + self.tag = tag + self.value = value + + def __repr__(self): + return "Tag({0!r}, {1!r})".format(self.tag, self.value) + + def __eq__(self, other): + if not isinstance(other, Tag): + return False + return (self.tag == other.tag) and (self.value == other.value) + + def __hash__(self): + return hash((self.tag, self.value)) if not isinstance(self.value, (list, dict, bytearray)) else id(self) + + +def loads(data): + """Parse CBOR bytes and return Python objects.""" + if data is None: + raise ValueError("got None for buffer to decode in loads") + if isinstance(data, (bytes, bytearray, memoryview)): + fp = BytesIO(bytes(data)) + else: + fp = data + return _loads(fp)[0] + + +def load(fp): + """Parse and return object from fp, a file-like object supporting .read(n).""" + return _loads(fp)[0] + + +_MAX_DEPTH = 100 + + +def _tag_aux(fp, tb): + bytes_read = 1 + tag = tb & CBOR_TYPE_MASK + tag_aux = tb & CBOR_INFO_BITS + if tag_aux <= 23: + aux = tag_aux + elif tag_aux == CBOR_UINT8_FOLLOWS: + data = fp.read(1) + aux = struct.unpack_from("!B", data, 0)[0] + bytes_read += 1 + elif tag_aux == CBOR_UINT16_FOLLOWS: + data = fp.read(2) + aux = struct.unpack_from("!H", data, 0)[0] + bytes_read += 2 + elif tag_aux == CBOR_UINT32_FOLLOWS: + data = fp.read(4) + aux = struct.unpack_from("!I", data, 0)[0] + bytes_read += 4 + elif tag_aux == CBOR_UINT64_FOLLOWS: + data = fp.read(8) + aux = struct.unpack_from("!Q", data, 0)[0] + bytes_read += 8 + else: + assert tag_aux == CBOR_VAR_FOLLOWS, "bogus tag {0:02x}".format(tb) + aux = None + + return tag, tag_aux, aux, bytes_read + + +def _read_byte(fp): + tb = fp.read(1) + if len(tb) == 0: + raise EOFError() + return tb[0] + + +def _loads_var_array(fp, limit, depth, returntags, bytes_read): + ob = [] + tb = _read_byte(fp) + while tb != CBOR_BREAK: + (subob, sub_len) = _loads_tb(fp, tb, limit, depth, returntags) + bytes_read += 1 + sub_len + ob.append(subob) + tb = _read_byte(fp) + return (ob, bytes_read + 1) + + +def _loads_var_map(fp, limit, depth, returntags, bytes_read): + ob = {} + tb = _read_byte(fp) + while tb != CBOR_BREAK: + (subk, sub_len) = _loads_tb(fp, tb, limit, depth, returntags) + bytes_read += 1 + sub_len + (subv, sub_len) = _loads(fp, limit, depth, returntags) + bytes_read += sub_len + ob[subk] = subv + tb = _read_byte(fp) + return (ob, bytes_read + 1) + + +def _loads_array(fp, limit, depth, returntags, aux, bytes_read): + ob = [] + for _ in range(aux): + subob, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + ob.append(subob) + return ob, bytes_read + + +def _loads_map(fp, limit, depth, returntags, aux, bytes_read): + ob = {} + for _ in range(aux): + subk, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + subv, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + ob[subk] = subv + return ob, bytes_read + + +def _loads(fp, limit=None, depth=0, returntags=False): + "return (object, bytes read)" + if depth > _MAX_DEPTH: + raise Exception("hit CBOR loads recursion depth limit") + tb = _read_byte(fp) + return _loads_tb(fp, tb, limit, depth, returntags) + + +def _loads_tb(fp, tb, limit=None, depth=0, returntags=False): + # Some special cases of CBOR_7 best handled by special struct.unpack logic here + if tb == CBOR_FLOAT16: + data = fp.read(2) + hibyte, lowbyte = struct.unpack_from("BB", data, 0) + exp = (hibyte >> 2) & 0x1F + mant = ((hibyte & 0x03) << 8) | lowbyte + if exp == 0: + val = mant * (2.0 ** -24) + elif exp == 31: + val = float('Inf') if mant == 0 else float('NaN') + else: + val = (mant + 1024.0) * (2 ** (exp - 25)) + if hibyte & 0x80: + val = -1.0 * val + return (val, 3) + elif tb == CBOR_FLOAT32: + data = fp.read(4) + pf = struct.unpack_from("!f", data, 0) + return (pf[0], 5) + elif tb == CBOR_FLOAT64: + data = fp.read(8) + pf = struct.unpack_from("!d", data, 0) + return (pf[0], 9) + + tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb) + + if tag == CBOR_UINT: + return (aux, bytes_read) + elif tag == CBOR_NEGINT: + return (-1 - aux, bytes_read) + elif tag == CBOR_BYTES: + ob, subpos = loads_bytes(fp, aux) + return (ob, bytes_read + subpos) + elif tag == CBOR_TEXT: + raw, subpos = loads_bytes(fp, aux, btag=CBOR_TEXT) + ob = raw.decode('utf8') + return (ob, bytes_read + subpos) + elif tag == CBOR_ARRAY: + if aux is None: + return _loads_var_array(fp, limit, depth, returntags, bytes_read) + return _loads_array(fp, limit, depth, returntags, aux, bytes_read) + elif tag == CBOR_MAP: + if aux is None: + return _loads_var_map(fp, limit, depth, returntags, bytes_read) + return _loads_map(fp, limit, depth, returntags, aux, bytes_read) + elif tag == CBOR_TAG: + ob, subpos = _loads(fp, limit, depth + 1, returntags) + bytes_read += subpos + if returntags: + ob = Tag(aux, ob) + else: + ob = tagify(ob, aux) + return ob, bytes_read + elif tag == CBOR_7: + if tb == CBOR_TRUE: + return (True, bytes_read) + if tb == CBOR_FALSE: + return (False, bytes_read) + if tb == CBOR_NULL: + return (None, bytes_read) + if tb == CBOR_UNDEFINED: + return (None, bytes_read) + raise ValueError("unknown cbor tag 7 byte: {:02x}".format(tb)) + + +def loads_bytes(fp, aux, btag=CBOR_BYTES): + if aux is not None: + ob = fp.read(aux) + return (ob, aux) + chunklist = [] + total_bytes_read = 0 + while True: + tb = fp.read(1)[0] + if tb == CBOR_BREAK: + total_bytes_read += 1 + break + tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb) + assert tag == btag, 'variable length value contains unexpected component' + ob = fp.read(aux) + chunklist.append(ob) + total_bytes_read += bytes_read + aux + return (b''.join(chunklist), total_bytes_read) + + +def _bytes_to_biguint(bs): + out = 0 + for ch in bs: + out = out << 8 + out = out | ch + return out + + +def tagify(ob, aux): + if aux == CBOR_TAG_DATE_STRING: + # RFC3339 date string parsing not implemented; return as Tag. + return Tag(aux, ob) + if aux == CBOR_TAG_DATE_ARRAY: + return datetime.datetime.fromtimestamp(ob, tz=datetime.timezone.utc) + if aux == CBOR_TAG_BIGNUM: + return _bytes_to_biguint(ob) + if aux == CBOR_TAG_NEGBIGNUM: + return -1 - _bytes_to_biguint(ob) + if aux == CBOR_TAG_REGEX: + return re.compile(ob) + return Tag(aux, ob) + + +def encode(obj, sort_keys=False): + return dumps(obj, sort_keys=sort_keys) + + +def decode(data): + return loads(data) diff --git a/nomadnet/vendor/quotes.py b/nomadnet/vendor/quotes.py new file mode 100644 index 0000000..4c87571 --- /dev/null +++ b/nomadnet/vendor/quotes.py @@ -0,0 +1,7 @@ +quotes = [ + ("I want the wisdom that wise men revere. I want more.", "Faithless"), + ("That's enough entropy for you my friend", "Unknown"), + ("Any time two people connect online, it's financed by a third person who believes they can manipulate the first two", "Jaron Lanier"), + ("The landscape of the future is set, but how one will march across it is not determined", "Terence McKenna") + ("Freedom originates in the division of power, despotism in its concentration.", "John Acton") +] \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..587992d --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +import sys +import setuptools + +exec(open("nomadnet/_version.py", "r").read()) + +with open("README.md", "r") as fh: + long_description = fh.read() + +if "--getversion" in sys.argv: + print(__version__, end="") + exit(0) + +package_data = { +"": [ + "examples/messageboard/*", + ] +} + +setuptools.setup( + name="nomadnet", + version=__version__, + author="Mark Qvist", + author_email="mark@unsigned.io", + description="Communicate Freely", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/markqvist/nomadnet", + packages=setuptools.find_packages(), + package_data=package_data, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + entry_points= { + 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] + }, + install_requires=["rns>=1.3.2", "lxmf>=1.0.0", "urwid>=2.6.16", "qrcode"], + python_requires=">=3.8", +) diff --git a/tools/colorgen.py b/tools/colorgen.py new file mode 100644 index 0000000..0ff636a --- /dev/null +++ b/tools/colorgen.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Color Table Generator with Perceptual Normalization + +Generates HSL color tables with optional normalization to uniform perceptual +intensity (CIELAB L*) followed by global lightness scaling. +""" + +import argparse +import colorsys +import math +import sys + + +def hsl_to_rgb(h, s, l): + """Convert HSL (0-360, 0-100, 0-100) to RGB (0.0-1.0).""" + h_norm = (h % 360) / 360.0 + s_norm = max(0, min(100, s)) / 100.0 + l_norm = max(0, min(100, l)) / 100.0 + r, g, b = colorsys.hls_to_rgb(h_norm, l_norm, s_norm) + return (r, g, b) + + +def rgb_to_hex(r, g, b): + """Convert RGB 0.0-1.0 to lowercase hex string.""" + r = max(0, min(1, r)) + g = max(0, min(1, g)) + b = max(0, min(1, b)) + return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" + + +# CIELAB conversion functions +def srgb_to_linear(c): + if c <= 0.04045: + return c / 12.92 + return ((c + 0.055) / 1.055) ** 2.4 + + +def linear_to_srgb(c): + if c <= 0.0031308: + return c * 12.92 + return 1.055 * (c ** (1/2.4)) - 0.055 + + +def rgb_to_lab(r, g, b): + """Convert sRGB (0-1) to CIELAB (L: 0-100, a,b: ~-128 to 127). D65 illuminant.""" + # sRGB to Linear + r_lin = srgb_to_linear(r) + g_lin = srgb_to_linear(g) + b_lin = srgb_to_linear(b) + + # Linear to XYZ (D65) + X = 0.4124564 * r_lin + 0.3575761 * g_lin + 0.1804375 * b_lin + Y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin + Z = 0.0193339 * r_lin + 0.1191920 * g_lin + 0.9503041 * b_lin + + # XYZ to LAB + Xn, Yn, Zn = 95.047, 100.0, 108.883 + delta = 6/29 + + def f(t): + if t > delta**3: + return t ** (1/3) + return t / (3 * delta**2) + 4/29 + + L = 116 * f(Y / Yn) - 16 + a = 500 * (f(X / Xn) - f(Y / Yn)) + b_val = 200 * (f(Y / Yn) - f(Z / Zn)) + + return (L, a, b_val) + + +def lab_to_rgb(L, a, b_val): + """Convert CIELAB to sRGB (0-1).""" + Xn, Yn, Zn = 95.047, 100.0, 108.883 + delta = 6/29 + + def inv_f(y): + if y > delta: + return y ** 3 + return 3 * delta**2 * (y - 4/29) + + L_adj = (L + 16) / 116 + X = Xn * inv_f(L_adj + a / 500) + Y = Yn * inv_f(L_adj) + Z = Zn * inv_f(L_adj - b_val / 200) + + # XYZ to Linear RGB + r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z + g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z + b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z + + r = linear_to_srgb(r_lin) + g = linear_to_srgb(g_lin) + b = linear_to_srgb(b_lin) + + return (r, g, b) + + +def generate_colors( + hues=None, + hue_start=0.0, + hue_step=30.0, + sat_start=70.0, + sat_step=0.0, + sat_steps=1, + light_start=50.0, + light_step=0.0, + light_steps=1, + perceptual_multiplier=1.0, + use_hsl_space=False, + normalize=False, + normalize_target=None, + discards=[] +): + """ + Generate color table with optional perceptual normalization. + + Pipeline: + 1. Generate HSL variations + 2. If normalize: Convert to LAB, set all L* to target (mean or specified) + 3. Apply global perceptual multiplier to L* + 4. Convert to RGB and output hex + """ + # Determine hue list + if hues is not None: + hue_list = [float(h) % 360 for h in hues] + else: + hue_list = [] + current = hue_start % 360 + if hue_step <= 0: + raise ValueError("hue_step must be positive when using start/step mode") + while current < 360: + hue_list.append(current) + current += hue_step + + # Determine if we need LAB processing + needs_lab = normalize or (perceptual_multiplier != 1.0) or not use_hsl_space + + colors = [] + lab_colors = [] # Store as (L, a, b) tuples if processing needed + + for h in hue_list: + for i in range(sat_steps): + s = sat_start + (i * sat_step) + s = max(0, min(100, s)) + + for j in range(light_steps): + # l = light_start + (j * light_step) + l = light_start + (i * light_step) + l = max(0, min(100, l)) + + r, g, b = hsl_to_rgb(h, s, l) + + if not needs_lab: + colors.append(rgb_to_hex(r, g, b)) + else: + L, a, b_lab = rgb_to_lab(r, g, b) + lab_colors.append((L, a, b_lab)) + + filtered_colors = [] + filtered_lab_colors = [] + i = 0 + for c in colors: + i += 1 + if not str(i) in discards: + filtered_colors.append(c) + + i = 0 + for c in lab_colors: + i += 1 + if not str(i) in discards: + filtered_lab_colors.append(c) + + colors = filtered_colors + lab_colors = filtered_lab_colors + + if not needs_lab: + return colors + + # Step 2: Perceptual Normalization (equalize intensity) + if normalize: + if normalize_target is not None: + target_L = normalize_target + else: + # Calculate mean perceptual lightness + target_L = sum(lab[0] for lab in lab_colors) / len(lab_colors) + print(f"Target L*: {target_L}") + + # Clamp target to valid LAB range + target_L = max(0, min(100, target_L)) + + # Normalize all colors to target L, preserving hue (a, b) + lab_colors = [(target_L, a, b) for (L, a, b) in lab_colors] + + # Step 3: Apply final global perceptual multiplier + final_colors = [] + for L, a, b in lab_colors: + L_final = L * perceptual_multiplier + L_final = max(0, min(100, L_final)) + r, g, b = lab_to_rgb(L_final, a, b) + final_colors.append(rgb_to_hex(r, g, b)) + + return final_colors + + +def main(): + parser = argparse.ArgumentParser( + description="Generate perceptually normalized color tables", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Pipeline: HSL Generation → [Normalize to uniform L*] → Global Multiplier → Hex + +Examples: + # 32 colors normalized to same perceptual intensity, then darkened 20% + python3 colorgen.py --hue-start 0 --hue-step 11.25 --sat-start 75 --normalize --perceptual-multiplier 0.8 + + # Force all colors to exactly L*=55, then scale by 1.1 + python3 colorgen.py --hues "0,90,180,270" --normalize --normalize-target 55 --perceptual-multiplier 1.1 + + # Generate with lightness variation, normalize to mean intensity, output brightened + python3 colorgen.py --hue-start 15 --hue-step 30 --light-start 40 --light-step 10 --light-steps 3 --normalize --perceptual-multiplier 1.2 + """ + ) + + # Hue configuration + hue_group = parser.add_mutually_exclusive_group() + hue_group.add_argument("--hues", type=str, metavar="LIST", + help='Comma-separated hue values, e.g., "0,60,120"') + hue_group.add_argument("--hue-start", type=float, default=0.0, + help="Initial hue offset in degrees") + parser.add_argument("--hue-step", type=float, default=30.0, + help="Separation between hues") + + # Saturation/Lightness configuration + parser.add_argument("--sat-start", type=float, default=75.0, + help="Initial saturation (0-100)") + parser.add_argument("--sat-step", type=float, default=0.0, + help="Saturation step") + parser.add_argument("--sat-steps", type=int, default=1, + help="Number of saturation variations") + parser.add_argument("--light-start", type=float, default=50.0, + help="Initial lightness (0-100)") + parser.add_argument("--light-step", type=float, default=0.0, + help="Lightness step") + parser.add_argument("--light-steps", type=int, default=1, + help="Number of lightness variations") + + # Perceptual processing + parser.add_argument("--normalize", action="store_true", + help="Normalize all colors to same perceptual intensity (L*) before applying multiplier") + parser.add_argument("--normalize-target", type=float, default=None, metavar="L", + help="Target L* value (0-100) for normalization. Default: mean of generated colors") + parser.add_argument("--perceptual-multiplier", type=float, default=1.0, + help="Final global lightness multiplier (1.0=no change)") + parser.add_argument("--hsl-space", action="store_true", + help="Skip LAB conversion if not normalizing (faster, less accurate perceptually)") + + # Output + parser.add_argument("--python-list", action="store_true", + help="Output as Python list") + parser.add_argument("--separator", type=str, default="\n", + help="Separator between colors") + parser.add_argument("--stats", action="store_true", + help="Print generation stats to stderr") + hue_group.add_argument("--discard", type=str, metavar="LIST", + help='Discard output indexes, e.g., "20,2,33"') + + args = parser.parse_args() + + if args.hsl_space and args.normalize: + print("Warning: --hsl-space ignored because --normalize requires LAB space", file=sys.stderr) + args.hsl_space = False + + try: + if args.discard: discards = args.discard.split(",") + else: discards = [] + + colors = generate_colors( + hues=args.hues.split(",") if args.hues else None, + hue_start=args.hue_start, + hue_step=args.hue_step, + sat_start=args.sat_start, + sat_step=args.sat_step, + sat_steps=args.sat_steps, + light_start=args.light_start, + light_step=args.light_step, + light_steps=args.light_steps, + perceptual_multiplier=args.perceptual_multiplier, + use_hsl_space=args.hsl_space, + normalize=args.normalize, + normalize_target=args.normalize_target, + discards=discards, + ) + + i = 0 + # Enable to test on light background + # print(f"#!bg=fff\n") + if args.python_list: print("colors = [", end="") + for c in colors: + i+=1 + if args.python_list: + print(f"\"{c}\", ", end="") + + else: print(f"`FT{c}colored {i}`f") + + if args.python_list: print("]") + + if args.stats: + total = len(colors) + mode = "normalized + " if args.normalize else "" + print(f"# Generated {total} colors ({mode}L* × {args.perceptual_multiplier})", + file=sys.stderr) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/rrc-log-dump.py b/tools/rrc-log-dump.py new file mode 100755 index 0000000..30c9bb8 --- /dev/null +++ b/tools/rrc-log-dump.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Dump the contents of rrc on-disk room history logs. + +Usage: + rrc-log-dump.py + + may be: + - a single .log file + - a hub directory under rrc_history/ + - the rrc_history/ root (or any ancestor; .log files are found recursively) +""" + +import os +import sys +import time + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(SCRIPT_DIR, "..", "nomadnet", "vendor")) +import cbor + + +H_KIND = "k" +H_SRC = "s" +H_NICK = "n" +H_TEXT = "t" +H_TS = "ts" +H_MENTION = "m" + +KIND_COLOR = { + "msg": "", + "system": "\033[2m", + "notice": "\033[36m", + "error": "\033[31m", +} +RESET = "\033[0m" + + +def _fmt_ts(ts_ms): + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts_ms) // 1000)) + except Exception: + return "????-??-?? ??:??:??" + + +def _fmt_speaker(entry): + nick = entry.get(H_NICK) + src = entry.get(H_SRC) + src_hex = src.hex()[:12] if isinstance(src, (bytes, bytearray)) else None + if nick and src_hex: + return "%s (%s)" % (nick, src_hex) + if nick: + return nick + if src_hex: + return src_hex + return "" + + +def dump_file(path, use_color): + print("=== %s ===" % path) + count = 0 + truncated = False + with open(path, "rb") as f: + while True: + try: + entry = cbor.load(f) + except EOFError: + break + except Exception as ex: + truncated = True + print(" [corrupt record after entry %d: %s]" % (count, ex), file=sys.stderr) + break + if not isinstance(entry, dict): + print(" [skipping non-dict entry: %r]" % (entry,), file=sys.stderr) + continue + kind = entry.get(H_KIND) or "msg" + ts = _fmt_ts(entry.get(H_TS, 0)) + speaker = _fmt_speaker(entry) + text = entry.get(H_TEXT) or "" + mention = " *" if entry.get(H_MENTION) else " " + color = KIND_COLOR.get(kind, "") if use_color else "" + end = RESET if (color and use_color) else "" + line = "%s %-6s%s %-32s %s" % (ts, kind, mention, speaker, text) + print(color + line + end) + count += 1 + suffix = " (truncated)" if truncated else "" + print("--- %d entries%s ---" % (count, suffix)) + + +def main(argv): + if len(argv) != 2 or argv[1] in ("-h", "--help"): + print(__doc__, file=sys.stderr) + return 2 + target = argv[1] + use_color = sys.stdout.isatty() + if os.path.isfile(target): + dump_file(target, use_color) + return 0 + if os.path.isdir(target): + files = [] + for root, _dirs, names in os.walk(target): + for name in names: + if name.endswith(".log"): + files.append(os.path.join(root, name)) + files.sort() + if not files: + print("no .log files found under %s" % target, file=sys.stderr) + return 1 + for i, path in enumerate(files): + if i > 0: + print() + dump_file(path, use_color) + return 0 + print("not a file or directory: %s" % target, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))