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/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..caf3250
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: false
+contact_links:
+ - name: ✨ Feature Request or Idea
+ url: https://github.com/markqvist/Reticulum/discussions/new?category=ideas
+ about: Propose and discuss features and ideas
+ - name: 💬 Questions, Help & Discussion
+ about: Ask anything, or get help
+ url: https://github.com/markqvist/Reticulum/discussions/new/choose
+ - name: 📖 Read the Reticulum Manual
+ url: https://markqvist.github.io/Reticulum/manual/
+ about: The complete documentation for Reticulum
diff --git a/.github/ISSUE_TEMPLATE/🐛-bug-report.md b/.github/ISSUE_TEMPLATE/🐛-bug-report.md
new file mode 100644
index 0000000..65b492e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/🐛-bug-report.md
@@ -0,0 +1,35 @@
+---
+name: "\U0001F41B Bug Report"
+about: Report a reproducible bug
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Read the Contribution Guidelines**
+Before creating a bug report on this issue tracker, you **must** read the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md). Issues that do not follow the contribution guidelines **will be deleted without comment**.
+
+- The issue tracker is used by developers of this project. **Do not use it to ask general questions, or for support requests**.
+- Ideas and feature requests can be made on the [Discussions](https://github.com/markqvist/Reticulum/discussions). **Only** feature requests accepted by maintainers and developers are tracked and included on the issue tracker. **Do not post feature requests here**.
+- After reading the [Contribution Guidelines](https://github.com/markqvist/Reticulum/blob/master/Contributing.md), **delete this section only** (*"Read the Contribution Guidelines"*) from your bug report, **and fill in all the other sections**.
+
+**Describe the Bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Describe in detail how to reproduce the bug.
+
+**Expected Behavior**
+A clear and concise description of what you expected to happen.
+
+**Logs & Screenshots**
+Please include any relevant log output. If applicable, also add screenshots to help explain your problem.
+
+**System Information**
+- OS and version
+- Python version
+- Program version
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml
new file mode 100644
index 0000000..b013a5a
--- /dev/null
+++ b/.github/workflows/publish-container.yml
@@ -0,0 +1,42 @@
+name: Create and publish a Docker image
+
+on:
+ push:
+ branches: ['master']
+ tags: ['*.*.*']
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push-image:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
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
index d8c4674..b306b86 100755
--- a/README.md
+++ b/README.md
@@ -1,41 +1,163 @@
-Nomad Network
-==========
+# Nomad Network - Communicate Freely
-Communicate Freely.
+> [!WARNING]
+> Several hastily launched, incorrect, and in most cases entirely LLM-generated fakes of Nomad Network and associated tools are currently being circulated and marketed. Most of these "projects" violate the open source GPLv3 license that Nomad Network was published under, but claim independent ownership and license grants. Such claims or grants are **not** legally valid, and **not** recognized by the Nomad Network authors and copyright holders.
+>
+> Any claimed assertion of copyright or grant of license that such projects are making are [void grants](https://reticulum.network/manual/brandolinis.html#void-grants-legal-foundations-of-machine-generated-code). Both the violators themselves, and **all** downstream projects using the infringing derivatives, are **directly** and **personally** liable for the legal consequences.
+>
+> For more detailed information, see the relevant sections of the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the Reticulum Manual. For software and projects recognized by the Reticulum community, see the [Programs Using Reticulum](https://reticulum.network/manual/software.html) chapter.
+>
+> Do **not** use these "projects", they are a hazard to the entire ecosystem we have been carefully building over the last ten years.
-
+*This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.*
-Nomad Network is built using Reticulum, see [github.com/markqvist/Reticulum](https://github.com/markqvist/Reticulum).
+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](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, Windows and macOS.
+
+Other Nomad Network clients include:
+
+- [MeshChatX](https://meshchatx.com/)
+- [Columba (mobile)](https://github.com/torlando-tech/columba)
+- [Ren Browser](https://github.com/Quad4-Software/Ren-Browser)
## Notable Features
- Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports.
- Zero-configuration, minimal-infrastructure mesh communication
-
-## Current Status
-
-Pre-alpha. At this point Nomad Network is usable as a basic messaging client over Reticulum networks, but only the very core features have been implemented. Development is ongoing and current features being implemented are:
-
- - Propagated messaging and discussion threads
- - Connectable nodes that can host pages, files and other resources
- - Collaborative information sharing and spatial map-style "wikis"
-
-## Dependencies:
- - Python 3
- - RNS
- - LXMF
+ - Distributed and encrypted message store holds messages for offline users
+ - Connectable nodes that can host pages and files
+ - Image rendering support in terminals that support it
+ - Node-side generated pages with PHP, Python, bash or others
+ - Built-in text-based browser for interacting with contents on nodes
+ - Built-in RRC client for live, many-to-many chat
+ - An easy to use and bandwidth efficient markup language for writing pages
+ - Page caching in browser
## How do I get started?
The easiest way to install Nomad Network is via pip:
```bash
# Install Nomad Network and dependencies
-pip3 install nomadnet
+pip install nomadnet
# Run the client
nomadnet
+
+# Or alternatively run as a daemon, with no user interface
+nomadnet --daemon
+
+# List options
+nomadnet --help
```
-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 for info.
+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 [distributed backbone](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here:
+
+ - `9ce92808be498e9e05590ff27cbfdfe4` The rns.recipes forum
+ - `a4a5e861626ce97c9aa544d9ecdf6d22` rmap.world
+
+To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node.
+
+### Install on Android
+You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the [Android Installation](https://markqvist.github.io/Reticulum/manual/gettingstartedfast.html#reticulum-on-android) section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip.
+
+For a native Android application with a graphical user interface, have a look at [Sideband](https://github.com/markqvist/Sideband).
+
+
+## Tools & Extensions
+
+Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist:
+
+- [Retipedia](https://github.com/RFnexus/Retipedia)
+- [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum))
+- [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)
+
+
+## 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
+
## 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. If you want to help out, or help sponsor an audit, please do get in touch.
\ No newline at end of file
+Nomad Network is beta software, and should be considered as such. Use at your own risk and responsibility.
+
+## Screenshots
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.mu b/README.mu
new file mode 100644
index 0000000..2c95bf1
--- /dev/null
+++ b/README.mu
@@ -0,0 +1,123 @@
+>> Nomad Network - Communicate Freely
+
+Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy.
+
+Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers.
+
+Nomad Network is build on `_`!`[LXMF`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=lxmf]`!`_ and `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_, which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics.
+
+Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over `*extremely`* low-bandwidth medium, and has been succesfully used over 300bps radio links.
+
+If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_, which is available for Linux, Android, Windows and macOS.
+
+>> Notable Features
+
+ • Encrypted messaging over packet-radio, LoRa, WiFi or anything else `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_ supports.
+ • Zero-configuration, minimal-infrastructure mesh communication
+ • Distributed and encrypted message store holds messages for offline users
+ • Connectable nodes that can host pages and files
+ • Image rendering support in terminals that support it
+ • Node-side generated pages with PHP, Python, bash or others
+ • Built-in text-based browser for interacting with contents on nodes
+ • Built-in RRC client for live, many-to-many chat
+ • An easy to use and bandwidth efficient markup language for writing pages
+ • Page caching in browser
+
+>> How do I get started?
+
+The easiest way to install Nomad Network is via `B333pip`b:
+
+`B333
+`=
+# Install Nomad Network and dependencies
+pip install nomadnet
+
+# Run the client
+nomadnet
+
+# Or alternatively run as a daemon, with no user interface
+nomadnet --daemon
+
+# List options
+nomadnet --help
+`=
+`b
+
+If you are using an operating system that blocks normal user package installation via `B333pip`b, you can return `B333pip`b to normal behaviour by editing the `B333~/.config/pip/pip.conf`b file, and adding the following directive in the `B333[global]`b section:
+
+`B333
+`=
+[global]
+break-system-packages = true
+`=
+`b
+
+Alternatively, you can use the `B333pipx`b tool to install Nomad Network in an isolated environment:
+
+`B333
+`=
+# Install Nomad Network
+pipx install nomadnet
+
+# Optionally install Reticulum utilities
+pipx install rns
+
+# Optionally install standalone LXMF utilities
+pipx install lxmf
+
+# Run the client
+nomadnet
+
+# Or alternatively run as a daemon, with no user interface
+nomadnet --daemon
+
+# List options
+nomadnet --help
+`=
+`b
+
+`!Please Note`!: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again.
+
+The first time the program is running, you will be presented with the `!Guide section`!, which contains all the information you need to start using Nomad Network.
+
+To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the `_`!`[Reticulum documentation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/index.md]`!`_ for info.
+
+If you want to try Nomad Network without building your own physical network, you can connect to the `_`!`[distributed RNS backbone`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=connect-to-the-distributed-backbone]`!`_ over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here:
+
+ • `!`_`[9ce92808be498e9e05590ff27cbfdfe4]`_`! The rns.recipes forum
+ • `!`_`[a4a5e861626ce97c9aa544d9ecdf6d22]`_`! rmap.world
+
+To browse pages on a node that is not currently known, open the URL dialog in the `!Network`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!Go`! or press enter. Nomadnet will attempt to discover and connect to the requested node.
+
+>>> Install on Android
+
+You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the `_`!`[Android Installation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=reticulum-on-android]`!`_ section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip.
+
+For a native Android application with a graphical user interface, have a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_.
+
+>> Help & Discussion
+
+For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the `_`!`[rns.recipes forum`9ce92808be498e9e05590ff27cbfdfe4]`!`_.
+
+>> Support Nomad Network
+
+For this to be possible, I need your help. Please support the continued development of open, free and private communications systems by donating via one of the following channels:
+
+• `!Monero`!
+ 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w
+
+• `!Bitcoin`!
+ bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6
+
+• `!Ethereum`!
+ 0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E
+
+• `!Liberapay`!
+ `[https://liberapay.com/Reticulum/]
+
+• `!Ko-Fi`!
+ `[https://ko-fi.com/markqvist]
+
+>> Caveat Emptor
+
+Nomad Network is experimental software, and should be considered as such. Use at your own risk and responsibility.
diff --git a/docs/screenshots/1.png b/docs/screenshots/1.png
deleted file mode 100644
index 69c665f..0000000
Binary files a/docs/screenshots/1.png and /dev/null differ
diff --git a/docs/screenshots/1a.png b/docs/screenshots/1a.png
new file mode 100644
index 0000000..3df866e
Binary files /dev/null and b/docs/screenshots/1a.png differ
diff --git a/docs/screenshots/2.png b/docs/screenshots/2.png
index af754cd..2744cef 100644
Binary files a/docs/screenshots/2.png and b/docs/screenshots/2.png differ
diff --git a/docs/screenshots/3.png b/docs/screenshots/3.png
index 4cda1dd..4a8c0de 100644
Binary files a/docs/screenshots/3.png and b/docs/screenshots/3.png differ
diff --git a/docs/screenshots/4.png b/docs/screenshots/4.png
new file mode 100644
index 0000000..98a9eb5
Binary files /dev/null and b/docs/screenshots/4.png differ
diff --git a/docs/screenshots/5.png b/docs/screenshots/5.png
new file mode 100644
index 0000000..77c3c60
Binary files /dev/null and b/docs/screenshots/5.png differ
diff --git a/docs/screenshots/6.png b/docs/screenshots/6.png
new file mode 100644
index 0000000..ee38e56
Binary files /dev/null and b/docs/screenshots/6.png differ
diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py
index 8a6f623..86d26bb 100644
--- a/nomadnet/Conversation.py
+++ b/nomadnet/Conversation.py
@@ -1,32 +1,51 @@
import os
+import re
import RNS
import LXMF
import shutil
+import RNS.vendor.umsgpack as msgpack
import nomadnet
from nomadnet.Directory import DirectoryEntry
+from LXMF import display_name_from_app_data
class Conversation:
cached_conversations = {}
+ unread_conversations = {}
+ failed_conversations = {}
created_callback = None
aspect_filter = "lxmf.delivery"
+ receive_path_responses = True
@staticmethod
- def received_announce(destination_hash, announced_identity, app_data):
+ def received_announce(destination_hash, announced_identity, app_data, announce_packet_hash=None, is_path_response=False):
app = nomadnet.NomadNetworkApp.get_shared_instance()
- destination_hash_text = RNS.hexrep(destination_hash, delimit=False)
- # Check if the announced destination is in
- # our list of conversations
- if destination_hash_text in [e[0] for e in Conversation.conversation_list(app)]:
- if app.directory.find(destination_hash):
- if Conversation.created_callback != None:
- Conversation.created_callback()
- else:
+
+ if 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()
- # Add the announce to the directory announce
- # stream logger
- app.directory.announce_received(destination_hash, app_data)
+ if not is_path_response:
+ # This reformats the new v0.5.0 announce data back to the expected format
+ # for nomadnets storage and other handling functions.
+ dn = LXMF.display_name_from_app_data(app_data)
+ app_data = b""
+ if dn != None: app_data = dn.encode("utf-8")
+
+ # Add the announce to the directory announce
+ # stream logger
+ app.directory.lxmf_announce_received(destination_hash, app_data)
+
+ else:
+ RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG)
@staticmethod
def query_for_peer(source_hash):
@@ -41,7 +60,7 @@ class Conversation:
source_hash = lxmessage.destination_hash
else:
source_hash = lxmessage.source_hash
-
+
source_hash_path = RNS.hexrep(source_hash, delimit=False)
conversation_path = app.conversationpath + "/" + source_hash_path
@@ -53,10 +72,65 @@ class Conversation:
ingested_path = lxmessage.write_to_directory(conversation_path)
+ if not originator and ingested_path is not None:
+ try:
+ rssi = getattr(lxmessage, "rssi", None)
+ snr = getattr(lxmessage, "snr", None)
+ q = getattr(lxmessage, "q", None)
+ if rssi is not None or snr is not None or q is not None:
+ index = ConversationMessage.read_index(conversation_path)
+ filename = os.path.basename(ingested_path)
+ entry = index.get(filename) or {}
+ if rssi is not None: entry["rssi"] = rssi
+ if snr is not None: entry["snr"] = snr
+ if q is not None: entry["q"] = q
+ index[filename] = entry
+ with open(os.path.join(conversation_path, ".index"), "wb") as index_file:
+ index_file.write(msgpack.packb(index))
+ except Exception as e:
+ RNS.log("Error while storing reception stats for message: "+str(e), RNS.LOG_ERROR)
+
+ try:
+ ConversationMessage.extract_attachments_from_lxm(lxmessage, app)
+ except Exception as e:
+ RNS.log("Error extracting attachments: "+str(e), RNS.LOG_ERROR)
+
if RNS.hexrep(source_hash, delimit=False) in Conversation.cached_conversations:
conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)]
conversation.scan_storage()
+ 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
@@ -70,23 +144,53 @@ class Conversation:
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 = app_data.decode("utf-8")
+ display_name = LXMF.display_name_from_app_data(app_data)
if display_name == None:
sort_name = ""
else:
sort_name = display_name
-
+
trust_level = app.directory.trust_level(source_hash, display_name)
-
- entry = (source_hash_text, display_name, trust_level, sort_name)
+
+ conversation_dir = app.conversationpath + "/" + dirname
+ try:
+ last_activity = os.path.getmtime(conversation_dir)
+ except Exception:
+ last_activity = 0
+
+ entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity, failed)
conversations.append(entry)
except Exception as e:
RNS.log("Error while loading conversation "+str(dirname)+", skipping it. The contained exception was: "+str(e), RNS.LOG_ERROR)
- conversations.sort(key=lambda e: (-e[2], e[3], e[0]), reverse=False)
+ conversations.sort(key=lambda e: e[5], reverse=True)
return conversations
@@ -114,14 +218,12 @@ class Conversation:
self.source_known = False
self.source_trusted = False
self.source_blocked = False
+ self.unread = False
self.__changed_callback = None
- self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash))
-
- if self.source_identity:
- self.source_known = True
- self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
+ if not self.ensure_send_destination():
+ RNS.Transport.request_path(bytes.fromhex(source_hash))
if initiator:
if not os.path.isdir(self.messages_path):
@@ -137,11 +239,48 @@ class Conversation:
def scan_storage(self):
old_len = len(self.messages)
+ existing = {}
+ for msg in self.messages:
+ existing[msg.file_path] = msg
+
+ index = ConversationMessage.read_index(self.messages_path)
+
self.messages = []
for filename in os.listdir(self.messages_path):
if len(filename) == RNS.Identity.HASHLENGTH//8*2:
message_path = self.messages_path + "/" + filename
- self.messages.append(ConversationMessage(message_path))
+ if message_path in existing:
+ old_msg = existing[message_path]
+ try:
+ current_mtime = os.path.getmtime(message_path)
+ if current_mtime > old_msg.sort_timestamp:
+ old_msg._cached_state = None
+ old_msg._cached_method = None
+ old_msg.sort_timestamp = current_mtime
+ except Exception:
+ pass
+ self.messages.append(old_msg)
+ else:
+ msg = ConversationMessage(message_path)
+ if filename in index:
+ msg.restore_from_index(index[filename])
+ self.messages.append(msg)
+
+ new_len = len(self.messages)
+
+ needs_index_update = []
+ for msg in self.messages:
+ filename = os.path.basename(msg.file_path)
+ if msg._cached_state is not None:
+ if filename not in index:
+ needs_index_update.append(msg)
+ elif "content" not in index[filename]:
+ needs_index_update.append(msg)
+ if needs_index_update:
+ ConversationMessage.write_index(self.messages_path, needs_index_update)
+
+ if new_len > old_len:
+ self.unread = True
if self.__changed_callback != None:
self.__changed_callback(self)
@@ -156,63 +295,297 @@ class Conversation:
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=""):
- if self.send_destination:
+ def ensure_send_destination(self):
+ if self.send_destination == None:
+ self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash))
+
+ if self.source_identity:
+ self.source_known = True
+ self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
+
+ return self.send_destination != None
+
+ def send(self, content="", title="", fields=None):
+ if self.ensure_send_destination():
dest = self.send_destination
source = self.app.lxmf_destination
- lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=LXMF.LXMessage.DIRECT)
+ desired_method = LXMF.LXMessage.DIRECT
+ preferred = self.app.directory.preferred_delivery(dest.hash)
+ if preferred == DirectoryEntry.PROPAGATED:
+ if self.app.message_router.get_outbound_propagation_node() != None:
+ desired_method = LXMF.LXMessage.PROPAGATED
+ elif preferred == DirectoryEntry.OPPORTUNISTIC:
+ desired_method = LXMF.LXMessage.OPPORTUNISTIC
+ else:
+ if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None:
+ RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG)
+ desired_method = LXMF.LXMessage.OPPORTUNISTIC
+
+ 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. Requesting path...", RNS.LOG_VERBOSE)
+ RNS.Transport.request_path(bytes.fromhex(self.source_hash))
+ return False
+
+ def paper_output(self, content="", title="", mode="print_qr"):
+ if self.ensure_send_destination():
+ try:
+ dest = self.send_destination
+ source = self.app.lxmf_destination
+ desired_method = LXMF.LXMessage.PAPER
+
+ lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method)
+
+ if mode == "print_qr":
+ qr_code = lxm.as_qr()
+ qr_tmp_path = self.app.tmpfilespath+"/"+str(RNS.hexrep(lxm.hash, delimit=False))
+ qr_code.save(qr_tmp_path)
+
+ print_result = self.app.print_file(qr_tmp_path)
+ os.unlink(qr_tmp_path)
+
+ if print_result:
+ message_path = Conversation.ingest(lxm, self.app, originator=True)
+ self.messages.append(ConversationMessage(message_path))
+
+ return print_result
+
+ elif mode == "save_qr":
+ qr_code = lxm.as_qr()
+ qr_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".png")
+ qr_code.save(qr_save_path)
+ message_path = Conversation.ingest(lxm, self.app, originator=True)
+ self.messages.append(ConversationMessage(message_path))
+ return qr_save_path
+
+ elif mode == "save_uri":
+ lxm_uri = lxm.as_uri()+"\n"
+ uri_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".txt")
+ with open(uri_save_path, "wb") as f:
+ f.write(lxm_uri.encode("utf-8"))
+
+ message_path = Conversation.ingest(lxm, self.app, originator=True)
+ self.messages.append(ConversationMessage(message_path))
+ return uri_save_path
+
+ elif mode == "return_uri":
+ return lxm.as_uri()
+
+ except Exception as e:
+ RNS.log("An error occurred while generating paper message, the contained exception was: "+str(e), RNS.LOG_ERROR)
+ return False
+
else:
RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE)
return False
def message_notification(self, message):
- message_path = Conversation.ingest(message, self.app, originator=True)
+ 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
- if self.source_identity:
- if self.source_identity.app_data:
- # TODO: Sanitise for viewing
- string += " | "+self.source.source_identity.app_data.decode("utf-8")
+ # TODO: Remove this
+ # if self.source_identity:
+ # if self.source_identity.app_data:
+ # # TODO: Sanitise for viewing, or just clean this
+ # string += " | "+self.source_identity.app_data.decode("utf-8")
return string
class ConversationMessage:
+ # Sentinel marking the renderer as not yet cached, so that a real
+ # cached value of None (no renderer field) can be told apart from a
+ # cache miss without forcing a disk load.
+ _RENDERER_UNSET = object()
+
def __init__(self, file_path):
self.file_path = file_path
self.loaded = False
self.timestamp = None
self.lxm = None
+ self._cached_renderer = ConversationMessage._RENDERER_UNSET
+ self._cached_hash = None
+ self._cached_state = None
+ self._cached_title = None
+ self._cached_content = None
+ self._cached_source_hash = None
+ self._cached_transport_encrypted = None
+ self._cached_transport_encryption = None
+ self._cached_signature_validated = None
+ self._cached_unverified_reason = None
+ self._cached_method = None
+ self._cached_has_attachments = None
+ self._cached_attachment_names = None
+ self._cached_rssi = None
+ self._cached_snr = None
+ self._cached_q = None
+
+ self.sort_timestamp = os.path.getmtime(file_path) if os.path.isfile(file_path) else 0
+
+ filename = os.path.basename(file_path)
+ if len(filename) == RNS.Identity.HASHLENGTH//8*2:
+ try:
+ self._cached_hash = bytes.fromhex(filename)
+ except Exception:
+ pass
+
def load(self):
try:
- self.lxm = LXMF.LXMessage.unpack_from_file(open(self.file_path, "rb"))
+ with open(self.file_path, "rb") as lxm_file:
+ self.lxm = LXMF.LXMessage.unpack_from_file(lxm_file)
self.loaded = True
self.timestamp = self.lxm.timestamp
+ self.sort_timestamp = os.path.getmtime(self.file_path)
- if self.lxm.state > LXMF.LXMessage.DRAFT and self.lxm.state < LXMF.LXMessage.SENT:
+
+ if self.lxm.state > LXMF.LXMessage.GENERATING and self.lxm.state < LXMF.LXMessage.SENT:
found = False
+
for pending in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_outbound:
if pending.hash == self.lxm.hash:
found = True
+ for pending_id in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_deferred_stamps:
+ if pending_id == self.lxm.hash:
+ found = True
+
if not found:
self.lxm.state = LXMF.LXMessage.FAILED
+ if self._cached_hash is None:
+ self._cached_hash = self.lxm.hash
+ self._cached_state = self.lxm.state
+ if self._cached_source_hash is None:
+ self._cached_source_hash = self.lxm.source_hash
+ self._cached_transport_encrypted = self.lxm.transport_encrypted
+ self._cached_transport_encryption = self.lxm.transport_encryption
+ if self._cached_signature_validated is None:
+ self._cached_signature_validated = self.lxm.signature_validated
+ self._cached_method = self.lxm.method
+ if self._cached_rssi is None:
+ self._cached_rssi = getattr(self.lxm, "rssi", None)
+ if self._cached_snr is None:
+ self._cached_snr = getattr(self.lxm, "snr", None)
+ if self._cached_q is None:
+ self._cached_q = getattr(self.lxm, "q", None)
+ if hasattr(self.lxm, "get_fields"):
+ _fields = self.lxm.get_fields()
+ if _fields and isinstance(_fields, dict) and LXMF.FIELD_RENDERER in _fields:
+ self._cached_renderer = _fields[LXMF.FIELD_RENDERER]
+ else:
+ self._cached_renderer = None
+ else:
+ self._cached_renderer = None
+ if self._cached_unverified_reason is None and hasattr(self.lxm, "unverified_reason"):
+ self._cached_unverified_reason = self.lxm.unverified_reason
+ self._cached_title = self.lxm.title_as_string()
+ self._cached_content = self.lxm.content_as_string()
+
+ if self._cached_has_attachments is None:
+ found_in_fields = False
+ fields = None
+ if hasattr(self.lxm, "get_fields"):
+ fields = self.lxm.get_fields()
+ if fields and isinstance(fields, dict):
+ found_in_fields = (
+ LXMF.FIELD_FILE_ATTACHMENTS in fields
+ or LXMF.FIELD_IMAGE in fields
+ or LXMF.FIELD_AUDIO in fields
+ )
+ if found_in_fields:
+ names = []
+ file_atts = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, [])
+ for idx, att in enumerate(file_atts):
+ if isinstance(att, list) and len(att) >= 2:
+ size = len(att[1]) if isinstance(att[1], bytes) else 0
+ safe = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx))
+ names.append(("file", safe, size))
+ if LXMF.FIELD_IMAGE in fields:
+ fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE])
+ if data:
+ size = len(data)
+ ext = ConversationMessage._ext_from_media_format(fmt, data)
+ safe = ConversationMessage.safe_attachment_name("image"+ext, fallback="image")
+ names.append(("file", safe, size))
+ if LXMF.FIELD_AUDIO in fields:
+ fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO])
+ if data:
+ size = len(data)
+ ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True)
+ safe = ConversationMessage.safe_attachment_name("audio"+ext, fallback="audio")
+ names.append(("file", safe, size))
+ self._cached_has_attachments = True
+ self._cached_attachment_names = names
+
+ if not found_in_fields:
+ att_dir = self._attachment_dir()
+ if att_dir and os.path.isdir(att_dir):
+ manifest = self._read_attachment_manifest(att_dir)
+ if manifest and "files" in manifest and len(manifest["files"]) > 0:
+ names = []
+ for entry in manifest["files"]:
+ names.append(("file", entry["name"], entry.get("size", 0)))
+ self._cached_has_attachments = True
+ self._cached_attachment_names = names
+ else:
+ self._cached_has_attachments = False
+ self._cached_attachment_names = []
+ else:
+ self._cached_has_attachments = False
+ self._cached_attachment_names = []
+
+ try:
+ app = nomadnet.NomadNetworkApp.get_shared_instance()
+ ConversationMessage.extract_attachments_from_lxm(self.lxm, app)
+ ConversationMessage.strip_attachments_from_file(self.file_path, app)
+ except Exception:
+ pass
+
except Exception as e:
RNS.log("Error while loading LXMF message "+str(self.file_path)+" from disk. The contained exception was: "+str(e), RNS.LOG_ERROR)
@@ -226,60 +599,470 @@ class ConversationMessage:
os.unlink(self.file_path)
def get_timestamp(self):
+ if self.timestamp is not None:
+ return self.timestamp
if not self.loaded:
self.load()
-
return self.timestamp
def get_title(self):
+ if self._cached_title is not None:
+ return self._cached_title
if not self.loaded:
self.load()
-
- return self.lxm.title_as_string()
+ return self._cached_title if self._cached_title is not None else ""
def get_content(self):
+ if self._cached_content is not None:
+ return self._cached_content
if not self.loaded:
self.load()
-
- return self.lxm.content_as_string()
+ return self._cached_content if self._cached_content is not None else ""
def get_hash(self):
+ if self._cached_hash is not None:
+ return self._cached_hash
if not self.loaded:
self.load()
-
- return self.lxm.hash
+ return self._cached_hash
def get_state(self):
+ if self._cached_state is not None:
+ return self._cached_state
if not self.loaded:
self.load()
+ return self._cached_state
- return self.lxm.state
+ def get_method(self):
+ if self._cached_method is not None:
+ return self._cached_method
+ if not self.loaded:
+ self.load()
+ return self._cached_method
+
+ def get_rssi(self):
+ return self._cached_rssi
+
+ def get_snr(self):
+ return self._cached_snr
+
+ def get_q(self):
+ return self._cached_q
def get_transport_encryption(self):
+ if self._cached_transport_encryption is not None:
+ return self._cached_transport_encryption
if not self.loaded:
self.load()
-
- return self.lxm.transport_encryption
+ return self._cached_transport_encryption
def get_transport_encrypted(self):
+ if self._cached_transport_encrypted is not None:
+ return self._cached_transport_encrypted
if not self.loaded:
self.load()
-
- return self.lxm.transport_encrypted
+ return self._cached_transport_encrypted
def signature_validated(self):
+ if self._cached_signature_validated is not None:
+ return self._cached_signature_validated
if not self.loaded:
self.load()
-
- return self.lxm.signature_validated
+ return self._cached_signature_validated
def get_signature_description(self):
if self.signature_validated():
return "Signature Verified"
else:
- if self.lxm.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN:
+ reason = self._cached_unverified_reason
+ if reason == LXMF.LXMessage.SOURCE_UNKNOWN:
return "Unknown Origin"
- elif self.lxm.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID:
+ elif reason == LXMF.LXMessage.SIGNATURE_INVALID:
return "Invalid Signature"
else:
- return "Unknown signature validation failure"
\ No newline at end of file
+ return "Unknown signature validation failure"
+
+ def get_fields(self):
+ if not self.loaded:
+ self.load()
+
+ if self.lxm and hasattr(self.lxm, "get_fields"):
+ fields = self.lxm.get_fields()
+ if fields and isinstance(fields, dict):
+ return fields
+
+ return {}
+
+ def content_renderer(self):
+ if self._cached_renderer is not ConversationMessage._RENDERER_UNSET:
+ return self._cached_renderer
+ if not self.loaded:
+ self.load()
+ # load() sets _cached_renderer on success; if it failed, lxm is None
+ if self._cached_renderer is ConversationMessage._RENDERER_UNSET:
+ return None
+ return self._cached_renderer
+
+ def has_attachments(self):
+ if self._cached_has_attachments is not None:
+ return self._cached_has_attachments
+ fields = self.get_fields()
+ return (
+ LXMF.FIELD_FILE_ATTACHMENTS in fields
+ or LXMF.FIELD_IMAGE in fields
+ or LXMF.FIELD_AUDIO in fields
+ )
+
+ def get_file_attachments(self):
+ att_dir = self._attachment_dir()
+ if att_dir and os.path.isdir(att_dir):
+ manifest = self._read_attachment_manifest(att_dir)
+ if manifest and "files" in manifest:
+ result = []
+ for entry in manifest["files"]:
+ fpath = os.path.join(att_dir, entry["stored_name"])
+ if os.path.isfile(fpath):
+ with open(fpath, "rb") as f:
+ result.append([entry["name"], f.read()])
+ return result
+
+ fields = self.get_fields()
+ return fields.get(LXMF.FIELD_FILE_ATTACHMENTS, [])
+
+ def get_image(self):
+ att_dir = self._attachment_dir()
+ if att_dir and os.path.isdir(att_dir):
+ fpath = os.path.join(att_dir, "image")
+ if os.path.isfile(fpath):
+ with open(fpath, "rb") as f:
+ return f.read()
+
+ fields = self.get_fields()
+ return fields.get(LXMF.FIELD_IMAGE, None)
+
+ def get_audio(self):
+ att_dir = self._attachment_dir()
+ if att_dir and os.path.isdir(att_dir):
+ fpath = os.path.join(att_dir, "audio")
+ if os.path.isfile(fpath):
+ with open(fpath, "rb") as f:
+ return f.read()
+
+ fields = self.get_fields()
+ return fields.get(LXMF.FIELD_AUDIO, None)
+
+ def get_attachment_file_path(self, field_type, field_index=0):
+ att_dir = self._attachment_dir()
+ if att_dir and os.path.isdir(att_dir):
+ manifest = self._read_attachment_manifest(att_dir)
+ if manifest and "files" in manifest and field_index < len(manifest["files"]):
+ return os.path.join(att_dir, manifest["files"][field_index]["stored_name"])
+ # Fallback for old extraction format
+ if field_type == "image":
+ fpath = os.path.join(att_dir, "image")
+ if os.path.isfile(fpath):
+ return fpath
+ elif field_type == "audio":
+ fpath = os.path.join(att_dir, "audio")
+ if os.path.isfile(fpath):
+ return fpath
+ return None
+
+ def _attachment_dir(self):
+ try:
+ app = nomadnet.NomadNetworkApp.get_shared_instance()
+ msg_hash = self.get_hash()
+ if msg_hash:
+ return os.path.join(app.attachmentpath, RNS.hexrep(msg_hash, delimit=False))
+ except Exception:
+ pass
+ return None
+
+ def _read_attachment_manifest(self, att_dir):
+ manifest_path = os.path.join(att_dir, "manifest")
+ if os.path.isfile(manifest_path):
+ try:
+ with open(manifest_path, "rb") as f:
+ manifest = msgpack.unpackb(f.read(), raw=False)
+ safe_files = []
+ for idx, entry in enumerate(manifest.get("files", [])):
+ if not isinstance(entry, dict):
+ continue
+ entry["name"] = ConversationMessage.safe_attachment_name(entry.get("name"), fallback="attachment_"+str(idx))
+ stored = entry.get("stored_name")
+ if not isinstance(stored, str) or not re.fullmatch(r"file_\d+", stored):
+ continue
+ safe_files.append(entry)
+ manifest["files"] = safe_files
+ return manifest
+
+ except Exception as e:
+ RNS.log(f"Error loading attachment manifest: {e}")
+ return None
+
+ return None
+
+ @staticmethod
+ def extract_attachments_from_lxm(lxmessage, app):
+ if not hasattr(lxmessage, "get_fields"):
+ return
+ fields = lxmessage.get_fields()
+ if not fields or not isinstance(fields, dict):
+ return
+
+ has_any = (
+ LXMF.FIELD_FILE_ATTACHMENTS in fields
+ or LXMF.FIELD_IMAGE in fields
+ or LXMF.FIELD_AUDIO in fields
+ )
+ if not has_any:
+ return
+
+ msg_hash_hex = RNS.hexrep(lxmessage.hash, delimit=False)
+ att_dir = os.path.join(app.attachmentpath, msg_hash_hex)
+ if os.path.isdir(att_dir):
+ return
+
+ os.makedirs(att_dir)
+ manifest = {"files": []}
+
+ file_attachments = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, [])
+ if file_attachments:
+ for idx, att in enumerate(file_attachments):
+ try:
+ if isinstance(att, list) and len(att) >= 2:
+ filename = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx))
+ data = att[1] if isinstance(att[1], bytes) else b""
+ stored_name = "file_"+str(idx)
+ with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data)
+ manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)})
+
+ except Exception as e:
+ RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR)
+ continue
+
+ if LXMF.FIELD_IMAGE in fields:
+ fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE])
+ if data:
+ ext = ConversationMessage._ext_from_media_format(fmt, data)
+ filename = ConversationMessage.safe_attachment_name("image" + ext, fallback="image")
+ stored_name = "file_"+str(len(manifest["files"]))
+ with open(os.path.join(att_dir, stored_name), "wb") as f:
+ f.write(data)
+ manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)})
+
+ if LXMF.FIELD_AUDIO in fields:
+ fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO])
+ if data:
+ ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True)
+ filename = ConversationMessage.safe_attachment_name("audio" + ext, fallback="audio")
+ stored_name = "file_"+str(len(manifest["files"]))
+ with open(os.path.join(att_dir, stored_name), "wb") as f:
+ f.write(data)
+ manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)})
+
+ with open(os.path.join(att_dir, "manifest"), "wb") as f:
+ f.write(msgpack.packb(manifest))
+
+ @staticmethod
+ def safe_attachment_name(name, fallback="attachment"):
+ try:
+ if isinstance(name, bytes):
+ name = name.decode("utf-8", errors="replace")
+ elif not isinstance(name, str):
+ name = str(name) if name is not None else ""
+ except Exception:
+ name = ""
+ name = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", name)
+ parts = re.split(r"[/\\]", name)
+ name = parts[-1] if parts else ""
+ if ":" in name:
+ name = name.split(":")[-1]
+ name = name.lstrip(".")
+ if not name or name in (".", ".."):
+ return fallback
+ if len(name) > 200:
+ base, ext = os.path.splitext(name)
+ ext = ext[:16]
+ name = base[:200 - len(ext)] + ext
+ return name
+
+ @staticmethod
+ def _unpack_media_field(field_data):
+ """Normalize FIELD_IMAGE/FIELD_AUDIO which can be raw bytes or [format, bytes].
+ Format element can be a string ('webp') or integer (LXMF audio mode constant)."""
+ if isinstance(field_data, bytes):
+ return None, field_data
+ elif isinstance(field_data, list) and len(field_data) >= 2 and isinstance(field_data[1], bytes):
+ return field_data[0], field_data[1]
+ return None, None
+
+ @staticmethod
+ def _detect_image_ext(data):
+ if not isinstance(data, bytes) or len(data) < 12:
+ return ".bin"
+ if data[:8] == b'\x89PNG\r\n\x1a\n':
+ return ".png"
+ elif data[:3] == b'\xff\xd8\xff':
+ return ".jpg"
+ elif data[:4] == b'GIF8':
+ return ".gif"
+ elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
+ return ".webp"
+ elif data[:4] == b'\x00\x00\x00\x1c' or data[:4] == b'\x00\x00\x00\x18':
+ return ".heic"
+ return ".bin"
+
+ @staticmethod
+ def _detect_audio_ext(data):
+ if not isinstance(data, bytes) or len(data) < 12:
+ return ".bin"
+ if data[:4] == b'OggS':
+ return ".ogg"
+ elif data[:2] == b'\xff\xfb' or data[:3] == b'ID3':
+ return ".mp3"
+ elif data[:4] == b'RIFF' and data[8:12] == b'WAVE':
+ return ".wav"
+ elif data[:4] == b'fLaC':
+ return ".flac"
+ return ".bin"
+
+ @staticmethod
+ def _ext_from_media_format(fmt, data, is_audio=False):
+ if isinstance(fmt, str) and len(fmt) > 0:
+ safe = re.sub(r"[^A-Za-z0-9]", "", fmt).lower()[:8]
+ if safe:
+ return "." + safe
+ if isinstance(fmt, int) and is_audio:
+ if fmt >= 16 and fmt <= 25:
+ return ".ogg"
+ elif fmt >= 1 and fmt <= 9:
+ return ".c2"
+ if is_audio:
+ return ConversationMessage._detect_audio_ext(data)
+ return ConversationMessage._detect_image_ext(data)
+
+ @staticmethod
+ def strip_attachments_from_file(file_path, app):
+ try:
+ with open(file_path, "rb") as f:
+ container = msgpack.unpackb(f.read(), strict_map_key=False)
+
+ lxmf_bytes = container[b"lxmf_bytes"] if b"lxmf_bytes" in container else container.get("lxmf_bytes")
+ if lxmf_bytes is None:
+ return
+
+ dest_len = LXMF.LXMessage.DESTINATION_LENGTH
+ sig_len = LXMF.LXMessage.SIGNATURE_LENGTH
+ header_len = 2 * dest_len + sig_len
+
+ header = lxmf_bytes[:header_len]
+ payload_bytes = lxmf_bytes[header_len:]
+ payload = msgpack.unpackb(payload_bytes, strict_map_key=False)
+
+ if len(payload) < 4 or not isinstance(payload[3], dict):
+ return
+
+ fields = payload[3]
+ attachment_keys = [LXMF.FIELD_FILE_ATTACHMENTS, LXMF.FIELD_IMAGE, LXMF.FIELD_AUDIO]
+ if not any(k in fields for k in attachment_keys):
+ return
+
+ # Compute message hash matching LXMF's logic
+ if len(payload) > 4:
+ hash_payload = msgpack.packb(payload[:4])
+ else:
+ hash_payload = payload_bytes
+ msg_hash = RNS.Identity.full_hash(lxmf_bytes[:2*dest_len] + hash_payload)
+ msg_hash_hex = RNS.hexrep(msg_hash, delimit=False)
+
+ att_dir = os.path.join(app.attachmentpath, msg_hash_hex)
+ if not os.path.isdir(att_dir):
+ return
+
+ for k in attachment_keys:
+ if k in fields:
+ del fields[k]
+
+ new_lxmf_bytes = header + msgpack.packb(payload)
+ key = b"lxmf_bytes" if b"lxmf_bytes" in container else "lxmf_bytes"
+ container[key] = new_lxmf_bytes
+
+ with open(file_path, "wb") as f:
+ f.write(msgpack.packb(container))
+
+ except Exception as e:
+ RNS.log("Error stripping attachments from LXM file: "+str(e), RNS.LOG_ERROR)
+
+ def to_index_entry(self):
+ return {
+ "timestamp": self.timestamp,
+ "sort_timestamp": self.sort_timestamp,
+ "state": self._cached_state,
+ "title": self._cached_title,
+ "content": self._cached_content,
+ "source_hash": self._cached_source_hash,
+ "transport_encrypted": self._cached_transport_encrypted,
+ "transport_encryption": self._cached_transport_encryption,
+ "signature_validated": self._cached_signature_validated,
+ "unverified_reason": self._cached_unverified_reason,
+ "method": self._cached_method,
+ "renderer": None if self._cached_renderer is ConversationMessage._RENDERER_UNSET else self._cached_renderer,
+ "has_attachments": self._cached_has_attachments,
+ "attachment_names": self._cached_attachment_names,
+ "rssi": self._cached_rssi,
+ "snr": self._cached_snr,
+ "q": self._cached_q,
+ }
+
+ def restore_from_index(self, entry):
+ self.timestamp = entry.get("timestamp")
+ self.sort_timestamp = entry.get("sort_timestamp", self.sort_timestamp)
+ self._cached_state = entry.get("state")
+ self._cached_title = entry.get("title")
+ self._cached_content = entry.get("content")
+ self._cached_source_hash = entry.get("source_hash")
+ self._cached_transport_encrypted = entry.get("transport_encrypted")
+ self._cached_transport_encryption = entry.get("transport_encryption")
+ self._cached_signature_validated = entry.get("signature_validated")
+ self._cached_unverified_reason = entry.get("unverified_reason")
+ self._cached_method = entry.get("method")
+ self._cached_renderer = entry.get("renderer", ConversationMessage._RENDERER_UNSET)
+ self._cached_has_attachments = entry.get("has_attachments")
+ self._cached_attachment_names = entry.get("attachment_names")
+ self._cached_rssi = entry.get("rssi")
+ self._cached_snr = entry.get("snr")
+ self._cached_q = entry.get("q")
+
+ @staticmethod
+ def read_index(conversation_path):
+ index_path = os.path.join(conversation_path, ".index")
+ if os.path.isfile(index_path):
+ try:
+ with open(index_path, "rb") as f:
+ return msgpack.unpackb(f.read(), raw=False)
+ except Exception:
+ pass
+ return {}
+
+ @staticmethod
+ def write_index(conversation_path, messages):
+ index_path = os.path.join(conversation_path, ".index")
+ index = {}
+ if os.path.isfile(index_path):
+ try:
+ with open(index_path, "rb") as f:
+ index = msgpack.unpackb(f.read(), raw=False)
+ except Exception:
+ index = {}
+
+ for msg in messages:
+ if msg._cached_state is not None:
+ filename = os.path.basename(msg.file_path)
+ index[filename] = msg.to_index_entry()
+
+ try:
+ with open(index_path, "wb") as f:
+ f.write(msgpack.packb(index))
+ except Exception as e:
+ RNS.log("Error writing conversation index: "+str(e), RNS.LOG_ERROR)
diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py
index fce3ddd..8727b7d 100644
--- a/nomadnet/Directory.py
+++ b/nomadnet/Directory.py
@@ -2,98 +2,356 @@ 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, PN_META_IMPL_NAME, PN_META_VERSION
+from nomadnet.util import strip_modifiers
+from nomadnet.util import sanitize_name
+
+class PNAnnounceHandler:
+ def __init__(self, owner):
+ self.aspect_filter = "lxmf.propagation"
+ self.owner = owner
+
+ def received_announce(self, destination_hash, announced_identity, app_data):
+ try:
+ if pn_announce_data_is_valid(app_data):
+ data = msgpack.unpackb(app_data)
+
+ if data[2] == True:
+ metadata = data[6]
+ if not PN_META_IMPL_NAME in metadata or not PN_META_VERSION in metadata: impl_str = ""
+ else: impl_str = f" running {metadata[PN_META_IMPL_NAME]} {metadata[PN_META_VERSION]}"
+ RNS.log(f"Received active propagation node announce from {RNS.prettyhexrep(destination_hash)}{impl_str}", RNS.LOG_DEBUG)
+
+ associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity)
+ associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity)
+
+ self.owner.app.directory.pn_announce_received(destination_hash, app_data, associated_peer, associated_node)
+ self.owner.app.autoselect_propagation_node()
+
+ except Exception as e:
+ RNS.log("Error while evaluating propagation node announce, ignoring announce.", RNS.LOG_DEBUG)
+ RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG)
+
class Directory:
ANNOUNCE_STREAM_MAXLENGTH = 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.announce_stream = []
+ self._node_announces = []
+ self._peer_announces = []
+ self._pn_announces = []
self.app = app
+ self.announce_lock = threading.Lock()
self.load_from_disk()
+ self.pn_announce_handler = PNAnnounceHandler(self)
+ RNS.Transport.register_announce_handler(self.pn_announce_handler)
+
+
def save_to_disk(self):
try:
packed_list = []
for source_hash in self.directory_entries:
e = self.directory_entries[source_hash]
- packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node))
+ packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank, getattr(e, "notes", "")))
directory = {
"entry_list": packed_list,
"announce_stream": self.announce_stream
}
- file = open(self.app.directorypath, "wb")
- file.write(msgpack.packb(directory))
- file.close()
+ with open(self.app.directorypath, "wb") as file:
+ file.write(msgpack.packb(directory))
+
except Exception as e:
RNS.log("Could not write directory to disk. Then contained exception was: "+str(e), RNS.LOG_ERROR)
def load_from_disk(self):
if os.path.isfile(self.app.directorypath):
try:
- file = open(self.app.directorypath, "rb")
- unpacked_directory = msgpack.unpackb(file.read())
+ with open(self.app.directorypath, "rb") as file:
+ unpacked_directory = msgpack.unpackb(file.read())
unpacked_list = unpacked_directory["entry_list"]
- file.close()
entries = {}
for e in unpacked_list:
+
+ if e[1] == None:
+ e[1] = "Undefined"
+
if len(e) > 3:
hosts_node = e[3]
else:
hosts_node = False
- entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node)
+ 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
- self.announce_stream = unpacked_directory["announce_stream"]
+
+ astream = unpacked_directory["announce_stream"]
+ self._node_announces = [e for e in astream if e[3] == "node"]
+ self._peer_announces = [e for e in astream if e[3] == "peer"]
+ self._pn_announces = [e for e in astream if e[3] == "pn"]
except Exception as e:
RNS.log("Could not load directory from disk. The contained exception was: "+str(e), RNS.LOG_ERROR)
- def announce_received(self, source_hash, app_data):
- timestamp = time.time()
- self.announce_stream.insert(0, (timestamp, source_hash, app_data))
- while len(self.announce_stream) > Directory.ANNOUNCE_STREAM_MAXLENGTH:
- self.announce_stream.pop()
+ 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 self.directory_entries[source_hash].display_name
+ return strip_modifiers(self.directory_entries[source_hash].display_name)
else:
return None
- def simplest_display_str(self, source_hash):
+ def simplest_display_str(self, source_hash, san=True):
+ def s(name):
+ if self.app.config["textui"]["sanitize_names"] and san: return sanitize_name(name)
+ else: return strip_modifiers(name)
+
trust_level = self.trust_level(source_hash)
if trust_level == DirectoryEntry.WARNING or trust_level == DirectoryEntry.UNTRUSTED:
- return "<"+RNS.hexrep(source_hash, delimit=False)+">"
+ if source_hash in self.directory_entries:
+ dn = s(self.directory_entries[source_hash].display_name)
+ if not dn: return RNS.prettyhexrep(source_hash)
+ else: return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">"
+
+ else: return "<"+RNS.hexrep(source_hash, delimit=False)+">"
+
else:
if source_hash in self.directory_entries:
- return self.directory_entries[source_hash].display_name
- else:
- return "<"+RNS.hexrep(source_hash, delimit=False)+">"
+ dn = s(self.directory_entries[source_hash].display_name)
+ if not dn: return RNS.prettyhexrep(source_hash)
+ else: return dn
+
+ else: return "<"+RNS.hexrep(source_hash, delimit=False)+">"
+
+ def alleged_display_str(self, source_hash):
+ if source_hash in self.directory_entries:
+ return 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:
- for entry in self.directory_entries:
- e = self.directory_entries[entry]
- if e.display_name == announced_display_name:
- if e.source_hash != source_hash:
- return DirectoryEntry.WARNING
+ if not self.directory_entries[source_hash].trust_level == DirectoryEntry.TRUSTED:
+ for entry in self.directory_entries:
+ e = self.directory_entries[entry]
+ if e.display_name == announced_display_name:
+ if e.source_hash != source_hash:
+ return DirectoryEntry.WARNING
return self.directory_entries[source_hash].trust_level
else:
return DirectoryEntry.UNKNOWN
+ def pn_trust_level(self, source_hash):
+ recalled_identity = RNS.Identity.recall(source_hash)
+ if recalled_identity != None:
+ associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", recalled_identity)
+ return self.trust_level(associated_node)
+
+ def sort_rank(self, source_hash):
+ if source_hash in self.directory_entries:
+ return self.directory_entries[source_hash].sort_rank
+ else:
+ return None
+
+ def preferred_delivery(self, source_hash):
+ if source_hash in self.directory_entries:
+ return self.directory_entries[source_hash].preferred_delivery
+ 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)
@@ -116,6 +374,18 @@ class Directory:
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:
@@ -123,6 +393,7 @@ class Directory:
if e.hosts_node:
node_list.append(e)
+ node_list.sort(key = lambda e: (e.sort_rank if e.sort_rank != None else 2^32, DirectoryEntry.TRUSTED-e.trust_level, e.display_name if e.display_name != None else "_"))
return node_list
def number_of_known_nodes(self):
@@ -144,14 +415,24 @@ class DirectoryEntry:
UNKNOWN = 0x02
TRUSTED = 0xFF
- def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False):
+ DIRECT = 0x01
+ PROPAGATED = 0x02
+ OPPORTUNISTIC = 0x03
+
+ def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None, notes=None):
if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8:
self.source_hash = source_hash
self.display_name = display_name
- if display_name == None:
- display_name = source_hash
+ self.sort_rank = sort_rank
+
+ if preferred_delivery == None:
+ self.preferred_delivery = DirectoryEntry.DIRECT
+ 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")
\ No newline at end of file
+ raise TypeError("Attempt to add invalid source hash to directory")
diff --git a/nomadnet/InterfaceProfiles.py b/nomadnet/InterfaceProfiles.py
new file mode 100644
index 0000000..254380b
--- /dev/null
+++ b/nomadnet/InterfaceProfiles.py
@@ -0,0 +1,240 @@
+import os
+import threading
+
+import RNS
+import RNS.vendor.umsgpack as msgpack
+from nomadnet.util import write_config_atomic
+
+
+class InterfaceProfiles:
+ VERSION = 1
+
+ def __init__(self, app):
+ self.app = app
+ self.path = os.path.join(app.storagepath, "interface_profiles.msgpack")
+ self._lock = threading.Lock()
+ self.profiles = []
+ self.load()
+
+ def load(self):
+ self.profiles = []
+ self.default_members = None
+ try:
+ if not os.path.isfile(self.path):
+ return
+ with open(self.path, "rb") as f:
+ data = msgpack.unpackb(f.read())
+ if not isinstance(data, dict):
+ return
+ clean = []
+ seen = set()
+ for p in data.get("profiles") or []:
+ if not isinstance(p, dict):
+ continue
+ pid = str(p.get("id") or "")
+ if not pid or pid in seen:
+ continue
+ members = [str(m) for m in (p.get("members") or []) if isinstance(m, (str, bytes))]
+ clean.append({"id": pid, "name": str(p.get("name") or ""), "members": members})
+ seen.add(pid)
+ self.profiles = clean
+ dm = data.get("default_members")
+ self.default_members = [str(m) for m in dm] if isinstance(dm, list) else None
+ except Exception as e:
+ RNS.log("Could not load interface profiles: "+str(e), RNS.LOG_ERROR)
+ self.profiles = []
+ self.default_members = None
+
+ def save(self):
+ with self._lock:
+ tmp = self.path + ".tmp"
+ try:
+ payload = {
+ "version": InterfaceProfiles.VERSION,
+ "profiles": [{"id": p["id"], "name": p["name"], "members": list(p["members"])} for p in self.profiles],
+ "default_members": self.default_members,
+ }
+ with open(tmp, "wb") as f:
+ f.write(msgpack.packb(payload))
+ f.flush()
+ try: os.fsync(f.fileno())
+ except Exception: pass
+ os.replace(tmp, self.path)
+ except Exception as e:
+ RNS.log("Could not save interface profiles: "+str(e), RNS.LOG_ERROR)
+ try: os.remove(tmp)
+ except Exception: pass
+
+ def _interfaces(self):
+ try:
+ return self.app.rns.config["interfaces"]
+ except Exception:
+ return {}
+
+ def interface_names(self):
+ try:
+ return list(self._interfaces().keys())
+ except Exception:
+ return []
+
+ def _is_enabled(self, iface):
+ return str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \
+ str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0')
+
+ def enabled_set(self):
+ interfaces = self._interfaces()
+ result = set()
+ for name in interfaces:
+ try:
+ if self._is_enabled(interfaces[name]):
+ result.add(name)
+ except Exception:
+ pass
+ return result
+
+ def get(self, pid):
+ for p in self.profiles:
+ if p["id"] == pid:
+ return p
+ return None
+
+ def members_existing(self, p):
+ existing = set(self.interface_names())
+ return set(m for m in p["members"] if m in existing)
+
+ def active_profile_id(self):
+ enabled = self.enabled_set()
+ for p in self.profiles:
+ if self.members_existing(p) == enabled:
+ return p["id"]
+ return None
+
+ def profiles_for(self, iface_name):
+ return [p for p in self.profiles if iface_name in p["members"]]
+
+ def label_for(self, iface_name):
+ names = [p["name"] or p["id"] for p in self.profiles_for(iface_name)]
+ return ", ".join(names) if names else None
+
+ def member_count(self, p):
+ return len(self.members_existing(p))
+
+ def _new_id(self):
+ while True:
+ pid = os.urandom(4).hex()
+ if not self.get(pid):
+ return pid
+
+ def create(self, name):
+ pid = self._new_id()
+ self.profiles.append({"id": pid, "name": name or "Profile", "members": []})
+ self.save()
+ return pid
+
+ def save_current_as_profile(self, name):
+ pid = self.create(name)
+ self.set_members(pid, sorted(self.enabled_set()))
+ return pid
+
+ def rename(self, pid, name):
+ p = self.get(pid)
+ if p is not None and name:
+ p["name"] = name
+ self.save()
+
+ def delete(self, pid):
+ self.profiles = [p for p in self.profiles if p["id"] != pid]
+ self.save()
+
+ def set_members(self, pid, members):
+ p = self.get(pid)
+ if p is not None:
+ valid = set(self.interface_names())
+ seen = set()
+ ordered = []
+ for m in members:
+ if m in valid and m not in seen:
+ ordered.append(m)
+ seen.add(m)
+ p["members"] = ordered
+ self.save()
+
+ def set_interface_profiles(self, iface_name, profile_ids):
+ pset = set(profile_ids)
+ for p in self.profiles:
+ if p["id"] in pset:
+ if iface_name not in p["members"]:
+ p["members"].append(iface_name)
+ elif iface_name in p["members"]:
+ p["members"] = [m for m in p["members"] if m != iface_name]
+ self.save()
+
+ def _apply_members(self, members):
+ existing = set(self.interface_names())
+ members = set(m for m in members if m in existing)
+ interfaces = self._interfaces()
+ try:
+ for name in interfaces:
+ value = "true" if name in members else "false"
+ interfaces[name]["interface_enabled"] = value
+ if "enabled" in interfaces[name]:
+ interfaces[name]["enabled"] = value
+ write_config_atomic(self.app.rns.config)
+ return True
+ except Exception as e:
+ RNS.log("Could not apply interface profile: "+str(e), RNS.LOG_ERROR)
+ return False
+
+ def select_profile(self, pid):
+ p = self.get(pid)
+ if p is None:
+ return False
+ return self._apply_members(self.members_existing(p))
+
+ def select_default(self):
+ if self.default_members is None:
+ return False
+ return self._apply_members(set(self.default_members))
+
+ def update_default_if_custom(self):
+ # Remember the live enabled-set as "Default" whenever it matches no named
+ # profile -- i.e. it's the user's manual setup. Only relevant once profiles exist.
+ if not self.profiles:
+ return
+ enabled = self.enabled_set()
+ matches_named = any(self.members_existing(p) == enabled for p in self.profiles)
+ if matches_named and self.default_members is not None:
+ return
+ new_default = sorted(enabled)
+ if self.default_members != new_default:
+ self.default_members = new_default
+ self.save()
+
+ def remove_interface(self, iface_name):
+ changed = False
+ for p in self.profiles:
+ if iface_name in p["members"]:
+ p["members"] = [m for m in p["members"] if m != iface_name]
+ changed = True
+ if changed:
+ self.save()
+
+ def rename_interface(self, old_name, new_name):
+ changed = False
+ for p in self.profiles:
+ if old_name in p["members"]:
+ p["members"] = [new_name if m == old_name else m for m in p["members"]]
+ changed = True
+ if changed:
+ self.save()
+
+ def prune(self):
+ valid = set(self.interface_names())
+ changed = False
+ for p in self.profiles:
+ pruned = [m for m in p["members"] if m in valid]
+ if len(pruned) != len(p["members"]):
+ p["members"] = pruned
+ changed = True
+ if changed:
+ self.save()
diff --git a/nomadnet/Node.py b/nomadnet/Node.py
new file mode 100644
index 0000000..31d4a26
--- /dev/null
+++ b/nomadnet/Node.py
@@ -0,0 +1,401 @@
+import os
+import sys
+
+import RNS
+import time
+import threading
+import hashlib
+import shutil
+import subprocess
+import RNS.vendor.umsgpack as msgpack
+
+from RNS.Utilities.rngit.media import convert_file_to_webp
+
+class Node:
+ JOB_INTERVAL = 5
+ SETTINGS_FLUSH_INTERVAL = 60
+ START_ANNOUNCE_DELAY = 6
+
+ def __init__(self, app):
+ 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.settings_dirty = False
+ self.conversion_cache_lock = threading.Lock()
+ self.last_settings_flush = time.time()
+ self.app_data = None
+ self.name = self.app.node_name
+
+ self.register_pages()
+ self.register_files()
+ self.register_media()
+
+ self.destination.set_link_established_callback(self.peer_connected)
+
+ 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_media(self):
+ self.destination.register_request_handler("/media", response_generator = self.serve_media,
+ allow = RNS.Destination.ALLOW_ALL)
+
+ def register_files(self):
+ # TODO: Deregister previously registered files
+ # that no longer exist.
+ self.servedfiles = []
+ self.scan_files(self.app.filespath)
+
+ 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:
+ if not file.endswith(".allowed"):
+ self.servedfiles.append(base_path+"/"+file)
+
+ for directory in directories:
+ self.scan_files(base_path+"/"+directory)
+
+ def serve_page(self, path, data, request_id, 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.settings_dirty = True
+
+ except Exception as e:
+ RNS.log("Could not increase served page request count", RNS.LOG_ERROR)
+
+ file_path = path.replace("/page", self.app.pagespath, 1)
+ request_allowed = self.request_allowed(file_path, remote_identity)
+
+ 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
+
+ NATIVE_MEDIA_EXTS = [".webp"]
+ MEDIA_EXTS = [".webp", ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"]
+ CONVERSION_QUALITY = 85
+ CONVERSION_MAX_DIMENSION = 1200
+ CONVERSION_CACHE_MAX_FILES = 256
+ CONVERSION_CACHE_MAX_BYTES = 192*1024*1024
+ def serve_media(self, path, data, request_id, link_id, remote_identity, requested_at):
+ if not type(data) == dict: return None
+ if not "path" in data: return None
+ if not "key" in data: return None
+
+ jail = os.path.normpath(self.app.pagespath)
+ media_path = os.path.join(self.app.pagespath, data["path"].replace("/media/", "").lstrip("/"))
+ media_path = os.path.normpath(media_path)
+ base_name = os.path.basename(media_path)
+ base_ext = os.path.splitext(base_name)[-1]
+
+ if not base_ext.lower() in self.MEDIA_EXTS:
+ RNS.log(f"Invalid media request type: {media_path}, must be in {self.MEDIA_EXTS}", RNS.LOG_DEBUG)
+ return False
+
+ if not media_path.startswith(jail+os.sep):
+ RNS.log(f"Invalid media request path: {media_path}", RNS.LOG_DEBUG)
+ return False
+
+ if len(media_path) > 512:
+ RNS.log(f"Invalid media request path length: {len(media_path)}", RNS.LOG_DEBUG)
+ return False
+
+ RNS.log(f"Media request {RNS.prettyhexrep(request_id)} for: {media_path}", RNS.LOG_VERBOSE)
+ try:
+ self.app.peer_settings["served_media_requests"] += 1
+ self.settings_dirty = True
+ except Exception as e: RNS.log("Could not increase served page request count", RNS.LOG_ERROR)
+
+ request_allowed = self.request_allowed(media_path, remote_identity)
+ try:
+ if request_allowed:
+ if not base_ext.lower() in self.NATIVE_MEDIA_EXTS:
+ converted_path = self.convert_media_to_webp(media_path)
+ if converted_path is False:
+ RNS.log(f"Could not convert {media_path} to WebP", RNS.LOG_DEBUG)
+ return False
+ file_path = converted_path
+ file_name = os.path.splitext(base_name)[0]+".webp"
+ else:
+ file_path = media_path
+ file_name = base_name
+
+ RNS.log(f"Serving media: {file_path}", RNS.LOG_VERBOSE)
+ return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}]
+
+ else:
+ RNS.log("Request denied", RNS.LOG_VERBOSE)
+ return False
+
+ except Exception as e:
+ RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(media_path), RNS.LOG_ERROR)
+ RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
+ return False
+
+ def convert_media_to_webp(self, media_path, quality = CONVERSION_QUALITY, max_dimension = CONVERSION_MAX_DIMENSION):
+ try:
+ hasher = hashlib.sha256()
+ with open(media_path, "rb") as fh:
+ for chunk in iter(lambda: fh.read(65536), b""): hasher.update(chunk)
+ source_hash = hasher.hexdigest()
+ except Exception as e:
+ RNS.log(f"Could not read media source for conversion: {media_path}", RNS.LOG_WARNING)
+ return False
+
+ if quality == None: quality = Node.CONVERSION_QUALITY
+ if max_dimension == None: max_dimension = Node.CONVERSION_MAX_DIMENSION
+
+ cache_name = f"{source_hash}.q{quality}.d{max_dimension if max_dimension is not None else 0}.webp"
+ cache_path = os.path.join(self.app.convcachepath, cache_name)
+ return self.__cached_conversion(media_path, cache_path, quality, max_dimension)
+
+ def __cached_conversion(self, media_path, cache_path, quality, max_dimension):
+ with self.conversion_cache_lock:
+ if os.path.isfile(cache_path):
+ RNS.log(f"Using cached media conversion for: {media_path}", RNS.LOG_VERBOSE)
+ try: os.utime(cache_path, None)
+ except Exception: pass
+ return cache_path
+
+ try:
+ os.makedirs(self.app.convcachepath, exist_ok=True)
+ converted_path = convert_file_to_webp(media_path, quality=quality, max_dimension=max_dimension)
+ if converted_path is False: return False
+
+ shutil.move(converted_path, cache_path)
+ self._prune_conversion_cache()
+ RNS.log(f"Converted media: {media_path} -> {cache_path}", RNS.LOG_VERBOSE)
+ return cache_path
+
+ except Exception as e:
+ RNS.log(f"Error during media conversion of {media_path}: {e}", RNS.LOG_WARNING)
+ return False
+
+ def _prune_conversion_cache(self):
+ try:
+ entries = []
+ for name in os.listdir(self.app.convcachepath):
+ path = os.path.join(self.app.convcachepath, name)
+ try:
+ st = os.stat(path)
+ if st.st_size > 0:
+ entries.append((st.st_mtime, st.st_size, path))
+ except OSError: pass
+
+ total = sum(e[1] for e in entries)
+ entries.sort()
+ while len(entries) > Node.CONVERSION_CACHE_MAX_FILES or total > Node.CONVERSION_CACHE_MAX_BYTES:
+ _, size, path = entries.pop(0)
+ total -= size
+ try: os.unlink(path)
+ except OSError: pass
+ except OSError: pass
+
+ def request_allowed(self, file_path, remote_identity):
+ if file_path.lower().endswith(".allowed"): return False
+ allowed_path = file_path+".allowed"
+ if not os.path.isfile(allowed_path): return True
+
+ allowed_list = []
+ try:
+ if os.access(allowed_path, os.X_OK):
+ allowed_input = subprocess.run([allowed_path], stdout=subprocess.PIPE).stdout
+ else:
+ with open(allowed_path, "rb") as fh:
+ allowed_input = fh.read()
+
+ for hash_str in allowed_input.splitlines():
+ if len(hash_str) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2:
+ try:
+ allowed_list.append(bytes.fromhex(hash_str.decode("utf-8")))
+ except Exception as e:
+ RNS.log("Could not decode RNS Identity hash from: "+str(hash_str), RNS.LOG_DEBUG)
+ RNS.log("The contained exception was: "+str(e), RNS.LOG_DEBUG)
+
+ except Exception as e:
+ RNS.log("Error while fetching list of allowed identities for request: "+str(e), RNS.LOG_ERROR)
+
+ if hasattr(remote_identity, "hash") and remote_identity.hash in allowed_list:
+ return True
+
+ RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE)
+ return False
+
+ def serve_file(self, path, data, request_id, remote_identity, requested_at):
+ RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE)
+ try:
+ self.app.peer_settings["served_file_requests"] += 1
+ self.settings_dirty = True
+
+ except Exception as e:
+ RNS.log("Could not increase served file request count", RNS.LOG_ERROR)
+
+ file_path = path.replace("/file", self.app.filespath, 1)
+ file_name = os.path.basename(file_path)
+ if not self.request_allowed(file_path, remote_identity):
+ RNS.log("Request denied", RNS.LOG_VERBOSE)
+ return DEFAULT_NOTALLOWED.encode("utf-8")
+
+ try:
+ RNS.log("Serving file: "+file_path, RNS.LOG_VERBOSE)
+ 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)
+ if not self.app.disable_propagation: 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()
+
+ if self.settings_dirty and now > self.last_settings_flush + Node.SETTINGS_FLUSH_INTERVAL:
+ self.settings_dirty = False
+ self.last_settings_flush = now
+ try: self.app.save_peer_settings()
+ except Exception as e: RNS.log("Could not save peer settings: "+str(e), RNS.LOG_ERROR)
+
+ time.sleep(self.job_interval)
+
+ 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.settings_dirty = True
+
+ 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
index 51df19a..b32a4e2 100644
--- a/nomadnet/NomadNetworkApp.py
+++ b/nomadnet/NomadNetworkApp.py
@@ -1,29 +1,85 @@
import os
+import io
+import sys
import time
+import shlex
import atexit
+import threading
+import traceback
+import subprocess
+import contextlib
+import tempfile
import RNS
import LXMF
import nomadnet
+from nomadnet.Directory import DirectoryEntry
+from datetime import datetime
+
import RNS.vendor.umsgpack as msgpack
from ._version import __version__
-from .vendor.configobj import ConfigObj
+from RNS.vendor.configobj import ConfigObj
class NomadNetworkApp:
time_format = "%Y-%m-%d %H:%M:%S"
_shared_instance = None
- configdir = os.path.expanduser("~")+"/.nomadnetwork"
+ userdir = os.path.expanduser("~")
+ if os.path.isdir("/etc/nomadnetwork") and os.path.isfile("/etc/nomadnetwork/config"):
+ configdir = "/etc/nomadnetwork"
+ elif os.path.isdir(userdir+"/.config/nomadnetwork") and os.path.isfile(userdir+"/.config/nomadnetwork/config"):
+ configdir = userdir+"/.config/nomadnetwork"
+ else:
+ configdir = userdir+"/.nomadnetwork"
+
+ START_ANNOUNCE_DELAY = 3
def exit_handler(self):
- RNS.log("Nomad Network Client exit handler executing...", RNS.LOG_VERBOSE)
+ self.should_run_jobs = False
+
RNS.log("Saving directory...", RNS.LOG_VERBOSE)
self.directory.save_to_disk()
+
+ if hasattr(self, "rrc") and self.rrc is not None:
+ try:
+ self.rrc.save()
+ self.rrc.shutdown()
+ except Exception:
+ pass
+
+ if hasattr(self.ui, "restore_ixon"):
+ if self.ui.restore_ixon:
+ try:
+ os.system("stty ixon")
+
+ except Exception as e:
+ RNS.log("Could not restore flow control sequences. The contained exception was: "+str(e), RNS.LOG_WARNING)
+
+ if hasattr(self.ui, "restore_palette"):
+ if self.ui.restore_palette:
+ try:
+ self.ui.screen.write("\x1b]104\x07")
+
+ except Exception as e:
+ RNS.log("Could not restore terminal color palette. The contained exception was: "+str(e), RNS.LOG_WARNING)
+
RNS.log("Nomad Network Client exiting now", RNS.LOG_VERBOSE)
- def __init__(self, configdir = None, rnsconfigdir = None):
+ 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
@@ -31,25 +87,90 @@ class NomadNetworkApp:
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.imagecachepath = self.configdir+"/storage/cache/images/browser"
+ self.convcachepath = self.configdir+"/storage/cache/images/converted_node"
+ self.dispcachepath = self.configdir+"/storage/cache/images/converted_disp"
self.resourcepath = self.configdir+"/storage/resources"
self.conversationpath = self.configdir+"/storage/conversations"
self.directorypath = self.configdir+"/storage/directory"
self.peersettingspath = self.configdir+"/storage/peersettings"
+ self.tmpfilespath = self.configdir+"/storage/tmp"
+ self.attachmentpath = self.configdir+"/storage/attachments"
+
+ self.pagespath = self.configdir+"/storage/pages"
+ self.filespath = self.configdir+"/storage/files"
+ self.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.peer_settings_lock = threading.Lock()
+ self.defer_jobs = 90
+ self.page_refresh_interval = 0
+ self.file_refresh_interval = 0
+
+ self.static_peers = []
+ self.peer_announce_at_start = True
+ self.try_propagation_on_fail = True
+ self.disable_propagation = True
+ self.notify_on_new_message = True
+ self.compose_markdown = True
+
+ self.lxmf_max_propagation_size = None
+ self.lxmf_max_sync_size = None
+ self.lxmf_max_incoming_size = None
+ self.node_propagation_cost = LXMF.LXMRouter.PROPAGATION_COST
+
+ self.periodic_lxmf_sync = True
+ self.lxmf_sync_interval = 360*60
+ self.lxmf_sync_limit = 8
+ self.compact_stream = False
+
+ self.required_stamp_cost = None
+ self.accept_invalid_stamps = False
+
+ self.rrc_history_per_room_cap = 500
+ self.rrc_filter_loaded_history = True
+ self.rrc_max_accepted_resource_size = 256*1024
+ self.rrc_ephemeral_notices = 600
+ self.rrc_nick_colors = True
+ self.rrc_nick_colors_theme = None
+ self.rrc_mention_color = None
+ self.rrc_color_mention_timestamps = True
+ self.rrc_ui_justify_msgs = True
+ self.rrc_ui_space_msgs = False
+ self.rrc_ui_render_markdown = True
+ self.rrc_ui_render_micron = True
+ self.rrc_show_gutters = False
+ self.rrc_enable_esoterics = False
if not os.path.isdir(self.storagepath):
os.makedirs(self.storagepath)
@@ -57,12 +178,38 @@ class NomadNetworkApp:
if not os.path.isdir(self.cachepath):
os.makedirs(self.cachepath)
+ if not os.path.isdir(self.imagecachepath):
+ os.makedirs(self.imagecachepath)
+
+ if not os.path.isdir(self.convcachepath):
+ os.makedirs(self.convcachepath)
+
+ if not os.path.isdir(self.dispcachepath):
+ os.makedirs(self.dispcachepath)
+
if not os.path.isdir(self.resourcepath):
os.makedirs(self.resourcepath)
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)
@@ -78,9 +225,19 @@ class NomadNetworkApp:
RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR)
nomadnet.panic()
else:
+ if not os.path.isdir(self.examplespath):
+ try:
+ import shutil
+ examplespath = os.path.join(os.path.dirname(__file__), "examples")
+ shutil.copytree(examplespath, self.examplespath, ignore=shutil.ignore_patterns("__pycache__"))
+
+ except Exception as e:
+ RNS.log("Could not copy examples into the "+self.examplespath+" directory.", RNS.LOG_ERROR)
+ RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
+
RNS.log("Could not load config file, creating default configuration file...")
self.createDefaultConfig()
-
+ self.firstrun = True
if os.path.isfile(self.identitypath):
try:
@@ -105,22 +262,65 @@ class NomadNetworkApp:
RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR)
nomadnet.panic()
- if os.path.isfile(self.peersettingspath):
+ self.peer_settings = None
+ self.peer_settings_notice = None
+ for path in (self.peersettingspath, self.peersettingspath+".bak"):
+ if self.peer_settings != None or not os.path.isfile(path): continue
try:
- file = open(self.peersettingspath, "rb")
- self.peer_settings = msgpack.unpackb(file.read())
- file.close()
+ with open(path, "rb") as file:
+ self.peer_settings = msgpack.unpackb(file.read())
+
+ if not "node_last_announce" in self.peer_settings:
+ self.peer_settings["node_last_announce"] = None
+
+ 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_media_requests" in self.peer_settings:
+ self.peer_settings["served_media_requests"] = 0
+
+ if not "served_file_requests" in self.peer_settings:
+ self.peer_settings["served_file_requests"] = 0
+
+ if not "quit_confirm" in self.peer_settings:
+ self.peer_settings["quit_confirm"] = True
+
+ self.peer_settings["announce_interval"] = self.announce_interval
+ if path != self.peersettingspath:
+ self.peer_settings_notice = "Peer settings were restored from the last backup.\nCheck your display name and propagation node."
+
except Exception as e:
- RNS.log("Could not load local peer settings from "+self.peersettingspath, RNS.LOG_ERROR)
- RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR)
- nomadnet.panic()
- else:
+ RNS.log(f"Could not load local peer settings from {path}", RNS.LOG_ERROR)
+ RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
+ RNS.log(f"Moving the corrupt file to {path}.corrupt", RNS.LOG_ERROR)
+ try: os.replace(path, path+".corrupt")
+ except Exception: pass
+ self.peer_settings = None
+ self.peer_settings_notice = "Peer settings were corrupt and have been reset.\nCheck your display name and propagation node."
+
+ if self.peer_settings == None:
try:
- RNS.log("No peer settings file found, creating new...")
+ RNS.log("Creating new peer settings file...")
self.peer_settings = {
- "display_name": "",
- "announce_interval": None,
+ "display_name": "Anonymous Peer",
+ "announce_interval": self.announce_interval,
"last_announce": None,
+ "node_last_announce": None,
+ "propagation_node": None,
+ "quit_confirm": True,
+ "last_lxmf_sync": 0,
+ "node_connects": 0,
+ "served_page_requests": 0,
+ "served_file_requests": 0
}
self.save_peer_settings()
RNS.log("Created new peer settings file")
@@ -129,14 +329,61 @@ class NomadNetworkApp:
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()
- atexit.register(self.exit_handler)
+ 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()
+
+ from nomadnet.InterfaceProfiles import InterfaceProfiles
+ self.interface_profiles = InterfaceProfiles(self)
+
+ static_peers = []
+ for static_peer in self.static_peers:
+ try:
+ dh = bytes.fromhex(static_peer)
+ if len(dh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8:
+ raise ValueError("Invalid destination length")
+ static_peers.append(dh)
+ except Exception as e:
+ RNS.log(f"Could not decode static peer destination hash {static_peer}: {e}", RNS.LOG_ERROR)
+
+ self.message_router = LXMF.LXMRouter(
+ identity = self.identity, storagepath = self.storagepath, autopeer = True,
+ propagation_limit = self.lxmf_max_propagation_size, sync_limit = self.lxmf_max_sync_size, delivery_limit = self.lxmf_max_incoming_size,
+ max_peers = self.max_peers, static_peers = static_peers, propagation_cost=self.node_propagation_cost
+ )
- self.message_router = LXMF.LXMRouter()
self.message_router.register_delivery_callback(self.lxmf_delivery)
- self.lxmf_destination = self.message_router.register_delivery_identity(self.identity, display_name=self.peer_settings["display_name"])
- self.lxmf_destination.set_default_app_data(self.get_display_name_bytes)
+ 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,
@@ -145,13 +392,113 @@ class NomadNetworkApp:
app_data=None
)
- RNS.Transport.register_announce_handler(nomadnet.Conversation)
-
RNS.log("LXMF Router ready to receive on: "+RNS.prettyhexrep(self.lxmf_destination.hash))
- self.directory = nomadnet.Directory.Directory(self)
+ 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)
- nomadnet.ui.spawn(self.uimode)
+ 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
@@ -164,15 +511,181 @@ class NomadNetworkApp:
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):
- self.lxmf_destination.announce()
+ RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE)
+ self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost)
+ self.lxmf_destination.display_name = self.peer_settings["display_name"]
+ self.message_router.announce(self.lxmf_destination.hash)
self.peer_settings["last_announce"] = time.time()
self.save_peer_settings()
+ def autoselect_propagation_node(self):
+ selected_node = None
+
+ if "propagation_node" in self.peer_settings and self.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:
+ if self.message_router.get_outbound_propagation_node() != selected_node:
+ pn_name_str = ""
+ RNS.log("Selecting "+RNS.prettyhexrep(selected_node)+pn_name_str+" as default LXMF propagation node", RNS.LOG_DEBUG)
+ self.message_router.set_outbound_propagation_node(selected_node)
+
+ def get_user_selected_propagation_node(self):
+ if "propagation_node" in self.peer_settings:
+ 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):
- file = open(self.peersettingspath, "wb")
- file.write(msgpack.packb(self.peer_settings))
- file.close()
+ with self.peer_settings_lock:
+ fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.peersettingspath), prefix=".peersettings.")
+ try:
+ with os.fdopen(fd, "wb") as file: file.write(msgpack.packb(self.peer_settings))
+ if os.path.isfile(self.peersettingspath): os.replace(self.peersettingspath, self.peersettingspath+".bak")
+ os.replace(tmp_path, self.peersettingspath)
+ except Exception:
+ try: os.unlink(tmp_path)
+ except Exception: pass
+ raise
def lxmf_delivery(self, message):
time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp))
@@ -187,13 +700,135 @@ class NomadNetworkApp:
nomadnet.Conversation.ingest(message, self)
+ if self.notify_on_new_message:
+ self.notify_message_recieved()
+
+ if self.should_print(message):
+ self.print_message(message)
+
+ def should_print(self, message):
+ if self.print_messages:
+ if self.print_all_messages:
+ return True
+
+ else:
+ source_hash_text = RNS.hexrep(message.source_hash, delimit=False)
+
+ if self.print_trusted_messages:
+ trust_level = self.directory.trust_level(message.source_hash)
+ if trust_level == DirectoryEntry.TRUSTED:
+ return True
+
+ if type(self.allowed_message_print_destinations) is list:
+ if source_hash_text in self.allowed_message_print_destinations:
+ return True
+
+ return False
+
+ def print_file(self, filename):
+ print_command = self.print_command+" "+filename
+
+ try:
+ return_code = subprocess.call(shlex.split(print_command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+ except Exception as e:
+ RNS.log("An error occurred while executing print command: "+str(print_command), RNS.LOG_ERROR)
+ RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
+ return False
+
+ if return_code == 0:
+ RNS.log("Successfully printed "+str(filename)+" using print command: "+print_command, RNS.LOG_DEBUG)
+ return True
+
+ else:
+ RNS.log("Printing "+str(filename)+" failed using print command: "+print_command, RNS.LOG_DEBUG)
+ return False
+
+
+ def print_message(self, message, received = None):
+ try:
+ template = self.printing_template_msg
+
+ if received == None:
+ received = time.time()
+
+ g = self.ui.glyphs
+
+ m_rtime = datetime.fromtimestamp(message.timestamp)
+ stime = m_rtime.strftime(self.time_format)
+
+ message_time = datetime.fromtimestamp(received)
+ rtime = message_time.strftime(self.time_format)
+
+ display_name = self.directory.simplest_display_str(message.source_hash)
+ title = message.title_as_string()
+ if title == "":
+ title = "None"
+
+ output = template.format(
+ origin=display_name,
+ stime=stime,
+ rtime=rtime,
+ mtitle=title,
+ mbody=message.content_as_string(),
+ )
+
+ filename = "/tmp/"+RNS.hexrep(RNS.Identity.full_hash(output.encode("utf-8")), delimit=False)
+ with open(filename, "wb") as f:
+ f.write(output.encode("utf-8"))
+ f.close()
+
+ self.print_file(filename)
+
+ os.unlink(filename)
+
+ except Exception as e:
+ RNS.log("Error while printing incoming LXMF message. The contained exception was: "+str(e))
+
def conversations(self):
return nomadnet.Conversation.conversation_list(self)
+ 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()
@@ -211,7 +846,7 @@ class NomadNetworkApp:
if RNS.loglevel > 7:
RNS.loglevel = 7
if option == "destination":
- if value.lower() == "file":
+ 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"]
@@ -227,6 +862,83 @@ class NomadNetworkApp:
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":
@@ -239,7 +951,36 @@ class NomadNetworkApp:
if not "intro_time" in self.config["textui"]:
self.config["textui"]["intro_time"] = 1
else:
- self.config["textui"]["intro_time"] = self.config["textui"].as_int("intro_time")
+ self.config["textui"]["intro_time"] = self.config["textui"].as_float("intro_time")
+
+ if not "intro_text" in self.config["textui"]:
+ self.config["textui"]["intro_text"] = "Nomad Network"
+
+ if not "editor" in self.config["textui"]:
+ self.config["textui"]["editor"] = "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
@@ -262,6 +1003,20 @@ class NomadNetworkApp:
else:
raise ValueError("The selected Text UI color mode is invalid")
+ if not "image_loading" in self.config["textui"]:
+ self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO
+ else:
+ if self.config["textui"]["image_loading"].lower() == "never":
+ self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_NEVER
+ elif self.config["textui"]["image_loading"].lower() == "manual":
+ self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_MANUAL
+ elif self.config["textui"]["image_loading"].lower() == "auto":
+ self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO
+ elif self.config["textui"]["image_loading"].lower() == "always":
+ self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_ALWAYS
+ else:
+ raise ValueError("The selected image loading mode is invalid")
+
if not "theme" in self.config["textui"]:
self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_DARK
else:
@@ -278,13 +1033,254 @@ class NomadNetworkApp:
if value == "web":
self.uimode = nomadnet.ui.UI_WEB
- if "node" in self.config:
- for option in self.config["node"]:
- value = self.config["node"][option]
+ if not "utilities" in self.config:
+ self.config["utilities"] = {}
+ if not "enable_utilities" in self.config["utilities"]:
+ self.config["utilities"]["enable_utilities"] = False
+ else:
+ self.config["utilities"]["enable_utilities"] = self.config["utilities"].as_bool("enable_utilities")
+
+ if "rrc" in self.config:
+ for option in self.config["rrc"]:
+ if option == "history_per_room_cap":
+ try: value = self.config["rrc"].as_int(option)
+ except Exception: value = None
+ if value is not None and value >= 0:
+ self.rrc_history_per_room_cap = value
+
+ if option == "filter_loaded_history":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = True
+ self.rrc_filter_loaded_history = value
+
+ if option == "max_accepted_resource_size":
+ try: value = self.config["rrc"].as_int(option)
+ except Exception: value = None
+ if value is not None and value >= 0:
+ self.rrc_max_accepted_resource_size = value*1024
+
+ if option == "ephemeral_notices":
+ try: value = self.config["rrc"].as_float(option)
+ except Exception: value = 0
+ self.rrc_ephemeral_notices = value*60
+
+ if option == "justify_msgs":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = True
+ self.rrc_ui_justify_msgs = value
+
+ if option == "space_msgs":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = False
+ self.rrc_ui_space_msgs = value
+
+ if option == "nick_colors":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = True
+ self.rrc_nick_colors = value
+
+ if option == "mention_color":
+ value = self.config["rrc"][option]
+ if len(value) == 6:
+ try: bytes.fromhex(value)
+ except Exception: value = None
+ self.rrc_mention_color = value
+
+ if option == "color_mention_timestamps":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = True
+ self.rrc_color_mention_timestamps = value
+
+ if option == "nick_colors_theme":
+ try:
+ colors = self.config["rrc"].as_list(option)
+ nick_colors_theme = []
+ for c in colors:
+ if len(c) == 6:
+ try: bytes.fromhex(c)
+ except: continue
+ nick_colors_theme.append(c)
+ self.rrc_nick_colors_theme = nick_colors_theme
+ except Exception as e:
+ RNS.log(f"Could not load custom nick colors theme: {e}", RNS.LOG_WARNING)
+ self.rrc_nick_colors_theme = None
+
+ if option == "render_markdown":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = True
+ self.rrc_ui_render_markdown = value
+
+ if option == "render_micron":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = True
+ self.rrc_ui_render_micron = value
+
+ if option == "show_gutters":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = False
+ self.rrc_show_gutters = value
+
+ if option == "enable_esoterics":
+ try: value = self.config["rrc"].as_bool(option)
+ except Exception: value = False
+ self.rrc_enable_esoterics = value
+
+ if "node" in self.config:
+ if not "enable_node" in self.config["node"]:
+ self.enable_node = False
+ 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__
- if option == "enable_node":
- value = self.config["node"].as_bool(option)
- self.enable_node = value
@staticmethod
def get_shared_instance():
@@ -319,29 +1315,420 @@ destination = file
[client]
-enable_client = Yes
+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 88 colors,
-# but 256 colors can probably be used on
-# most terminals. Some terminals
+# 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 = 88
# colormode = 256
-# colormode = 24bit
+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
+
+# If your terminal supports image output,
+# the page browser can display images and
+# graphics on pages. You can specify how,
+# or if at all, images are loaded. Valid
+# options are "auto", "manual", "never"
+# and "always".
+
+image_loading = auto
+
+# You can specify whether mouse events
+# should be considered as input to the
+# application. On by default.
+mouse_enabled = True
+
+# What editor to use for editing text.
+editor = nano
+
+# If you don't want the Guide section to
+# show up in the menu, you can disable it.
+hide_guide = no
+
+# You can select whether or not to perform
+# text sanitization on names received in
+# announces.
+sanitize_names = yes
+
+# You can enable clipboard features. This
+# relies on your terminal supporting OSC52
+# escape sequences.
+clipboard_copy = no
+
+[utilities]
+
+# You can enable a Utilities section in the
+# menu, where you can view the path and announce
+# rate tables and probe destinations. Disabled
+# by default.
+enable_utilities = no
+
+[rrc]
+
+# Maximum number of messages retained per
+# room in the in-memory scrollback buffer,
+# and the number of messages restored from
+# on-disk history at startup. The on-disk
+# log itself is appended to indefinitely;
+# this cap only controls how much backlog
+# is visible. Set to 0 to keep every message
+# in memory (full history).
+history_per_room_cap = 500
+
+# You can choose whether to filter system
+# and notice events when room history is
+# loaded.
+filter_loaded_history = yes
+
+# Maximum size in kilobytes of a resource
+# transfer that will be accepted from a
+# hub. Lower this on low-bandwidth links
+# to automatically reject transfers that
+# would take too long to receive. Set to
+# 0 to reject all resource transfers.
+max_accepted_resource_size = 256
+
+# You can choose whether notices and sys
+# messages persist indefinitely, or are
+# removed from the message history after
+# the specified time in minutes. Set to 0
+# to disable and keep forever.
+ephemeral_notices = 10
+
+# Other display and formatting options:
+color_mention_timestamps = yes
+render_markdown = yes
+render_micron = yes
+nick_colors = yes
+justify_msgs = yes
+space_msgs = no
+show_gutters = yes
+
+# You can configure your own color theme
+# for nick color assignment
+# nick_colors_theme = f68787, 00c394, d59e00, ...
+
+# You can set a specific color for mentions
+# of your nick
+# mention_color = FFBB44
+
[node]
-enable_node = No
+# Whether to enable node hosting
+enable_node = no
-'''.splitlines()
\ No newline at end of file
+# 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..f5cde89
--- /dev/null
+++ b/nomadnet/RRC.py
@@ -0,0 +1,1641 @@
+import os
+import re
+import time
+import threading
+import hashlib
+from collections import deque
+
+import RNS
+
+from nomadnet.vendor import cbor
+from nomadnet.util import strip_modifiers
+
+
+HISTORY_DIR_NAME = "rrc_history"
+HISTORY_FILENAME_SANITIZE_RE = re.compile(r"[^a-z0-9._-]+")
+
+H_KIND = "k"
+H_SRC = "s"
+H_NICK = "n"
+H_TEXT = "t"
+H_TS = "ts"
+H_MENTION = "m"
+
+
+_MENTION_RE_CACHE = {}
+
+
+def _mention_re(nick):
+ if not isinstance(nick, str) or not nick:
+ return None
+ pat = _MENTION_RE_CACHE.get(nick)
+ if pat is None:
+ pat = re.compile(r"(? 32:
+ _MENTION_RE_CACHE.clear()
+ _MENTION_RE_CACHE[nick] = pat
+ return pat
+
+# https://github.com/kc1awv/rrcd/blob/main/rrcd/constants.py
+RRC_VERSION = 1
+
+K_V = 0
+K_T = 1
+K_ID = 2
+K_TS = 3
+K_SRC = 4
+K_ROOM = 5
+K_BODY = 6
+K_NICK = 7
+
+T_HELLO = 1
+T_WELCOME = 2
+
+T_JOIN = 10
+T_JOINED = 11
+T_PART = 12
+T_PARTED = 13
+
+T_MSG = 20
+T_NOTICE = 21
+T_ACTION = 22
+
+T_PING = 30
+T_PONG = 31
+
+T_ERROR = 40
+
+T_RESOURCE_ENVELOPE = 50
+
+B_HELLO_NAME = 0
+B_HELLO_VER = 1
+B_HELLO_CAPS = 2
+
+B_WELCOME_HUB = 0
+B_WELCOME_VER = 1
+B_WELCOME_CAPS = 2
+B_WELCOME_LIMITS = 3
+
+L_MAX_NICK_BYTES = 0
+L_MAX_ROOM_NAME_BYTES = 1
+L_MAX_MSG_BODY_BYTES = 2
+L_MAX_ROOMS_PER_SESSION = 3
+L_RATE_LIMIT_MSGS_PER_MINUTE= 4
+
+CAP_RESOURCE_ENVELOPE = 0
+CAP_ACTION = 1
+
+B_RES_ID = 0
+B_RES_KIND = 1
+B_RES_SIZE = 2
+B_RES_SHA256 = 3
+B_RES_ENCODING = 4
+
+RES_KIND_NOTICE = "notice"
+RES_KIND_MOTD = "motd"
+RES_KIND_BLOB = "blob"
+
+DEFAULT_DEST_NAME = "rrc.hub"
+DEFAULT_MAX_NICK_BYTES = 32
+DEFAULT_MAX_ROOM_BYTES = 64
+DEFAULT_MAX_MSG_BYTES = 350
+DEFAULT_MAX_ROOMS = 32
+DEFAULT_RATE_PER_MINUTE = 240
+
+
+
+
+
+
+def _now_ms():
+ return int(time.time()*1000)
+
+
+def _msg_id():
+ return os.urandom(8)
+
+
+# greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)"
+_WHO_ENTRY_RE = re.compile(
+ r"(?:^|,\s)"
+ r"(?:(?P[0-9a-fA-F]{64})|(?P.+?)\s\((?P[0-9a-fA-F]{12})\))"
+ r"(?=,\s|$)"
+)
+
+
+# hub /who response format: "members in : nick1 (hex12), nick2 (hex12), , ..."
+# nicked users carry only a 12-hex prefix of their identity hash; un-nicked users appear as the full hex
+def _parse_who_notice(text):
+ if not isinstance(text, str):
+ return None
+ prefix = "members in "
+ if not text.startswith(prefix):
+ return None
+ sep_idx = text.find(": ", len(prefix))
+ if sep_idx < 0:
+ return None
+ room = text[len(prefix):sep_idx].strip().lower()
+ if not room:
+ return None
+ body = text[sep_idx+2:].strip()
+ entries = []
+ if body and body != "(none)":
+ for m in _WHO_ENTRY_RE.finditer(body):
+ if m.group("nick") is not None:
+ entries.append((m.group("nick").strip(), m.group("np").lower()))
+ elif m.group("bh") is not None:
+ entries.append((None, m.group("bh").lower()))
+ return (room, entries)
+
+
+def _parse_room_list_notice(text):
+ if not isinstance(text, str):
+ return None
+ stripped = text.strip()
+ if stripped == "No public rooms registered":
+ return {}
+ lines = text.split("\n")
+ if not lines or not lines[0].lstrip().startswith("Registered public rooms"):
+ return None
+ rooms = {}
+ for line in lines[1:]:
+ s = line.strip()
+ if not s:
+ continue
+ if " - " in s:
+ name, topic = s.split(" - ", 1)
+ rooms[name.strip().lower()] = topic.strip() or None
+ else:
+ rooms[s.strip().lstrip("#").lower()] = None
+ return rooms
+
+
+# On low-MTU links the hub may deliver a /list reply as a sequence of
+# chunked NOTICE packets (one line per packet) instead of a single notice
+# or a resource transfer. The chunks carry no continuation marker, but each
+# line has a recognisable shape. Lines matching it, arriving within a short
+# window after a /list header, are treated as continuation fragments and
+# merged into the room list instead of being recorded as chat messages.
+_LIST_FRAGMENT_RE = re.compile(r"^ (?P\S+?)(?: - (?P.*))?$")
+LIST_FRAGMENT_WINDOW = 60.0
+
+
+def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None):
+ env = {
+ K_V: RRC_VERSION,
+ K_T: int(msg_type),
+ K_ID: mid or _msg_id(),
+ K_TS: ts or _now_ms(),
+ K_SRC: src,
+ }
+ if room is not None:
+ env[K_ROOM] = room
+ if body is not None:
+ env[K_BODY] = body
+ if nick is not None and nick != "":
+ env[K_NICK] = nick
+ return env
+
+
+class RRCMessage:
+ def __init__(self, kind, room, src, nick, text, ts):
+ self.kind = kind
+ self.room = room
+ self.src = src
+ self.nick = nick
+ self.text = strip_modifiers(text)
+ self.ts = ts
+ self.mention = False
+
+
+class RRCHub:
+ STATUS_DISCONNECTED = 0
+ STATUS_CONNECTING = 1
+ STATUS_CONNECTED = 2
+ STATUS_FAILED = 3
+
+ CLEAN_HISTORY_INTERVAL = 5
+ SYS_NOTICE_TIMEOUT = 600
+
+ def __init__(self, manager, hub_hash, dest_name=None, name=None):
+ self.manager = manager
+ self.hub_hash = hub_hash
+ self.dest_name = dest_name or DEFAULT_DEST_NAME
+ self.name = name or RNS.prettyhexrep(hub_hash)
+
+ self.link = None
+ self.status = RRCHub.STATUS_DISCONNECTED
+ self.status_text = "Disconnected"
+ self.welcomed = False
+ self.hub_name = None
+ self.hub_version = None
+ self.hub_caps = {}
+ self.motd = None
+
+ self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES
+ self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES
+ self.max_msg_body_bytes = DEFAULT_MAX_MSG_BYTES
+ self.max_rooms_per_session = DEFAULT_MAX_ROOMS
+ self.rate_limit_msgs_per_minute = DEFAULT_RATE_PER_MINUTE
+
+ self.rooms = set()
+ self.messages = {}
+ self.notices = []
+ self.unread_rooms = set()
+ self.mention_rooms = set()
+ self.members = {}
+ self.nicks = {}
+ # Display-only member entries derived from 12-hex identity prefixes
+ # in /who replies. Populated when the full identity hash of a nicked
+ # member is not yet known, and promoted to regular members as soon
+ # as the full hash is learned from room traffic or join fanouts.
+ self.prefix_members = {}
+
+ self.auto_reconnect = False
+ self.auto_list = False
+ self.auto_who = False
+
+ self._lock = threading.RLock()
+ # we need this lock to serialize writes on non-*nix systems where O_APPEND
+ # write()'s apparently aren't guaranteed to be atomic
+ self._history_io_lock = threading.Lock()
+ self._resource_expectations = {}
+ self._sent_ids = deque(maxlen=256)
+
+ self._hello_thread = None
+ self._stop_hello = threading.Event()
+ self._manual_disconnect = False
+ self._reconnect_attempts = 0
+ self._reconnect_timer = None
+ self._pending_pings = {}
+ self._last_history_clean = 0
+ self.clean_last_removed = 0
+
+ self.available_rooms = {}
+ self._silent_list_pending = 0
+ self._silent_who_rooms = set()
+ self._list_fragment_until = 0.0
+
+ self.nick_override = None
+ self._pending_joins = set()
+ self._pending_parts = set()
+ self._silent_joins = set()
+
+ self._history_write_failed = False
+
+ def _log(self, msg, level=None):
+ if level is None:
+ level = RNS.LOG_INFO
+ RNS.log("[RRC "+self.name+"] "+msg, level)
+
+ def add_room(self, room):
+ room_n = self._normalize_room(room)
+ with self._lock:
+ self.rooms.add(room_n)
+ if room_n not in self.messages:
+ self.messages[room_n] = []
+ self.manager.save()
+ self.manager._notify_change(self)
+ return room_n
+
+ def remove_room(self, room):
+ r = self._normalize_room(room)
+ with self._lock:
+ self.rooms.discard(r)
+ self.messages.pop(r, None)
+ self.unread_rooms.discard(r)
+ self.mention_rooms.discard(r)
+ self.members.pop(r, None)
+ self.prefix_members.pop(r, None)
+ self._delete_history(r)
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def clear_messages(self, room):
+ r = self._normalize_room(room)
+ with self._lock:
+ if r in self.messages:
+ self.messages[r] = []
+ self.unread_rooms.discard(r)
+ self.mention_rooms.discard(r)
+ self._delete_history(r)
+ self.manager._notify_change(self)
+
+ def get_members(self, room):
+ with self._lock:
+ return list(self.members.get(room, set()) | self.prefix_members.get(room, set()))
+
+ def _upgrade_prefix_member(self, room, full_hash):
+ # Once a full identity hash is learned from room traffic or join
+ # fanouts, promote any display-only prefix member that matches it,
+ # so the entry becomes a full, addressable member. The prefix
+ # entry's nickname is carried over unless the promoting source
+ # provides one.
+ pset = self.prefix_members.get(room)
+ if pset:
+ for p in list(pset):
+ if full_hash.startswith(p):
+ pset.discard(p)
+ nick = self.nicks.pop(p, None)
+ if nick and full_hash not in self.nicks:
+ self.nicks[full_hash] = nick
+ break
+
+ def display_name_for(self, peer):
+ if not isinstance(peer, (bytes, bytearray)):
+ return ""
+ ph = bytes(peer)
+ with self._lock:
+ nick = self.nicks.get(ph)
+ if nick:
+ return nick
+ return ph.hex()[:12]
+
+ def mark_read(self, room):
+ r = self._normalize_room(room)
+ with self._lock:
+ self.unread_rooms.discard(r)
+ self.mention_rooms.discard(r)
+ self.manager._notify_change(self)
+
+ def _normalize_room(self, room):
+ r = (room or "").strip().lower()
+ if not r:
+ raise ValueError("room must not be empty")
+ return r
+
+ def _set_status(self, status, text=None):
+ self.status = status
+ if text is not None:
+ self.status_text = text
+ self.manager._notify_change(self)
+
+ def connect(self):
+ with self._lock:
+ if self.status in (RRCHub.STATUS_CONNECTING, RRCHub.STATUS_CONNECTED):
+ return
+ self._manual_disconnect = False
+ if self._reconnect_timer is not None:
+ self._reconnect_timer.cancel()
+ self._reconnect_timer = None
+ if self._reconnect_attempts > 0:
+ text = "Reconnecting (attempt "+str(self._reconnect_attempts)+")"
+ else:
+ text = "Connecting"
+ self._set_status(RRCHub.STATUS_CONNECTING, text)
+
+ t = threading.Thread(target=self._connect_worker, daemon=True)
+ t.start()
+
+ def _connect_worker(self):
+ try:
+ path_timeout = self.manager.app.rns.get_medium_path_timeout()
+ timeout_s = max(20.0, path_timeout)
+ if not RNS.Transport.has_path(self.hub_hash):
+ RNS.Transport.request_path(self.hub_hash)
+ deadline = time.monotonic() + max(5.0, path_timeout)
+ while time.monotonic() < deadline:
+ if RNS.Transport.has_path(self.hub_hash):
+ break
+ time.sleep(0.1)
+
+ hub_identity = None
+ deadline = time.monotonic() + timeout_s
+ while time.monotonic() < deadline:
+ hub_identity = RNS.Identity.recall(self.hub_hash)
+ if hub_identity is not None:
+ break
+ time.sleep(0.2)
+
+ if hub_identity is None:
+ self._set_status(RRCHub.STATUS_FAILED, "Hub identity unknown")
+ return
+
+ app_name, aspects = RNS.Destination.app_and_aspects_from_name(self.dest_name)
+ hub_dest = RNS.Destination(
+ hub_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ app_name,
+ *aspects,
+ )
+
+ if hub_dest.hash != self.hub_hash:
+ self._set_status(RRCHub.STATUS_FAILED, "Hash/destination name mismatch")
+ return
+
+ self._stop_hello.clear()
+ link = RNS.Link(hub_dest, established_callback=self._on_established, closed_callback=self._on_closed)
+ link.set_packet_callback(lambda data, pkt: self._on_packet(data))
+ with self._lock:
+ self.link = link
+
+ except Exception as e:
+ self._set_status(RRCHub.STATUS_FAILED, "Connect error: "+str(e))
+
+ def _on_established(self, link):
+ try:
+ link.set_resource_strategy(RNS.Link.ACCEPT_APP)
+ link.set_resource_callback(self._resource_advertised)
+ link.set_resource_started_callback(self._resource_advertised)
+ link.set_resource_concluded_callback(self._resource_concluded)
+ except Exception:
+ pass
+
+ try:
+ link.identify(self.manager.identity)
+ except Exception as e:
+ self._log("identify failed: "+str(e), RNS.LOG_ERROR)
+ try: link.teardown()
+ except Exception: pass
+ return
+
+ self._set_status(RRCHub.STATUS_CONNECTING, "Identified, sending HELLO")
+
+ def hello_loop():
+ attempts = 0
+ while not self._stop_hello.is_set() and not self.welcomed and attempts < 5:
+ with self._lock:
+ cur_link = self.link
+ if cur_link is None or cur_link.status != RNS.Link.ACTIVE:
+ return
+ try:
+ self._send_hello(cur_link)
+ except Exception as e:
+ self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR)
+ attempts += 1
+ self._stop_hello.wait(timeout=max(3.0, (cur_link.rtt or 0)*cur_link.traffic_timeout_factor))
+ if not self.welcomed and not self._stop_hello.is_set():
+ self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout")
+ try:
+ with self._lock:
+ if self.link is not None:
+ self.link.teardown()
+ except Exception:
+ pass
+
+ self._hello_thread = threading.Thread(target=hello_loop, daemon=True)
+ self._hello_thread.start()
+
+ def _send_hello(self, link):
+ body = {
+ B_HELLO_NAME: "nomadnet",
+ B_HELLO_VER: "0.1",
+ B_HELLO_CAPS: {
+ CAP_RESOURCE_ENVELOPE: True,
+ CAP_ACTION: True,
+ },
+ }
+ env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body)
+ nick = self.get_effective_nick()
+ if nick:
+ env[K_NICK] = nick
+ payload = cbor.encode(env)
+ RNS.Packet(link, payload).send()
+
+ def _on_closed(self, link):
+ self._stop_hello.set()
+ with self._lock:
+ self.link = None
+ self.welcomed = False
+ self.motd = None
+ self.members.clear()
+ self.prefix_members.clear()
+ self._resource_expectations.clear()
+ self._pending_joins.clear()
+ self._pending_parts.clear()
+ self._silent_joins.clear()
+ self._silent_who_rooms.clear()
+ should_reconnect = self.auto_reconnect and not self._manual_disconnect
+ self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected")
+ if should_reconnect:
+ self._schedule_reconnect()
+
+ def _schedule_reconnect(self):
+ with self._lock:
+ self._reconnect_attempts += 1
+ backoff = min(60.0, max(1.0, 2.0 ** min(self._reconnect_attempts, 6)))
+ if self._reconnect_timer is not None:
+ self._reconnect_timer.cancel()
+
+ def fire():
+ with self._lock:
+ self._reconnect_timer = None
+ if self._manual_disconnect or not self.auto_reconnect:
+ return
+ self.connect()
+
+ self._reconnect_timer = threading.Timer(backoff, fire)
+ self._reconnect_timer.daemon = True
+ self._reconnect_timer.start()
+ self._set_status(RRCHub.STATUS_DISCONNECTED, "Reconnect in "+str(int(backoff))+"s")
+
+
+ def disconnect(self):
+ self._stop_hello.set()
+ with self._lock:
+ self._manual_disconnect = True
+ self._reconnect_attempts = 0
+ if self._reconnect_timer is not None:
+ self._reconnect_timer.cancel()
+ self._reconnect_timer = None
+ link = self.link
+ self.link = None
+ if link is not None:
+ try: link.teardown()
+ except Exception: pass
+ self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected")
+
+ def set_auto_reconnect(self, enabled, save=True):
+ with self._lock:
+ self.auto_reconnect = bool(enabled)
+ if not enabled and self._reconnect_timer is not None:
+ self._reconnect_timer.cancel()
+ self._reconnect_timer = None
+ if save:
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def set_auto_list(self, enabled, save=True):
+ with self._lock:
+ self.auto_list = bool(enabled)
+ if save:
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def set_auto_who(self, enabled, save=True):
+ with self._lock:
+ self.auto_who = bool(enabled)
+ if save:
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def get_effective_nick(self):
+ if isinstance(self.nick_override, str) and self.nick_override:
+ return self.nick_override
+ return self.manager.get_nickname()
+
+ def set_nick_override(self, nick):
+ with self._lock:
+ if nick is None or (isinstance(nick, str) and nick == ""):
+ self.nick_override = None
+ else:
+ self.nick_override = str(nick)
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def _packet_would_fit(self, link, payload):
+ try:
+ pkt = RNS.Packet(link, payload)
+ pkt.pack()
+ return True
+ except Exception:
+ return False
+
+ def _send_env(self, env):
+ with self._lock:
+ link = self.link
+ if link is None or link.status != RNS.Link.ACTIVE:
+ raise RuntimeError("not connected")
+ payload = cbor.encode(env)
+ if not self._packet_would_fit(link, payload):
+ raise RuntimeError("message exceeds link MTU")
+ RNS.Packet(link, payload).send()
+
+ def join_room(self, room, key=None, silent=False):
+ r = self._normalize_room(room)
+ body = key if (isinstance(key, str) and key) else None
+ env = _make_envelope(T_JOIN, src=self.manager.identity.hash, room=r, body=body)
+ nick = self.get_effective_nick()
+ if nick:
+ env[K_NICK] = nick
+ with self._lock:
+ self._pending_joins.add(r)
+ if silent:
+ self._silent_joins.add(r)
+ self._send_env(env)
+ with self._lock:
+ if r not in self.messages:
+ self.messages[r] = []
+ self.manager._notify_change(self)
+
+ def send_command(self, text, room=None):
+ if not isinstance(text, str) or not text.startswith("/"):
+ raise ValueError("command must start with /")
+ env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=room, body=text)
+ nick = self.get_effective_nick()
+ if nick:
+ env[K_NICK] = nick
+ self._send_env(env)
+
+ def send_ping(self, room=None):
+ body = os.urandom(8)
+ env = _make_envelope(T_PING, src=self.manager.identity.hash, body=body)
+ with self._lock:
+ now_ms = _now_ms()
+ self._pending_pings[body] = (now_ms, room)
+ expired = [k for k, v in self._pending_pings.items() if now_ms - v[0] > 15000]
+ for k in expired:
+ self._pending_pings.pop(k, None)
+ self._send_env(env)
+ return body
+
+ def part_room(self, room):
+ room_n = self._normalize_room(room)
+ env = _make_envelope(T_PART, src=self.manager.identity.hash, room=room_n)
+ with self._lock:
+ self._pending_parts.add(room_n)
+ try:
+ self._send_env(env)
+ except Exception:
+ pass
+ with self._lock:
+ self.rooms.discard(room_n)
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def send_message(self, room, text):
+ r = self._normalize_room(room)
+ if not isinstance(text, str) or not text.strip():
+ raise ValueError("message text must be non-empty")
+ if len(text.encode("utf-8")) > self.max_msg_body_bytes:
+ raise ValueError("message too long for hub limit")
+ env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=r, body=text)
+ nick = self.get_effective_nick()
+ if nick:
+ env[K_NICK] = nick
+ mid = env[K_ID]
+ if isinstance(mid, (bytes, bytearray)):
+ self._sent_ids.append(bytes(mid))
+ self._send_env(env)
+ self._record_message(RRCMessage("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True)
+ return mid
+
+ def send_action(self, room, text):
+ r = self._normalize_room(room)
+ if not isinstance(text, str) or not text.strip():
+ raise ValueError("action text must be non-empty")
+ if len(text.encode("utf-8")) > self.max_msg_body_bytes:
+ raise ValueError("action too long for hub limit")
+ env = _make_envelope(T_ACTION, src=self.manager.identity.hash, room=r, body=text)
+ nick = self.get_effective_nick()
+ if nick:
+ env[K_NICK] = nick
+ mid = env[K_ID]
+ if isinstance(mid, (bytes, bytearray)):
+ self._sent_ids.append(bytes(mid))
+ self._send_env(env)
+ self._record_message(RRCMessage("action", r, self.manager.identity.hash, nick, text, _now_ms()), local=True)
+ return mid
+
+ def _per_room_cap(self):
+ try:
+ v = int(getattr(self.manager.app, "rrc_history_per_room_cap", 0))
+ except Exception:
+ return None
+ return v if v > 0 else None
+
+ def _filter_history(self):
+ try: v = getattr(self.manager.app, "rrc_filter_loaded_history", True)
+ except Exception: return True
+ return v
+
+ def _ephemeral_notices_history(self):
+ try: v = getattr(self.manager.app, "rrc_ephemeral_notices", self.SYS_NOTICE_TIMEOUT)
+ except Exception: return self.SYS_NOTICE_TIMEOUT
+ return v
+
+ def _entry_for(self, msg):
+ return {
+ H_KIND: msg.kind,
+ H_SRC: bytes(msg.src) if isinstance(msg.src, (bytes, bytearray)) else None,
+ H_NICK: msg.nick if isinstance(msg.nick, str) else None,
+ H_TEXT: msg.text if isinstance(msg.text, str) else "",
+ H_TS: int(msg.ts) if isinstance(msg.ts, int) else _now_ms(),
+ H_MENTION: bool(getattr(msg, "mention", False)),
+ }
+
+ def _msg_from_entry(self, room, entry):
+ if not isinstance(entry, dict):
+ return None
+ m = RRCMessage(
+ entry.get(H_KIND) if isinstance(entry.get(H_KIND), str) else "msg",
+ room,
+ entry.get(H_SRC) if isinstance(entry.get(H_SRC), (bytes, bytearray)) else None,
+ entry.get(H_NICK) if isinstance(entry.get(H_NICK), str) else None,
+ entry.get(H_TEXT) if isinstance(entry.get(H_TEXT), str) else "",
+ entry.get(H_TS) if isinstance(entry.get(H_TS), int) else 0,
+ )
+ m.mention = bool(entry.get(H_MENTION, False))
+ return m
+
+ def _persistable_room(self, room):
+ return isinstance(room, str) and room and room != "*"
+
+ def _append_history(self, room, msg):
+ if not self._persistable_room(room):
+ return
+ try:
+ self.manager._ensure_history_dir(self)
+ path = self.manager._history_path(self, room)
+ payload = cbor.encode(self._entry_for(msg))
+ with self._history_io_lock:
+ with open(path, "ab") as f:
+ f.write(payload)
+ self._history_write_failed = False
+ except Exception as e:
+ if not self._history_write_failed:
+ self._history_write_failed = True
+ self._log("history persistence failed, suppressing further warnings until recovery: "+str(e), RNS.LOG_ERROR)
+
+ def _delete_history(self, room):
+ if not self._persistable_room(room):
+ return
+ path = self.manager._history_path(self, room)
+ try:
+ with self._history_io_lock:
+ if os.path.isfile(path):
+ os.unlink(path)
+ except Exception:
+ pass
+
+ def _load_history(self):
+ with self._lock:
+ rooms = list(self.messages.keys())
+ filter_msgs = self._filter_history()
+ for room in rooms:
+ if not self._persistable_room(room):
+ continue
+ path = self.manager._history_path(self, room)
+ if not os.path.isfile(path):
+ continue
+ window = deque(maxlen=self._per_room_cap())
+ decode_error = None
+ try:
+ with open(path, "rb") as f:
+ while True:
+ try:
+ entry = cbor.load(f)
+ except EOFError:
+ break
+ except Exception as ex:
+ decode_error = ex
+ break
+ m = self._msg_from_entry(room, entry)
+ if m is None:
+ continue
+ if filter_msgs and (m.kind == "system" or m.kind == "notice"):
+ continue
+ window.append(m)
+ except OSError as ex:
+ self._log("history load failed for #"+room+": "+str(ex), RNS.LOG_ERROR)
+ continue
+ if decode_error is not None:
+ self._log("history file for #"+room+" is corrupt, truncating to last "+str(len(window))+" valid messages: "+str(decode_error), RNS.LOG_ERROR)
+ with self._lock:
+ self.messages[room] = list(window)
+
+ def _clean_history(self):
+ now = time.time()
+ cleaned = False
+ remove_after = self._ephemeral_notices_history()
+ if now > self._last_history_clean + self.CLEAN_HISTORY_INTERVAL:
+ RNS.log(f"Cleaning loaded message history", RNS.LOG_DEBUG)
+ with self._lock:
+ try:
+ for r in self.messages:
+ old = set()
+ for m in self.messages[r]:
+ age = now-m.ts/1000.0
+ should_filter = False
+ if m.kind == "system": should_filter = True
+ elif m.kind == "notice": should_filter = True
+ if should_filter and age > remove_after: old.add(m)
+
+ for m in old:
+ self.messages[r].remove(m)
+ cleaned = True
+
+ except Exception as e: RNS.trace_exception(e)
+
+ self._last_history_clean = time.time()
+ if cleaned: self.clean_last_removed = time.time()
+
+ def _record_message(self, msg, local=False):
+ cap = self._per_room_cap()
+ with self._lock:
+ buf = self.messages.setdefault(msg.room or "*", [])
+ buf.append(msg)
+ if cap is not None and len(buf) > cap:
+ del buf[:len(buf)-cap]
+ if not local and msg.room:
+ if msg.room != self.manager.active_room_for(self):
+ self.unread_rooms.add(msg.room)
+ if msg.mention:
+ self.mention_rooms.add(msg.room)
+ self.manager._notify_messages(self, msg)
+ self._append_history(msg.room, msg)
+ self._clean_history()
+
+ def _record_system(self, room, text):
+ if not room:
+ return
+ msg = RRCMessage("system", room, None, None, text, _now_ms())
+ cap = self._per_room_cap()
+ with self._lock:
+ buf = self.messages.setdefault(room, [])
+ buf.append(msg)
+ if cap is not None and len(buf) > cap:
+ del buf[:len(buf)-cap]
+ self.manager._notify_messages(self, msg)
+ self._append_history(room, msg)
+ self._clean_history()
+
+ def _record_notice(self, msg):
+ target_room = msg.room
+ if not target_room:
+ target_room = self.manager.active_room_for(self)
+ if target_room:
+ msg.room = target_room
+
+ cap = self._per_room_cap()
+ with self._lock:
+ self.notices.append(msg)
+ if len(self.notices) > 200:
+ del self.notices[:len(self.notices)-200]
+ if target_room:
+ buf = self.messages.setdefault(target_room, [])
+ buf.append(msg)
+ if cap is not None and len(buf) > cap:
+ del buf[:len(buf)-cap]
+ if target_room != self.manager.active_room_for(self):
+ self.unread_rooms.add(target_room)
+ self.manager._notify_messages(self, msg)
+ if target_room:
+ self._append_history(target_room, msg)
+ self._clean_history()
+
+ def _process_notice_text(self, text):
+ # Parse hub service notices (/list and /who replies) regardless of
+ # whether they arrived as a packet or a resource transfer. Returns
+ # True when the notice was consumed silently (auto /list or /who,
+ # or a /list continuation fragment) and should not be recorded to
+ # the message log.
+ parsed = _parse_room_list_notice(text)
+ if parsed is not None:
+ with self._lock:
+ self.available_rooms = {strip_modifiers(name): strip_modifiers(topic) for name, topic in parsed.items()}
+ # The first line of a /list reply opens a short window in
+ # which continuation fragments (chunked NOTICE packets on
+ # low-MTU links) are absorbed into the room list.
+ self._list_fragment_until = time.monotonic()+LIST_FRAGMENT_WINDOW
+ silent = self._silent_list_pending > 0
+ if silent:
+ self._silent_list_pending -= 1
+ self.manager._notify_change(self)
+ if silent: return True
+ with self._lock:
+ list_window_active = time.monotonic() < self._list_fragment_until
+ if list_window_active:
+ fm = _LIST_FRAGMENT_RE.match(text)
+ if fm is not None:
+ with self._lock:
+ frag_name = fm.group("name").strip().lower()
+ frag_topic = fm.group("topic")
+ if frag_topic:
+ frag_topic = strip_modifiers(frag_topic.strip()) or None
+ else:
+ frag_topic = None
+ self.available_rooms[frag_name] = frag_topic
+ self._list_fragment_until = time.monotonic()+LIST_FRAGMENT_WINDOW
+ self.manager._notify_change(self)
+ return True
+ parsed_who = _parse_who_notice(text)
+ if parsed_who is not None:
+ who_room, who_entries = parsed_who
+ with self._lock:
+ members = self.members.setdefault(who_room, set())
+ # Treat /who replies as fresh roster snapshots: replace any
+ # display-only prefix members from earlier replies before
+ # applying this one.
+ prefix_members = self.prefix_members.setdefault(who_room, set())
+ for p in list(prefix_members):
+ self.nicks.pop(p, None)
+ prefix_members.clear()
+ for nick, hash_hex in who_entries:
+ try:
+ hash_bytes = bytes.fromhex(hash_hex)
+ except Exception:
+ continue
+ if nick is None:
+ members.add(hash_bytes)
+ self._upgrade_prefix_member(who_room, hash_bytes)
+ continue
+ known = None
+ for ph in members:
+ if ph.startswith(hash_bytes):
+ known = ph
+ break
+ if known is not None:
+ self.nicks[known] = nick
+ else:
+ # A /who line only carries a 12-hex identity prefix
+ # for nicked members. If the full hash is not yet
+ # known, keep the prefix as a display-only member.
+ # It is promoted to a full member as soon as the
+ # hash is learned from room traffic or fanouts.
+ prefix_members.add(hash_bytes)
+ self.nicks[hash_bytes] = nick
+ silent_who = who_room in self._silent_who_rooms
+ if silent_who:
+ self._silent_who_rooms.discard(who_room)
+ self.manager._notify_change(self)
+ if silent_who:
+ return True
+ return False
+
+ def get_messages(self, room, take_lock=True):
+ if take_lock:
+ with self._lock: buf = list(self.messages.get(room, []))
+ else: buf = list(self.messages.get(room, []))
+
+ return buf
+
+ def _on_packet(self, data):
+ try:
+ env = cbor.decode(data)
+ except Exception as e:
+ self._log("decode failed: "+str(e), RNS.LOG_DEBUG)
+ return
+ if not isinstance(env, dict):
+ return
+ try:
+ t = env.get(K_T)
+ except Exception:
+ return
+
+ if t == T_PING:
+ try:
+ pong = _make_envelope(T_PONG, src=self.manager.identity.hash, body=env.get(K_BODY))
+ self._send_env(pong)
+ except Exception:
+ pass
+ return
+
+ if t == T_PONG:
+ body = env.get(K_BODY)
+ if isinstance(body, (bytes, bytearray)):
+ key = bytes(body)
+ with self._lock:
+ pending = self._pending_pings.pop(key, None)
+ if pending is not None:
+ sent_ms, room = pending
+ rtt_ms = max(0, _now_ms() - sent_ms)
+ self._record_system(room, "Pong from hub: "+str(rtt_ms)+" ms")
+ return
+
+ if t == T_WELCOME:
+ self.welcomed = True
+ body = env.get(K_BODY)
+ if isinstance(body, dict):
+ hub_name = body.get(B_WELCOME_HUB)
+ if isinstance(hub_name, str):
+ self.hub_name = strip_modifiers(hub_name)
+ ver = body.get(B_WELCOME_VER)
+ if isinstance(ver, str):
+ self.hub_version = ver
+ caps = body.get(B_WELCOME_CAPS)
+ if isinstance(caps, dict):
+ self.hub_caps = dict(caps)
+ limits = body.get(B_WELCOME_LIMITS)
+ if isinstance(limits, dict):
+ if L_MAX_NICK_BYTES in limits:
+ self.max_nick_bytes = int(limits[L_MAX_NICK_BYTES])
+ if L_MAX_ROOM_NAME_BYTES in limits:
+ self.max_room_name_bytes = int(limits[L_MAX_ROOM_NAME_BYTES])
+ if L_MAX_MSG_BODY_BYTES in limits:
+ self.max_msg_body_bytes = int(limits[L_MAX_MSG_BODY_BYTES])
+ if L_MAX_ROOMS_PER_SESSION in limits:
+ self.max_rooms_per_session = int(limits[L_MAX_ROOMS_PER_SESSION])
+ if L_RATE_LIMIT_MSGS_PER_MINUTE in limits:
+ self.rate_limit_msgs_per_minute = int(limits[L_RATE_LIMIT_MSGS_PER_MINUTE])
+ self._set_status(RRCHub.STATUS_CONNECTED, "Connected")
+ with self._lock:
+ self._reconnect_attempts = 0
+ self.manager._on_welcome(self)
+ if self.auto_list:
+ try:
+ with self._lock:
+ self._silent_list_pending += 1
+ self.send_command("/list", room=None)
+ except Exception:
+ with self._lock:
+ if self._silent_list_pending > 0:
+ self._silent_list_pending -= 1
+ return
+
+ if t == T_JOINED:
+ room = env.get(K_ROOM)
+ if isinstance(room, str) and room:
+ r = room.strip().lower()
+ body = env.get(K_BODY)
+ joiner_nick = env.get(K_NICK)
+ own_hash = self.manager.identity.hash if self.manager.identity is not None else None
+
+ body_hashes = []
+ if isinstance(body, list):
+ body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))]
+
+ with self._lock:
+ self_join = r in self._pending_joins
+ silent = r in self._silent_joins
+ if self_join:
+ self._pending_joins.discard(r)
+ if silent:
+ self._silent_joins.discard(r)
+
+ self.rooms.add(r)
+ if r not in self.messages:
+ self.messages[r] = []
+ members = self.members.setdefault(r, set())
+ for h in body_hashes:
+ members.add(h)
+ self._upgrade_prefix_member(r, h)
+ if own_hash is not None:
+ members.add(own_hash)
+ self._upgrade_prefix_member(r, own_hash)
+
+ # rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it
+ # so display_name_for() can render the nick instead of a hash prefix.
+ if (not self_join) and isinstance(joiner_nick, str) and joiner_nick and len(body_hashes) == 1:
+ jh = body_hashes[0]
+ if own_hash is None or jh != own_hash:
+ self.nicks[jh] = joiner_nick
+
+ if self_join:
+ if not silent:
+ self._record_system(r, "You joined #"+r)
+ if self.auto_who:
+ try:
+ with self._lock:
+ self._silent_who_rooms.add(r)
+ self.send_command("/who "+r, room=r)
+ except Exception:
+ with self._lock:
+ self._silent_who_rooms.discard(r)
+ self.manager.save()
+ else:
+ joiner = None
+ if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash):
+ joiner = body_hashes[0]
+ if joiner is not None:
+ self._record_system(r, self.display_name_for(joiner)+" joined")
+ self.manager._notify_change(self)
+ return
+
+ if t == T_PARTED:
+ room = env.get(K_ROOM)
+ if isinstance(room, str) and room:
+ r = room.strip().lower()
+ body = env.get(K_BODY)
+ parter_nick = env.get(K_NICK)
+ own_hash = self.manager.identity.hash if self.manager.identity is not None else None
+
+ body_hashes = []
+ if isinstance(body, list):
+ body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))]
+
+ with self._lock:
+ self_part = r in self._pending_parts
+ if self_part:
+ self._pending_parts.discard(r)
+
+ # rrcd 0.3.2 attaches advisory K_NICK to fanout PARTED; learn it
+ # before forgetting the member so " left" renders correctly.
+ if (not self_part) and isinstance(parter_nick, str) and parter_nick and len(body_hashes) == 1:
+ ph = body_hashes[0]
+ if own_hash is None or ph != own_hash:
+ self.nicks[ph] = parter_nick
+
+ members = self.members.get(r)
+ pset = self.prefix_members.get(r)
+ if members is not None:
+ for h in body_hashes:
+ members.discard(h)
+ if pset:
+ for p in list(pset):
+ if h.startswith(p):
+ pset.discard(p)
+ self.nicks.pop(p, None)
+ if self_part:
+ self.rooms.discard(r)
+ self.members.pop(r, None)
+ self.prefix_members.pop(r, None)
+
+ if self_part:
+ self.manager.save()
+ else:
+ parter = None
+ if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash):
+ parter = body_hashes[0]
+ if parter is not None:
+ self._record_system(r, self.display_name_for(parter)+" left")
+ self.manager._notify_change(self)
+ return
+
+ if t == T_MSG:
+ body = env.get(K_BODY)
+ room = env.get(K_ROOM)
+ src = env.get(K_SRC)
+ nick = env.get(K_NICK)
+ mid = env.get(K_ID)
+ own_hash = self.manager.identity.hash if self.manager.identity is not None else None
+ if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash:
+ if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids:
+ return
+ if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick:
+ with self._lock:
+ self.nicks[bytes(src)] = nick
+ if isinstance(room, str) and room:
+ rn = room.strip().lower()
+ self.members.setdefault(rn, set()).add(bytes(src))
+ self._upgrade_prefix_member(rn, bytes(src))
+ if isinstance(body, str):
+ msg = RRCMessage(
+ "msg",
+ room.strip().lower() if isinstance(room, str) else None,
+ bytes(src) if isinstance(src, (bytes, bytearray)) else None,
+ nick if isinstance(nick, str) else None,
+ body,
+ _now_ms(),
+ )
+ is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash
+ if not is_own:
+ own_nick = self.get_effective_nick()
+ pat = _mention_re(own_nick)
+ if pat is not None and pat.search(body):
+ msg.mention = True
+ self._record_message(msg)
+ return
+
+ if t == T_ACTION:
+ body = env.get(K_BODY)
+ room = env.get(K_ROOM)
+ src = env.get(K_SRC)
+ nick = env.get(K_NICK)
+ mid = env.get(K_ID)
+ own_hash = self.manager.identity.hash if self.manager.identity is not None else None
+ if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash:
+ if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids:
+ return
+ if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick:
+ with self._lock:
+ self.nicks[bytes(src)] = nick
+ if isinstance(room, str) and room:
+ rn = room.strip().lower()
+ self.members.setdefault(rn, set()).add(bytes(src))
+ self._upgrade_prefix_member(rn, bytes(src))
+ if isinstance(body, str):
+ msg = RRCMessage(
+ "action",
+ room.strip().lower() if isinstance(room, str) else None,
+ bytes(src) if isinstance(src, (bytes, bytearray)) else None,
+ nick if isinstance(nick, str) else None,
+ body,
+ _now_ms(),
+ )
+ is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash
+ if not is_own:
+ own_nick = self.get_effective_nick()
+ pat = _mention_re(own_nick)
+ if pat is not None and pat.search(body):
+ msg.mention = True
+ self._record_message(msg)
+ return
+
+ if t == T_NOTICE:
+ body = env.get(K_BODY)
+ room = env.get(K_ROOM)
+ src = env.get(K_SRC)
+ if isinstance(body, str):
+ #################### HACK ZONE #####################
+ # This is the kind of crazy mess that happens when
+ # an LLM designs a chat protocol, and the client
+ # maintainer is too open-minded, decides to go with
+ # it, and now is responsible for the fundamental
+ # architectural chaos.
+ #
+ # Yes, I love RRC, but trying to figure out an
+ # elegant way to solve the underlying issues that
+ # make this kind of mess necessary revealed the
+ # following answer: There is none.
+ #
+ # There are so many bugs, weird cases, architectural
+ # mismatches, impossible situations and general
+ # weirdness here, that I am just not going to go and
+ # attempt to sort it all out. Sorry, I don't have the
+ # time, so a hack like this is what it will be. This
+ # makes things actually work semi-expectedly, even
+ # over low-bandwidth links and such, so hacky fix it
+ # is. Until we have something better, this will do
+ # just fine. On a related note, I was considering
+ # taking up maintenance of rrcd, now that it seems to
+ # not really be worked on or updated anymore, but
+ # looking at the entire system, I think it's better
+ # left as it; it works fine, albeit with a few quirks
+ # for it's purpose. My future work in chat-related
+ # protocols on Reticulum will be focused on a better
+ # architecture, incorporating everything we learned
+ # from RRC, and making it Reticulum-native from the
+ # start, instead of mirroring IRC too closely.
+ # - Mark
+ was_parsed = self._process_notice_text(body)
+ room_n = room.strip().lower() if isinstance(room, str) else None
+ if room_n is None and isinstance(body, str) and body.strip():
+ probably_motd = True
+ if was_parsed: probably_motd = False
+ if body.lstrip().lower().startswith("registered public rooms:"): probably_motd = False; was_parsed = True
+ if body.lstrip().lower().startswith("members in "): probably_motd = False; was_parsed = True
+ if probably_motd:
+ with self._lock: self.motd = strip_modifiers(body)
+ was_parsed = False
+ self.manager._notify_change(self)
+ msg = RRCMessage(
+ "notice",
+ room_n,
+ bytes(src) if isinstance(src, (bytes, bytearray)) else None,
+ None,
+ body,
+ _now_ms(),
+ )
+ if not was_parsed: self._record_notice(msg)
+ return
+
+ if t == T_ERROR:
+ body = env.get(K_BODY)
+ room = env.get(K_ROOM)
+ text = body if isinstance(body, str) else "(error)"
+ r = room.strip().lower() if isinstance(room, str) else None
+ rollback_join = False
+ if r:
+ with self._lock:
+ if r in self._pending_joins:
+ rollback_join = True
+ self._pending_joins.discard(r)
+ self._silent_joins.discard(r)
+ self._pending_parts.discard(r)
+ if rollback_join:
+ self.rooms.discard(r)
+ if rollback_join:
+ self.manager.save()
+ msg = RRCMessage(
+ "error",
+ r,
+ None,
+ None,
+ text,
+ _now_ms(),
+ )
+ self._record_notice(msg)
+ return
+
+ if t == T_RESOURCE_ENVELOPE:
+ body = env.get(K_BODY)
+ if not isinstance(body, dict):
+ return
+ try:
+ rid = body.get(B_RES_ID)
+ kind = body.get(B_RES_KIND)
+ size = body.get(B_RES_SIZE)
+ sha256 = body.get(B_RES_SHA256)
+ encoding = body.get(B_RES_ENCODING)
+ if not isinstance(rid, (bytes, bytearray)): return
+ if not isinstance(kind, str): return
+ if not isinstance(size, int) or size <= 0: return
+ room = env.get(K_ROOM)
+ with self._lock:
+ self._resource_expectations[bytes(rid)] = {
+ "kind": kind,
+ "size": size,
+ "sha256": bytes(sha256) if isinstance(sha256, (bytes, bytearray)) else None,
+ "encoding": encoding if isinstance(encoding, str) else "utf-8",
+ "room": room.strip().lower() if isinstance(room, str) else None,
+ "expires": time.monotonic()+30.0,
+ }
+ except Exception:
+ pass
+ return
+
+ def _resource_advertised(self, resource):
+ try:
+ if hasattr(resource, "get_data_size"):
+ size = resource.get_data_size()
+ elif hasattr(resource, "total_size"):
+ size = resource.total_size
+ else:
+ size = getattr(resource, "size", 0)
+ except Exception:
+ return False
+ try:
+ max_size = int(getattr(self.manager.app, "rrc_max_accepted_resource_size", 262144))
+ except Exception:
+ max_size = 262144
+ if max_size <= 0 or size > max_size:
+ self._log("rejecting resource transfer of "+str(size)+" bytes (max accepted is "+str(max_size)+" bytes)", RNS.LOG_DEBUG)
+ return False
+ return True
+
+ def _resource_concluded(self, resource):
+ try:
+ if resource.status != RNS.Resource.COMPLETE:
+ try:
+ if hasattr(resource, "data") and resource.data:
+ resource.data.close()
+ except Exception:
+ pass
+ return
+ try:
+ size = resource.total_size if hasattr(resource, "total_size") else getattr(resource, "size", 0)
+ except Exception:
+ size = 0
+ data = None
+ try:
+ data = resource.data.read()
+ finally:
+ try:
+ if hasattr(resource, "data") and resource.data:
+ resource.data.close()
+ except Exception:
+ pass
+ if data is None:
+ return
+
+ now = time.monotonic()
+ matched = None
+ with self._lock:
+ expired = [k for k, v in self._resource_expectations.items() if v["expires"] < now]
+ for k in expired:
+ self._resource_expectations.pop(k, None)
+ for k, exp in list(self._resource_expectations.items()):
+ if exp["size"] == len(data):
+ matched = exp
+ self._resource_expectations.pop(k, None)
+ break
+
+ kind = matched["kind"] if matched else RES_KIND_BLOB
+ room = matched["room"] if matched else None
+ encoding = matched["encoding"] if matched else "utf-8"
+ sha = matched["sha256"] if matched else None
+ if sha is not None:
+ if hashlib.sha256(data).digest() != sha:
+ return
+ if kind in (RES_KIND_NOTICE, RES_KIND_MOTD):
+ try:
+ text = data.decode(encoding, errors="replace")
+ except Exception:
+ return
+ if kind == RES_KIND_MOTD:
+ with self._lock:
+ self.motd = strip_modifiers(text)
+ self.manager._notify_change(self)
+ elif self._process_notice_text(text):
+ return
+ msg = RRCMessage("notice", room, None, None, text, _now_ms())
+ self._record_notice(msg)
+ except Exception as e:
+ self._log("resource handling failed: "+str(e), RNS.LOG_ERROR)
+
+
+class RRCManager:
+ def __init__(self, app):
+ self.app = app
+ self.hubs = []
+ self._lock = threading.RLock()
+ self._change_callback = None
+ self._message_callback = None
+ self._active_hub = None
+ self._active_room = None
+ self._loaded = False
+ self._loading = False
+ self._save_lock = threading.Lock()
+
+ @property
+ def identity(self):
+ return self.app.identity
+
+ def get_nickname(self):
+ try:
+ n = self.app.peer_settings.get("display_name")
+ if isinstance(n, str):
+ return n
+ except Exception:
+ pass
+ return None
+
+ def set_change_callback(self, cb):
+ self._change_callback = cb
+
+ def set_message_callback(self, cb):
+ self._message_callback = cb
+
+ def _notify_change(self, hub=None):
+ try:
+ if self._change_callback is not None:
+ self._change_callback(hub)
+ except Exception:
+ pass
+
+ def _notify_messages(self, hub, msg):
+ try:
+ if self._message_callback is not None:
+ self._message_callback(hub, msg)
+ except Exception:
+ pass
+
+ def _on_welcome(self, hub):
+ for r in list(hub.rooms):
+ try:
+ hub.join_room(r, silent=True)
+ except Exception:
+ pass
+
+ def set_active(self, hub, room):
+ self._active_hub = hub
+ self._active_room = room
+ if hub is not None and room is not None:
+ hub.mark_read(room)
+
+ def active_room_for(self, hub):
+ if self._active_hub is hub:
+ return self._active_room
+ return None
+
+ def has_unread(self):
+ with self._lock:
+ for hub in self.hubs:
+ if hub.unread_rooms:
+ return True
+ return False
+
+ def add_hub(self, hub_hash, dest_name=None, name=None):
+ with self._lock:
+ for h in self.hubs:
+ if h.hub_hash == hub_hash and (h.dest_name == (dest_name or DEFAULT_DEST_NAME)):
+ return h
+ hub = RRCHub(self, hub_hash, dest_name=dest_name, name=name)
+ self.hubs.append(hub)
+ self.save()
+ self._notify_change()
+ return hub
+
+ def remove_hub(self, hub):
+ with self._lock:
+ if hub in self.hubs:
+ self.hubs.remove(hub)
+ try:
+ hub.disconnect()
+ except Exception:
+ pass
+ self.save()
+ self._notify_change()
+
+ def find_hub(self, hub_hash, dest_name=None):
+ dn = dest_name or DEFAULT_DEST_NAME
+ with self._lock:
+ for h in self.hubs:
+ if h.hub_hash == hub_hash and h.dest_name == dn:
+ return h
+ return None
+
+ def _store_path(self):
+ return os.path.join(self.app.storagepath, "rrc_hubs")
+
+ def _history_root(self):
+ return os.path.join(self.app.storagepath, HISTORY_DIR_NAME)
+
+ def _history_dir(self, hub):
+ hub_key = hub.hub_hash.hex()
+ if hub.dest_name and hub.dest_name != DEFAULT_DEST_NAME:
+ suffix = hashlib.sha256(hub.dest_name.encode("utf-8")).hexdigest()[:8]
+ hub_key = hub_key + "__" + suffix
+ return os.path.join(self._history_root(), hub_key)
+
+ def _history_path(self, hub, room):
+ sanitized = HISTORY_FILENAME_SANITIZE_RE.sub("_", room or "")[:64]
+ room_hash = hashlib.sha256((room or "").encode("utf-8")).hexdigest()[:8]
+ filename = (sanitized + "_" + room_hash + ".log") if sanitized else (room_hash + ".log")
+ return os.path.join(self._history_dir(hub), filename)
+
+ def _ensure_history_dir(self, hub):
+ d = self._history_dir(hub)
+ os.makedirs(d, exist_ok=True)
+ return d
+
+ def load(self):
+ if self._loaded:
+ return
+ self._loaded = True
+ path = self._store_path()
+ if not os.path.isfile(path):
+ return
+ self._loading = True
+ try:
+ with open(path, "rb") as f:
+ data = f.read()
+ obj = cbor.decode(data)
+ if not isinstance(obj, dict):
+ return
+ entries = obj.get("hubs")
+ if not isinstance(entries, list):
+ return
+ for e in entries:
+ if not isinstance(e, dict):
+ continue
+ hh = e.get("hash")
+ if not isinstance(hh, (bytes, bytearray)):
+ continue
+ dn = e.get("dest_name")
+ nm = e.get("name")
+ hub = self.add_hub(bytes(hh), dest_name=dn if isinstance(dn, str) else None, name=nm if isinstance(nm, str) else None)
+ rooms = e.get("rooms")
+ if isinstance(rooms, list):
+ for r in rooms:
+ if isinstance(r, str):
+ hub.add_room(r)
+ parted = e.get("parted_rooms")
+ if isinstance(parted, list):
+ for r in parted:
+ if isinstance(r, str):
+ try:
+ rn = hub._normalize_room(r)
+ with hub._lock:
+ hub.messages.setdefault(rn, [])
+ except Exception:
+ pass
+ ar = e.get("auto_reconnect")
+ if isinstance(ar, bool):
+ hub.auto_reconnect = ar
+ al = e.get("auto_list")
+ if isinstance(al, bool):
+ hub.auto_list = al
+ aw = e.get("auto_who")
+ if isinstance(aw, bool):
+ hub.auto_who = aw
+ no = e.get("nick")
+ if isinstance(no, str) and no:
+ hub.nick_override = no
+ try:
+ hub._load_history()
+ except Exception as ex:
+ RNS.log("Failed to load RRC history for "+hub.name+": "+str(ex), RNS.LOG_ERROR)
+ except Exception as e:
+ RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR)
+ finally:
+ self._loading = False
+
+ def save(self):
+ if self._loading:
+ return
+ path = self._store_path()
+ tmp_path = path + ".tmp"
+ with self._save_lock:
+ try:
+ entries = []
+ with self._lock:
+ for h in self.hubs:
+ joined = set(h.rooms)
+ parted = set(h.messages.keys()) - joined
+ entry = {
+ "hash": h.hub_hash,
+ "dest_name": h.dest_name,
+ "name": h.name,
+ "rooms": sorted(joined),
+ "parted_rooms": sorted(parted),
+ "auto_reconnect": bool(h.auto_reconnect),
+ "auto_list": bool(h.auto_list),
+ "auto_who": bool(h.auto_who),
+ }
+ if isinstance(h.nick_override, str) and h.nick_override:
+ entry["nick"] = h.nick_override
+ entries.append(entry)
+ data = cbor.encode({"hubs": entries})
+ with open(tmp_path, "wb") as f:
+ f.write(data)
+ f.flush()
+ try: os.fsync(f.fileno())
+ except Exception: pass
+ os.replace(tmp_path, path)
+ except Exception as e:
+ #
+ #
+ #
+ #
+ try: os.unlink(tmp_path)
+ except Exception: pass
+
+ def shutdown(self):
+ for h in list(self.hubs):
+ try:
+ h.disconnect()
+ except Exception:
+ pass
diff --git a/nomadnet/__init__.py b/nomadnet/__init__.py
index 900858b..eb90424 100644
--- a/nomadnet/__init__.py
+++ b/nomadnet/__init__.py
@@ -3,11 +3,15 @@ import glob
from .NomadNetworkApp import NomadNetworkApp
from .Conversation import Conversation
+from .Directory import Directory
+from .Node import Node
from .ui import *
-modules = glob.glob(os.path.dirname(__file__)+"/*.py")
-__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')]
+py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
+pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
+modules = py_modules+pyc_modules
+__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
def panic():
os._exit(255)
\ No newline at end of file
diff --git a/nomadnet/_version.py b/nomadnet/_version.py
index c1336bd..c0f285b 100644
--- a/nomadnet/_version.py
+++ b/nomadnet/_version.py
@@ -1 +1 @@
-__version__ = "0.0.5"
\ No newline at end of file
+__version__ = "1.4.4"
diff --git a/nomadnet/examples/messageboard/README.md b/nomadnet/examples/messageboard/README.md
new file mode 100644
index 0000000..16e35e0
--- /dev/null
+++ b/nomadnet/examples/messageboard/README.md
@@ -0,0 +1,18 @@
+# lxmf_messageboard
+Simple message board that can be hosted on a NomadNet node, messages can be posted by 'conversing' with a unique peer, all messages are then forwarded to the message board.
+
+## How Do I Use It?
+A user can submit messages to the message board by initiating a chat with the message board peer, they are assigned a username (based on the first 5 characters of their address) and their messages are added directly to the message board. The message board can be viewed on a page hosted by a NomadNet node.
+
+An example message board can be found on the reticulum testnet hosted on the SolarExpress Node `` and the message board peer ``
+
+## How Does It Work?
+The message board page itself is hosted on a NomadNet node, you can place the message_board.mu into the pages directory. You can then run the message_board.py script which provides the peer that the users can send messages to. The two parts are joined together using umsgpack and a flat file system similar to NomadNet and Reticulum and runs in the background.
+
+## How Do I Set It Up?
+* Turn on node hosting in NomadNet
+* Put the `message_board.mu` file into `pages` directory in the config file for `NomadNet`. Edit the file to customise from the default page.
+* Run the `message_board.py` script (`python3 message_board.py` either in a `screen` or as a system service), this script uses `NomadNet` and `RNS` libraries and has no additional libraries that need to be installed. Take a note of the message boards address, it is printed on starting the board, you can then place this address in `message_board.mu` file to make it easier for users to interact the board.
+
+## Credits
+* This example application was written and contributed by @chengtripp
\ No newline at end of file
diff --git a/nomadnet/examples/messageboard/messageboard.mu b/nomadnet/examples/messageboard/messageboard.mu
new file mode 100644
index 0000000..24993d0
--- /dev/null
+++ b/nomadnet/examples/messageboard/messageboard.mu
@@ -0,0 +1,41 @@
+#!/bin/python3
+import time
+import os
+import RNS.vendor.umsgpack as msgpack
+
+message_board_peer = 'please_replace'
+userdir = os.path.expanduser("~")
+
+if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"):
+ configdir = "/etc/nomadmb"
+elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"):
+ configdir = userdir+"/.config/nomadmb"
+else:
+ configdir = userdir+"/.nomadmb"
+
+storagepath = configdir+"/storage"
+if not os.path.isdir(storagepath):
+ os.makedirs(storagepath)
+
+boardpath = configdir+"/storage/board"
+
+print('`!`F222`Bddd`cNomadNet Message Board')
+
+print('-')
+print('`a`b`f')
+print("")
+print("To add a message to the board just converse with the NomadNet Message Board at `[lxmf@{}]".format(message_board_peer))
+time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
+print("Last Updated: {}".format(time_string))
+print("")
+print('>Messages')
+print(" Date Time Username Message")
+f = open(boardpath, "rb")
+board_contents = msgpack.unpack(f)
+board_contents.reverse()
+
+for content in board_contents:
+ print("`a{}".format(content.rstrip()))
+ print("")
+
+f.close()
diff --git a/nomadnet/examples/messageboard/messageboard.py b/nomadnet/examples/messageboard/messageboard.py
new file mode 100644
index 0000000..8a71cd1
--- /dev/null
+++ b/nomadnet/examples/messageboard/messageboard.py
@@ -0,0 +1,187 @@
+# Simple message board that can be hosted on a NomadNet node, messages can be posted by 'conversing' with a unique peer, all messages are then forwarded to the message board.
+# https://github.com/chengtripp/lxmf_messageboard
+
+import RNS
+import LXMF
+import os, time
+from queue import Queue
+import RNS.vendor.umsgpack as msgpack
+
+display_name = "NomadNet Message Board"
+max_messages = 20
+
+def setup_lxmf():
+ if os.path.isfile(identitypath):
+ identity = RNS.Identity.from_file(identitypath)
+ RNS.log('Loaded identity from file', RNS.LOG_INFO)
+ else:
+ RNS.log('No Primary Identity file found, creating new...', RNS.LOG_INFO)
+ identity = RNS.Identity()
+ identity.to_file(identitypath)
+
+ return identity
+
+def lxmf_delivery(message):
+ # Do something here with a received message
+ RNS.log("A message was received: "+str(message.content.decode('utf-8')))
+
+ message_content = message.content.decode('utf-8')
+ source_hash_text = RNS.hexrep(message.source_hash, delimit=False)
+
+ #Create username (just first 5 char of your addr)
+ username = source_hash_text[0:5]
+
+ RNS.log('Username: {}'.format(username), RNS.LOG_INFO)
+
+ time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp))
+ new_message = '{} {}: {}\n'.format(time_string, username, message_content)
+
+ # Push message to board
+ # First read message board (if it exists
+ if os.path.isfile(boardpath):
+ f = open(boardpath, "rb")
+ message_board = msgpack.unpack(f)
+ f.close()
+ else:
+ message_board = []
+
+ #Check we aren't doubling up (this can sometimes happen if there is an error initially and it then gets fixed)
+ if new_message not in message_board:
+ # Append our new message to the list
+ message_board.append(new_message)
+
+ # Prune the message board if needed
+ while len(message_board) > max_messages:
+ RNS.log('Pruning Message Board')
+ message_board.pop(0)
+
+ # Now open the board and write the updated list
+ f = open(boardpath, "wb")
+ msgpack.pack(message_board, f)
+ f.close()
+
+ # Send reply
+ message_reply = '{}_{}_Your message has been added to the messageboard'.format(source_hash_text, time.time())
+ q.put(message_reply)
+
+def announce_now(lxmf_destination):
+ lxmf_destination.announce()
+
+def send_message(destination_hash, message_content):
+ try:
+ # Make a binary destination hash from a hexadecimal string
+ destination_hash = bytes.fromhex(destination_hash)
+
+ except Exception as e:
+ RNS.log("Invalid destination hash", RNS.LOG_ERROR)
+ return
+
+ # Check that size is correct
+ if not len(destination_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8:
+ RNS.log("Invalid destination hash length", RNS.LOG_ERROR)
+
+ else:
+ # Length of address was correct, let's try to recall the
+ # corresponding Identity
+ destination_identity = RNS.Identity.recall(destination_hash)
+
+ if destination_identity == None:
+ # No path/identity known, we'll have to abort or request one
+ RNS.log("Could not recall an Identity for the requested address. You have probably never received an announce from it. Try requesting a path from the network first. In fact, let's do this now :)", RNS.LOG_ERROR)
+ RNS.Transport.request_path(destination_hash)
+ RNS.log("OK, a path was requested. If the network knows a path, you will receive an announce with the Identity data shortly.", RNS.LOG_INFO)
+
+ else:
+ # We know the identity for the destination hash, let's
+ # reconstruct a destination object.
+ lxmf_destination = RNS.Destination(destination_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
+
+ # Create a new message object
+ lxm = LXMF.LXMessage(lxmf_destination, local_lxmf_destination, message_content, title="Reply", desired_method=LXMF.LXMessage.DIRECT)
+
+ # You can optionally tell LXMF to try to send the message
+ # as a propagated message if a direct link fails
+ lxm.try_propagation_on_fail = True
+
+ # Send it
+ message_router.handle_outbound(lxm)
+
+def announce_check():
+ if os.path.isfile(announcepath):
+ f = open(announcepath, "r")
+ announce = int(f.readline())
+ f.close()
+ else:
+ RNS.log('failed to open announcepath', RNS.LOG_DEBUG)
+ announce = 1
+
+ if announce > int(time.time()):
+ RNS.log('Recent announcement', RNS.LOG_DEBUG)
+ else:
+ f = open(announcepath, "w")
+ next_announce = int(time.time()) + 1800
+ f.write(str(next_announce))
+ f.close()
+ announce_now(local_lxmf_destination)
+ RNS.log('Announcement sent, expr set 1800 seconds', RNS.LOG_INFO)
+
+#Setup Paths and Config Files
+userdir = os.path.expanduser("~")
+
+if os.path.isdir("/etc/nomadmb") and os.path.isfile("/etc/nomadmb/config"):
+ configdir = "/etc/nomadmb"
+elif os.path.isdir(userdir+"/.config/nomadmb") and os.path.isfile(userdir+"/.config/nomadmb/config"):
+ configdir = userdir+"/.config/nomadmb"
+else:
+ configdir = userdir+"/.nomadmb"
+
+storagepath = configdir+"/storage"
+if not os.path.isdir(storagepath):
+ os.makedirs(storagepath)
+
+identitypath = configdir+"/storage/identity"
+announcepath = configdir+"/storage/announce"
+boardpath = configdir+"/storage/board"
+
+# Message Queue
+q = Queue(maxsize = 5)
+
+# Start Reticulum and print out all the debug messages
+reticulum = RNS.Reticulum(loglevel=RNS.LOG_VERBOSE)
+
+# Create a Identity.
+current_identity = setup_lxmf()
+
+# Init the LXMF router
+message_router = LXMF.LXMRouter(identity = current_identity, storagepath = configdir)
+
+# Register a delivery destination (for yourself)
+# In this example we use the same Identity as we used
+# to instantiate the LXMF router. It could be a different one,
+# but it can also just be the same, depending on what you want.
+local_lxmf_destination = message_router.register_delivery_identity(current_identity, display_name=display_name)
+
+# Set a callback for when a message is received
+message_router.register_delivery_callback(lxmf_delivery)
+
+# Announce node properties
+
+RNS.log('LXMF Router ready to receive on: {}'.format(RNS.prettyhexrep(local_lxmf_destination.hash)), RNS.LOG_INFO)
+announce_check()
+
+while True:
+
+ # Work through internal message queue
+ for i in list(q.queue):
+ message_id = q.get()
+ split_message = message_id.split('_')
+ destination_hash = split_message[0]
+ message = split_message[2]
+ RNS.log('{} {}'.format(destination_hash, message), RNS.LOG_INFO)
+ send_message(destination_hash, message)
+
+ # Check whether we need to make another announcement
+ announce_check()
+
+ #Sleep
+ time.sleep(10)
diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py
new file mode 100644
index 0000000..87f4d87
--- /dev/null
+++ b/nomadnet/examples/various/input_fields.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+import os
+env_string = ""
+for e in os.environ:
+ env_string += "{}={}\n".format(e, os.environ[e])
+
+template = """>Fields and Submitting Data
+
+Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables. This page contains a few examples.
+
+>> Read Environment Variables
+
+{@ENV}
+>>Examples of Fields and Submissions
+
+The following section contains a simple set of fields, and a few different links that submit the field data in different ways.
+
+-=
+
+
+>>>Text Fields
+An input field : `B444``b
+
+An masked field : `B444``b
+
+An small field : `B444`<8|small`test>`b, and some more text.
+
+A multi-row field : `B444`<40x4|notes`>`b
+
+Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b
+
+The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!.
+
+>> Checkbox Fields
+
+`B444`|sign_up|1|*`>`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
index 2ee25de..cb8591f 100644
--- a/nomadnet/nomadnet.py
+++ b/nomadnet/nomadnet.py
@@ -2,18 +2,27 @@
from ._version import __version__
+import io
import argparse
import nomadnet
-def program_setup(configdir, rnsconfigdir):
- app = nomadnet.NomadNetworkApp(configdir = configdir, rnsconfigdir = rnsconfigdir)
+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()
@@ -28,7 +37,18 @@ def main():
else:
rnsconfigarg = None
- program_setup(configarg, rnsconfigarg)
+ 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("")
diff --git a/nomadnet/ui/MenuUI.py b/nomadnet/ui/MenuUI.py
index b5bc34b..0d7bad9 100644
--- a/nomadnet/ui/MenuUI.py
+++ b/nomadnet/ui/MenuUI.py
@@ -4,5 +4,5 @@ import nomadnet
class MenuUI:
def __init__(self):
- RNS.log("Menu UI not implemented", RNS.LOG_ERROR)
+ 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
index 00587a8..413972d 100644
--- a/nomadnet/ui/TextUI.py
+++ b/nomadnet/ui/TextUI.py
@@ -1,9 +1,13 @@
import RNS
-import importlib
+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.ui.textui.images import ImageScreen
from nomadnet import NomadNetworkApp
COLORMODE_MONO = 1
@@ -11,61 +15,244 @@ COLORMODE_16 = 16
COLORMODE_88 = 88
COLORMODE_256 = 256
COLORMODE_TRUE = 2**24
-THEME_DARK = 0x01
-THEME_LIGHT = 0x02
+
+IMGLOAD_NEVER = 0x00
+IMGLOAD_MANUAL = 0x01
+IMGLOAD_AUTO = 0x02
+IMGLOAD_ALWAYS = 0x03
THEMES = {
- THEME_DARK: [
- # 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'),
- ('shortcutbar', 'black', 'light gray', 'standout', '#111', '#bbb'),
- ('body_text', 'white', 'default', 'default', '#0a0', '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'),
- ('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_delivered", 'black', 'light blue', 'standout', '#111', '#28b'),
- ("msg_header_failed", 'black', 'dark gray', 'standout', 'black', 'dark gray'),
- ("msg_warning_untrusted", 'black', 'dark red', 'standout', '#111', 'dark red'),
- ("list_focus", "black", "light gray", "standout", "#111", "#bbb"),
- ("list_off_focus", "black", "dark gray", "standout", "#111", "dark gray"),
- ("list_trusted", "light green", "default", "default", "#6b2", "default"),
- ("list_focus_trusted", "black", "light gray", "standout", "#180", "#bbb"),
- ("list_unknown", "dark gray", "default", "default", "light gray", "default"),
- ("list_untrusted", "dark red", "default", "default", "dark red", "default"),
- ("list_focus_untrusted", "black", "light gray", "standout", "#810", "#bbb"),
- ]
+ 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", "", ""),
+ ("interface_tile_focus", "light blue,bold", "default", "bold", "#5af,bold", "default"),
+ ("connected_status", "dark green", "default", "default", "dark green", "default"),
+ ("disconnected_status", "dark red", "default", "default", "dark red", "default"),
+ ("disabled_status", "dark red", "default", "default", "dark red", "default"),
+ ("placeholder", "dark gray", "default", "default", "dark gray", "default"),
+ ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"),
+ ("error", "light red,blink", "default", "blink", "#f44,blink", "default"),
+ ("irc_ts", "dark gray", "default", "default", "#888", "default"),
+ ("irc_nick_self", "light green", "default", "default", "#6c5", "default"),
+ ("irc_nick_peer", "light cyan", "default", "default", "#3cd", "default"),
+ ("irc_notice", "yellow", "default", "default", "#fd3", "default"),
+ ("irc_error", "light red", "default", "default", "#f55", "default"),
+ ("irc_system", "dark gray", "default", "default", "#888", "default"),
+ ("irc_mention", "light red,bold", "default", "bold", "#fb4,bold", "default"),
+
+ ],
+ },
+ THEME_LIGHT: {
+ "urwid_theme": [
+ # 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"),
+ ("interface_tile_focus", "dark blue,bold", "default", "bold", "#06c,bold", "default"),
+ ("connected_status", "dark green", "default", "default", "#4a0", "default"),
+ ("disconnected_status", "dark red", "default", "default", "#a22", "default"),
+ ("disabled_status", "dark red", "default", "default", "#a22", "default"),
+ ("placeholder", "light gray", "default", "default", "#999", "default"),
+ ("placeholder_text", "light gray", "default", "default", "#999", "default"),
+ ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"),
+ ("irc_ts", "dark gray", "default", "default", "#888", "default"),
+ ("irc_nick_self", "dark green", "default", "default", "#3a0", "default"),
+ ("irc_nick_peer", "dark cyan", "default", "default", "#077", "default"),
+ ("irc_notice", "brown", "default", "default", "#a70", "default"),
+ ("irc_error", "dark red", "default", "default", "#a22", "default"),
+ ("irc_system", "dark gray", "default", "default", "#888", "default"),
+ ("irc_mention", "dark red,bold", "default", "bold", "#c50,bold", "default"),
+ ],
+ }
+}
+
+CONFIG_SECTION_COLORS = {
+ "logging": ("yellow", "#fd3", "brown", "#a80"),
+ "client": ("light cyan", "#3cd", "dark cyan", "#077"),
+ "textui": ("light green", "#6c5", "dark green", "#2a0"),
+ "utilities": ("light gray", "#bbb", "dark gray", "#555"),
+ "rrc": ("light magenta", "#d7f", "dark magenta", "#a2a"),
+ "node": ("light blue", "#5af", "dark blue", "#06c"),
+ "printing": ("brown", "#b86", "brown", "#852"),
+ "reticulum": ("light magenta", "#a6f", "dark magenta", "#63c"),
+}
+
+for _section, (_d16, _dhi, _l16, _lhi) in CONFIG_SECTION_COLORS.items():
+ THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section, _d16, "default", "default", _dhi, "default"))
+ THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section+"_selected", _d16+",bold", "default", "bold", _dhi+",bold", "default"))
+ THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section, _l16, "default", "default", _lhi, "default"))
+ THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section+"_selected", _l16+",bold", "default", "bold", _lhi+",bold", "default"))
+
+GLYPHSETS = {
+ "plain": 1,
+ "unicode": 2,
+ "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"),
+ ("focus_arrow", ">", "\u25B8", "\u25B8"),
+ ("sep_dot", "|", "\u00B7", "\u00B7"),
+ ("connected", "+", "\u2713", "\U000f0201"),
+ ("disconnected", "x", "\u2717", "\U000f0202"),
+ ("file", "[F]", "\u25a4", "\uf15b"),
+ ("image", "[I]", "\u25a3", "\uf1c5"),
+ ("audio", "[~]", "\u266b", "\uf1c7"),
+ ("pin", "*", "\u2605", "\uf08d"),
+ ("copy", "[C]", "\u29c9", "\uf0c5"),
+ ("folder", "[+]", "\u25b8", "\uf07b"),
+ ("folder_open", "[-]", "\u25be", "\uf07c"),
+ ("fold_open", "-", "▾", "▾"),
+ ("fold_closed", "+", "▸", "▸"),
+ ("dropdown", " [v]", " ▾", " "),
}
class TextUI:
def __init__(self):
+ self.restore_ixon = False
+
+ try:
+ rval = os.system("stty -a | grep \"\\-ixon\"")
+ if rval == 0:
+ pass
+
+ else:
+ os.system("stty -ixon")
+ self.restore_ixon = True
+
+ except Exception as e:
+ RNS.log("Could not configure terminal flow control sequences, some keybindings may not work.", RNS.LOG_WARNING)
+ RNS.log("The contained exception was: "+str(e))
+
self.app = NomadNetworkApp.get_shared_instance()
self.app.ui = self
self.loop = None
- if importlib.util.find_spec("urwid") != None:
- import urwid
- else:
- RNS.log("The text-mode user interface requires Urwid to be installed on your system.", RNS.LOG_ERROR)
- RNS.log("You can install it with the command: pip3 install urwid", RNS.LOG_ERROR)
- nomadnet.panic()
-
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"]
+ 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"]
- palette = THEMES[theme]
+ self.palette = THEMES[theme]["urwid_theme"]
- self.screen = urwid.raw_display.Screen()
- self.screen.register_palette(palette)
+ 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 = ImageScreen()
+ self.screen.register_palette(self.palette)
self.main_display = Main.MainDisplay(self, self.app)
@@ -75,18 +262,44 @@ class TextUI:
else:
initial_widget = self.main_display.widget
- self.loop = urwid.MainLoop(initial_widget, screen=self.screen)
+ 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()
+ if intro_timeout <= 0 and self.app.peer_settings_notice: self.main_display.show_notice(self.app.peer_settings_notice)
self.loop.run()
def set_colormode(self, colormode):
+ self.colormode = colormode
self.screen.set_terminal_properties(colormode)
- self.screen.reset_default_terminal_palette()
+
+ if self.colormode < 256:
+ self.screen.reset_default_terminal_palette()
+ self.restore_palette = True
+
+ def unhandled_input(self, key):
+ if key == "ctrl q": self.main_display.quit()
+ elif key == "ctrl e": pass
def display_main(self, loop, user_data):
self.loop.widget = self.main_display.widget
+ if self.app.peer_settings_notice: self.main_display.show_notice(self.app.peer_settings_notice)
diff --git a/nomadnet/ui/__init__.py b/nomadnet/ui/__init__.py
index 8fd7944..cb06cf8 100644
--- a/nomadnet/ui/__init__.py
+++ b/nomadnet/ui/__init__.py
@@ -3,20 +3,28 @@ import glob
import RNS
import nomadnet
-modules = glob.glob(os.path.dirname(__file__)+"/*.py")
-__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')]
+py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
+pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
+modules = py_modules+pyc_modules
+__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
+THEME_DARK = 0x01
+THEME_LIGHT = 0x02
UI_NONE = 0x00
UI_MENU = 0x01
UI_TEXT = 0x02
UI_GRAPHICAL = 0x03
UI_WEB = 0x04
-UI_MODES = [UI_MENU, UI_TEXT, UI_GRAPHICAL, UI_WEB]
+UI_MODES = [UI_NONE, UI_MENU, UI_TEXT, UI_GRAPHICAL, UI_WEB]
def spawn(uimode):
if uimode in UI_MODES:
- RNS.log("Starting user interface...", RNS.LOG_INFO)
+ 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()
@@ -29,8 +37,11 @@ def spawn(uimode):
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)
+ 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..ad38f7a
--- /dev/null
+++ b/nomadnet/ui/textui/Browser.py
@@ -0,0 +1,2430 @@
+import RNS
+import LXMF
+import io
+import os
+import time
+import bisect
+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.Conversation import ConversationMessage
+from nomadnet.vendor.Scrollable import *
+from nomadnet.util import strip_modifiers
+from nomadnet.util import sanitize_name
+from nomadnet.ui.textui.images import _termlib
+from .Helpers import ClickableIcon, osc52_copy
+from .ReadlineEdit import ReadlineMixin, ReadlineEdit
+
+IMGLOAD_NEVER = 0x00
+IMGLOAD_MANUAL = 0x01
+IMGLOAD_AUTO = 0x02
+IMGLOAD_ALWAYS = 0x03
+
+class BrowserFrame(urwid.Frame):
+ def keypress(self, size, key):
+ 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 l":
+ self.delegate.load_images()
+ elif key == "ctrl x":
+ self.delegate.load_images(force_reload=True)
+ elif key == "ctrl s":
+ self.delegate.save_node_dialog()
+ elif key == "ctrl b":
+ self.delegate.save_node_dialog()
+ elif key == "ctrl g":
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen()
+ elif key == "ctrl y":
+ if self.delegate.app.config["textui"]["clipboard_copy"]:
+ self.delegate.copy_url()
+ elif self.focus_position == "body":
+ if key == "down" or key == "up":
+ try:
+ if hasattr(self.delegate, "page_list") and self.delegate.page_list:
+ def df(loop, user_data):
+ st = None
+ if self.delegate.page_list:
+ nf = self.delegate.page_list.focus
+ if hasattr(nf, "key_timeout"):
+ st = nf
+ elif hasattr(nf, "original_widget"):
+ no = nf.original_widget
+ if hasattr(no, "original_widget"):
+ st = no.original_widget
+ else:
+ if hasattr(no, "key_timeout"):
+ st = no
+
+ if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress):
+ st.keypress(None, None)
+
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.25, df)
+
+ except Exception as e:
+ RNS.log("Error while setting up cursor timeout. The contained exception was: "+str(e), RNS.LOG_ERROR)
+
+ return super(BrowserFrame, self).keypress(size, key)
+
+ else:
+ return super(BrowserFrame, self).keypress(size, key)
+
+class PageList(urwid.ListBox):
+ def __init__(self, body):
+ super().__init__(body)
+ self._page_size = (0, 0)
+ self._page_focus = None
+ self._page_layouts = {}
+
+ def invalidate_layout(self):
+ self._page_layouts = {}
+ self._invalidate()
+
+ def _layout(self, maxcol):
+ offsets = self._page_layouts.get(maxcol)
+ if offsets is None or len(offsets) != len(self.body) + 1:
+ layout_started = time.time()
+ offsets = [0]
+ for widget in self.body:
+ offsets.append(offsets[-1] + widget.rows((maxcol,), True))
+ self._page_layouts[maxcol] = offsets
+ # RNS.log(f" rebuilt {len(self.body)} widgets, {offsets[-1]} rows, width {maxcol}, {(time.time()-layout_started)*1000:.1f}ms", RNS.LOG_DEBUG)
+ return offsets
+
+ def calculate_visible(self, size, focus=False):
+ visible = super().calculate_visible(size, focus)
+ if visible is not None and self.body:
+ self._sync_layout(size[0], visible)
+ return visible
+
+ def _sync_layout(self, maxcol, visible):
+ # Validate the cached layout against the live geometry of the
+ # currently visible widgets, and delta-update the cached offsets when a
+ # visible widget has changed height since the layout was built without
+ # rescanning the entire document on every render.
+
+ if not self.body or not self._page_layouts: return
+
+ offsets = self._page_layouts.get(maxcol)
+ if offsets is None or len(offsets) != len(self.body) + 1: return
+
+ def sync_position(pos, live_rows):
+ if pos < 0 or pos >= len(self.body) or live_rows < 0: return
+ cached_rows = offsets[pos + 1] - offsets[pos]
+ if cached_rows == live_rows: return
+
+ delta = live_rows - cached_rows
+ for i in range(pos + 1, len(offsets)): offsets[i] += delta
+
+ # Other cached widths may also be affected by this change. Sync
+ # them using the changed widget's live rows at their widths.
+ for width, other in list(self._page_layouts.items()):
+ if width == maxcol or len(other) != len(offsets): continue
+ other_cached = other[pos + 1] - other[pos]
+ other_live = self.body[pos].rows((width,), True)
+ if other_cached == other_live: continue
+ other_delta = other_live - other_cached
+ for i in range(pos + 1, len(other)): other[i] += other_delta
+
+ middle = visible.middle
+ sync_position(middle.focus_pos, middle.focus_rows)
+ for item in visible.top.fill: sync_position(item.position, item.rows)
+ for item in visible.bottom.fill: sync_position(item.position, item.rows)
+
+ def render(self, size, focus=False):
+ self._page_size = size
+ if self.body and self.focus is not self._page_focus:
+ self._page_focus = self.focus
+ widget = self.focus
+ while widget is not None:
+ widget._invalidate()
+ widget = getattr(widget, "original_widget", None)
+ return super().render(size, focus)
+
+ def rows_max(self, size=None, focus=False):
+ size = size or self._page_size
+ return self._layout(size[0])[-1]
+
+ def get_scrollpos(self, size=None, focus=False):
+ size = size or self._page_size
+ if not self.body or not size[1]: return 0
+
+ middle, top, bottom = self.calculate_visible(size, focus)
+ if middle is None: return 0
+ return self._layout(size[0])[self.top_position(size)] + top.trim
+
+ def set_scrollpos(self, position):
+ size = self._page_size
+ maxcol, maxrow = size
+ if not self.body or not maxrow: return
+
+ offsets = self._layout(maxcol)
+ position = max(0, min(int(position), max(0, offsets[-1] - maxrow)))
+ pos = bisect.bisect_left(offsets, position, 0, len(self.body))
+ if pos == len(self.body) or offsets[pos] - position >= maxrow: pos -= 1
+
+ # Guard against the cached layout being inconsistent
+ # with the live widget geometry. Without it, change_focus
+ # can be called with an offset that the live target widget
+ # cannot satisfy, raising ListBoxError.
+ pos = max(0, min(pos, len(self.body) - 1))
+ offset_inset = offsets[pos] - position
+ if offset_inset > maxrow - 1: offset_inset = maxrow - 1
+ elif offset_inset < 0:
+ tgt_rows = max(1, self.body[pos].rows((maxcol,), True))
+ if offset_inset < 1 - tgt_rows: offset_inset = 1 - tgt_rows
+
+ self.change_focus(size, pos, offset_inset)
+
+ def top_position(self, size=None):
+ size = size or self._page_size
+ if not self.body or not size[1]: return 0
+ middle, top, bottom = self.calculate_visible(size, True)
+ if middle is None: return 0
+ return top.fill[-1].position if top.fill else middle.focus_pos
+
+ def scroll(self, rows):
+ self.set_scrollpos(self.get_scrollpos() + rows)
+
+ def focused_widget_name(self):
+ widget = self.focus
+ while getattr(widget, "original_widget", None) is not None: widget = widget.original_widget
+ return type(widget).__name__
+
+ def keypress(self, size, key):
+ self._page_size = size
+ key_started = time.time()
+ focus_before = self.focus_position if self.body else None
+ scroll_before = self.get_scrollpos(size, True)
+ unhandled_key = self.handle_key(size, key)
+
+ # RNS.log(f" {key}: {unhandled_key} focus {focus_before} -> {self.focus_position if self.body else None} ({self.focused_widget_name()}) scrollpos {scroll_before}->{self.get_scrollpos(size, True)}, {(time.time()-key_started) * 1000:.1f}ms", RNS.LOG_DEBUG)
+ return unhandled_key
+
+ def handle_key(self, size, key):
+ try:
+ if key in ("page up", "page down", "home", "end"):
+ focus_widget = self.focus
+ if focus_widget is not None and focus_widget.selectable():
+ key = focus_widget.keypress((size[0],), key)
+ if key is None:
+ self.make_cursor_visible(size)
+ return None
+
+ if key == "page down": self.scroll(size[1] - 1)
+ elif key == "page up": self.scroll(1 - size[1])
+ elif key == "home": self.set_scrollpos(0)
+ elif key == "end": self.set_scrollpos(self.rows_max(size))
+ return None
+
+ unhandled_key = super().keypress(size, key)
+ if key in ("up", "down"): return None
+ return unhandled_key
+
+ except Exception as e:
+ RNS.log(f"Error while handling browser keypress: {e}", RNS.LOG_ERROR)
+ RNS.trace_exception(e)
+ return None
+
+ def mouse_event(self, size, event, button, col, row, focus):
+ self._page_size = size
+ if button in (4, 5): return False
+ return super().mouse_event(size, event, button, col, row, focus)
+
+class Browser:
+ DEFAULT_PATH = "/page/index.mu"
+ DEFAULT_TIMEOUT = 10
+ 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_list = None
+ self.page_rows = []
+ self.page_partials = {}
+ self.page_images = {}
+ self.updater_running = False
+ self.image_updater_running = False
+ self.partial_updater_lock = Lock()
+ self.image_updater_lock = Lock()
+ self.image_cache_lock = Lock()
+ self.image_mode = self.app.config["textui"]["image_loading"]
+ self.image_rendering_supported = _termlib.is_kitty_supported() and self.image_mode != IMGLOAD_NEVER
+ self.build_display()
+
+ self.history = []
+ 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()
+
+ if self.image_rendering_supported: RNS.log(f"In-browser image rendering supported", RNS.LOG_DEBUG)
+ else: RNS.log(f"In-browser image rendering is not supported", RNS.LOG_DEBUG)
+
+ def current_url(self):
+ if self.destination_hash == None: return ""
+ 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_EXTREME)
+
+ # In-document anchor link (#name or empty #)
+ if link_target.startswith("#"):
+ def df(loop, user_data): self._jump_to_anchor(link_target[1:])
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df)
+ return
+
+ # rrc://[:]/ URL form
+ if link_target.startswith("rrc://"):
+ self.handle_rrc_link(link_target[6:])
+ return
+
+ components = link_target.split("@")
+ destination_type = None
+
+ if len(components) == 2:
+ destination_type = self.expand_shorthands(components[0])
+ link_target = components[1]
+ elif link_target.startswith("p:"):
+ comps = link_target.split(":")
+ if len(comps) > 1: partial_ids = comps[1:]
+ destination_type = "partial"
+ else:
+ destination_type = "nomadnetwork.node"
+ link_target = components[0]
+
+ if destination_type == "nomadnetwork.node":
+ if self.status >= Browser.DISCONECTED:
+ RNS.log("Browser handling link to: "+str(link_target), RNS.LOG_DEBUG)
+ self.browser_footer = urwid.Text("Opening link to: "+str(link_target))
+ try:
+ self.retrieve_url(link_target, request_data)
+ except Exception as e:
+ self.browser_footer = urwid.Text("Could not open link: "+str(e))
+ self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+ else:
+ RNS.log("Browser already handling link, cannot handle link to: "+str(link_target), RNS.LOG_DEBUG)
+
+ elif destination_type == "lxmf.delivery":
+ RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG)
+ self.handle_lxmf_link(link_target)
+
+ elif destination_type == "rrc.hub.session":
+ RNS.log("Passing RRC link to handler", RNS.LOG_DEBUG)
+ self.handle_rrc_link(link_target)
+
+ elif destination_type == "partial":
+ if partial_ids != None and len(partial_ids) > 0: self.handle_partial_updates(partial_ids)
+
+ else:
+ RNS.log("No known handler for destination type "+str(destination_type), RNS.LOG_DEBUG)
+ self.browser_footer = urwid.Text("Could not open link: "+"No known handler for destination type "+str(destination_type))
+ self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+
+ def _jump_to_anchor(self, name):
+ anchors = getattr(self.attr_maps, "anchors", None) or {}
+ header_rows = getattr(self.attr_maps, "header_rows", None) or []
+
+ target_idx = None
+ if name:
+ target_idx = anchors.get(name)
+ if target_idx is None:
+ self.browser_footer = urwid.Text("Unknown anchor: #"+name)
+ self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+ return
+ else:
+ current = 0
+ try:
+ current = self.page_rows[self.page_list.top_position()]
+ except Exception:
+ current = 0
+ for hr in header_rows:
+ if hr > current:
+ target_idx = hr
+ break
+ if target_idx is None:
+ return
+
+ self.reveal_index(int(target_idx))
+
+ try:
+ self.page_list.set_focus(self.page_rows.index(int(target_idx)))
+ self.page_list.set_focus_valign("top")
+
+ except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR)
+
+ def _content_cols(self):
+ try:
+ lw = self.delegate.given_list_width
+ screen_cols = self.app.ui.loop.screen.get_cols_rows()[0]
+ browser_cols = screen_cols-lw-2
+ cols = browser_cols
+
+ except Exception as e:
+ RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING)
+ RNS.trace_exception(e)
+ cols = 100
+
+ return max(40, cols)
+
+ def handle_lxmf_link(self, link_target):
+ try:
+ def san(name):
+ if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name)
+ else: return strip_modifiers(name)
+
+ if not type(link_target) is str:
+ raise ValueError("Invalid data type for LXMF link")
+
+ if len(link_target) != (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2:
+ raise ValueError("Invalid length for LXMF link")
+
+ try:
+ bytes.fromhex(link_target)
+
+ except Exception as e:
+ raise ValueError("Could not decode destination hash from LXMF link")
+
+ existing_conversations = nomadnet.Conversation.conversation_list(self.app)
+
+ source_hash_text = link_target
+ display_name_data = RNS.Identity.recall_app_data(bytes.fromhex(source_hash_text))
+
+ display_name = None
+ if display_name_data != None:
+ display_name = san(LXMF.display_name_from_app_data(display_name_data))
+
+ if not source_hash_text in [c[0] for c in existing_conversations]:
+ entry = DirectoryEntry(bytes.fromhex(source_hash_text), display_name=display_name)
+ self.app.directory.remember(entry)
+
+ new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True)
+ self.app.ui.main_display.sub_displays.conversations_display.update_conversation_list()
+
+ self.app.ui.main_display.sub_displays.conversations_display.display_conversation(None, source_hash_text)
+ self.app.ui.main_display.show_conversations(None)
+
+ except Exception as e:
+ RNS.log("Error while starting conversation from link. The contained exception was: "+str(e), RNS.LOG_ERROR)
+ self.browser_footer = urwid.Text("Could not open LXMF link: "+str(e))
+ self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+
+
+ def handle_rrc_link(self, link_target):
+ try:
+ if not isinstance(link_target, str):
+ raise ValueError("invalid RRC link payload")
+ rest = link_target.strip()
+ if rest.startswith("/"):
+ rest = rest[1:]
+ hub_part, _, room = rest.partition("/")
+ hex_part, _, dest = hub_part.partition(":")
+ hex_part = hex_part.strip()
+ dest = dest.strip() or None
+ try:
+ hub_hash = bytes.fromhex(hex_part)
+ except Exception:
+ raise ValueError("invalid hub hash")
+ expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
+ if len(hub_hash) != expected_len:
+ raise ValueError("hub hash must be "+str(expected_len)+" bytes")
+
+ room = room.strip().lstrip("#").strip()
+ room_norm = room.lower() if room else None
+
+ existing = self.app.rrc.find_hub(hub_hash, dest_name=dest)
+ self.app.ui.main_display.show_channels(None)
+ channels = self.app.ui.main_display.sub_displays.channels_display
+
+ if existing is not None:
+ channels.update_list()
+ if room_norm:
+ channels._select_room(None, (existing, room_norm))
+ else:
+ channels._select_hub(None, existing)
+ return
+
+
+ channels.confirm_new_hub_dialog(hub_hash, dest, room_norm)
+
+ except Exception as e:
+ RNS.log("Could not open RRC link: "+str(e), RNS.LOG_ERROR)
+ self.browser_footer = urwid.Text("Could not open RRC link: "+str(e))
+ self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+
+
+ def micron_released_focus(self):
+ if self.delegate != None:
+ self.delegate.focus_lists()
+
+ def build_display(self):
+ self.browser_header = urwid.Text("")
+ self.browser_footer = urwid.Text("")
+
+ self.page_list = None
+ self.page_rows = []
+ self.page_partials = {}
+ self.page_images = {}
+ self.browser_body = urwid.Filler(
+ urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER),
+ urwid.MIDDLE,
+ )
+
+ self.frame = BrowserFrame(self.browser_body, header=self.browser_header, footer=self.browser_footer)
+ self.frame.delegate = self
+ self.linebox = urwid.LineBox(self.frame, title="Remote Node")
+ self.display_widget = urwid.AttrMap(self.linebox, "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_list = None
+ self.page_rows = []
+ self.page_partials = {}
+ self.page_images = {}
+ self.browser_body = urwid.Filler(
+ urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER),
+ urwid.MIDDLE,
+ )
+ self.browser_footer = urwid.Text("")
+ self.browser_header = urwid.Text("")
+ self.linebox.set_title("Remote Node")
+ else:
+ self.display_widget.set_attr_map({None: "body_text"})
+ self.browser_header = self.make_control_widget()
+ if self.page_background_color != None or self.page_foreground_color != None:
+ style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color))
+ self.browser_header.set_attr_map({None: style_name})
+
+ if self.destination_hash != None:
+ remote_display_string = self.app.directory.simplest_display_str(self.destination_hash)
+ else:
+ 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):
+ self._purge_page_images()
+ self.page_rows = list(range(len(self.attr_maps)))
+ self.page_list = PageList(urwid.SimpleFocusListWalker(list(self.attr_maps)))
+ self.page_partials = {}
+ self.page_images = {}
+ self.browser_body = urwid.AttrMap(ScrollBar(self.page_list, thumb_char="\u2503", trough_char=" "), "scrollbar")
+ self.detect_partials()
+ if self.should_load_images(): self.detect_images()
+ self.init_folds()
+
+ def refresh_page_layout(self):
+ if self.page_list is not None: self.page_list.invalidate_layout()
+
+ def image_cache_path(self, url):
+ destination_hash, path = self.parse_url(url)
+ url = f"{RNS.hexrep(destination_hash, delimit=False)}:{path}"
+ url_hash = self.url_hash(url)
+ if not url_hash: return None
+ else:
+ path = f"{self.app.imagecachepath}/{url_hash}"
+ return path
+
+ def resolve_image(self, url):
+ cachepath = self.image_cache_path(url)
+ if not cachepath: return None
+ else:
+ if os.path.exists(cachepath): return cachepath
+ else: return None
+
+ def _purge_page_images(self):
+ if not self.image_rendering_supported: return
+ try:
+ screen = getattr(getattr(self.app.ui, "loop", None), "screen", None)
+ if hasattr(screen, "purge_images"): screen.purge_images()
+ except Exception: pass
+
+ def should_load_images(self):
+ if self.image_mode == IMGLOAD_NEVER: return False
+ elif self.image_mode == IMGLOAD_MANUAL: return False
+ elif self.image_mode == IMGLOAD_ALWAYS: return True
+ elif self.image_mode == IMGLOAD_AUTO:
+ if self.destination_hash == self.loopback: return True
+ rtt_limit = 1.5; edr_limit = 10000
+ if self.link:
+ rtt = self.link.rtt
+ edr = self.link.get_expected_rate() or self.last_response_speed()
+ rtt_ok = rtt and rtt < rtt_limit
+ edr_ok = edr and edr > edr_limit
+ RNS.log(f"Checking image load conditions, RTT is {RNS.prettyshorttime(rtt) if rtt else 'unknown'}, EDR is {RNS.prettyspeed(edr) if edr else 'unknown'}", RNS.LOG_DEBUG)
+ if rtt_ok and edr_ok: return True
+ else: return False
+
+ return False
+
+ def load_images(self, force_reload=False):
+ if not self.image_rendering_supported: return
+ elif self.image_mode == IMGLOAD_NEVER: return
+ else: self.detect_images(force_reload=force_reload)
+
+ def detect_images(self, force_reload=False):
+ if not self.image_rendering_supported: return
+ for w in self.attr_maps:
+ o = w._original_widget
+ if hasattr(o, "_contained_image"): o = o._contained_image
+ if hasattr(o, "image_url"):
+ resolved_image = self.resolve_image(o.image_url)
+ if resolved_image and force_reload:
+ RNS.log(f"Clearing image cache for {o.image_url} ({resolved_image})", RNS.LOG_DEBUG)
+ try: os.unlink(resolved_image)
+ except Exception as e: RNS.log(f"Could not clear image cache for {o.image_url}: {e}", RNS.LOG_ERROR)
+ resolved_image = None
+
+ if force_reload: continue
+ elif resolved_image and self.image_rendering_supported: o.load(resolved_image, remote_source=True)
+ else:
+ image = { "widget": o, "id": o.image_id, "url": o.image_url, "refresh": None, "updated": None,
+ "update_requested": None, "request_id": None, "link": None, "failed": False, "pr_throttle": 0,
+ "progress_updated": None, "previous_progress": 0 }
+
+ self.page_images[o.image_id] = image
+
+ self.refresh_page_layout()
+ if force_reload: self.reload()
+ elif len(self.page_images) > 0: self.start_image_updater()
+
+ def start_image_updater(self):
+ if not self.image_rendering_supported: return
+ if not self.image_updater_running: self.update_images()
+
+ def update_images(self, loop=None, user_data=None):
+ if not self.image_rendering_supported: return
+ def job():
+ if self.image_updater_lock.locked(): return
+ with self.image_updater_lock:
+ self.image_updater_running = True
+
+ for iid in self.page_images:
+ try:
+ image = self.page_images[iid]
+ if image["failed"]: continue
+ if not image["updated"] or (image["refresh"] != None and time.time() > image["updated"]+image["refresh"]):
+ image["update_requested"] = time.time()
+ self.__load_image(image)
+ while not image["updated"] and not image["failed"] and len(self.page_images) > 0: time.sleep(0.2)
+
+ except Exception as e: RNS.log(f"Error updating page image: {e}", RNS.LOG_ERROR)
+
+ if len(self.page_images) > 0: self.app.ui.loop.set_alarm_in(1, self.update_images)
+ else: self.image_updater_running = False
+
+ threading.Thread(target=job, daemon=True).start()
+
+ def __load_image(self, image):
+ if image["failed"] == True: return
+ w = image["widget"]
+ url = image["url"]
+ try: image_destination_hash, path = self.parse_url(image["url"])
+ except Exception as e:
+ RNS.log(f"Could not parse image URL: {e}", RNS.LOG_ERROR)
+ image["failed"] = True
+ w.notice(f"Could not load image {url}: {e}")
+ return
+
+ if image_destination_hash == self.loopback:
+ local_image = path.replace("/media/", "")
+ local_path = f"{self.app.pagespath}/{local_image}"
+
+ if self.app.node:
+ image_ext = os.path.splitext(local_path)[-1]
+ if not image_ext in self.app.node.NATIVE_MEDIA_EXTS:
+ RNS.log(f"Non-native media type for {local_path}, attempting auto-conversion", RNS.LOG_DEBUG)
+ local_path = self.app.node.convert_media_to_webp(local_path)
+
+ if self.image_rendering_supported:
+ w.load(local_path)
+ self.refresh_page_layout()
+ image["updated"] = time.time()
+ return
+
+ if not RNS.Transport.has_path(image_destination_hash):
+ if time.time() <= image["pr_throttle"]: return
+ else:
+ image["pr_throttle"] = time.time()+15
+ RNS.log(f"Requesting path for image: {path}", RNS.LOG_DEBUG)
+ RNS.Transport.request_path(image_destination_hash)
+ pr_time = time.time()+self.path_timeout(image_destination_hash)
+ while not RNS.Transport.has_path(image_destination_hash):
+ if time.time() > pr_time: return
+ time.sleep(0.25)
+
+ if not image["link"]:
+ if self.link and self.link.destination.hash == image_destination_hash and self.link.status == RNS.Link.ACTIVE:
+ image["link"] = self.link
+ RNS.log(f"Re-using existing page link for image {url}", RNS.LOG_EXTREME)
+
+ if not image["link"]:
+ for pid in self.page_images:
+ other_image = self.page_images[pid]
+ if other_image["link"]:
+ existing_link = other_image["link"]
+ if existing_link.destination.hash == image_destination_hash and existing_link.status == RNS.Link.ACTIVE:
+ RNS.log(f"Re-using existing link for image {url}", RNS.LOG_EXTREME)
+ image["link"] = existing_link
+ break
+
+ if not image["link"] or (image["link"] and image["link"].status) == RNS.Link.CLOSED:
+ RNS.log(f"Establishing link for image: {image_destination_hash} / {path}", RNS.LOG_DEBUG)
+ identity = RNS.Identity.recall(image_destination_hash)
+ destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects)
+ w.notice(f"Establishing link...")
+
+ def f_established(wd):
+ def established(link):
+ RNS.log(f"Link established for image: {path}", RNS.LOG_DEBUG)
+ wd.notice(f"Link established")
+ return established
+
+ def f_closed(wd):
+ def closed(link):
+ RNS.log(f"Link closed for image: {path}", RNS.LOG_DEBUG)
+ image["link"] = None
+ image["failed"] = True
+ wd.notice(f"Link closed during transfer")
+ return closed
+
+ image["link"] = RNS.Link(destination, established_callback = f_established(w), closed_callback = f_closed(w))
+ timeout = time.time()+max(self.timeout, image["link"].establishment_timeout)
+ while image["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1)
+
+ if image["link"] and image["link"].status == RNS.Link.ACTIVE and image["request_id"] == None:
+ RNS.log(f"Sending request for image: {path}", RNS.LOG_DEBUG)
+ receipt = image["link"].request("/media", data=self.__get_image_request_data(image), response_callback = self.image_received,
+ failed_callback = self.image_failed, progress_callback = self.image_progressed)
+ w.notice(f"Request sent")
+
+ if receipt: image["request_id"] = receipt.request_id
+ else:
+ RNS.log(f"Image request failed", RNS.LOG_ERROR)
+ image["failed"] = True
+ w.notice(f"Request failed")
+
+ def __get_image_request_data(self, image):
+ destination_hash, path = self.parse_url(image["url"])
+ request_data = {"path": path, "key": None}
+ return request_data
+
+ def image_failed(self, request_receipt):
+ RNS.log("Loading page image failed", RNS.LOG_ERROR)
+ for iid in self.page_images:
+ image = self.page_images[iid]
+ if image["request_id"] == request_receipt.request_id:
+ try:
+ image["updated"] = time.time()
+ image["request_id"] = None
+ image["failed"] = True
+ url = image["url"]
+ w = image["widget"]
+ w.notice(f"Could not load image {url}")
+ except Exception as e:
+ RNS.log(f"Error in image failed callback: {e}", RNS.LOG_ERROR)
+ RNS.trace_exception(e)
+
+ def image_progressed(self, request_receipt):
+ for iid in self.page_images:
+ i = self.page_images[iid]
+ if i["request_id"] == request_receipt.request_id:
+ try:
+ response_progress = request_receipt.progress
+ response_time = request_receipt.get_response_time()
+ response_size = request_receipt.response_size
+ response_transfer_size = request_receipt.response_transfer_size
+
+ w = i["widget"]
+ now = time.time()
+ if i["progress_updated"] == None: i["progress_updated"] = now
+ if now > i["progress_updated"]+0.5:
+ td = now - i["progress_updated"]
+ pd = response_progress - i["previous_progress"]
+ bd = pd*response_transfer_size
+ response_speed = (bd/td)*8
+ i["previous_progress"] = response_progress
+ i["progress_updated"] = now
+ stats = f"{round(response_progress*100, 2)}% ({RNS.prettysize(response_progress*response_size)} of {RNS.prettysize(response_size)}) - {RNS.prettyspeed(response_speed)}"
+ w.notice(stats)
+
+ except Exception as e: RNS.trace_exception(e)
+
+ def image_received(self, request_receipt):
+ if not request_receipt.response: self.image_failed(request_receipt)
+ for iid in self.page_images:
+ image = self.page_images[iid]
+ if image["request_id"] == request_receipt.request_id:
+ try:
+ image["updated"] = image["update_requested"]
+ image["request_id"] = None
+ w = image["widget"]
+ w.notice(f"Image loaded")
+
+ except Exception as e:
+ RNS.log(f"Error while loading received image: {e}", RNS.LOG_ERROR)
+ RNS.trace_exception(e)
+ image["failed"] = True
+
+ try:
+ url = image["url"]
+ cachepath = self.image_cache_path(url)
+ if isinstance(request_receipt.response, bytes):
+ with open(cachepath, "wb") as f: f.write(request_receipt.response)
+
+ elif type(request_receipt.response) == io.BufferedReader:
+ if request_receipt.metadata != None:
+ file_handle = request_receipt.response
+ file_destination = cachepath
+ shutil.move(file_handle.name, file_destination)
+ threading.Thread(target=self.clean_image_cache, daemon=True).start()
+
+ resolved_path = self.resolve_image(url)
+ if resolved_path and self.image_rendering_supported:
+ w.load(resolved_path, remote_source=True)
+ self.refresh_page_layout()
+
+ except Exception as e:
+ RNS.log("Error while handling image response: "+str(e), RNS.LOG_ERROR)
+ image["failed"] = True
+
+ IMAGE_CACHE_MAX_FILES = 256
+ IMAGE_CACHE_MAX_BYTES = 192*1024*1024
+ def clean_image_cache(self):
+ if self.image_cache_lock.locked(): return
+ with self.image_cache_lock:
+ try:
+ entries = []
+ for name in os.listdir(self.app.imagecachepath):
+ path = os.path.join(self.app.imagecachepath, name)
+ try:
+ st = os.stat(path)
+ if st.st_size > 0: entries.append((st.st_mtime, st.st_size, path))
+ except OSError as exc: RNS.log(f"Could not get cached file information for {path}: {e}", RNS.LOG_DEBUG)
+
+ cleaned_b = 0; cleaned_c = 0
+ total = sum(e[1] for e in entries)
+ entries.sort()
+ while len(entries) > self.IMAGE_CACHE_MAX_FILES or total > self.IMAGE_CACHE_MAX_BYTES:
+ _, size, path = entries.pop(0)
+ total -= size
+ cleaned_b += size
+ cleaned_c += 1
+ try: os.unlink(path)
+ except OSError as exc: RNS.log(f"Could not remove cached file {path}: {exc}")
+ if cleaned_b or cleaned_c: RNS.log(f"Cleaned {RNS.prettysize(cleaned_b)} / {cleaned_c} image{'s' if cleaned_c != 1 else ''} from cache", RNS.LOG_DEBUG)
+ except Exception as exc: RNS.log(f"Error while cleaning browser image cache: {exc}", RNS.LOG_ERROR)
+
+ # Render an arbitrary markup buffer (used by the page editor preview) and
+ # return the content widget, with partials/folds/fields wired as usual
+ def render_markup_buffer(self, markup):
+ self.status = Browser.DONE
+ self.markup = markup
+ self.page_background_color = None
+ self.page_foreground_color = None
+ for tag, attr in (("#!bg=", "page_background_color"), ("#!fg=", "page_foreground_color")):
+ pos = markup.find(tag)
+ if pos >= 0:
+ endpos = markup.find("\n", pos)
+ if endpos < 0: endpos = len(markup)
+ if endpos-(pos+len(tag)) in (3, 6):
+ setattr(self, attr, markup[pos+len(tag):endpos])
+ try:
+ self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
+ except Exception as e:
+ self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render preview: {e}"), "browser_inactive")]
+ self.update_page_display()
+ return self.browser_body
+
+ def init_folds(self):
+ self.fold_sections = {}
+ self.collapsed_headings = set()
+ attrmaps = self.attr_maps
+ header_levels = getattr(attrmaps, "header_levels", None) or {}
+ row_levels = getattr(attrmaps, "row_levels", None) or {}
+ collapsibles = getattr(attrmaps, "collapsibles", None) or {}
+ if not collapsibles:
+ return
+
+ n = len(attrmaps)
+ for idx in sorted(collapsibles.keys()):
+ widget = self.collapsible_widget_at(idx)
+ if widget is None: continue
+ depth = header_levels.get(idx, 1)
+ end = n
+ for i in range(idx+1, n):
+ if row_levels.get(i, depth) < depth or \
+ (i in header_levels and header_levels.get(i, 1) <= depth):
+ end = i; break
+ self.fold_sections[widget] = (idx+1, end)
+ if collapsibles[idx]:
+ self.collapsed_headings.add(widget)
+ try: widget.set_collapsed(True)
+ except Exception: pass
+
+ if self.collapsed_headings:
+ self.rebuild_visible()
+
+ def collapsible_widget_at(self, idx):
+ try:
+ o = self.attr_maps[idx]._original_widget
+ if getattr(o, "is_collapsible_heading", False): return o
+ except Exception: pass
+ return None
+
+ def hidden_indices(self):
+ hidden = set()
+ for widget in getattr(self, "collapsed_headings", ()):
+ rng = getattr(self, "fold_sections", {}).get(widget)
+ if rng: hidden.update(range(rng[0], rng[1]))
+ return hidden
+
+ def rebuild_visible(self):
+ page = getattr(self, "page_list", None)
+ if page is None: return
+ hidden = self.hidden_indices()
+ self.page_rows = [i for i in range(len(self.attr_maps)) if i not in hidden]
+ focus = page.focus_position if page.body else 0
+ page.body[:] = [self.attr_maps[i] for i in self.page_rows]
+ page.invalidate_layout()
+ if page.body: page.set_focus(min(focus, len(page.body)-1))
+
+ def fold_changed(self, heading_widget):
+ if getattr(heading_widget, "collapsed", False):
+ self.collapsed_headings.add(heading_widget)
+ else:
+ self.collapsed_headings.discard(heading_widget)
+ self.rebuild_visible()
+ try:
+ for pos, w in enumerate(self.page_list.body):
+ if getattr(w, "_original_widget", None) is heading_widget:
+ self.page_list.set_focus(pos)
+ break
+ except Exception: pass
+
+ def reveal_index(self, idx):
+ changed = False
+ for widget in list(getattr(self, "collapsed_headings", ())):
+ rng = getattr(self, "fold_sections", {}).get(widget)
+ if rng and rng[0] <= idx < rng[1]:
+ self.collapsed_headings.discard(widget)
+ try: widget.set_collapsed(False)
+ except Exception: pass
+ changed = True
+ if changed: self.rebuild_visible()
+
+ def parse_url(self, url):
+ path = None
+ destination_hash = None
+ components = url.split(":")
+ if len(components) == 1:
+ if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2:
+ try: destination_hash = bytes.fromhex(components[0])
+ except Exception as e: raise ValueError("Malformed URL")
+ path = Browser.DEFAULT_PATH
+ else: raise ValueError("Malformed URL")
+ elif len(components) == 2:
+ if len(components[0]) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2:
+ try: destination_hash = bytes.fromhex(components[0])
+ except Exception as e: raise ValueError("Malformed URL")
+ path = components[1]
+ if len(path) == 0: path = Browser.DEFAULT_PATH
+ else:
+ if len(components[0]) == 0:
+ if self.destination_hash != None:
+ destination_hash = self.destination_hash
+ path = components[1]
+ if len(path) == 0: path = Browser.DEFAULT_PATH
+ else: raise ValueError("Malformed URL")
+ else: raise ValueError("Malformed URL")
+ else: raise ValueError("Malformed URL")
+
+ return destination_hash, path
+
+ def detect_partials(self):
+ for w in self.attr_maps:
+ o = w._original_widget
+ if hasattr(o, "partial_hash"):
+ RNS.log(f"Found partial: {o.partial_hash} / {o.partial_url} / {o.partial_refresh}")
+ partial = {"hash": o.partial_hash, "id": o.partial_id, "url": o.partial_url, "fields": o.partial_fields,
+ "refresh": o.partial_refresh, "content": None, "updated": None, "update_requested": None, "request_id": None,
+ "destination": None, "link": None, "pile": o, "attr_maps": None, "failed": False, "pr_throttle": 0}
+
+ self.page_partials[o.partial_hash] = partial
+
+ if len(self.page_partials) > 0: self.start_partial_updater()
+
+ def partial_failed(self, request_receipt):
+ RNS.log("Loading page partial failed", RNS.LOG_ERROR)
+ for pid in self.page_partials:
+ partial = self.page_partials[pid]
+ if partial["request_id"] == request_receipt.request_id:
+ try:
+ partial["updated"] = time.time()
+ partial["request_id"] = None
+ partial["content"] = None
+ partial["attr_maps"] = None
+ url = partial["url"]
+ pile = partial["pile"]
+ pile.contents = [(urwid.Text(f"Could not load partial {url}: The resource transfer failed"), pile.options())]
+ self.refresh_page_layout()
+ except Exception as e:
+ RNS.log(f"Error in partial failed callback: {e}", RNS.LOG_ERROR)
+ RNS.trace_exception(e)
+
+ def partial_progressed(self, request_receipt):
+ pass
+
+ def partial_received(self, request_receipt):
+ for pid in self.page_partials:
+ partial = self.page_partials[pid]
+ if partial["request_id"] == request_receipt.request_id:
+ try:
+ partial["updated"] = partial["update_requested"]
+ partial["request_id"] = None
+ partial["content"] = request_receipt.response.decode("utf-8").rstrip()
+ partial["attr_maps"] = markup_to_attrmaps(strip_modifiers(partial["content"]), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
+ pile = partial["pile"]
+ pile.contents = [(e, pile.options()) for e in partial["attr_maps"]]
+ self.refresh_page_layout()
+
+ except Exception as e:
+ RNS.trace_exception(e)
+
+ def __load_partial(self, partial):
+ if partial["failed"] == True: return
+ try: partial_destination_hash, path = self.parse_url(partial["url"])
+ except Exception as e:
+ RNS.log(f"Could not parse partial URL: {e}", RNS.LOG_ERROR)
+ partial["failed"] = True
+ pile = partial["pile"]
+ url = partial["url"]
+ pile.contents = [(urwid.Text(f"Could not load partial {url}: {e}"), pile.options())]
+ self.refresh_page_layout()
+ return
+
+ if partial_destination_hash != self.loopback and not RNS.Transport.has_path(partial_destination_hash):
+ if time.time() <= partial["pr_throttle"]: return
+ else:
+ partial["pr_throttle"] = time.time()+15
+ RNS.log(f"Requesting path for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME)
+ RNS.Transport.request_path(partial_destination_hash)
+ pr_time = time.time()+self.path_timeout(partial_destination_hash)
+ while not RNS.Transport.has_path(partial_destination_hash):
+ if time.time() > pr_time: return
+ time.sleep(0.25)
+
+ for pid in self.page_partials:
+ other_partial = self.page_partials[pid]
+ if other_partial["link"]:
+ existing_link = other_partial["link"]
+ if existing_link.destination.hash == partial_destination_hash and existing_link.status == RNS.Link.ACTIVE:
+ RNS.log(f"Re-using existing link: {existing_link}", RNS.LOG_EXTREME)
+ partial["link"] = existing_link
+ break
+
+ if not partial["link"] or partial["link"].status == RNS.Link.CLOSED:
+ RNS.log(f"Establishing link for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME)
+ identity = RNS.Identity.recall(partial_destination_hash)
+ destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects)
+
+ def established(link):
+ RNS.log(f"Link established for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME)
+
+ def closed(link):
+ RNS.log(f"Link closed for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME)
+ partial["link"] = None
+
+ partial["link"] = RNS.Link(destination, established_callback = established, closed_callback = closed)
+ timeout = time.time()+max(self.timeout, partial["link"].establishment_timeout)
+ while partial["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1)
+
+ if partial["link"] and partial["link"].status == RNS.Link.ACTIVE and partial["request_id"] == None:
+ RNS.log(f"Sending request for partial: {partial_destination_hash} / {path}", RNS.LOG_EXTREME)
+ receipt = partial["link"].request(path, data=self.__get_partial_request_data(partial), response_callback = self.partial_received,
+ failed_callback = self.partial_failed, progress_callback = self.partial_progressed)
+
+ if receipt: partial["request_id"] = receipt.request_id
+ else: RNS.log(f"Partial request failed", RNS.LOG_ERROR)
+
+ def __get_partial_request_data(self, partial):
+ request_data = None
+ if partial["fields"] != None:
+ link_data = partial["fields"]
+ link_fields = []
+ request_data = {}
+ all_fields = True if "*" in link_data else False
+
+ for e in link_data:
+ if "=" in e:
+ c = e.split("=")
+ if len(c) == 2:
+ request_data["var_"+str(c[0])] = str(c[1])
+ else:
+ link_fields.append(e)
+
+ def recurse_down(w):
+ if isinstance(w, list):
+ for t in w:
+ recurse_down(t)
+ elif isinstance(w, tuple):
+ for t in w:
+ recurse_down(t)
+ elif hasattr(w, "contents"):
+ recurse_down(w.contents)
+ elif hasattr(w, "original_widget"):
+ recurse_down(w.original_widget)
+ elif hasattr(w, "_original_widget"):
+ recurse_down(w._original_widget)
+ else:
+ if hasattr(w, "field_name") and (all_fields or w.field_name in link_fields):
+ field_key = "field_" + w.field_name
+ if isinstance(w, urwid.Edit):
+ request_data[field_key] = w.edit_text
+ elif isinstance(w, urwid.RadioButton):
+ if w.state:
+ user_data = getattr(w, "field_value", None)
+ if user_data is not None:
+ request_data[field_key] = user_data
+ elif isinstance(w, urwid.CheckBox):
+ user_data = getattr(w, "field_value", "1")
+ if w.state:
+ existing_value = request_data.get(field_key, '')
+ if existing_value:
+ # Concatenate the new value with the existing one
+ request_data[field_key] = existing_value + ',' + user_data
+ else:
+ # Initialize the field with the current value
+ request_data[field_key] = user_data
+ else:
+ pass # do nothing if checkbox is not check
+
+ recurse_down(self.attr_maps)
+ RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME)
+
+ return request_data
+
+ def start_partial_updater(self):
+ if not self.updater_running: self.update_partials()
+
+ def handle_partial_updates(self, partial_ids):
+ RNS.log(f"Update partials: {partial_ids}")
+ def job():
+ for pid in self.page_partials:
+ try:
+ partial = self.page_partials[pid]
+ if partial["id"] in partial_ids:
+ partial["update_requested"] = time.time()
+ self.__load_partial(partial)
+ except Exception as e: RNS.log(f"Error updating page partial: {e}", RNS.LOG_ERROR)
+
+ threading.Thread(target=job, daemon=True).start()
+
+ def update_partials(self, loop=None, user_data=None):
+ with self.partial_updater_lock:
+ def job():
+ for pid in self.page_partials:
+ try:
+ partial = self.page_partials[pid]
+ if partial["failed"]: continue
+ if not partial["updated"] or (partial["refresh"] != None and time.time() > partial["updated"]+partial["refresh"]):
+ partial["update_requested"] = time.time()
+ self.__load_partial(partial)
+ except Exception as e: RNS.log(f"Error updating page partial: {e}", RNS.LOG_ERROR)
+
+ threading.Thread(target=job, daemon=True).start()
+
+ if len(self.page_partials) > 0:
+ self.updater_running = True
+ self.app.ui.loop.set_alarm_in(1, self.update_partials)
+ else:
+ self.updater_running = False
+
+ def identify(self):
+ if self.link != None:
+ 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 path_timeout(self, destination_hash):
+ return max(self.timeout + self.app.rns.get_first_hop_timeout(destination_hash), self.app.rns.get_medium_path_timeout())
+
+ def download_local_file(self, path):
+ try:
+ file_path = self.app.filespath+path.replace("/file", "", 1)
+ 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()+self.path_timeout(self.destination_hash)
+ while not RNS.Transport.has_path(self.destination_hash):
+ if time.time() > pr_time:
+ 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()+self.path_timeout(self.destination_hash)
+ while not RNS.Transport.has_path(self.destination_hash):
+ if time.time() > pr_time:
+ 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 isinstance(request_receipt.response, bytes):
+ markup = request_receipt.response.decode("utf-8")
+ self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
+
+ elif type(request_receipt.response) == io.BufferedReader:
+ if request_receipt.metadata != None:
+ file_name = ConversationMessage.safe_attachment_name(request_receipt.metadata["name"])
+ file_handle = request_receipt.response
+ file_destination = self.app.downloads_path+"/"+file_name
+
+ counter = 0
+ while os.path.isfile(file_destination):
+ counter += 1
+ file_destination = self.app.downloads_path+"/"+file_name+"."+str(counter)
+
+ shutil.move(file_handle.name, file_destination)
+
+ self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1)
+ try: self.saved_file_size = os.path.getsize(file_destination)
+ except: self.saved_file_size = 0
+ self.file_saved_at = time.time()
+
+ else:
+ file_name = request_receipt.response[0]
+ file_data = request_receipt.response[1]
+ file_destination_name = ConversationMessage.safe_attachment_name(file_name)
+ file_destination = self.app.downloads_path+"/"+file_destination_name
+
+ counter = 0
+ while os.path.isfile(file_destination):
+ counter += 1
+ file_destination = self.app.downloads_path+"/"+file_destination_name+"."+str(counter)
+
+ fh = open(file_destination, "wb")
+ fh.write(file_data)
+ fh.close()
+
+ self.saved_file_name = file_destination.replace(self.app.downloads_path+"/", "", 1)
+ try: self.saved_file_size = os.path.getsize(file_destination)
+ except: self.saved_file_size = 0
+
+ self.status = Browser.DONE
+ self.response_progress = 0
+ self.response_speed = None
+ self.progress_updated_at = None
+ self.previous_progress = 0
+
+ self.update_display()
+
+ except Exception as e:
+ RNS.log("An error occurred while handling file response. The contained exception was: "+str(e), RNS.LOG_ERROR)
+
+
+ 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 last_response_speed(self):
+ if self.response_transfer_size and self.response_time:
+ return (self.response_transfer_size/max(self.response_time, 0.0001))*8
+ else: return None
+
+ def status_text(self):
+ if self.status == Browser.DONE and self.response_transfer_size != None:
+ if self.response_time != None: response_time_str = "{:.2f}".format(self.response_time)
+ else: response_time_str = "None"
+
+ if self.saved_file_name == None: resp_size = self.response_size
+ else: resp_size = self.saved_file_size
+
+ if resp_size: stats_string = " "+self.g["page"]+size_str(resp_size)
+ else: stats_string = " "
+ if self.response_transfer_size:
+ stats_string += " "+self.g["arrow_d"]+size_str(self.response_transfer_size)+" in "+response_time_str
+ if self.response_transfer_size and self.response_time:
+ stats_string += "s "+self.g["speed"]+size_str(self.response_transfer_size/self.response_time, suffix="b")+"/s"
+
+ 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..8c54f08
--- /dev/null
+++ b/nomadnet/ui/textui/Config.py
@@ -0,0 +1,1265 @@
+import RNS
+import nomadnet
+import urwid
+import platform
+
+from RNS.vendor.configobj import ConfigObj
+from nomadnet.util import write_config_atomic
+from nomadnet.vendor.additional_urwid_widgets.FormWidgets import *
+
+CONFIG_SECTION_GLYPHS = {
+ ("logging", "(L)", "≡", ""),
+ ("client", "(C)", "✉", ""),
+ ("utilities", "(Y)", "⚒", ""),
+ ("textui", "(U)", "▤", ""),
+ ("rrc", "(R)", "❖", ""),
+ ("node", "(N)", "Ⓝ", "\U000f0002"),
+ ("printing", "(P)", "⎙", ""),
+ ("reticulum", "(Rt)", "⇄", ""),
+ ("editor", "(E)", "✎", ""),
+}
+
+def _get_section_icon(glyphset, name):
+ glyphset_index = 1
+ if glyphset == "plain":
+ glyphset_index = 0
+ elif glyphset == "nerdfont":
+ glyphset_index = 2
+
+ for glyph_tuple in CONFIG_SECTION_GLYPHS:
+ if glyph_tuple[0] == name:
+ return glyph_tuple[glyphset_index + 1]
+
+ return "(#)" if glyphset == "plain" else "⚙" if glyphset == "unicode" else ""
+
+def _get_cols_rows():
+ return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows()
+
+def section_title_attr(section_key):
+ return "config_title_" + section_key
+
+class ConfigCheckbox(FormCheckbox):
+ def __init__(self, config_key, on_text="Enabled", off_text="Disabled", state=False, validation_types=None, transform=None, **kwargs):
+ self.on_text = on_text
+ self.off_text = off_text
+ super().__init__(config_key, label="", state=state, validation_types=validation_types, transform=transform, **kwargs)
+ self._refresh_label(self.get_state())
+ urwid.connect_signal(self, "change", self._on_change)
+
+ def _on_change(self, widget, new_state):
+ self._refresh_label(new_state)
+
+ def _refresh_label(self, state):
+ if state:
+ self.set_label(("connected_status", " " + self.on_text))
+ else:
+ self.set_label(("inactive_text", " " + self.off_text))
+
+CONFIG_SECTIONS = [
+ {
+ "key": "logging",
+ "name": "Logging",
+ "glyph": "logging",
+ "description": "Log verbosity and where log output is written",
+ "fields": [
+ {
+ "config_key": "loglevel",
+ "label": "Log level: ",
+ "type": "dropdown",
+ "options": ["0", "1", "2", "3", "4", "5", "6", "7"],
+ "default": "4",
+ "help": "0 = critical, 4 = info, 7 = extreme verbosity",
+ },
+ {
+ "config_key": "destination",
+ "label": "Destination: ",
+ "type": "dropdown",
+ "options": ["file", "stdout"],
+ "default": "file",
+ "help": "Write log to a file or to standard output",
+ },
+ {
+ "config_key": "logfile",
+ "label": "Log file: ",
+ "type": "edit",
+ "placeholder": "Defaults to the config directory",
+ "help": "Path to the log file (when destination is file)",
+ },
+ ],
+ },
+ {
+ "key": "client",
+ "name": "LXMF client",
+ "glyph": "client",
+ "description": "Client behaviour, announces, syncing and messages",
+ "fields": [
+ {
+ "config_key": "downloads_path",
+ "label": "Downloads path: ",
+ "type": "edit",
+ "default": "~/Downloads",
+ "placeholder": "~/Downloads",
+ "help": "Where downloaded files are saved",
+ },
+ {
+ "config_key": "attachment_save_path",
+ "label": "Attachment path: ",
+ "type": "edit",
+ "placeholder": "Defaults to the downloads path",
+ "help": "Where message attachments are saved",
+ },
+ {
+ "config_key": "notify_on_new_message",
+ "label": "Notify on message: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Show a notification when a new message arrives",
+ },
+ {
+ "config_key": "announce_at_start",
+ "label": "Announce at start: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Announce the local peer when starting",
+ },
+ {
+ "config_key": "announce_interval",
+ "label": "Announce interval: ",
+ "type": "edit",
+ "default": "360",
+ "validation": ["number"],
+ "help": "Minutes between peer announces (minimum 30)",
+ },
+ {
+ "config_key": "try_propagation_on_send_fail",
+ "label": "Propagate on fail: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Use a propagation node if direct delivery fails",
+ },
+ {
+ "config_key": "periodic_lxmf_sync",
+ "label": "Periodic sync: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Automatically sync messages from a propagation node",
+ },
+ {
+ "config_key": "lxmf_sync_interval",
+ "label": "Sync interval: ",
+ "type": "edit",
+ "default": "360",
+ "validation": ["number"],
+ "help": "Minutes between automatic syncs",
+ },
+ {
+ "config_key": "lxmf_sync_limit",
+ "label": "Sync limit: ",
+ "type": "edit",
+ "default": "8",
+ "validation": ["number"],
+ "help": "Max messages fetched per sync (0 = no limit)",
+ },
+ {
+ "config_key": "required_stamp_cost",
+ "label": "Required stamp cost: ",
+ "type": "edit",
+ "default": "None",
+ "placeholder": "None",
+ "validation": ["stamp_cost"],
+ "help": "Required inbound stamp cost 1-255, or None to disable",
+ },
+ {
+ "config_key": "accept_invalid_stamps",
+ "label": "Accept invalid stamps: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Accept messages carrying an invalid stamp",
+ },
+ {
+ "config_key": "max_accepted_size",
+ "label": "Max message size: ",
+ "type": "edit",
+ "default": "500",
+ "validation": ["float"],
+ "help": "Maximum accepted incoming message size, in KB",
+ },
+ {
+ "config_key": "compact_announce_stream",
+ "label": "Compact announces: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Show only one announce per destination",
+ },
+ {
+ "config_key": "compose_in_markdown",
+ "label": "Compose in markdown: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Author messages using markdown",
+ },
+ ],
+ },
+ {
+ "key": "textui",
+ "name": "Text UI",
+ "glyph": "textui",
+ "description": "Appearance and behaviour of the text interface",
+ "note": "Switch between the light and dark theme, glyphs and other appearance options here.",
+ "fields": [
+ {
+ "config_key": "intro_time",
+ "label": "Intro time: ",
+ "type": "edit",
+ "default": "1",
+ "validation": ["float"],
+ "help": "Seconds to show the intro screen at startup (0 to disable)",
+ },
+ {
+ "config_key": "intro_text",
+ "label": "Intro text: ",
+ "type": "edit",
+ "default": "Nomad Network",
+ "placeholder": "Nomad Network",
+ "help": "Text shown on the intro screen",
+ },
+ {
+ "config_key": "theme",
+ "label": "Theme: ",
+ "type": "dropdown",
+ "options": ["dark", "light"],
+ "default": "dark",
+ "help": "Colour theme",
+ },
+ {
+ "config_key": "colormode",
+ "label": "Color mode: ",
+ "type": "dropdown",
+ "options": ["monochrome", "16", "88", "256", "24bit"],
+ "default": "24bit",
+ "help": "Colour capability of your terminal",
+ },
+ {
+ "config_key": "image_loading",
+ "label": "Image Loading: ",
+ "type": "dropdown",
+ "options": ["never", "manual", "auto", "always"],
+ "default": "auto",
+ "help": "How, or if at all, to load page images",
+ },
+ {
+ "config_key": "glyphs",
+ "label": "Glyphs: ",
+ "type": "dropdown",
+ "options": ["plain", "unicode", "nerdfont"],
+ "default": "nerdfont",
+ "help": "Glyph set used for icons",
+ },
+ {
+ "config_key": "mouse_enabled",
+ "label": "Mouse enabled: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Allow mouse interaction",
+ },
+ {
+ "config_key": "editor",
+ "label": "Editor: ",
+ "type": "edit",
+ "default": "nano",
+ "placeholder": "nano",
+ "help": "Command used to edit text",
+ },
+ {
+ "config_key": "hide_guide",
+ "label": "Hide guide: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Hide the Guide entry in the menu",
+ },
+ {
+ "config_key": "sanitize_names",
+ "label": "Sanitize names: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Strip control characters from announced names",
+ },
+ {
+ "config_key": "clipboard_copy",
+ "label": "Clipboard copy: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Enable copying to the system clipboard",
+ },
+ {
+ "config_key": "animation_interval",
+ "label": "Animation interval: ",
+ "type": "edit",
+ "default": "1",
+ "validation": ["number"],
+ "help": "Interval between UI animation frames",
+ },
+ ],
+ },
+ {
+ "key": "utilities",
+ "name": "Utilities",
+ "glyph": "utilities",
+ "description": "Optional utility views and tools",
+ "note": "Adds a Utilities menu where you can view path/announce tables and probe destinations.",
+ "fields": [
+ {
+ "config_key": "enable_utilities",
+ "label": "Enable utilities: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Show the Utilities section (path tables, probe) in the menu",
+ },
+ ],
+ },
+ {
+ "key": "rrc",
+ "name": "RRC Client Settings",
+ "glyph": "rrc",
+ "description": "Rendering and history for the rooms client",
+ "fields": [
+ {
+ "config_key": "history_per_room_cap",
+ "label": "History cap: ",
+ "type": "edit",
+ "default": "500",
+ "validation": ["number"],
+ "help": "Messages kept in memory per room",
+ },
+ {
+ "config_key": "filter_loaded_history",
+ "label": "Filter history: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Filter system messages out of loaded history",
+ },
+ {
+ "config_key": "ephemeral_notices",
+ "label": "Ephemeral notices: ",
+ "type": "edit",
+ "default": "10",
+ "validation": ["float"],
+ "help": "Minutes to keep notices (0 keeps them)",
+ },
+ {
+ "config_key": "color_mention_timestamps",
+ "label": "Color mention time: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Colour the timestamp when you are mentioned",
+ },
+ {
+ "config_key": "render_markdown",
+ "label": "Render markdown: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Render markdown formatting in messages",
+ },
+ {
+ "config_key": "render_micron",
+ "label": "Render micron: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Render micron markup in messages",
+ },
+ {
+ "config_key": "nick_colors",
+ "label": "Nick colors: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Colour user nicknames",
+ },
+ {
+ "config_key": "justify_msgs",
+ "label": "Justify messages: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Justify message text",
+ },
+ {
+ "config_key": "space_msgs",
+ "label": "Space messages: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Add extra spacing between messages",
+ },
+ {
+ "config_key": "show_gutters",
+ "label": "Show gutters: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Show gutters alongside messages",
+ },
+ {
+ "config_key": "mention_color",
+ "label": "Mention color: ",
+ "type": "edit",
+ "placeholder": "6-digit hex, e.g. ff8800",
+ "help": "Hex colour used for mentions of your nick",
+ },
+ {
+ "config_key": "nick_colors_theme",
+ "label": "Nick color theme: ",
+ "type": "list",
+ "default": [],
+ "placeholder": "6-digit hex colour",
+ "help": "Custom palette of hex colours used for nicknames",
+ },
+ {
+ "config_key": "enable_esoterics",
+ "label": "Enable esoterics: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Enable esoteric features",
+ },
+ ],
+ },
+ {
+ "key": "node",
+ "name": "Node",
+ "glyph": "node",
+ "description": "Node hosting, propagation and announces",
+ "note": "Enable this to host a node on the network, serving pages and files to others.",
+ "fields": [
+ {
+ "config_key": "enable_node",
+ "label": "Enable node: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Host a node on the network",
+ },
+ {
+ "config_key": "node_name",
+ "label": "Node name: ",
+ "type": "edit",
+ "default": "None",
+ "placeholder": "None",
+ "help": "Display name for the node, or None",
+ },
+ {
+ "config_key": "announce_at_start",
+ "label": "Announce at start: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Announce the node when starting",
+ },
+ {
+ "config_key": "announce_interval",
+ "label": "Announce interval: ",
+ "type": "edit",
+ "default": "360",
+ "validation": ["number"],
+ "help": "Minutes between node announces (minimum 1)",
+ },
+ {
+ "config_key": "disable_propagation",
+ "label": "Disable propagation: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Disable the propagation node functionality",
+ },
+ {
+ "config_key": "propagation_cost",
+ "label": "Propagation cost: ",
+ "type": "edit",
+ "default": "16",
+ "validation": ["number"],
+ "help": "Stamp cost for propagation (minimum 13)",
+ },
+ {
+ "config_key": "max_transfer_size",
+ "label": "Max transfer size: ",
+ "type": "edit",
+ "default": "256",
+ "validation": ["float"],
+ "help": "Maximum single message size on propagation, in KB",
+ },
+ {
+ "config_key": "max_sync_size",
+ "label": "Max sync size: ",
+ "type": "edit",
+ "default": "10240",
+ "validation": ["float"],
+ "help": "Maximum sync batch size, in KB",
+ },
+ {
+ "config_key": "pages_path",
+ "label": "Pages path: ",
+ "type": "edit",
+ "placeholder": "Default node pages directory",
+ "help": "Directory of pages served by the node",
+ },
+ {
+ "config_key": "page_refresh_interval",
+ "label": "Page refresh: ",
+ "type": "edit",
+ "default": "0",
+ "validation": ["number"],
+ "help": "Minutes between page rescans (0 = never)",
+ },
+ {
+ "config_key": "files_path",
+ "label": "Files path: ",
+ "type": "edit",
+ "placeholder": "Default node files directory",
+ "help": "Directory of files served by the node",
+ },
+ {
+ "config_key": "file_refresh_interval",
+ "label": "File refresh: ",
+ "type": "edit",
+ "default": "0",
+ "validation": ["number"],
+ "help": "Minutes between file rescans (0 = never)",
+ },
+ {
+ "config_key": "prioritise_destinations",
+ "label": "Priority dests: ",
+ "type": "list",
+ "default": [],
+ "placeholder": "Destination hash",
+ "help": "Destination hashes given storage priority",
+ },
+ {
+ "config_key": "static_peers",
+ "label": "Static peers: ",
+ "type": "list",
+ "default": [],
+ "placeholder": "Destination hash",
+ "help": "Propagation peers always kept connected",
+ },
+ {
+ "config_key": "max_peers",
+ "label": "Max peers: ",
+ "type": "edit",
+ "placeholder": "No limit",
+ "validation": ["number"],
+ "help": "Maximum number of propagation peers",
+ },
+ {
+ "config_key": "message_storage_limit",
+ "label": "Storage limit: ",
+ "type": "edit",
+ "default": "2000",
+ "validation": ["float"],
+ "help": "Maximum propagation storage, in MB",
+ },
+ ],
+ },
+ {
+ "key": "printing",
+ "name": "Printing",
+ "glyph": "printing",
+ "description": "Printing of received messages",
+ "note": "The rendered message file is appended to this command, so it runs as e.g. 'lp '.",
+ "fields": [
+ {
+ "config_key": "print_messages",
+ "label": "Print messages: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Print received messages",
+ },
+ {
+ "config_key": "print_command",
+ "label": "Print command: ",
+ "type": "edit",
+ "default": "lp",
+ "placeholder": "lp",
+ "help": ("Command to print with; the message file is appended as the last argument. Examples: "
+ "lp | "
+ "lp -d NAME (named printer, list with lpstat -p) | "
+ "lp -d NAME -o cpi=16 -o lpi=8 (small thermal-roll printers) | "
+ "lp -d NAME -o media=Custom.58x210mm (58mm thermal roll)"),
+ },
+ {
+ "config_key": "print_from",
+ "label": "Print from: ",
+ "type": "list",
+ "default": [],
+ "placeholder": "everywhere, trusted, or hash",
+ "help": "Which senders may trigger automatic printing",
+ },
+ {
+ "config_key": "message_template",
+ "label": "Message template: ",
+ "type": "edit",
+ "placeholder": "Path to template file",
+ "help": "Template file used when printing messages",
+ },
+ ],
+ },
+ {
+ "key": "reticulum",
+ "name": "Reticulum (global)",
+ "glyph": "reticulum",
+ "configfile": "reticulum",
+ "description": "Global Reticulum settings (shared instance, transport)",
+ "fields": [
+ {
+ "config_key": "enable_transport",
+ "label": "Enable transport: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Route traffic for other peers (transport node)",
+ },
+ {
+ "config_key": "share_instance",
+ "label": "Share instance: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Run a shared instance other local programs connect to",
+ },
+ {
+ "config_key": "instance_name",
+ "label": "Instance name: ",
+ "type": "edit",
+ "default": "default",
+ "placeholder": "default",
+ "help": "Name used to isolate multiple shared instances",
+ },
+ {
+ "config_key": "shared_instance_type",
+ "label": "Instance type: ",
+ "type": "edit",
+ "placeholder": "unix or tcp (blank for auto)",
+ "help": "Transport used for shared instance communication",
+ },
+ {
+ "config_key": "shared_instance_port",
+ "label": "Instance port: ",
+ "type": "edit",
+ "placeholder": "37428",
+ "validation": ["number"],
+ "help": "TCP port for the shared instance",
+ },
+ {
+ "config_key": "instance_control_port",
+ "label": "Control port: ",
+ "type": "edit",
+ "placeholder": "37429",
+ "validation": ["number"],
+ "help": "TCP control port for the shared instance",
+ },
+ {
+ "config_key": "discover_interfaces",
+ "label": "Discover interfaces: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Discover interfaces advertised by transport instances",
+ },
+ {
+ "config_key": "enable_remote_management",
+ "label": "Remote management: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Allow remote management of this instance",
+ },
+ {
+ "config_key": "remote_management_allowed",
+ "label": "Remote allowed: ",
+ "type": "list",
+ "default": [],
+ "placeholder": "Identity hash",
+ "help": "Identity hashes allowed to manage this instance",
+ },
+ {
+ "config_key": "respond_to_probes",
+ "label": "Respond to probes: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Answer reachability probes from other nodes",
+ },
+ {
+ "config_key": "use_implicit_proof",
+ "label": "Implicit proof: ",
+ "type": "checkbox",
+ "default": True,
+ "help": "Use implicit proofs for packet delivery",
+ },
+ {
+ "config_key": "panic_on_interface_error",
+ "label": "Panic on iface error: ",
+ "type": "checkbox",
+ "default": False,
+ "help": "Exit if an interface has an unrecoverable error",
+ },
+ ],
+ },
+]
+
+_SECTION_ORDER = {"node": 0, "client": 1, "logging": 2, "textui": 3, "utilities": 4, "rrc": 5, "printing": 6, "reticulum": 7}
+CONFIG_SECTIONS.sort(key=lambda s: _SECTION_ORDER.get(s["key"], 99))
+
+class ConfigDisplayShortcuts():
+ def __init__(self, app):
+ self.app = app
+ self.default_shortcuts = "[Enter] Edit Section [C-w] Text Editor [Up/Down] Navigate"
+ self.widget = urwid.AttrMap(urwid.Text(self.default_shortcuts), "shortcutbar")
+
+ def update_shortcuts(self, new_shortcuts):
+ self.widget.original_widget.set_text(new_shortcuts)
+
+ def reset_shortcuts(self):
+ self.update_shortcuts(self.default_shortcuts)
+
+ def set_section_shortcuts(self):
+ self.update_shortcuts("[Up/Down] Navigate Fields [Enter] Select Option [C-s] Save [C-r] Reset [Esc] Cancel")
+
+class ConfigFiller(urwid.WidgetWrap):
+ def __init__(self, widget, app):
+ self.app = app
+ super().__init__(widget)
+
+ def keypress(self, size, key):
+ if key == "ctrl w":
+ self.app.ui.main_display.sub_displays.config_display.open_text_editor()
+ return None
+ return super(ConfigFiller, self).keypress(size, key)
+
+class SelectableConfigSection(urwid.WidgetWrap):
+ def __init__(self, parent, section_key, name, description, icon, note=None):
+ self.parent = parent
+ self.section_key = section_key
+ self.name = name
+ self.icon = icon
+ self._selectable = True
+
+ self.title_attr = section_title_attr(section_key)
+ self.title_attr_selected = self.title_attr + "_selected"
+
+ self.selection_txt = urwid.Text(" ")
+ self.title_widget = urwid.Text((self.title_attr, f"{icon} {name}"))
+
+ title_content = urwid.Columns([
+ (4, self.selection_txt),
+ self.title_widget,
+ ])
+
+ description_row = urwid.Columns([
+ (4, urwid.Text(" ")),
+ urwid.Text(("value", description)),
+ ])
+
+ pile_rows = [title_content, description_row]
+ if note:
+ pile_rows.append(urwid.Columns([
+ (4, urwid.Text(" ")),
+ urwid.Text(("inactive_text", note)),
+ ]))
+
+ pile = urwid.Pile(pile_rows)
+ padded_body = urwid.Padding(pile, left=2, right=2)
+
+ box = urwid.LineBox(
+ padded_body,
+ title=None,
+ tlcorner="╭", tline="─",
+ trcorner="╮", lline="│",
+ rline="│", blcorner="╰",
+ bline="─", brcorner="╯"
+ )
+
+ super().__init__(box)
+
+ def selectable(self):
+ return True
+
+ def render(self, size, focus=False):
+ self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected'])
+
+ if focus:
+ self.title_widget.set_text((self.title_attr_selected, f"{self.icon} {self.name}"))
+ else:
+ self.title_widget.set_text((self.title_attr, f"{self.icon} {self.name}"))
+
+ return super().render(size, focus=focus)
+
+ def keypress(self, size, key):
+ if key == "up":
+ listbox = self.parent.list_box
+ if listbox.focus_position == 0:
+ self.parent.app.ui.main_display.frame.focus_position = "header"
+ return None
+ elif key == "enter":
+ self.parent.switch_to_section(self.section_key)
+ return None
+ return key
+
+class ConfigActionItem(urwid.WidgetWrap):
+ def __init__(self, parent, label, on_select):
+ self.parent = parent
+ self.on_select = on_select
+ self._selectable = True
+
+ text_widget = urwid.Text(label, align="left")
+ super().__init__(urwid.AttrMap(text_widget, "list_normal", focus_map="list_focus"))
+
+ def selectable(self):
+ return True
+
+ def keypress(self, size, key):
+ if key == "enter":
+ self.on_select()
+ return None
+ return super().keypress(size, key)
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and urwid.util.is_mouse_press(event):
+ self.on_select()
+ return True
+ return False
+
+class ConfigSectionView(urwid.WidgetWrap):
+ LABEL_WIDTH = 24
+
+ def __init__(self, parent, section):
+ self.parent = parent
+ self.section = section
+ self.section_key = section["key"]
+ self.fields = {}
+
+ self.parent.shortcuts_display.set_section_shortcuts()
+
+ self.config_path = self._resolve_config_path(section)
+ self.file_config = ConfigObj(self.config_path)
+ section_config = self.file_config.get(self.section_key, {})
+
+ cols, rows = _get_cols_rows()
+ self.content_width = min(80, max(40, cols - 6))
+
+ icon = _get_section_icon(self.parent.glyphset, section["glyph"])
+
+ field_items = []
+ for field in section["fields"]:
+ widget = self._build_field_widget(field)
+ current = section_config.get(field["config_key"], field.get("default"))
+ self._populate_field(widget, field, current)
+ self.fields[field["config_key"]] = {"field": field, "widget": widget}
+ field_items.append(self._build_field_row(field, widget))
+
+ self.list_walker = urwid.SimpleFocusListWalker(field_items)
+ self.form_listbox = urwid.ListBox(self.list_walker)
+
+ header = urwid.Pile([
+ urwid.Text((section_title_attr(self.section_key) + "_selected", f"{icon} {section['name']}"), align=urwid.CENTER),
+ urwid.Divider("─"),
+ ])
+
+ save_btn = urwid.AttrMap(urwid.Button("Save", on_press=self.on_save), "button_normal", focus_map="button_focus")
+ reset_btn = urwid.AttrMap(urwid.Button("Reset", on_press=self.on_reset), "button_normal", focus_map="button_focus")
+ back_btn = urwid.AttrMap(urwid.Button("Cancel", on_press=self.on_back), "button_normal", focus_map="button_focus")
+ button_row = urwid.Columns([
+ (urwid.WEIGHT, 0.3, save_btn),
+ (urwid.WEIGHT, 0.05, urwid.Text("")),
+ (urwid.WEIGHT, 0.3, reset_btn),
+ (urwid.WEIGHT, 0.05, urwid.Text("")),
+ (urwid.WEIGHT, 0.3, back_btn),
+ ])
+ self._footer = urwid.Pile([
+ urwid.Divider("─"),
+ urwid.Padding(button_row, align=urwid.CENTER, width=self.content_width),
+ ])
+ try:
+ self._footer.focus_position = 1
+ except Exception:
+ pass
+
+ self._frame = urwid.Frame(self.form_listbox, header=header, footer=self._footer)
+ super().__init__(self._frame)
+
+ def _resolve_config_path(self, section):
+ if section.get("configfile") == "reticulum":
+ try:
+ return self.parent.app.rns.config.filename
+ except Exception:
+ return RNS.Reticulum.configpath
+ return self.parent.app.configpath
+
+ def _build_field_row(self, field, widget):
+ row = urwid.Columns([
+ (self.LABEL_WIDTH, urwid.Text(("key", field["label"]), align=urwid.RIGHT)),
+ widget,
+ ])
+ contents = [row]
+ help_text = self._field_help(field)
+ if help_text:
+ contents.append(urwid.Padding(urwid.Text(("inactive_text", help_text)), left=self.LABEL_WIDTH + 2))
+ contents.append(urwid.Padding(widget.error_widget, left=self.LABEL_WIDTH + 2))
+ contents.append(urwid.Divider())
+ field_pile = urwid.Pile(contents)
+ return urwid.Padding(field_pile, align=urwid.CENTER, width=self.content_width)
+
+ def _field_help(self, field):
+ parts = []
+ if field.get("help"):
+ parts.append(field["help"])
+ default = field.get("default", None)
+ if field["type"] == "checkbox":
+ default_text = "yes" if default else "no"
+ elif field["type"] == "list":
+ if not default:
+ default_text = "none"
+ elif isinstance(default, (list, tuple)):
+ default_text = ", ".join(str(item) for item in default)
+ else:
+ default_text = str(default)
+ else:
+ default_text = str(default) if default not in (None, "") else "none"
+ parts.append(f"(default: {default_text})")
+ return " ".join(parts)
+
+ def _build_field_widget(self, field):
+ if field["type"] == "dropdown":
+ return FormDropdown(
+ config_key=field["config_key"],
+ label=field.get("label", ""),
+ options=field["options"],
+ default=str(field.get("default", field["options"][0])),
+ validation_types=field.get("validation", []),
+ )
+ elif field["type"] == "checkbox":
+ return ConfigCheckbox(
+ config_key=field["config_key"],
+ on_text=field.get("on_text", "Enabled"),
+ off_text=field.get("off_text", "Disabled"),
+ state=bool(field.get("default", False)),
+ validation_types=field.get("validation", []),
+ )
+ elif field["type"] == "list":
+ return FormMultiList(
+ config_key=field["config_key"],
+ placeholder=field.get("placeholder", ""),
+ validation_types=field.get("validation", []),
+ )
+ else:
+ return FormEdit(
+ config_key=field["config_key"],
+ caption="",
+ edit_text=str(field.get("default", "")),
+ placeholder=field.get("placeholder", ""),
+ validation_types=field.get("validation", []),
+ )
+
+ def _populate_field(self, widget, field, value):
+ if value is None:
+ return
+
+ if field["type"] == "checkbox":
+ if isinstance(value, bool):
+ state = value
+ else:
+ s = str(value).strip().lower()
+ if s in ("", "false", "off", "no"):
+ state = False
+ else:
+ try:
+ state = float(s) != 0
+ except ValueError:
+ state = True
+ widget.set_state(state)
+ elif field["type"] == "dropdown":
+ str_value = str(value)
+ if str_value in widget.options:
+ widget.selected = str_value
+ widget.main_button.base_widget.set_text(str_value)
+ elif field["type"] == "list":
+ if value:
+ widget.set_value(value if isinstance(value, list) else [value])
+ else:
+ if isinstance(value, (list, tuple)):
+ widget.edit_text = ", ".join(str(item) for item in value)
+ else:
+ widget.edit_text = str(value)
+
+ def validate_all(self):
+ all_valid = True
+ for entry in self.fields.values():
+ if not entry["widget"].validate():
+ all_valid = False
+ return all_valid
+
+ def _widget_write_value(self, field, widget):
+ if field["type"] == "checkbox":
+ return field.get("on_value", "yes") if widget.get_state() else field.get("off_value", "no")
+ return widget.get_value()
+
+ def _default_write_value(self, field):
+ default = field.get("default")
+ if field["type"] == "checkbox":
+ return field.get("on_value", "yes") if bool(default) else field.get("off_value", "no")
+ if default is None:
+ return ""
+ if isinstance(default, (list, tuple)):
+ return list(default)
+ return str(default)
+
+ def on_save(self, button):
+ if not self.validate_all():
+ self.show_message("Some fields have invalid values.\nCorrect the marked fields and try again.", title="Not saved", back_to_list=False)
+ return
+
+ try:
+ self.file_config.reload()
+ except Exception:
+ pass
+
+ section_existed = self.section_key in self.file_config
+ if not section_existed:
+ self.file_config[self.section_key] = {}
+
+ section_config = self.file_config[self.section_key]
+
+ for config_key, entry in self.fields.items():
+ field = entry["field"]
+ widget = entry["widget"]
+ present = config_key in section_config
+ value = self._widget_write_value(field, widget)
+
+ if not value:
+ if present:
+ del section_config[config_key]
+ elif present or value != self._default_write_value(field):
+ section_config[config_key] = value
+
+ if not section_existed and not section_config:
+ del self.file_config[self.section_key]
+
+ try:
+ write_config_atomic(self.file_config)
+ if self.section.get("configfile") == "reticulum":
+ try:
+ self.parent.app.rns.config.reload()
+ except Exception:
+ pass
+ self.parent.mark_restart_pending()
+ self.show_message(f"{self.section['name']} configuration saved.\nRestart Nomad Network for changes to take effect.")
+ except Exception as e:
+ self.show_message(f"Error saving configuration: {str(e)}", title="Error", back_to_list=False)
+
+ def on_reset(self, button=None):
+ self._confirm(
+ f"Reset all {self.section['name']} settings to their defaults?\nYou will still need to Save to apply.",
+ self._do_reset,
+ )
+
+ def _do_reset(self):
+ for entry in self.fields.values():
+ self._reset_field(entry["widget"], entry["field"])
+
+ def _reset_field(self, widget, field):
+ default = field.get("default")
+ if field["type"] == "checkbox":
+ widget.set_state(bool(default))
+ elif field["type"] == "dropdown":
+ default_str = str(default if default is not None else widget.options[0])
+ if default_str in widget.options:
+ widget.selected = default_str
+ widget.main_button.base_widget.set_text(default_str)
+ elif field["type"] == "list":
+ widget.set_value(default if isinstance(default, list) else [])
+ else:
+ widget.edit_text = "" if default is None else str(default)
+ widget.error_widget.set_text("")
+
+ def on_back(self, button):
+ self.parent.switch_to_list()
+
+ def dismiss_dialog(self):
+ self._dismiss_overlay()
+
+ def _confirm(self, message, on_yes):
+ def yes(button):
+ self._dismiss_overlay()
+ on_yes()
+
+ def no(button):
+ self._dismiss_overlay()
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(message, align=urwid.CENTER),
+ urwid.Divider(),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)),
+ ]),
+ ]),
+ parent=self,
+ title="Confirm",
+ )
+ self._show_overlay(dialog, height=9, width=64)
+
+ def _show_overlay(self, dialog, height=8, width=60):
+ overlay = urwid.Overlay(
+ dialog,
+ self,
+ align=urwid.CENTER,
+ width=width,
+ valign=urwid.MIDDLE,
+ height=height,
+ min_width=1,
+ min_height=1,
+ )
+ self.parent.widget = overlay
+ self.parent.app.ui.main_display.update_active_sub_display()
+
+ def _dismiss_overlay(self):
+ self.parent.widget = self
+ self.parent.app.ui.main_display.update_active_sub_display()
+
+ def show_message(self, message, title="Notice", back_to_list=True):
+ def dismiss_dialog(button=None):
+ if back_to_list:
+ self.parent.switch_to_list()
+ else:
+ self._dismiss_overlay()
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(message, align=urwid.CENTER),
+ urwid.Divider(),
+ urwid.Button("OK", on_press=dismiss_dialog)
+ ]),
+ parent=self,
+ title=title
+ )
+
+ self._show_overlay(dialog, height=10, width=60)
+
+ def keypress(self, size, key):
+ if key == "ctrl s":
+ self.on_save(None)
+ return None
+ if key == "ctrl r":
+ self.on_reset(None)
+ return None
+ if key == "esc":
+ self.parent.switch_to_list()
+ return None
+
+ result = super().keypress(size, key)
+ if result is None:
+ return None
+
+ if result in ("down", "tab") and self._frame.focus_position != "footer":
+ self._frame.focus_position = "footer"
+ try:
+ self._footer.focus_position = 1
+ except Exception:
+ pass
+ return None
+ if result == "up":
+ if self._frame.focus_position == "footer":
+ self._frame.focus_position = "body"
+ else:
+ self.parent.app.ui.main_display.frame.focus_position = "header"
+ return None
+
+ return result
+
+class ConfigDisplay():
+ def __init__(self, app):
+ self.app = app
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+ self.restart_pending = False
+ self.editor_term = None
+
+ self.shortcuts_display = ConfigDisplayShortcuts(self.app)
+
+ self._build_list_view()
+ self.widget = self.list_view
+
+ def _build_list_view(self):
+ self.section_items = []
+ for section in CONFIG_SECTIONS:
+ icon = _get_section_icon(self.glyphset, section["glyph"])
+ self.section_items.append(
+ SelectableConfigSection(self, section["key"], section["name"], section["description"], icon, note=section.get("note"))
+ )
+
+ editor_icon = _get_section_icon(self.glyphset, "editor")
+ self.editor_item = ConfigActionItem(self, f"{editor_icon} Open config file in text editor", self.open_text_editor)
+
+ list_contents = list(self.section_items) + [urwid.Divider(), self.editor_item]
+ self.list_walker = urwid.SimpleFocusListWalker(list_contents)
+ self.list_box = urwid.ListBox(self.list_walker)
+
+ self.header_text = urwid.Text("")
+ self.list_divider = urwid.Divider("─")
+ self.list_pile = urwid.Pile([('weight', 1, self.list_box)])
+ self._update_header()
+
+ self.list_view = ConfigFiller(self.list_pile, self.app)
+
+ def _update_header(self):
+ contents = []
+ if self.restart_pending:
+ self.header_text.set_text(("warning_text", "Changes saved. Restart NomadNet for them to take effect"))
+ contents.append((self.header_text, self.list_pile.options('pack')))
+ contents.append((self.list_divider, self.list_pile.options('pack')))
+ contents.append((self.list_box, self.list_pile.options('weight', 1)))
+ self.list_pile.contents = contents
+
+ def mark_restart_pending(self):
+ self.restart_pending = True
+ self._update_header()
+
+ def switch_to_section(self, section_key):
+ section = next(s for s in CONFIG_SECTIONS if s["key"] == section_key)
+ self.section_view = ConfigSectionView(self, section)
+ self.widget = self.section_view
+ self.app.ui.main_display.update_active_sub_display()
+
+ def switch_to_list(self):
+ self.shortcuts_display.reset_shortcuts()
+ self.editor_term = None
+ self._update_header()
+ self.widget = self.list_view
+ self.app.ui.main_display.update_active_sub_display()
+
+ def open_text_editor(self):
+ self.editor_term = EditorTerminal(self.app, self)
+ self.widget = urwid.LineBox(self.editor_term)
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+ self.editor_term.term.change_focus(True)
+
+ def shortcuts(self):
+ return self.shortcuts_display
+
+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.switch_to_list()
+ self.app.ui.main_display.request_redraw()
+
+ urwid.connect_signal(self.term, 'closed', quit_term)
+
+ super().__init__(self.term)
+
+
+ def keypress(self, size, key):
+ return super(EditorTerminal, self).keypress(size, key)
diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py
index af523e5..6410abf 100644
--- a/nomadnet/ui/textui/Conversations.py
+++ b/nomadnet/ui/textui/Conversations.py
@@ -1,25 +1,89 @@
import RNS
+import RNS.vendor.umsgpack as msgpack
+import collections
+import os
+import shutil
import time
import nomadnet
import LXMF
import urwid
-from datetime import datetime
+from datetime import datetime, timedelta
from nomadnet.Directory import DirectoryEntry
+from LXMF import pn_announce_data_is_valid, PN_META_NAME
+from nomadnet.Conversation import ConversationMessage
+from .FileBrowser import FileBrowser
+
+from nomadnet.util import strip_modifiers
+from nomadnet.util import sanitize_name
+
+from RNS.Utilities.rngit.util import MarkdownToMicron
+from RNS.Utilities.rngit.highlight import SyntaxHighlighter
+from .MicronParser import markup_to_attrmaps
+from .Helpers import ClickableIcon, osc52_copy, qr_ascii
+from .ReadlineEdit import ReadlineMixin, ReadlineEdit
+from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags
+from nomadnet.ui import THEME_DARK, THEME_LIGHT
+
+def relative_time(timestamp):
+ now = time.time()
+ delta = now - timestamp
+ if delta < 0:
+ return "just now"
+ elif delta < 60:
+ return "just now"
+ elif delta < 3600:
+ m = int(delta / 60)
+ return str(m)+"m ago"
+ elif delta < 86400:
+ h = int(delta / 3600)
+ return str(h)+"h ago"
+ else:
+ days = (datetime.fromtimestamp(now).date() - datetime.fromtimestamp(timestamp).date()).days
+ if days <= 1:
+ return "yesterday"
+ elif days < 7:
+ return str(days)+"d ago"
+ elif days < 30:
+ return str(days // 7)+"w ago"
+ else:
+ return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
+
+
+def _format_size(size):
+ if size < 1024:
+ return str(size)+" B"
+ elif size < 1048576:
+ return str(round(size/1024, 1))+" KB"
+ else:
+ return str(round(size/1048576, 1))+" MB"
+
+
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox
class ConversationListDisplayShortcuts():
def __init__(self, app):
self.app = app
- self.widget = urwid.AttrMap(urwid.Text("[Enter] Open [C-e] Peer Info [C-x] Delete [C-n] New"), "shortcutbar")
+ self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-o] Sort [C-p] My LXMF [C-g] Fullscreen"), "shortcutbar")
class ConversationDisplayShortcuts():
def __init__(self, app):
self.app = app
- self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-k] Clear [C-w] Close [C-t] Editor Type [C-p] Purge"), "shortcutbar")
+ self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-v] Delivery [C-r] Details [C-s] Save [Tab] ↑ Messages"), "shortcutbar")
+
+class ConversationBodyShortcuts():
+ def __init__(self, app):
+ self.app = app
+
+ self.widget = urwid.AttrMap(urwid.Text("[C-r] Details [C-v] Delivery [C-o] Sort [C-s] Save [C-u] Purge [C-k] Cancel Send [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar")
+
+class TabButton(urwid.Button):
+ button_left = urwid.Text("[")
+ button_right = urwid.Text("]")
+
class ConversationsArea(urwid.LineBox):
def keypress(self, size, key):
@@ -29,82 +93,494 @@ class ConversationsArea(urwid.LineBox):
self.delegate.delete_selected_conversation()
elif key == "ctrl n":
self.delegate.new_conversation()
+ elif key == "ctrl u":
+ self.delegate.ingest_lxm_uri()
+ elif key == "ctrl r":
+ self.delegate.sync_conversations()
+ elif key == "ctrl g":
+ self.delegate.toggle_fullscreen()
+ elif key == "ctrl o":
+ self.delegate.toggle_list_sort()
+ elif key == "ctrl p":
+ self.delegate.show_my_qr()
elif key == "tab":
- self.delegate.app.ui.main_display.frame.set_focus("header")
- elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()):
- self.delegate.app.ui.main_display.frame.set_focus("header")
+ self.delegate.app.ui.main_display.frame.focus_position = "header"
+ elif key == "up":
+ if self.delegate.ilb.body_is_empty():
+ self.delegate.app.ui.main_display.frame.focus_position = "header"
+ return None
+ result = super(ConversationsArea, self).keypress(size, key)
+ if result == "up":
+ self.delegate.app.ui.main_display.frame.focus_position = "header"
+ return None
+ return result
else:
return super(ConversationsArea, self).keypress(size, key)
+class PropNodePicker(urwid.WidgetWrap):
+ def __init__(self, options, current_hash, on_change):
+ self._options = list(options)
+ self._current = current_hash
+ self._on_change = on_change
+ self._pile = urwid.Pile([])
+ super().__init__(self._pile)
+ self._show_collapsed()
+
+ def _label_for(self, h):
+ for ph, label in self._options:
+ if ph == h:
+ return label
+ if h is not None:
+ return "<"+RNS.hexrep(h, delimit=False)+">"
+ return "(select propagation node)"
+
+ def _show_collapsed(self):
+ btn = urwid.Button("▾ "+self._label_for(self._current))
+ urwid.connect_signal(btn, "click", self._on_expand_click)
+ self._pile.contents = [
+ (urwid.AttrMap(btn, None, focus_map="list_focus"), self._pile.options()),
+ ]
+
+ def _on_expand_click(self, _btn):
+ self._show_expanded()
+
+ def _on_back_click(self, _btn):
+ self._show_collapsed()
+
+ def _make_row_click(self, picked_hash):
+ def _click(_btn):
+ try:
+ self._current = picked_hash
+ self._on_change(picked_hash)
+ except Exception as e:
+ RNS.log("Propagation node change handler failed: "+str(e), RNS.LOG_ERROR)
+ self._show_collapsed()
+ return _click
+
+ def _show_expanded(self):
+ rows = []
+ for ph, label in self._options:
+ marker = "● " if ph == self._current else "○ "
+ row = urwid.Button(marker+label)
+ urwid.connect_signal(row, "click", self._make_row_click(ph))
+ rows.append(urwid.AttrMap(row, None, focus_map="list_focus"))
+
+ if not rows:
+ rows.append(urwid.Text(" (no propagation nodes seen yet)"))
+
+ list_height = min(10, max(3, len(rows)))
+ listbox = urwid.ListBox(urwid.SimpleFocusListWalker(rows))
+ boxed = urwid.BoxAdapter(listbox, list_height)
+
+ back = urwid.Button("◀ Back")
+ urwid.connect_signal(back, "click", self._on_back_click)
+
+ self._pile.contents = [
+ (urwid.AttrMap(back, None, focus_map="list_focus"), self._pile.options()),
+ (boxed, self._pile.options()),
+ ]
+
+
class DialogLineBox(urwid.LineBox):
def keypress(self, size, key):
if key == "esc":
- self.delegate.update_conversation_list()
+ if hasattr(self.delegate, "update_conversation_list"):
+ self.delegate.update_conversation_list()
+ elif hasattr(self.delegate, "dialog_active"):
+ self.delegate.dialog_active = False
+ self.delegate.conversation_changed(None)
else:
return super(DialogLineBox, self).keypress(size, key)
class ConversationsDisplay():
list_width = 0.33
+ given_list_width = 52
cached_conversation_widgets = {}
+ SORT_RECENT = 0
+ SORT_NAME = 1
+
+ LIST_FILTER_TRUSTED = "trusted"
+ LIST_FILTER_UNTRUSTED = "untrusted"
+
def __init__(self, app):
self.app = app
self.dialog_open = False
+ self.sync_dialog = None
self.currently_displayed_conversation = None
+ self.list_sort_mode = ConversationsDisplay.SORT_RECENT
+ self.list_filter = ConversationsDisplay.LIST_FILTER_TRUSTED
+ self.show_blocked = False
def disp_list_shortcuts(sender, arg1, arg2):
self.shortcuts_display = self.list_shortcuts
self.app.ui.main_display.update_active_shortcuts()
+ self._build_persistent_listbox()
self.update_listbox()
self.columns_widget = urwid.Columns(
[
- ("weight", ConversationsDisplay.list_width, self.listbox),
- ("weight", 1-ConversationsDisplay.list_width, self.make_conversation_widget(None))
+ # (urwid.WEIGHT, ConversationsDisplay.list_width, self.listbox),
+ # (urwid.WEIGHT, 1-ConversationsDisplay.list_width, self.make_conversation_widget(None))
+ (ConversationsDisplay.given_list_width, self.listbox),
+ (urwid.WEIGHT, 1, self.make_conversation_widget(None))
],
dividechars=0, focus_column=0, box_columns=[0]
)
self.list_shortcuts = ConversationListDisplayShortcuts(self.app)
self.editor_shortcuts = ConversationDisplayShortcuts(self.app)
+ self.body_shortcuts = ConversationBodyShortcuts(self.app)
self.shortcuts_display = self.list_shortcuts
- self.widget = self.columns_widget
- nomadnet.Conversation.created_callback = self.update_conversation_list
+ self.widget = urwid.WidgetPlaceholder(self.columns_widget)
+
+ self._pending_actions = collections.deque()
+ self._wake_fd = None
+ try:
+ self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending)
+ except Exception:
+ pass
+
+ nomadnet.Conversation.created_callback = lambda: self._wake(self._conversations_changed)
+
+ try:
+ self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status)
+ except Exception:
+ pass
+
+ def _conversations_changed(self):
+ self.update_conversation_list()
+ # If the identity keys for the displayed conversation just
+ # became known, enable its editor without requiring the user
+ # to close and reopen the conversation.
+ if self.currently_displayed_conversation != None:
+ widget = ConversationsDisplay.cached_conversation_widgets.get(self.currently_displayed_conversation)
+ if widget != None:
+ widget.check_editor_allowed()
+ widget._update_peer_info()
+
+ def _process_pending(self, data):
+ while True:
+ try:
+ action = self._pending_actions.popleft()
+ except IndexError:
+ break
+ try:
+ action()
+ except Exception as e:
+ RNS.log("Conversations UI action failed: "+str(e), RNS.LOG_ERROR)
+ return True
+
+ def _wake(self, action):
+ self._pending_actions.append(action)
+ if self._wake_fd is not None:
+ try:
+ os.write(self._wake_fd, b".")
+ return
+ except Exception:
+ pass
+ try:
+ self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None))
+ except Exception:
+ pass
def focus_change_event(self):
- # This hack corrects buggy styling behaviour in IndicativeListBox
if not self.dialog_open:
- ilb_position = self.ilb.get_selected_position()
self.update_conversation_list()
- if ilb_position != None:
- self.ilb.select_item(ilb_position)
- def update_listbox(self):
- conversation_list_widgets = []
- for conversation in self.app.conversations():
- conversation_list_widgets.append(self.conversation_list_widget(conversation))
+ def toggle_list_sort(self):
+ if self.list_sort_mode == ConversationsDisplay.SORT_RECENT:
+ self.list_sort_mode = ConversationsDisplay.SORT_NAME
+ else:
+ self.list_sort_mode = ConversationsDisplay.SORT_RECENT
+ self.update_conversation_list()
+
+ def _conversation_filter_predicate(self, conversation):
+ try:
+ trust_level = conversation[2]
+ except Exception:
+ return False
+ if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED:
+ return trust_level in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN)
+ return trust_level == DirectoryEntry.TRUSTED
+
+ def _set_filter(self, key):
+ if self.list_filter == key:
+ return
+ self.list_filter = key
+ try:
+ self.update_conversation_list()
+ except Exception as e:
+ RNS.log("Failed to apply conversation filter: "+str(e), RNS.LOG_ERROR)
+
+ def _on_show_blocked_change(self, _cb, new_state):
+ self.show_blocked = new_state
+ try:
+ self.update_conversation_list()
+ except Exception as e:
+ RNS.log("Failed to toggle show-blocked: "+str(e), RNS.LOG_ERROR)
+
+ def _apply_pile_layout(self):
+ pack_opts = self.pile.options('pack')
+ weight_opts = self.pile.options('weight', 1)
+ items = [(self.tab_bar, pack_opts)]
+ if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED:
+ items.append((self.show_blocked_checkbox, pack_opts))
+ items.append((self.ilb, weight_opts))
+ items.append((self.sync_status_text, pack_opts))
+ try:
+ prev_focus = self.pile.focus_position
+ except Exception:
+ prev_focus = None
+ self.pile.contents = items
+ try:
+ if prev_focus is not None and prev_focus < len(items):
+ self.pile.focus_position = prev_focus
+ except Exception:
+ pass
+
+ def _blocked_row_widget(self, dest_hash):
+ g = self.app.ui.glyphs
+ entry = self.app.directory.find(dest_hash)
+ display_name = None
+ if entry is not None and getattr(entry, "display_name", None):
+ display_name = strip_modifiers(entry.display_name)
+ if not display_name:
+ display_name = RNS.prettyhexrep(dest_hash)
+ label = " "+g["cross"]+" [blocked] "+display_name+" <"+RNS.hexrep(dest_hash, delimit=False)+">"
+ widget = ListEntry(label)
+ urwid.connect_signal(widget, "click", self._unblock_dialog, dest_hash)
+ attr = urwid.AttrMap(widget, "list_untrusted", "list_focus_untrusted")
+ attr.blocked_dest_hash = dest_hash
+ return attr
+
+ def _unblock_dialog(self, _sender, dest_hash):
+ self.dialog_open = True
+
+ def dismiss(_b):
+ self.dialog_open = False
+ self.update_conversation_list()
+
+ def confirmed(_b):
+ self.dialog_open = False
+ try:
+ self.app.unblock_destination(dest_hash)
+ except Exception as e:
+ RNS.log("Unblock failed: "+str(e), RNS.LOG_ERROR)
+ self.update_conversation_list()
+
+ try:
+ who = self.app.directory.simplest_display_str(dest_hash)
+ except Exception:
+ who = RNS.hexrep(dest_hash, delimit=False)
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(""),
+ urwid.Text("Unblock "+str(who)+"?\n\nThis lifts the RNS blackhole on the peer's identity\nand removes them from your ignored list.\n", align=urwid.CENTER),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes, unblock", on_press=confirmed)),
+ (urwid.WEIGHT, 0.10, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)),
+ ]),
+ ]), title="Confirm unblock"
+ )
+ dialog.delegate = self
+
+ bottom = self.listbox
+ overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
+ try:
+ self.columns_widget.contents[0] = (
+ overlay,
+ self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width),
+ )
+ self.columns_widget.focus_position = 0
+ except Exception:
+ pass
+
+ def _build_persistent_listbox(self):
+ self.tab_trusted = TabButton("Trusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED))
+ self.tab_untrusted = TabButton("Untrusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED))
+
+ self.tab_bar = urwid.Columns([
+ ('weight', 1, self.tab_trusted),
+ ('weight', 1, self.tab_untrusted),
+ ], dividechars=1)
+
+ self.show_blocked_checkbox = urwid.CheckBox("Show blocked (0)", state=self.show_blocked)
+ urwid.connect_signal(self.show_blocked_checkbox, "change", self._on_show_blocked_change)
- walker = urwid.SimpleFocusListWalker(conversation_list_widgets)
- self.list_widgets = conversation_list_widgets
self.ilb = IndicativeListBox(
- self.list_widgets,
+ [urwid.Text("")],
on_selection_change=self.conversation_list_selection,
initialization_is_selection_change=False,
- highlight_offFocus="list_off_focus"
+ highlight_offFocus="list_off_focus",
)
- self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=("relative", 100)))
+ self.sync_status_text = urwid.AttrMap(urwid.Text(self._sync_status_line(), align=urwid.LEFT), "shortcutbar")
+
+ self.pile = urwid.Pile([
+ ('pack', self.tab_bar),
+ ('weight', 1, self.ilb),
+ ('pack', self.sync_status_text),
+ ])
+ try: self.pile.focus_position = 1
+ except Exception: pass
+
+ self.listbox = ConversationsArea(self.pile, title="Conversations")
self.listbox.delegate = self
+ def update_listbox(self):
+ if not hasattr(self, "pile"):
+ self._build_persistent_listbox()
+
+ try:
+ conversations = self.app.conversations()
+ except Exception as e:
+ RNS.log("Failed to enumerate conversations: "+str(e), RNS.LOG_ERROR)
+ conversations = []
+
+ try:
+ if self.list_sort_mode == ConversationsDisplay.SORT_NAME:
+ conversations.sort(key=lambda e: (e[3].lower(), e[0]))
+ except Exception:
+ pass
+
+ def _is_pinned(c):
+ try:
+ entry = self.app.directory.find(bytes.fromhex(c[0]))
+ return entry is not None and entry.sort_rank is not None
+ except Exception:
+ return False
+ try:
+ conversations = sorted(conversations, key=lambda c: 0 if _is_pinned(c) else 1)
+ except Exception:
+ pass
+
+ glyphs = self.app.ui.glyphs
+ def _alerts(c):
+ return bool(c[4]) or (len(c) > 6 and bool(c[6]))
+ trusted_count = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED)
+ untrusted_count = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN))
+ trusted_unread = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED and _alerts(c))
+ untrusted_unread = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) and _alerts(c))
+
+ def _label(name, total, unread):
+ if unread:
+ return f"{name} ({total}) {glyphs['unread']} {unread}"
+ return f"{name} ({total})"
+
+ self.tab_trusted.set_label(_label("Trusted", trusted_count, trusted_unread))
+ self.tab_untrusted.set_label(_label("Untrusted", untrusted_count, untrusted_unread))
+
+ filtered = [c for c in conversations if self._conversation_filter_predicate(c)]
+
+ conversation_list_widgets = []
+ for conversation in filtered:
+ try:
+ conversation_list_widgets.append(self.conversation_list_widget(conversation))
+ except Exception as e:
+ try: hh = conversation[0]
+ except Exception: hh = "?"
+ RNS.log("Skipping conversation row for "+str(hh)+": "+str(e), RNS.LOG_ERROR)
+
+ blocked_count = len(self.app.ignored_list) if hasattr(self.app, "ignored_list") else 0
+ try:
+ self.show_blocked_checkbox.set_label(f"Show blocked ({blocked_count})")
+ except Exception:
+ pass
+
+ if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED and self.show_blocked:
+ for blocked_hash in list(self.app.ignored_list):
+ try:
+ conversation_list_widgets.append(self._blocked_row_widget(blocked_hash))
+ except Exception as e:
+ RNS.log("Skipping blocked row: "+str(e), RNS.LOG_ERROR)
+
+ empty_placeholder = False
+ if not conversation_list_widgets:
+ empty_label = {
+ ConversationsDisplay.LIST_FILTER_TRUSTED: "No trusted conversations",
+ ConversationsDisplay.LIST_FILTER_UNTRUSTED: "No untrusted conversations",
+ }.get(self.list_filter, "No conversations")
+ conversation_list_widgets = [urwid.Text(empty_label, align='center')]
+ empty_placeholder = True
+
+ self.list_widgets = conversation_list_widgets
+
+ try:
+ self.ilb.set_body(conversation_list_widgets)
+ except Exception as e:
+ RNS.log("Failed to populate conversation list: "+str(e), RNS.LOG_ERROR)
+
+ self._apply_pile_layout()
+
+ if empty_placeholder:
+ try: self.pile.focus_position = 0
+ except Exception: pass
+
+ try:
+ self.sync_status_text.original_widget.set_text(self._sync_status_line())
+ except Exception:
+ pass
+
+ def _sync_status_line(self):
+ try:
+ last = self.app.peer_settings.get("last_lxmf_sync")
+ except Exception:
+ last = None
+ if not last:
+ when = "never"
+ else:
+ try:
+ when = relative_time(float(last))
+ except Exception:
+ when = "unknown"
+
+ node_label = None
+ try:
+ pn_hash = self.app.get_default_propagation_node()
+ if pn_hash is not None:
+ pn_ident = RNS.Identity.recall(pn_hash)
+ if pn_ident is not None:
+ node_dest = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident)
+ entry = self.app.directory.find(node_dest)
+ if entry is not None and getattr(entry, "display_name", None):
+ node_label = strip_modifiers(str(entry.display_name)) or None
+ if node_label is None:
+ node_label = "<"+RNS.hexrep(pn_hash, delimit=False)[:8]+"…>"
+ except Exception:
+ node_label = None
+
+ line = " Last sync: "+when
+ if node_label:
+ line += " ("+node_label+")"
+ return line
+
+ def _refresh_sync_status(self, _loop=None, _data=None):
+ try:
+ if hasattr(self, "sync_status_text") and self.sync_status_text is not None:
+ self.sync_status_text.original_widget.set_text(self._sync_status_line())
+ except Exception:
+ pass
+ try:
+ self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status)
+ except Exception:
+ pass
+
def delete_selected_conversation(self):
self.dialog_open = True
- source_hash = self.ilb.get_selected_item().source_hash
+ item = self.ilb.get_selected_item()
+ source_hash = getattr(item, "source_hash", None) if item is not None else None
+ if source_hash is None:
+ self.dialog_open = False
+ return
def dismiss_dialog(sender):
- self.update_conversation_list()
self.dialog_open = False
+ self.update_conversation_list()
def confirmed(sender):
self.dialog_open = False
@@ -114,37 +590,277 @@ class ConversationsDisplay():
dialog = DialogLineBox(
urwid.Pile([
- urwid.Text("Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n", align="center"),
- urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))])
+ urwid.Text(
+ "Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n",
+ align=urwid.CENTER,
+ ),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)),
+ ])
]), title="?"
)
dialog.delegate = self
bottom = self.listbox
- overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2)
+ overlay = urwid.Overlay(
+ dialog,
+ bottom,
+ align=urwid.CENTER,
+ width=urwid.RELATIVE_100,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ left=2,
+ right=2,
+ )
- options = self.columns_widget.options("weight", ConversationsDisplay.list_width)
+ # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width)
+ options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
self.columns_widget.contents[0] = (overlay, options)
+ def _refresh_open_conversation_widget(self, source_hash_text):
+ widget = ConversationsDisplay.cached_conversation_widgets.get(source_hash_text)
+ if widget is None:
+ return
+ try:
+ widget._trust_banner_dismissed = False
+ except Exception:
+ pass
+ try:
+ widget._refresh_trust_banner()
+ except Exception:
+ pass
+ try:
+ widget._update_peer_info()
+ except Exception:
+ pass
+ try:
+ widget.update_message_widgets(replace=True)
+ except Exception:
+ pass
+
+ def show_my_qr(self):
+ try:
+ addr = RNS.hexrep(self.app.lxmf_destination.hash, delimit=False)
+ except Exception:
+ return
+ try:
+ display = self.app.peer_settings.get("display_name") or "My LXMF"
+ except Exception:
+ display = "My LXMF"
+ self.show_qr_dialog(addr, title=display)
+
+ def show_qr_dialog(self, data, title=None):
+ qr_text = qr_ascii(data)
+
+ def dismiss(_b):
+ self._restore_listbox_pane()
+
+ rows = [urwid.Text("")]
+ if qr_text is not None:
+ rows.append(urwid.Text(qr_text, align=urwid.CENTER))
+ rows.append(urwid.Text(""))
+ else:
+ rows.append(urwid.Text("LXMF destination address:", align=urwid.CENTER))
+ rows.append(urwid.Text(""))
+ rows += [
+ urwid.Text("< "+data+" >", align=urwid.CENTER),
+ urwid.Text(""),
+ urwid.Columns([
+ (urwid.WEIGHT, 1, urwid.Text("")),
+ (12, urwid.Button("Close", on_press=dismiss)),
+ (urwid.WEIGHT, 1, urwid.Text("")),
+ ]),
+ urwid.Text(""),
+ ]
+ dialog_title = "LXMF Address" if qr_text is None else "QR Code"
+ dialog = DialogLineBox(urwid.Pile(rows), title=dialog_title)
+ dialog.delegate = self
+ self._overlay_dialog(dialog)
+
+ def _overlay_dialog(self, dialog):
+ overlay = urwid.Overlay(
+ dialog, self.columns_widget,
+ align=urwid.CENTER, width=(urwid.RELATIVE, 70),
+ valign=urwid.MIDDLE, height=urwid.PACK,
+ min_width=44,
+ )
+ try:
+ self.widget.original_widget = overlay
+ self.dialog_open = True
+ except Exception:
+ pass
+
+ def _restore_listbox_pane(self):
+ try:
+ self.widget.original_widget = self.columns_widget
+ self.dialog_open = False
+ self.update_conversation_list()
+ except Exception:
+ pass
+
+ def _ping_peer_from_dialog(self, source_hash_text, status_widget, ping_button):
+ try:
+ dest = bytes.fromhex(source_hash_text)
+ except Exception:
+ status_widget.set_text(("error_text", "Invalid address"))
+ return
+
+ identity = RNS.Identity.recall(dest)
+ if identity is None:
+ status_widget.set_text(("error_text", "Identity unknown; query first"))
+ return
+
+ if not RNS.Transport.has_path(dest):
+ status_widget.set_text("No path; requesting…")
+ try: RNS.Transport.request_path(dest)
+ except Exception: pass
+
+ status_widget.set_text("Pinging…")
+ ping_button.set_label("Pinging…")
+ started_at = time.time()
+
+ def schedule_ui(fn):
+ try:
+ self.app.ui.loop.set_alarm_in(0, lambda *_: fn())
+ except Exception:
+ try: fn()
+ except Exception: pass
+
+ def on_established(link):
+ elapsed_ms = int((time.time() - started_at) * 1000)
+ try:
+ hops = RNS.Transport.hops_to(dest)
+ if hops is None or hops >= RNS.Transport.PATHFINDER_M:
+ hops_str = ""
+ else:
+ hops_str = f" ({hops} hop{'s' if hops != 1 else ''})"
+ except Exception:
+ hops_str = ""
+ def update():
+ status_widget.set_text(f"Pong in {elapsed_ms} ms{hops_str}")
+ ping_button.set_label("Ping")
+ schedule_ui(update)
+ try: link.teardown()
+ except Exception: pass
+
+ def on_closed(link):
+ try:
+ if getattr(link, "status", None) == RNS.Link.ACTIVE:
+ return
+ except Exception:
+ pass
+ def update():
+ if status_widget.text.strip() in ("Pinging…", ""):
+ status_widget.set_text(("error_text", "Ping failed (no link)"))
+ ping_button.set_label("Ping")
+ schedule_ui(update)
+
+ try:
+ destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
+ RNS.Link(destination, established_callback=on_established, closed_callback=on_closed)
+ except Exception as e:
+ status_widget.set_text(("error_text", f"Ping init failed: {e}"))
+ ping_button.set_label("Ping")
+
+ def _block_peer_from_dialog(self, source_hash_text):
+ try:
+ dest = bytes.fromhex(source_hash_text)
+ except Exception:
+ return
+
+ def cancel_block(_b):
+ self.dialog_open = False
+ self.update_conversation_list()
+
+ def confirm_block(_b):
+ try:
+ self.app.block_destination(dest, reason="user-blocked from peer info dialog")
+ except Exception as e:
+ RNS.log("Block failed: "+str(e), RNS.LOG_ERROR)
+ try:
+ self.delete_conversation(source_hash_text)
+ nomadnet.Conversation.delete_conversation(source_hash_text, self.app)
+ except Exception:
+ pass
+ self.dialog_open = False
+ self.update_conversation_list()
+
+ try:
+ who = self.app.directory.simplest_display_str(dest)
+ except Exception:
+ who = source_hash_text
+
+ confirm_dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(""),
+ urwid.Text("Block "+str(who)+"?\n\nThis blackholes the peer's identity in Reticulum,\nadds them to your ignored list, and deletes any\nconversation with them.\n", align=urwid.CENTER),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirm_block)),
+ (urwid.WEIGHT, 0.10, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel_block)),
+ ]),
+ ]), title="Confirm block"
+ )
+ confirm_dialog.delegate = self
+ bottom = self.listbox
+ overlay = urwid.Overlay(confirm_dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
+ try:
+ self.columns_widget.contents[0] = (
+ overlay,
+ self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width),
+ )
+ self.columns_widget.focus_position = 0
+ self.dialog_open = True
+ except Exception:
+ pass
+
def edit_selected_in_directory(self):
+ g = self.app.ui.glyphs
self.dialog_open = True
- source_hash_text = self.ilb.get_selected_item().source_hash
- display_name = self.ilb.get_selected_item().display_name
- if display_name == None:
+ item = self.ilb.get_selected_item()
+ if item == None:
+ self.dialog_open = False
+ return
+ source_hash_text = getattr(item, "source_hash", None)
+ if source_hash_text is None:
+ blocked = getattr(item, "blocked_dest_hash", None)
+ if isinstance(blocked, (bytes, bytearray)):
+ source_hash_text = RNS.hexrep(blocked, delimit=False)
+ if source_hash_text is None:
+ self.dialog_open = False
+ return
+ display_name = getattr(item, "display_name", None)
+ if display_name is None:
+ try:
+ display_name = self.app.directory.display_name(bytes.fromhex(source_hash_text))
+ except Exception:
+ display_name = None
+ if display_name is None:
display_name = ""
- e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash_text)
+ e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash_text)
t_id = urwid.Text("Addr : "+source_hash_text)
- e_name = urwid.Edit(caption="Name : ",edit_text=display_name)
+ e_name = ReadlineEdit(caption="Name : ",edit_text=display_name)
+ e_copy = ReadlineEdit(caption="Copy : ", edit_text=source_hash_text)
selected_id_widget = t_id
- untrusted_selected = False
- unknown_selected = True
- trusted_selected = False
+ untrusted_selected = False
+ unknown_selected = True
+ trusted_selected = False
+
+ direct_selected = True
+ propagated_selected = False
+ opportunistic_selected = False
+
+ pinned_initial = False
+ notes_initial = ""
try:
- if self.app.directory.find(bytes.fromhex(source_hash_text)):
+ existing_entry = self.app.directory.find(bytes.fromhex(source_hash_text))
+ if existing_entry:
trust_level = self.app.directory.trust_level(bytes.fromhex(source_hash_text))
if trust_level == DirectoryEntry.UNTRUSTED:
untrusted_selected = True
@@ -158,17 +874,37 @@ class ConversationsDisplay():
untrusted_selected = False
unknown_selected = False
trusted_selected = True
+
+ preferred = self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text))
+ if preferred == DirectoryEntry.PROPAGATED:
+ direct_selected = False
+ propagated_selected = True
+ elif preferred == DirectoryEntry.OPPORTUNISTIC:
+ direct_selected = False
+ opportunistic_selected = True
+
+ pinned_initial = existing_entry.sort_rank is not None
+ notes_initial = getattr(existing_entry, "notes", "") or ""
+
except Exception as e:
pass
+ e_notes = ReadlineEdit(caption="Notes: ", edit_text=notes_initial, multiline=True)
+ cb_pin = urwid.CheckBox("Pin to top", state=pinned_initial)
+
trust_button_group = []
r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted", state=untrusted_selected)
r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected)
r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected)
+ method_button_group = []
+ r_direct = urwid.RadioButton(method_button_group, "Deliver over direct link", state=direct_selected)
+ r_opportunistic = urwid.RadioButton(method_button_group, "Deliver opportunistically", state=opportunistic_selected)
+ r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected)
+
def dismiss_dialog(sender):
- self.update_conversation_list()
self.dialog_open = False
+ self.update_conversation_list()
def confirmed(sender):
try:
@@ -180,43 +916,96 @@ class ConversationsDisplay():
elif r_trusted.state == True:
trust_level = DirectoryEntry.TRUSTED
- entry = DirectoryEntry(source_hash, display_name, trust_level)
+ delivery = DirectoryEntry.DIRECT
+ if r_propagated.state == True:
+ delivery = DirectoryEntry.PROPAGATED
+ elif r_opportunistic.state == True:
+ delivery = DirectoryEntry.OPPORTUNISTIC
+
+ sort_rank = 0 if cb_pin.state else None
+ notes_value = e_notes.get_edit_text()
+ entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery, sort_rank=sort_rank, notes=notes_value)
self.app.directory.remember(entry)
- self.update_conversation_list()
+ self._refresh_open_conversation_widget(source_hash_text)
self.dialog_open = False
+ self.update_conversation_list()
self.app.ui.main_display.sub_displays.network_display.directory_change_callback()
except Exception as e:
RNS.log("Could not save directory entry. The contained exception was: "+str(e), RNS.LOG_VERBOSE)
if not dialog_pile.error_display:
dialog_pile.error_display = True
- options = dialog_pile.options(height_type="pack")
+ options = dialog_pile.options(height_type=urwid.PACK)
dialog_pile.contents.append((urwid.Text(""), options))
- dialog_pile.contents.append((urwid.Text(("error_text", "Could not save entry. Check your input."), align="center"), options))
+ dialog_pile.contents.append((
+ urwid.Text(("error_text", "Could not save entry. Check your input."), align=urwid.CENTER),
+ options,)
+ )
source_is_known = self.app.directory.is_known(bytes.fromhex(source_hash_text))
if source_is_known:
- known_section = urwid.Divider("\u2504")
+ 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="pack")
+ options = dialog_pile.options(height_type=urwid.PACK)
dialog_pile.contents = [
(urwid.Text("Query sent"), options),
(urwid.Button("OK", on_press=dismiss_dialog), options)
]
query_button = urwid.Button("Query network for keys", on_press=query_action, user_data=source_hash_text)
- known_section = urwid.Pile([urwid.Divider("\u2504"), urwid.Text("\u2139\n", align="center"), urwid.Text("The identity of this peer is not known, and you cannot currently communicate.\n", align="center"), query_button, urwid.Divider("\u2504")])
+ 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,
- urwid.Divider("\u2504"),
+ e_copy,
+ urwid.Divider(g["divider1"]),
r_untrusted,
r_unknown,
r_trusted,
+ urwid.Divider(g["divider1"]),
+ r_direct,
+ r_opportunistic,
+ r_propagated,
+ urwid.Divider(g["divider1"]),
+ cb_pin,
+ e_notes,
known_section,
- urwid.Columns([("weight", 0.45, urwid.Button("Save", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))])
+ actions_row,
+ action_status,
+ urwid.Divider(g["divider1"]),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)),
+ ])
])
dialog_pile.error_display = False
@@ -224,9 +1013,19 @@ class ConversationsDisplay():
dialog.delegate = self
bottom = self.listbox
- overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2)
+ overlay = urwid.Overlay(
+ dialog,
+ bottom,
+ align=urwid.CENTER,
+ width=urwid.RELATIVE_100,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ left=2,
+ right=2,
+ )
- options = self.columns_widget.options("weight", ConversationsDisplay.list_width)
+ # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width)
+ options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
self.columns_widget.contents[0] = (overlay, options)
def new_conversation(self):
@@ -234,8 +1033,8 @@ class ConversationsDisplay():
source_hash = ""
display_name = ""
- e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash)
- e_name = urwid.Edit(caption="Name : ",edit_text=display_name)
+ e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash)
+ e_name = ReadlineEdit(caption="Name : ",edit_text=display_name)
trust_button_group = []
r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted")
@@ -243,15 +1042,15 @@ class ConversationsDisplay():
r_trusted = urwid.RadioButton(trust_button_group, "Trusted")
def dismiss_dialog(sender):
- self.update_conversation_list()
self.dialog_open = False
+ self.update_conversation_list()
def confirmed(sender):
try:
existing_conversations = nomadnet.Conversation.conversation_list(self.app)
display_name = e_name.get_edit_text()
- source_hash_text = e_id.get_edit_text()
+ source_hash_text = e_id.get_edit_text().strip()
source_hash = bytes.fromhex(source_hash_text)
trust_level = DirectoryEntry.UNTRUSTED
if r_unknown.state == True:
@@ -259,23 +1058,34 @@ class ConversationsDisplay():
elif r_trusted.state == True:
trust_level = DirectoryEntry.TRUSTED
- if not source_hash in [c[0] for c in existing_conversations]:
+ if not source_hash_text in [c[0] for c in existing_conversations]:
entry = DirectoryEntry(source_hash, display_name, trust_level)
self.app.directory.remember(entry)
new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True)
+
self.update_conversation_list()
- self.display_conversation(source_hash_text)
+ if trust_level != DirectoryEntry.TRUSTED:
+ if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED:
+ self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED)
+ self.display_conversation(None, source_hash_text)
self.dialog_open = False
+ self.update_conversation_list()
except Exception as e:
RNS.log("Could not start conversation. The contained exception was: "+str(e), RNS.LOG_VERBOSE)
if not dialog_pile.error_display:
dialog_pile.error_display = True
- options = dialog_pile.options(height_type="pack")
+ options = dialog_pile.options(height_type=urwid.PACK)
dialog_pile.contents.append((urwid.Text(""), options))
- dialog_pile.contents.append((urwid.Text(("error_text", "Could not start conversation. Check your input."), align="center"), options))
+ dialog_pile.contents.append((
+ urwid.Text(
+ ("error_text", "Could not start conversation. Check your input."),
+ align=urwid.CENTER,
+ ),
+ options,
+ ))
dialog_pile = urwid.Pile([
e_id,
@@ -285,7 +1095,11 @@ class ConversationsDisplay():
r_unknown,
r_trusted,
urwid.Text(""),
- urwid.Columns([("weight", 0.45, urwid.Button("Create", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Back", on_press=dismiss_dialog))])
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)),
+ ])
])
dialog_pile.error_display = False
@@ -293,9 +1107,172 @@ class ConversationsDisplay():
dialog.delegate = self
bottom = self.listbox
- overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2)
+ overlay = urwid.Overlay(
+ dialog,
+ bottom,
+ align=urwid.CENTER,
+ width=urwid.RELATIVE_100,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ left=2,
+ right=2,
+ )
- options = self.columns_widget.options("weight", ConversationsDisplay.list_width)
+ # options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width)
+ options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
+ self.columns_widget.contents[0] = (overlay, options)
+
+ def ingest_lxm_uri(self):
+ self.dialog_open = True
+ lxm_uri = ""
+ e_uri = ReadlineEdit(caption="URI : ",edit_text=lxm_uri)
+
+ def dismiss_dialog(sender):
+ self.dialog_open = False
+ self.update_conversation_list()
+
+ def confirmed(sender):
+ try:
+ local_delivery_signal = "local_delivery_occurred"
+ duplicate_signal = "duplicate_lxm"
+ lxm_uri = e_uri.get_edit_text().strip()
+
+ ingest_result = self.app.message_router.ingest_lxm_uri(
+ lxm_uri,
+ signal_local_delivery=local_delivery_signal,
+ signal_duplicate=duplicate_signal
+ )
+
+ if ingest_result == False:
+ raise ValueError("The URI contained no decodable messages")
+
+ elif ingest_result == local_delivery_signal:
+ rdialog_pile = urwid.Pile([
+ urwid.Text("Message was decoded, decrypted successfully, and added to your conversation list."),
+ urwid.Text(""),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.6, urwid.Text("")),
+ (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
+ ])
+ ])
+ rdialog_pile.error_display = False
+
+ rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI")
+ rdialog.delegate = self
+ bottom = self.listbox
+
+ roverlay = urwid.Overlay(
+ rdialog,
+ bottom,
+ align=urwid.CENTER,
+ width=urwid.RELATIVE_100,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ left=2,
+ right=2,
+ )
+
+ options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
+ self.columns_widget.contents[0] = (roverlay, options)
+
+ elif ingest_result == duplicate_signal:
+ rdialog_pile = urwid.Pile([
+ urwid.Text("The decoded message has already been processed by the LXMF Router, and will not be ingested again."),
+ urwid.Text(""),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.6, urwid.Text("")),
+ (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
+ ])
+ ])
+ rdialog_pile.error_display = False
+
+ rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI")
+ rdialog.delegate = self
+ bottom = self.listbox
+
+ roverlay = urwid.Overlay(
+ rdialog,
+ bottom,
+ align=urwid.CENTER,
+ width=urwid.RELATIVE_100,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ left=2,
+ right=2,
+ )
+
+ options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
+ self.columns_widget.contents[0] = (roverlay, options)
+
+ else:
+ if self.app.enable_node:
+ propagation_text = "The decoded message was not addressed to this LXMF address, but has been added to the propagation node queues, and will be distributed on the propagation network."
+ else:
+ propagation_text = "The decoded message was not addressed to this LXMF address, and has been discarded."
+
+ rdialog_pile = urwid.Pile([
+ urwid.Text(propagation_text),
+ urwid.Text(""),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.6, urwid.Text("")),
+ (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
+ ])
+ ])
+ rdialog_pile.error_display = False
+
+ rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI")
+ rdialog.delegate = self
+ bottom = self.listbox
+
+ roverlay = urwid.Overlay(
+ rdialog,
+ bottom,
+ align=urwid.CENTER,
+ width=urwid.RELATIVE_100,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ left=2,
+ right=2,
+ )
+
+ options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
+ self.columns_widget.contents[0] = (roverlay, options)
+
+ except Exception as e:
+ RNS.log("Could not ingest LXM URI. The contained exception was: "+str(e), RNS.LOG_VERBOSE)
+ if not dialog_pile.error_display:
+ dialog_pile.error_display = True
+ options = dialog_pile.options(height_type=urwid.PACK)
+ dialog_pile.contents.append((urwid.Text(""), options))
+ dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URI data. Check your input."), align=urwid.CENTER), options))
+
+ dialog_pile = urwid.Pile([
+ e_uri,
+ urwid.Text(""),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Ingest", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)),
+ ])
+ ])
+ dialog_pile.error_display = False
+
+ dialog = DialogLineBox(dialog_pile, title="Ingest message URI")
+ dialog.delegate = self
+ bottom = self.listbox
+
+ overlay = urwid.Overlay(
+ dialog,
+ bottom,
+ align=urwid.CENTER,
+ width=urwid.RELATIVE_100,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ left=2,
+ right=2,
+ )
+
+ options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
self.columns_widget.contents[0] = (overlay, options)
def delete_conversation(self, source_hash):
@@ -303,25 +1280,385 @@ class ConversationsDisplay():
conversation = ConversationsDisplay.cached_conversation_widgets[source_hash]
self.close_conversation(conversation)
+ def toggle_fullscreen(self):
+ if ConversationsDisplay.given_list_width != 0:
+ self.saved_list_width = ConversationsDisplay.given_list_width
+ ConversationsDisplay.given_list_width = 0
+ else:
+ ConversationsDisplay.given_list_width = self.saved_list_width
+
+ self.update_conversation_list()
+
+ def _decode_pn_app_data(self, app_data):
+ try:
+ if not pn_announce_data_is_valid(app_data):
+ return None
+ data = msgpack.unpackb(app_data)
+ enabled = bool(data[2])
+ name = None
+ try:
+ meta = data[6]
+ if isinstance(meta, dict) and PN_META_NAME in meta:
+ raw = meta[PN_META_NAME]
+ if isinstance(raw, (bytes, bytearray)):
+ name = raw.decode("utf-8", errors="replace")
+ except Exception:
+ name = None
+ return {"enabled": enabled, "name": name}
+ except Exception:
+ return None
+
+ def _pn_dropdown_label(self, pn_hash, meta):
+ name = (meta or {}).get("name")
+ if name:
+ label = sanitize_name(name) or ""
+ label = " ".join(label.split())
+ else:
+ label = ""
+ if not label:
+ label = RNS.prettyhexrep(pn_hash)
+ max_len = 40
+ if len(label) > max_len:
+ label = label[:max_len-1]+"…"
+ tail = "<"+RNS.hexrep(pn_hash, delimit=False)+">"
+ if tail not in label:
+ label = label+" "+tail
+ if meta is None:
+ status = "[?]"
+ elif meta.get("enabled"):
+ status = "[E]"
+ else:
+ status = "[D]"
+ return status+" "+label
+
+ def _build_pn_options(self):
+ options = []
+ seen = set()
+
+ try:
+ pn_announces = list(self.app.directory._pn_announces)
+ except Exception:
+ pn_announces = []
+ for tup in pn_announces:
+ if len(tup) < 3: continue
+ pn_hash = tup[1]
+ app_data = tup[2]
+ if pn_hash in seen: continue
+ seen.add(pn_hash)
+ meta = self._decode_pn_app_data(app_data)
+ options.append((pn_hash, self._pn_dropdown_label(pn_hash, meta)))
+
+ for extra in (self.app.get_user_selected_propagation_node(), self.app.get_default_propagation_node()):
+ if extra is None or extra in seen:
+ continue
+ seen.add(extra)
+ meta = None
+ try:
+ cached = RNS.Identity.recall_app_data(extra)
+ if cached is not None:
+ meta = self._decode_pn_app_data(cached)
+ except Exception:
+ meta = None
+ options.append((extra, self._pn_dropdown_label(extra, meta)))
+
+ return options
+
+ def sync_conversations(self):
+ g = self.app.ui.glyphs
+ self.dialog_open = True
+
+ def dismiss_dialog(sender):
+ self.dialog_open = False
+ self.sync_dialog = None
+ 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):
- ilb_position = self.ilb.get_selected_position()
+ 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("weight", ConversationsDisplay.list_width)
- self.columns_widget.contents[0] = (self.listbox, options)
- if ilb_position != None:
- self.ilb.select_item(ilb_position)
+ options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
+ if not self.dialog_open:
+ self.columns_widget.contents[0] = (self.listbox, options)
+ elif self.sync_dialog is not None:
+ bottom = self.listbox
+ overlay = urwid.Overlay(
+ self.sync_dialog,
+ bottom,
+ align=urwid.CENTER,
+ width=urwid.RELATIVE_100,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ left=2,
+ right=2,
+ )
+ self.columns_widget.contents[0] = (overlay, options)
+ # else: another dialog (peer info, new conversation, block confirm, etc.) is
+ # open as an overlay in contents[0]; leave it alone so an incoming message
+ # doesn't dismiss it. The underlying listbox is a persistent widget and was
+ # already refreshed by update_listbox() above.
+
+ if selected_hash is not None:
+ for idx, widget in enumerate(self.list_widgets):
+ if getattr(widget, "source_hash", None) == selected_hash:
+ self.ilb.select_item(idx)
+ break
nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen()
+ if self.app.ui.main_display.sub_displays.active_display == self.app.ui.main_display.sub_displays.conversations_display:
+ 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.widget.options("weight", 1-ConversationsDisplay.list_width)
- self.widget.contents[1] = (self.make_conversation_widget(source_hash), options)
+ options = self.columns_widget.options(urwid.WEIGHT, 1)
+ self.columns_widget.contents[1] = (self.make_conversation_widget(source_hash), options)
+ if source_hash == None:
+ self.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 getattr(widget, "source_hash", None) == source_hash:
+ conversation_position = index
+ index += 1
+
+ if conversation_position != None:
+ self.ilb.select_item(conversation_position)
def make_conversation_widget(self, source_hash):
@@ -333,8 +1670,7 @@ class ConversationsDisplay():
conversation_widget.check_editor_allowed()
return conversation_widget
else:
- widget = ConversationWidget(source_hash)
- widget.delegate = self
+ widget = ConversationWidget(source_hash, delegate=self)
ConversationsDisplay.cached_conversation_widgets[source_hash] = widget
widget.check_editor_allowed()
@@ -356,12 +1692,17 @@ class ConversationsDisplay():
def conversation_list_widget(self, conversation):
- trust_level = conversation[2]
- display_name = conversation[1]
- source_hash = conversation[0]
+ 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 = "\u2715"
+ symbol = g["cross"]
style = "list_untrusted"
focus_style = "list_focus_untrusted"
elif trust_level == DirectoryEntry.UNKNOWN:
@@ -369,26 +1710,57 @@ class ConversationsDisplay():
style = "list_unknown"
focus_style = "list_focus"
elif trust_level == DirectoryEntry.TRUSTED:
- symbol = "\u2713"
- style = "list_trusted"
- focus_style = "list_focus_trusted"
+ symbol = g["check"]
+ style = "list_normal"
+ focus_style = "list_focus"
elif trust_level == DirectoryEntry.WARNING:
- symbol = "\u26A0"
+ symbol = g["warning"]
style = "list_warning"
focus_style = "list_focus"
else:
- symbol = "\u26A0"
+ symbol = g["warning"]
style = "list_untrusted"
focus_style = "list_focus_untrusted"
- display_text = symbol
+ is_pinned = False
+ try:
+ entry = self.app.directory.find(bytes.fromhex(source_hash))
+ is_pinned = entry is not None and entry.sort_rank is not None
+ except Exception:
+ is_pinned = False
+
+ head = symbol
+ if is_pinned:
+ head = g.get("pin", "*") + " " + head
+
if display_name != None and display_name != "":
- display_text += " "+display_name
+ head += " "+display_name
if trust_level != DirectoryEntry.TRUSTED:
- display_text += " <"+source_hash+">"
+ head += " <"+source_hash+">"
- widget = ListEntry(display_text)
+ 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
@@ -398,13 +1770,22 @@ class ConversationsDisplay():
def shortcuts(self):
- focus_path = self.widget.get_focus_path()
- if focus_path[0] == 0:
+ try:
+ focus_path = self.columns_widget.get_focus_path()
+ except Exception:
return self.list_shortcuts
- elif focus_path[0] == 1:
+ if not focus_path or focus_path[0] != 1:
+ return self.list_shortcuts
+ try:
+ cw = self.columns_widget.contents[1][0]
+ frame = cw.base_widget.frame
+ if frame is None:
+ return self.editor_shortcuts
+ if frame.focus_position == "footer":
+ return self.editor_shortcuts
+ return self.body_shortcuts
+ except Exception:
return self.editor_shortcuts
- else:
- return self.list_shortcuts
class ListEntry(urwid.Text):
_selectable = True
@@ -430,19 +1811,48 @@ class ListEntry(urwid.Text):
self._emit('click')
return True
-class MessageEdit(urwid.Edit):
+LXM_STATE_NAMES = {
+ LXMF.LXMessage.GENERATING: "Generating",
+ LXMF.LXMessage.OUTBOUND: "Outbound",
+ LXMF.LXMessage.SENDING: "Sending",
+ LXMF.LXMessage.SENT: "Sent",
+ LXMF.LXMessage.DELIVERED: "Delivered",
+ LXMF.LXMessage.REJECTED: "Rejected",
+ LXMF.LXMessage.CANCELLED: "Cancelled",
+ LXMF.LXMessage.FAILED: "Failed",
+}
+
+LXM_METHOD_NAMES = {
+ LXMF.LXMessage.OPPORTUNISTIC: "Opportunistic",
+ LXMF.LXMessage.DIRECT: "Direct link",
+ LXMF.LXMessage.PROPAGATED: "Propagation node",
+ LXMF.LXMessage.PAPER: "Paper",
+}
+
+class MessageEdit(ReadlineMixin, urwid.Edit):
def keypress(self, size, key):
if key == "ctrl d":
self.delegate.send_message()
+ elif key == "ctrl p":
+ self.delegate.paper_message()
+ elif key == "ctrl f":
+ self.delegate.attach_file()
+ elif key == "ctrl s":
+ self.delegate.save_focused_attachments()
+ elif key == "ctrl v":
+ self.delegate.cycle_delivery_method()
+ elif key == "ctrl r":
+ self.delegate.toggle_message_details()
elif key == "ctrl k":
- self.delegate.clear_editor()
+ if not self.delegate.cancel_pending_send():
+ return super(MessageEdit, self).keypress(size, key)
elif key == "up":
y = self.get_cursor_coords(size)[1]
if y == 0:
if self.delegate.full_editor_active and self.name == "title_editor":
- self.delegate.frame.set_focus("body")
+ self.delegate.frame.focus_position = "body"
elif not self.delegate.full_editor_active and self.name == "content_editor":
- self.delegate.frame.set_focus("body")
+ self.delegate.frame.focus_position = "body"
else:
return super(MessageEdit, self).keypress(size, key)
else:
@@ -452,25 +1862,58 @@ class MessageEdit(urwid.Edit):
class ConversationFrame(urwid.Frame):
+ @property
+ def focus_position(self):
+ return urwid.Frame.focus_position.fget(self)
+
+ @focus_position.setter
+ def focus_position(self, part):
+ urwid.Frame.focus_position.fset(self, part)
+ try:
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts()
+ except Exception:
+ pass
+
def keypress(self, size, key):
- if self.get_focus() == "body":
- if key == "up" and self.delegate.messagelist.top_is_visible:
- nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header")
+ if self.focus_position == "header":
+ result = super(ConversationFrame, self).keypress(size, key)
+ if result == "up":
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
+ return None
+ if result == "down":
+ self.focus_position = "body"
+ return None
+ return result
+ if self.focus_position == "body":
+ if getattr(self.delegate, "dialog_active", False) or getattr(self.delegate, "dialog_open", False):
+ return super(ConversationFrame, self).keypress(size, key)
+ elif key == "up" and self.delegate.messagelist.top_is_visible:
+ if getattr(self.delegate, "has_visible_trust_banner", lambda: False)():
+ try:
+ self.delegate._header_pile.focus_position = 1
+ self.focus_position = "header"
+ return None
+ except Exception:
+ pass
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
elif key == "down" and self.delegate.messagelist.bottom_is_visible:
- self.set_focus("footer")
+ self.focus_position = "footer"
else:
return super(ConversationFrame, self).keypress(size, key)
- elif key == "ctrl k":
- self.delegate.clear_editor()
else:
return super(ConversationFrame, self).keypress(size, key)
class ConversationWidget(urwid.WidgetWrap):
- def __init__(self, source_hash):
+ MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024
+
+ def __init__(self, source_hash, delegate):
+ self.app = nomadnet.NomadNetworkApp.get_shared_instance()
+ g = self.app.ui.glyphs
+ self.delegate = delegate
if source_hash == None:
self.frame = None
display_widget = urwid.LineBox(urwid.Filler(urwid.Text("\n No conversation selected"), "top"))
- urwid.WidgetWrap.__init__(self, display_widget)
+ super().__init__(display_widget)
else:
if source_hash in ConversationsDisplay.cached_conversation_widgets:
return ConversationsDisplay.cached_conversation_widgets[source_hash]
@@ -478,11 +1921,15 @@ class ConversationWidget(urwid.WidgetWrap):
self.source_hash = source_hash
self.conversation = nomadnet.Conversation(source_hash, nomadnet.NomadNetworkApp.get_shared_instance())
self.message_widgets = []
- self.updating_message_widgets = False
+ self.sort_by_timestamp = False
+ self.pending_attachments = []
+ self.dialog_active = False
+ self.editor_allowed = None
+ self.show_message_details = False
self.update_message_widgets()
- self.conversation.register_changed_callback(self.conversation_changed)
+ self.conversation.register_changed_callback(self._on_conversation_changed_from_callback)
#title_editor = MessageEdit(caption="\u270E", edit_text="", multiline=False)
title_editor = MessageEdit(caption="", edit_text="", multiline=False)
@@ -494,9 +1941,13 @@ class ConversationWidget(urwid.WidgetWrap):
msg_editor.delegate = self
msg_editor.name = "content_editor"
- header = None
- if self.conversation.trust_level == DirectoryEntry.UNTRUSTED:
- header = urwid.AttrMap(urwid.Padding(urwid.Text("\u26A0 Warning: Conversation with untrusted peer \u26A0", align="center")), "msg_warning_untrusted")
+ self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent")
+ self._update_peer_info()
+
+ self._trust_banner_dismissed = False
+ self._header_pile = urwid.Pile([self.peer_info_widget])
+ self._refresh_trust_banner()
+ header = self._header_pile
self.minimal_editor = urwid.AttrMap(msg_editor, "msg_editor")
self.minimal_editor.name = "minimal_editor"
@@ -533,24 +1984,301 @@ class ConversationWidget(urwid.WidgetWrap):
self.frame
)
- urwid.WidgetWrap.__init__(self, self.display_widget)
+ super().__init__(self.display_widget)
+
+ def has_visible_trust_banner(self):
+ if self._trust_banner_dismissed:
+ return False
+ try:
+ tl = self.app.directory.trust_level(bytes.fromhex(self.source_hash))
+ except Exception:
+ tl = DirectoryEntry.UNKNOWN
+ return tl != DirectoryEntry.TRUSTED
+
+ def _refresh_trust_banner(self):
+ contents = [(self.peer_info_widget, self._header_pile.options())]
+ if self.has_visible_trust_banner():
+ banner = self._build_trust_banner()
+ contents.append((banner, self._header_pile.options()))
+ self._header_pile.contents = contents
+ if len(contents) > 1:
+ try: self._header_pile.focus_position = 1
+ except Exception: pass
+
+ def _build_trust_banner(self):
+ g = self.app.ui.glyphs
+ msg = urwid.Text(" "+g["warning"]+" This peer isn't trusted yet.")
+ btn_trust = urwid.Button("Trust", on_press=self._on_trust_click)
+ btn_block = urwid.Button("Block", on_press=self._on_block_click)
+ btn_nothing = urwid.Button("Do nothing", on_press=self._on_ignore_click)
+ row = urwid.Columns([
+ ('weight', 1, msg),
+ ('pack', btn_trust),
+ (1, urwid.Text(" ")),
+ ('pack', btn_block),
+ (1, urwid.Text(" ")),
+ ('pack', btn_nothing),
+ (1, urwid.Text(" ")),
+ ], dividechars=0)
+ return urwid.AttrMap(row, "msg_warning_untrusted")
+
+ def _on_trust_click(self, _btn):
+ try:
+ src = bytes.fromhex(self.source_hash)
+ existing = self.app.directory.find(src)
+ display_name = getattr(existing, "display_name", None) if existing is not None else None
+ preferred = getattr(existing, "preferred_delivery", None) if existing is not None else None
+ entry = DirectoryEntry(src, display_name, DirectoryEntry.TRUSTED, preferred_delivery=preferred)
+ self.app.directory.remember(entry)
+ except Exception as e:
+ RNS.log("Could not mark peer as trusted: "+str(e), RNS.LOG_ERROR)
+ self._refresh_trust_banner()
+ try:
+ self.frame.focus_position = "footer"
+ except Exception:
+ pass
+ try:
+ if self.delegate.list_filter != ConversationsDisplay.LIST_FILTER_TRUSTED:
+ self.delegate._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED)
+ else:
+ self.delegate.update_conversation_list()
+ except Exception as e:
+ RNS.log("Trust UI refresh failed: "+str(e), RNS.LOG_ERROR)
+
+ def _on_ignore_click(self, _btn):
+ self._trust_banner_dismissed = True
+ self._refresh_trust_banner()
+
+ def _on_block_click(self, _btn):
+ def dismiss(_b):
+ self.dialog_active = False
+ try: self.delegate.dialog_open = False
+ except Exception: pass
+ try: self.delegate.update_conversation_list()
+ except Exception: pass
+
+ def confirmed(_b):
+ self.dialog_active = False
+ try: self.delegate.dialog_open = False
+ except Exception: pass
+ try:
+ self._block_peer()
+ except Exception as e:
+ RNS.log("Block failed: "+str(e), RNS.LOG_ERROR)
+ try: self.delegate.update_conversation_list()
+ except Exception: pass
+
+ try:
+ who = self.app.directory.simplest_display_str(bytes.fromhex(self.source_hash))
+ except Exception:
+ who = self.source_hash
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(""),
+ urwid.Text("Block "+str(who)+"?\n\nThis will blackhole the peer's identity in Reticulum,\nadd them to your ignored list, and delete this conversation.\n", align=urwid.CENTER),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirmed)),
+ (urwid.WEIGHT, 0.10, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)),
+ ]),
+ ]), title="Confirm block"
+ )
+ dialog.delegate = self.delegate
+
+ bottom = self.delegate.listbox
+ overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
+ try:
+ self.delegate.columns_widget.contents[0] = (
+ overlay,
+ self.delegate.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width),
+ )
+ self.delegate.columns_widget.focus_position = 0
+ self.dialog_active = True
+ try: self.delegate.dialog_open = True
+ except Exception: pass
+ except Exception:
+ pass
+
+ def _block_peer(self):
+ try:
+ src = bytes.fromhex(self.source_hash)
+ except Exception:
+ return
+
+ try:
+ self.app.block_destination(src, reason="user-blocked from nomadnet conversation")
+ except Exception as e:
+ RNS.log("Block failed: "+str(e), RNS.LOG_ERROR)
+
+ try:
+ self.delegate.delete_conversation(self.source_hash)
+ nomadnet.Conversation.delete_conversation(self.source_hash, self.app)
+ except Exception as e:
+ RNS.log("Could not delete blocked conversation: "+str(e), RNS.LOG_ERROR)
+
+ def _update_peer_info(self):
+ def san(name):
+ if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name)
+ else: return strip_modifiers(name)
+
+ g = self.app.ui.glyphs
+ source_hash_bytes = bytes.fromhex(self.source_hash)
+
+ display_name = self.app.directory.display_name(source_hash_bytes)
+ app_data = None
+ if display_name is None or self.app.message_router.get_outbound_stamp_cost(source_hash_bytes) is None:
+ app_data = RNS.Identity.recall_app_data(source_hash_bytes)
+
+ if display_name is None:
+ if app_data:
+ display_name = san(LXMF.display_name_from_app_data(app_data))
+ if display_name is None:
+ display_name = RNS.prettyhexrep(source_hash_bytes)
+
+ stamp_cost = self.app.message_router.get_outbound_stamp_cost(source_hash_bytes)
+ if stamp_cost is None and app_data:
+ stamp_cost = LXMF.stamp_cost_from_app_data(app_data)
+
+ hops = RNS.Transport.hops_to(source_hash_bytes)
+ if hops >= RNS.Transport.PATHFINDER_M:
+ hops_str = "unknown"
+ else:
+ hops_str = str(hops)+" hop" + ("s" if hops != 1 else "")
+
+ preferred = self.app.directory.preferred_delivery(source_hash_bytes)
+ if preferred == DirectoryEntry.PROPAGATED:
+ via_str = "Propagation"
+ elif preferred == DirectoryEntry.OPPORTUNISTIC:
+ via_str = "Opportunistic"
+ else:
+ via_str = "Link"
+
+ right_parts = []
+ if stamp_cost is not None:
+ right_parts.append("Stamp: "+str(stamp_cost))
+ right_parts.append("Via: "+via_str)
+ right_parts.append(g["speed"]+hops_str)
+
+ left = " "+display_name
+ right = " ".join(right_parts)+" "
+ self.peer_info_widget.original_widget.set_text(left+" | "+right)
+
+ def clear_history_dialog(self):
+ def dismiss_dialog(sender):
+ 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.frame.contents["footer"] = (self.minimal_editor, None)
self.full_editor_active = False
else:
- self.frame.contents["footer"] = (self.full_editor, None)
self.full_editor_active = True
+ self.frame.contents["footer"] = (self._build_footer(), None)
def check_editor_allowed(self):
+ g = self.app.ui.glyphs
if self.frame:
allowed = nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash))
+ if allowed == self.editor_allowed:
+ return
+ self.editor_allowed = allowed
if allowed:
- self.frame.contents["footer"] = (self.minimal_editor, None)
+ self.conversation.ensure_send_destination()
+ self.frame.contents["footer"] = (self._build_footer(), None)
else:
- warning = urwid.AttrMap(urwid.Padding(urwid.Text("\u2139 You cannot currently communicate with this peer, since it's identity keys are unknown", align="center")), "msg_header_caution")
+ warning = urwid.AttrMap(
+ urwid.Padding(urwid.Text(
+ "\n"+g["info"]+"\n\nYou cannot currently message this peer, since its identity keys are not known. "
+ "The keys have been requested from the network, and you will be able to send messages "
+ "as soon as they arrive.\n\n"
+ "To query the network manually, select this conversation in the conversation list, "
+ "press Ctrl-E, and use the query button.\n",
+ align=urwid.CENTER,
+ )),
+ "msg_header_caution",
+ )
self.frame.contents["footer"] = (warning, None)
+ self._schedule_editor_recheck()
+
+ def _schedule_editor_recheck(self):
+ if getattr(self, "_editor_recheck_pending", False):
+ return
+ try:
+ loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop
+ if loop is None:
+ return
+ self._editor_recheck_pending = True
+ loop.set_alarm_in(1.0, self._editor_recheck)
+ except Exception:
+ self._editor_recheck_pending = False
+
+ def _editor_recheck(self, loop=None, user_data=None):
+ self._editor_recheck_pending = False
+ if not self.frame:
+ return
+ if getattr(self.delegate, "currently_displayed_conversation", None) != self.source_hash:
+ return
+ if nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)):
+ self.check_editor_allowed()
+ try:
+ loop.draw_screen()
+ except Exception:
+ pass
+ else:
+ self._schedule_editor_recheck()
def toggle_focus_area(self):
name = ""
@@ -560,110 +2288,852 @@ class ConversationWidget(urwid.WidgetWrap):
pass
if name == "messagelist":
- self.frame.set_focus("footer")
+ self.frame.focus_position = "footer"
elif name == "minimal_editor" or name == "full_editor":
- self.frame.set_focus("body")
+ self.frame.focus_position = "body"
def keypress(self, size, key):
if key == "tab":
self.toggle_focus_area()
- elif key == "ctrl w":
+ return None
+ key = super(ConversationWidget, self).keypress(size, key)
+ if key is None:
+ return None
+ if key == "ctrl w":
self.close()
- elif key == "ctrl p":
+ elif key == "ctrl u":
self.conversation.purge_failed()
self.conversation_changed(None)
+ elif key == "ctrl k":
+ self.cancel_pending_send()
elif key == "ctrl t":
self.toggle_editor()
+ elif key == "ctrl x":
+ self.clear_history_dialog()
+ elif key == "ctrl g":
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.conversations_display.toggle_fullscreen()
+ elif key == "ctrl o":
+ self.sort_by_timestamp ^= True
+ self.conversation_changed(None)
+ elif key == "ctrl a":
+ self.attach_file()
+ elif key == "ctrl s":
+ self.save_focused_attachments()
+ elif key == "ctrl v":
+ self.cycle_delivery_method()
+ elif key == "ctrl r":
+ self.toggle_message_details()
else:
- return super(ConversationWidget, self).keypress(size, key)
+ return key
+
+ def toggle_message_details(self):
+ self.show_message_details = not self.show_message_details
+ self.update_message_widgets(replace=True)
+
+ def _message_is_pending(self, conv_message):
+ try:
+ src = conv_message._cached_source_hash
+ state = conv_message.get_state()
+ except Exception:
+ return False
+ is_outbound = src is not None and self.app.lxmf_destination.hash == src
+ return is_outbound and state is not None and state < LXMF.LXMessage.SENT
+
+ def cancel_message(self, conv_message):
+ if conv_message is None:
+ return
+ try:
+ self.app.message_router.cancel_outbound(conv_message.get_hash())
+ conv_message._cached_state = LXMF.LXMessage.FAILED
+ except Exception as e:
+ RNS.log("Could not cancel outbound message: "+str(e), RNS.LOG_ERROR)
+ self.conversation_changed(None)
+
+ def cancel_pending_send(self):
+ for widget in reversed(self.message_widgets):
+ conv_message = getattr(widget, "conv_message", None)
+ if conv_message is not None and self._message_is_pending(conv_message):
+ self.cancel_message(conv_message)
+ return True
+ return False
+
+ def cycle_delivery_method(self):
+ source_hash_bytes = bytes.fromhex(self.source_hash)
+ order = [DirectoryEntry.DIRECT, DirectoryEntry.OPPORTUNISTIC, DirectoryEntry.PROPAGATED]
+ current = self.app.directory.preferred_delivery(source_hash_bytes)
+ try:
+ selected = order[(order.index(current) + 1) % len(order)]
+ except ValueError:
+ selected = order[0]
+ entry = self.app.directory.find(source_hash_bytes)
+ if entry is None:
+ display_name = self.app.directory.display_name(source_hash_bytes)
+ entry = DirectoryEntry(source_hash_bytes, display_name, DirectoryEntry.UNKNOWN, preferred_delivery=selected)
+ else:
+ entry.preferred_delivery = selected
+ self.app.directory.remember(entry)
+ self._update_peer_info()
+
+ def _on_conversation_changed_from_callback(self, conversation):
+ self.delegate._wake(lambda: self.conversation_changed(conversation))
def conversation_changed(self, conversation):
+ if hasattr(self, "peer_info_widget"):
+ self._update_peer_info()
self.update_message_widgets(replace = True)
def update_message_widgets(self, replace = False):
- while self.updating_message_widgets:
- time.sleep(0.5)
+ prev_pos = None
+ prev_count = len(self.message_widgets) if getattr(self, "message_widgets", None) else 0
+ if getattr(self, "messagelist", None) is not None:
+ try:
+ prev_pos = self.messagelist.get_selected_position()
+ except Exception:
+ prev_pos = None
- self.updating_message_widgets = True
self.message_widgets = []
- added_hashes = []
+ added_hashes = set()
+ needs_index = []
for message in self.conversation.messages:
message_hash = message.get_hash()
if not message_hash in added_hashes:
- added_hashes.append(message_hash)
- message_widget = LXMessageWidget(message)
+ added_hashes.add(message_hash)
+ was_loaded = message.loaded
+ try:
+ message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"], conversation_widget=self)
+ except Exception as e:
+ RNS.log("Skipping message loading for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_WARNING)
+ message.unload()
+ continue
self.message_widgets.append(message_widget)
-
- self.message_widgets.sort(key=lambda m: m.timestamp, reverse=False)
+ if not was_loaded and message.loaded:
+ needs_index.append(message)
+ message.unload()
+
+ if needs_index:
+ try:
+ ConversationMessage.write_index(
+ self.conversation.messages_path, needs_index)
+ except Exception:
+ pass
+
+ if self.sort_by_timestamp:
+ self.message_widgets.sort(key=lambda m: m.timestamp, reverse=False)
+ else:
+ self.message_widgets.sort(key=lambda m: m.sort_timestamp, reverse=False)
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox
- self.messagelist = IndicativeListBox(self.message_widgets, position = len(self.message_widgets)-1)
+ new_count = len(self.message_widgets)
+ if prev_pos is None or prev_count == 0 or prev_pos >= prev_count - 1:
+ position = max(0, new_count - 1)
+ else:
+ position = min(prev_pos, max(0, new_count - 1))
+ self.messagelist = IndicativeListBox(self.message_widgets, position = position)
self.messagelist.name = "messagelist"
if replace:
- self.frame.contents["body"] = (self.messagelist, None)
+ current_body = self.frame.contents["body"][0]
+ if self.dialog_active and isinstance(current_body, urwid.Overlay):
+ current_body.bottom_w = self.messagelist
+ else:
+ self.frame.contents["body"] = (self.messagelist, None)
nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen()
- self.updating_message_widgets = False
-
def clear_editor(self):
self.content_editor.set_edit_text("")
self.title_editor.set_edit_text("")
+ self.pending_attachments = []
+ self.frame.contents["footer"] = (self._build_footer(), None)
+
+ def _collect_attachment_refs(self):
+ g = self.app.ui.glyphs
+ refs = []
+ sorted_messages = sorted(self.conversation.messages, key=lambda m: m.sort_timestamp, reverse=True)
+ for conv_message in sorted_messages:
+ if not conv_message.has_attachments():
+ continue
+
+ cached_names = conv_message._cached_attachment_names or []
+ att_file_idx = 0
+ for atype, aname, *arest in cached_names:
+ asize = arest[0] if arest else 0
+ glyph = g["file"] if atype == "file" else g[atype]
+ label = glyph+" "+aname
+ if asize > 0:
+ label += " ("+_format_size(asize)+")"
+ if atype == "file":
+ refs.append((label, aname, conv_message, "file", att_file_idx))
+ att_file_idx += 1
+ else:
+ refs.append((label, aname, conv_message, atype, 0))
+
+ return refs
+
+ def save_focused_attachments(self):
+ g = self.app.ui.glyphs
+ self.dialog_active = True
+
+ try:
+ attachment_items = self._collect_attachment_refs()
+ except Exception as e:
+ RNS.log("Error collecting attachments: "+str(e), RNS.LOG_ERROR)
+ attachment_items = []
+
+ save_dir = self.app.attachment_save_path if self.app.attachment_save_path else self.app.downloads_path
+
+ def dismiss_dialog(sender):
+ self.dialog_active = False
+ self.conversation_changed(None)
+
+ if not attachment_items:
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text("No attachments in this conversation.\n"),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.6, urwid.Text("")),
+ (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
+ ])
+ ]), title="Attachments"
+ )
+ dialog.delegate = self
+ bottom = self.messagelist
+ overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=45, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
+ self.frame.contents["body"] = (overlay, self.frame.options())
+ self.frame.focus_position = "body"
+ return
+
+ checkboxes = []
+ for label, filename, conv_msg, field_type, field_index in attachment_items:
+ cb = urwid.CheckBox(label, state=False)
+ cb._attachment_filename = filename
+ cb._conv_message = conv_msg
+ cb._field_type = field_type
+ cb._field_index = field_index
+ checkboxes.append(cb)
+
+ status_text = urwid.Text("")
+
+ def do_save(sender):
+ saved = []
+ errors = []
+ for cb in checkboxes:
+ if cb.get_state():
+ try:
+ src_path = cb._conv_message.get_attachment_file_path(cb._field_type, cb._field_index)
+ if src_path and os.path.isfile(src_path):
+ path = _copy_attachment_to_dest(cb._attachment_filename, src_path)
+ saved.append(path)
+ except Exception as e:
+ errors.append(str(e))
+
+ if saved:
+ lines = [g["check"]+" Copied "+str(len(saved))+" file(s) to "+save_dir+":"]
+ for p in saved:
+ lines.append(" "+os.path.basename(p))
+ if errors:
+ lines.append(g["cross"]+" "+str(len(errors))+" failed")
+ status_text.set_text("\n".join(lines))
+ elif errors:
+ status_text.set_text(g["cross"]+" Failed: "+errors[0])
+ else:
+ status_text.set_text("No files selected")
+
+ dialog_widgets = list(checkboxes)
+ dialog_widgets.append(urwid.Divider(g["divider1"]))
+ dialog_widgets.append(urwid.Text("Copy to: "+save_dir))
+ dialog_widgets.append(status_text)
+ dialog_widgets.append(urwid.Text(""))
+ dialog_widgets.append(urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Copy to Downloads", on_press=do_save)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Close", on_press=dismiss_dialog)),
+ ]))
+
+ dialog = DialogLineBox(urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)), title="Attachments")
+ dialog.delegate = self
+ bottom = self.messagelist
+
+ overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=("relative", 80), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2)
+ self.frame.contents["body"] = (overlay, self.frame.options())
+ self.frame.focus_position = "body"
def send_message(self):
content = self.content_editor.get_edit_text()
title = self.title_editor.get_edit_text()
if not content == "":
- if self.conversation.send(content, title):
+ fields = None
+ if self.pending_attachments:
+ file_attachments = []
+ for file_path in self.pending_attachments:
+ try:
+ with open(file_path, "rb") as af:
+ file_data = af.read()
+ file_name = os.path.basename(file_path)
+ file_attachments.append([file_name, file_data])
+ except Exception as e:
+ RNS.log("Error reading attachment "+str(file_path)+": "+str(e), RNS.LOG_ERROR)
+
+ if file_attachments:
+ fields = {LXMF.FIELD_FILE_ATTACHMENTS: file_attachments}
+
+ if self.app.compose_markdown:
+ if not fields: fields = {}
+ fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN
+
+ if self.conversation.send(content, title, fields=fields):
+ self.clear_editor()
+
+ def attach_file(self):
+ self.dialog_active = True
+ initial_attachments = list(self.pending_attachments)
+
+ def on_change(selected):
+ self.pending_attachments[:] = selected
+ self.frame.contents["footer"] = (self._build_footer(), None)
+
+ def on_done(selected):
+ self.pending_attachments[:] = selected
+ self.frame.contents["footer"] = (self._build_footer(), None)
+ self.file_browser_closed()
+
+ def on_cancel():
+ self.pending_attachments[:] = initial_attachments
+ self.frame.contents["footer"] = (self._build_footer(), None)
+ self.file_browser_closed()
+
+ browser = FileBrowser(
+ on_done=on_done,
+ on_cancel=on_cancel,
+ on_change=on_change,
+ title="Attach File",
+ multi_select=True,
+ selected=self.pending_attachments,
+ max_file_size=ConversationWidget.MAX_ATTACHMENT_SIZE,
+ )
+ bottom = self.messagelist
+ overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2)
+ self.frame.contents["body"] = (overlay, self.frame.options())
+ self.frame.focus_position = "body"
+
+ def file_browser_closed(self):
+ self.dialog_active = False
+ self.frame.contents["footer"] = (self._build_footer(), None)
+ self.conversation_changed(None)
+
+ def paper_message_saved(self, path):
+ g = self.app.ui.glyphs
+ def dismiss_dialog(sender):
+ self.dialog_open = False
+ self.conversation_changed(None)
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text("The paper message was saved to:\n\n"+str(path)+"\n", align=urwid.CENTER),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.6, urwid.Text("")),
+ (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
+ ])
+ ]), title=g["papermsg"].replace(" ", "")
+ )
+ dialog.delegate = self
+ bottom = self.messagelist
+
+ overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
+
+ self.frame.contents["body"] = (overlay, self.frame.options())
+ self.frame.focus_position = "body"
+
+ def print_paper_message_qr(self):
+ content = self.content_editor.get_edit_text()
+ title = self.title_editor.get_edit_text()
+ if not content == "":
+ if self.conversation.paper_output(content, title):
self.clear_editor()
else:
- pass
+ self.paper_message_failed()
+
+ def save_paper_message_qr(self):
+ content = self.content_editor.get_edit_text()
+ title = self.title_editor.get_edit_text()
+ if not content == "":
+ output_result = self.conversation.paper_output(content, title, mode="save_qr")
+ if output_result != False:
+ self.clear_editor()
+ self.paper_message_saved(output_result)
+ else:
+ self.paper_message_failed()
+
+ def save_paper_message_uri(self):
+ content = self.content_editor.get_edit_text()
+ title = self.title_editor.get_edit_text()
+ if not content == "":
+ output_result = self.conversation.paper_output(content, title, mode="save_uri")
+ if output_result != False:
+ self.clear_editor()
+ self.paper_message_saved(output_result)
+ else:
+ self.paper_message_failed()
+
+ def paper_message(self):
+ def dismiss_dialog(sender):
+ self.dialog_open = False
+ self.conversation_changed(None)
+
+ def print_qr(sender):
+ dismiss_dialog(self)
+ self.print_paper_message_qr()
+
+ def save_qr(sender):
+ dismiss_dialog(self)
+ self.save_paper_message_qr()
+
+ def save_uri(sender):
+ dismiss_dialog(self)
+ self.save_paper_message_uri()
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(
+ "Select the desired paper message output method.\nSaved files will be written to:\n\n"+str(self.app.downloads_path)+"\n",
+ align=urwid.CENTER,
+ ),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.5, urwid.Button("Print QR", on_press=print_qr)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.5, urwid.Button("Save QR", on_press=save_qr)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.5, urwid.Button("Save URI", on_press=save_uri)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.5, urwid.Button("Cancel", on_press=dismiss_dialog))
+ ])
+ ]), title="Create Paper Message"
+ )
+ dialog.delegate = self
+ bottom = self.messagelist
+
+ overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
+
+ self.frame.contents["body"] = (overlay, self.frame.options())
+ self.frame.focus_position = "body"
+
+ def paper_message_failed(self):
+ def dismiss_dialog(sender):
+ self.dialog_open = False
+ self.conversation_changed(None)
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(
+ "Could not output paper message,\ncheck your settings. See the log\nfile for any error messages.\n",
+ align=urwid.CENTER,
+ ),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.6, urwid.Text("")),
+ (urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
+ ])
+ ]), title="!"
+ )
+ dialog.delegate = self
+ bottom = self.messagelist
+
+ overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=34, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
+
+ self.frame.contents["body"] = (overlay, self.frame.options())
+ self.frame.focus_position = "body"
def close(self):
self.delegate.close_conversation(self)
class LXMessageWidget(urwid.WidgetWrap):
- def __init__(self, message):
+ mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None)
+
+ def __init__(self, message, theme=THEME_DARK, conversation_widget=None):
app = nomadnet.NomadNetworkApp.get_shared_instance()
+ g = app.ui.glyphs
+ self._conversation_widget = conversation_widget
+ self.conv_message = message
self.timestamp = message.get_timestamp()
+ self.sort_timestamp = message.sort_timestamp
+ self.transfer_done = False
+ self._live_lxm = None
+
+ msg_hash = message.get_hash()
+ msg_state = message.get_state()
+ msg_source_hash = message._cached_source_hash
+ msg_method = message._cached_method
time_format = app.time_format
message_time = datetime.fromtimestamp(self.timestamp)
+ renderer = message.content_renderer()
encryption_string = ""
if message.get_transport_encrypted():
- encryption_string = " [\U0001F512"+str(message.get_transport_encryption())+"]"
+ encryption_string = " "+g["encrypted"]
else:
- encryption_string = " [\U0001F513"+str(message.get_transport_encryption())+"]"
-
- title_string = message_time.strftime(time_format)+encryption_string
+ encryption_string = " "+g["plaintext"]
- if app.lxmf_destination.hash == message.lxm.source_hash:
- if message.lxm.state == LXMF.LXMessage.DELIVERED:
+ title_string = relative_time(self.timestamp)+" | "+message_time.strftime(time_format)+encryption_string
+
+ is_outbound = False
+ if msg_source_hash is None:
+ header_style = "msg_header_failed"
+ title_string = g["warning"]+" "+title_string
+ elif app.lxmf_destination.hash == msg_source_hash:
+ is_outbound = True
+ if msg_state == LXMF.LXMessage.DELIVERED:
header_style = "msg_header_delivered"
- title_string = "\u2713 " + title_string
- elif message.lxm.state == LXMF.LXMessage.FAILED:
+ title_string = g["check"]+" "+g["arrow_r"]+" "+title_string
+ elif msg_state == LXMF.LXMessage.FAILED:
header_style = "msg_header_failed"
- title_string = "\u2715 " + title_string
+ 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 = "\u2192 " + title_string
+ title_string = g["arrow_r"]+" "+title_string
else:
if message.signature_validated():
header_style = "msg_header_ok"
- title_string = "\u2713 " + title_string
+ title_string = g["check"]+" "+g["arrow_l"]+" "+title_string
else:
header_style = "msg_header_caution"
- title_string = "\u26A0 "+message.get_signature_description() + "\n " + title_string
+ title_string = g["warning"]+" "+g["arrow_l"]+" "+message.get_signature_description() + "\n " + title_string
if message.get_title() != "":
- title_string += " | " + message.get_title()
+ title_string += " | " + strip_modifiers(message.get_title())
- title = urwid.AttrMap(urwid.Text(title_string), header_style)
+ inbound_untrusted = False
+ if not is_outbound and msg_source_hash is not None:
+ try:
+ sender_trust = app.directory.trust_level(msg_source_hash)
+ if sender_trust in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN):
+ inbound_untrusted = True
+ except Exception:
+ inbound_untrusted = False
- display_widget = urwid.Pile([
- title,
- urwid.Text(message.get_content()),
- urwid.Text("")
- ])
+ has_attachments = message.has_attachments()
+ cached_names = message._cached_attachment_names or []
- urwid.WidgetWrap.__init__(self, display_widget)
\ No newline at end of file
+ if has_attachments and cached_names:
+ attachment_strings = []
+ for atype, aname, *arest in cached_names:
+ attachment_strings.append(g[atype if atype != "file" else "file"]+" "+aname)
+ title_string += " | " + " ".join(attachment_strings)
+
+ content_text = message.get_content()
+
+ if content_text and app.config["textui"]["clipboard_copy"]:
+ copy_glyph = g.get("copy", "[C]")
+ check_glyph = g.get("check", "v").center(len(copy_glyph))
+ copy_icon = ClickableIcon(copy_glyph)
+
+ conv_widget = self._conversation_widget
+ def on_copy_click(icon=copy_icon, content=content_text, cg=copy_glyph, kg=check_glyph, cw=conv_widget):
+ osc52_copy(content)
+ icon.set_text(kg)
+ def _restore(loop, user_data):
+ icon.set_text(cg)
+ try:
+ app.ui.loop.set_alarm_in(2.0, _restore)
+ except Exception:
+ icon.set_text(cg)
+ if cw is not None and cw.frame is not None:
+ def _refocus(loop, user_data):
+ try:
+ cw.frame.focus_position = "footer"
+ except Exception:
+ pass
+ try:
+ app.ui.loop.set_alarm_in(0, _refocus)
+ except Exception:
+ pass
+ copy_icon._on_click = on_copy_click
+
+ copy_width = len(copy_glyph) + 2
+ title_row = urwid.Columns([
+ ("weight", 1, urwid.Text(title_string)),
+ (copy_width, urwid.Padding(copy_icon, left=1, right=1)),
+ ])
+ title = urwid.AttrMap(title_row, header_style)
+ else:
+ title = urwid.AttrMap(urwid.Text(title_string), header_style)
+
+ self.progress_widget = urwid.Text("")
+ self.progress_attr = urwid.AttrMap(self.progress_widget, "progress_full")
+
+ content_lines = content_text.split("\n")
+ markdown = renderer == LXMF.RENDERER_MARKDOWN
+
+ default_fg = "bbb" if theme == THEME_DARK else "444"
+ if markdown:
+ formatted = self.mdc.format_block(content_text)
+ message_body = strip_non_formatting_tags(formatted)
+ rendered = markup_to_attrmaps(strip_modifiers(message_body), url_delegate=None, fg_color=default_fg, bg_color=None)
+ content_pile = urwid.Padding(urwid.Pile(rendered), left=2, right=2)
+
+ else: indented = "\n".join(" "+line for line in content_lines)
+
+ pile_widgets = [title]
+
+ if conversation_widget is not None and getattr(conversation_widget, "show_message_details", False):
+ def _safe(fn):
+ try: return fn()
+ except Exception: return None
+
+ def _detail_row(glyph, values):
+ return urwid.Columns([
+ (3, urwid.Text(("body_text", glyph), align=urwid.RIGHT)),
+ urwid.Text(("inactive_text", " · ".join(values))),
+ ], dividechars=1)
+
+ detail_rows = []
+
+ delivery = []
+ if msg_method is not None:
+ delivery.append(LXM_METHOD_NAMES.get(msg_method, "Unknown"))
+ if is_outbound and msg_state is not None:
+ delivery.append(LXM_STATE_NAMES.get(msg_state, "Unknown"))
+ if delivery:
+ detail_rows.append(_detail_row(g.get("sent" if is_outbound else "peer", ">"), delivery))
+
+ signal = []
+ snr = _safe(message.get_snr)
+ rssi = _safe(message.get_rssi)
+ q = _safe(message.get_q)
+ if snr is not None: signal.append("SNR "+str(round(snr, 1))+" dB")
+ if rssi is not None: signal.append("RSSI "+str(round(rssi, 1))+" dBm")
+ if q is not None: signal.append("Q "+str(round(q))+"%")
+ if signal:
+ detail_rows.append(_detail_row(g.get("speed", "~"), signal))
+
+ security = []
+ sig = _safe(message.get_signature_description)
+ if sig: security.append(sig)
+ enc = _safe(message.get_transport_encryption)
+ if enc: security.append(str(enc))
+ if security:
+ detail_rows.append(_detail_row(g.get("encrypted", "#"), security))
+
+ if msg_hash is not None:
+ short_hash = _safe(lambda: RNS.hexrep(msg_hash, delimit=False))
+ if short_hash:
+ detail_rows.append(_detail_row(g.get("info", "i"), [short_hash]))
+
+ if detail_rows:
+ pile_widgets.append(urwid.Padding(urwid.Pile(detail_rows), left=2, right=2))
+
+ if is_outbound and msg_state is not None and msg_state < LXMF.LXMessage.SENT and msg_hash is not None:
+ try:
+ for pending in app.message_router.pending_outbound:
+ if pending.hash == msg_hash:
+ self._live_lxm = pending
+ break
+ except Exception:
+ pass
+
+ if self._live_lxm is not None:
+ pct = int(self._live_lxm.progress * 100)
+ bar_width = 20
+ filled = int(bar_width * self._live_lxm.progress)
+ if app.ui.colormode >= 256:
+ bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
+ else:
+ bar = "#" * filled + "-" * (bar_width - filled)
+ self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%")
+ if conversation_widget is not None:
+ cancel_icon = ClickableIcon(" "+g["cross"]+" Cancel ",
+ on_click=lambda cw=conversation_widget, cm=message: cw.cancel_message(cm))
+ progress_row = urwid.Columns([
+ ("weight", 1, self.progress_attr),
+ ("pack", urwid.AttrMap(cancel_icon, "msg_notice_caution")),
+ ], dividechars=1)
+ pile_widgets.append(progress_row)
+ else:
+ pile_widgets.append(self.progress_attr)
+ self._start_progress_poll()
+
+ if markdown: pile_widgets.append(content_pile)
+ else: pile_widgets.append(urwid.Text(indented))
+
+ if has_attachments and cached_names:
+ if inbound_untrusted:
+ pile_widgets.append(urwid.AttrMap(
+ urwid.Text(" "+g["warning"]+" This attachment came from a peer that's untrusted. Be careful when opening it."),
+ "list_untrusted",
+ ))
+
+ att_file_idx = 0
+ for atype, aname, *arest in cached_names:
+ glyph = g["file"] if atype == "file" else g[atype]
+ asize = arest[0] if arest else 0
+ label = " "+glyph+" "+aname
+ if asize > 0:
+ label += " ("+_format_size(asize)+")"
+ if atype == "file":
+ pile_widgets.append(ClickableAttachment(label, aname, message, "file", att_file_idx))
+ att_file_idx += 1
+ else:
+ pile_widgets.append(ClickableAttachment(label, aname, message, atype))
+
+ pile_widgets.append(urwid.Text(""))
+
+ super().__init__(urwid.Pile(pile_widgets))
+
+ def _start_progress_poll(self):
+ try:
+ loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop
+ if loop:
+ loop.set_alarm_in(0.3, self._poll_progress)
+ except Exception:
+ pass
+
+ def _poll_progress(self, loop=None, user_data=None):
+ if self.transfer_done:
+ return
+
+ if self._live_lxm is None:
+ self.transfer_done = True
+ return
+
+ app = nomadnet.NomadNetworkApp.get_shared_instance()
+ g = app.ui.glyphs
+ progress = self._live_lxm.progress
+ state = self._live_lxm.state
+ pct = int(progress * 100)
+
+ if state == LXMF.LXMessage.FAILED:
+ self.progress_widget.set_text(" "+g["cross"]+" Transfer failed")
+ self.transfer_done = True
+ self._live_lxm = None
+ elif state == LXMF.LXMessage.REJECTED:
+ self.progress_widget.set_text(" "+g["cross"]+" Rejected: too large or not accepted")
+ self.transfer_done = True
+ self._live_lxm = None
+ elif state >= LXMF.LXMessage.SENT:
+ self.progress_widget.set_text("")
+ self.transfer_done = True
+ self._live_lxm = None
+ else:
+ bar_width = 20
+ filled = int(bar_width * progress)
+ if app.ui.colormode >= 256:
+ bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
+ else:
+ bar = "#" * filled + "-" * (bar_width - filled)
+ self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%")
+
+ if not self.transfer_done:
+ try:
+ ui_loop = app.ui.loop
+ if ui_loop:
+ ui_loop.set_alarm_in(0.3, self._poll_progress)
+ ui_loop.draw_screen()
+ except Exception:
+ pass
+
+
+class ClickableAttachment(urwid.Text):
+ def __init__(self, label, filename, conv_message, field_type, field_index=0):
+ self.filename = filename
+ self.conv_message = conv_message
+ self.field_type = field_type
+ self.field_index = field_index
+ self.saved = False
+ super().__init__(label)
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and urwid.util.is_mouse_press(event):
+ self._save()
+ return True
+ return False
+
+ def _save(self):
+ if self.saved:
+ return
+ app = nomadnet.NomadNetworkApp.get_shared_instance()
+ g = app.ui.glyphs
+ try:
+ src_path = self.conv_message.get_attachment_file_path(self.field_type, self.field_index)
+ if src_path and os.path.isfile(src_path):
+ save_path = _copy_attachment_to_dest(self.filename, src_path)
+ else:
+ if self.field_type == "file":
+ attachments = self.conv_message.get_file_attachments()
+ if self.field_index < len(attachments):
+ att = attachments[self.field_index]
+ if isinstance(att, list) and len(att) >= 2:
+ data = att[1] if isinstance(att[1], bytes) else b""
+ else:
+ data = b""
+ else:
+ data = b""
+ elif self.field_type == "image":
+ data = self.conv_message.get_image()
+ data = data if isinstance(data, bytes) else b""
+ elif self.field_type == "audio":
+ data = self.conv_message.get_audio()
+ data = data if isinstance(data, bytes) else b""
+ else:
+ data = b""
+ self.conv_message.unload()
+ if not data:
+ return
+ save_path = _save_attachment_to_disk(self.filename, data)
+
+ self.saved = True
+ self.set_text(" "+g["check"]+" Copied to: "+save_path)
+ except Exception as e:
+ RNS.log("Error saving attachment: "+str(e), RNS.LOG_ERROR)
+ self.set_text(" "+g["cross"]+" Save failed: "+str(e))
+
+
+def _resolve_attachment_save_path(filename):
+ app = nomadnet.NomadNetworkApp.get_shared_instance()
+ save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path
+ if not os.path.isdir(save_dir):
+ os.makedirs(save_dir)
+ safe_name = ConversationMessage.safe_attachment_name(filename)
+ base_dir = os.path.realpath(save_dir) + os.sep
+ candidate = os.path.realpath(os.path.join(save_dir, safe_name))
+ if not (candidate + os.sep).startswith(base_dir):
+ raise OSError(13, os.strerror(13))
+ counter = 0
+ base, ext = os.path.splitext(safe_name)
+ while os.path.isfile(candidate):
+ counter += 1
+ candidate = os.path.realpath(os.path.join(save_dir, base+"_"+str(counter)+ext))
+ if not (candidate + os.sep).startswith(base_dir):
+ raise OSError(13, os.strerror(13))
+ return candidate
+
+
+def _copy_attachment_to_dest(filename, src_path):
+ save_path = _resolve_attachment_save_path(filename)
+ shutil.copy2(src_path, save_path)
+ return save_path
+
+
+def _save_attachment_to_disk(filename, data):
+ save_path = _resolve_attachment_save_path(filename)
+ with open(save_path, "wb") as f:
+ f.write(data)
+ return save_path
+
+
+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
index aeec463..3ff090b 100644
--- a/nomadnet/ui/textui/Directory.py
+++ b/nomadnet/ui/textui/Directory.py
@@ -15,7 +15,7 @@ class DirectoryDisplay():
])
self.shortcuts_display = DirectoryDisplayShortcuts(self.app)
- self.widget = urwid.Filler(pile, 'top')
+ self.widget = urwid.Filler(pile, urwid.TOP)
def shortcuts(self):
return self.shortcuts_display
\ No newline at end of file
diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py
new file mode 100644
index 0000000..2952311
--- /dev/null
+++ b/nomadnet/ui/textui/Editor.py
@@ -0,0 +1,1154 @@
+import os
+import json
+import shutil
+import urwid
+import RNS
+import nomadnet
+
+from .ReadlineEdit import ReadlineEdit
+from .Browser import Browser
+from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox
+
+PREVIEW_DELAY = 0.4
+PANE_WEIGHTS = {"files": 0.22, "editor": 0.40, "preview": 0.38}
+PANE_ORDER = ["files", "editor", "preview"]
+MAX_HIGHLIGHT_CHARS = 50000
+MAX_PREVIEW_CHARS = 100000
+
+
+
+
+SNIPPETS = [
+ ("Heading", ">Heading\n"),
+ ("Sub-heading", ">>Sub heading\n"),
+ ("Collapsible", "`+>Section\nbody\n<\n"),
+ ("Bold", "`!bold`!"),
+ ("Italic", "`*italic`*"),
+ ("Underline", "`_underline`_"),
+ ("Foreground col", "`F00ftext`f"),
+ ("Background col", "`B400text`b"),
+ ("Link", "`[label`:/page/index.mu]"),
+ ("Anchor", "`:anchor-name"),
+ ("Anchor link", "`[label`#anchor-name]"),
+ ("Input field", "`<24|name`>"),
+ ("Masked field", "`"),
+ ("Checkbox", "`|name|value`>"),
+ ("Radio", "`<^|name|value`>"),
+ ("Divider", "-\n"),
+ ("Table", "`t\n| A | B |\n| 1 | 2 |\n`t\n"),
+ ("Literal block", "`=\nliteral text\n`=\n"),
+]
+
+
+
+
+
+
+
+EDITOR_STYLES = [
+ ("editor_comment", "dark gray", "default", None, "#888", "default"),
+ ("editor_heading", "light cyan,bold", "default", None, "#0cc", "default"),
+ ("editor_tag", "light magenta", "default", None, "#c6c", "default"),
+ ("editor_field", "yellow", "default", None, "#cc0", "default"),
+ ("editor_link", "light green", "default", None, "#0c6", "default"),
+ ("editor_dir", "light blue,bold", "default", None, "#39f", "default"),
+ ("editor_exec", "dark gray", "default", None, "#666", "default"),
+ ("editor_lineno", "dark gray", "default", None, "#555", "default"),
+]
+
+
+def is_executable(path):
+ try:
+ return (not RNS.vendor.platformutils.is_windows()) and os.path.isfile(path) and os.access(path, os.X_OK)
+ except Exception:
+ return False
+
+
+def _scan_tag(text, i, n):
+ # text[i] == "`"; return (end_index, attr)
+ if i + 1 >= n:
+ return i + 1, "editor_tag"
+ c = text[i + 1]
+ if c == "<":
+ j = text.find(">", i + 2); return (n if j < 0 else j + 1), "editor_field"
+ if c == "[":
+ j = text.find("]", i + 2); return (n if j < 0 else j + 1), "editor_link"
+ if c == "{":
+ j = text.find("}", i + 2); return (n if j < 0 else j + 1), "editor_link"
+ if c in "FB":
+ if i + 2 < n and text[i + 2] == "T":
+ return min(i + 9, n), "editor_tag"
+ return min(i + 5, n), "editor_tag"
+ if c == ":":
+ j = i + 2
+ while j < n and (text[j].isalnum() or text[j] in "_-"):
+ j += 1
+ return j, "editor_tag"
+ return i + 2, "editor_tag"
+
+
+def highlight_micron(text):
+ n = len(text)
+ attrs = [None] * n
+ i = 0
+ line_start = True
+ while i < n:
+ c = text[i]
+ if line_start and c == "#":
+ j = text.find("\n", i); j = n if j < 0 else j
+ for k in range(i, j): attrs[k] = "editor_comment"
+ i = j; continue
+ if line_start and c == ">":
+ j = i
+ while j < n and text[j] == ">": j += 1
+ for k in range(i, j): attrs[k] = "editor_heading"
+ i = j; line_start = False; continue
+ if line_start and c == "`" and i + 2 < n and text[i + 1] in "+-" and text[i + 2] == ">":
+ j = i + 2
+ while j < n and text[j] == ">": j += 1
+ for k in range(i, j): attrs[k] = "editor_heading"
+ i = j; line_start = False; continue
+ if c == "\n":
+ i += 1; line_start = True; continue
+ if c == "\\" and i + 1 < n:
+ i += 2; line_start = False; continue
+ if c == "`":
+ j, attr = _scan_tag(text, i, n)
+ for k in range(i, min(j, n)): attrs[k] = attr
+ i = j; line_start = False; continue
+ i += 1; line_start = False
+
+ out = []
+ run_attr = attrs[0] if n else None
+ run_len = 0
+ for a in attrs:
+ if a == run_attr:
+ run_len += 1
+ else:
+
+ out.append((run_attr, run_len)); run_attr = a; run_len = 1
+ if run_len:
+ out.append((run_attr, run_len))
+ return out
+
+
+class MicronEdit(ReadlineEdit):
+ def __init__(self, *args, **kwargs):
+ self._hl_text = None
+ self._hl_attrib = []
+ super().__init__(*args, **kwargs)
+
+ def get_text(self):
+ text = self.caption + self.edit_text
+ if text != self._hl_text:
+ self._hl_text = text
+ self._hl_attrib = [] if len(text) > MAX_HIGHLIGHT_CHARS else highlight_micron(text)
+ return text, self._hl_attrib
+
+
+class GutterEdit(MicronEdit):
+ # MicronEdit with a wrap-aware line-number gutter on the left
+ def _gutter_width(self, maxcol):
+ gw = max(3, len(str(self.edit_text.count("\n") + 1)) + 2)
+ if maxcol - gw < 1:
+ return 0
+ return gw
+
+ def rows(self, size, focus=False):
+ gw = self._gutter_width(size[0])
+ return super().rows((max(1, size[0] - gw),), focus)
+
+ def get_cursor_coords(self, size):
+ gw = self._gutter_width(size[0])
+ coords = super().get_cursor_coords((max(1, size[0] - gw),))
+ if coords is None:
+ return None
+ return coords[0] + gw, coords[1]
+
+ def move_cursor_to_coords(self, size, x, y):
+ gw = self._gutter_width(size[0])
+ if isinstance(x, int):
+ x = max(0, x - gw)
+ return super().move_cursor_to_coords((max(1, size[0] - gw),), x, y)
+
+ def render(self, size, focus=False):
+ maxcol = size[0]
+ gw = self._gutter_width(maxcol)
+ if gw == 0:
+ return super().render(size, focus)
+ tw = max(1, maxcol - gw)
+
+ cheap = len(self.edit_text) > MAX_HIGHLIGHT_CHARS
+ wrap = "any" if cheap else "space"
+ if self.wrap != wrap:
+ self.set_wrap_mode(wrap)
+
+ edit_canv = super().render((tw,), focus=False)
+ h = edit_canv.rows()
+ rows = []
+ for i, line in enumerate(self.edit_text.split("\n"), 1):
+ rows.append(str(i).rjust(gw - 1) + " ")
+ if cheap:
+ wrapped = max(1, (len(line) + tw - 1) // tw)
+ else:
+ wrapped = max(1, urwid.Text(line).rows((tw,)))
+ rows.extend([""] * (wrapped - 1))
+ rows = (rows + [""] * h)[:h]
+
+
+
+
+ gutter_canv = urwid.Text([("editor_lineno", "\n".join(r.ljust(gw) for r in rows))], wrap="clip").render((gw,))
+ joined = urwid.CanvasJoin([(gutter_canv, None, False, gw), (edit_canv, None, focus, tw)])
+ if focus:
+ coords = super().get_cursor_coords((tw,))
+ if coords is not None:
+ joined.cursor = (coords[0] + gw, coords[1])
+ return joined
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ gw = self._gutter_width(size[0])
+ if x < gw:
+ return True
+ return super().mouse_event((max(1, size[0] - gw),), event, button, x - gw, y, focus)
+
+
+class FileEntry(urwid.WidgetWrap):
+ signals = ["click"]
+
+ def __init__(self, label, path, depth, executable=False):
+ self.path = path
+ self.executable = executable
+ text = (" " * depth) + "· " + label + (" *" if executable else "")
+ style = "editor_exec" if executable else None
+ super().__init__(urwid.AttrMap(urwid.Text(text), style, focus_map="list_focus"))
+
+ def selectable(self):
+ return True
+
+ def keypress(self, size, key):
+ if self._command_map[key] == urwid.ACTIVATE:
+ self._emit("click")
+ return None
+ return key
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and urwid.util.is_mouse_press(event):
+ self._emit("click")
+ return True
+ return False
+
+
+class FolderEntry(urwid.WidgetWrap):
+ signals = ["click"]
+
+ def __init__(self, label, path, depth, expanded, glyphs):
+ self.path = path
+ icon = glyphs.get("folder_open" if expanded else "folder", "[-]" if expanded else "[+]")
+ text = (" " * depth) + icon + " " + label + "/"
+ super().__init__(urwid.AttrMap(urwid.Text(text), "editor_dir", focus_map="list_focus"))
+
+ def selectable(self):
+ return True
+
+ def keypress(self, size, key):
+ if self._command_map[key] == urwid.ACTIVATE:
+ self._emit("click")
+ return None
+ return key
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and urwid.util.is_mouse_press(event):
+ self._emit("click")
+ return True
+ return False
+
+
+class PathDialog(urwid.WidgetWrap):
+ def __init__(self, title, default, on_ok, on_cancel):
+ self.on_ok = on_ok
+ self.on_cancel = on_cancel
+ self.edit = ReadlineEdit(caption="", edit_text=default)
+ pile = urwid.Pile([
+ urwid.Text(title),
+ urwid.Divider(),
+ urwid.AttrMap(self.edit, "list_off_focus", "list_focus"),
+ urwid.Divider(),
+ urwid.Columns([
+ urwid.Button("OK", on_press=lambda b: self.on_ok(self.edit.edit_text)),
+ urwid.Button("Cancel", on_press=lambda b: self.on_cancel()),
+ ], dividechars=2),
+ ])
+ super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title))
+
+ def keypress(self, size, key):
+ if key == "esc":
+ self.on_cancel(); return None
+ if key == "enter" and self._w.original_widget.original_widget.focus_position == 2:
+ self.on_ok(self.edit.edit_text); return None
+ return super().keypress(size, key)
+
+
+class InfoDialog(urwid.WidgetWrap):
+ def __init__(self, title, message, on_ok):
+ self.on_ok = on_ok
+ pile = urwid.Pile([
+ urwid.Text(message),
+ urwid.Divider(),
+ urwid.Button("OK", on_press=lambda b: on_ok()),
+ ])
+ super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title))
+
+ def keypress(self, size, key):
+ if key in ("esc", "enter"):
+ self.on_ok(); return None
+ return super().keypress(size, key)
+
+
+class ConfirmDialog(urwid.WidgetWrap):
+ def __init__(self, title, message, buttons, on_cancel=None):
+ self.on_cancel = on_cancel
+ cols = [urwid.Button(label, on_press=lambda b, c=cb: c()) for label, cb in buttons]
+ pile = urwid.Pile([urwid.Text(message), urwid.Divider(), urwid.Columns(cols, dividechars=2)])
+ super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title))
+
+ def keypress(self, size, key):
+ if key == "esc" and self.on_cancel:
+ self.on_cancel(); return None
+ return super().keypress(size, key)
+
+
+class SnippetDialog(urwid.WidgetWrap):
+ def __init__(self, on_pick, on_cancel):
+ self.on_cancel = on_cancel
+ items = [urwid.AttrMap(urwid.Button(name, on_press=lambda b, t=text: on_pick(t)), None, focus_map="list_focus")
+ for name, text in SNIPPETS]
+ super().__init__(urwid.LineBox(urwid.ListBox(urwid.SimpleFocusListWalker(items)), title="Insert Micron snippet"))
+
+ def keypress(self, size, key):
+ if key == "esc":
+ self.on_cancel(); return None
+ return super().keypress(size, key)
+
+
+class FindDialog(urwid.WidgetWrap):
+ def __init__(self, ed, on_close):
+ self.ed = ed
+ self.on_close = on_close
+ self.find_edit = ReadlineEdit(caption="Find: ")
+ self.repl_edit = ReadlineEdit(caption="Replace: ")
+ pile = urwid.Pile([
+ urwid.AttrMap(self.find_edit, "list_off_focus", "list_focus"),
+ urwid.AttrMap(self.repl_edit, "list_off_focus", "list_focus"),
+ urwid.Divider(),
+ urwid.Columns([
+ urwid.Button("Next", on_press=lambda b: ed.find_next(self.find_edit.edit_text)),
+ urwid.Button("Replace", on_press=lambda b: ed.replace_one(self.find_edit.edit_text, self.repl_edit.edit_text)),
+ urwid.Button("All", on_press=lambda b: ed.replace_all(self.find_edit.edit_text, self.repl_edit.edit_text)),
+ urwid.Button("Close", on_press=lambda b: on_close()),
+ ], dividechars=1),
+ ])
+ super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title="Find / Replace"))
+
+ def keypress(self, size, key):
+ if key == "esc":
+ self.on_close(); return None
+ return super().keypress(size, key)
+
+
+class PermissionsDialog(urwid.WidgetWrap):
+ def __init__(self, title, note, content, on_ok, on_cancel):
+ self.on_ok = on_ok
+ self.on_cancel = on_cancel
+ self.edit = ReadlineEdit(caption="", edit_text=content, multiline=True)
+ pile = urwid.Pile([
+ urwid.Text(note),
+ urwid.Divider(),
+ urwid.BoxAdapter(urwid.AttrMap(urwid.ListBox(urwid.SimpleListWalker([self.edit])), "list_off_focus", "list_focus"), 7),
+ urwid.Divider(),
+ urwid.Columns([
+ urwid.Button("Save", on_press=lambda b: self.on_ok(self.edit.edit_text)),
+ urwid.Button("Cancel", on_press=lambda b: self.on_cancel()),
+ ], dividechars=2),
+ ])
+ super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title))
+
+ def keypress(self, size, key):
+ if key == "esc":
+ self.on_cancel(); return None
+ return super().keypress(size, key)
+
+
+class EditorColumns(urwid.Columns):
+ def keypress(self, size, key):
+ ed = self.editor_display
+ if key == "ctrl s":
+ ed.save_or_saveas(); return None
+ if key == "ctrl n":
+ ed.new_file_dialog(); return None
+ if key == "ctrl f":
+ ed.find_dialog(); return None
+ if key == "ctrl g":
+ ed.snippet_dialog(); return None
+ if key == "ctrl p":
+ ed.permissions_dialog(); return None
+ if key == "f2" and ed.files_focused():
+ ed.rename_dialog(); return None
+ if key == "delete" and ed.files_focused():
+ ed.delete_dialog(); return None
+ if key == "ctrl t":
+ ed.toggle_pane("files"); return None
+ if key == "ctrl b":
+ ed.toggle_pane("editor"); return None
+ if key == "ctrl r":
+ ed.toggle_pane("preview"); return None
+ if key == "tab":
+ self.focus_position = (self.focus_position + 1) % len(self.contents); return None
+ if key == "shift tab":
+ self.focus_position = (self.focus_position - 1) % len(self.contents); return None
+ if key == "esc":
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
+ return None
+ result = super().keypress(size, key)
+ if key == "up" and result == "up":
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
+ return None
+ return result
+
+
+class PageEditorShortcuts():
+ def __init__(self, app):
+ self.app = app
+ self.status = urwid.Text("", align=urwid.RIGHT)
+ base = "[C-n]New [C-s]Save [C-f]Find [C-g]Snippet [C-p]Perms [F2]Rename [Del]Delete [Tab]Pane [C-t/b/r]Toggle [Esc]Menu"
+ self.widget = urwid.AttrMap(urwid.Columns([urwid.Text(base), self.status], dividechars=2), "shortcutbar")
+
+ def set_status(self, msg):
+ self.status.set_text(msg)
+
+
+class PageEditorDisplay():
+ def __init__(self, app):
+ self.app = app
+ self.current_path = None
+ self.editable = False
+ self.is_exec = False
+ self.dirty = False
+ self.preview_alarm = None
+ self._loading = False
+ self._find = None
+ self._last_file = None
+ self.expanded = set(p for p in (app.pagespath, app.filespath) if p)
+ self.filter_mu = False
+ self.pane_visible = {"files": True, "editor": True, "preview": True}
+ self.load_state()
+ self.register_styles()
+
+ self.preview_browser = Browser(self.app, "nomadnetwork", "node", delegate=None)
+ self.preview_browser.loopback = self.app.node.destination.hash
+ self.preview_browser.destination_hash = self.app.node.destination.hash
+ self.preview_browser.handle_link = self._preview_handle_link
+ self.preview_browser.marked_link = lambda *a, **k: None
+ self.preview_browser._content_cols = self._preview_cols
+ # don't fetch partials in the preview
+ self.preview_browser.detect_partials = lambda: None
+
+ self.editor = GutterEdit(caption="", edit_text="", multiline=True)
+ urwid.connect_signal(self.editor, "postchange", self.on_edit_change)
+
+ self.filter_checkbox = urwid.CheckBox("Show only .mu", state=self.filter_mu, on_state_change=self.on_filter_change)
+ self.tree_walker = urwid.SimpleFocusListWalker(self.build_entries())
+ self.tree_listbox = IndicativeListBox(self.tree_walker, initialization_is_selection_change=False, highlight_offFocus="list_off_focus")
+ self.files_pile = urwid.Pile([
+ (urwid.PACK, urwid.AttrMap(self.filter_checkbox, None, focus_map="list_focus")),
+ (urwid.WEIGHT, 1, self.tree_listbox),
+ ])
+ try: self.files_pile.focus_position = 1
+ except Exception: pass
+
+ self.pane_widgets = {
+ "files": urwid.LineBox(self.files_pile, title="Files"),
+ "editor": urwid.LineBox(urwid.ListBox(urwid.SimpleListWalker([self.editor])), title="Editor"),
+ "preview": urwid.LineBox(urwid.SolidFill(" "), title="Preview"),
+ }
+
+ self.columns = EditorColumns([], dividechars=0)
+ self.columns.editor_display = self
+ self._visible = []
+ self.rebuild_columns()
+
+ self.shortcuts_display = PageEditorShortcuts(self.app)
+ self.body = urwid.WidgetPlaceholder(self.columns)
+ self.widget = self.body
+
+ def register_styles(self):
+ screen = self.app.ui.screen
+ for entry in EDITOR_STYLES:
+ try: screen.register_palette_entry(*entry)
+ except Exception: pass
+
+ def _preview_handle_link(self, target, link_data=None):
+ # in the preview follow in page anchors but ignore external navigation
+ # TODO
+
+ if isinstance(target, str) and target.startswith("#"):
+ name = target[1:]
+ self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self.preview_browser._jump_to_anchor(name))
+
+ def _preview_cols(self):
+ try:
+ cols = self.app.ui.loop.screen.get_cols_rows()[0]
+ visible = self.visible_panes()
+ total = sum(PANE_WEIGHTS[n] for n in visible)
+ frac = PANE_WEIGHTS["preview"] / total if "preview" in visible else PANE_WEIGHTS["preview"]
+ return max(20, int(cols * frac) - 3)
+ except Exception:
+ return 80
+
+ def shortcuts(self):
+ return self.shortcuts_display
+
+ def start(self):
+ self.refresh_tree()
+ if self._last_file and os.path.isfile(self._last_file) and self.current_path is None:
+ self.load_file(self._last_file)
+ self._last_file = None
+
+ def files_focused(self):
+ try: return self.visible_panes()[self.columns.focus_position] == "files"
+ except Exception: return False
+
+ def _state_file(self):
+ d = getattr(self.app, "configdir", None)
+ return os.path.join(d, "editor_state.json") if d else None
+
+ def load_state(self):
+ p = self._state_file()
+ if not p or not os.path.isfile(p):
+ return
+ try:
+ with open(p) as f:
+ st = json.load(f)
+ self.filter_mu = bool(st.get("filter_mu", self.filter_mu))
+ if isinstance(st.get("expanded"), list):
+ self.expanded = set(st["expanded"])
+ pv = st.get("pane_visible")
+ if isinstance(pv, dict):
+ for k in self.pane_visible:
+ if k in pv: self.pane_visible[k] = bool(pv[k])
+ self._last_file = st.get("last_file")
+ except Exception:
+ pass
+
+ def save_state(self):
+ p = self._state_file()
+ if not p:
+ return
+ try:
+ self._atomic_write(p, json.dumps({"filter_mu": self.filter_mu, "expanded": sorted(self.expanded),
+ "pane_visible": self.pane_visible, "last_file": self.current_path}))
+ except Exception:
+ pass
+
+ def _selected_tree(self):
+ try:
+ sel = self.tree_listbox.get_selected_item()
+ return sel.original_widget if sel else None
+ except Exception:
+ return None
+
+ def _roots(self):
+ return [r for r in (self.app.pagespath, self.app.filespath) if r]
+
+ def _is_root(self, path):
+ try:
+ rp = os.path.realpath(path)
+ except Exception:
+ return False
+ return any(rp == os.path.realpath(r) for r in self._roots())
+
+ def _confine(self, path, base=None):
+ # Never save outside of the Nomad storage directory
+ path = (path or "").strip()
+ if not path:
+ return None
+ path = os.path.expanduser(path)
+ if not os.path.isabs(path):
+ base = base or self.app.pagespath or self.app.filespath
+ if not base:
+ return None
+ path = os.path.join(base, path)
+ norm = os.path.normpath(path)
+ for root in self._roots():
+ rp = os.path.normpath(root)
+ if norm == rp or norm.startswith(rp + os.sep):
+ return norm
+ return None
+
+ def _atomic_write(self, path, text):
+ d = os.path.dirname(path) or "."
+ tmp = os.path.join(d, "." + os.path.basename(path) + ".tmp")
+ try:
+ existing_mode = None
+ try:
+ existing_mode = os.stat(path).st_mode
+ except OSError:
+ existing_mode = None
+ with open(tmp, "w", encoding="utf-8") as f:
+ f.write(text)
+ f.flush()
+ try: os.fsync(f.fileno())
+ except Exception: pass
+ if existing_mode is not None:
+ try: os.chmod(tmp, existing_mode)
+ except Exception: pass
+ os.replace(tmp, path)
+ except Exception:
+ try: os.remove(tmp)
+ except Exception: pass
+ raise
+
+ def _confirm_overwrite(self, dest, proceed):
+ self.show_dialog(ConfirmDialog("Overwrite",
+ "%s already exists.\nOverwrite it?" % os.path.basename(dest),
+ [("Overwrite", proceed), ("Cancel", self.close_dialog)],
+ on_cancel=self.close_dialog))
+
+ def visible_panes(self):
+ return [n for n in PANE_ORDER if self.pane_visible[n]] or ["editor"]
+
+ def rebuild_columns(self):
+ focused = None
+ try: focused = self._visible[self.columns.focus_position]
+ except Exception: pass
+ visible = self.visible_panes()
+ self.columns.contents = [
+ (self.pane_widgets[n], self.columns.options(urwid.WEIGHT, PANE_WEIGHTS[n], box_widget=True))
+ for n in visible
+ ]
+ self._visible = visible
+ # the rpeview starts non-selectable
+ target = focused if focused in visible else ("files" if "files" in visible else visible[0])
+ try: self.columns.focus_position = visible.index(target)
+ except Exception: pass
+
+ def toggle_pane(self, name):
+ visible = self.visible_panes()
+ if self.pane_visible[name] and len(visible) <= 1:
+ return
+ self.pane_visible[name] = not self.pane_visible[name]
+ self.rebuild_columns()
+ self.save_state()
+
+ def refresh_tree(self):
+ focus_path = None
+ try:
+ sel = self.tree_listbox.get_selected_item()
+ if sel is not None:
+ focus_path = getattr(sel.original_widget, "path", None)
+ except Exception:
+ pass
+ entries = self.build_entries()
+ # indicativelistbox wraps items in attrmap for highlighting
+ self.tree_walker[:] = [urwid.AttrMap(e, None) for e in entries]
+ if focus_path is not None:
+ for i, e in enumerate(entries):
+ if getattr(e, "path", None) == focus_path:
+ try: self.tree_listbox.select_item(i)
+ except Exception: pass
+ break
+
+ def build_entries(self):
+ entries = []
+ for label, root in (("pages", self.app.pagespath), ("files", self.app.filespath)):
+ if not root or not os.path.isdir(root):
+ continue
+ entries.extend(self.walk_dir(label, root, 0))
+ if not entries:
+ entries = [urwid.Text("no files")]
+ return entries
+
+ def walk_dir(self, name, path, depth):
+ out = []
+ expanded = path in self.expanded
+ folder = FolderEntry(name, path, depth, expanded, self.app.ui.glyphs)
+ urwid.connect_signal(folder, "click", self.on_folder_click)
+ out.append(folder)
+ if not expanded:
+ return out
+ try:
+ names = sorted(os.listdir(path), key=str.lower)
+ except Exception:
+ return out
+ dirs = [n for n in names if os.path.isdir(os.path.join(path, n)) and not n.startswith(".") and n != "__pycache__"]
+ files = [n for n in names if not os.path.isdir(os.path.join(path, n)) and not n.startswith(".")]
+ if self.filter_mu:
+ files = [n for n in files if n.endswith(".mu")]
+ dirs = [d for d in dirs if self._dir_has_mu(os.path.join(path, d))]
+ for d in dirs:
+ out.extend(self.walk_dir(d, os.path.join(path, d), depth + 1))
+ for fn in files:
+ full = os.path.join(path, fn)
+ entry = FileEntry(fn, full, depth + 1, executable=is_executable(full))
+ urwid.connect_signal(entry, "click", self.on_file_click)
+ out.append(entry)
+ return out
+
+ def _dir_has_mu(self, path):
+ for root, dirs, files in os.walk(path):
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
+ if any(f.endswith(".mu") for f in files):
+ return True
+ return False
+
+ def on_filter_change(self, checkbox, state):
+ self.filter_mu = state
+ self.refresh_tree()
+ self.save_state()
+
+ def on_folder_click(self, folder):
+ if folder.path in self.expanded:
+ self.expanded.discard(folder.path)
+ else:
+ self.expanded.add(folder.path)
+ self.refresh_tree()
+ self.save_state()
+
+ def on_file_click(self, entry):
+ self.open_path(entry.path)
+
+ def open_path(self, path):
+ if self.dirty:
+ self.confirm_discard(lambda: self._do_open(path))
+ else:
+ self._do_open(path)
+
+ def _do_open(self, path):
+ self.load_file(path)
+ if self.pane_visible["editor"]:
+ try: self.columns.focus_position = self.visible_panes().index("editor")
+ except Exception: pass
+
+ def confirm_discard(self, proceed):
+ def save_then():
+ self.close_dialog()
+ if self.current_path and self.editable:
+ self.save_file(self.current_path)
+ proceed()
+ name = os.path.basename(self.current_path) if self.current_path else "this buffer"
+ if self.current_path and self.editable:
+ buttons = [("Save", save_then), ("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)]
+ else:
+ buttons = [("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)]
+ self.show_dialog(ConfirmDialog("Unsaved changes", "Discard unsaved changes to %s?" % name,
+ buttons, on_cancel=self.close_dialog))
+
+ def load_file(self, path):
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+ self.editable = True
+ except UnicodeDecodeError:
+ content = ""
+ self.editable = False
+ except Exception as e:
+ self.set_status("open failed: %s" % e); return
+
+ self.current_path = path
+ self.is_exec = is_executable(path)
+ self.dirty = False
+ self._loading = True
+ self.editor.set_edit_text(content)
+ self.editor.set_edit_pos(0)
+ self._loading = False
+
+ name = os.path.basename(path)
+ self.update_title()
+ self.save_state()
+ if not self.editable:
+ self.set_status("binary file (not editable)")
+ elif self.is_exec:
+ self.set_preview_note(name + " is an executable page. the node runs it and serves its output.")
+ self.set_status("executable page")
+ self.show_info_dialog("Executable page", name + " is marked executable (chmod +x). NomadNet runs it and serves its output as the page, so the preview shows the source rather than the rendered output.")
+ else:
+ self.render_preview()
+ self.set_status("opened " + name)
+
+ def update_title(self, modified=False):
+
+
+
+ if self.current_path is None:
+ self.pane_widgets["editor"].set_title("Editor"); return
+ suffix = " (binary)" if not self.editable else (" (executable)" if self.is_exec else "")
+ if modified:
+ suffix += " *"
+ self.pane_widgets["editor"].set_title("Editor: " + os.path.basename(self.current_path) + suffix)
+
+ def set_preview_note(self, msg):
+ self.pane_widgets["preview"].original_widget = urwid.Filler(urwid.Text(msg), urwid.TOP)
+
+ def show_info_dialog(self, title, message):
+ self.show_dialog(InfoDialog(title, message, self.close_dialog))
+
+ def save_or_saveas(self):
+ if self.current_path is None:
+ self.save_as_dialog(); return
+ self.save_file(self.current_path)
+
+ def save_file(self, path):
+ if not self.editable and self.current_path is not None:
+ self.set_status("cannot save binary file"); return
+ try:
+ self._atomic_write(path, self.editor.edit_text)
+ self.current_path = path
+ self.editable = True
+ self.dirty = False
+ self.set_modified(False)
+ self.set_status("saved " + os.path.basename(path))
+ self.refresh_tree()
+ self.save_state()
+ except Exception as e:
+ self.set_status("save failed: %s" % e)
+
+ def new_file_dialog(self):
+ default = (self.app.pagespath or "") + os.sep
+ self.show_dialog(PathDialog("New (end with / for a folder)", default, self._do_new, self.close_dialog))
+
+ def _do_new(self, path):
+ self.close_dialog()
+ raw = (path or "").strip()
+ if not raw:
+ return
+ is_folder = raw.endswith("/") or raw.endswith(os.sep)
+ target = self._confine(raw)
+ if target is None:
+ self.set_status("path must be inside pages/ or files/"); return
+ try:
+ if is_folder:
+ os.makedirs(target, exist_ok=True)
+ self.expanded.add(target)
+ self.refresh_tree(); self.save_state()
+ self.set_status("created folder " + os.path.basename(target))
+ else:
+ os.makedirs(os.path.dirname(target), exist_ok=True)
+ if not os.path.exists(target):
+ self._atomic_write(target, "")
+ self.refresh_tree()
+ self.open_path(target)
+ except Exception as e:
+ self.set_status("new failed: %s" % e)
+
+ def rename_dialog(self):
+ w = self._selected_tree()
+ if not w or not getattr(w, "path", None):
+ self.set_status("select a file/folder to rename"); return
+ if self._is_root(w.path):
+ self.set_status("cannot rename the pages/files root"); return
+ self.show_dialog(PathDialog("Rename", w.path, lambda p, old=w.path: self._do_rename(old, p), self.close_dialog))
+
+ def _do_rename(self, old, new):
+ self.close_dialog()
+ target = self._confine(new, base=os.path.dirname(old))
+ if target is None:
+ self.set_status("path must be inside pages/ or files/"); return
+ if target == os.path.normpath(old):
+ return
+
+ def proceed():
+ self.close_dialog()
+ try:
+ os.makedirs(os.path.dirname(target), exist_ok=True)
+ os.rename(old, target)
+ if os.path.isfile(old + ".allowed"):
+ try: os.replace(old + ".allowed", target + ".allowed")
+ except Exception: pass
+ old_prefix = old + os.sep
+ if self.current_path == old:
+ self.current_path = target; self.update_title()
+ elif self.current_path and self.current_path.startswith(old_prefix):
+ self.current_path = target + self.current_path[len(old):]; self.update_title()
+ self.expanded = set(
+ target if p == old else (target + p[len(old):] if p.startswith(old_prefix) else p)
+ for p in self.expanded
+ )
+ self.refresh_tree(); self.save_state()
+ self.set_status("renamed to " + os.path.basename(target))
+ except Exception as e:
+ self.set_status("rename failed: %s" % e)
+
+ if os.path.exists(target) and os.path.realpath(target) != os.path.realpath(old):
+ self._confirm_overwrite(target, proceed)
+ else:
+ proceed()
+
+ def delete_dialog(self):
+ w = self._selected_tree()
+ if not w or not getattr(w, "path", None):
+ self.set_status("select a file/folder to delete"); return
+ path = w.path
+ if self._is_root(path):
+ self.set_status("cannot delete the pages/files root"); return
+ self.show_dialog(ConfirmDialog("Delete", "Delete %s?" % os.path.basename(path),
+ [("Delete", lambda: self._do_delete(path)), ("Cancel", self.close_dialog)], on_cancel=self.close_dialog))
+
+ def _do_delete(self, path):
+ self.close_dialog()
+ try:
+ prefix = path + os.sep
+ if os.path.isdir(path):
+ shutil.rmtree(path)
+ self.expanded = set(p for p in self.expanded if p != path and not p.startswith(prefix))
+ else:
+ os.remove(path)
+ if os.path.isfile(path + ".allowed"):
+ try: os.remove(path + ".allowed")
+ except Exception: pass
+ if self.current_path is not None and (self.current_path == path or self.current_path.startswith(prefix)):
+ self.current_path = None; self.editable = False; self.is_exec = False; self.dirty = False
+ self._loading = True; self.editor.set_edit_text(""); self._loading = False
+ self.update_title(); self.set_preview_note("")
+ self.refresh_tree(); self.save_state()
+ self.set_status("deleted " + os.path.basename(path))
+ except Exception as e:
+ self.set_status("delete failed: %s" % e)
+
+ def _read_allowed(self, path):
+ ap = path + ".allowed"
+ if not os.path.isfile(ap):
+ return ""
+ try:
+ with open(ap, encoding="utf-8") as f:
+ return "\n".join(ln.strip() for ln in f.read().splitlines() if ln.strip())
+ except Exception:
+ return ""
+
+ def _walk_pages(self, path):
+ for root, dirs, files in os.walk(path):
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
+ for fn in files:
+ if fn.endswith(".allowed"):
+ continue
+ yield os.path.join(root, fn)
+
+ def _folder_permissions(self, path):
+ bodies = {}
+ total = 0
+ for page in self._walk_pages(path):
+ total += 1
+ body = self._read_allowed(page)
+ bodies[body] = bodies.get(body, 0) + 1
+ restricted = sum(count for body, count in bodies.items() if body)
+ distinct = [body for body in bodies if body]
+ common = distinct[0] if len(bodies) == 1 and distinct else None
+ return common, restricted, total
+
+ def permissions_dialog(self):
+ w = self._selected_tree()
+ if not w or not getattr(w, "path", None):
+ self.set_status("select a file/folder for permissions"); return
+ path = w.path
+ is_dir = os.path.isdir(path)
+
+ if is_dir:
+ common, restricted, total = self._folder_permissions(path)
+ content = common or ""
+ if not total:
+ state = "This folder contains no files."
+ elif not restricted:
+ state = "All %d files here are public." % total
+ elif common is not None:
+ state = "All %d files here share these entries." % total
+ else:
+ state = "Entries differ across files (%d of %d restricted)." % (restricted, total)
+ note = ("Allowed identity hashes per line.\n"
+ + state + "\nSaving applies these entries to every file in this folder and its subfolders.")
+ else:
+ content = self._read_allowed(path)
+ note = "Allowed identity hashes per line. Empty = public.\nApplies to this page or file."
+
+ title = "Permissions: " + os.path.basename(path) + ("/" if is_dir else "")
+ self.show_dialog(PermissionsDialog(title, note, content,
+ lambda txt: self._save_permissions(path, is_dir, txt), self.close_dialog))
+
+ def _save_permissions(self, path, is_dir, text):
+ body = "\n".join(ln.strip() for ln in text.splitlines() if ln.strip())
+
+ if is_dir and not body:
+ restricted = self._folder_permissions(path)[1]
+ if restricted:
+ self.show_dialog(ConfirmDialog("Make public",
+ "This removes the access restrictions from %d page(s)\nunder %s, making them public.\n\nContinue?"
+ % (restricted, os.path.basename(path)),
+ [("Make public", lambda: self._apply_permissions(path, is_dir, body)), ("Cancel", self.close_dialog)],
+ on_cancel=self.close_dialog))
+ return
+
+ self._apply_permissions(path, is_dir, body)
+
+ def _apply_permissions(self, path, is_dir, body):
+ self.close_dialog()
+ try:
+ if is_dir:
+ count = 0
+ for page in self._walk_pages(path):
+ self._write_allowed(page, body)
+ count += 1
+ self.set_status(("permissions applied to %d files" if body else "permissions cleared on %d files") % count)
+ else:
+ self._write_allowed(path, body)
+ self.set_status("permissions saved" if body else "permissions cleared (public)")
+ self.refresh_tree()
+ except Exception as e:
+ self.set_status("permissions failed: %s" % e)
+
+ def _write_allowed(self, path, body):
+ ap = path + ".allowed"
+ if body:
+ self._atomic_write(ap, body + "\n")
+ elif os.path.exists(ap):
+ os.remove(ap)
+
+ def snippet_dialog(self):
+ self.show_dialog(SnippetDialog(self._insert_snippet, self.close_dialog))
+
+ def _insert_snippet(self, text):
+ self.close_dialog()
+ if self.pane_visible["editor"]:
+ try: self.columns.focus_position = self.visible_panes().index("editor")
+ except Exception: pass
+ pos = self.editor.edit_pos
+ t = self.editor.edit_text
+ self.editor.set_edit_text(t[:pos] + text + t[pos:])
+ self.editor.set_edit_pos(pos + len(text))
+
+ def find_dialog(self):
+ if self._find is None:
+ self._find = FindDialog(self, self.close_dialog)
+ self.show_dialog(self._find)
+
+ def find_next(self, term):
+ if not term:
+ return
+ t = self.editor.edit_text
+ idx = t.find(term, self.editor.edit_pos)
+ if idx < 0:
+ idx = t.find(term, 0)
+ if idx < 0:
+ self.set_status("'%s' not found" % term); return
+ self.editor.set_edit_pos(idx + len(term))
+ self.set_status("found at %d" % idx)
+
+ def replace_one(self, term, repl):
+ if not term:
+ return
+ t = self.editor.edit_text
+ end = self.editor.edit_pos
+ start = end - len(term)
+ if start >= 0 and t[start:end] == term:
+ self.editor.set_edit_text(t[:start] + repl + t[end:])
+ self.editor.set_edit_pos(start + len(repl))
+ self.set_status("replaced")
+ else:
+ self.find_next(term)
+
+ def replace_all(self, term, repl):
+ if not term:
+ return
+ cnt = self.editor.edit_text.count(term)
+ if cnt:
+ self.editor.set_edit_text(self.editor.edit_text.replace(term, repl))
+ self.set_status("replaced %d" % cnt)
+ else:
+ self.set_status("'%s' not found" % term)
+
+ def save_as_dialog(self):
+ default = self.current_path or ((self.app.pagespath or "") + os.sep)
+ self.show_dialog(PathDialog("Save as", default, self._do_saveas, self.close_dialog))
+
+ def _do_saveas(self, path):
+ self.close_dialog()
+ target = self._confine(path)
+ if target is None:
+ self.set_status("path must be inside pages/ or files/"); return
+
+ def proceed():
+ self.close_dialog()
+ try:
+ os.makedirs(os.path.dirname(target), exist_ok=True)
+ except Exception:
+ pass
+ self.editable = True
+ self.save_file(target)
+
+ already = self.current_path is None or os.path.realpath(target) != os.path.realpath(self.current_path)
+ if os.path.exists(target) and already:
+ self._confirm_overwrite(target, proceed)
+ else:
+ proceed()
+
+ def show_dialog(self, dialog):
+ self.dialog = dialog
+ overlay = urwid.Overlay(
+ dialog, self.columns,
+ align=urwid.CENTER, width=(urwid.RELATIVE, 60),
+ valign=urwid.MIDDLE, height=(urwid.RELATIVE, 40),
+ min_width=30, min_height=8,
+ )
+ self.body.original_widget = overlay
+
+ def close_dialog(self):
+ self.dialog = None
+ self.body.original_widget = self.columns
+
+ def on_edit_change(self, widget, old_text):
+ if self._loading or (self.current_path is not None and not self.editable):
+ return
+ self.dirty = True
+ self.set_modified(True)
+ if self.is_exec:
+ return
+ if self.preview_alarm is not None:
+ try: self.app.ui.loop.remove_alarm(self.preview_alarm)
+ except Exception: pass
+ self.preview_alarm = self.app.ui.loop.set_alarm_in(PREVIEW_DELAY, self._render_preview_alarm)
+
+ def _render_preview_alarm(self, loop, user_data):
+ self.preview_alarm = None
+ self.render_preview()
+
+ def render_preview(self):
+ if len(self.editor.edit_text) > MAX_PREVIEW_CHARS:
+ self.set_preview_note("File too large to preview (%d KB). Editing still works." % (len(self.editor.edit_text) // 1000))
+ return
+ pos = self._preview_scrollpos()
+ try:
+ body = self.preview_browser.render_markup_buffer(self.editor.edit_text)
+ except Exception as e:
+ body = urwid.Filler(urwid.Text("preview error: %s" % e), urwid.TOP)
+ self.pane_widgets["preview"].original_widget = body
+ self._set_preview_scrollpos(pos)
+
+ def _preview_scrollable(self):
+ try:
+ return self.preview_browser.browser_body.original_widget.original_widget
+ except Exception:
+ return None
+
+ def _preview_scrollpos(self):
+ s = self._preview_scrollable()
+ try: return s.get_scrollpos()
+ except Exception: return None
+
+ def _set_preview_scrollpos(self, pos):
+ if pos is None: return
+ s = self._preview_scrollable()
+ try: s.set_scrollpos(pos)
+ except Exception: pass
+
+ def set_modified(self, modified):
+ self.update_title(modified)
+
+ def set_status(self, msg):
+ self.shortcuts_display.set_status(msg)
diff --git a/nomadnet/ui/textui/Extras.py b/nomadnet/ui/textui/Extras.py
index ce62430..54b4fec 100644
--- a/nomadnet/ui/textui/Extras.py
+++ b/nomadnet/ui/textui/Extras.py
@@ -5,57 +5,15 @@ class IntroDisplay():
font = urwid.font.HalfBlock5x4Font()
- big_text = urwid.BigText(("intro_title", "Nomad Network"), font)
- big_text = urwid.Padding(big_text, align="center", width="clip")
+ 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="center"),
+ urwid.Text(("Version %s" % (str(self.app.version))), align=urwid.CENTER),
urwid.Divider(),
- urwid.Text(("-= Starting =- "), align="center"),
+ urwid.Text(("-= Starting =- "), align=urwid.CENTER),
])
self.widget = urwid.Filler(intro)
-
-class DemoDisplay():
- def __init__(self, ui, app):
- import urwid
-
- def color_mono(btn):
- ui.set_colormode(nomadnet.ui.COLORMODE_MONO)
-
- def color_16(btn):
- ui.set_colormode(nomadnet.ui.COLORMODE_16)
-
- def color_88(btn):
- ui.set_colormode(nomadnet.ui.COLORMODE_88)
-
- def color_8bit(btn):
- ui.set_colormode(nomadnet.ui.COLORMODE_256)
-
- def color_true(btn):
- ui.set_colormode(nomadnet.ui.COLORMODE_TRUE)
-
- # pile = urwid.Pile([
- # urwid.Text(("heading", "This is a heading")),
- # urwid.Text(("body_text", "Hello World \U0001F332")),
- # urwid.Button(("buttons", "Monochrome"), color_mono),
- # urwid.Button(("buttons", "16 color"), color_16),
- # urwid.Button(("buttons", "88 color"), color_88),
- # urwid.Button(("buttons", "256 color"), color_8bit),
- # urwid.Button(("buttons", "True color"), color_true),
- # ])
-
- gf = urwid.GridFlow([
- urwid.Text(("heading", "This is a heading")),
- urwid.Text(("body_text", "Hello World \U0001F332")),
- urwid.Button(("buttons", "Monochrome"), color_mono),
- urwid.Button(("buttons", "16 color"), color_16),
- urwid.Button(("buttons", "88 color"), color_88),
- urwid.Button(("buttons", "256 color"), color_8bit),
- urwid.Button(("buttons", "True color"), color_true),
- ], cell_width=20, h_sep=0, v_sep=0, align="left")
-
- self.widget = urwid.Filler(urwid.Padding((urwid.Text("Test"),urwid.Text("Test 2"))), 'top')
- #self.widget = urwid.Filler(pile, 'top')
\ No newline at end of file
diff --git a/nomadnet/ui/textui/FileBrowser.py b/nomadnet/ui/textui/FileBrowser.py
new file mode 100644
index 0000000..f80a4f6
--- /dev/null
+++ b/nomadnet/ui/textui/FileBrowser.py
@@ -0,0 +1,234 @@
+import os
+import nomadnet
+import urwid
+
+class FileBrowserEntry(urwid.WidgetWrap):
+ signals = ["click"]
+
+ def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False):
+ self.full_path = full_path
+ self.name = name
+ self.is_dir = is_dir
+ self.is_parent = is_parent
+ self.selected = selected
+ g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs
+ if is_parent:
+ display = g["arrow_l"]+" .."
+ elif is_dir:
+ display = g["arrow_r"]+" "+name+"/"
+ elif selected:
+ display = g["check"]+" "+name
+ else:
+ display = " "+name
+ self.text_widget = urwid.SelectableIcon(display, 0)
+ if is_dir or is_parent:
+ style = "list_trusted"
+ focus_style = "list_focus"
+ elif selected:
+ style = "list_trusted"
+ focus_style = "list_focus_trusted"
+ else:
+ style = "list_unknown"
+ focus_style = "list_focus"
+ display_widget = urwid.AttrMap(self.text_widget, style, focus_style)
+ super().__init__(display_widget)
+
+ def keypress(self, size, key):
+ if key == "enter":
+ self._emit("click")
+ else:
+ return key
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and urwid.util.is_mouse_press(event):
+ self._emit("click")
+ return True
+ return False
+
+class FileBrowser(urwid.WidgetWrap):
+ def __init__(self, on_done=None, on_cancel=None, on_change=None, title="Select File", multi_select=True, start_path=None, selected=None, max_file_size=None):
+ self.on_done_cb = on_done
+ self.on_cancel_cb = on_cancel
+ self.on_change_cb = on_change
+ self.multi_select = multi_select
+ self.max_file_size = max_file_size
+ self.selected = list(selected) if selected else []
+
+ app = nomadnet.NomadNetworkApp.get_shared_instance()
+ self.g = app.ui.glyphs
+ self.current_path = os.path.expanduser(start_path) if start_path else os.path.expanduser("~")
+
+ self.path_label = urwid.Text("")
+ self.status_label = urwid.Text("")
+ self.file_walker = urwid.SimpleFocusListWalker([])
+ self.file_listbox = urwid.ListBox(self.file_walker)
+
+ self.button_columns = urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)),
+ ])
+
+ header_pile = urwid.Pile([
+ self.path_label,
+ self.status_label,
+ urwid.Divider(self.g["divider1"]),
+ ])
+ footer_pile = urwid.Pile([
+ urwid.Divider(self.g["divider1"]),
+ self.button_columns,
+ ])
+
+ self._populate()
+
+ self.browser_frame = urwid.Frame(self.file_listbox, header=header_pile, footer=footer_pile)
+ self._main_widget = urwid.LineBox(self.browser_frame, title=title)
+ self._notice_open = False
+ super().__init__(self._main_widget)
+
+ def _show_notice(self, message, title="Notice"):
+ def dismiss(_b=None):
+ self._notice_open = False
+ self._w = self._main_widget
+
+ box = urwid.LineBox(urwid.Pile([
+ urwid.Text(message, align=urwid.CENTER),
+ urwid.Divider(),
+ urwid.Padding(urwid.Button("OK", on_press=dismiss), align=urwid.CENTER, width=8),
+ ]), title=title)
+ self._notice_open = True
+ self._w = urwid.Overlay(box, self._main_widget, align=urwid.CENTER, width=("relative", 75), valign=urwid.MIDDLE, height=urwid.PACK)
+
+ def _dismiss_notice(self):
+ self._notice_open = False
+ self._w = self._main_widget
+
+ def _update_status(self):
+ if self.selected:
+ names = [os.path.basename(p) for p in self.selected]
+ self.status_label.set_text(" "+self.g["file"]+" "+str(len(self.selected))+" selected: "+", ".join(names))
+ elif self.multi_select:
+ self.status_label.set_text(" No files selected")
+ else:
+ self.status_label.set_text(" Select a file")
+
+ def _populate(self):
+ self.path_label.set_text(" "+self.current_path)
+ self._update_status()
+
+ focus_pos = None
+ try:
+ focus_pos = self.file_listbox.focus_position
+ except Exception:
+ pass
+
+ entries = []
+ parent = os.path.dirname(self.current_path)
+ if parent != self.current_path:
+ entry = FileBrowserEntry("..", parent, is_parent=True)
+ urwid.connect_signal(entry, "click", self._entry_clicked, entry)
+ entries.append(entry)
+
+ try:
+ items = sorted(os.listdir(self.current_path))
+ except PermissionError:
+ entries.append(urwid.Text(("error_text", " Permission denied")))
+ self.file_walker[:] = entries
+ return
+ except Exception:
+ entries.append(urwid.Text(("error_text", " Cannot read directory")))
+ self.file_walker[:] = entries
+ return
+
+ dirs = []
+ files = []
+ for item in items:
+ if item.startswith("."):
+ continue
+ full = os.path.join(self.current_path, item)
+ if os.path.isdir(full):
+ dirs.append((item, full))
+ elif os.path.isfile(full):
+ files.append((item, full))
+
+ for name, full in dirs:
+ entry = FileBrowserEntry(name, full, is_dir=True)
+ urwid.connect_signal(entry, "click", self._entry_clicked, entry)
+ entries.append(entry)
+
+ for name, full in files:
+ entry = FileBrowserEntry(name, full, selected=(full in self.selected))
+ urwid.connect_signal(entry, "click", self._entry_clicked, entry)
+ entries.append(entry)
+
+ if not dirs and not files:
+ entries.append(urwid.Text(("inactive_text", " (empty)")))
+
+ self.file_walker[:] = entries
+ if focus_pos is not None and focus_pos < len(entries):
+ self.file_listbox.set_focus(focus_pos)
+ elif entries:
+ self.file_listbox.set_focus(0)
+
+ def _entry_clicked(self, entry_widget, user_data=None):
+ entry = user_data if user_data else entry_widget
+ if entry.is_dir or entry.is_parent:
+ self.current_path = entry.full_path
+ self._populate()
+ return
+
+ if self.max_file_size is not None and entry.full_path not in self.selected:
+ try:
+ file_size = os.path.getsize(entry.full_path)
+ except Exception:
+ file_size = 0
+ if file_size > self.max_file_size:
+ limit_mb = self.max_file_size / (1024 * 1024)
+ size_mb = file_size / (1024 * 1024)
+ self._show_notice(
+ "%s is %.1f MB.\nAttachments are limited to %.0f MB." % (entry.name, size_mb, limit_mb),
+ title="File too large",
+ )
+ return
+
+ if self.multi_select:
+ if entry.full_path in self.selected:
+ self.selected.remove(entry.full_path)
+ else:
+ self.selected.append(entry.full_path)
+ if self.on_change_cb is not None:
+ self.on_change_cb(list(self.selected))
+ self._populate()
+ else:
+ self.selected = [entry.full_path]
+ if self.on_change_cb is not None:
+ self.on_change_cb(list(self.selected))
+ if self.on_done_cb is not None:
+ self.on_done_cb(list(self.selected))
+
+ def _dismiss(self, sender):
+ if self.on_done_cb is not None:
+ self.on_done_cb(list(self.selected))
+
+ def _cancel(self, sender):
+ self.selected = []
+ if self.on_cancel_cb is not None:
+ self.on_cancel_cb()
+
+ def keypress(self, size, key):
+ if self._notice_open:
+ if key in ("esc", "enter"):
+ self._dismiss_notice()
+ return None
+ return super().keypress(size, key)
+ if key == "esc":
+ self._cancel(None)
+ return None
+ result = super().keypress(size, key)
+ if result == "down" and self.browser_frame.focus_position == "body":
+ self.browser_frame.focus_position = "footer"
+ return None
+ elif result == "up" and self.browser_frame.focus_position == "footer":
+ self.browser_frame.focus_position = "body"
+ return None
+ return result
diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py
new file mode 100644
index 0000000..a204272
--- /dev/null
+++ b/nomadnet/ui/textui/Guide.py
@@ -0,0 +1,2056 @@
+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.g = app.ui.glyphs
+ self.reader = reader
+ self.last_keypress = 0
+
+ def marked_link(self, target, fields=None):
+ pass
+
+ def micron_released_focus(self):
+ self.reader.focus_topics()
+
+ def resolve_image(self, url):
+ if url.endswith("demo.webp"):
+ import os
+ import nomadnet.ui.textui.images as images_module
+ path = f"{os.path.dirname(images_module.__file__)}/demo.webp"
+ RNS.log(path)
+ return path
+
+ return None
+
+ def handle_link(self, target, fields=None):
+ if not target:
+ return
+ if target.startswith("#"):
+ if self.reader is not None:
+ try:
+ def df(loop, user_data): self.reader.jump_to_anchor(target[1:])
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.set_alarm_in(0.0, df)
+ except Exception as e:
+ RNS.log("Guide anchor jump failed: "+str(e), RNS.LOG_ERROR)
+ return
+ try:
+ self.app.ui.main_display.show_network(None)
+ self.app.ui.main_display.sub_displays.network_display.browser.handle_link(target, fields)
+ except Exception as e:
+ RNS.log("Could not open guide link: "+str(e), RNS.LOG_ERROR)
+
+
+class GuideEntry(urwid.WidgetWrap):
+ def __init__(self, app, parent, reader, topic_name):
+ self.app = app
+ 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, "Micron 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, download certain files or execute certain scripts. In such cases, you can use `*authentication`* to control who gets to run certain requests.
+
+To enable authentication for any page or file, simply add a new file next to it with ".allowed" added to its file-name. If your page is named "secret_page.mu", just add a file named "secret_page.mu.allowed". The same works for files in your files directory, and the ".allowed" file itself is never served.
+
+For each user allowed to access the page, add a line to this file, containing the hash of that users primary identity. Users can find their own identity hash in the `![ Network ]`! part of the program, under `!Local Peer Info`!. If you want to allow access for three different users, your file would look like this:
+
+`Faaa
+`=
+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 Profiles
+
+Interface profiles let you save different combinations of enabled interfaces and switch between them quickly. For example, you might keep a `!Home`! profile that uses a local AutoInterface, and a `!Travel`! profile that reaches out over a TCP gateway instead.
+
+Press `!< Tab >`! from the `![ Interfaces ]`! section to move between the `!Interfaces`! and `!Profiles`! tabs.
+
+To save your current set of enabled interfaces as a new profile, press `!< Ctrl + S >`!. To assign an interface to one or more profiles, edit it with `!< Ctrl + E >`! and use the profile checkboxes in the edit form. You can also toggle the selected interface on or off directly with `!< Ctrl + O >`!, and filter the list by All, Enabled or Disabled with `!< Ctrl + F >`!.
+
+`Ffff`! (!) Note:`! Switching profiles changes which interfaces are enabled, so a restart of NomadNet or your Reticulum instance is required for the change to take effect.`f`b
+
+
+>Interface Types
+
+Jump to a specific interface type:
+
+ `F79d`_`[AutoInterface`#autointerface]`_`f
+ `F79d`_`[TCPClientInterface`#tcpclientinterface]`_`f
+ `F79d`_`[TCPServerInterface`#tcpserverinterface]`_`f
+ `F79d`_`[UDPInterface`#udpinterface]`_`f
+ `F79d`_`[I2PInterface`#i2pinterface]`_`f
+ `F79d`_`[RNodeInterface`#rnodeinterface]`_`f
+ `F79d`_`[RNodeMultiInterface`#rnodemultiinterface]`_`f
+ `F79d`_`[SerialInterface`#serialinterface]`_`f
+ `F79d`_`[KISSInterface`#kissinterface]`_`f
+ `F79d`_`[PipeInterface`#pipeinterface]`_`f
+
+>>AutoInterface
+
+The Auto Interface enables communication with other discoverable Reticulum nodes over autoconfigured IPv6 and UDP. It does not need any functional IP infrastructure like routers or DHCP servers, but will require at least some sort of switching medium between peers (a wired switch, a hub, a WiFi access point or similar).
+
+```
+Required Parameters:
+Interface Name
+
+Optional Parameters:
+Devices: Specific network devices to use
+Ignored Devices: Network devices to exclude
+Group ID: Create isolated networks on the same physical LAN
+Discovery Scope: Can set to link, admin, site, organisation or global
+```
+
+The AutoInterface is ideal for quickly connecting to other Reticulum nodes on your local network without complex configuration.
+
+>>TCPClientInterface
+
+The TCP Client interface connects to remote TCP server interfaces, enabling communication over the Internet or private networks.
+
+```
+Required Parameters:
+Target Host: Hostname or IP address of the server
+Target Port: Port number to connect to
+
+Optional Parameters:
+I2P Tunneled: Enable for connecting through I2P
+KISS Framing: Enable for KISS framing for software modems
+```
+
+This interface is commonly used to connect to Reticulum gateways or other persistent nodes on the Internet.
+
+>>TCPServerInterface
+
+The TCP Server interface listens for incoming connections, allowing other Reticulum peers to connect to your node using TCPClientInterface.
+
+```
+Required Parameters:
+Listen IP: IP address to bind to (0.0.0.0 for all interfaces)
+Listen Port: Port number to listen on
+
+Optional Parameters:
+Prefer IPv6: Bind to IPv6 address if available
+I2P Tunneled: Enable for I2P tunnel support
+Device: Specific network device to use (e,g
+```
+
+Useful when you want other nodes to be able to connect to your Transport instance over TCP/IP.
+
+>>UDPInterface
+
+The UDP interface allows communication over IP networks using UDP packets.
+
+```
+Required Parameters:
+Listen IP: IP address to bind to
+Listen Port: Port to listen on
+Forward IP: IP address to forward to (Can be broadcast address)
+Forward Port: Port to forward to
+
+Optional Parameters:
+Device: Specific network device to use
+```
+
+>>I2PInterface
+
+The I2P interface enables connections over the Invisible Internet Protocol. The I2PInterface requires an I2P daemon to be running on your system, such as `!i2pd`!
+
+```
+Optional Parameters:
+Peers: I2P addresses to connect to (Can be left as none if running as a Transport)
+```
+
+
+>>RNodeInterface
+
+The RNode interface allows using LoRa transceivers running RNode firmware as Reticulum network interfaces.
+
+```
+Required Parameters:
+Port: Serial port or BLE device path
+Frequency: Operating frequency in MHz
+Bandwidth: Channel bandwidth
+TX Power: Transmit power in dBm
+Spreading Factor: LoRa spreading factor (7-12)
+Coding Rate: LoRa coding rate (4:5-4:8)
+
+Optional Parameters:
+ID Callsign: Station identification
+ID Interval: Identification interval in seconds
+Airtime Limits: Control duty cycle
+```
+
+The interface includes a parameter calculator to estimate link budget, sensitivity, and data rate on the air based on your settings.
+
+>>RNodeMultiInterface
+
+The RNode Multi Interface is designed for use with specific hardware platforms that support multiple subinterfaces or virtual radios. For most LoRa hardware platforms, you will want to use the standard `!RNodeInterface`! instead.
+
+```
+Required Parameters:
+Port: Serial port or BLE device path
+Subinterfaces: Configuration for each subinterface / virtual radio port
+
+```
+
+>>SerialInterface
+
+The Serial interface enables using direct serial connections as Reticulum interfaces.
+
+```
+Required Parameters:
+Port: Serial port path
+Speed: Baud rate of serial device
+Databits: Number of data bits
+Parity: Parity setting
+Stopbits: Number of stop bits
+```
+
+>>KISSInterface
+
+The KISS interface supports packet radio modems and TNCs using the KISS protocol.
+
+```
+Required Parameters:
+
+Port: Serial port path
+Speed: Baud rate of serial device
+Databits: Number of data bits
+Parity: Parity setting
+Stopbits: Number of stop bits
+Preamble: Modem preamble in milliseconds
+TX Tail: Transmit tail in milliseconds
+Slottime: CSMA slottime in milliseconds
+Persistence: CSMA persistence value
+
+Optional Parameters:
+
+ID Callsign: Station identification
+ID Interval: Identification interval in seconds
+Flow Control: Enable packet flow control
+```
+
+>>PipeInterface
+
+The Pipe interface allows using external programs as Reticulum interfaces via stdin and stdout.
+
+```
+Required Parameters:
+
+Command: External command to execute
+
+Optional Parameters:
+Respawn Delay: Seconds to wait before restarting after failure
+```
+
+<
+>>
+-∿
+ For more information and to view the full Interface documentation consult the Reticulum manual or visit https://reticulum.network/manual/interfaces.html (Requires external Internet connection)
+
+
+>Interface Access Code (IFAC) Settings
+
+Interface Access Codes create private networks and securely segment network traffic. Under `!Show more options`!, you'll find these IFAC settings:
+
+`!Virtual Network Name`! (network_name):
+When added, creates a logical network that only communicates with other interfaces using the same name. This allows multiple separate Reticulum networks to exist on the same physical channel or medium.
+
+`!IFAC Passphrase`! (passphrase):
+Sets an authentication passphrase for the interface. Only interfaces configured with the same passphrase will be able to communicate. Can be used with or without a network name.
+
+`!IFAC Size`! (ifac_size):
+Controls the length of Interface Authentication Codes (8-512 bits). Larger sizes provide stronger security but add overhead to each packet. The default of `!8`! is usually appropriate for most uses.
+
+>Interface Modes
+
+When running a Transport node, you can configure interface modes that affect how Reticulum selects paths, propagates announces, and discovers routes:
+
+`!full`!: Default mode with all discovery, meshing and transport functionality
+`!gateway`!: Discovers paths on behalf of other nodes
+`!access_point`!: Operates as a network access point
+`!roaming`!: For physically mobile interfaces
+`!boundary`!: For interfaces connecting different network segments
+
+'''
+
+TOPIC_CONVERSATIONS = '''>Conversations
+
+Conversations in Nomad Network
+'''
+
+
+TOPIC_CHANNELS = '''>Channels & RRC
+
+NomadNet includes a built-in client for `*RRC`* (Reticulum Relay Chat), a real-time text chat protocol that runs over Reticulum. The reference RRC server implementation lives at https://github.com/kc1awv/rrcd. Each RRC server is called a `!hub`!, and each hub hosts one or more `!rooms`! (channels) you can join. Hubs are addressed by a Reticulum destination hash, just like nodes and conversations.
+
+The Channels section of NomadNet (accessible from the main menu, or by pressing the corresponding shortcut) is where you manage your hubs, view connection status, and chat in rooms.
+
+>>Joining a hub
+
+To start chatting on a hub you must first add it to your hub list:
+
+>>>
+ - Open the `![ Channels ]`! section.
+ - Press the shortcut to open the `*New Hub`* dialog (shown in the shortcut bar at the bottom).
+ - Enter the `!hub address`! (the destination hash of the hub, typically 16 hex characters / 8 bytes) and an optional display name.
+ - Confirm the dialog. The hub will appear in your hub list.
+<
+
+You can sometimes receive a hub link from another user or from a node page. Activating such a link will pre-fill the New Hub dialog (and optionally a room name) for you.
+
+>>Connecting and listing rooms
+
+Once a hub is added:
+
+>>>
+ - Select the hub in the list and connect to it. Hubs can also be set to auto-reconnect.
+ - When connected, run `*/list`* to see public rooms hosted on this hub.
+ - Run `*/join `* to join a room. Joined rooms appear under the hub in your channel list.
+<
+
+>>Chatting
+
+Inside a room, anything you type that does not start with a `*/`* is sent as a message to everyone in the room. The most common in-room commands are:
+
+>>>
+ - `*/help`* show the full list of commands
+ - `*/who`* list users in the current room
+ - `*/nick `* set your display name on this hub
+ - `*/topic [text]`* view or set the room topic
+ - `*/part [room]`* leave a room (defaults to the current one)
+ - `*/quit`* disconnect from the hub
+<
+
+Messages support both `*markdown`* and `*micron`* formatting, depending on your render settings (see the `*Configuration Options`* topic). Because the backtick is the micron control character, you must use the `!broken-bar`! character `*¦`* in place of `*\\``* when typing micron formatting in a message. The renderer converts `*¦`* back to `*\\``* for display.
+
+>>Mentions and privacy
+
+You will be notified (and the bell may ring, depending on your configuration) when another user mentions your nick with `*@yournick`*. RRC traffic is end-to-end encrypted by Reticulum between you and the hub, but other users in the same room can read what you write there. Treat rooms as semi-public spaces.
+
+>>Hub etiquette
+
+Each hub is operated independently and may have its own rules, MOTD, ban list and operator team. Be respecful, follow the hub's MOTD, and remember that operators can kick, ban and set modes on rooms they own.
+
+'''
+
+
+TOPIC_FIRST_RUN = '''>First Time Information
+
+Hi there. This first run message will only appear once. It contains a few pointers on getting started with Nomad Network, and getting the most out of the program.
+
+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
+<
+
+>>>
+`!image_loading = auto`!
+>>>>
+Specifies how, if at all, images on pages should be loaded. Available options:
+>>>>>
+`!never`! Never load any images
+`!manual`! Only load images when manually requseted with Ctrl-L
+`!auto`! Load images automatically if link conditions are sufficient
+`!always`! Always load images, regardless of link RTT and EDR
+<
+
+>>>
+`!theme = dark`!
+>>>>
+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.
+``
+
+>>Image Rendering Test
+
+`(The RNS logo`w=n`a=c`:/media/demo.webp)
+
+If you see the RNS logo above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, iTerm2 and others.
+
+It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either.
+
+'''
+
+
+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`_`[Collapsible Sections`#collapsible-sections]`_`f
+ `F79d`_`[Colors`#colors]`_`f
+ `F79d`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f
+ `F79d`_`[Links`#links]`_`f
+ `F79d`_`[Anchors`#anchors]`_`f
+ `F79d`_`[Tables`#tables]`_`f
+ `F79d`_`[Images`#images]`_`f
+ `F79d`_`[Fields & Requests`#fields-requests]`_`f
+ `F79d`_`[Comments`#comments]`_`f
+ `F79d`_`[Partials`#partials]`_`f
+ `F79d`_`[Literals`#literals]`_`f
+ `F79d`_`[Closing Remarks`#closing-remarks]`_`f
+
+>>Recommendations and Requirements
+
+While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal `*must`* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better.
+
+Formatting such as `_underline`_, `!bold`! or `*italics`* will be displayed if your terminal supports it.
+
+If you are having trouble getting micron output to display correctly, try using `*gnome-terminal`* 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.
+``
+
+>Collapsible Sections
+
+Any section heading can be made collapsible, turning it into an accordion the reader can fold away and reveal on demand. Prefix a heading with \\`+ to make it foldable and open by default, or with \\`- to make it foldable but start collapsed:
+
+`Faaa
+`=
+`+>Open by default
+This text can be folded away by selecting the heading.
+
+`->Hidden until expanded
+This starts collapsed, and appears when the reader expands it.
+`=
+``
+
+A collapsible heading folds everything beneath it, up to the next heading of the same or a higher level, exactly like a normal section. It can therefore contain anything: text, tables, input fields, sub-sections, and even live partials.
+
+To fold or unfold a section, move onto its heading and press Enter or Space, or click it with the mouse. Open sections are marked with ▾ and collapsed ones with ▸.
+
+You can choose your own indicators for a page by placing a #!fold directive near the top, giving the open glyph followed by the collapsed glyph:
+
+`Faaa
+`=
+#!fold [-] [+]
+`=
+``
+
+
+>Colors
+
+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
+
+>Images
+
+You can include images in pages by using the image tag. All images must specify at least an alt-text and a URL, and can optionally specify rendering options such as width, height and alignment. The alt-text is required so a meaningful placeholder can be displayed for clients that do not support image rendering.
+
+Width (`Faaaw=`f) and height (`Faaah=`f) can be specified either in terminal columns/rows, in percentages, or using the `Faaan`f specifier, which will render the image as close to its native resolution as possible
+
+Alignment (`Faaaa=`f) can be either `Faaaleft`f (`Faaal`f), `Faaaright`f (`Faaar`f) or `Faaacenter`f (`Faaac`f).
+
+`Faaa
+`=
+`(The RNS logo`w=n`a=c`:/media/demo.webp)
+`=
+``
+
+Images are block-level elements, and must be placed independently on their own line.
+
+If your terminal supports image rendering, the image will be displayed as specified. If not, a text placeholder is shown instead:
+
+`(The RNS logo`w=n`a=c`:/media/demo.webp)
+
+If you see the image above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, iTerm2 and others.
+
+It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either.
+
+`!Important!`! For the sake of bandwidth efficiency, images sent over the network `!must`! be in WebP format. Nomadnet will reject and ignore all other image formats. You can easily convert images to WebP with command-line utilities such as `Faaaconvert`f and similar. The WebP format supports both efficient compression and lossless alpha masks, so it is suitable for anything you can achieve with PNG, JPEG or other formats, but much lighter on bandwidth.
+
+>Fields & Requests
+
+Nomad Network lets you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables.
+
+>>Request Links
+
+Links can contain request variables and a list of fields to submit to the node-side application. You can include all fields on the page, only specific ones, and any number of request variables. To simply submit all fields on a page to a specified node-side page, create a link like this:
+
+`Faaa
+`=
+`[Submit Fields`:/page/fields.mu`*]
+`=
+``
+
+Note the `!*`! following the extra `!\\``! at the end of the path. This `!*`! denotes `*all fields`*. You can also specify a list of fields to include:
+
+`Faaa
+`=
+`[Submit Fields`:/page/fields.mu`username|auth_token]
+`=
+``
+
+If you want to include pre-set variables, you can do it like this:
+
+`Faaa
+`=
+`[Query the System`:/page/fields.mu`username|auth_token|action=view|amount=64]
+`=
+``
+
+>> Fields
+
+Here's an example of creating a field. We'll create a field named `!user_input`! and fill it with the text `!Pre-defined data`!. Note that we are using background color tags to make the field more visible to the user:
+
+`Faaa
+`=
+A simple input field: `B444``b
+`=
+``
+
+You must always set a field `*name`*, but you can of course omit the pre-defined value of the field:
+
+`Faaa
+`=
+An empty input field: `B444``b
+`=
+``
+
+You can set the size of the field like this:
+
+`Faaa
+`=
+A sized input field: `B444`<16|with_size`>`b
+`=
+``
+
+You can also reserve more than one row for a field by appending `!xROWS`! to the width, which is useful for longer, multi-line input. This creates a field that is 40 columns wide and 5 rows tall:
+
+`Faaa
+`=
+A multi-row input field: `B444`<40x5|notes`>`b
+`=
+``
+
+It is possible to mask fields, for example for use with passwords and similar:
+
+`Faaa
+`=
+A masked input field: `B444``b
+`=
+``
+
+And you can of course control all parameters at the same time:
+
+`Faaa
+`=
+Full control: `B444``b
+`=
+``
+
+Collecting the above markup produces the following output:
+
+`Faaa`B333
+
+A simple input field: `B444``B333
+
+An empty input field: `B444``B333
+
+A sized input field: `B444`<16|with_size`>`B333
+
+A masked input field: `B444``B333
+
+A multi-row input field: `B444`<40x5|notes`>`B333
+
+Full control: `B444``B333
+`b
+
+>>> Checkboxes
+
+In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options.
+
+`Faaa
+`=
+`|field_name|value`>`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`|sign_up|1`>`b Sign me up`
+
+You can also pre-check both checkboxes and radio groups by appending a |* after the field value.
+
+`B444`|checkbox|1|*`>`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,
+ "Micron 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..ea97bee
--- /dev/null
+++ b/nomadnet/ui/textui/Helpers.py
@@ -0,0 +1,46 @@
+import sys
+import base64
+import urwid
+import RNS
+
+
+def osc52_copy(text):
+ if not text:
+ return False
+ try:
+ encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
+ sys.stdout.write("\x1b]52;c;" + encoded + "\x07")
+ sys.stdout.flush()
+ return True
+ except Exception as e:
+ RNS.log("Could not emit clipboard escape sequence: "+str(e), RNS.LOG_ERROR)
+ return False
+
+
+def qr_ascii(data):
+ try:
+ import qrcode
+ import io
+ qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1)
+ qr.add_data(data)
+ qr.make()
+ buf = io.StringIO()
+ qr.print_ascii(out=buf, invert=False)
+ return buf.getvalue().rstrip("\n")
+ except Exception as e:
+ RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR)
+ return None
+
+
+class ClickableIcon(urwid.Text):
+ _selectable = False
+
+ def __init__(self, text, on_click=None):
+ super().__init__(text)
+ self._on_click = on_click
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and "press" in event and self._on_click is not None:
+ self._on_click()
+ return True
+ return False
diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py
new file mode 100644
index 0000000..b3914ef
--- /dev/null
+++ b/nomadnet/ui/textui/Interfaces.py
@@ -0,0 +1,4177 @@
+import RNS
+import time
+import nomadnet
+import threading
+from math import log10, pow
+
+from nomadnet.vendor.additional_urwid_widgets.FormWidgets import *
+from nomadnet.vendor.AsciiChart import AsciiChart
+from .ReadlineEdit import ReadlineEdit
+from .Helpers import ClickableIcon
+from nomadnet.util import write_config_atomic
+
+### GYLPHS ###
+INTERFACE_GLYPHS = {
+ # Glyph name # Plain # Unicode # Nerd Font
+ ("NetworkInterfaceType", "(IP)", "\U0001f5a7", "\U000f0200"),
+ ("SerialInterfaceType", "(<->)", "\u2194", "\U000f065c"),
+ ("RNodeInterfaceType", "(R)" , "\u16b1", "\U000f043a"),
+ ("OtherInterfaceType", "(#)" , "\U0001f67e", "\ued95"),
+}
+
+### HELPER ###
+PLATFORM_IS_LINUX = False
+try:
+ PLATFORM_IS_LINUX = (RNS.vendor.platformutils.is_android() or
+ RNS.vendor.platformutils.is_linux())
+except Exception:
+ pass
+
+def _get_interface_icon(glyphset, iface_type):
+ glyphset_index = 1 # Default to unicode
+ if glyphset == "plain":
+ glyphset_index = 0 # plain
+ elif glyphset == "nerdfont":
+ glyphset_index = 2 # nerdfont
+
+ type_to_glyph_tuple = {
+ "BackboneInterface": "NetworkInterfaceType",
+ "AutoInterface": "NetworkInterfaceType",
+ "TCPClientInterface": "NetworkInterfaceType",
+ "TCPServerInterface": "NetworkInterfaceType",
+ "UDPInterface": "NetworkInterfaceType",
+ "I2PInterface": "NetworkInterfaceType",
+
+ "RNodeInterface": "RNodeInterfaceType",
+ "RNodeMultiInterface": "RNodeInterfaceType",
+
+ "SerialInterface": "SerialInterfaceType",
+ "KISSInterface": "SerialInterfaceType",
+ "AX25KISSInterface": "SerialInterfaceType",
+
+ "PipeInterface": "OtherInterfaceType"
+ }
+
+ glyph_tuple_name = type_to_glyph_tuple.get(iface_type, "OtherInterfaceType")
+
+ for glyph_tuple in INTERFACE_GLYPHS:
+ if glyph_tuple[0] == glyph_tuple_name:
+ return glyph_tuple[glyphset_index + 1]
+
+ # Fallback
+ return "(#)" if glyphset == "plain" else "\U0001f67e" if glyphset == "unicode" else "\ued95"
+
+def format_bytes(bytes_value):
+ units = ['bytes', 'KB', 'MB', 'GB', 'TB']
+ size = float(bytes_value)
+ unit_index = 0
+
+ while size >= 1024.0 and unit_index < len(units) - 1:
+ size /= 1024.0
+ unit_index += 1
+
+ if unit_index == 0:
+ return f"{int(size)} {units[unit_index]}"
+ else:
+ return f"{size:.1f} {units[unit_index]}"
+
+
+def format_announce_rate(hz):
+ try:
+ hz = float(hz or 0)
+ except (TypeError, ValueError):
+ return "-"
+ if hz <= 0:
+ return "-"
+ per_hour = hz * 3600
+ if per_hour < 100:
+ return "<1/hr" if per_hour < 1 else "%d/hr" % round(per_hour)
+ return "%d/min" % round(hz * 60)
+
+def format_speed(bps):
+ try:
+ bps = float(bps or 0)
+ except (TypeError, ValueError):
+ return "-"
+ try:
+ return RNS.prettyspeed(bps)
+ except Exception:
+ return "%d bps" % int(bps)
+
+def interface_mode_str(mode):
+ modes = {
+ 1: "Full", # MODE_FULL
+ 2: "Point-to-Point", # MODE_POINT_TO_POINT
+ 3: "Access Point", # MODE_ACCESS_POINT
+ 4: "Roaming", # MODE_ROAMING
+ 5: "Boundary", # MODE_BOUNDARY
+ 6: "Gateway", # MODE_GATEWAY
+ }
+ return modes.get(mode, "Full")
+
+def connection_markup(glyphs, is_connected, label=None):
+ icon = glyphs.get("connected" if is_connected else "disconnected", "")
+ if label is None:
+ label = "Connected" if is_connected else "Disconnected"
+ attr = "connected_status" if is_connected else "disconnected_status"
+ sep = " " if icon else ""
+ return (attr, "%s%s%s" % (icon, sep, label))
+
+def _get_cols_rows():
+ return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows()
+
+
+### PORT FUNCTIONS ###
+PYSERIAL_AVAILABLE = False # If NomadNet is installed on environments with rnspure instead of rns, pyserial won't be available
+try:
+ import serial.tools.list_ports
+ PYSERIAL_AVAILABLE = True
+except ImportError:
+ class DummyPort:
+ def __init__(self, device, description=None, manufacturer=None, hwid=None):
+ self.device = device
+ self.description = description or device
+ self.manufacturer = manufacturer
+ self.hwid = hwid
+ self.vid = None
+ self.pid = None
+
+def get_port_info():
+ if not PYSERIAL_AVAILABLE:
+ return []
+
+ try:
+ ports = serial.tools.list_ports.comports()
+ port_info = []
+
+ # Ports are sorted into categories for dropdown, priority ports appear first
+ priority_ports = [] # USB, ACM, bluetooth, etc
+ standard_ports = [] # COM, tty/s ports
+
+ for port in ports:
+ desc = f"{port.device}"
+ if port.description and port.description != port.device:
+ desc += f" ({port.description})"
+ if port.manufacturer:
+ desc += f" - {port.manufacturer}"
+
+ is_standard = (
+ port.device.startswith("COM") or # windows
+ "/dev/ttyS" in port.device or # Linux
+ "Serial" in port.description
+ )
+
+ port_data = {
+ 'device': port.device,
+ 'description': desc,
+ 'hwid': port.hwid,
+ 'vid': port.vid,
+ 'pid': port.pid,
+ 'is_standard': is_standard
+ }
+
+ if is_standard:
+ standard_ports.append(port_data)
+ else:
+ priority_ports.append(port_data)
+
+ priority_ports.sort(key=lambda x: x['device'])
+ standard_ports.sort(key=lambda x: x['device'])
+
+ return priority_ports + standard_ports
+ except Exception as e:
+ RNS.log(f"error accessing serial ports: {str(e)}", RNS.LOG_ERROR)
+ return []
+
+def get_port_field():
+ if not PYSERIAL_AVAILABLE:
+ return {
+ "config_key": "port",
+ "type": "edit",
+ "label": "Port: ",
+ "default": "",
+ "placeholder": "/dev/ttyUSB0 or COM port (pyserial not installed)",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ }
+
+ port_info = get_port_info()
+
+ if len(port_info) > 1:
+ options = [p['description'] for p in port_info]
+ device_map = {p['description']: p['device'] for p in port_info}
+
+ return {
+ "config_key": "port",
+ "type": "dropdown",
+ "label": "Port: ",
+ "options": options,
+ "default": options[0] if options else "",
+ "validation": ["required"],
+ "transform": lambda x: device_map[x]
+ }
+ else:
+ # single or no ports - use text field
+ default = port_info[0]['device'] if port_info else ""
+ placeholder = "/dev/ttyXXX (or COM port on Windows)"
+
+ return {
+ "config_key": "port",
+ "type": "edit",
+ "label": "Port: ",
+ "default": default,
+ "placeholder": placeholder,
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ }
+
+### RNODE ####
+def calculate_rnode_parameters(bandwidth, spreading_factor, coding_rate, noise_floor=6, antenna_gain=0,
+ transmit_power=17):
+ crn = {
+ 5: 1,
+ 6: 2,
+ 7: 3,
+ 8: 4,
+ }
+ coding_rate_n = crn.get(coding_rate, 1)
+
+ sfn = {
+ 5: -2.5,
+ 6: -5,
+ 7: -7.5,
+ 8: -10,
+ 9: -12.5,
+ 10: -15,
+ 11: -17.5,
+ 12: -20
+ }
+
+ data_rate = spreading_factor * (
+ (4 / (4 + coding_rate_n)) / (pow(2, spreading_factor) / (bandwidth / 1000))) * 1000
+
+ sensitivity = -174 + 10 * log10(bandwidth) + noise_floor + (sfn.get(spreading_factor, 0))
+
+ if bandwidth == 203125 or bandwidth == 406250 or bandwidth > 500000:
+ sensitivity = -165.6 + 10 * log10(bandwidth) + noise_floor + (sfn.get(spreading_factor, 0))
+
+ link_budget = (transmit_power - sensitivity) + antenna_gain
+
+ if data_rate < 1000:
+ data_rate_str = f"{data_rate:.0f} bps"
+ else:
+ data_rate_str = f"{(data_rate / 1000):.2f} kbps"
+
+ return {
+ "data_rate": data_rate_str,
+ "link_budget": f"{link_budget:.1f} dB",
+ "sensitivity": f"{sensitivity:.1f} dBm",
+ "raw_data_rate": data_rate,
+ "raw_link_budget": link_budget,
+ "raw_sensitivity": sensitivity
+ }
+
+class RNodeCalculator(urwid.WidgetWrap):
+ def __init__(self, parent_view):
+ self.parent_view = parent_view
+ self.update_alarm = None
+
+ self.data_rate_widget = urwid.Text("Data Rate: Calculating...")
+ self.link_budget_widget = urwid.Text("Link Budget: Calculating...")
+ self.sensitivity_widget = urwid.Text("Sensitivity: Calculating...")
+
+ self.noise_floor_edit = ReadlineEdit("", "0")
+ self.antenna_gain_edit = ReadlineEdit("", "0")
+
+ layout = urwid.Pile([
+ urwid.Divider("-"),
+
+ urwid.Columns([
+ (28, urwid.Text(("key", "Enter Noise Floor (dB): "), align="right")),
+ self.noise_floor_edit
+ ]),
+
+ urwid.Columns([
+ (28, urwid.Text(("key", "Enter Antenna Gain (dBi): "), align="right")),
+ self.antenna_gain_edit
+ ]),
+
+ urwid.Divider(),
+
+ urwid.Text(("connected_status", "On-Air Calculations:"), align="left"),
+ self.data_rate_widget,
+ self.link_budget_widget,
+ self.sensitivity_widget,
+ urwid.Divider(),
+
+ urwid.Text([
+ "These calculations will update as you change RNode parameters"
+ ])
+ ])
+
+ super().__init__(layout)
+
+ self.connect_all_field_signals()
+
+ self.update_calculation()
+
+ def connect_all_field_signals(self):
+ urwid.connect_signal(self.noise_floor_edit, 'change', self._queue_update)
+ urwid.connect_signal(self.antenna_gain_edit, 'change', self._queue_update)
+ rnode_fields = ['bandwidth', 'spreadingfactor', 'codingrate', 'txpower']
+
+ for field_name in rnode_fields:
+ if field_name in self.parent_view.fields:
+
+ field_widget = self.parent_view.fields[field_name]['widget']
+
+ if hasattr(field_widget, 'edit_text'):
+ urwid.connect_signal(field_widget, 'change', self._queue_update)
+ elif hasattr(field_widget, '_emit') and 'change' in getattr(field_widget, 'signals', []):
+ urwid.connect_signal(field_widget, 'change', self._queue_update)
+
+ def _queue_update(self, widget, new_text):
+ if self.update_alarm is not None:
+ try:
+ self.parent_view.parent.app.ui.loop.remove_alarm(self.update_alarm)
+ except:
+ pass
+
+ self.update_alarm = self.parent_view.parent.app.ui.loop.set_alarm_in(
+ 0.3, self._delayed_update)
+
+ def _delayed_update(self, loop, user_data):
+ self.update_alarm = None
+ self.update_calculation()
+
+ def update_calculation(self):
+ try:
+
+ try:
+ bandwidth_widget = self.parent_view.fields.get('bandwidth', {}).get('widget')
+ bandwidth = int(bandwidth_widget.get_value()) if bandwidth_widget else 125000
+ except (ValueError, AttributeError):
+ bandwidth = 125000
+
+ try:
+ sf_widget = self.parent_view.fields.get('spreadingfactor', {}).get('widget')
+ spreading_factor = int(sf_widget.get_value()) if sf_widget else 7
+ except (ValueError, AttributeError):
+ spreading_factor = 7
+
+ try:
+ cr_widget = self.parent_view.fields.get('codingrate', {}).get('widget')
+ coding_rate = int(cr_widget.get_value()) if cr_widget else 5
+ if isinstance(coding_rate, str) and ":" in coding_rate:
+ coding_rate = int(coding_rate.split(":")[1])
+ except (ValueError, AttributeError):
+ coding_rate = 5
+
+ try:
+ txpower_widget = self.parent_view.fields.get('txpower', {}).get('widget')
+ if hasattr(txpower_widget, 'edit_text'):
+ txpower_text = txpower_widget.edit_text.strip()
+ txpower = int(txpower_text) if txpower_text else 17
+ else:
+ txpower = int(txpower_widget.get_value()) if txpower_widget else 17
+ except (ValueError, AttributeError):
+ txpower = 17
+
+ try:
+ noise_floor_text = self.noise_floor_edit.edit_text.strip()
+ noise_floor = int(noise_floor_text) if noise_floor_text else 0
+ except (ValueError, AttributeError):
+ noise_floor = 0
+
+ try:
+ antenna_gain_text = self.antenna_gain_edit.edit_text.strip()
+ antenna_gain = int(antenna_gain_text) if antenna_gain_text else 0
+ except (ValueError, AttributeError):
+ antenna_gain = 0
+
+ result = calculate_rnode_parameters(
+ bandwidth=bandwidth,
+ spreading_factor=spreading_factor,
+ coding_rate=coding_rate,
+ noise_floor=noise_floor,
+ antenna_gain=antenna_gain,
+ transmit_power=txpower
+ )
+
+ self.data_rate_widget.set_text(f"Data Rate: {result['data_rate']}")
+ self.link_budget_widget.set_text(f"Link Budget: {result['link_budget']}")
+ self.sensitivity_widget.set_text(f"Sensitivity: {result['sensitivity']}")
+
+ except (ValueError, KeyError, TypeError) as e:
+ self.data_rate_widget.set_text(f"Data Rate: Waiting for parameters...")
+ self.link_budget_widget.set_text(f"Link Budget: Waiting for valid parameters...")
+ self.sensitivity_widget.set_text(f"Sensitivity: Waiting for parameters...")
+
+### INTERFACE FIELDS ###
+COMMON_INTERFACE_OPTIONS = [
+ {
+ "config_key": "network_name",
+ "type": "edit",
+ "label": "Virtual Network Name: ",
+ "placeholder": "Optional virtual network name",
+ "default": "",
+ "validation": [],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "passphrase",
+ "type": "edit",
+ "label": "IFAC Passphrase: ",
+ "placeholder": "IFAC authentication passphrase",
+ "default": "",
+ "validation": [],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "ifac_size",
+ "type": "edit",
+ "label": "IFAC Size: ",
+ "placeholder": "8 - 512",
+ "default": "",
+ "validation": ['number'],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "bitrate",
+ "type": "edit",
+ "label": "Inferred Bitrate: ",
+ "placeholder": "Automatically determined",
+ "default": "",
+ "validation": ['number'],
+ "transform": lambda x: x.strip()
+ },
+]
+
+INTERFACE_FIELDS = {
+ "BackboneInterface": [
+ {
+ "config_key": "listen_on",
+ "type": "edit",
+ "label": "Listen On: ",
+ "default": "",
+ "placeholder": "e.g., 0.0.0.0",
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "port",
+ "type": "edit",
+ "label": "Port: ",
+ "default": "",
+ "placeholder": "e.g., 4242",
+ "validation": ["number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else None
+ },
+ {
+ "config_key": "device",
+ "type": "edit",
+ "label": "Device: ",
+ "default": "",
+ "placeholder": "e.g., eth0",
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "remote",
+ "type": "edit",
+ "label": "Remote: ",
+ "default": "",
+ "placeholder": "e.g., a remote TCPServerInterface location",
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "target_host",
+ "type": "edit",
+ "label": "Target Host: ",
+ "default": "",
+ "placeholder": "e.g., 201:5d78:af73:5caf:a4de:a79f:3278:71e5",
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "port",
+ "type": "edit",
+ "label": "Target Port: ",
+ "default": "",
+ "placeholder": "e.g., 4242",
+ "validation": ["number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else None
+ },
+ {
+ "config_key": "prefer_ipv6",
+ "type": "checkbox",
+ "label": "",
+ "default": False,
+ "validation": [],
+ "transform": lambda x: bool(x)
+ },
+ ],
+ "AutoInterface": [
+ {
+
+ },
+ {
+ "additional_options": [
+ {
+ "config_key": "devices",
+ "type": "multilist",
+ "label": "Devices: ",
+ "validation": [],
+ "transform": lambda x: ",".join(x)
+ },
+ {
+ "config_key": "ignored_devices",
+ "type": "multilist",
+ "label": "Ignored Devices: ",
+ "validation": [],
+ "transform": lambda x: ",".join(x)
+ },
+ {
+ "config_key": "group_id",
+ "type": "edit",
+ "label": "Group ID: ",
+ "default": "",
+ "placeholder": "e.g., my_custom_network",
+ "validation": [],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "discovery_scope",
+ "type": "dropdown",
+ "label": "Discovery Scope: ",
+ "options": ["None", "link", "admin", "site", "organisation", "global"],
+ "default": "None",
+ "validation": [],
+ "transform": lambda x: "" if x == "None" else x.strip()
+ }
+ ]
+ },
+ ],
+ "I2PInterface": [
+ {
+ "config_key": "peers",
+ "type": "multilist",
+ "label": "Peers: ",
+ "placeholder": "",
+ "validation": ["required"],
+ "transform": lambda x: ",".join(x)
+ }
+ ],
+ "TCPServerInterface": [
+ {
+ "config_key": "listen_ip",
+ "type": "edit",
+ "label": "Listen IP: ",
+ "default": "",
+ "placeholder": "e.g., 0.0.0.0",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "listen_port",
+ "type": "edit",
+ "label": "Listen Port: ",
+ "default": "",
+ "placeholder": "e.g., 4242",
+ "validation": ["number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else None
+ },
+ {
+ "additional_options": [
+ {
+ "config_key": "prefer_ipv6",
+ "type": "checkbox",
+ "label": "Prefer IPv6?",
+ "default": False,
+ "validation": [],
+ "transform": lambda x: bool(x)
+ },
+ {
+ "config_key": "i2p_tunneled",
+ "type": "checkbox",
+ "label": "I2P Tunneled?",
+ "default": False,
+ "validation": [],
+ "transform": lambda x: bool(x)
+ },
+ {
+ "config_key": "device",
+ "type": "edit",
+ "label": "Device: ",
+ "placeholder": "A specific network device to listen on - e.g. eth0",
+ "default": "",
+ "validation": [],
+ "transform": lambda x: x.strip() if x.strip() else None
+ },
+ {
+ "config_key": "port",
+ "type": "edit",
+ "label": "Port: ",
+ "default": "",
+ "placeholder": "e.g., 4242",
+ "validation": ["number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else None
+ },
+ ]
+ }
+ ],
+ "TCPClientInterface": [
+ {
+ "config_key": "target_host",
+ "type": "edit",
+ "label": "Target Host: ",
+ "default": "",
+ "placeholder": "e.g., 127.0.0.1",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "target_port",
+ "type": "edit",
+ "label": "Target Port: ",
+ "default": "",
+ "placeholder": "e.g., 4242",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else None
+ },
+ {
+ "additional_options": [
+ {
+ "config_key": "i2p_tunneled",
+ "type": "checkbox",
+ "label": "I2P Tunneled?",
+ "default": False,
+ "validation": [],
+ "transform": lambda x: bool(x)
+ },
+ {
+ "config_key": "kiss_framing",
+ "type": "checkbox",
+ "label": "KISS Framing?",
+ "default": False,
+ "validation": [],
+ "transform": lambda x: bool(x)
+ }
+ ]
+ }
+ ],
+ "UDPInterface": [
+ {
+ "config_key": "listen_ip",
+ "type": "edit",
+ "label": "Listen IP: ",
+ "default": "",
+ "placeholder": "e.g., 0.0.0.0",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "listen_port",
+ "type": "edit",
+ "label": "Listen Port: ",
+ "default": "",
+ "placeholder": "e.g., 4242",
+ "validation": ["number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else None
+ },
+ {
+ "config_key": "forward_ip",
+ "type": "edit",
+ "label": "Forward IP: ",
+ "default": "",
+ "placeholder": "e.g., 255.255.255.255",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "forward_port",
+ "type": "edit",
+ "label": "Forward Port: ",
+ "default": "",
+ "placeholder": "e.g., 4242",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else None
+ },
+ {
+ "additional_options": [
+ {
+ "config_key": "device",
+ "type": "edit",
+ "label": "Device: ",
+ "placeholder": "A specific network device to listen on - e.g. eth0",
+ "default": "",
+ "validation": [],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "port",
+ "type": "edit",
+ "label": "Port: ",
+ "default": "",
+ "placeholder": "e.g., 4242",
+ "validation": ["number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else None
+ },
+ ]
+ }
+ ],
+ "RNodeInterface": [
+ get_port_field(),
+ {
+ "config_key": "frequency",
+ "type": "edit",
+ "label": "Frequency (MHz): ",
+ "default": "",
+ "placeholder": "868.5",
+ "validation": ["required", "float"],
+ "transform": lambda x: int(float(x.strip()) * 1000000) if x.strip() else 868500000
+ },
+ {
+ "config_key": "txpower",
+ "type": "edit",
+ "label": "Transmit Power (dBm): ",
+ "default": "",
+ "placeholder": "17",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip()) if x.strip() else 17
+ },
+ {
+ "config_key": "bandwidth",
+ "type": "dropdown",
+ "label": "Bandwidth (Hz): ",
+ "options": ["7800", "10400", "15600", "20800", "31250", "41700", "62500", "125000", "250000",
+ "500000", "1625000"],
+ "default": "7800",
+ "validation": ["required"],
+ "transform": lambda x: int(x)
+ },
+ {
+ "config_key": "spreadingfactor",
+ "type": "dropdown",
+ "label": "Spreading Factor: ",
+ "options": ["7", "8", "9", "10", "11", "12"],
+ "default": "7",
+ "validation": ["required"],
+ "transform": lambda x: int(x)
+ },
+ {
+ "config_key": "codingrate",
+ "type": "dropdown",
+ "label": "Coding Rate: ",
+ "options": ["4:5", "4:6", "4:7", "4:8"],
+ "default": "4:5",
+ "validation": ["required"],
+ "transform": lambda x: int(x.split(":")[1])
+ },
+ {
+ "additional_options": [
+ {
+ "config_key": "id_callsign",
+ "type": "edit",
+ "label": "Callsign: ",
+ "default": "",
+ "placeholder": "e.g. MYCALL-0",
+ "validation": [""],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "id_interval",
+ "type": "edit",
+ "label": "ID Interval (Seconds): ",
+ "placeholder": "e.g. 600",
+ "default": "",
+ "validation": ['number'],
+ "transform": lambda x: "" if x == "" else int(x)
+ },
+ {
+ "config_key": "airtime_limit_long",
+ "type": "edit",
+ "label": "Airtime Limit Long (Seconds): ",
+ "placeholder": "e.g. 1.5",
+ "default": "",
+ "validation": ['number'],
+ "transform": lambda x: "" if x == "" else int(x)
+ },
+ {
+ "config_key": "airtime_limit_short",
+ "type": "edit",
+ "label": "Airtime Limit Short (Seconds): ",
+ "placeholder": "e.g. 33",
+ "default": "",
+ "validation": ['number'],
+ "transform": lambda x: "" if x == "" else int(x)
+ },
+ ]
+ }
+ ],
+ "RNodeMultiInterface": [
+ get_port_field(),
+ {
+ "config_key": "subinterfaces",
+ "type": "multitable",
+ "fields": {
+ "frequency": {
+ "label": "Freq (Hz)",
+ "type": "edit",
+ "validation": ["required", "float"],
+ "transform": lambda x: int(x) if x else None
+ },
+ "bandwidth": {
+ "label": "BW (Hz)",
+ "type": "edit",
+ "options": ["7800", "10400", "15600", "20800", "31250", "41700", "62500", "125000", "250000", "500000", "1625000"],
+ "transform": lambda x: int(x) if x else None
+ },
+ "txpower": {
+ "label": "TX (dBm)",
+ "type": "edit",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x) if x else None
+ },
+ "vport": {
+ "label": "V.Port",
+ "type": "edit",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x) if x else None
+ },
+ "spreadingfactor": {
+ "label": "SF",
+ "type": "edit",
+ "transform": lambda x: int(x) if x else None
+ },
+ "codingrate": {
+ "label": "CR",
+ "type": "edit",
+ "transform": lambda x: int(x) if x else None
+ }
+ },
+ "validation": ["required"],
+ "transform": lambda x: x
+ },
+ {
+ "additional_options": [
+ {
+ "config_key": "id_callsign",
+ "type": "edit",
+ "label": "Callsign: ",
+ "default": "",
+ "placeholder": "e.g. MYCALL-0",
+ "validation": [""],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "id_interval",
+ "type": "edit",
+ "label": "ID Interval (Seconds): ",
+ "placeholder": "e.g. 600",
+ "default": "",
+ "validation": ['number'],
+ "transform": lambda x: "" if x == "" else int(x)
+ }
+ ]
+ }
+ ],
+ "SerialInterface": [
+ get_port_field(),
+ {
+ "config_key": "speed",
+ "type": "edit",
+ "label": "Speed (bps): ",
+ "default": "",
+ "placeholder": "e.g. 115200",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip())
+ },
+ {
+ "config_key": "databits",
+ "type": "edit",
+ "label": "Databits: ",
+ "default": "",
+ "placeholder": "e.g. 8",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip())
+ },
+ {
+ "config_key": "parity",
+ "type": "edit",
+ "label": "Parity: ",
+ "default": "",
+ "placeholder": "",
+ "validation": ["number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "stopbits",
+ "type": "edit",
+ "label": "Stopbits: ",
+ "default": "",
+ "placeholder": "e.g. 1",
+ "validation": ["number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ ],
+ "PipeInterface": [
+ {
+ "config_key": "command",
+ "type": "edit",
+ "label": "Command: ",
+ "default": "",
+ "placeholder": "e.g. netcat -l 5757",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "respawn_delay",
+ "type": "edit",
+ "label": "Respawn Delay (seconds): ",
+ "default": "",
+ "placeholder": "e.g. 5",
+ "validation": ["number"],
+ "transform": lambda x: x.strip()
+ },
+ ],
+ "KISSInterface": [
+ get_port_field(),
+ {
+ "config_key": "speed",
+ "type": "edit",
+ "label": "Speed (bps): ",
+ "default": "",
+ "placeholder": "e.g. 115200",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip())
+ },
+ {
+ "config_key": "databits",
+ "type": "edit",
+ "label": "Databits: ",
+ "default": "",
+ "placeholder": "e.g. 8",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip())
+ },
+ {
+ "config_key": "parity",
+ "type": "edit",
+ "label": "Parity: ",
+ "default": "",
+ "placeholder": "",
+ "validation": ["number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "stopbits",
+ "type": "edit",
+ "label": "Stopbits: ",
+ "default": "",
+ "placeholder": "e.g. 1",
+ "validation": ["number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "preamble",
+ "type": "edit",
+ "label": "Preamble (miliseconds): ",
+ "default": "",
+ "placeholder": "e.g. 150",
+ "validation": ["required", "number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "txtail",
+ "type": "edit",
+ "label": "TX Tail (miliseconds): ",
+ "default": "",
+ "placeholder": "e.g. 10",
+ "validation": ["required", "number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "slottime",
+ "type": "edit",
+ "label": "slottime (miliseconds): ",
+ "default": "",
+ "placeholder": "e.g. 20",
+ "validation": ["required", "number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "persistence",
+ "type": "edit",
+ "label": "Persistence (miliseconds): ",
+ "default": "",
+ "placeholder": "e.g. 200",
+ "validation": ["required", "number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "additional_options": [
+ {
+ "config_key": "id_callsign",
+ "type": "edit",
+ "label": "ID Callsign: ",
+ "default": "",
+ "placeholder": "e.g. MYCALL-0",
+ "validation": [""],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "id_interval",
+ "type": "edit",
+ "label": "ID Interval (Seconds): ",
+ "placeholder": "e.g. 600",
+ "default": "",
+ "validation": ['number'],
+ "transform": lambda x: "" if x == "" else int(x)
+ },
+ {
+ "config_key": "flow_control",
+ "type": "checkbox",
+ "label": "Flow Control ",
+ "validation": [],
+ "transform": lambda x: "" if x == "" else bool(x)
+ },
+ ]
+ }
+ ],
+ "AX25KISSInterface": [
+ get_port_field(),
+ {
+ "config_key": "callsign",
+ "type": "edit",
+ "label": "Callsign: ",
+ "default": "",
+ "placeholder": "e.g. NO1CLL",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "ssid",
+ "type": "edit",
+ "label": "SSID: ",
+ "default": "",
+ "placeholder": "e.g. 0",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "speed",
+ "type": "edit",
+ "label": "Speed (bps): ",
+ "default": "",
+ "placeholder": "e.g. 115200",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip())
+ },
+ {
+ "config_key": "databits",
+ "type": "edit",
+ "label": "Databits: ",
+ "default": "",
+ "placeholder": "e.g. 8",
+ "validation": ["required", "number"],
+ "transform": lambda x: int(x.strip())
+ },
+ {
+ "config_key": "parity",
+ "type": "edit",
+ "label": "Parity: ",
+ "default": "",
+ "placeholder": "",
+ "validation": ["number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "stopbits",
+ "type": "edit",
+ "label": "Stopbits: ",
+ "default": "",
+ "placeholder": "e.g. 1",
+ "validation": ["number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "preamble",
+ "type": "edit",
+ "label": "Preamble (miliseconds): ",
+ "default": "",
+ "placeholder": "e.g. 150",
+ "validation": ["required", "number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "txtail",
+ "type": "edit",
+ "label": "TX Tail (miliseconds): ",
+ "default": "",
+ "placeholder": "e.g. 10",
+ "validation": ["required", "number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "slottime",
+ "type": "edit",
+ "label": "Slottime (miliseconds): ",
+ "default": "",
+ "placeholder": "e.g. 20",
+ "validation": ["required", "number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "config_key": "persistence",
+ "type": "edit",
+ "label": "Persistence (miliseconds): ",
+ "default": "",
+ "placeholder": "e.g. 200",
+ "validation": ["required", "number"],
+ "transform": lambda x: "" if x == "" else int(x.strip())
+ },
+ {
+ "additional_options": [
+ {
+ "config_key": "flow_control",
+ "type": "checkbox",
+ "label": "Flow Control ",
+ "validation": [],
+ "transform": lambda x: "" if x == "" else bool(x)
+ },
+ ]
+ }
+ ],
+ "CustomInterface": [
+ {
+ "config_key": "type",
+ "type": "edit",
+ "label": "Interface Type: ",
+ "default": "",
+ "placeholder": "Name of custom interface class",
+ "validation": ["required"],
+ "transform": lambda x: x.strip()
+ },
+ {
+ "config_key": "custom_parameters",
+ "type": "keyvaluepairs",
+ "label": "Parameters: ",
+ "validation": [],
+ "transform": lambda x: x
+ },
+ ],
+ "default": [
+ {
+
+ },
+ ]
+}
+
+### INTERFACE WIDGETS ####
+class SelectableInterfaceItem(urwid.WidgetWrap):
+ def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None, profiles_label=None, announce_rate="N/A"):
+ self.parent = parent
+ self._selectable = True
+ self.icon = icon
+ self.name = name
+ self.is_connected = is_connected
+ self.is_enabled = is_enabled
+ self.iface_options = iface_options
+
+
+ if is_enabled:
+ enabled_txt = ("connected_status", "Enabled")
+ else:
+ enabled_txt = ("disabled_status", "Disabled")
+
+ connected_txt = connection_markup(parent.g, is_connected)
+
+ self.selection_txt = urwid.Text(" ")
+ self.title_widget = urwid.Text(("interface_title", f"{icon} {name}"))
+
+ title_content = urwid.Columns([
+ (4, self.selection_txt),
+ self.title_widget,
+ ])
+
+ self.tx_widget = urwid.Text(("value", format_bytes(tx)))
+ self.rx_widget = urwid.Text(("value", format_bytes(rx)))
+ self.announce_widget = urwid.Text(("value", announce_rate))
+
+ self.status_widget = urwid.Text(enabled_txt)
+ self.connection_widget = urwid.Text(connected_txt)
+
+ if is_enabled:
+ status_row = urwid.Columns([
+ (10, urwid.Text(("key", "Status: "))),
+ (10, self.status_widget),
+ (3, urwid.Text(" | ")),
+ self.connection_widget,
+ ])
+ else:
+ status_row = urwid.Columns([
+ (10, urwid.Text(("key", "Status: "))),
+ self.status_widget,
+ ])
+
+ rows = [
+ status_row,
+
+ urwid.Columns([
+ (10, urwid.Text(("key", "Type:"))),
+ urwid.Text(("value", iface_type)),
+ ]),
+ ]
+
+ if profiles_label is not None:
+ rows.append(urwid.Columns([
+ (10, urwid.Text(("key", "Profiles:"))),
+ urwid.Text(("value", profiles_label)),
+ ]))
+
+ if is_enabled:
+ rows.append(urwid.Divider("-"))
+ rows.append(urwid.Columns([
+ (5, urwid.Text(("key", "TX:"))),
+ (14, self.tx_widget),
+ (5, urwid.Text(("key", "RX:"))),
+ (14, self.rx_widget),
+ (12, urwid.Text(("key", "Announces:"))),
+ self.announce_widget,
+ ]))
+
+ pile_contents = [title_content] + rows
+
+ pile = urwid.Pile(pile_contents)
+
+ padded_body = urwid.Padding(pile, left=2, right=2)
+
+ box = urwid.LineBox(
+ padded_body,
+ title=None,
+ #todo
+ tlcorner="╭", tline="─",
+ trcorner="╮", lline="│",
+ rline="│", blcorner="╰",
+ bline="─", brcorner="╯"
+ )
+
+ super().__init__(box)
+
+ def update_status_display(self):
+ if self.is_enabled:
+ self.status_widget.set_text(("connected_status", "Enabled"))
+ else:
+ self.status_widget.set_text(("disabled_status", "Disabled"))
+
+ def selectable(self):
+ return True
+
+ def render(self, size, focus=False):
+ self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected'])
+
+ if focus:
+ self.title_widget.set_text(
+ ("interface_title_selected", f"{self.icon} {self.name}"))
+ else:
+ self.title_widget.set_text(("interface_title", f"{self.icon} {self.name}"))
+
+ return super().render(size, focus=focus)
+
+ def keypress(self, size, key):
+ if key == "up":
+ listbox = self.parent.box_adapter._original_widget
+ walker = listbox.body
+
+ interface_items = [i for i, item in enumerate(walker)
+ if isinstance(item, SelectableInterfaceItem)]
+
+ if interface_items and walker[listbox.focus_position] is self and \
+ listbox.focus_position == interface_items[0]:
+ self.parent.app.ui.main_display.frame.focus_position = "header"
+ return None
+ elif key == "enter":
+ self.parent.switch_to_show_interface(self.name)
+ return None
+ return key
+
+ def update_stats(self, tx, rx, announce_rate=None):
+ self.tx_widget.set_text(("value", format_bytes(tx)))
+ self.rx_widget.set_text(("value", format_bytes(rx)))
+ if announce_rate is not None:
+ self.announce_widget.set_text(("value", announce_rate))
+
+ def update_connection(self, is_connected):
+ if is_connected == self.is_connected:
+ return
+ self.is_connected = is_connected
+ self.connection_widget.set_text(connection_markup(self.parent.g, is_connected))
+
+class InterfaceOptionItem(urwid.WidgetWrap):
+ def __init__(self, parent_display, label, value, on_select=None):
+ self.parent_display = parent_display
+ self.label = label
+ self.value = value
+ self.on_select = on_select
+ self._selectable = True
+
+ text_widget = urwid.Text(label, align="left")
+ super().__init__(urwid.AttrMap(text_widget, "list_normal", focus_map="list_focus"))
+
+ def selectable(self):
+ return True
+
+ def _activate(self):
+ if self.on_select is not None:
+ self.on_select()
+ else:
+ self.parent_display.dismiss_dialog()
+ self.parent_display.switch_to_add_interface(self.value)
+
+ def keypress(self, size, key):
+ if key == "enter":
+ self._activate()
+ return None
+ return super().keypress(size, key)
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and urwid.util.is_mouse_press(event):
+ self._activate()
+ return True
+ return False
+
+class InterfaceBandwidthChart:
+
+ def __init__(self, history_length=60, glyphset="unicode"):
+ self.history_length = history_length
+ self.glyphset = glyphset
+ self.rx_rates = [0] * history_length
+ self.tx_rates = [0] * history_length
+
+ self.prev_rx = None
+ self.prev_tx = None
+ self.prev_time = None
+
+ self.max_rx_rate = 1
+ self.max_tx_rate = 1
+
+ self.first_update = True
+ self.initialization_complete = False
+ self.stabilization_updates = 2
+ self.update_count = 0
+
+ self.peak_rx_for_display = 0
+ self.peak_tx_for_display = 0
+
+ def update(self, rx_bytes, tx_bytes):
+ current_time = time.time()
+
+ if self.prev_rx is None or self.first_update:
+ self.prev_rx = rx_bytes
+ self.prev_tx = tx_bytes
+ self.prev_time = current_time
+ self.first_update = False
+ return
+
+ time_delta = max(0.1, current_time - self.prev_time)
+
+ rx_delta = max(0, rx_bytes - self.prev_rx) / time_delta
+ tx_delta = max(0, tx_bytes - self.prev_tx) / time_delta
+
+ self.prev_rx = rx_bytes
+ self.prev_tx = tx_bytes
+ self.prev_time = current_time
+
+ self.update_count += 1
+
+ self.rx_rates.pop(0)
+ self.tx_rates.pop(0)
+ self.rx_rates.append(rx_delta*8)
+ self.tx_rates.append(tx_delta*8)
+
+ if self.update_count >= self.stabilization_updates:
+ self.initialization_complete = True
+
+ self.peak_rx_for_display = max(self.peak_rx_for_display, rx_delta)
+ self.peak_tx_for_display = max(self.peak_tx_for_display, tx_delta)
+
+
+ current_rx_max = max(self.rx_rates)
+ current_tx_max = max(self.tx_rates)
+
+ self.max_rx_rate = max(1, current_rx_max)
+ self.max_tx_rate = max(1, current_tx_max)
+
+ def get_charts(self, height=8):
+ chart = AsciiChart(glyphset=self.glyphset)
+
+ rx_data = self.rx_rates.copy()
+ tx_data = self.tx_rates.copy()
+
+ peak_rx = self.peak_rx_for_display if self.initialization_complete else 0
+ peak_tx = self.peak_tx_for_display if self.initialization_complete else 0
+
+ peak_rx_str = RNS.prettyspeed(peak_rx*8)
+ peak_tx_str = RNS.prettyspeed(peak_tx*8)
+
+ rx_chart = chart.plot(
+ [rx_data],
+ {
+ 'height': height,
+ 'format': RNS.prettyspeed,
+ 'min': 0,
+ 'max': self.max_rx_rate * 1.1,
+ }
+ )
+
+ tx_chart = chart.plot(
+ [tx_data],
+ {
+ 'height': height,
+ 'format': RNS.prettyspeed,
+ 'min': 0,
+ 'max': self.max_tx_rate * 1.1,
+ }
+ )
+
+ return rx_chart, tx_chart, peak_rx_str, peak_tx_str
+
+
+class ResponsiveChartContainer(urwid.WidgetWrap):
+
+ def __init__(self, rx_box, tx_box, min_cols_for_horizontal=100):
+ self.rx_box = rx_box
+ self.tx_box = tx_box
+ self.min_cols_for_horizontal = min_cols_for_horizontal
+
+ self.horizontal_layout = urwid.Columns([
+ (urwid.WEIGHT, 1, self.rx_box),
+ (urwid.WEIGHT, 1, self.tx_box)
+ ])
+
+ self.vertical_layout = urwid.Pile([
+ self.rx_box,
+ self.tx_box
+ ])
+
+ self.layout = urwid.WidgetPlaceholder(self.horizontal_layout)
+
+ super().__init__(self.layout)
+
+ def render(self, size, focus=False):
+ maxcol = size[0] if len(size) > 0 else 0
+
+ if maxcol >= self.min_cols_for_horizontal and self.layout.original_widget is not self.horizontal_layout:
+ self.layout.original_widget = self.horizontal_layout
+ elif maxcol < self.min_cols_for_horizontal and self.layout.original_widget is not self.vertical_layout:
+ self.layout.original_widget = self.vertical_layout
+
+ return super().render(size, focus)
+
+### URWID FILLER ###
+class InterfaceFiller(urwid.WidgetWrap):
+ def __init__(self, widget, app):
+ self.app = app
+ self.filler = urwid.Filler(widget, urwid.TOP)
+ super().__init__(self.filler)
+
+ def keypress(self, size, key):
+ if key == "tab":
+ self.app.ui.main_display.sub_displays.interface_display.show_profiles()
+ return None
+ if key == "ctrl t":
+ self.app.ui.main_display.sub_displays.interface_display.toggle_view_mode()
+ return None
+ if key == "ctrl s":
+ self.app.ui.main_display.sub_displays.interface_display.save_current_profile()
+ return None
+ if key == "ctrl f":
+ self.app.ui.main_display.sub_displays.interface_display.cycle_filter()
+ return None
+ if key == "ctrl o":
+ self.app.ui.main_display.sub_displays.interface_display.toggle_focused_interface()
+ return None
+ if key == "ctrl b":
+ self.app.ui.main_display.sub_displays.interface_display.show_bulk_actions()
+ return None
+ if key == "ctrl a":
+ # add interface
+ self.app.ui.main_display.sub_displays.interface_display.add_interface()
+ return
+ elif key == "ctrl x":
+ # remove Interface
+ self.app.ui.main_display.sub_displays.interface_display.remove_selected_interface()
+ return
+ elif key == "ctrl e":
+ # edit interface
+ self.app.ui.main_display.sub_displays.interface_display.edit_selected_interface()
+ return None
+ elif key == "ctrl w":
+ # open config file editor
+ self.app.ui.main_display.sub_displays.interface_display.open_config_editor()
+ return None
+
+ return super().keypress(size, key)
+
+### VIEWS ###
+class AddInterfaceView(urwid.WidgetWrap):
+ def __init__(self, parent, iface_type):
+ self.parent = parent
+ self.iface_type = iface_type
+ self.fields = {}
+ self.port_pile = None
+ self.additional_fields = {}
+ self.additional_pile_contents = []
+ self.common_fields = {}
+
+ self.parent.shortcuts_display.set_add_interface_shortcuts()
+
+ name_field = FormEdit(
+ config_key="name",
+ placeholder="Enter interface name",
+ validation_types=["required"]
+ )
+ self.fields['name'] = {
+ 'label': "Name: ",
+ 'widget': name_field
+ }
+
+ config = INTERFACE_FIELDS.get(iface_type, INTERFACE_FIELDS["default"])
+ iface_fields = [field for field in config if "config_key" in field]
+
+ for field in iface_fields:
+ self._initialize_field(field)
+
+ self._initialize_additional_fields(config)
+
+ self._initialize_common_fields()
+
+ pile_items = self._build_form_layout(iface_fields)
+
+ form_pile = urwid.Pile(pile_items)
+ form_filler = urwid.Filler(form_pile, valign="top")
+ form_box = urwid.LineBox(
+ form_filler,
+ title="Add Interface",
+ tlcorner="╭", tline="─",
+ trcorner="╮", lline="│",
+ rline="│", blcorner="╰",
+ bline="─", brcorner="╯"
+ )
+
+ background = urwid.SolidFill(" ")
+ self.overlay = urwid.Overlay(
+ top_w=form_box,
+ bottom_w=background,
+ align='center',
+ width=('relative', 85),
+ valign='middle',
+ height=('relative', 85),
+ )
+ super().__init__(self.overlay)
+
+ def _initialize_field(self, field):
+ if field["type"] == "dropdown":
+ widget = FormDropdown(
+ config_key=field["config_key"],
+ label=field.get("label", ""),
+ options=field["options"],
+ default=field.get("default"),
+ validation_types=field.get("validation", []),
+ transform=field.get("transform")
+ )
+ elif field["type"] == "checkbox":
+ widget = FormCheckbox(
+ config_key=field["config_key"],
+ label=field.get("label", ""),
+ state=field.get("default", False),
+ validation_types=field.get("validation", []),
+ transform=field.get("transform")
+ )
+ elif field["type"] == "multilist":
+ widget = FormMultiList(
+ config_key=field["config_key"],
+ placeholder=field.get("placeholder", ""),
+ validation_types=field.get("validation", []),
+ transform=field.get("transform")
+ )
+ elif field["type"] == "multitable":
+ widget = FormMultiTable(
+ config_key=field["config_key"],
+ fields=field.get("fields", {}),
+ validation_types=field.get("validation", []),
+ transform=field.get("transform")
+ )
+ elif field["type"] == "keyvaluepairs":
+ widget = FormKeyValuePairs(
+ config_key=field["config_key"],
+ validation_types=field.get("validation", []),
+ transform=field.get("transform")
+ )
+ else:
+ widget = FormEdit(
+ config_key=field["config_key"],
+ caption="",
+ edit_text=field.get("default", ""),
+ placeholder=field.get("placeholder", ""),
+ validation_types=field.get("validation", []),
+ transform=field.get("transform")
+ )
+
+ label = field.get("label", "")
+ if not label:
+ label = " ".join(word.capitalize() for word in field["config_key"].split('_')) + ": "
+
+ self.fields[field["config_key"]] = {
+ 'label': label,
+ 'widget': widget
+ }
+
+ def _initialize_additional_fields(self, config):
+ for field in config:
+ if isinstance(field, dict) and "additional_options" in field:
+ for option in field["additional_options"]:
+ if option["type"] == "checkbox":
+ widget = FormCheckbox(
+ config_key=option["config_key"],
+ label=option.get("label", ""),
+ state=option.get("default", False),
+ validation_types=option.get("validation", []),
+ transform=option.get("transform")
+ )
+ elif option["type"] == "dropdown":
+ widget = FormDropdown(
+ config_key=option["config_key"],
+ label=option.get("label", ""),
+ options=option["options"],
+ default=option.get("default"),
+ validation_types=option.get("validation", []),
+ transform=option.get("transform")
+ )
+ elif option["type"] == "multilist":
+ widget = FormMultiList(
+ config_key=option["config_key"],
+ placeholder=option.get("placeholder", ""),
+ validation_types=option.get("validation", []),
+ transform=option.get("transform")
+ )
+ else:
+ widget = FormEdit(
+ config_key=option["config_key"],
+ caption="",
+ edit_text=str(option.get("default", "")),
+ placeholder=option.get("placeholder", ""),
+ validation_types=option.get("validation", []),
+ transform=option.get("transform")
+ )
+
+ label = option.get("label", "")
+ if not label:
+ label = " ".join(word.capitalize() for word in option["config_key"].split('_')) + ": "
+
+ self.additional_fields[option["config_key"]] = {
+ 'label': label,
+ 'widget': widget,
+ 'type': option["type"]
+ }
+
+ def _initialize_common_fields(self):
+ if self.parent.app.rns.transport_enabled():
+ # Transport mode options
+ COMMON_INTERFACE_OPTIONS.extend([
+ {
+ "config_key": "outgoing",
+ "type": "checkbox",
+ "label": "Allow outgoing traffic",
+ "default": True,
+ "validation": [],
+ "transform": lambda x: bool(x)
+ },
+ {
+ "config_key": "mode",
+ "type": "dropdown",
+ "label": "Interface Mode: ",
+ "options": ["full", "gateway", "access_point", "roaming", "boundary"],
+ "default": "full",
+ "validation": [],
+ "transform": lambda x: x
+ },
+ {
+ "config_key": "announce_cap",
+ "type": "edit",
+ "label": "Announce Cap: ",
+ "placeholder": "Default: 2.0",
+ "default": "",
+ "validation": ["float"],
+ "transform": lambda x: float(x) if x.strip() else 2.0
+ }
+ ])
+
+ for option in COMMON_INTERFACE_OPTIONS:
+ if option["type"] == "checkbox":
+ widget = FormCheckbox(
+ config_key=option["config_key"],
+ label=option["label"],
+ state=option.get("default", False),
+ validation_types=option.get("validation", []),
+ transform=option.get("transform")
+ )
+ elif option["type"] == "dropdown":
+ widget = FormDropdown(
+ config_key=option["config_key"],
+ label=option["label"],
+ options=option["options"],
+ default=option.get("default"),
+ validation_types=option.get("validation", []),
+ transform=option.get("transform")
+ )
+ else:
+ widget = FormEdit(
+ config_key=option["config_key"],
+ caption="",
+ edit_text=str(option.get("default", "")),
+ placeholder=option.get("placeholder", ""),
+ validation_types=option.get("validation", []),
+ transform=option.get("transform")
+ )
+
+ self.common_fields[option["config_key"]] = {
+ 'label': option["label"],
+ 'widget': widget,
+ 'type': option["type"]
+ }
+
+ def _on_rnode_field_change(self, widget, new_value):
+ if hasattr(self, 'rnode_calculator') and self.calculator_visible:
+ self.rnode_calculator.update_calculation()
+
+ def _build_form_layout(self, iface_fields):
+ pile_items = []
+ pile_items.append(urwid.Text(
+ ("form_title", f"Add new {_get_interface_icon(self.parent.glyphset, self.iface_type)} {self.iface_type}"),
+ align="center"))
+ pile_items.append(urwid.Divider("─"))
+
+ for key in ["name"] + [f["config_key"] for f in iface_fields]:
+ field = self.fields[key]
+ widget = field["widget"]
+
+ # Special case for multitable and keyvaluepairs - they already have their own layout
+ if isinstance(widget, (FormMultiTable, FormKeyValuePairs)):
+ pile_items.append(urwid.Text(("key", field["label"]), align="left"))
+ pile_items.append(widget)
+ pile_items.append(urwid.Padding(widget.error_widget, left=2))
+ continue
+
+ field_pile = urwid.Pile([
+ urwid.Columns([
+ (26, urwid.Text(("key", field["label"]), align="right")),
+ widget,
+ ]),
+ urwid.Padding(widget.error_widget, left=24)
+ ])
+
+ if self.iface_type in ["RNodeInterface", "RNodeMultiInterface", "SerialInterface", "AX25KISSInterface",
+ "KISSInterface"] and key == "port":
+ refresh_btn = urwid.Button("Refresh Ports", on_press=self.refresh_ports)
+ refresh_btn = urwid.AttrMap(refresh_btn, "button_normal", focus_map="button_focus")
+ refresh_row = urwid.Padding(refresh_btn, left=26, width=20)
+ field_pile.contents.append((refresh_row, field_pile.options()))
+ self.port_pile = field_pile
+
+ pile_items.append(field_pile)
+
+ self.more_options_visible = False
+ self.more_options_button = urwid.Button("Show more options", on_press=self.toggle_more_options)
+ self.more_options_button = urwid.AttrMap(self.more_options_button, "button_normal", focus_map="button_focus")
+ self.more_options_widget = urwid.Pile([])
+
+ self.ifac_options_visible = False
+ self.ifac_options_button = urwid.Button("Show IFAC options", on_press=self.toggle_ifac_options)
+ self.ifac_options_button = urwid.AttrMap(self.ifac_options_button, "button_normal", focus_map="button_focus")
+ self.ifac_options_widget = urwid.Pile([])
+
+ if self.iface_type in ["RNodeInterface"]:
+ self.calculator_button = urwid.Button("Show On-Air Calculations", on_press=self.toggle_calculator)
+ self.calculator_button = urwid.AttrMap(self.calculator_button, "button_normal", focus_map="button_focus")
+
+ save_btn = urwid.Button("Save", on_press=self.on_save)
+ back_btn = urwid.Button("Cancel", on_press=self.on_back)
+ button_row = urwid.Columns([
+ (urwid.WEIGHT, 0.45, save_btn),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, back_btn),
+ ])
+
+ pile_items.extend([
+ urwid.Divider(),
+ self.more_options_button,
+ self.more_options_widget,
+ ])
+ if self.iface_type in ["RNodeInterface"]:
+ self.rnode_calculator = RNodeCalculator(self)
+ self.calculator_visible = False
+ self.calculator_widget = urwid.Pile([])
+ pile_items.extend([
+ self.calculator_button,
+ self.calculator_widget,
+ ])
+ pile_items.extend(self._build_profiles_section())
+ pile_items.extend([
+ urwid.Divider("─"),
+ button_row,
+ ])
+
+ return pile_items
+
+ def _build_profiles_section(self):
+ self.profile_checkboxes = []
+ profiles = getattr(self.parent.app, "interface_profiles", None)
+ if profiles is None or not profiles.profiles:
+ return []
+ iface_name = getattr(self, "iface_name", None)
+ current = set()
+ if iface_name:
+ current = set(p["id"] for p in profiles.profiles_for(iface_name))
+ items = [
+ urwid.Divider("─"),
+ urwid.Text(("key", "Profiles"), align="left"),
+ urwid.Text(("inactive_text", " Assign this interface to one or more profiles."), align="left"),
+ ]
+ for p in profiles.profiles:
+ cb = urwid.CheckBox(p["name"] or "(unnamed)", state=(p["id"] in current))
+ cb.profile_id = p["id"]
+ self.profile_checkboxes.append(cb)
+ items.append(urwid.Padding(cb, left=2))
+ return items
+
+ def _apply_profile_membership(self, iface_name):
+ profiles = getattr(self.parent.app, "interface_profiles", None)
+ if profiles is None:
+ return
+ checked = [cb.profile_id for cb in getattr(self, "profile_checkboxes", []) if cb.get_state()]
+ try:
+ profiles.set_interface_profiles(iface_name, checked)
+ except Exception:
+ pass
+
+ def toggle_more_options(self, button):
+ if self.more_options_visible:
+ self.more_options_widget.contents = []
+ button.base_widget.set_label("Show more options")
+ self.more_options_visible = False
+ else:
+ pile_contents = []
+
+ if self.additional_fields:
+ for key, field in self.additional_fields.items():
+ widget = field['widget']
+
+ if field['type'] == "checkbox":
+ centered_widget = urwid.Columns([
+ ('weight', 1, urwid.Text("")),
+ ('pack', widget),
+ ('weight', 1, urwid.Text(""))
+ ])
+ field_pile = urwid.Pile([
+ centered_widget,
+ urwid.Padding(widget.error_widget, left=24)
+ ])
+ else:
+ field_pile = urwid.Pile([
+ urwid.Columns([
+ (26, urwid.Text(("key", field["label"]), align="right")),
+ widget
+ ]),
+ urwid.Padding(widget.error_widget, left=24)
+ ])
+
+ pile_contents.append(field_pile)
+
+ if self.additional_fields and self.common_fields:
+ pile_contents.append(urwid.Divider("─"))
+
+ if self.common_fields:
+ for key, field in self.common_fields.items():
+ widget = field['widget']
+
+ if field['type'] == "checkbox":
+ centered_widget = urwid.Columns([
+ ('weight', 1, urwid.Text("")),
+ ('pack', widget),
+ ('weight', 1, urwid.Text(""))
+ ])
+ field_pile = urwid.Pile([
+ centered_widget,
+ urwid.Padding(widget.error_widget, left=24)
+ ])
+ else:
+ field_pile = urwid.Pile([
+ urwid.Columns([
+ (26, urwid.Text(("key", field["label"]), align="right")),
+ widget
+ ]),
+ urwid.Padding(widget.error_widget, left=24)
+ ])
+
+ pile_contents.append(field_pile)
+
+ if pile_contents:
+ self.more_options_widget.contents = [(w, self.more_options_widget.options()) for w in pile_contents]
+ else:
+ self.more_options_widget.contents = [(
+ urwid.Text("No additional options available", align="center"),
+ self.more_options_widget.options()
+ )]
+
+ button.base_widget.set_label("Hide more options")
+ self.more_options_visible = True
+
+ def toggle_ifac_options(self, button):
+ if self.ifac_options_visible:
+ self.ifac_options_widget.contents = []
+ button.base_widget.set_label("Show IFAC options")
+ self.ifac_options_visible = False
+ else:
+ dummy = urwid.Text("IFAC (Interface Access Codes)", align="left")
+ self.ifac_options_widget.contents = [(dummy, self.more_options_widget.options())]
+ button.base_widget.set_label("Hide IFAC options")
+ self.ifac_options_visible = True
+
+ def toggle_calculator(self, button):
+ if self.calculator_visible:
+ self.calculator_widget.contents = []
+ button.base_widget.set_label("Show On-Air Calculations")
+ self.calculator_visible = False
+ else:
+ calculator_contents = [self.rnode_calculator]
+
+ self.calculator_widget.contents = [(w, self.calculator_widget.options()) for w in calculator_contents]
+
+ self.rnode_calculator.update_calculation()
+
+ button.base_widget.set_label("Hide On-Air Calculations")
+ self.calculator_visible = True
+
+ def refresh_ports(self, button):
+ if self.port_pile is not None:
+ # Get fresh port config
+ port_field = get_port_field()
+
+ if port_field["type"] == "dropdown":
+ widget = FormDropdown(
+ config_key=port_field["config_key"],
+ label=port_field["label"],
+ options=port_field["options"],
+ default=port_field.get("default"),
+ validation_types=port_field.get("validation", []),
+ transform=port_field.get("transform")
+ )
+ else:
+ widget = FormEdit(
+ config_key=port_field["config_key"],
+ caption="",
+ edit_text=port_field.get("default", ""),
+ placeholder=port_field.get("placeholder", ""),
+ validation_types=port_field.get("validation", []),
+ transform=port_field.get("transform")
+ )
+
+ self.fields["port"] = {
+ 'label': port_field["label"],
+ 'widget': widget
+ }
+
+ columns = urwid.Columns([
+ (26, urwid.Text(("key", port_field["label"]), align="right")),
+ widget
+ ])
+ self.port_pile.contents[0] = (columns, self.port_pile.options())
+
+ self.port_pile.contents[1] = (urwid.Padding(widget.error_widget, left=24), self.port_pile.options())
+
+ def validate_all(self):
+ all_valid = True
+
+ # validate main fields
+ for field in self.fields.values():
+ if not field["widget"].validate():
+ all_valid = False
+
+ # validate additional iface fields
+ for field in self.additional_fields.values():
+ if not field["widget"].validate():
+ all_valid = False
+
+ # validate common fields
+ for field in self.common_fields.values():
+ if not field["widget"].validate():
+ all_valid = False
+
+ return all_valid
+
+ def on_save(self, button):
+ all_valid = self.validate_all()
+
+ name = self.fields['name']["widget"].get_value() or "Untitled interface"
+
+ existing_interfaces = self.parent.app.rns.config['interfaces']
+ if name in existing_interfaces:
+ self.fields['name']["widget"].error = f"Interface name '{name}' already exists"
+ self.fields['name']["widget"].error_widget.set_text(("error", self.fields['name']["widget"].error))
+ all_valid = False
+
+ if not all_valid:
+ return
+
+ if self.iface_type == "CustomInterface":
+ custom_type = self.fields.get('type', {}).get('widget').get_value()
+ interface_config = {
+ "type": custom_type,
+ "interface_enabled": True
+ }
+ else:
+ interface_config = {
+ "type": self.iface_type,
+ "interface_enabled": True
+ }
+
+ for field_key, field in self.fields.items():
+ if field_key not in ["name", "custom_parameters", "type"]:
+ widget = field["widget"]
+ value = widget.get_value()
+
+ if field_key == "subinterfaces" and self.iface_type == "RNodeMultiInterface" and isinstance(value,
+ dict):
+ for subname, subconfig in value.items():
+ interface_config[f"{subname}"] = subconfig
+ elif value is not None and value != "":
+ interface_config[widget.config_key] = value
+
+ if self.iface_type == "CustomInterface" and "custom_parameters" in self.fields:
+ custom_params = self.fields["custom_parameters"]["widget"].get_value()
+ if isinstance(custom_params, dict):
+ for param_key, param_value in custom_params.items():
+ interface_config[param_key] = param_value
+
+ for field_key, field in self.additional_fields.items():
+ widget = field["widget"]
+ value = widget.get_value()
+ if value is not None and value != "":
+ interface_config[widget.config_key] = value
+
+ for field_key, field in self.common_fields.items():
+ widget = field["widget"]
+ value = widget.get_value()
+ if value is not None and value != "":
+ interface_config[widget.config_key] = value
+
+ try:
+ interfaces = self.parent.app.rns.config['interfaces']
+ interfaces[name] = interface_config
+ write_config_atomic(self.parent.app.rns.config)
+ self._apply_profile_membership(name)
+
+ display_type = custom_type if self.iface_type == "CustomInterface" else self.iface_type
+
+ new_item = SelectableInterfaceItem(
+ parent=self.parent,
+ name=name,
+ is_connected=False, # will always be false until restart
+ is_enabled=True,
+ iface_type=display_type,
+ tx=0,
+ rx=0,
+ icon=_get_interface_icon(self.parent.glyphset, display_type),
+ iface_options=interface_config
+ )
+
+ self.parent.interface_items.append(new_item)
+ self.parent._rebuild_list()
+ self.parent.mark_restart_pending()
+
+ self.show_message(f"Interface {name} added. Restart NomadNet to start using this interface")
+
+ except Exception as e:
+ print(f"Error saving interface: {str(e)}")
+ self.show_message(f"Error: {str(e)}", title="Error")
+
+ def on_back(self, button):
+ self.parent.switch_to_list()
+
+ def show_message(self, message, title="Notice"):
+ def dismiss_dialog(button):
+ self.parent.switch_to_list()
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(message, align="center"),
+ urwid.Divider(),
+ urwid.Button("OK", on_press=dismiss_dialog)
+ ]),
+ title=title
+ )
+
+ overlay = urwid.Overlay(
+ dialog,
+ self.parent.interfaces_display,
+ align='center',
+ width=100,
+ valign='middle',
+ height=8,
+ min_width=1,
+ min_height=1
+ )
+
+ self.parent.widget = overlay
+ self.parent.app.ui.main_display.update_active_sub_display()
+
+
+class EditInterfaceView(AddInterfaceView):
+ def __init__(self, parent, iface_name):
+ self.parent = parent
+ self.iface_name = iface_name
+
+ self.interface_config = parent.app.rns.config['interfaces'][iface_name]
+ config_type = self.interface_config.get("type", "Unknown")
+
+ # check if this is a custom interface type
+ known_types = list(INTERFACE_FIELDS.keys())
+ if config_type not in known_types:
+ self.original_type = config_type
+ self.iface_type = "CustomInterface"
+ else:
+ self.original_type = None
+ self.iface_type = config_type
+
+ super().__init__(parent, self.iface_type)
+
+ self.overlay.top_w.title_widget.set_text(f"Edit Interface: {iface_name}")
+
+ self._populate_form_fields()
+
+ def _populate_form_fields(self):
+ self.fields['name']['widget'].edit_text = self.iface_name
+
+ if self.original_type and self.iface_type == "CustomInterface":
+ if "type" in self.fields:
+ self.fields["type"]["widget"].edit_text = self.original_type
+
+ if "custom_parameters" in self.fields:
+ custom_params = {}
+ standard_keys = ["type", "interface_enabled", "enabled", "description",
+ "network_name", "bitrate", "passphrase", "ifac_size",
+ "mode", "outgoing", "announce_cap"]
+
+ for key, value in self.interface_config.items():
+ if key not in standard_keys:
+ custom_params[key] = value
+
+ self.fields["custom_parameters"]["widget"].set_value(custom_params)
+
+ for key, field in self.fields.items():
+ if key not in ['name', 'type', 'custom_parameters']:
+ widget = field['widget']
+
+ if key == "subinterfaces" and isinstance(widget, FormMultiTable):
+ self._populate_subinterfaces(widget)
+ continue
+
+ if key in self.interface_config:
+ value = self.interface_config[key]
+
+ if key == 'frequency':
+ value = float(value) / 1000000
+ value = f"{value:.6f}".rstrip('0').rstrip('.') if '.' in f"{value:.6f}" else f"{value}"
+
+ self._set_field_value(widget, value)
+
+ for key, field in self.additional_fields.items():
+ if key in self.interface_config:
+ self._set_field_value(field['widget'], self.interface_config[key])
+
+ for key, field in self.common_fields.items():
+ if key in self.interface_config:
+ self._set_field_value(field['widget'], self.interface_config[key])
+
+ def _set_field_value(self, widget, value):
+ if hasattr(widget, 'edit_text'):
+ widget.edit_text = str(value)
+ elif hasattr(widget, 'set_state'):
+ checkbox_state = value if isinstance(value, bool) else value.strip().lower() not in (
+ 'false', 'off', 'no', '0')
+ widget.set_state(checkbox_state)
+ elif isinstance(widget, FormDropdown):
+ str_value = str(value)
+ if str_value in widget.options:
+ widget.selected = str_value
+ widget.main_button.base_widget.set_text(str_value)
+ else:
+ # Try to match after transform
+ for opt in widget.options:
+ try:
+ if widget.transform(opt) == value:
+ widget.selected = opt
+ widget.main_button.base_widget.set_text(opt)
+ break
+ except:
+ pass
+ elif isinstance(widget, FormMultiList):
+ self._populate_multilist(widget, value)
+
+ def _populate_multilist(self, widget, value):
+ items = []
+ if isinstance(value, str):
+ items = [item.strip() for item in value.split(',') if item.strip()]
+ elif isinstance(value, list):
+ items = value
+
+ while len(widget.entries) > 1:
+ widget.remove_entry(None, widget.entries[-1])
+
+ if items:
+ first_entry = widget.entries[0]
+ first_edit = first_entry.contents[0][0]
+ if len(items) > 0:
+ first_edit.edit_text = items[0]
+
+ for i in range(1, len(items)):
+ widget.add_entry(None)
+ entry = widget.entries[i]
+ edit_widget = entry.contents[0][0]
+ edit_widget.edit_text = items[i]
+
+ def _populate_subinterfaces(self, widget):
+ subinterfaces = {}
+
+ for key, value in self.interface_config.items():
+ if isinstance(value, dict):
+ if key.startswith('[[[') and key.endswith(']]]'):
+ clean_key = key[3:-3] # removes [[[...]]]
+ subinterfaces[clean_key] = value
+ elif key not in ["type", "interface_enabled", "enabled", "port",
+ "id_callsign", "id_interval"]:
+ subinterfaces[key] = value
+
+ if subinterfaces:
+ widget.set_value(subinterfaces)
+
+ def on_save(self, button):
+ if not self.validate_all():
+ return
+
+ new_name = self.fields['name']["widget"].get_value() or self.iface_name
+
+ if new_name != self.iface_name and new_name in self.parent.app.rns.config['interfaces']:
+ self.fields['name']["widget"].error = f"Interface name '{new_name}' already exists"
+ self.fields['name']["widget"].error_widget.set_text(("error", self.fields['name']["widget"].error))
+ return
+
+ if self.iface_type == "CustomInterface":
+ interface_type = self.fields.get('type', {}).get('widget').get_value()
+ else:
+ interface_type = self.iface_type
+
+ updated_config = {
+ "type": interface_type,
+ "interface_enabled": self.interface_config.get("interface_enabled", True),
+ }
+ if "enabled" in self.interface_config:
+ updated_config["enabled"] = self.interface_config["enabled"]
+
+ for field_key, field in self.fields.items():
+ if field_key not in ["name", "custom_parameters", "type", "subinterfaces"]:
+ widget = field["widget"]
+ value = widget.get_value()
+ if value is not None and value != "":
+ updated_config[widget.config_key] = value
+
+ if self.iface_type == "CustomInterface" and "custom_parameters" in self.fields:
+ custom_params = self.fields["custom_parameters"]["widget"].get_value()
+ if isinstance(custom_params, dict):
+ for param_key, param_value in custom_params.items():
+ updated_config[param_key] = param_value
+
+ elif self.iface_type == "RNodeMultiInterface" and "subinterfaces" in self.fields:
+ subinterfaces = self.fields["subinterfaces"]["widget"].get_value()
+ for subname, subconfig in subinterfaces.items():
+ updated_config[subname] = subconfig
+
+ for field_key, field in self.additional_fields.items():
+ widget = field["widget"]
+ value = widget.get_value()
+ if value is not None and value != "":
+ updated_config[widget.config_key] = value
+
+ for field_key, field in self.common_fields.items():
+ widget = field["widget"]
+ value = widget.get_value()
+ if value is not None and value != "":
+ updated_config[widget.config_key] = value
+
+ try:
+ interfaces = self.parent.app.rns.config['interfaces']
+
+ if new_name != self.iface_name:
+ del interfaces[self.iface_name]
+ interfaces[new_name] = updated_config
+
+ try: self.parent.app.interface_profiles.rename_interface(self.iface_name, new_name)
+ except Exception: pass
+
+ for i, item in enumerate(self.parent.interface_items):
+ if item.name == self.iface_name:
+ self.parent.interface_items[i].name = new_name
+ break
+ else:
+ interfaces[self.iface_name] = updated_config
+
+ write_config_atomic(self.parent.app.rns.config)
+ self._apply_profile_membership(new_name)
+
+ display_type = interface_type
+
+ for item in self.parent.interface_items:
+ if item.name == new_name:
+ item.iface_type = display_type
+ break
+
+ self.parent._rebuild_list()
+ self.parent.mark_restart_pending()
+ self.show_message(f"Interface {new_name} updated. Restart NomadNet for these changes to take effect")
+
+ except Exception as e:
+ print(f"Error saving interface: {str(e)}")
+ self.show_message(f"Error updating interface: {str(e)}", title="Error")
+
+
+class ShowInterface(urwid.WidgetWrap):
+ def __init__(self, parent, iface_name):
+ self.parent = parent
+ self.iface_name = iface_name
+ self.started = False
+ self.g = self.parent.app.ui.glyphs
+
+ # get config
+ self.interface_config = self.parent.app.rns.config['interfaces'][iface_name]
+ iface_type = self.interface_config.get("type", "Unknown")
+
+ self.parent.shortcuts_display.set_show_interface_shortcuts()
+
+ self.config_rows = []
+
+ screen_cols, _ = _get_cols_rows()
+ margin = 22
+ if screen_cols >= 145:
+ self.history_length = screen_cols//2-margin
+ else:
+ self.history_length = screen_cols-(margin+2)
+
+ # get interface stats
+ interface_stats = self.parent.app.interface_stats
+ stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']}
+ self.stats = stats_lookup.get(iface_name, {})
+
+ self.tx = self.stats.get("txb", 0)
+ self.rx = self.stats.get("rxb", 0)
+ self.is_connected = self.stats.get("status", False)
+ self.is_enabled = (str(self.interface_config.get("enabled")).lower() != 'false' and
+ str(self.interface_config.get("interface_enabled")).lower() != 'false')
+
+ header_content = [
+ urwid.Text(("interface_title", f"Interface: {iface_name}"), align="center"),
+ urwid.Divider("=")
+ ]
+ header = urwid.Pile(header_content)
+
+ self.edit_button = urwid.Button("Edit", on_press=self.on_edit)
+ self.back_button = urwid.Button("Back", on_press=self.on_back)
+
+ self.toggle_button = urwid.Button(
+ "Disable" if self.is_enabled else "Enable",
+ on_press=self.on_toggle_enabled
+ )
+
+ button_row = urwid.Columns([
+ (urwid.WEIGHT, 0.3, self.back_button),
+ (urwid.WEIGHT, 0.05, urwid.Text("")),
+ (urwid.WEIGHT, 0.3, self.toggle_button),
+ (urwid.WEIGHT, 0.05, urwid.Text("")),
+ (urwid.WEIGHT, 0.3, self.edit_button),
+ ])
+
+ footer_content = [
+ urwid.Divider("="),
+ button_row
+ ]
+ footer = urwid.Pile(footer_content)
+
+ # status widgets
+ self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disabled_status",
+ "Enabled" if self.is_enabled else "Disabled"))
+
+ self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disabled_status",
+ self.parent.g['selected'] if self.is_enabled else self.parent.g[
+ 'unselected']))
+
+ self.connection_text = urwid.Text(connection_markup(self.parent.g, self.is_connected))
+
+ if self.is_enabled:
+ status_columns = urwid.Columns([
+ (10, urwid.Text(("key", "Status:"))),
+ (4, self.status_indicator),
+ (8, self.status_text),
+ (3, urwid.Text(" | ")),
+ self.connection_text,
+ ])
+ else:
+ status_columns = urwid.Columns([
+ (10, urwid.Text(("key", "Status:"))),
+ (4, self.status_indicator),
+ self.status_text,
+ ])
+
+ self.info_rows = [
+ urwid.Columns([
+ (10, urwid.Text(("key", "Type:"))),
+ urwid.Text(("value", f"{_get_interface_icon(self.parent.glyphset, iface_type)} {iface_type}")),
+ ]),
+ status_columns,
+ urwid.Divider("-")
+ ]
+
+ for row in self._build_statistics():
+ self.info_rows.append(row)
+ self.info_rows.append(urwid.Divider("-"))
+
+ self.bandwidth_chart = InterfaceBandwidthChart(history_length=self.history_length, glyphset=self.parent.glyphset)
+ self.bandwidth_chart.update(self.rx, self.tx)
+
+ self.rx_chart_text = urwid.Text("Loading RX data...", align='left')
+ self.tx_chart_text = urwid.Text("Loading TX data...", align='left')
+ self.rx_peak_text = urwid.Text("Peak: 0 B/s", align='right')
+ self.tx_peak_text = urwid.Text("Peak: 0 B/s", align='right')
+
+ self.rx_box = urwid.LineBox(
+ urwid.Pile([
+ urwid.AttrMap(self.rx_chart_text, "rx"),
+ self.rx_peak_text
+ ]),
+ title="RX Traffic (60s)"
+ )
+
+ self.tx_box = urwid.LineBox(
+ urwid.Pile([
+ urwid.AttrMap(self.tx_chart_text, "tx"),
+ self.tx_peak_text
+ ]),
+ title="TX Traffic (60s)"
+ )
+
+ self.horizontal_charts = urwid.Columns([
+ (urwid.WEIGHT, 1, self.rx_box),
+ (urwid.WEIGHT, 1, self.tx_box)
+ ])
+
+ self.vertical_charts = urwid.Pile([
+ self.rx_box,
+ self.tx_box
+ ])
+
+ self.disconnected_message = urwid.Filler(
+ urwid.Text(("disabled_status",
+ "Charts not available - Interface is not connected"),
+ align="center"),
+ valign="top"
+ )
+ self.disconnected_box = urwid.LineBox(self.disconnected_message, title="Bandwidth Charts")
+
+ self.charts_widget = self.vertical_charts
+ self.is_horizontal = False
+
+ screen_cols, _ = _get_cols_rows()
+ # RNS.log(screen_cols)
+ if screen_cols >= 145:
+ self.charts_widget = self.horizontal_charts
+ self.is_horizontal = True
+
+ if not self.is_connected:
+ self.charts_widget = self.disconnected_box
+
+ connection_params = []
+ radio_params = []
+ network_params = []
+ ifac_params = []
+ other_params = []
+
+ # Sort parameters into groups
+ for key, value in self.interface_config.items():
+ # skip empty
+ if value is None or value == "":
+ continue
+
+ # Skip these keys as their shown elsewhere
+ if key in ["type", "interface_enabled", "enabled", 'selected_interface_mode', 'name']:
+ continue
+
+ # Connection parameters
+ elif key in ["port", "listen_ip", "listen_port", "target_host", "target_port", "device"]:
+ connection_params.append((key, value))
+
+ # Radio parameters
+ elif key in ["frequency", "bandwidth", "spreadingfactor", "codingrate", "txpower"]:
+ radio_params.append((key, value))
+
+ # Network parameters
+ elif key in ["network_name", "bitrate", "peers", "group_id", "multicast_address_type",
+ "discovery_scope", "announce_cap", "mode"]:
+ network_params.append((key, value))
+
+ # IFAC parameters
+ elif key in ["passphrase", "ifac_size", "ifac_netname", "ifac_netkey"]:
+ ifac_params.append((key, value))
+
+ else:
+ other_params.append((key, value))
+
+ def create_param_row(key, value):
+ if isinstance(value, bool):
+ value_str = "Yes" if value else "No"
+ elif key == "frequency":
+ int_value = int(value)
+ value_str = f"{int_value / 1000000:.3f} MHz"
+ elif key == "bandwidth":
+ int_value = int(value)
+ value_str = f"{int_value / 1000:.1f} kHz"
+ elif key == "passphrase":
+ value_str = len(value)*"*"
+ else:
+ value_str = str(value)
+ # format display keys: "listen_port" => Listen Port
+ display_key = key.replace('_', ' ').title()
+
+ return urwid.Columns([
+ (18, urwid.Text(("key", f"{display_key}:"))),
+ urwid.Text(("value", value_str)),
+ ])
+
+ if connection_params:
+ connection_params.sort(key=lambda x: x[0])
+ self.config_rows.append(urwid.Text(("interface_title", "Connection Parameters"), align="left"))
+ for key, value in connection_params:
+ self.config_rows.append(create_param_row(key, value))
+ self.config_rows.append(urwid.Divider("-"))
+
+ if radio_params:
+ radio_params.sort(key=lambda x: x[0])
+ self.config_rows.append(urwid.Text(("interface_title", "Radio Parameters"), align="left"))
+ for key, value in radio_params:
+ self.config_rows.append(create_param_row(key, value))
+ self.config_rows.append(urwid.Divider("-"))
+
+ if network_params:
+ network_params.sort(key=lambda x: x[0])
+ self.config_rows.append(urwid.Text(("interface_title", "Network Parameters"), align="left"))
+ for key, value in network_params:
+ self.config_rows.append(create_param_row(key, value))
+ self.config_rows.append(urwid.Divider("-"))
+
+ if ifac_params:
+ ifac_params.sort(key=lambda x: x[0])
+ self.config_rows.append(urwid.Text(("interface_title", "IFAC Parameters"), align="left"))
+ for key, value in ifac_params:
+ self.config_rows.append(create_param_row(key, value))
+ self.config_rows.append(urwid.Divider("-"))
+
+ if other_params:
+ other_params.sort(key=lambda x: x[0])
+ self.config_rows.append(urwid.Text(("interface_title", "Additional Parameters"), align="left"))
+ for key, value in other_params:
+ self.config_rows.append(create_param_row(key, value))
+ self.config_rows.append(urwid.Divider("-"))
+
+ if not self.config_rows:
+ self.config_rows.append(urwid.Text("No additional parameters", align="center"))
+
+ body_content = []
+ body_content.extend(self.info_rows)
+ body_content.append(
+ self.charts_widget)
+ body_content.append(urwid.Divider("-"))
+ body_content.extend(self.config_rows)
+
+ body_pile = urwid.Pile(body_content)
+ body_padding = urwid.Padding(body_pile, left=2, right=2)
+
+ body = urwid.ListBox(urwid.SimpleListWalker([body_padding]))
+
+ self.frame = urwid.Frame(
+ body=body,
+ header=header,
+ footer=footer
+ )
+
+ self.content_box = urwid.LineBox(self.frame)
+
+ super().__init__(self.content_box)
+
+ def _stat_rows_spec(self, stats):
+ # Ordered (row_id, label) pairs that apply to this interface
+ spec = [("mode", "Mode:")]
+ if stats.get("bitrate") is not None:
+ spec.append(("bitrate", "Bitrate:"))
+ spec.append(("tx", "TX:"))
+ spec.append(("rx", "RX:"))
+ if "rxs" in stats and "txs" in stats:
+ spec.append(("speed", "Speed:"))
+ if stats.get("incoming_announce_frequency") is not None:
+ spec.append(("announce", "Announces:"))
+ if stats.get("incoming_pr_frequency") is not None:
+ spec.append(("pathreq", "Path Reqs:"))
+ if stats.get("clients") is not None:
+ spec.append(("clients", "Clients:"))
+ if stats.get("peers") is not None:
+ spec.append(("peers", "Peers:"))
+ if stats.get("ifac_netname") is not None or stats.get("ifac_signature") is not None:
+ spec.append(("ifac", "Network:"))
+ if stats.get("announce_queue") is not None:
+ spec.append(("queued", "Queued:"))
+ if "held_announces" in stats:
+ spec.append(("held", "Held:"))
+ return spec
+
+ def _radio_rows_spec(self, stats):
+ spec = []
+ if "channel_load_short" in stats and "channel_load_long" in stats:
+ spec.append(("chload", "Ch. Load:"))
+ if "airtime_short" in stats and "airtime_long" in stats:
+ spec.append(("airtime", "Airtime:"))
+ if "noise_floor" in stats:
+ spec.append(("noise", "Noise Fl.:"))
+ if stats.get("battery_percent") is not None:
+ spec.append(("battery", "Battery:"))
+ if "cpu_load" in stats or "cpu_temp" in stats:
+ spec.append(("cpu", "CPU:"))
+ if "mem_load" in stats:
+ spec.append(("mem", "Memory:"))
+ return spec
+
+ def _fmt_pct(self, v):
+ try:
+ v = float(v)
+ except (TypeError, ValueError):
+ return "0"
+ if v <= 1.0:
+ v *= 100.0
+ return "%d" % int(round(v))
+
+ def _fmt_pct_pair(self, short_v, long_v):
+ return "%s%% (15s), %s%% (1h)" % (self._fmt_pct(short_v), self._fmt_pct(long_v))
+
+ def _format_stat(self, row_id, stats):
+ if row_id == "mode":
+ return interface_mode_str(stats.get("mode"))
+ if row_id == "bitrate":
+ return format_speed(stats.get("bitrate"))
+ if row_id == "tx":
+ return format_bytes(stats.get("txb", 0))
+ if row_id == "rx":
+ return format_bytes(stats.get("rxb", 0))
+ if row_id == "speed":
+ return "TX %s RX %s" % (format_speed(stats.get("txs", 0)), format_speed(stats.get("rxs", 0)))
+ if row_id == "announce":
+ return "In %s Out %s" % (format_announce_rate(stats.get("incoming_announce_frequency")),
+ format_announce_rate(stats.get("outgoing_announce_frequency")))
+ if row_id == "pathreq":
+ return "In %s Out %s" % (format_announce_rate(stats.get("incoming_pr_frequency")),
+ format_announce_rate(stats.get("outgoing_pr_frequency")))
+ if row_id == "clients":
+ return str(stats.get("clients"))
+ if row_id == "peers":
+ return "%s reachable" % stats.get("peers")
+ if row_id == "ifac":
+ netname = stats.get("ifac_netname")
+ size = stats.get("ifac_size")
+ bits = ("%d-bit IFAC" % (size * 8)) if size else "IFAC"
+ return ("%s (%s)" % (netname, bits)) if netname else bits
+ if row_id == "queued":
+ n = stats.get("announce_queue") or 0
+ return "%d announce%s" % (n, "" if n == 1 else "s")
+ if row_id == "held":
+ n = stats.get("held_announces") or 0
+ return "%d announce%s" % (n, "" if n == 1 else "s")
+ if row_id == "chload":
+ return self._fmt_pct_pair(stats.get("channel_load_short"), stats.get("channel_load_long"))
+ if row_id == "airtime":
+ return self._fmt_pct_pair(stats.get("airtime_short"), stats.get("airtime_long"))
+ if row_id == "noise":
+ nf = stats.get("noise_floor")
+ return ("%s dBm" % nf) if nf is not None else "Unknown"
+ if row_id == "battery":
+ try:
+ return "%d%% (%s)" % (int(stats.get("battery_percent")), stats.get("battery_state", "Unknown"))
+ except (TypeError, ValueError):
+ return "Unknown"
+ if row_id == "cpu":
+ load = stats.get("cpu_load")
+ temp = stats.get("cpu_temp")
+ parts = []
+ if load is not None:
+ parts.append("%s%% load" % load)
+ if temp is not None:
+ parts.append("%s°C" % temp)
+ return ", ".join(parts) if parts else "Unknown"
+ if row_id == "mem":
+ m = stats.get("mem_load")
+ return ("%s%%" % m) if m is not None else "Unknown"
+ return ""
+
+ def _build_statistics(self):
+ # Builds the live statistics section and records the dynamic widgets in
+ # self.stat_w so they can be refreshed in place each poll tick.
+ self.stat_w = {}
+
+ def stat_row(row_id, label):
+ w = urwid.Text(("value", self._format_stat(row_id, self.stats)))
+ self.stat_w[row_id] = w
+ return urwid.Columns([(12, urwid.Text(("key", label))), w])
+
+ rows = [urwid.Text(("interface_title", "Statistics"), align="left")]
+ for row_id, label in self._stat_rows_spec(self.stats):
+ rows.append(stat_row(row_id, label))
+
+ radio = self._radio_rows_spec(self.stats)
+ if radio:
+ rows.append(urwid.Divider("-"))
+ rows.append(urwid.Text(("interface_title", "Radio"), align="left"))
+ for row_id, label in radio:
+ rows.append(stat_row(row_id, label))
+
+ return rows
+
+ def _refresh_stats(self, stats):
+ for row_id, w in self.stat_w.items():
+ try:
+ w.set_text(("value", self._format_stat(row_id, stats)))
+ except Exception:
+ pass
+
+ def update_status_display(self):
+ if self.is_enabled:
+ self.status_indicator.set_text(("connected_status", self.parent.g['selected']))
+ self.status_text.set_text(("connected_status", "Enabled"))
+ else:
+ self.status_indicator.set_text(("disabled_status", self.parent.g['unselected']))
+ self.status_text.set_text(("disabled_status", "Disabled"))
+
+ def update_connection_display(self, is_connected):
+ old_connection_state = self.is_connected
+ self.is_connected = is_connected
+
+ self.connection_text.set_text(connection_markup(self.parent.g, self.is_connected))
+
+ if old_connection_state != self.is_connected:
+ body_pile = self.frame.body.body[0].original_widget
+
+ chart_index = None
+ for i, (widget, options) in enumerate(body_pile.contents):
+ if (widget == self.horizontal_charts or
+ widget == self.vertical_charts or
+ widget == self.disconnected_box):
+ chart_index = i
+ break
+
+ if chart_index is not None:
+ if self.is_connected:
+ new_widget = self.horizontal_charts if self.is_horizontal else self.vertical_charts
+ else:
+ new_widget = self.disconnected_box
+
+ body_pile.contents[chart_index] = (new_widget, body_pile.options())
+
+ def on_toggle_enabled(self, button):
+ self.is_enabled = not self.is_enabled
+
+ self.toggle_button.set_label("Disable" if self.is_enabled else "Enable")
+
+ self.interface_config["interface_enabled"] = self.is_enabled
+ if "enabled" in self.interface_config:
+ self.interface_config["enabled"] = self.is_enabled
+
+ try:
+ interfaces = self.parent.app.rns.config['interfaces']
+ interfaces[self.iface_name] = self.interface_config
+ write_config_atomic(self.parent.app.rns.config)
+
+ self.update_status_display()
+
+ for item in self.parent.interface_items:
+ if item.name == self.iface_name:
+ item.is_enabled = self.is_enabled
+ item.update_status_display()
+
+ if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None:
+ self.parent.app.ui.loop.draw_screen()
+
+ self.parent.mark_restart_pending()
+
+ except Exception as e:
+ self.show_error_message(f"Error updating interface: {str(e)}")
+
+ def show_restart_required_message(self):
+
+ def dismiss_dialog(button):
+ self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(
+ f"Interface {self.iface_name} has been " +
+ ("enabled" if self.is_enabled else "disabled") +
+ ".\nRestart required for changes to take effect.",
+ align="center"
+ ),
+ urwid.Divider(),
+ urwid.Button("OK", on_press=dismiss_dialog)
+ ]),
+ title="Notice"
+ )
+
+ overlay = urwid.Overlay(
+ dialog,
+ self.parent.app.ui.main_display.frame.body,
+ align='center',
+ width=50,
+ valign='middle',
+ height=8
+ )
+
+ self.parent.app.ui.main_display.frame.body = overlay
+
+ def show_error_message(self, message):
+
+ def dismiss_dialog(button):
+ self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(message, align="center"),
+ urwid.Divider(),
+ urwid.Button("OK", on_press=dismiss_dialog)
+ ]),
+ title="Error"
+ )
+
+ overlay = urwid.Overlay(
+ dialog,
+ self.parent.app.ui.main_display.frame.body,
+ align='center',
+ width=50,
+ valign='middle',
+ height=8
+ )
+
+ self.parent.app.ui.main_display.frame.body = overlay
+
+ def keypress(self, size, key):
+ if key == 'tab':
+ if self.frame.focus_position == 'body':
+ self.frame.focus_position = 'footer'
+ footer_pile = self.frame.footer
+ if isinstance(footer_pile, urwid.Pile):
+ footer_pile.focus_position = 1 # button row
+ button_row = footer_pile.contents[1][0]
+ if isinstance(button_row, urwid.Columns):
+ button_row.focus_position = 0
+ return None
+ elif self.frame.focus_position == 'footer':
+ footer_pile = self.frame.footer
+ if isinstance(footer_pile, urwid.Pile):
+ button_row = footer_pile.contents[1][0]
+ if isinstance(button_row, urwid.Columns):
+ # If on first button (Back), move to Toggle button
+ if button_row.focus_position == 0:
+ button_row.focus_position = 2 # skip spacer
+ return None
+ # If on toggle button, move to Edit button
+ elif button_row.focus_position == 2:
+ button_row.focus_position = 4
+ return None
+ # if on edit button wrap back to toggle button
+ elif button_row.focus_position == 4:
+ button_row.focus_position = 0
+ return None
+ elif key == 'shift tab':
+ if self.frame.focus_position == 'footer':
+ self.frame.focus_position = 'body'
+ return None
+ elif self.frame.focus_position == 'footer':
+ footer_pile = self.frame.footer
+ if isinstance(footer_pile, urwid.Pile):
+ button_row = footer_pile.contents[1][0]
+ if isinstance(button_row, urwid.Columns):
+ if button_row.focus_position == 4: # edit button
+ button_row.focus_position = 2 # toggle button
+ return None
+ elif button_row.focus_position == 2:
+ button_row.focus_position = 0 # back button
+ return None
+ elif button_row.focus_position == 0: # back button
+ self.frame.focus_position = 'body'
+ return None
+ elif key == 'down':
+ if self.frame.focus_position == 'body':
+ result = super().keypress(size, key)
+ # if the key wasn't consumed, we're at the bottom
+ if result == 'down':
+ self.frame.focus_position = 'footer'
+ footer_pile = self.frame.footer
+ if isinstance(footer_pile, urwid.Pile):
+ footer_pile.focus_position = 1 # button row
+ button_row = footer_pile.contents[1][0]
+ if isinstance(button_row, urwid.Columns):
+ button_row.focus_position = 0 # focus on back button
+ return None
+ return result
+ elif key == 'up':
+ if self.frame.focus_position == 'footer':
+ self.frame.focus_position = 'body'
+ listbox = self.frame.body
+ if hasattr(listbox, 'body') and len(listbox.body) > 0:
+ listbox.focus_position = len(listbox.body) - 1
+ return None
+ elif self.frame.focus_position == 'body':
+ result = super().keypress(size, key)
+ # if the key wasn't consumed, we're at the top
+ if result == 'up':
+ pass
+ return result
+ elif key == "h" and self.is_connected: # horizontal layout
+ if not self.is_horizontal:
+ self.switch_to_horizontal()
+ return None
+ elif key == "v" and self.is_connected: # vertical layout
+ if self.is_horizontal:
+ self.switch_to_vertical()
+ return None
+
+ return super().keypress(size, key)
+
+ def switch_to_horizontal(self):
+ if not self.is_connected:
+ return
+
+ self.is_horizontal = True
+
+ body_pile = self.frame.body.body[0].original_widget
+ for i, (widget, options) in enumerate(body_pile.contents):
+ if widget == self.vertical_charts:
+ body_pile.contents[i] = (self.horizontal_charts, options)
+ self.charts_widget = self.horizontal_charts
+ break
+
+ def switch_to_vertical(self):
+ if not self.is_connected:
+ return
+
+ self.is_horizontal = False
+ body_pile = self.frame.body.body[0].original_widget
+ for i, (widget, options) in enumerate(body_pile.contents):
+ if widget == self.horizontal_charts:
+ body_pile.contents[i] = (self.vertical_charts, options)
+ self.charts_widget = self.vertical_charts
+ break
+
+ def start(self):
+ # Poll while the detail view is open, regardless of connection state, so
+ # that the connection indicator and live stats keep refreshing and a
+ # dropped link can be seen to come back up.
+ if not self.started:
+ self.started = True
+ self.parent.app.ui.loop.set_alarm_in(1, self.update_bandwidth_charts)
+
+ def _show_disconnect_overlay(self):
+ # Uses its own overlay object so it never collides with the interface
+ # list's disconnect overlay handling while both poll loops are running.
+ if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay:
+ return
+ dialog_text = urwid.Pile([
+ urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"),
+ urwid.Text("Waiting to Reconnect...", align="center"),
+ ])
+ dialog_box = urwid.LineBox(urwid.Filler(dialog_text))
+ self._disconnect_overlay = urwid.Overlay(
+ dialog_box, self, align='center', width=35, valign='middle', height=4)
+ self.parent.widget = self._disconnect_overlay
+ self.parent.app.ui.main_display.update_active_sub_display()
+
+ def update_bandwidth_charts(self, loop, user_data):
+ if not self.started:
+ return
+
+ try:
+ # The interface list's poll loop owns the actual stats fetch; when the
+ # shared RNS instance drops it flags _rns_disconnected, which we surface
+ # here too instead of silently charting stale data.
+ if getattr(self.parent, "_rns_disconnected", False):
+ self._show_disconnect_overlay()
+ else:
+ if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay:
+ self.parent.widget = self
+ self.parent.app.ui.main_display.update_active_sub_display()
+
+ interface_stats = self.parent.app.interface_stats
+ stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']}
+ stats = stats_lookup.get(self.iface_name, {})
+
+ tx = stats.get("txb", self.tx)
+ rx = stats.get("rxb", self.rx)
+
+ new_connection_status = stats.get("status", False)
+ if new_connection_status != self.is_connected:
+ self.update_connection_display(new_connection_status)
+
+ self._refresh_stats(stats)
+
+ if self.is_connected:
+ self.bandwidth_chart.update(rx, tx)
+
+ rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8)
+
+ self.rx_chart_text.set_text(rx_chart)
+ self.tx_chart_text.set_text(tx_chart)
+ self.rx_peak_text.set_text(f"Peak: {peak_rx}")
+ self.tx_peak_text.set_text(f"Peak: {peak_tx}")
+
+ self.tx = tx
+ self.rx = rx
+ except Exception as e:
+ self._show_disconnect_overlay()
+ finally:
+ if self.started:
+ loop.set_alarm_in(1, self.update_bandwidth_charts)
+
+ def on_back(self, button):
+ self.started = False
+ self.parent.switch_to_list()
+
+ def on_edit(self, button):
+ self.started = False
+ self.parent.switch_to_edit_interface(self.iface_name)
+
+class InterfaceTile(urwid.WidgetWrap):
+ def __init__(self, parent, name, is_connected, is_enabled, iface_type, icon, announce_rate="0"):
+ self.parent = parent
+ self.name = name
+ self.icon = icon
+ self.g = parent.g
+ self.dot_style = "connected_status" if is_enabled else "disabled_status"
+ self.dot = self.g['selected'] if is_enabled else self.g['unselected']
+ self.title_widget = urwid.Text("", wrap="clip")
+ typ = urwid.Text(("value", iface_type), wrap="clip")
+ if is_enabled:
+ status = urwid.Text([
+ (self.dot_style, "Enabled"),
+ ("value", f" {self.g['sep_dot']} "),
+ connection_markup(self.g, is_connected, "Conn." if is_connected else "Disc."),
+ ], wrap="clip")
+ else:
+ status = urwid.Text([(self.dot_style, "Disabled")], wrap="clip")
+ inner_items = [self.title_widget, typ, status]
+ if is_enabled:
+ inner_items.append(urwid.Text([("key", "Announces "), ("value", announce_rate)], wrap="clip"))
+ inner = urwid.Pile(inner_items)
+ self.box = urwid.LineBox(
+ urwid.Padding(inner, left=1, right=1),
+ tlcorner="╭", trcorner="╮", blcorner="╰", brcorner="╯",
+ )
+ self.attr = urwid.AttrMap(self.box, None, focus_map={None: "interface_tile_focus"})
+ super().__init__(self.attr)
+ self._render_title(False)
+
+ def _render_title(self, focus):
+ arrow = self.g['focus_arrow'] if focus else " "
+ style = "interface_tile_focus" if focus else "interface_title"
+ self.title_widget.set_text([(self.dot_style, self.dot), (style, f" {arrow} {self.icon} {self.name}")])
+
+ def selectable(self):
+ return True
+
+ def render(self, size, focus=False):
+ self._render_title(focus)
+ return super().render(size, focus)
+
+ def keypress(self, size, key):
+ if key == "enter":
+ self.parent.switch_to_show_interface(self.name)
+ return None
+ return key
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and urwid.util.is_mouse_press(event):
+ self.parent.switch_to_show_interface(self.name)
+ return True
+ return False
+
+
+### PROFILE VIEWS ###
+class ProfileListRow(urwid.WidgetWrap):
+ def __init__(self, view, pid, label, selected):
+ self.view = view
+ self.pid = pid
+ self.label = label
+ self.selected = selected
+ self.g = view.parent.g
+ self.text_widget = urwid.Text("")
+ super().__init__(urwid.AttrMap(self.text_widget, None))
+ self._render_row(False)
+
+ def _render_row(self, focus):
+ mark = self.g['selected'] if self.selected else self.g['unselected']
+ arrow = self.g['focus_arrow'] if focus else " "
+ if focus:
+ style = "interface_tile_focus"
+ elif self.selected:
+ style = "connected_status"
+ else:
+ style = "interface_title"
+ self.text_widget.set_text((style, f" {arrow} {mark} {self.label}"))
+
+ def selectable(self):
+ return True
+
+ def render(self, size, focus=False):
+ self._render_row(focus)
+ return super().render(size, focus)
+
+ def keypress(self, size, key):
+ if key in (" ", "enter"):
+ if self.pid is not None:
+ self.view.select(self.pid)
+ return None
+ return key
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and urwid.util.is_mouse_press(event):
+ if self.pid is not None:
+ self.view.select(self.pid)
+ return True
+ return False
+
+
+class ProfilesView(urwid.WidgetWrap):
+ def __init__(self, parent):
+ self.parent = parent
+ self.app = parent.app
+ self.profiles = parent.app.interface_profiles
+ self.profiles.prune()
+ self.status = urwid.Text("", align="center")
+ self.walker = urwid.SimpleFocusListWalker([])
+ self.listbox = urwid.ListBox(self.walker)
+ body_rows = max(3, parent.list_rows - 3)
+ self.pile = urwid.Pile([
+ ('pack', parent._make_tab_bar("profiles")),
+ ('pack', self.status),
+ ('pack', urwid.Divider()),
+ urwid.BoxAdapter(self.listbox, body_rows),
+ ])
+ super().__init__(urwid.Filler(self.pile, urwid.TOP))
+ self.refresh()
+
+ def refresh(self, focus_pid="__keep__"):
+ prev = None
+ try:
+ w, _ = self.listbox.body.get_focus()
+ prev = getattr(w, "pid", None)
+ except Exception:
+ pass
+ self.profiles.update_default_if_custom()
+ active = self.profiles.active_profile_id()
+ items = [ProfileListRow(self, "__default__", "Default", active is None)]
+ for p in self.profiles.profiles:
+ count = self.profiles.member_count(p)
+ label = f"{p['name'] or '(unnamed)'} ({count} interface{'' if count == 1 else 's'})"
+ items.append(ProfileListRow(self, p["id"], label, p["id"] == active))
+ if len(self.profiles.profiles) == 0:
+ items.append(urwid.Text("\n No profiles yet. Press Ctrl + A to create one.", align="left"))
+ self.walker[:] = items
+ target = focus_pid if focus_pid != "__keep__" else prev
+ for i, w in enumerate(self.walker):
+ if getattr(w, "pid", "__x__") == target:
+ try: self.listbox.focus_position = i
+ except Exception: pass
+ break
+
+ def _focused(self):
+ try:
+ w, _ = self.listbox.body.get_focus()
+ return w if isinstance(w, ProfileListRow) else None
+ except Exception:
+ return None
+
+ def select(self, pid):
+ if pid == "__default__":
+ if self.profiles.default_members is None:
+ return
+ count = len(self.profiles.default_members)
+ title = "Switch to Default"
+ msg = "Restore your saved manual setup?\nThis enables %d interface%s and disables the rest." % (count, "" if count == 1 else "s")
+ else:
+ p = self.profiles.get(pid)
+ if p is None:
+ return
+ count = self.profiles.member_count(p)
+ title = "Switch Profile"
+ msg = "Switch to profile '%s'?\nThis enables its %d interface%s and disables the rest." % (p["name"] or "(unnamed)", count, "" if count == 1 else "s")
+
+ def yes(_b):
+ self.dismiss_dialog()
+ if pid == "__default__":
+ self.profiles.select_default()
+ else:
+ self.profiles.select_profile(pid)
+ self.parent.mark_restart_pending()
+ self.refresh(focus_pid=pid)
+ self.status.set_text(("connected_status", "Restart NomadNet for changes to take effect"))
+
+ def no(_b):
+ self.dismiss_dialog()
+
+ pile = urwid.Pile([
+ urwid.Text(msg, align="center"),
+ urwid.Divider(),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)),
+ ]),
+ ])
+ self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title), height=10)
+
+ def _overlay(self, dialog, height=8, width=50):
+ frame = self.app.ui.main_display.frame
+ frame.body = urwid.Overlay(dialog, self, align="center", width=width,
+ valign="middle", height=height, min_width=20)
+
+ def dismiss_dialog(self):
+ self.app.ui.main_display.frame.body = self
+
+ def new_profile(self):
+ edit = ReadlineEdit(caption="Name: ")
+
+ def ok(_b):
+ val = edit.get_edit_text().strip()
+ self.dismiss_dialog()
+ if val:
+ pid = self.profiles.create(val)
+ self.refresh(focus_pid=pid)
+
+ self._prompt("New Profile", edit, ok)
+
+ def save_current(self):
+ n = len(self.profiles.enabled_set())
+ edit = ReadlineEdit(caption="Name (%d enabled): " % n)
+
+ def ok(_b):
+ val = edit.get_edit_text().strip()
+ self.dismiss_dialog()
+ if val:
+ pid = self.profiles.save_current_as_profile(val)
+ self.refresh(focus_pid=pid)
+ self.status.set_text(("connected_status", "Saved current setup (%d interface%s) as a profile" % (n, "" if n == 1 else "s")))
+
+ self._prompt("Save Current as Profile", edit, ok)
+
+ def rename_profile(self):
+ w = self._focused()
+ if w is None or w.pid is None:
+ return
+ p = self.profiles.get(w.pid)
+ if p is None:
+ return
+ edit = ReadlineEdit(caption="Name: ", edit_text=p["name"])
+
+ def ok(_b):
+ val = edit.get_edit_text().strip()
+ self.dismiss_dialog()
+ if val:
+ self.profiles.rename(p["id"], val)
+ self.refresh(focus_pid=p["id"])
+
+ self._prompt("Rename Profile", edit, ok)
+
+ def delete_profile(self):
+ w = self._focused()
+ if w is None or w.pid is None:
+ return
+ p = self.profiles.get(w.pid)
+ if p is None:
+ return
+
+ def yes(_b):
+ self.dismiss_dialog()
+ self.profiles.delete(p["id"])
+ self.refresh()
+
+ def no(_b):
+ self.dismiss_dialog()
+
+ pile = urwid.Pile([
+ urwid.Text(f"Delete profile '{p['name'] or p['id']}'?\nInterfaces keep their current enabled state.", align="center"),
+ urwid.Divider(),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)),
+ ]),
+ ])
+ self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title="Confirm"), height=10)
+
+ def _prompt(self, title, edit, on_ok):
+ pile = urwid.Pile([
+ edit,
+ urwid.Divider(),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("OK", on_press=on_ok)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())),
+ ]),
+ ])
+ self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title))
+
+ def keypress(self, size, key):
+ if key in ("tab", "esc"):
+ self.parent.show_interfaces(); return None
+ if key == "ctrl a":
+ self.new_profile(); return None
+ if key == "ctrl s":
+ self.save_current(); return None
+ if key == "ctrl e":
+ self.rename_profile(); return None
+ if key == "ctrl x":
+ self.delete_profile(); return None
+ return super().keypress(size, key)
+
+
+### MAIN DISPLAY ###
+class InterfaceDisplay:
+ def __init__(self, app):
+ self.app = app
+ self.started = False
+ self.poll_scheduler = False
+ self.size_scheduler = False
+ self.interface_items = []
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+
+ self.terminal_cols, self.terminal_rows = _get_cols_rows()
+ self.iface_row_offset = 4
+ self.list_rows = self.terminal_rows - self.iface_row_offset
+
+ self.active_tab = "interfaces"
+ self.iface_view_mode = "rows"
+ self.iface_filter = "all"
+ self.total_interfaces = 0
+ self.restart_pending = False
+ self._notice_shown = False
+ self._rns_disconnected = False
+ self.profiles_view = None
+
+ self._build_interface_items()
+
+ self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents())
+ self.list_box = urwid.ListBox(self.list_walker)
+
+ self.body_rows = max(3, self.list_rows - 4)
+ self.box_adapter = urwid.BoxAdapter(self.list_box, self.body_rows)
+
+ self.tabs_pile = urwid.Pile([
+ ('pack', self._make_tab_bar("interfaces")),
+ ('pack', self._make_filter_bar()),
+ ('pack', urwid.Divider()),
+ self.box_adapter,
+ ])
+ self.interfaces_display = InterfaceFiller(self.tabs_pile, self.app)
+ self.shortcuts_display = InterfaceDisplayShortcuts(self.app)
+ self.widget = self.interfaces_display
+
+ def _empty_message(self):
+ if self.iface_filter != "all" and self.total_interfaces > 0:
+ return "No %s interfaces." % self.iface_filter
+ return "No interfaces found. Press Ctrl + A to add a new interface "
+
+ def _interface_list_contents(self):
+ if not self.interface_items:
+ return [urwid.Text(("interface_title", self._empty_message()), align="center")]
+ return list(self.interface_items)
+
+ def _make_filter_bar(self):
+ cols = [('weight', 1, urwid.Text(""))]
+ cols.append(('pack', urwid.Text(("interface_title", "Filter by: "))))
+ for key, label in (("all", "All"), ("enabled", "Enabled"), ("disabled", "Disabled"), ("connected", "Connected"), ("disconnected", "Disconnected")):
+ sel = self.iface_filter == key
+ mark = self.g['selected'] if sel else self.g['unselected']
+ style = "interface_tile_focus" if sel else "interface_title"
+ cols.append(('pack', ClickableIcon((style, "%s %s " % (mark, label)), on_click=lambda k=key: self._set_filter(k))))
+ cols.append(('weight', 1, urwid.Text("")))
+ return urwid.Columns(cols, dividechars=0)
+
+ def _set_filter(self, key):
+ if key == self.iface_filter:
+ return
+ self.iface_filter = key
+ self._build_interface_items()
+ self._rebuild_list()
+ self.app.ui.main_display.update_active_sub_display()
+
+ def cycle_filter(self):
+ order = ["all", "enabled", "disabled", "connected", "disconnected"]
+ i = order.index(self.iface_filter) if self.iface_filter in order else 0
+ self._set_filter(order[(i + 1) % len(order)])
+
+ def _make_tab_bar(self, active):
+ iface = f" Interfaces ({self.total_interfaces}) "
+ prof = " Profiles "
+ if active == "interfaces":
+ iface_w = ClickableIcon(("interface_title_selected", "["+iface+"]"), on_click=self.show_interfaces)
+ prof_w = ClickableIcon(("interface_title", " "+prof+" "), on_click=self.show_profiles)
+ else:
+ iface_w = ClickableIcon(("interface_title", " "+iface+" "), on_click=self.show_interfaces)
+ prof_w = ClickableIcon(("interface_title_selected", "["+prof+"]"), on_click=self.show_profiles)
+ return urwid.Columns([
+ ('weight', 1, urwid.Text("")),
+ ('pack', iface_w),
+ ('pack', urwid.Text(" ")),
+ ('pack', prof_w),
+ ('weight', 1, urwid.Text("")),
+ ], dividechars=0)
+
+ def _build_grid_listbox(self):
+ tiles = self.interface_tiles if self.interface_tiles else [urwid.Text(self._empty_message(), align="center")]
+ self.iface_grid = urwid.GridFlow(tiles, cell_width=34, h_sep=2, v_sep=1, align="center")
+ return urwid.ListBox(urwid.SimpleFocusListWalker([self.iface_grid]))
+
+ def _set_iface_body(self):
+ if self.iface_view_mode == "grid":
+ self.box_adapter.original_widget = self._build_grid_listbox()
+ else:
+ self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents())
+ self.list_box = urwid.ListBox(self.list_walker)
+ self.box_adapter.original_widget = self.list_box
+
+ def _focused_interface_name(self):
+ try:
+ if self.iface_view_mode == "grid":
+ return getattr(self.iface_grid.focus, "name", None)
+ fw, _ = self.box_adapter._original_widget.body.get_focus()
+ return getattr(fw, "name", None) if isinstance(fw, SelectableInterfaceItem) else None
+ except Exception:
+ return None
+
+ def toggle_view_mode(self):
+ self.iface_view_mode = "grid" if self.iface_view_mode == "rows" else "rows"
+ self._set_iface_body()
+ self.app.ui.main_display.update_active_sub_display()
+
+ def show_profiles(self):
+ if getattr(self.app, "interface_profiles", None) is None:
+ return
+ self.active_tab = "profiles"
+ self.profiles_view = ProfilesView(self)
+ self.shortcuts_display.set_profiles_tab_shortcuts()
+ self.widget = self.profiles_view
+ self.app.ui.main_display.update_active_sub_display()
+
+ def show_interfaces(self):
+ self.active_tab = "interfaces"
+ self._build_interface_items()
+ self._rebuild_list()
+ self.shortcuts_display.reset_shortcuts()
+ self.widget = self.interfaces_display
+ self.app.ui.main_display.update_active_sub_display()
+
+ def start(self):
+ # started from Main.py
+ self.started = True
+ if not self.poll_scheduler: self.app.ui.loop.set_alarm_in(1, self.poll_stats)
+ if not self.size_scheduler: self.app.ui.loop.set_alarm_in(5, self.check_terminal_size)
+
+ def switch_to_edit_interface(self, iface_name):
+ self.edit_interface_view = EditInterfaceView(self, iface_name)
+ self.widget = self.edit_interface_view
+ self.app.ui.main_display.update_active_sub_display()
+
+ def edit_selected_interface(self):
+ interface_name = self._focused_interface_name()
+ if interface_name is None:
+ return
+ self.switch_to_edit_interface(interface_name)
+
+ def check_terminal_size(self, loop, user_data):
+ self.size_scheduler = True
+ new_cols, new_rows = _get_cols_rows()
+
+ if new_rows != self.terminal_rows or new_cols != self.terminal_cols:
+ self.terminal_cols, self.terminal_rows = new_cols, new_rows
+ self.list_rows = self.terminal_rows - self.iface_row_offset
+ self.body_rows = max(3, self.list_rows - 4 - (1 if self.restart_pending else 0))
+
+ self.box_adapter.height = self.body_rows
+
+ loop.draw_screen()
+
+ if self.started:
+ loop.set_alarm_in(5, self.check_terminal_size)
+
+ def _show_disconnect_overlay(self):
+ # Surfaced when the shared RNS instance (rnsd) we are attached to goes away.
+ if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay:
+ return
+ if self.widget is not self.interfaces_display:
+ return
+ dialog_text = urwid.Pile([
+ urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"),
+ urwid.Text("Waiting to Reconnect...", align="center"),
+ ])
+ dialog_box = urwid.LineBox(urwid.Filler(dialog_text))
+ self.disconnect_overlay = urwid.Overlay(
+ dialog_box,
+ self.interfaces_display,
+ align='center',
+ width=35,
+ valign='middle',
+ height=4,
+ )
+ self.widget = self.disconnect_overlay
+ self.app.ui.main_display.update_active_sub_display()
+
+ def poll_stats(self, loop, user_data):
+ self.poll_scheduler = True
+ try:
+ # React to the outcome of the previous (asynchronous) stats fetch. The
+ # fetch runs in a daemon thread so the UI never blocks on the RPC round
+ # trip; the thread cannot raise into this loop, so it reports failure via
+ # the _rns_disconnected flag instead.
+ if self._rns_disconnected:
+ self._show_disconnect_overlay()
+ else:
+ if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay:
+ self.widget = self.interfaces_display
+ self.app.ui.main_display.update_active_sub_display()
+
+ interface_stats = self.app.interface_stats
+ stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']}
+ for item in self.interface_items:
+ # use interface name as the key
+ stats_for_interface = stats_lookup.get(item.name)
+ if stats_for_interface:
+ tx = stats_for_interface.get("txb", 0)
+ rx = stats_for_interface.get("rxb", 0)
+ announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0))
+ item.update_stats(tx, rx, announce_rate)
+ item.update_connection(stats_for_interface.get("status", False))
+ else:
+ item.update_connection(False)
+
+ def job():
+ try:
+ self.app.interface_stats = self.app.rns.get_interface_stats()
+ self._rns_disconnected = False
+ except Exception:
+ self._rns_disconnected = True
+ threading.Thread(target=job, daemon=True).start()
+ except Exception as e:
+ self._show_disconnect_overlay()
+ finally:
+ if self.started:
+ loop.set_alarm_in(1, self.poll_stats)
+
+ def shortcuts(self):
+ return self.shortcuts_display
+
+ def switch_to_show_interface(self, iface_name):
+ show_interface = ShowInterface(self, iface_name)
+ self.widget = show_interface
+ self.app.ui.main_display.update_active_sub_display()
+
+ show_interface.start()
+
+ def switch_to_list(self):
+ self.show_interfaces()
+
+ def add_interface(self):
+ dialog_widgets = []
+
+ def add_heading(txt):
+ dialog_widgets.append(urwid.Text(("interface_title", txt), align="left"))
+
+ def add_option(label, value):
+ item = InterfaceOptionItem(self, label, value)
+ dialog_widgets.append(item)
+
+ add_heading("Paste Configuration")
+ dialog_widgets.append(InterfaceOptionItem(self, "Paste an interface config…", None, on_select=self.show_paste_config_dialog))
+ dialog_widgets.append(urwid.Divider())
+
+ # Get the icons based on plain, unicode, nerdfont glyphset
+ network_icon = _get_interface_icon(self.glyphset, "AutoInterface")
+ rnode_icon = _get_interface_icon(self.glyphset, "RNodeInterface")
+ serial_icon = _get_interface_icon(self.glyphset, "SerialInterface")
+ other_icon = _get_interface_icon(self.glyphset, "PipeInterface")
+
+ add_heading(f"{network_icon} IP Networks")
+ add_option("Auto Interface", "AutoInterface")
+ add_option("TCP Client Interface", "TCPClientInterface")
+ add_option("TCP Server Interface", "TCPServerInterface")
+ add_option("UDP Interface", "UDPInterface")
+ add_option("I2P Interface", "I2PInterface")
+ if PLATFORM_IS_LINUX:
+ add_option("Backbone Interface", "BackboneInterface")
+
+ if PYSERIAL_AVAILABLE:
+ add_heading(f"{rnode_icon} RNodes")
+ add_option("RNode Interface", "RNodeInterface")
+ add_option("RNode Multi Interface", "RNodeMultiInterface")
+
+ add_heading(f"{serial_icon} Hardware")
+ add_option("Serial Interface", "SerialInterface")
+ add_option("KISS Interface", "KISSInterface")
+ add_option("AX.25 KISS Interface", "AX25KISSInterface")
+
+ add_heading(f"{other_icon} Other")
+ add_option("Pipe Interface", "PipeInterface")
+ add_option("Custom Interface", "CustomInterface")
+
+
+ listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets))
+ dialog = DialogLineBox(listbox, parent=self, title="Select Interface Type")
+
+ overlay = urwid.Overlay(
+ dialog,
+ self.interfaces_display,
+ align='center',
+ width=('relative', 50),
+ valign='middle',
+ height=('relative', 50),
+ min_width=20,
+ min_height=15,
+ left=2,
+ right=2
+ )
+ self.widget = overlay
+ self.app.ui.main_display.update_active_sub_display()
+
+ def show_paste_config_dialog(self):
+ self.paste_edit = ReadlineEdit(caption="", multiline=True)
+ self.paste_status = urwid.Text("")
+
+ instructions = urwid.Text(
+ "Paste an interface block, including its [[Name]] header, then Create. "
+ "Multiple interfaces are supported.", align="left")
+
+ editor = urwid.AttrMap(
+ urwid.LineBox(urwid.BoxAdapter(urwid.ListBox(urwid.SimpleListWalker([self.paste_edit])), 12)),
+ "list_off_focus", "list_focus")
+
+ pile = urwid.Pile([
+ instructions,
+ urwid.Divider(),
+ editor,
+ self.paste_status,
+ urwid.Divider(),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=lambda _b: self._create_interfaces_from_text(self.paste_edit.get_edit_text()))),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())),
+ ]),
+ ])
+
+ dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Paste Interface Config")
+ overlay = urwid.Overlay(
+ dialog, self.interfaces_display,
+ align="center", width=("relative", 75),
+ valign="middle", height=("relative", 75),
+ min_width=44, min_height=14,
+ )
+ self.widget = overlay
+ self.app.ui.main_display.update_active_sub_display()
+
+ def _section_to_dict(self, sec):
+ out = {}
+ for k, v in sec.items():
+ out[k] = self._section_to_dict(v) if hasattr(v, "items") else v
+ return out
+
+ def _parse_pasted_interfaces(self, text):
+ import RNS.vendor.configobj as configobj
+ raw = (text or "").strip("\n")
+ if not raw.strip():
+ return {}
+ for prefix in ("", "[interfaces]\n"):
+ try:
+ parsed = configobj.ConfigObj(infile=(prefix + raw).split("\n"))
+ except Exception:
+ continue
+ found = {}
+ sections = []
+ ifsec = parsed.get("interfaces")
+ if hasattr(ifsec, "items"):
+ sections.extend(ifsec.items())
+ for name, sec in parsed.items():
+ if name != "interfaces" and hasattr(sec, "items"):
+ sections.append((name, sec))
+ for name, sec in sections:
+ if not hasattr(sec, "items"):
+ continue
+ d = self._section_to_dict(sec)
+ if "type" in d and name not in found:
+ found[name] = d
+ if found:
+ return found
+ return {}
+
+ def _create_interfaces_from_text(self, text):
+ parsed = self._parse_pasted_interfaces(text)
+ if not parsed:
+ self.paste_status.set_text(("error", " Could not find an interface with a 'type'. Include the [[Name]] header."))
+ return
+
+ interfaces = self.app.rns.config['interfaces']
+ existing = [n for n in parsed if n in interfaces]
+ if existing:
+ self.paste_status.set_text(("error", " Already exists: " + ", ".join(existing)))
+ return
+
+ try:
+ for name, conf in parsed.items():
+ interfaces[name] = conf
+ write_config_atomic(self.app.rns.config)
+ except Exception as e:
+ self.paste_status.set_text(("error", " Failed: " + str(e)))
+ return
+
+ self.mark_restart_pending()
+ self.dismiss_dialog()
+ self.show_interfaces()
+
+ def switch_to_add_interface(self, iface_type):
+ self.add_interface_view = AddInterfaceView(self, iface_type)
+ self.widget = self.add_interface_view
+ self.app.ui.main_display.update_active_sub_display()
+
+ def remove_selected_interface(self):
+ interface_name = self._focused_interface_name()
+ if interface_name is None:
+ return
+
+ def on_confirm_yes(button):
+ try:
+ if interface_name in self.app.rns.config['interfaces']:
+ del self.app.rns.config['interfaces'][interface_name]
+ write_config_atomic(self.app.rns.config)
+ try: self.app.interface_profiles.remove_interface(interface_name)
+ except Exception: pass
+
+ self.mark_restart_pending()
+ self._build_interface_items()
+ self._rebuild_list()
+ self.dismiss_dialog()
+
+ except Exception as e:
+ print(e)
+
+ def on_confirm_no(button):
+ self.dismiss_dialog()
+
+ confirm_text = urwid.Text(("interface_title", f"Remove interface {interface_name}?"), align="center")
+ yes_button = urwid.Button("Yes", on_press=on_confirm_yes)
+ no_button = urwid.Button("No", on_press=on_confirm_no)
+
+ buttons_row = urwid.Columns([
+ (urwid.WEIGHT, 0.45, yes_button),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, no_button),
+ ])
+
+ pile = urwid.Pile([
+ confirm_text,
+ buttons_row
+ ])
+
+ dialog = DialogLineBox(pile, parent=self, title="?")
+
+ overlay = urwid.Overlay(
+ dialog,
+ self.interfaces_display,
+ align='center',
+ width=('relative', 35),
+ valign='middle',
+ height=(5),
+ min_width=5,
+ left=2,
+ right=2
+ )
+ dialog.original_widget.focus_position = 1 # columns row
+ buttons_row = dialog.original_widget.contents[1][0]
+ buttons_row.focus_position = 2 # second button "No"
+
+ self.widget = overlay
+ self.app.ui.main_display.update_active_sub_display()
+
+ def dismiss_dialog(self):
+ self.widget = self.interfaces_display
+ self.app.ui.main_display.update_active_sub_display()
+
+ def _build_interface_items(self):
+ self.interface_items = []
+ self.interface_tiles = []
+ interfaces = self.app.rns.config['interfaces']
+ processed_interfaces = {}
+
+ for interface_name, interface in interfaces.items():
+ interface_data = interface.copy()
+
+ if interface_data.get("type") == "RNodeMultiInterface":
+ sub_interfaces = []
+ for sub_name, sub_config in interface_data.items():
+ if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode",
+ "configured_bitrate"}:
+ if isinstance(sub_config, dict):
+ sub_config["name"] = sub_name
+ sub_interfaces.append(sub_config)
+
+ interface_data["sub_interfaces"] = sub_interfaces
+
+ for sub in sub_interfaces:
+ del interface_data[sub["name"]]
+
+ processed_interfaces[interface_name] = interface_data
+
+ self.total_interfaces = len(processed_interfaces)
+ self.app.interface_stats = self.app.rns.get_interface_stats()
+ stats_lookup = {interface['short_name']: interface for interface in self.app.interface_stats['interfaces']}
+
+ profiles = getattr(self.app, "interface_profiles", None)
+
+ for interface_name, interface_data in processed_interfaces.items():
+ is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0')
+
+ if self.iface_filter == "enabled" and not is_enabled:
+ continue
+ if self.iface_filter == "disabled" and is_enabled:
+ continue
+
+ iface_type = interface_data.get("type", "Unknown")
+ icon = _get_interface_icon(self.glyphset, iface_type)
+
+ stats_for_interface = stats_lookup.get(interface_name)
+
+ if stats_for_interface:
+ tx = stats_for_interface.get("txb", 0)
+ rx = stats_for_interface.get("rxb", 0)
+ is_connected = stats_for_interface["status"]
+ announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0))
+ else:
+ tx = 0
+ rx = 0
+ is_connected = False
+ announce_rate = "-"
+
+ if self.iface_filter == "connected" and not is_connected:
+ continue
+ if self.iface_filter == "disconnected" and is_connected:
+ continue
+
+ profiles_label = profiles.label_for(interface_name) if profiles is not None else None
+
+ item = SelectableInterfaceItem(
+ parent=self,
+ name=interface_data.get("name", interface_name),
+ is_connected=is_connected,
+ is_enabled=is_enabled,
+ iface_type=iface_type,
+ tx=tx,
+ rx=rx,
+ icon=icon,
+ profiles_label=profiles_label,
+ announce_rate=announce_rate
+ )
+
+ self.interface_items.append(item)
+
+ tile = InterfaceTile(
+ parent=self,
+ name=interface_data.get("name", interface_name),
+ is_connected=is_connected,
+ is_enabled=is_enabled,
+ iface_type=iface_type,
+ icon=icon,
+ announce_rate=announce_rate,
+ )
+ self.interface_tiles.append(tile)
+
+ def _rebuild_list(self):
+ self._set_iface_body()
+ self.tabs_pile.contents[0] = (self._make_tab_bar("interfaces"), self.tabs_pile.options('pack'))
+ self.tabs_pile.contents[1] = (self._make_filter_bar(), self.tabs_pile.options('pack'))
+
+ def mark_restart_pending(self):
+ self.restart_pending = True
+ if self._notice_shown:
+ return
+ try:
+ mark = "!" if self.glyphset == "plain" else "⚠"
+ banner = urwid.AttrMap(
+ urwid.Text("%s Interfaces have been updated. Restart required" % mark, align="center"),
+ "warning_text")
+ self.tabs_pile.contents.insert(2, (banner, self.tabs_pile.options('pack')))
+ self._notice_shown = True
+ self.body_rows = max(3, self.body_rows - 1)
+ self.box_adapter.height = self.body_rows
+ except Exception:
+ pass
+
+ def _focus_interface(self, name):
+ try:
+ if self.iface_view_mode == "grid":
+ for i, t in enumerate(self.interface_tiles):
+ if getattr(t, "name", None) == name:
+ self.iface_grid.focus_position = i
+ return
+ else:
+ body = self.box_adapter._original_widget.body
+ for i, w in enumerate(body):
+ if getattr(w, "name", None) == name:
+ self.box_adapter._original_widget.focus_position = i
+ return
+ except Exception:
+ pass
+
+ def toggle_focused_interface(self):
+ name = self._focused_interface_name()
+ if name is None:
+ return
+ interfaces = self.app.rns.config['interfaces']
+ if name not in interfaces:
+ return
+ iface = interfaces[name]
+ is_enabled = str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \
+ str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0')
+ value = "false" if is_enabled else "true"
+ iface["interface_enabled"] = value
+ if "enabled" in iface:
+ iface["enabled"] = value
+ try:
+ write_config_atomic(self.app.rns.config)
+ except Exception:
+ return
+ self.mark_restart_pending()
+ self._build_interface_items()
+ self._rebuild_list()
+ self._focus_interface(name)
+ self.app.ui.main_display.update_active_sub_display()
+
+ def _iface_is_enabled(self, iface):
+ return str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \
+ str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0')
+
+ def _set_iface_enabled(self, iface, enabled):
+ value = "true" if enabled else "false"
+ iface["interface_enabled"] = value
+ if "enabled" in iface:
+ iface["enabled"] = value
+
+ def show_bulk_actions(self):
+ interfaces = self.app.rns.config['interfaces']
+ if not interfaces:
+ self._bulk_notice("No interfaces configured.")
+ return
+
+ dialog_widgets = [
+ urwid.Text(("interface_title", "Apply an action to all interfaces"), align="left"),
+ urwid.Divider(),
+ InterfaceOptionItem(self, "Enable all interfaces", None, on_select=lambda: self._bulk_apply("enable")),
+ InterfaceOptionItem(self, "Disable all interfaces", None, on_select=lambda: self._bulk_apply("disable")),
+ InterfaceOptionItem(self, "Invert (toggle each interface)", None, on_select=lambda: self._bulk_apply("invert")),
+ InterfaceOptionItem(self, "Solo selected (disable all others)", None, on_select=lambda: self._bulk_apply("solo")),
+ ]
+
+ listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets))
+ dialog = DialogLineBox(listbox, parent=self, title="Bulk Actions")
+
+ overlay = urwid.Overlay(
+ dialog,
+ self.interfaces_display,
+ align='center',
+ width=('relative', 50),
+ valign='middle',
+ height=('relative', 50),
+ min_width=20,
+ min_height=10,
+ left=2,
+ right=2
+ )
+ self.widget = overlay
+ self.app.ui.main_display.update_active_sub_display()
+
+ def _bulk_apply(self, action):
+ interfaces = self.app.rns.config['interfaces']
+ focused = self._focused_interface_name()
+
+ if action == "solo" and focused is None:
+ self._bulk_notice("Select an interface first to solo it.")
+ return
+
+ changed = 0
+ for name, iface in interfaces.items():
+ current = self._iface_is_enabled(iface)
+ if action == "enable":
+ target = True
+ elif action == "disable":
+ target = False
+ elif action == "invert":
+ target = not current
+ elif action == "solo":
+ target = (name == focused)
+ else:
+ target = current
+
+ if target != current:
+ self._set_iface_enabled(iface, target)
+ changed += 1
+
+ try:
+ write_config_atomic(self.app.rns.config)
+ except Exception as e:
+ self._bulk_notice(f"Error applying bulk action: {str(e)}", title="Error")
+ return
+
+ self.mark_restart_pending()
+ self._build_interface_items()
+ self._rebuild_list()
+
+ verb = {"enable": "Enabled all", "disable": "Disabled all", "invert": "Inverted all", "solo": "Soloed selected"}[action]
+ if changed == 0:
+ self._bulk_notice("No interfaces needed changing.")
+ else:
+ self._bulk_notice(f"{verb} interfaces ({changed} changed).\nRestart required for changes to take effect.")
+
+ def _bulk_notice(self, message, title="Notice"):
+ def dismiss_dialog(button):
+ self.dismiss_dialog()
+
+ dialog = DialogLineBox(
+ urwid.Pile([
+ urwid.Text(message, align="center"),
+ urwid.Divider(),
+ urwid.Button("OK", on_press=dismiss_dialog)
+ ]),
+ parent=self,
+ title=title
+ )
+
+ overlay = urwid.Overlay(
+ dialog,
+ self.interfaces_display,
+ align='center',
+ width=60,
+ valign='middle',
+ height=8,
+ min_width=20,
+ min_height=1
+ )
+ self.widget = overlay
+ self.app.ui.main_display.update_active_sub_display()
+
+ def save_current_profile(self):
+ profiles = getattr(self.app, "interface_profiles", None)
+ if profiles is None:
+ return
+ n = len(profiles.enabled_set())
+ edit = ReadlineEdit(caption="Name: ")
+
+ def ok(_b):
+ val = edit.get_edit_text().strip()
+ self.dismiss_dialog()
+ if val:
+ profiles.save_current_as_profile(val)
+ self.show_interfaces()
+
+ pile = urwid.Pile([
+ urwid.Text("Save the %d currently enabled interface%s as a new profile." % (n, "" if n == 1 else "s"), align="left"),
+ urwid.Divider(),
+ edit,
+ urwid.Divider(),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=ok)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())),
+ ]),
+ ])
+ dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Save Current as Profile")
+ overlay = urwid.Overlay(
+ dialog, self.interfaces_display,
+ align="center", width=("relative", 60),
+ valign="middle", height=("relative", 45), min_width=44, min_height=9,
+ )
+ self.widget = overlay
+ self.app.ui.main_display.update_active_sub_display()
+
+ def open_config_editor(self):
+ import platform
+
+ editor_cmd = self.app.config["textui"]["editor"]
+
+ if platform.system() == "Darwin" and editor_cmd == "editor":
+ editor_cmd = "nano"
+
+ editor_term = urwid.Terminal(
+ (editor_cmd, self.app.rns.configpath),
+ encoding='utf-8',
+ main_loop=self.app.ui.loop,
+ )
+
+ def quit_term(*args, **kwargs):
+ self.widget = self.interfaces_display
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.request_redraw()
+
+ urwid.connect_signal(editor_term, 'closed', quit_term)
+
+ editor_box = urwid.LineBox(editor_term, title="Editing RNS Config")
+ self.widget = editor_box
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+ editor_term.change_focus(True)
+
+### SHORTCUTS ###
+class InterfaceDisplayShortcuts:
+ def __init__(self, app):
+ self.app = app
+ self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [C-o] On/Off [C-b] Bulk [Enter] Show [C-f] Filter [C-s] Save Profile [C-t] Grid/Rows [Tab] Profiles"
+ self.current_shortcuts = self.default_shortcuts
+ self.widget = urwid.AttrMap(
+ urwid.Text(self.current_shortcuts),
+ "shortcutbar"
+ )
+
+ def update_shortcuts(self, new_shortcuts):
+ self.current_shortcuts = new_shortcuts
+ self.widget.original_widget.set_text(new_shortcuts)
+
+ def reset_shortcuts(self):
+ self.update_shortcuts(self.default_shortcuts)
+
+ def set_show_interface_shortcuts(self):
+ show_shortcuts = "[Up/Down] Navigate [Tab] Switch Focus [h] Horizontal Charts [v] Vertical Charts "
+ self.update_shortcuts(show_shortcuts)
+
+ def set_add_interface_shortcuts(self):
+ add_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option"
+ self.update_shortcuts(add_shortcuts)
+
+ def set_edit_interface_shortcuts(self):
+ edit_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option"
+ self.update_shortcuts(edit_shortcuts)
+
+ def set_profiles_tab_shortcuts(self):
+ profiles_shortcuts = "[Space] Select [C-a] New [C-s] Save Current [C-e] Rename [C-x] Delete [Tab] Interfaces"
+ self.update_shortcuts(profiles_shortcuts)
\ No newline at end of file
diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py
new file mode 100644
index 0000000..8472809
--- /dev/null
+++ b/nomadnet/ui/textui/Log.py
@@ -0,0 +1,132 @@
+import os
+import sys
+import itertools
+import mmap
+import urwid
+import nomadnet
+from nomadnet.util import strip_modifiers
+
+
+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 == "ctrl q":
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.quit()
+ return None
+
+ if key == "up":
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
+
+ return super(LogTerminal, self).keypress(size, key)
+
+
+class LogTail(urwid.WidgetWrap):
+ def __init__(self, app):
+ self.app = app
+ self.log_tail = urwid.Text(strip_modifiers(tail(self.app.logfilepath, 50)))
+ self.log = urwid.Scrollable(self.log_tail)
+ self.log.set_scrollpos(-1)
+ self.log_scrollbar = urwid.ScrollBar(self.log)
+ # We have this here because ui.textui.Main depends on this field to kill it
+ self.log_term = None
+
+ super().__init__(self.log_scrollbar)
+
+ def terminate(self):
+ pass
+
+
+def log_widget(app, platform=sys.platform):
+ if platform == "win32":
+ return LogTail(app)
+ else:
+ return LogTerminal(app)
+
+# https://stackoverflow.com/a/34029605/3713120
+def _tail(f_name, n, offset=0):
+ def skip_back_lines(mm: mmap.mmap, numlines: int, startidx: int) -> int:
+ '''Factored out to simplify handling of n and offset'''
+ for _ in itertools.repeat(None, numlines):
+ startidx = mm.rfind(b'\n', 0, startidx)
+ if startidx < 0:
+ break
+ return startidx
+
+ # Open file in binary mode
+ with open(f_name, 'rb') as binf, mmap.mmap(binf.fileno(), 0, access=mmap.ACCESS_READ) as mm:
+ # len(mm) - 1 handles files ending w/newline by getting the prior line
+ startofline = skip_back_lines(mm, offset, len(mm) - 1)
+ if startofline < 0:
+ return [] # Offset lines consumed whole file, nothing to return
+ # If using a generator function (yield-ing, see below),
+ # this should be a plain return, no empty list
+
+ endoflines = startofline + 1 # Slice end to omit offset lines
+
+ # Find start of lines to capture (add 1 to move from newline to beginning of following line)
+ startofline = skip_back_lines(mm, n, startofline) + 1
+
+ # Passing True to splitlines makes it return the list of lines without
+ # removing the trailing newline (if any), so list mimics f.readlines()
+ # return mm[startofline:endoflines].splitlines(True)
+ # If Windows style \r\n newlines need to be normalized to \n
+ return mm[startofline:endoflines].replace(os.linesep.encode(sys.getdefaultencoding()), b'\n').splitlines(True)
+
+
+def tail(f_name, n):
+ """
+ Return the last n lines of a given file name, f_name.
+ Akin to `tail - `
+ """
+ def decode(b):
+ return b.decode(encoding)
+
+ encoding = sys.getdefaultencoding()
+ lines = map(decode, _tail(f_name=f_name, n=n))
+ return ''.join(lines)
diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py
index e2bce9b..e439db2 100644
--- a/nomadnet/ui/textui/Main.py
+++ b/nomadnet/ui/textui/Main.py
@@ -1,10 +1,17 @@
import RNS
-import time
from .Network import *
from .Conversations import *
+from .Channels import *
from .Directory import *
+from .Config import *
+from .Interfaces import *
+from .Transport import *
+from .Utilities import *
from .Map import *
+from .Log import *
+from .Guide import *
+from .Editor import *
import urwid
class SubDisplays():
@@ -12,10 +19,26 @@ class SubDisplays():
self.app = app
self.network_display = NetworkDisplay(self.app)
self.conversations_display = ConversationsDisplay(self.app)
+ self.channels_display = ChannelsDisplay(self.app)
self.directory_display = DirectoryDisplay(self.app)
+ self.config_display = ConfigDisplay(self.app)
+ self.interface_display = InterfaceDisplay(self.app)
+ self.utilities_display = UtilitiesDisplay(self.app) if self._utilities_enabled(app) else None
self.map_display = MapDisplay(self.app)
+ self.log_display = LogDisplay(self.app)
+ self.guide_display = GuideDisplay(self.app)
+ self.page_editor_display = PageEditorDisplay(self.app) if app.enable_node else None
- self.active_display = self.conversations_display
+ if app.firstrun:
+ self.active_display = self.guide_display
+ else:
+ self.active_display = self.conversations_display
+
+ def _utilities_enabled(self, app):
+ try:
+ return app.config["utilities"].as_bool("enable_utilities")
+ except Exception:
+ return False
def active(self):
return self.active_display
@@ -68,11 +91,23 @@ class MainFrame(urwid.Frame):
def keypress(self, size, key):
self.keypress_focus_check()
- if key == "ctrl q":
- raise urwid.ExitMainLoop
+ #if key == "ctrl q":
+ # raise urwid.ExitMainLoop
return super(MainFrame, self).keypress(size, key)
+class QuitDialogLineBox(urwid.LineBox):
+ def __init__(self, *args, on_cancel=None, **kwargs):
+ self.on_cancel = on_cancel
+ super().__init__(*args, **kwargs)
+
+ def keypress(self, size, key):
+ if key == "esc":
+ if self.on_cancel is not None:
+ self.on_cancel()
+ return None
+ return super().keypress(size, key)
+
class MainDisplay():
def __init__(self, ui, app):
self.ui = ui
@@ -93,6 +128,11 @@ class MainDisplay():
self.sub_displays.active_display = self.sub_displays.conversations_display
self.update_active_sub_display()
+ def show_channels(self, user_data):
+ self.sub_displays.active_display = self.sub_displays.channels_display
+ self.update_active_sub_display()
+ self.sub_displays.channels_display.start()
+
def show_directory(self, user_data):
self.sub_displays.active_display = self.sub_displays.directory_display
self.update_active_sub_display()
@@ -101,38 +141,206 @@ class MainDisplay():
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_utilities(self, user_data):
+ if self.sub_displays.utilities_display is None:
+ return
+ self.sub_displays.active_display = self.sub_displays.utilities_display
+ self.update_active_sub_display()
+ self.sub_displays.utilities_display.start()
+
+ def show_log(self, user_data):
+ self.sub_displays.active_display = self.sub_displays.log_display
+ self.sub_displays.log_display.show()
+ self.update_active_sub_display()
+
+ def show_guide(self, user_data):
+ self.sub_displays.active_display = self.sub_displays.guide_display
+ self.update_active_sub_display()
+
+ def show_page_editor(self, user_data):
+ if not self.app.enable_node:
+ return
+ self.sub_displays.active_display = self.sub_displays.page_editor_display
+ self.update_active_sub_display()
+ self.sub_displays.page_editor_display.start()
+ self.frame.focus_position = "body"
+
def update_active_sub_display(self):
self.frame.contents["body"] = (self.sub_displays.active().widget, None)
self.update_active_shortcuts()
+ 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):
+ if self.app.peer_settings["quit_confirm"]: self.show_quit_dialog()
+ else: self.do_quit()
+
+ def show_quit_dialog(self, sender=None):
+ if getattr(self, "_quit_dialog_open", False): return
+
+ active = self.sub_displays.active_display
+ if active is self.sub_displays.log_display:
+ self.do_quit()
+ return
+ if active is self.sub_displays.config_display and self.sub_displays.config_display.editor_term is not None:
+ self.do_quit()
+ return
+
+ dont_ask_again = urwid.CheckBox("Don't ask again")
+
+ def confirm(button=None):
+ if dont_ask_again.get_state():
+ try:
+ self.app.peer_settings["quit_confirm"] = False
+ self.app.save_peer_settings()
+ except Exception: pass
+ self.do_quit()
+
+ def cancel(button=None):
+ self._quit_dialog_open = False
+ self.app.ui.loop.widget = self.frame
+
+ buttons = urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirm)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=cancel)),
+ ])
+ try: buttons.focus_position = 2 # default to "No"
+ except Exception: pass
+
+ pile = urwid.Pile([
+ urwid.Text("Are you sure you want to quit?", align="center"),
+ urwid.Divider(),
+ buttons,
+ urwid.Divider(),
+ dont_ask_again,
+ ])
+
+ dialog = QuitDialogLineBox(
+ urwid.Filler(pile, urwid.TOP),
+ title="Quit NomadNet",
+ on_cancel=cancel,
+ )
+
+ self._quit_dialog_open = True
+
+ self.app.ui.loop.widget = urwid.Overlay(
+ dialog, self.frame,
+ align="center", width=44,
+ valign="middle", height=7, min_width=20,
+ )
+
+ def show_notice(self, text):
+ def close(button=None): self.app.ui.loop.widget = self.frame
+ pile = urwid.Pile([urwid.Text(text, align="center"), urwid.Divider(), urwid.Button("OK", on_press=close)])
+ dialog = QuitDialogLineBox(urwid.Filler(pile, urwid.TOP), title="Notice", on_cancel=close)
+ self.app.ui.loop.widget = urwid.Overlay(dialog, self.frame, align="center", width=64, valign="middle", height=9, min_width=20)
+
+ def do_quit(self, sender=None):
+ logterm_pid = None
+ if True or RNS.vendor.platformutils.is_android():
+ if self.sub_displays.log_display != None and self.sub_displays.log_display.log_term != None:
+ if self.sub_displays.log_display.log_term.log_term != None:
+ logterm_pid = self.sub_displays.log_display.log_term.log_term.pid
+ if logterm_pid != None:
+ import os, signal
+ os.kill(logterm_pid, signal.SIGKILL)
+
raise urwid.ExitMainLoop
class MenuColumns(urwid.Columns):
def keypress(self, size, key):
if key == "tab" or key == "down":
- self.handler.frame.set_focus("body")
+ self.handler.frame.focus_position = "body"
return super(MenuColumns, self).keypress(size, key)
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
- menu_text = ("pack", urwid.Text(" \U00002638"))
- button_network = (11, MenuButton("Network", on_press=handler.show_network))
- button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations))
- button_directory = (13, MenuButton("Directory", on_press=handler.show_directory))
- button_map = (7, MenuButton("Map", on_press=handler.show_map))
- button_quit = (8, MenuButton("Quit", on_press=handler.quit))
+ 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_utilities = (13, MenuButton("Utilities", on_press=handler.show_utilities))
+ button_guide = (9, MenuButton("Guide", on_press=handler.show_guide))
+ button_mynode = (11, MenuButton("My Node", on_press=handler.show_page_editor))
+ button_quit = (8, MenuButton("Quit", on_press=handler.quit))
# buttons = [menu_text, button_conversations, button_node, button_directory, button_map]
- buttons = [menu_text, button_conversations, button_network, button_quit]
+ buttons = [menu_text, button_conversations, button_network]
+ if self.app.enable_node:
+ buttons.append(button_mynode)
+ buttons += [button_channels, button_log, button_interfaces]
+ enable_utilities = False
+ try:
+ enable_utilities = self.app.config["utilities"].as_bool("enable_utilities")
+ except Exception:
+ enable_utilities = False
+ if enable_utilities:
+ buttons.append(button_utilities)
+ buttons.append(button_config)
+ if not self.app.config["textui"]["hide_guide"]:
+ buttons.append(button_guide)
+ buttons.append(button_quit)
+
columns = MenuColumns(buttons, dividechars=1)
columns.handler = handler
+ 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
index 3d4a03d..b2ef4dd 100644
--- a/nomadnet/ui/textui/Map.py
+++ b/nomadnet/ui/textui/Map.py
@@ -15,7 +15,7 @@ class MapDisplay():
])
self.shortcuts_display = MapDisplayShortcuts(self.app)
- self.widget = urwid.Filler(pile, 'top')
+ self.widget = urwid.Filler(pile, urwid.TOP)
def shortcuts(self):
return self.shortcuts_display
\ No newline at end of file
diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py
new file mode 100644
index 0000000..d4ac77d
--- /dev/null
+++ b/nomadnet/ui/textui/MicronParser.py
@@ -0,0 +1,1344 @@
+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
+from nomadnet.util import STRIP_CONTROL_RE
+try: from .images import ImageWidget
+except Exception: ImageWidget = None
+
+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
+
+DEFAULT_FOLD_GLYPHS = ("▾", "▸")
+
+def default_fold_glyphs():
+ try:
+ g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs
+ return (g.get("fold_open", DEFAULT_FOLD_GLYPHS[0]), g.get("fold_closed", DEFAULT_FOLD_GLYPHS[1]))
+ except Exception:
+ return DEFAULT_FOLD_GLYPHS
+
+def default_state(fg=None, bg=None):
+ global SELECTED_STYLES
+ 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):
+ markup = STRIP_CONTROL_RE.sub("", markup)
+ global LINK_CLASS
+ if link_class: LINK_CLASS = link_class
+ else: LINK_CLASS = LinkableText
+
+ global SELECTED_STYLES
+ ensure_selected_styles()
+
+ attrmaps = _AttrMapList()
+
+ fgc = None; bgc = DEFAULT_BG
+ if bg_color != None: bgc = bg_color
+ if fg_color != None: fgc = fg_color
+
+ state = default_state(fgc, bgc)
+ if anchors is None:
+ anchors = state["anchors"]
+ else:
+ state["anchors"] = anchors
+
+ # Split entire document into lines for
+ # processing.
+ 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.setdefault("header_levels", {})[row_index] = state["depth"]
+ cp = state.get("_collapsible_pending")
+ if cp is not None:
+ state.setdefault("collapsibles", {})[row_index] = bool(cp["collapsed"])
+ state["_collapsible_pending"] = None
+ state["_header_pending"] = False
+
+ row_levels = state.setdefault("row_levels", {})
+ for display_widget in display_widgets:
+ row_levels[len(attrmaps)] = state["depth"]
+ attrmap = urwid.AttrMap(display_widget, make_style(state))
+ attrmaps.append(attrmap)
+
+ try:
+ setattr(attrmaps, "anchors", anchors)
+ setattr(attrmaps, "header_rows", list(state.get("header_rows", [])))
+ setattr(attrmaps, "header_levels", dict(state.get("header_levels", {})))
+ setattr(attrmaps, "row_levels", dict(state.get("row_levels", {})))
+ setattr(attrmaps, "collapsibles", dict(state.get("collapsibles", {})))
+ setattr(attrmaps, "fold_glyphs", state.get("fold_glyphs") or default_fold_glyphs())
+ except Exception:
+ pass
+
+ return attrmaps
+
+def parse_partial(line):
+ try:
+ endpos = line.find("}")
+ if endpos == -1: return None
+ else:
+ partial_data = line[0:endpos]
+
+ partial_id = None
+ partial_components = partial_data.split("`")
+ if len(partial_components) == 1:
+ partial_url = partial_components[0]
+ partial_refresh = None
+ partial_fields = ""
+ elif len(partial_components) == 2:
+ partial_url = partial_components[0]
+ partial_refresh = float(partial_components[1])
+ partial_fields = ""
+ elif len(partial_components) == 3:
+ partial_url = partial_components[0]
+ partial_refresh = float(partial_components[1])
+ partial_fields = partial_components[2]
+ else:
+ partial_url = ""
+ partial_fields = ""
+ partial_refresh = None
+
+ if partial_refresh != None and partial_refresh < 1: partial_refresh = None
+
+ pf = partial_fields.split("|")
+ if len(pf) > 0:
+ partial_fields = pf
+ for f in pf:
+ if f.startswith("pid="):
+ pcs = f.split("=")
+ partial_id = pcs[1]
+
+ if len(partial_url):
+ pile = urwid.Pile([urwid.Text(f"⧖")])
+ partial_descriptor = "|".join(partial_components)
+ pile.partial_id = partial_id
+ pile.partial_hash = RNS.hexrep(RNS.Identity.full_hash(partial_descriptor.encode("utf-8")), delimit=False)
+ pile.partial_url = partial_url
+ pile.partial_fields = partial_fields
+ pile.partial_refresh = partial_refresh
+ return [pile]
+
+ except Exception as e: return None
+
+def parse_image(line, state, url_delegate):
+ try:
+ endpos = line.rfind(")")
+ if endpos <= 0: return None
+ image_data = line[:endpos]
+ fields = image_data.split("`")
+ if len(fields) < 2: return None
+
+ alt_text = fields[0].strip()
+ image_url = fields[-1].strip()
+ properties = fields[1:-1]
+
+ width = None
+ height = None
+ align = None
+ for prop in properties:
+ if "=" not in prop: continue
+ key, _, value = prop.partition("=")
+ key = key.strip()
+ value = value.strip()
+ if key == "w": width = value
+ elif key == "h": height = value
+ elif key == "a": align = value
+
+ if align == "c": align = "center"
+ if align == "l": align = "left"
+ if align == "r": align = "right"
+
+ widget = None
+ if image_url:
+ try: path = url_delegate.resolve_image(image_url)
+ except Exception as e:
+ RNS.trace_exception(e) # TODO: Remove
+ path = None
+
+ if ImageWidget is not None:
+ try:
+ if not ImageWidget.placeholder_glyph: ImageWidget.placeholder_glyph = f"{url_delegate.g['image']} "
+
+ widget = ImageWidget(path, name=alt_text)
+ for prop_name, prop_value in ( ("width", width), ("height", height), ("align", align) ):
+ if prop_value is not None:
+ try:
+ if isinstance(prop_value, str) and prop_value.isdigit(): prop_value = int(prop_value)
+ setattr(widget, prop_name, prop_value)
+ except Exception as e:
+ RNS.trace_exception(e) # TODO: Remove
+ pass
+
+ except Exception as e:
+ RNS.trace_exception(e) # TODO: Remove
+ widget = None
+
+ if widget is None:
+ placeholder = alt_text if alt_text else (image_url or "image")
+ widget = urwid.Text(f"[{url_delegate.g['warning']} Error loading image: {placeholder}]")
+
+ widget.image_url = image_url
+ widget.image_alt = alt_text
+ if state["depth"] == 0: return [widget]
+ else:
+ padded = urwid.Padding(widget, left=left_indent(state), right=right_indent(state))
+ padded._contained_image = widget
+ return [padded]
+
+ except Exception as e:
+ # TODO: Remove
+ RNS.trace_exception(e)
+ return None
+
+def render_table(lines, state, url_delegate):
+ if len(lines) < 2: return None
+ if state["table_maxwidth"]: max_width = state["table_maxwidth"]
+ else: max_width = MAX_TABLE_WIDTH
+ if state["table_align"]: align = state["table_align"]
+ else: align = None
+
+ converter = MarkdownToMicron(max_width=max_width)
+ micron_lines = converter.format_table_raw(lines, align=align)
+
+ widgets = []
+ for line in micron_lines:
+ # Disable table mode to avoid recursion
+ was_table_mode = state["table_mode"]
+ state["table_mode"] = False
+
+ line_widgets = parse_line(line, state, url_delegate)
+ state["table_mode"] = was_table_mode
+
+ if line_widgets: widgets.extend(line_widgets)
+
+ return widgets if widgets else None
+
+class FormColumns(urwid.Columns):
+ MIN_FIELD_WIDTH = 4
+
+ @staticmethod
+ def _natural_width(widget, maxcol, focused):
+ # mirror the width urwid.Columns itself computes for a PACK column
+ if not isinstance(widget, urwid.Widget):
+ return 0
+ sizing = widget.sizing()
+ if sizing & frozenset((urwid.FIXED, urwid.FLOW)):
+ width = 0
+ if urwid.FIXED in sizing:
+ width = widget.pack((), focused)[0]
+ if urwid.FLOW in sizing and (not width or width > maxcol):
+ width = widget.pack((maxcol,), focused)[0]
+ return width
+ return widget.pack((maxcol,), focused)[0]
+
+ def column_widths(self, size, focus=False):
+ contents = self.contents
+ field_idxs = [i for i, (w, (t, n, b)) in enumerate(contents) if t == urwid.GIVEN]
+
+ if not field_idxs:
+ return super().column_widths(size, focus)
+
+ maxcol = size[0]
+ nat = []
+ for i, (w, (t, width, b)) in enumerate(contents):
+ if t == urwid.GIVEN:
+ nat.append(width)
+ elif t == urwid.PACK:
+ nat.append(self._natural_width(w, maxcol, focus and i == self.focus_position))
+ else:
+ return super().column_widths(size, focus)
+
+ dividers = self.dividechars * max(0, len(nat) - 1)
+ field_set = set(field_idxs)
+ fixed = sum(w for i, w in enumerate(nat) if i not in field_set)
+ requested = sum(nat[i] for i in field_idxs)
+ available = maxcol - dividers - fixed
+
+ if available >= requested:
+ return super().column_widths(size, focus)
+
+ if available < self.MIN_FIELD_WIDTH * len(field_idxs):
+ return super().column_widths(size, focus)
+
+ overflow = requested - available
+ slack = {i: max(0, nat[i] - self.MIN_FIELD_WIDTH) for i in field_idxs}
+ total_slack = sum(slack.values())
+ if total_slack < overflow:
+ return super().column_widths(size, focus)
+
+ widths = list(nat)
+ removed = {i: min(slack[i], overflow * slack[i] // total_slack) for i in field_idxs}
+ shortfall = overflow - sum(removed.values())
+ # Hand out any rounding remainder one column at a time.
+ pending = [i for i in field_idxs if removed[i] < slack[i]]
+ k = 0
+ while shortfall > 0 and pending:
+ i = pending[k % len(pending)]
+ removed[i] += 1
+ shortfall -= 1
+ if removed[i] >= slack[i]:
+ pending = [j for j in pending if removed[j] < slack[j]]
+ k = 0
+ else:
+ k += 1
+ for i in field_idxs:
+ widths[i] = nat[i] - removed[i]
+ return widths
+
+
+class MicronField(ReadlineEdit):
+ def __init__(self, *args, min_rows=1, **kwargs):
+ try: self._min_rows = max(1, int(min_rows))
+ except (TypeError, ValueError): self._min_rows = 1
+ super().__init__(*args, **kwargs)
+
+ def rows(self, size, focus=False):
+ return max(self._min_rows, super().rows(size, focus))
+
+ def render(self, size, focus=False):
+ canv = super().render(size, focus)
+ if canv.rows() < self._min_rows:
+ canv = urwid.CompositeCanvas(canv)
+ canv.pad_trim_top_bottom(0, self._min_rows - canv.rows())
+ return canv
+
+
+class CollapsibleHeading(urwid.WidgetWrap):
+ _selectable = True
+ ignore_focus = False
+
+ def __init__(self, segments, align, heading_style, depth,
+ collapsed=False, glyphs=None, delegate=None):
+ self._segments = list(segments)
+ self.align = align
+ self.heading_style = heading_style
+ self.depth = depth
+ self.collapsed = bool(collapsed)
+ self.glyphs = glyphs if glyphs is not None else default_fold_glyphs()
+ self.delegate = delegate
+ self.is_collapsible_heading = True
+ super().__init__(self._build())
+
+ def selectable(self):
+ return True
+
+ def _glyph(self):
+ return self.glyphs[1] if self.collapsed else self.glyphs[0]
+
+ def _glyph_col(self):
+ if self._segments and isinstance(self._segments[0], str):
+ return len(self._segments[0])
+ return 0
+
+ def _build(self):
+ segs = list(self._segments)
+ marker = self._glyph() + " "
+ if segs and isinstance(segs[0], str):
+ segs.insert(1, marker)
+ else:
+ segs.insert(0, marker)
+ return urwid.AttrMap(urwid.Text(segs, align=self.align), self.heading_style)
+
+ def set_collapsed(self, collapsed):
+ collapsed = bool(collapsed)
+ if collapsed != self.collapsed:
+ self.collapsed = collapsed
+ self._w = self._build()
+ self._invalidate()
+
+ def toggle(self):
+ self.collapsed = not self.collapsed
+ self._w = self._build()
+ self._invalidate()
+ if self.delegate is not None and hasattr(self.delegate, "fold_changed"):
+ self.delegate.fold_changed(self)
+
+ def keypress(self, size, key):
+ if self._command_map[key] == urwid.ACTIVATE or key == " ":
+ self.toggle()
+ return None
+ return key
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button == 1 and is_mouse_press(event):
+ self.toggle()
+ return True
+ return False
+
+ def render(self, size, focus=False):
+ canv = super().render(size, focus)
+ if focus:
+ canv = urwid.CompositeCanvas(canv)
+ gx = self._glyph_col()
+ if self.align == "left" and gx < size[0]:
+ canv.cursor = (gx, 0)
+ return canv
+
+
+def parse_line(line, state, url_delegate):
+ pre_escape = False
+ if len(line) > 0:
+ first_char = line[0]
+
+ # 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"]:
+ collapsible_heading = False
+ collapsed_initial = False
+ if (line.startswith("`+") or line.startswith("`-")) and line[2:3] == ">":
+ collapsible_heading = True
+ collapsed_initial = line[1] == "-"
+ line = line[2:]
+ first_char = line[0]
+
+ # Apply markup sanitization
+ if first_char == ">" and "`<" in line:
+ # Remove heading status from lines containing fields
+ line = line.lstrip(">")
+ first_char = line[0]
+
+ # Check if the command is an escape
+ if first_char == "\\":
+ line = line[1:]
+ pre_escape = True
+
+ # Check for comments
+ elif first_char == "#":
+ parts = line.split()
+ if parts and parts[0] == "#!fold":
+ if len(parts) >= 3: state["fold_glyphs"] = (parts[1], parts[2])
+ elif len(parts) == 2: state["fold_glyphs"] = (parts[1], parts[1])
+ return None
+
+ # Check for tables
+ if line.startswith("`t"):
+ line = line[2:]
+ align = line[0] if len(line) and line[0] in ["l", "c", "r"] else None
+ max_width = None
+ if align: line = line[1:]
+ if len(line):
+ try: max_width = int(line)
+ except: pass
+
+ if state["table_mode"]:
+ widgets = render_table(state["table_buffer"], state, url_delegate)
+ state["table_mode"] = False
+ state["table_buffer"] = []
+ state["table_align"] = None
+ state["table_maxwidth"] = MAX_TABLE_WIDTH
+ return widgets
+
+ else:
+ state["table_mode"] = True
+ state["table_buffer"] = []
+ state["table_align"] = align
+ state["table_maxwidth"] = max_width
+ return None
+
+ # Buffer the line if in table mode
+ if state["table_mode"]:
+ state["table_buffer"].append(line)
+ return None
+
+ # Check for partials
+ elif line.startswith("`{"):
+ return parse_partial(line[2:])
+
+ # Check for images
+ elif line.startswith("`("):
+ return parse_image(line[2:], state, url_delegate)
+
+ # Check for section heading reset
+ elif first_char == "<":
+ state["depth"] = 0
+ 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))
+ if collapsible_heading:
+ state["_collapsible_pending"] = {"collapsed": collapsed_initial}
+ glyphs = state.get("fold_glyphs") or default_fold_glyphs()
+ return [CollapsibleHeading(output, state["align"], heading_style,
+ state["depth"], collapsed_initial, glyphs, url_delegate)]
+ return [urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)]
+ else:
+ return None
+ else:
+ 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"
+ # Check if the divider char is renderable
+ try: urwid.Divider(divider_char).render((30,))
+ except Exception as e: divider_char = "\u2500"
+ else: divider_char = "\u2500"
+
+ 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"]
+ frows = o.get("rows", 1)
+ fmask = "*" if o["masked"] else None
+ f = MicronField(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask, min_rows=frows)
+ f.field_name = fn
+ fa = urwid.AttrMap(f, fs)
+ widgets.append((fw, fa))
+ elif o["type"] == "checkbox":
+ fn = o["name"]
+ fv = o["value"]
+ flabel = o["label"]
+ fs = o["style"]
+ fprechecked = o.get("prechecked", False)
+ f = urwid.CheckBox(flabel, state=fprechecked)
+ f.field_name = fn
+ f.field_value = fv
+ fa = urwid.AttrMap(f, fs)
+ widgets.append((urwid.PACK, fa))
+ elif o["type"] == "radio":
+ fn = o["name"]
+ fv = o["value"]
+ flabel = o["label"]
+ fs = o["style"]
+ fprechecked = o.get("prechecked", False)
+ if "radio_groups" not in state:
+ state["radio_groups"] = {}
+ if fn not in state["radio_groups"]:
+ state["radio_groups"][fn] = []
+ group = state["radio_groups"][fn]
+ f = urwid.RadioButton(group, flabel, state=fprechecked, user_data=fv)
+ f.field_name = fn
+ f.field_value = fv
+ fa = urwid.AttrMap(f, fs)
+ widgets.append((urwid.PACK, fa))
+
+
+
+
+ columns_widget = FormColumns(widgets, dividechars=0)
+ text_widget = columns_widget
+ # text_widget = urwid.Text("<"+output+">", align=state["align"])
+
+ else:
+ 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_rows = 1
+ field_type = "field"
+ field_name = field_content
+ field_value = ""
+ field_data = ""
+ field_prechecked = False
+
+ # check if field_content contains '|'
+ if '|' in field_content:
+ f_components = field_content.split('|')
+ field_flags = f_components[0]
+ field_name = f_components[1]
+
+ # handle field type indicators
+ if '^' in field_flags:
+ field_type = "radio"
+ field_flags = field_flags.replace("^", "")
+ elif '?' in field_flags:
+ field_type = "checkbox"
+ field_flags = field_flags.replace("?", "")
+ elif '!' in field_flags:
+ field_flags = field_flags.replace("!", "")
+ field_masked = True
+
+ # Handle field width
+ if len(field_flags) > 0:
+ width_flag, _, rows_flag = field_flags.partition("x")
+ try:
+ field_width = min(int(width_flag), 256)
+ except ValueError:
+ pass # Ignore invalid width
+ if rows_flag:
+ try:
+ field_rows = max(1, min(int(rows_flag), 256))
+ except ValueError:
+ pass
+
+ # Check for value and pre-checked flag
+ if len(f_components) > 2:
+ field_value = f_components[2]
+ else:
+ field_value = ""
+ if len(f_components) > 3:
+ if f_components[3] == '*':
+ field_prechecked = True
+
+ else:
+ # No '|', so field_name is field_content
+ field_name = field_content
+ field_type = "field"
+ field_masked = False
+ field_width = 24
+ field_value = ""
+ field_prechecked = False
+
+ # Find the closing '>' character
+ field_end = line.find('>', backtick_pos)
+ if field_end == -1:
+ pass # No closing '>', invalid field
+ else:
+ field_data = line[backtick_pos+1:field_end]
+
+ # Now, we have all field data
+ if field_type in ["checkbox", "radio"]:
+ # for checkboxes and radios, field_data is the label
+ output.append({
+ "type": field_type,
+ "name": field_name,
+ "value": field_value if field_value else field_data,
+ "label": field_data,
+ "prechecked": field_prechecked,
+ "style": make_style(state)
+ })
+ else:
+ # For text fields field_data is the initial text
+ output.append({
+ "type": "field",
+ "name": field_name,
+ "width": field_width,
+ "rows": field_rows,
+ "masked": field_masked,
+ "data": field_data,
+ "style": make_style(state)
+ })
+ skip = field_end - i
+ except Exception as e:
+ pass
+
+
+ elif c == "[":
+ endpos = line[i:].find("]")
+ if endpos == -1:
+ 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._cursor_promised = None
+ self._cursor_promised_at = 0
+ self.key_timeout = 2
+ self.in_columns = False
+ if self.delegate != None:
+ self.delegate.last_keypress = 0
+
+ def handle_link(self, link_target, 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 cursor_visible(self):
+ return self.delegate == None or time.time() < self.delegate.last_keypress+self.key_timeout
+
+ def render(self, size, focus=False):
+ c = super().render(size, focus)
+ if focus:
+ if time.time() < self._cursor_promised_at+0.5:
+ coords = self._cursor_promised
+ else:
+ coords = self.get_cursor_coords(size)
+ if coords != None:
+ c = urwid.CompositeCanvas(c)
+ c.cursor = coords
+ if self.delegate != None:
+ self.peek_link()
+ return c
+
+ def get_cursor_coords(self, size):
+ self._cursor_promised_at = time.time()
+ self._cursor_promised = None
+ if not self.cursor_visible() or self._cursor_position > len(self.text):
+ return None
+ (maxcol,) = size
+ trans = self.get_line_translation(maxcol)
+ x, y = calc_coords(self.text, trans, self._cursor_position)
+ if maxcol <= x:
+ return None
+ self._cursor_promised = (x, y)
+ return x, y
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ 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
index 79ad293..11c4e54 100644
--- a/nomadnet/ui/textui/Network.py
+++ b/nomadnet/ui/textui/Network.py
@@ -1,21 +1,52 @@
import RNS
import urwid
import nomadnet
+import time
+import threading
from datetime import datetime
from nomadnet.Directory import DirectoryEntry
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY
+from nomadnet.util import strip_modifiers
+from nomadnet.util import sanitize_name
+
+from .Browser import Browser
+from .ReadlineEdit import ReadlineEdit
+from .Helpers import qr_ascii
class NetworkDisplayShortcuts():
def __init__(self, app):
self.app = app
+ g = app.ui.glyphs
- self.widget = urwid.AttrMap(urwid.Text("Network Display Shortcuts"), "shortcutbar")
+ shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-g] Fullscreen"
+ self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar")
+class BrowserShortcuts():
+ def __init__(self, app, browser):
+ self.app = app
+ self.browser = browser
+ g = app.ui.glyphs
+
+ shortcut_text = "[C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL "
+ if app.config["textui"]["clipboard_copy"]: shortcut_text += "[C-y] Copy "
+ shortcut_text += "[C-g] Fullscreen [C-s/b] Save Node"
+ if browser.image_rendering_supported: shortcut_text += " [C-l] Load Images [C-x] Reload Images"
+ self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar")
class DialogLineBox(urwid.LineBox):
def keypress(self, size, key):
if key == "esc":
- self.delegate.update_conversation_list()
+ delegate = getattr(self, "delegate", None)
+ if delegate is not None and hasattr(delegate, "update_conversation_list"):
+ delegate.update_conversation_list()
+ elif delegate is not None and getattr(delegate, "parent", None) is not None:
+ try:
+ delegate.dialog_open = False
+ options = delegate.parent.left_pile.options(height_type=urwid.PACK, height_amount=None)
+ delegate.parent.left_pile.contents[1] = (type(delegate)(delegate.app, delegate.parent), options)
+ except Exception:
+ pass
+ return None
else:
return super(DialogLineBox, self).keypress(size, key)
@@ -44,42 +75,341 @@ class ListEntry(urwid.Text):
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 = "NomadNet Node " + g["node"]
+ is_node = True
+ elif info_type == "pn":
+ type_string = "LXMF Propagation Node " + g["sent"]
+ is_pn = True
+ elif info_type == "peer" or info_type == False:
+ type_string = "Peer " + g["peer"]
+
+ try:
+ data_str = 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(op_hash, display_name, trust_level)
+ self.app.directory.remember(entry)
+
+ new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True)
+ 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, timestamp, source_hash):
+ 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)
- ts_string = dt.strftime(time_format)
+ 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)
- display_str = self.app.directory.simplest_display_str(source_hash)
+
+ def san(name):
+ if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name)
+ else: return strip_modifiers(name)
+
+ if show_destination:
+ display_str = RNS.hexrep(source_hash, delimit=False)
+ else:
+ try: display_str = san(announce[2].decode("utf-8"))
+ except: display_str = self.app.directory.simplest_display_str(source_hash)
+
+ if not display_str: display_str = RNS.prettyhexrep(source_hash)
+ if len(display_str) > 34: display_str = display_str[:34] + "…"
if trust_level == DirectoryEntry.UNTRUSTED:
- symbol = "\u2715"
+ symbol = g["cross"]
style = "list_untrusted"
focus_style = "list_focus_untrusted"
elif trust_level == DirectoryEntry.UNKNOWN:
- symbol = "?"
+ symbol = g["unknown"]
style = "list_unknown"
focus_style = "list_focus"
elif trust_level == DirectoryEntry.TRUSTED:
- symbol = "\u2713"
+ symbol = g["check"]
style = "list_trusted"
focus_style = "list_focus_trusted"
elif trust_level == DirectoryEntry.WARNING:
- symbol = "\u26A0"
+ symbol = g["warning"]
style = "list_warning"
focus_style = "list_focus"
else:
- symbol = "\u26A0"
+ symbol = g["warning"]
style = "list_untrusted"
focus_style = "list_focus_untrusted"
- widget = ListEntry(ts_string+" "+display_str)
+ 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)
- urwid.WidgetWrap.__init__(self, self.display_widget)
+ super().__init__(self.display_widget)
+
+ def display_announce(self, event, announce):
+ try:
+ parent = self.app.ui.main_display.sub_displays.network_display
+ info_widget = AnnounceInfo(announce, parent, self.app)
+ options = parent.left_pile.options(height_type=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):
@@ -88,43 +418,146 @@ class AnnounceStream(urwid.WidgetWrap):
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()
- wlt = [AnnounceStreamEntry(self.app, e[0], e[1]) for e in self.app.directory.announce_stream]
- self.ilb = IndicativeListBox(
+ self.ilb = ExceptionHandlingListBox(
self.widget_list,
- #wlt,
on_selection_change=self.list_selection,
initialization_is_selection_change=False,
- modifier_key=MODIFIER_KEY.CTRL,
+ #modifier_key=MODIFIER_KEY.CTRL,
#highlight_offFocus="list_off_focus"
)
- self.display_widget = self.ilb
- urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Announce Stream"))
+ self.pile = urwid.Pile([
+ ('pack', self.tab_bar),
+ ('pack', self.filter_bar),
+ ('weight', 1, self.ilb),
+ ])
+
+ self.display_widget = self.pile
+ super().__init__(urwid.LineBox(self.display_widget, title="Announce Stream"))
+
+ def keypress(self, size, key):
+ if key == "up" and self.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:
- if not e[0] in self.added_entries:
- self.added_entries.insert(0, e[0])
- new_entries.insert(0, e)
+ announce_type = e[3]
- new_widgets = [AnnounceStreamEntry(self.app, e[0], e[1]) for e in new_entries]
- for nw in new_widgets:
- self.widget_list.insert(0, nw)
+ 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 len(new_widgets) > 0:
- if self.ilb != None:
- self.ilb.set_body(self.widget_list)
+ 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
@@ -170,13 +603,263 @@ class SelectText(urwid.Text):
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 = "NomadNet 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(op_hash, display_name, trust_level)
+ self.app.directory.remember(entry)
+
+ new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True)
+ 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()
-
- self.ilb = IndicativeListBox(
- self.make_node_widgets(),
+ 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"
@@ -189,29 +872,176 @@ class KnownNodes(urwid.WidgetWrap):
else:
self.no_content = True
widget_style = "inactive_text"
- self.display_widget = urwid.Pile([urwid.Text(("warning_text", "- i -\n"), align="center"), SelectText(("warning_text", "Currently, no nodes are known\n\n"), align="center")])
+ self.pile = urwid.Pile([
+ urwid.Text(("warning_text", g["info"]+"\n"), align=urwid.CENTER),
+ SelectText(
+ (
+ "warning_text",
+ "Currently, no nodes are saved\n\nCtrl+L to view the announce stream\n\n",
+ ),
+ align=urwid.CENTER,
+ ),
+ ])
+ self.display_widget = urwid.Filler(self.pile, valign=urwid.TOP, height=urwid.PACK)
- urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Known Nodes"), widget_style))
+ super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title="Saved Nodes"), widget_style))
def keypress(self, size, key):
- if key == "up" and (self.no_content or self.ilb.top_is_visible):
- nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.set_focus("header")
-
+ 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
- widget_list.append(ListEntry("Node "+RNS.prettyhexrep(node_entry.source_hash)))
+ 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):
@@ -221,14 +1051,14 @@ class AnnounceTime(urwid.WidgetWrap):
self.display_widget = urwid.Text("")
self.update_time()
- urwid.WidgetWrap.__init__(self, self.display_widget)
+ super().__init__(self.display_widget)
def update_time(self):
self.last_announce_string = "Never"
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("Last Announce : "+self.last_announce_string)
+ self.display_widget.set_text("Announced : "+self.last_announce_string)
def update_time_callback(self, loop=None, user_data=None):
self.update_time()
@@ -245,69 +1075,272 @@ class AnnounceTime(urwid.WidgetWrap):
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("Addr : "+RNS.hexrep(self.app.lxmf_destination.hash, delimit=False))
- e_name = urwid.Edit(caption="Name : ", edit_text=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[3] = (LocalPeer(self.app, self.parent), options)
+ 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="center"),
+ urwid.Text("\n\n\nSaved\n\n", align=urwid.CENTER),
urwid.Button("OK", on_press=dismiss_dialog)
- ]), title="i"
+ ]), title=g["info"]
)
dialog.delegate = self
bottom = self
- #overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=4, right=4)
+ #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4)
overlay = dialog
- options = self.parent.left_pile.options(height_type="pack", height_amount=None)
+ options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None)
self.dialog_open = True
- self.parent.left_pile.contents[3] = (overlay, options)
+ self.parent.left_pile.contents[1] = (overlay, options)
def announce_query(sender):
def dismiss_dialog(sender):
self.dialog_open = False
- options = self.parent.left_pile.options(height_type="pack", height_amount=None)
- self.parent.left_pile.contents[3] = (LocalPeer(self.app, self.parent), options)
+ 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", align="center"),
+ urwid.Text("\n\n\nAnnounce Sent\n\n\n", align=urwid.CENTER),
urwid.Button("OK", on_press=dismiss_dialog)
- ]), title="i"
+ ]), title=g["info"]
)
dialog.delegate = self
bottom = self
- #overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=4, right=4)
+ #overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=4, right=4)
overlay = dialog
-
- self.dialog_open = True
- options = self.parent.left_pile.options(height_type="pack", height_amount=None)
- self.parent.left_pile.contents[3] = (overlay, options)
- def node_settings_query(sender):
- options = self.parent.left_pile.options(height_type="pack", height_amount=None)
- self.parent.left_pile.contents[3] = (self.parent.node_settings_display, options)
+ 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)
@@ -317,56 +1350,257 @@ class LocalPeer(urwid.WidgetWrap):
self.t_last_announce.update_time()
announce_button = urwid.Button("Announce Now", on_press=announce_query)
-
- self.display_widget = urwid.Pile(
- [
- t_id,
- e_name,
- urwid.Divider("\u2504"),
- self.t_last_announce,
- announce_button,
- urwid.Divider("\u2504"),
- urwid.Columns([("weight", 0.45, urwid.Button("Save", on_press=save_query)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Node Cfg", on_press=node_settings_query))])
- ]
- )
- urwid.WidgetWrap.__init__(self, urwid.LineBox(self.display_widget, title="Local Peer Info"))
+ peer_widgets = [
+ t_id,
+ i_id,
+ e_name,
+ urwid.Divider(g["divider1"]),
+ self.t_last_announce,
+ announce_button,
+ urwid.Divider(g["divider1"]),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)),
+ ]),
+ ]
+
+ if self.app.enable_node:
+ peer_widgets.append(
+ urwid.AttrMap(urwid.Text(" "+g["node"]+" Node Online", align=urwid.LEFT), "shortcutbar")
+ )
+
+ self.display_widget = urwid.Pile(peer_widgets)
+
+ super().__init__(urwid.LineBox(self.display_widget, title="Local Peer Info"))
def start(self):
self.t_last_announce.start()
-class NodeSettings(urwid.WidgetWrap):
+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="pack", height_amount=None)
- self.parent.left_pile.contents[3] = (LocalPeer(self.app, self.parent), options)
+ 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)
- widget_style = "inactive_text"
- pile = urwid.Pile([
- urwid.Text("- i -\n", align="center"),
- urwid.Text("\nNode Hosting currently unavailable\n\n", align="center"),
- urwid.Padding(urwid.Button("Back", on_press=show_peer_info), "center", "pack")
- ])
+ 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))
+
+ def edit_query(sender):
+ self.app.ui.main_display.show_page_editor(None)
+
+ def show_qr_query(sender):
+ def dismiss_dialog(sender):
+ self.dialog_open = False
+ options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None)
+ self.parent.left_pile.contents[1] = (NodeInfo(self.app, self.parent), options)
+
+ dest_addr = RNS.hexrep(self.app.node.destination.hash, delimit=False)
+ qr_text = qr_ascii(dest_addr)
+
+ rows = [urwid.Text("")]
+ if qr_text is not None:
+ rows.append(urwid.Text(qr_text, align=urwid.CENTER))
+ else:
+ rows.append(urwid.Text("Node destination address:", align=urwid.CENTER))
+ rows += [
+ urwid.Text(""),
+ urwid.Text("< "+dest_addr+" >", align=urwid.CENTER),
+ urwid.Text(""),
+ urwid.Button("Close", on_press=dismiss_dialog),
+ ]
+
+ dialog = DialogLineBox(urwid.Pile(rows), title="Node Destination")
+ dialog.delegate = self
+
+ self.dialog_open = True
+ options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None)
+ self.parent.left_pile.contents[1] = (dialog, options)
+
+ if NodeInfo.announce_timer == None:
+ self.t_last_announce = NodeAnnounceTime(self.app)
+ NodeInfo.announce_timer = self.t_last_announce
+ 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)
+ edit_button = urwid.Button("Edit", on_press=edit_query)
+ reset_button = urwid.Button("Rst Stats", on_press=stats_query)
+ qr_button = urwid.Button("Show Destination", on_press=show_qr_query)
+
+ if not self.app.disable_propagation:
+ pile = urwid.Pile([
+ t_id,
+ e_name,
+ urwid.Divider(g["divider1"]),
+ e_lxmf,
+ urwid.Divider(g["divider1"]),
+ self.t_last_announce,
+ self.t_storage_stats,
+ self.t_active_links,
+ self.t_total_connections,
+ self.t_total_pages,
+ self.t_total_files,
+ urwid.Divider(g["divider1"]),
+ urwid.Columns([
+ (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)),
+ (urwid.WEIGHT, 0.5, urwid.Text("")),
+ (urwid.WEIGHT, 6, connect_button),
+ (urwid.WEIGHT, 0.5, urwid.Text("")),
+ (urwid.WEIGHT, 8, reset_button),
+ (urwid.WEIGHT, 0.5, urwid.Text("")),
+ (urwid.WEIGHT, 7, announce_button),
+ ]),
+ urwid.Columns([
+ (urwid.WEIGHT, 4, edit_button),
+ (urwid.WEIGHT, 0.5, urwid.Text("")),
+ (urwid.WEIGHT, 9, qr_button),
+ ])
+ ])
+ else:
+ pile = urwid.Pile([
+ t_id,
+ e_name,
+ urwid.Divider(g["divider1"]),
+ self.t_last_announce,
+ self.t_storage_stats,
+ self.t_active_links,
+ self.t_total_connections,
+ self.t_total_pages,
+ self.t_total_files,
+ urwid.Divider(g["divider1"]),
+ urwid.Columns([
+ (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)),
+ (urwid.WEIGHT, 0.5, urwid.Text("")),
+ (urwid.WEIGHT, 6, connect_button),
+ (urwid.WEIGHT, 0.5, urwid.Text("")),
+ (urwid.WEIGHT, 8, reset_button),
+ (urwid.WEIGHT, 0.5, urwid.Text("")),
+ (urwid.WEIGHT, 7, announce_button),
+ ]),
+ urwid.Columns([
+ (urwid.WEIGHT, 4, edit_button),
+ (urwid.WEIGHT, 0.5, urwid.Text("")),
+ (urwid.WEIGHT, 9, qr_button),
+ ])
+ ])
+ else:
+ pile = urwid.Pile([
+ urwid.Text("\n"+g["info"], align=urwid.CENTER),
+ urwid.Text("\nThis instance is not hosting a node\n\n", align=urwid.CENTER),
+ urwid.Padding(urwid.Button("Back", on_press=show_peer_info), urwid.CENTER, urwid.PACK)
+ ])
self.display_widget = pile
- urwid.WidgetWrap.__init__(self, urwid.AttrMap(urwid.LineBox(self.display_widget, title="Node Settings"), widget_style))
+ super().__init__(urwid.AttrMap(urwid.LineBox(self.display_widget, title="Local Node Info"), widget_style))
- def node_list_selection(self, arg1, arg2):
- pass
-
- def make_node_widgets(self):
- widget_list = []
- for node_entry in self.node_list:
- # TODO: Implement this
- widget_list.append(ListEntry("Node "+RNS.prettyhexrep(node_entry.source_hash)))
-
- # TODO: Sort list
- return widget_list
+ 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):
@@ -381,7 +1615,7 @@ class UpdatingText(urwid.WidgetWrap):
self.append_text = append_text
self.update()
- urwid.WidgetWrap.__init__(self, self.display_widget)
+ super().__init__(self.display_widget)
def update(self):
self.value = self.value_method()
@@ -413,7 +1647,7 @@ class NetworkStats(urwid.WidgetWrap):
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=" (last 30m)")
+ 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([
@@ -423,57 +1657,355 @@ class NetworkStats(urwid.WidgetWrap):
self.display_widget = urwid.LineBox(pile, title="Network Stats")
- urwid.WidgetWrap.__init__(self, self.display_widget)
+ super().__init__(self.display_widget)
def start(self):
self.w_heard_peers.start()
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_settings_display = NodeSettings(self.app, self)
+ self.node_info_display = NodeInfo(self.app, self)
- self.left_pile = urwid.Pile([
- ("pack", self.known_nodes_display),
- ("weight", 1, self.announce_stream_display),
- ("pack", self.network_stats_display),
- ("pack", self.local_peer_display),
+ self.known_nodes_display.delegate = self
+
+ self.list_display = 1
+ default_info_display = self.node_info_display if self.app.enable_node else self.local_peer_display
+ self.left_pile = NetworkLeftPile([
+ (urwid.WEIGHT, 1, self.known_nodes_display),
+ # (urwid.PACK, self.network_stats_display),
+ (urwid.PACK, default_info_display),
])
+ self.left_pile.parent = self
+
self.left_area = self.left_pile
- self.right_area = urwid.AttrMap(urwid.LineBox(urwid.Filler(urwid.Text("Disconnected\n\u2190 \u2192", align="center"), "middle"), title="Remote Node"), "inactive_text")
+ self.right_area = self.browser.display_widget
+ self.right_area_width = 1-NetworkDisplay.list_width
self.columns = urwid.Columns(
[
- ("weight", NetworkDisplay.list_width, self.left_area),
- ("weight", 1-NetworkDisplay.list_width, self.right_area)
+ # (urwid.WEIGHT, NetworkDisplay.list_width, self.left_area),
+ # (urwid.WEIGHT, self.right_area_width, self.right_area)
+ (NetworkDisplay.given_list_width, self.left_area),
+ (urwid.WEIGHT, 1, self.right_area)
],
dividechars=0, focus_column=0
)
- self.shortcuts_display = NetworkDisplayShortcuts(self.app)
+ self.list_shortcuts = NetworkDisplayShortcuts(self.app)
+ self.browser_shortcuts = BrowserShortcuts(self.app, self.browser)
+ self.shortcuts_display = self.list_shortcuts
self.widget = self.columns
+ def toggle_list(self):
+ if self.list_display != 0:
+ options = self.left_pile.options(height_type=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()
- self.announce_stream_display.start()
def shortcuts(self):
+ try: focus_path = self.columns.get_focus_path()
+ except Exception: return self.list_shortcuts
+ try:
+ if focus_path[0] == 0: return self.list_shortcuts
+ if focus_path[0] == 1: return self.browser_shortcuts
+ except: pass
return self.shortcuts_display
def directory_change_callback(self):
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):
"""
@@ -484,13 +2016,14 @@ def pretty_date(time=False):
from datetime import datetime
now = datetime.now()
if type(time) is int:
- diff = now - datetime.fromtimestamp(time)
+ then = datetime.fromtimestamp(time)
elif isinstance(time,datetime):
- diff = now - time
+ then = time
elif not time:
- diff = now - now
+ then = now
+ diff = now - then
second_diff = diff.seconds
- day_diff = diff.days
+ day_diff = (now.date() - then.date()).days
if day_diff < 0:
return ''
@@ -516,4 +2049,4 @@ def pretty_date(time=False):
return str(int(day_diff / 7)) + " weeks ago"
if day_diff < 365:
return str(int(day_diff / 30)) + " months ago"
- return str(int(day_diff / 365)) + " years ago"
+ return str(int(day_diff / 365)) + " years ago"
\ No newline at end of file
diff --git a/nomadnet/ui/textui/ReadlineEdit.py b/nomadnet/ui/textui/ReadlineEdit.py
new file mode 100644
index 0000000..85b8dd9
--- /dev/null
+++ b/nomadnet/ui/textui/ReadlineEdit.py
@@ -0,0 +1,150 @@
+import urwid
+
+
+_KILL_KEYS = frozenset(("ctrl u", "ctrl k", "ctrl w", "ctrl l"))
+
+
+class _KillRing:
+ """Module-global kill buffer shared across all ReadlineMixin widgets.
+
+ Mirrors GNU readline: consecutive kills accumulate into the same entry
+ (forward kills append, backward kills prepend); any non-kill keypress
+ breaks the chain so the next kill replaces the buffer.
+ """
+ text = ""
+ last_was_kill = False
+
+ @classmethod
+ def reset_chain(cls):
+ cls.last_was_kill = False
+
+ @classmethod
+ def kill(cls, killed, direction):
+ if not killed:
+ return
+ if cls.last_was_kill:
+ cls.text = cls.text + killed if direction == "forward" else killed + cls.text
+ else:
+ cls.text = killed
+ cls.last_was_kill = True
+
+
+class ReadlineMixin:
+ """Mixin adding readline-style editing keys to an urwid.Edit-derived widget.
+
+ Bindings (GNU readline defaults, plus ctrl-l as kill-whole-buffer):
+ ctrl-a beginning-of-line
+ ctrl-e end-of-line
+ ctrl-u unix-line-discard (kill from cursor to beginning of line)
+ ctrl-k kill-line (kill from cursor to end of line)
+ ctrl-w unix-word-rubout (kill previous whitespace-delimited word)
+ ctrl-l kill-whole-buffer (kill the entire edit buffer)
+ ctrl-y yank (insert most-recently-killed text)
+ ctrl-left backward-word (alphanumeric boundary)
+ ctrl-right forward-word (alphanumeric boundary)
+
+ "Line" is the current logical line within the edit buffer, delimited by
+ newlines -- so on multiline Edits these act on the line under the cursor,
+ not the entire buffer.
+
+ The kill buffer is shared across all widgets using this mixin, so text
+ killed in one Edit can be yanked into another.
+ """
+
+ def keypress(self, size, key):
+ if key == "ctrl a": self._rl_beg_of_line()
+ elif key == "ctrl e": self._rl_end_of_line()
+ elif key == "ctrl u": self._rl_kill_to_beg()
+ elif key == "ctrl k": self._rl_kill_to_end()
+ elif key == "ctrl w": self._rl_kill_word_back()
+ elif key == "ctrl l": self._rl_kill_whole_buffer()
+ elif key == "ctrl y": self._rl_yank()
+ elif key == "ctrl left": self._rl_backward_word()
+ elif key == "ctrl right": self._rl_forward_word()
+ else:
+ result = super().keypress(size, key)
+ if key not in _KILL_KEYS:
+ _KillRing.reset_chain()
+ return result
+ if key not in _KILL_KEYS:
+ _KillRing.reset_chain()
+ return None
+
+ def _rl_line_bounds(self):
+ text, pos = self.edit_text, self.edit_pos
+ bol = text.rfind("\n", 0, pos)
+ bol = 0 if bol == -1 else bol + 1
+ eol = text.find("\n", pos)
+ eol = len(text) if eol == -1 else eol
+ return bol, eol
+
+ def _rl_delete(self, start, end, kill_direction=None):
+ if start == end:
+ return
+ text = self.edit_text
+ if kill_direction is not None:
+ _KillRing.kill(text[start:end], kill_direction)
+ self.set_edit_text(text[:start] + text[end:])
+ self.set_edit_pos(start)
+
+ @staticmethod
+ def _rl_is_word_char(ch):
+ return ch.isalnum() or ch == "_"
+
+ def _rl_beg_of_line(self):
+ bol, _ = self._rl_line_bounds()
+ self.set_edit_pos(bol)
+
+ def _rl_end_of_line(self):
+ _, eol = self._rl_line_bounds()
+ self.set_edit_pos(eol)
+
+ def _rl_kill_to_beg(self):
+ bol, _ = self._rl_line_bounds()
+ self._rl_delete(bol, self.edit_pos, kill_direction="backward")
+
+ def _rl_kill_to_end(self):
+ _, eol = self._rl_line_bounds()
+ self._rl_delete(self.edit_pos, eol, kill_direction="forward")
+
+ def _rl_kill_word_back(self):
+ text, pos = self.edit_text, self.edit_pos
+ p = pos
+ while p > 0 and text[p - 1].isspace():
+ p -= 1
+ while p > 0 and not text[p - 1].isspace():
+ p -= 1
+ self._rl_delete(p, pos, kill_direction="backward")
+
+ def _rl_kill_whole_buffer(self):
+ self._rl_delete(0, len(self.edit_text), kill_direction="forward")
+
+ def _rl_yank(self):
+ if not _KillRing.text:
+ return
+ pos = self.edit_pos
+ text = self.edit_text
+ self.set_edit_text(text[:pos] + _KillRing.text + text[pos:])
+ self.set_edit_pos(pos + len(_KillRing.text))
+
+ def _rl_backward_word(self):
+ text, pos = self.edit_text, self.edit_pos
+ while pos > 0 and not self._rl_is_word_char(text[pos - 1]):
+ pos -= 1
+ while pos > 0 and self._rl_is_word_char(text[pos - 1]):
+ pos -= 1
+ self.set_edit_pos(pos)
+
+ def _rl_forward_word(self):
+ text, pos = self.edit_text, self.edit_pos
+ n = len(text)
+ while pos < n and not self._rl_is_word_char(text[pos]):
+ pos += 1
+ while pos < n and self._rl_is_word_char(text[pos]):
+ pos += 1
+ self.set_edit_pos(pos)
+
+
+class ReadlineEdit(ReadlineMixin, urwid.Edit):
+ """Drop-in urwid.Edit replacement with readline-style editing keys."""
+ pass
diff --git a/nomadnet/ui/textui/Transport.py b/nomadnet/ui/textui/Transport.py
new file mode 100644
index 0000000..a358bc1
--- /dev/null
+++ b/nomadnet/ui/textui/Transport.py
@@ -0,0 +1,495 @@
+import RNS
+import time
+import urwid
+
+def _fmt_secs(secs):
+ secs = int(secs)
+ if secs < 0:
+ secs = 0
+ if secs < 60:
+ return "%ds" % secs
+ if secs < 3600:
+ return "%dm" % (secs // 60)
+ if secs < 86400:
+ return "%dh" % (secs // 3600)
+ return "%dd" % (secs // 86400)
+
+def _rel_future(ts):
+ if not ts:
+ return "-"
+ remaining = ts - time.time()
+ if remaining <= 0:
+ return "expired"
+ return _fmt_secs(remaining)
+
+def _rel_past(ts):
+ if not ts:
+ return "-"
+ return _fmt_secs(time.time() - ts) + " ago"
+
+def _fmt_duration(secs):
+ if not secs:
+ return "n/a"
+ secs = int(secs)
+ if secs < 0:
+ secs = 0
+ days = secs // 86400
+ hours = (secs % 86400) // 3600
+ minutes = (secs % 3600) // 60
+ if days:
+ return "%dd %dh" % (days, hours)
+ if hours:
+ return "%dh %dm" % (hours, minutes)
+ if minutes:
+ return "%dm" % minutes
+ return "%ds" % secs
+
+def _fmt_rate(hz):
+ try:
+ hz = float(hz or 0)
+ except Exception:
+ return "0"
+ if hz <= 0:
+ return "0"
+ per_hour = hz * 3600
+ if per_hour >= 1:
+ return "%.0f/h" % per_hour
+ return "%.1f/d" % (hz * 86400)
+
+def _mode_str(mode):
+ return {1: "Full", 2: "Point-to-Point", 3: "Access Point", 4: "Roaming", 5: "Boundary", 6: "Gateway"}.get(mode, "Full")
+
+class TransportRow(urwid.WidgetWrap):
+ def __init__(self, columns):
+ self._selectable = True
+ super().__init__(urwid.AttrMap(columns, "list_normal", focus_map="list_focus"))
+
+ def selectable(self):
+ return True
+
+ def keypress(self, size, key):
+ return key
+
+class LazyTableWalker(urwid.ListWalker):
+ def __init__(self):
+ self.items = []
+ self.factory = None
+ self.empty_widget = None
+ self.focus = 0
+
+ def configure(self, items, factory, empty_widget=None):
+ self.items = items or []
+ self.factory = factory
+ self.empty_widget = empty_widget
+ if self.focus >= len(self.items):
+ self.focus = max(0, len(self.items) - 1)
+ self._modified()
+
+ def _build(self, position):
+ return self.factory(self.items[position], position)
+
+ def get_focus(self):
+ if not self.items:
+ if self.empty_widget is not None:
+ return (self.empty_widget, 0)
+ return (None, None)
+ if self.focus >= len(self.items):
+ self.focus = len(self.items) - 1
+ if self.focus < 0:
+ self.focus = 0
+ return (self._build(self.focus), self.focus)
+
+ def set_focus(self, position):
+ self.focus = position
+ self._modified()
+
+ def get_next(self, position):
+ n = position + 1
+ if not self.items or n >= len(self.items):
+ return (None, None)
+ return (self._build(n), n)
+
+ def get_prev(self, position):
+ p = position - 1
+ if not self.items or p < 0:
+ return (None, None)
+ return (self._build(p), p)
+
+ def positions(self, reverse=False):
+ count = len(self.items)
+ if count == 0:
+ return [0] if self.empty_widget is not None else []
+ if reverse:
+ return range(count - 1, -1, -1)
+ return range(count)
+
+ def __len__(self):
+ return len(self.items)
+
+ def __getitem__(self, position):
+ if not self.items:
+ if position == 0 and self.empty_widget is not None:
+ return self.empty_widget
+ raise IndexError(position)
+ if position < 0 or position >= len(self.items):
+ raise IndexError(position)
+ return self._build(position)
+
+class TransportFiller(urwid.WidgetWrap):
+ def __init__(self, widget, display):
+ self.display = display
+ super().__init__(widget)
+
+ def keypress(self, size, key):
+ if key == "tab":
+ self.display.cycle_tab()
+ return None
+ if key == "ctrl r" or key == "f5":
+ self.display.refresh()
+ return None
+ if key == "esc":
+ self.display.go_back()
+ return None
+ result = super().keypress(size, key)
+ if key == "up" and result == "up":
+ self.display.go_back_to_header()
+ return None
+ return result
+
+class TransportDisplayShortcuts:
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(
+ urwid.Text("[Tab] Switch Table [Up] Filter [C-r] Refresh [Esc] Back"),
+ "shortcutbar"
+ )
+
+class TransportDisplay:
+ POLL_INTERVAL = 5
+
+ HASH_W = 34
+ HOPS_W = 6
+ VIA_W = 34
+ EXP_W = 12
+ LAST_W = 16
+ VIOL_W = 11
+ STATUS_W = 10
+ PATHS_W = 8
+ HELD_W = 8
+ QUEUE_W = 8
+ RATE_W = 10
+ MODE_W = 12
+
+ def __init__(self, app, parent=None):
+ self.app = app
+ self.parent = parent
+ self.started = False
+ self.poll_scheduler = False
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+
+ self.active_tab = "paths"
+ self.paths = []
+ self.rates = []
+ self.interfaces = []
+ self.paths_per_interface = {}
+ self.link_count = 0
+ self.transport_id = None
+ self.network_id = None
+ self.transport_uptime = None
+ self.held_announces = 0
+ self.filter_text = ""
+
+ self.summary_text = urwid.Text("")
+ self.list_walker = LazyTableWalker()
+ self.list_box = urwid.ListBox(self.list_walker)
+
+ self.filter_edit = urwid.Edit("", "")
+ urwid.connect_signal(self.filter_edit, "change", self._on_filter_change)
+ self.filter_row = urwid.Columns([
+ (9, urwid.Text(("key", "Filter:"))),
+ urwid.AttrMap(self.filter_edit, "list_normal", focus_map="list_focus"),
+ ], dividechars=1)
+
+ self.pile = urwid.Pile([
+ ('pack', self.summary_text),
+ ('pack', self._tab_bar()),
+ ('pack', self.filter_row),
+ ('pack', self._header_row()),
+ ('pack', urwid.Divider("─")),
+ ('weight', 1, self.list_box),
+ ])
+
+ self.transport_view = TransportFiller(self.pile, self)
+ self.shortcuts_display = TransportDisplayShortcuts(self.app)
+ self.widget = self.transport_view
+
+ self._refresh_data()
+ self._rebuild()
+
+ def shortcuts(self):
+ return self.shortcuts_display
+
+ def _tab_bar(self):
+ def tab(label, key):
+ attr = "list_focus" if self.active_tab == key else "list_normal"
+ return (urwid.PACK, urwid.Text((attr, " %s " % label)))
+
+ return urwid.Columns([
+ tab("Paths", "paths"),
+ tab("Announce Rates", "rates"),
+ tab("Interfaces", "interfaces"),
+ (urwid.WEIGHT, 1, urwid.Text("")),
+ ], dividechars=1)
+
+ def _header_row(self):
+ if self.active_tab == "paths":
+ cols = [
+ (self.HASH_W, urwid.Text(("key", "Destination"), wrap="clip")),
+ (self.HOPS_W, urwid.Text(("key", "Hops"), wrap="clip")),
+ (urwid.WEIGHT, 1, urwid.Text(("key", "Interface"), wrap="clip")),
+ (self.VIA_W, urwid.Text(("key", "Via"), wrap="clip")),
+ (self.EXP_W, urwid.Text(("key", "Expires"), wrap="clip")),
+ ]
+ elif self.active_tab == "interfaces":
+ cols = [
+ (urwid.WEIGHT, 1, urwid.Text(("key", "Interface"), wrap="clip")),
+ (self.STATUS_W, urwid.Text(("key", "Status"), wrap="clip")),
+ (self.PATHS_W, urwid.Text(("key", "Paths"), wrap="clip")),
+ (self.HELD_W, urwid.Text(("key", "Held"), wrap="clip")),
+ (self.QUEUE_W, urwid.Text(("key", "Queue"), wrap="clip")),
+ (self.RATE_W, urwid.Text(("key", "Ann in"), wrap="clip")),
+ (self.MODE_W, urwid.Text(("key", "Mode"), wrap="clip")),
+ ]
+ else:
+ cols = [
+ (self.HASH_W, urwid.Text(("key", "Destination"), wrap="clip")),
+ (self.LAST_W, urwid.Text(("key", "Last announce"), wrap="clip")),
+ (self.VIOL_W, urwid.Text(("key", "Violations"), wrap="clip")),
+ (urwid.WEIGHT, 1, urwid.Text(("key", "Blocked"), wrap="clip")),
+ ]
+ return urwid.Columns(cols, dividechars=1)
+
+ def _refresh_data(self):
+ try:
+ self.paths = self.app.rns.get_path_table() or []
+ except Exception:
+ self.paths = []
+ try:
+ self.rates = self.app.rns.get_rate_table() or []
+ except Exception:
+ self.rates = []
+ try:
+ self.link_count = self.app.rns.get_link_count()
+ except Exception:
+ self.link_count = 0
+
+ try:
+ stats = self.app.rns.get_interface_stats() or {}
+ except Exception:
+ stats = {}
+ self.transport_id = stats.get("transport_id")
+ self.network_id = stats.get("network_id")
+ self.transport_uptime = stats.get("transport_uptime")
+ self.interfaces = stats.get("interfaces", []) or []
+ try:
+ self.held_announces = sum(int(i.get("held_announces", 0) or 0) for i in self.interfaces)
+ except Exception:
+ self.held_announces = 0
+
+ self.paths_per_interface = {}
+ for entry in self.paths:
+ iface = entry.get("interface", "")
+ self.paths_per_interface[iface] = self.paths_per_interface.get(iface, 0) + 1
+
+ try:
+ self.interfaces.sort(key=lambda i: self.paths_per_interface.get(i.get("name", ""), 0), reverse=True)
+ except Exception:
+ pass
+
+ try:
+ self.paths.sort(key=lambda e: (e.get("interface", ""), e.get("hops", 0)))
+ except Exception:
+ pass
+ try:
+ self.rates.sort(key=lambda e: e.get("last", 0), reverse=True)
+ except Exception:
+ pass
+
+ def _make_path_row(self, entry, position):
+ dest = RNS.prettyhexrep(entry["hash"]) if entry.get("hash") else "?"
+ via = RNS.prettyhexrep(entry["via"]) if entry.get("via") else "?"
+ hops = entry.get("hops", 0)
+ iface = entry.get("interface", "")
+ expires = _rel_future(entry.get("expires", 0))
+
+ columns = urwid.Columns([
+ (self.HASH_W, urwid.Text(("list_normal", dest), wrap="clip")),
+ (self.HOPS_W, urwid.Text(str(hops), wrap="clip")),
+ (urwid.WEIGHT, 1, urwid.Text(iface, wrap="clip")),
+ (self.VIA_W, urwid.Text(via, wrap="clip")),
+ (self.EXP_W, urwid.Text(expires, wrap="clip")),
+ ], dividechars=1)
+ return TransportRow(columns)
+
+ def _make_rate_row(self, entry, position):
+ now = time.time()
+ dest = RNS.prettyhexrep(entry["hash"]) if entry.get("hash") else "?"
+ last = _rel_past(entry.get("last", 0))
+ violations = str(entry.get("rate_violations", 0))
+ blocked_until = entry.get("blocked_until", 0) or 0
+ if blocked_until > now:
+ blocked = ("disconnected_status", "blocked %s" % _rel_future(blocked_until))
+ else:
+ blocked = ("connected_status", "no")
+
+ columns = urwid.Columns([
+ (self.HASH_W, urwid.Text(("list_normal", dest), wrap="clip")),
+ (self.LAST_W, urwid.Text(last, wrap="clip")),
+ (self.VIOL_W, urwid.Text(violations, wrap="clip")),
+ (urwid.WEIGHT, 1, urwid.Text(blocked, wrap="clip")),
+ ], dividechars=1)
+ return TransportRow(columns)
+
+ def _make_iface_row(self, entry, position):
+ short_name = entry.get("short_name")
+ if short_name == "None": short_name = None
+ name = short_name or entry.get("name") or "?"
+ RNS.log(name)
+ name = name.replace("\x00", "")
+ paths = self.paths_per_interface.get(entry.get("name", ""), 0)
+ held = entry.get("held_announces", 0) or 0
+ queue = entry.get("announce_queue")
+ queue_str = "-" if queue is None else str(queue)
+ rate = _fmt_rate(entry.get("incoming_announce_frequency", 0))
+ mode = _mode_str(entry.get("mode"))
+
+ if entry.get("status"):
+ status = ("connected_status", "up")
+ else:
+ status = ("disconnected_status", "down")
+
+ columns = urwid.Columns([
+ (urwid.WEIGHT, 1, urwid.Text(("list_normal", name), wrap="clip")),
+ (self.STATUS_W, urwid.Text(status, wrap="clip")),
+ (self.PATHS_W, urwid.Text(str(paths), wrap="clip")),
+ (self.HELD_W, urwid.Text(str(held), wrap="clip")),
+ (self.QUEUE_W, urwid.Text(queue_str, wrap="clip")),
+ (self.RATE_W, urwid.Text(rate, wrap="clip")),
+ (self.MODE_W, urwid.Text(mode, wrap="clip")),
+ ], dividechars=1)
+ return TransportRow(columns)
+
+ def _rebuild(self):
+ blocked_now = 0
+ try:
+ now = time.time()
+ blocked_now = sum(1 for r in self.rates if (r.get("blocked_until", 0) or 0) > now)
+ except Exception:
+ pass
+
+ transport_id = RNS.prettyhexrep(self.transport_id) if self.transport_id else "n/a"
+ network_id = RNS.prettyhexrep(self.network_id) if self.network_id else "n/a"
+ uptime = _fmt_duration(self.transport_uptime)
+
+ summary = [
+ ("key", "Transport ID: "), ("value", transport_id),
+ ("key", " Network ID: "), ("value", network_id),
+ ("key", " Uptime: "), ("value", uptime), "\n",
+ ("key", "Active links: "), ("value", str(self.link_count)),
+ ("key", " Known paths: "), ("value", str(len(self.paths))),
+ ("key", " Held announces: "), ("value", str(self.held_announces)),
+ ("key", " Announce sources: "), ("value", str(len(self.rates))),
+ ("key", " Rate-limited: "), ("value", str(blocked_now)),
+ ]
+
+ if self.active_tab == "paths":
+ data = self._filtered(self.paths)
+ factory = self._make_path_row
+ empty_msg = "No paths in the routing table yet."
+ elif self.active_tab == "interfaces":
+ data = self._filtered(self.interfaces)
+ factory = self._make_iface_row
+ empty_msg = "No interface statistics available."
+ else:
+ data = self._filtered(self.rates)
+ factory = self._make_rate_row
+ empty_msg = "No announce rate data available."
+
+ if self.filter_text.strip():
+ summary.append(("inactive_text", " [filter: %d shown]" % len(data)))
+ self.summary_text.set_text(summary)
+
+ self.pile.contents[1] = (self._tab_bar(), self.pile.options('pack'))
+ self.pile.contents[3] = (self._header_row(), self.pile.options('pack'))
+
+ empty = urwid.Text(("inactive_text", empty_msg), align=urwid.CENTER)
+ self.list_walker.configure(data, factory, empty)
+
+ def cycle_tab(self):
+ order = ["paths", "rates", "interfaces"]
+ i = order.index(self.active_tab) if self.active_tab in order else 0
+ self.active_tab = order[(i + 1) % len(order)]
+ self._rebuild()
+
+ def _on_filter_change(self, widget, new_text):
+ self.filter_text = new_text
+ self._rebuild()
+
+ def _haystack(self, entry):
+ parts = []
+ for key in ("hash", "via"):
+ value = entry.get(key)
+ if value:
+ parts.append(RNS.prettyhexrep(value))
+ for key in ("interface", "name", "short_name"):
+ value = entry.get(key)
+ if value:
+ parts.append(str(value))
+ return " ".join(parts).lower()
+
+ def _filtered(self, items):
+ needle = self.filter_text.strip().lower()
+ if not needle:
+ return items
+ return [entry for entry in items if needle in self._haystack(entry)]
+
+ def go_back(self):
+ if self.parent is not None:
+ self.parent.show_landing()
+
+ def go_back_to_header(self):
+ try:
+ self.app.ui.main_display.frame.focus_position = "header"
+ except Exception:
+ pass
+
+ def _is_visible(self):
+ md = self.app.ui.main_display
+ if self.parent is not None:
+ return md.sub_displays.active_display is self.parent and getattr(self.parent, "active", None) == "transport"
+ return md.sub_displays.active_display is self
+
+ def refresh(self):
+ self._refresh_data()
+ self._rebuild()
+
+ def start(self):
+ self.started = True
+ self._refresh_data()
+ self._rebuild()
+ if not self.poll_scheduler and self.app.ui.loop is not None:
+ self.poll_scheduler = True
+ self.app.ui.loop.set_alarm_in(self.POLL_INTERVAL, self._poll)
+
+ def _poll(self, loop, user_data):
+ if not self.started or not self._is_visible():
+ self.poll_scheduler = False
+ return
+ self._refresh_data()
+ self._rebuild()
+ try:
+ loop.draw_screen()
+ except Exception:
+ pass
+ loop.set_alarm_in(self.POLL_INTERVAL, self._poll)
diff --git a/nomadnet/ui/textui/Utilities.py b/nomadnet/ui/textui/Utilities.py
new file mode 100644
index 0000000..ec0dfee
--- /dev/null
+++ b/nomadnet/ui/textui/Utilities.py
@@ -0,0 +1,1766 @@
+import RNS
+import os
+import time
+import tempfile
+import threading
+import nomadnet
+import urwid
+
+from .Transport import TransportDisplay
+from .ReadlineEdit import ReadlineEdit
+from .FileBrowser import FileBrowser
+from nomadnet.vendor.additional_urwid_widgets.FormWidgets import Dropdown
+
+class OutputLog:
+ def __init__(self, title="Output"):
+ self.walker = urwid.SimpleListWalker([])
+ self.listbox = urwid.ListBox(self.walker)
+ self.widget = urwid.LineBox(
+ urwid.ScrollBar(self.listbox),
+ title=title,
+ tlcorner="╭", tline="─",
+ trcorner="╮", lline="│",
+ rline="│", blcorner="╰",
+ bline="─", brcorner="╯"
+ )
+
+ def log(self, message, attr="body_text"):
+ self.walker.append(urwid.Text((attr, message)))
+ try:
+ self.listbox.set_focus(len(self.walker) - 1)
+ self.listbox.set_focus_valign("bottom")
+ except Exception:
+ pass
+
+ def clear(self):
+ self.walker[:] = []
+
+def _utility_body(form_rows, output):
+ return urwid.Pile([
+ ('pack', urwid.Pile(form_rows)),
+ ('weight', 1, output.widget),
+ ])
+
+DEFAULT_PROBE_SIZE = 16
+DEFAULT_PROBE_TIMEOUT = 12
+
+def _interface_enabled(iface):
+ try:
+ return str(iface.get("enabled")).lower() not in ("false", "off", "no", "0") and \
+ str(iface.get("interface_enabled")).lower() not in ("false", "off", "no", "0")
+ except Exception:
+ return True
+
+def _has_active_interfaces(app):
+ try:
+ interfaces = app.rns.config["interfaces"]
+ except Exception:
+ return True
+ try:
+ return any(_interface_enabled(interfaces[name]) for name in interfaces)
+ except Exception:
+ return True
+
+class UtilityItem(urwid.WidgetWrap):
+ def __init__(self, parent, name, description, on_select):
+ self.parent = parent
+ self.name = name
+ self.on_select = on_select
+ self._selectable = True
+
+ self.selection_txt = urwid.Text(" ")
+ self.title_widget = urwid.Text(("interface_title", name))
+
+ title_row = urwid.Columns([
+ (4, self.selection_txt),
+ self.title_widget,
+ ])
+ description_row = urwid.Columns([
+ (4, urwid.Text(" ")),
+ urwid.Text(("value", description)),
+ ])
+
+ box = urwid.LineBox(
+ urwid.Padding(urwid.Pile([title_row, description_row]), left=2, right=2),
+ title=None,
+ tlcorner="╭", tline="─",
+ trcorner="╮", lline="│",
+ rline="│", blcorner="╰",
+ bline="─", brcorner="╯"
+ )
+ super().__init__(box)
+
+ def selectable(self):
+ return True
+
+ def render(self, size, focus=False):
+ self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected'])
+ attr = "interface_title_selected" if focus else "interface_title"
+ self.title_widget.set_text((attr, self.name))
+ return super().render(size, focus=focus)
+
+ def keypress(self, size, key):
+ if key == "up":
+ if self.parent.landing_listbox.focus_position == 0:
+ self.parent.app.ui.main_display.frame.focus_position = "header"
+ return None
+ elif key == "enter":
+ self.on_select()
+ return None
+ return key
+
+class UtilitiesDisplayShortcuts:
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(
+ urwid.Text("[Enter] Open [Up/Down] Navigate"),
+ "shortcutbar"
+ )
+
+class UtilitiesDisplay:
+ def __init__(self, app):
+ self.app = app
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+ self.active = "landing"
+ self.transport = None
+ self.probe = None
+ self.path = None
+ self.identity = None
+ self.send = None
+ self.speedtest = None
+
+ self.shortcuts_display = UtilitiesDisplayShortcuts(self.app)
+ self._build_landing()
+ self.widget = self.landing_view
+
+ def _build_landing(self):
+ items = [
+ UtilityItem(self, "Path Tables", "View routing, announce rate and per-interface tables", self.open_transport),
+ UtilityItem(self, "Path Resolver", "Look up or drop the path to a destination", self.open_path),
+ UtilityItem(self, "Probe", "Send probes to a destination and measure round trip", self.open_probe),
+ UtilityItem(self, "Identity", "Show your addresses and look up a destination's identity", self.open_identity),
+ UtilityItem(self, "Send File", "Send a file to a destination over Reticulum to a rncp listener", self.open_send),
+ UtilityItem(self, "Speed Test", "Measure throughput to a peer also running this screen", self.open_speedtest),
+ ]
+ self.landing_listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items))
+
+ self.landing_view = urwid.Pile([
+ ('pack', urwid.Text(("body_text", "Select a utility to open."), align=urwid.CENTER)),
+ ('pack', urwid.Divider("─")),
+ ('weight', 1, self.landing_listbox),
+ ])
+
+ def shortcuts(self):
+ if self.active == "transport" and self.transport is not None:
+ return self.transport.shortcuts_display
+ if self.active == "probe" and self.probe is not None:
+ return self.probe.shortcuts_display
+ if self.active == "path" and self.path is not None:
+ return self.path.shortcuts_display
+ if self.active == "identity" and self.identity is not None:
+ return self.identity.shortcuts_display
+ if self.active == "send" and self.send is not None:
+ return self.send.shortcuts_display
+ if self.active == "speedtest" and self.speedtest is not None:
+ return self.speedtest.shortcuts_display
+ return self.shortcuts_display
+
+ def _leave_speedtest(self):
+ if self.speedtest is not None and self.active == "speedtest":
+ self.speedtest.stop_listening()
+
+ def start(self):
+ self.show_landing()
+
+ def show_landing(self):
+ self._leave_speedtest()
+ self.active = "landing"
+ self.widget = self.landing_view
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+
+ def open_transport(self):
+ if self.transport is None:
+ self.transport = TransportDisplay(self.app, parent=self)
+ self.active = "transport"
+ self.widget = self.transport.widget
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+ self.transport.start()
+
+ def open_probe(self):
+ if self.probe is None:
+ self.probe = ProbeView(self.app, parent=self)
+ self.probe.update_interface_status()
+ self.active = "probe"
+ self.widget = self.probe.widget
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+
+ def open_path(self):
+ if self.path is None:
+ self.path = PathView(self.app, parent=self)
+ self.path.update_interface_status()
+ self.active = "path"
+ self.widget = self.path.widget
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+
+ def open_identity(self):
+ if self.identity is None:
+ self.identity = IdentityView(self.app, parent=self)
+ self.active = "identity"
+ self.widget = self.identity.widget
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+
+ def open_send(self):
+ if self.send is None:
+ self.send = SendFileView(self.app, parent=self)
+ self.send.update_interface_status()
+ self.active = "send"
+ self.widget = self.send.widget
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+
+ def open_speedtest(self):
+ if self.speedtest is None:
+ self.speedtest = SpeedTestView(self.app, parent=self)
+ self.active = "speedtest"
+ self.widget = self.speedtest.widget
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+ self.speedtest.update_interface_status()
+ self.speedtest.start_listening()
+
+class ProbeShortcuts:
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(
+ urwid.Text("[Up/Down] Navigate [Enter] Probe / Activate [C-l] Clear [Esc] Back"),
+ "shortcutbar"
+ )
+
+class UtilityViewFiller(urwid.WidgetWrap):
+ def __init__(self, body, view):
+ self.view = view
+ super().__init__(body)
+
+ def keypress(self, size, key):
+ if key == "esc":
+ self.view.go_back()
+ return None
+ if key == "ctrl l":
+ if hasattr(self.view, "clear_output"):
+ self.view.clear_output()
+ return None
+ result = super().keypress(size, key)
+ if hasattr(self.view, "after_keypress"):
+ self.view.after_keypress()
+ if key == "up" and result == "up":
+ self.view.go_back_to_header()
+ return None
+ return result
+
+class ProbeView:
+ def __init__(self, app, parent=None):
+ self.app = app
+ self.parent = parent
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+
+ self.shortcuts_display = ProbeShortcuts(self.app)
+
+ self._probing = False
+ self._concluded = False
+ self._remaining = 0
+ self._sent = 0
+ self._replies = 0
+ self._cur_receipt = None
+
+ self.name_edit = ReadlineEdit("", "rnstransport.probe")
+ self.hash_edit = ReadlineEdit("", "")
+ self.size_edit = ReadlineEdit("", str(DEFAULT_PROBE_SIZE))
+ self.count_edit = ReadlineEdit("", "1")
+
+ self.probe_button = urwid.AttrMap(urwid.Button("Probe", on_press=self.on_probe), "button_normal", focus_map="button_focus")
+ self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus")
+ button_row = urwid.Columns([
+ (urwid.WEIGHT, 0.45, self.probe_button),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, self.back_button),
+ ])
+
+ self.status_text = urwid.Text("")
+
+ rows = [
+ urwid.Text(("form_title", "Probe a destination"), align=urwid.CENTER),
+ urwid.Divider("─"),
+ self.status_text,
+ self._field_row("Destination name:", self.name_edit),
+ urwid.Padding(urwid.Text(("inactive_text", "Full dotted name for example rnstransport.probe")), left=22),
+ self._field_row("Destination hash:", self.hash_edit),
+ self._field_row("Payload size:", self.size_edit),
+ self._field_row("Probe count:", self.count_edit),
+ urwid.Divider(),
+ button_row,
+ urwid.Divider("─"), ]
+ self.output = OutputLog()
+ self.widget = UtilityViewFiller(_utility_body(rows, self.output), self)
+ self.update_interface_status()
+
+ def _field_row(self, label, edit):
+ return urwid.Columns([
+ (20, urwid.Text(("key", label), align=urwid.RIGHT)),
+ urwid.AttrMap(edit, "list_normal", focus_map="list_focus"),
+ ], dividechars=1)
+
+ def update_interface_status(self):
+ if _has_active_interfaces(self.app):
+ self.status_text.set_text("")
+ else:
+ self.status_text.set_text(("warning_text", "(!) No interfaces are active"))
+
+ def go_back(self):
+ if self.parent is not None:
+ self.parent.show_landing()
+
+ def go_back_to_header(self):
+ try:
+ self.app.ui.main_display.frame.focus_position = "header"
+ except Exception:
+ pass
+
+ def _log(self, message, attr="body_text"):
+ self.output.log(message, attr)
+
+ def clear_output(self):
+ self.output.clear()
+
+ def _draw(self):
+ try:
+ self.app.ui.loop.draw_screen()
+ except Exception:
+ pass
+
+ def _schedule(self, fn):
+ try:
+ self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw()))
+ except Exception:
+ try:
+ fn()
+ except Exception:
+ pass
+
+ def on_probe(self, button=None):
+ if self._probing:
+ return
+
+ self.update_interface_status()
+
+ full_name = self.name_edit.edit_text.strip()
+ hexhash = self.hash_edit.edit_text.strip()
+
+ if not full_name:
+ self._log("Enter a destination name (e.g. rnstransport.probe)", "error_text")
+ return
+
+ try:
+ app_name, aspects = RNS.Destination.app_and_aspects_from_name(full_name)
+ except Exception as e:
+ self._log("Invalid destination name: %s" % str(e), "error_text")
+ return
+
+ dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
+ if len(hexhash) != dest_len:
+ self._log("Destination hash must be %d hex characters" % dest_len, "error_text")
+ return
+ try:
+ destination_hash = bytes.fromhex(hexhash)
+ except Exception:
+ self._log("Invalid destination hash", "error_text")
+ return
+
+ try:
+ size = int(self.size_edit.edit_text.strip() or str(DEFAULT_PROBE_SIZE))
+ except ValueError:
+ size = DEFAULT_PROBE_SIZE
+ try:
+ count = int(self.count_edit.edit_text.strip() or "1")
+ except ValueError:
+ count = 1
+ if count < 1:
+ count = 1
+
+ self._app_name = app_name
+ self._aspects = aspects
+ self._dest_hash = destination_hash
+ self._size = max(1, size)
+ self._remaining = count
+ self._sent = 0
+ self._replies = 0
+ self._probing = True
+ self.probe_button.original_widget.set_label("Probing…")
+ self._begin_probe()
+
+ def _begin_probe(self):
+ dest_hash = self._dest_hash
+ if not RNS.Transport.has_path(dest_hash):
+ try:
+ RNS.Transport.request_path(dest_hash)
+ except Exception:
+ pass
+ self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash))
+ self._path_deadline = time.time() + self._timeout()
+ self.app.ui.loop.set_alarm_in(0.25, self._await_path)
+ else:
+ self._send_one()
+
+ def _timeout(self):
+ try:
+ return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout())
+ except Exception:
+ return DEFAULT_PROBE_TIMEOUT
+
+ def _await_path(self, loop, user_data):
+ if RNS.Transport.has_path(self._dest_hash):
+ self._send_one()
+ elif time.time() > self._path_deadline:
+ self._log("Path request timed out", "warning_text")
+ self._finish()
+ else:
+ loop.set_alarm_in(0.25, self._await_path)
+
+ def _send_one(self):
+ dest_hash = self._dest_hash
+ identity = RNS.Identity.recall(dest_hash)
+ if identity is None:
+ self._log("Could not recall identity for destination", "error_text")
+ self._finish()
+ return
+
+ try:
+ request_destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self._app_name, *self._aspects)
+ probe = RNS.Packet(request_destination, os.urandom(self._size))
+ probe.pack()
+ except OSError:
+ self._log("Probe payload of %d bytes exceeds the MTU" % self._size, "error_text")
+ self._finish()
+ return
+ except Exception as e:
+ self._log("Could not create probe: %s" % str(e), "error_text")
+ self._finish()
+ return
+
+ self._concluded = False
+ receipt = probe.send()
+ self._cur_receipt = receipt
+ self._sent += 1
+ self._log("Sent probe %d (%d bytes) to %s …" % (self._sent, self._size, RNS.prettyhexrep(dest_hash)))
+
+ try:
+ receipt.set_delivery_callback(self._on_delivered)
+ except Exception:
+ pass
+ self.app.ui.loop.set_alarm_in(self._timeout(), self._on_timeout, user_data=receipt)
+
+ def _on_delivered(self, receipt):
+ self._schedule(lambda: self._conclude(receipt, True))
+
+ def _on_timeout(self, loop, user_data):
+ if user_data is not self._cur_receipt:
+ return
+ self._conclude(user_data, False)
+
+ def _conclude(self, receipt, delivered):
+ if self._concluded:
+ return
+ if receipt is not self._cur_receipt:
+ return
+ self._concluded = True
+
+ is_delivered = delivered
+ if not is_delivered and receipt is not None:
+ try:
+ is_delivered = receipt.status == RNS.PacketReceipt.DELIVERED
+ except Exception:
+ is_delivered = False
+
+ if is_delivered and receipt is not None:
+ self._replies += 1
+ try:
+ hops = RNS.Transport.hops_to(self._dest_hash)
+ except Exception:
+ hops = 0
+ try:
+ rtt = receipt.get_rtt()
+ rtt_str = "%.0f ms" % (rtt * 1000) if rtt < 1 else "%.3f s" % rtt
+ except Exception:
+ rtt_str = "?"
+ hop_str = "%d hop%s" % (hops, "" if hops == 1 else "s")
+ self._log("Reply from %s %s over %s%s" % (
+ RNS.prettyhexrep(receipt.destination.hash), rtt_str, hop_str, self._reception_stats(receipt)),
+ "connected_status")
+ else:
+ self._log("Probe %d timed out" % self._sent, "warning_text")
+
+ self._next()
+
+ def _reception_stats(self, receipt):
+ stats = ""
+ try:
+ proof_packet = getattr(receipt, "proof_packet", None)
+ if proof_packet is None:
+ return ""
+ if getattr(self.app.rns, "is_connected_to_shared_instance", False):
+ packet_hash = proof_packet.packet_hash
+ rssi = self.app.rns.get_packet_rssi(packet_hash)
+ snr = self.app.rns.get_packet_snr(packet_hash)
+ q = self.app.rns.get_packet_q(packet_hash)
+ else:
+ rssi = getattr(proof_packet, "rssi", None)
+ snr = getattr(proof_packet, "snr", None)
+ q = None
+ if rssi is not None:
+ stats += " [RSSI %s dBm]" % rssi
+ if snr is not None:
+ stats += " [SNR %s dB]" % snr
+ if q is not None:
+ stats += " [Q %s%%]" % q
+ except Exception:
+ pass
+ return stats
+
+ def _next(self):
+ self._remaining -= 1
+ if self._remaining > 0:
+ self._send_one()
+ else:
+ self._finish()
+
+ def _finish(self):
+ if self._sent > 0:
+ loss = round((1 - (self._replies / self._sent)) * 100, 1)
+ self._log("Sent %d, received %d, packet loss %s%%" % (self._sent, self._replies, loss), "key")
+ self._probing = False
+ self.probe_button.original_widget.set_label("Probe")
+ self._draw()
+
+class PathShortcuts:
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(
+ urwid.Text("[Up/Down] Navigate [Enter] Resolve / Activate [C-l] Clear [Esc] Back"),
+ "shortcutbar"
+ )
+
+class PathView:
+ def __init__(self, app, parent=None):
+ self.app = app
+ self.parent = parent
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+
+ self.shortcuts_display = PathShortcuts(self.app)
+
+ self._resolving = False
+
+ self.hash_edit = ReadlineEdit("", "")
+
+ self.resolve_button = urwid.AttrMap(urwid.Button("Resolve", on_press=self.on_resolve), "button_normal", focus_map="button_focus")
+ self.drop_button = urwid.AttrMap(urwid.Button("Drop path", on_press=self.on_drop), "button_normal", focus_map="button_focus")
+ self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus")
+ button_row = urwid.Columns([
+ (urwid.WEIGHT, 0.3, self.resolve_button),
+ (urwid.WEIGHT, 0.05, urwid.Text("")),
+ (urwid.WEIGHT, 0.3, self.drop_button),
+ (urwid.WEIGHT, 0.05, urwid.Text("")),
+ (urwid.WEIGHT, 0.3, self.back_button),
+ ])
+
+ self.status_text = urwid.Text("")
+
+ rows = [
+ urwid.Text(("form_title", "Resolve a path"), align=urwid.CENTER),
+ urwid.Divider("─"),
+ self.status_text,
+ urwid.Columns([
+ (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)),
+ urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"),
+ ], dividechars=1),
+ urwid.Padding(urwid.Text(("inactive_text", "Requests the path from the network if it is not already known.")), left=22),
+ urwid.Divider(),
+ button_row,
+ urwid.Divider("─"), ]
+ self.output = OutputLog()
+ self.widget = UtilityViewFiller(_utility_body(rows, self.output), self)
+ self.update_interface_status()
+
+ def update_interface_status(self):
+ if _has_active_interfaces(self.app):
+ self.status_text.set_text("")
+ else:
+ self.status_text.set_text(("warning_text", "(!) No interfaces are active"))
+
+ def go_back(self):
+ if self.parent is not None:
+ self.parent.show_landing()
+
+ def go_back_to_header(self):
+ try:
+ self.app.ui.main_display.frame.focus_position = "header"
+ except Exception:
+ pass
+
+ def _log(self, message, attr="body_text"):
+ self.output.log(message, attr)
+
+ def clear_output(self):
+ self.output.clear()
+
+ def _draw(self):
+ try:
+ self.app.ui.loop.draw_screen()
+ except Exception:
+ pass
+
+ def _validate_hash(self):
+ hexhash = self.hash_edit.edit_text.strip()
+ dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
+ if len(hexhash) != dest_len:
+ self._log("Destination hash must be %d hex characters" % dest_len, "error_text")
+ return None
+ try:
+ return bytes.fromhex(hexhash)
+ except Exception:
+ self._log("Invalid destination hash", "error_text")
+ return None
+
+ def _timeout(self):
+ try:
+ return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout())
+ except Exception:
+ return DEFAULT_PROBE_TIMEOUT
+
+ def on_resolve(self, button=None):
+ if self._resolving:
+ return
+ self.update_interface_status()
+ dest_hash = self._validate_hash()
+ if dest_hash is None:
+ return
+ self._dest_hash = dest_hash
+ if RNS.Transport.has_path(dest_hash):
+ self._show_path()
+ return
+ try:
+ RNS.Transport.request_path(dest_hash)
+ except Exception:
+ pass
+ self._resolving = True
+ self.resolve_button.original_widget.set_label("Resolving…")
+ self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash))
+ self._deadline = time.time() + self._timeout()
+ self.app.ui.loop.set_alarm_in(0.25, self._await_path)
+
+ def _await_path(self, loop, user_data):
+ if RNS.Transport.has_path(self._dest_hash):
+ self._resolving = False
+ self.resolve_button.original_widget.set_label("Resolve")
+ self._show_path()
+ self._draw()
+ elif time.time() > self._deadline:
+ self._resolving = False
+ self.resolve_button.original_widget.set_label("Resolve")
+ self._log("Path not found", "warning_text")
+ self._draw()
+ else:
+ loop.set_alarm_in(0.25, self._await_path)
+
+ def _show_path(self):
+ dest_hash = self._dest_hash
+ try:
+ hops = RNS.Transport.hops_to(dest_hash)
+ next_hop_bytes = self.app.rns.get_next_hop(dest_hash)
+ except Exception:
+ next_hop_bytes = None
+ hops = 0
+ if not next_hop_bytes:
+ self._log("Path data unavailable", "warning_text")
+ return
+ try:
+ interface = self.app.rns.get_next_hop_if_name(dest_hash)
+ except Exception:
+ interface = "?"
+ hop_str = "%d hop%s" % (hops, "" if hops == 1 else "s")
+ self._log("Path found: %s is %s away via %s on %s" % (
+ RNS.prettyhexrep(dest_hash), hop_str, RNS.prettyhexrep(next_hop_bytes), interface),
+ "connected_status")
+
+ def on_drop(self, button=None):
+ dest_hash = self._validate_hash()
+ if dest_hash is None:
+ return
+ try:
+ dropped = self.app.rns.drop_path(dest_hash)
+ except Exception as e:
+ self._log("Could not drop path: %s" % str(e), "error_text")
+ return
+ if dropped:
+ self._log("Dropped path to %s" % RNS.prettyhexrep(dest_hash))
+ else:
+ self._log("No path to %s to drop" % RNS.prettyhexrep(dest_hash), "warning_text")
+
+class IdentityShortcuts:
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(
+ urwid.Text("[Up/Down] Navigate [Enter] Look up / Activate [C-l] Clear [Esc] Back"),
+ "shortcutbar"
+ )
+
+class IdentityView:
+ def __init__(self, app, parent=None):
+ self.app = app
+ self.parent = parent
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+ self.shortcuts_display = IdentityShortcuts(self.app)
+ self._resolving = False
+
+ self.hash_edit = ReadlineEdit("", "")
+ self.lookup_button = urwid.AttrMap(urwid.Button("Look up", on_press=self.on_lookup), "button_normal", focus_map="button_focus")
+ self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus")
+ button_row = urwid.Columns([
+ (urwid.WEIGHT, 0.45, self.lookup_button),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, self.back_button),
+ ])
+
+ rows = [
+ urwid.Text(("form_title", "Identity"), align=urwid.CENTER),
+ urwid.Divider("─"),
+ urwid.Text(("key", "Your addresses")),
+ ]
+ rows += self._address_rows()
+ rows += [
+ urwid.Divider(),
+ urwid.Text(("key", "Look up a destination")),
+ urwid.Columns([
+ (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)),
+ urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"),
+ ], dividechars=1),
+ urwid.Padding(urwid.Text(("inactive_text", "Recalls the identity, requesting it from the network if needed.")), left=22),
+ urwid.Divider(),
+ button_row,
+ urwid.Divider("─"), ]
+ self.output = OutputLog()
+ self.widget = UtilityViewFiller(_utility_body(rows, self.output), self)
+
+ def _addr(self, obj):
+ try:
+ return RNS.prettyhexrep(obj.hash) if obj is not None else ""
+ except Exception:
+ return ""
+
+ def _address_rows(self):
+ rows = []
+
+ def row(label, value):
+ return urwid.Columns([
+ (18, urwid.Text(("key", label), align=urwid.RIGHT)),
+ urwid.Text(("value", value)),
+ ], dividechars=1)
+
+ rows.append(row("Identity:", self._addr(getattr(self.app, "identity", None))))
+ rows.append(row("LXMF address:", self._addr(getattr(self.app, "lxmf_destination", None))))
+ node = getattr(self.app, "node", None)
+ if node is not None:
+ rows.append(row("Node:", self._addr(getattr(node, "destination", None))))
+ return rows
+
+ def go_back(self):
+ if self.parent is not None:
+ self.parent.show_landing()
+
+ def go_back_to_header(self):
+ try:
+ self.app.ui.main_display.frame.focus_position = "header"
+ except Exception:
+ pass
+
+ def _log(self, message, attr="body_text"):
+ self.output.log(message, attr)
+
+ def clear_output(self):
+ self.output.clear()
+
+ def _draw(self):
+ try:
+ self.app.ui.loop.draw_screen()
+ except Exception:
+ pass
+
+ def on_lookup(self, button=None):
+ if self._resolving:
+ return
+ hexhash = self.hash_edit.edit_text.strip()
+ dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
+ if len(hexhash) != dest_len:
+ self._log("Destination hash must be %d hex characters" % dest_len, "error_text")
+ return
+ try:
+ dest_hash = bytes.fromhex(hexhash)
+ except Exception:
+ self._log("Invalid destination hash", "error_text")
+ return
+
+ self._dest_hash = dest_hash
+ identity = RNS.Identity.recall(dest_hash)
+ if identity is not None:
+ self._show_identity(identity)
+ return
+
+ try:
+ RNS.Transport.request_path(dest_hash)
+ except Exception:
+ pass
+ self._resolving = True
+ self.lookup_button.original_widget.set_label("Looking up…")
+ self._log("Identity unknown, requesting from the network …")
+ try:
+ self._deadline = time.time() + max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(dest_hash), self.app.rns.get_medium_path_timeout())
+ except Exception:
+ self._deadline = time.time() + DEFAULT_PROBE_TIMEOUT
+ self.app.ui.loop.set_alarm_in(0.25, self._await_identity)
+
+ def _await_identity(self, loop, user_data):
+ identity = RNS.Identity.recall(self._dest_hash)
+ if identity is not None:
+ self._resolving = False
+ self.lookup_button.original_widget.set_label("Look up")
+ self._show_identity(identity)
+ self._draw()
+ elif time.time() > self._deadline:
+ self._resolving = False
+ self.lookup_button.original_widget.set_label("Look up")
+ self._log("Identity not found", "warning_text")
+ self._draw()
+ else:
+ loop.set_alarm_in(0.25, self._await_identity)
+
+ def _show_identity(self, identity):
+ self._log("%s resolves to identity %s" % (
+ RNS.prettyhexrep(self._dest_hash), RNS.prettyhexrep(identity.hash)), "connected_status")
+
+RNCP_APP_NAME = "rncp"
+
+class SendFileShortcuts:
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(
+ urwid.Text("[Up/Down] Navigate [Enter] Choose / Send [C-l] Clear [Esc] Back"),
+ "shortcutbar"
+ )
+
+class SendFileView:
+ def __init__(self, app, parent=None):
+ self.app = app
+ self.parent = parent
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+ self.shortcuts_display = SendFileShortcuts(self.app)
+
+ self._sending = False
+ self._concluded = False
+ self._link = None
+ self._resource = None
+ self.file_path = None
+
+ self.hash_edit = ReadlineEdit("", "")
+ self.file_label = urwid.Text(("inactive_text", "No file selected"))
+ self.status_text = urwid.Text("")
+ self.progress_text = urwid.Text("")
+
+ self.choose_button = urwid.AttrMap(urwid.Button("Choose file…", on_press=self.choose_file), "button_normal", focus_map="button_focus")
+ self.send_button = urwid.AttrMap(urwid.Button("Send", on_press=self.on_send), "button_normal", focus_map="button_focus")
+ self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus")
+ button_row = urwid.Columns([
+ (urwid.WEIGHT, 0.45, self.send_button),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, self.back_button),
+ ])
+
+ rows = [
+ urwid.Text(("form_title", "Send a file"), align=urwid.CENTER),
+ urwid.Divider("─"),
+ self.status_text,
+ urwid.Columns([
+ (20, urwid.Text(("key", "File:"), align=urwid.RIGHT)),
+ urwid.AttrMap(self.choose_button, "button_normal", focus_map="button_focus"),
+ ], dividechars=1),
+ urwid.Padding(self.file_label, left=22),
+ urwid.Columns([
+ (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)),
+ urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"),
+ ], dividechars=1),
+ urwid.Padding(urwid.Text(("inactive_text", "The receiver must be running 'rncp --listen' and have your identity hash in the allow list.")), left=22),
+ urwid.Divider(),
+ button_row,
+ urwid.Divider("─"),
+ self.progress_text, ]
+ self.output = OutputLog()
+ self.widget = UtilityViewFiller(_utility_body(rows, self.output), self)
+ self.update_interface_status()
+
+ def update_interface_status(self):
+ if _has_active_interfaces(self.app):
+ self.status_text.set_text("")
+ else:
+ self.status_text.set_text(("warning_text", "(!) No interfaces are active"))
+
+ def go_back(self):
+ if self.parent is not None:
+ self.parent.show_landing()
+
+ def go_back_to_header(self):
+ try:
+ self.app.ui.main_display.frame.focus_position = "header"
+ except Exception:
+ pass
+
+ def _log(self, message, attr="body_text"):
+ self.output.log(message, attr)
+
+ def clear_output(self):
+ self.output.clear()
+
+ def _draw(self):
+ try:
+ self.app.ui.loop.draw_screen()
+ except Exception:
+ pass
+
+ def _schedule(self, fn):
+ try:
+ self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw()))
+ except Exception:
+ try:
+ fn()
+ except Exception:
+ pass
+
+ def choose_file(self, button=None):
+ def on_done(selected):
+ if selected:
+ self.file_path = selected[0]
+ self.file_label.set_text(("value", os.path.basename(self.file_path)))
+ self._close_browser()
+
+ def on_cancel():
+ self._close_browser()
+
+ browser = FileBrowser(on_done=on_done, on_cancel=on_cancel, title="Select File to Send", multi_select=False)
+ overlay = urwid.Overlay(browser, self.widget, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2)
+ self.parent.widget = overlay
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+
+ def _close_browser(self):
+ self.parent.widget = self.widget
+ self.app.ui.main_display.update_active_sub_display()
+ self.app.ui.main_display.frame.focus_position = "body"
+ self._draw()
+
+ def _timeout(self):
+ try:
+ return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout())
+ except Exception:
+ return DEFAULT_PROBE_TIMEOUT
+
+ def on_send(self, button=None):
+ if self._sending:
+ return
+ self.update_interface_status()
+
+ if not self.file_path or not os.path.isfile(self.file_path):
+ self._log("Choose a file to send first", "error_text")
+ return
+
+ hexhash = self.hash_edit.edit_text.strip()
+ dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
+ if len(hexhash) != dest_len:
+ self._log("Destination hash must be %d hex characters" % dest_len, "error_text")
+ return
+ try:
+ dest_hash = bytes.fromhex(hexhash)
+ except Exception:
+ self._log("Invalid destination hash", "error_text")
+ return
+
+ self._dest_hash = dest_hash
+ self._sending = True
+ self._concluded = False
+ self._resource = None
+ self.send_button.original_widget.set_label("Sending…")
+ self.progress_text.set_text("")
+
+ if RNS.Transport.has_path(dest_hash):
+ self._establish_link()
+ else:
+ try:
+ RNS.Transport.request_path(dest_hash)
+ except Exception:
+ pass
+ self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash))
+ self._deadline = time.time() + self._timeout()
+ self.app.ui.loop.set_alarm_in(0.25, self._await_path)
+
+ def _await_path(self, loop, user_data):
+ if RNS.Transport.has_path(self._dest_hash):
+ self._establish_link()
+ elif time.time() > self._deadline:
+ self._log("Path not found", "warning_text")
+ self._finish()
+ else:
+ loop.set_alarm_in(0.25, self._await_path)
+
+ def _establish_link(self):
+ identity = RNS.Identity.recall(self._dest_hash)
+ if identity is None:
+ self._log("Could not recall identity for destination", "error_text")
+ self._finish()
+ return
+ try:
+ destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, RNCP_APP_NAME, "receive")
+ self._log("Establishing link with %s …" % RNS.prettyhexrep(self._dest_hash))
+ self._link = RNS.Link(destination, established_callback=self._on_link_up, closed_callback=self._on_link_closed)
+ except Exception as e:
+ self._log("Could not establish link: %s" % str(e), "error_text")
+ self._finish()
+ return
+ self._link_deadline = time.time() + max(self._timeout(), self._link.establishment_timeout)
+ self.app.ui.loop.set_alarm_in(0.5, self._check_link)
+
+ def _check_link(self, loop, user_data):
+ if not self._sending or self._resource is not None:
+ return
+ try:
+ active = self._link is not None and self._link.status == RNS.Link.ACTIVE
+ except Exception:
+ active = False
+ if active:
+ return
+ if time.time() > self._link_deadline:
+ self._log("Link establishment timed out", "warning_text")
+ try:
+ self._link.teardown()
+ except Exception:
+ pass
+ self._finish()
+ else:
+ loop.set_alarm_in(0.5, self._check_link)
+
+ def _on_link_up(self, link):
+ self._schedule(lambda: self._start_resource(link))
+
+ def _start_resource(self, link):
+ if not self._sending or self._resource is not None:
+ return
+ try:
+ link.identify(self.app.identity)
+ metadata = {"name": os.path.basename(self.file_path).encode("utf-8")}
+ self._resource = RNS.Resource(open(self.file_path, "rb"), link, metadata=metadata,
+ callback=self._on_resource_done, progress_callback=self._on_progress, auto_compress=True)
+ self._log("Advertising %s …" % os.path.basename(self.file_path))
+ except Exception as e:
+ self._log("Could not start transfer: %s" % str(e), "error_text")
+ self._finish()
+
+ def _on_progress(self, resource):
+ self._schedule(lambda: self._show_progress(resource))
+
+ def _show_progress(self, resource):
+ try:
+ pct = int(resource.get_progress() * 100)
+ except Exception:
+ pct = 0
+ self.progress_text.set_text(("body_text", "Transferring… %d%%" % pct))
+
+ def _on_resource_done(self, resource):
+ self._schedule(lambda: self._resource_done(resource))
+
+ def _resource_done(self, resource):
+ if self._concluded:
+ return
+ self._concluded = True
+ try:
+ complete = resource.status == RNS.Resource.COMPLETE
+ except Exception:
+ complete = False
+ if complete:
+ self.progress_text.set_text(("connected_status", "Transferring… 100%"))
+ self._log("Transfer complete", "connected_status")
+ else:
+ self._log("Transfer failed or the file was not accepted", "warning_text")
+ try:
+ if self._link is not None:
+ self._link.teardown()
+ except Exception:
+ pass
+ self._finish()
+
+ def _on_link_closed(self, link):
+ self._schedule(self._link_closed)
+
+ def _link_closed(self):
+ if self._sending and self._resource is None and not self._concluded:
+ self._log("Link closed before the transfer started", "warning_text")
+ self._finish()
+
+ def _finish(self):
+ self._sending = False
+ self.send_button.original_widget.set_label("Send")
+ self._draw()
+
+
+SPEEDTEST_APP_NAME = "nomadnetwork"
+SPEEDTEST_ASPECTS = ("utilities", "speedtest")
+SPEEDTEST_FULL_NAME = ".".join((SPEEDTEST_APP_NAME,) + SPEEDTEST_ASPECTS)
+SPEEDTEST_INMEM_MAX = 8 * 1024 * 1024
+
+SPEEDTEST_SIZES = [
+ ("2 KB", 2 * 1024),
+ ("8 KB", 8 * 1024),
+ ("32 KB", 32 * 1024),
+ ("128 KB", 128 * 1024),
+ ("512 KB", 512 * 1024),
+ ("1 MB", 1024 * 1024),
+ ("4 MB", 4 * 1024 * 1024),
+ ("16 MB", 16 * 1024 * 1024),
+ ("64 MB", 64 * 1024 * 1024),
+ ("256 MB", 256 * 1024 * 1024),
+ ("1000 MB", 1000 * 1024 * 1024),
+ ("2000 MB", 2000 * 1024 * 1024),
+]
+SPEEDTEST_SIZE_LABELS = [label for label, _ in SPEEDTEST_SIZES]
+SPEEDTEST_BYTES_BY_LABEL = {label: nbytes for label, nbytes in SPEEDTEST_SIZES}
+DEFAULT_SPEEDTEST_LABEL = "1 MB"
+
+def _fmt_bitrate(bytes_per_sec):
+ bits = float(bytes_per_sec) * 8
+ for unit in ("bps", "Kbps", "Mbps", "Gbps"):
+ if bits < 1000:
+ return "%.1f %s" % (bits, unit)
+ bits /= 1000
+ return "%.1f Tbps" % bits
+
+def _size_str(num, suffix="B"):
+ num = float(num)
+ for unit in ("", "K", "M", "G", "T"):
+ if abs(num) < 1024.0:
+ return "%.1f %s%s" % (num, unit, suffix)
+ num /= 1024.0
+ return "%.1f P%s" % (num, suffix)
+
+class SpeedTestShortcuts:
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(
+ urwid.Text("[Up/Down] Navigate [Enter] Start / Cancel [C-l] Clear [Esc] Back"),
+ "shortcutbar"
+ )
+
+class SpeedTestView:
+ def __init__(self, app, parent=None):
+ self.app = app
+ self.parent = parent
+ self.glyphset = self.app.config["textui"]["glyphs"]
+ self.g = self.app.ui.glyphs
+ self.shortcuts_display = SpeedTestShortcuts(self.app)
+
+ self.server_destination = None
+ self._listening = False
+ self._incoming_link = None
+ self._recv_link = None
+ self._recv_resource = None
+ self._recv_started = None
+ self._recv_polling = False
+
+ self._sending = False
+ self._client_link = None
+ self._client_resource = None
+ self._send_started = None
+ self._send_bytes = 0
+ self._send_seq = 0
+ self._tmppath = None
+
+ self.hash_edit = ReadlineEdit("", "")
+ self.size_dropdown = Dropdown("", SPEEDTEST_SIZE_LABELS, DEFAULT_SPEEDTEST_LABEL)
+ self.address_text = urwid.Text(("value", "-"))
+ self.status_text = urwid.Text("")
+ self.listen_status = urwid.Text("")
+ self.rate_text = urwid.Text("")
+ self.progress_bar = urwid.ProgressBar("progress_empty", "progress_full", current=0, done=100)
+
+ self.start_button = urwid.AttrMap(urwid.Button("Send test file", on_press=self.on_start), "button_normal", focus_map="button_focus")
+ self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus")
+ button_row = urwid.Columns([
+ (urwid.WEIGHT, 0.45, self.start_button),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, self.back_button),
+ ])
+
+ rows = [
+ urwid.Text(("form_title", "Speed test"), align=urwid.CENTER),
+ urwid.Divider("─"),
+ self.status_text,
+ self.listen_status,
+ urwid.Columns([
+ (22, urwid.Text(("key", "Your speedtest hash:"), align=urwid.RIGHT)),
+ self.address_text,
+ ], dividechars=1),
+ urwid.Padding(urwid.Text(("inactive_text", "Both sides open this screen and enter each other's hash; then one presses Start.")), left=24),
+ urwid.Columns([
+ (22, urwid.Text(("key", "Peer hash:"), align=urwid.RIGHT)),
+ urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"),
+ ], dividechars=1),
+ urwid.Columns([
+ (22, urwid.Text(("key", "Test size:"), align=urwid.RIGHT)),
+ self.size_dropdown,
+ ], dividechars=1),
+ urwid.Divider(),
+ button_row,
+ urwid.Divider("─"),
+ urwid.Columns([
+ (22, urwid.Text(("key", "Progress:"), align=urwid.RIGHT)),
+ self.progress_bar,
+ ], dividechars=1),
+ urwid.Padding(self.rate_text, left=24),
+ ]
+ self.output = OutputLog()
+ self.widget = UtilityViewFiller(_utility_body(rows, self.output), self)
+
+ def update_interface_status(self):
+ if _has_active_interfaces(self.app):
+ self.status_text.set_text("")
+ else:
+ self.status_text.set_text(("warning_text", "(!) No interfaces are active"))
+
+ def _peer_hash_entered(self):
+ return len(self.hash_edit.edit_text.strip()) == (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
+
+ def _update_listen_status(self):
+ dot = self.g.get("connected", "*")
+ if self._sending:
+ self.listen_status.set_text(("connected_status", "%s %s Sending test…" % (dot, self.g.get("arrow_u", "^"))))
+ elif self._recv_resource is not None or self._recv_link is not None:
+ self.listen_status.set_text(("connected_status", "%s %s Receiving test…" % (dot, self.g.get("arrow_d", "v"))))
+ elif self._listening and self.server_destination is not None:
+ if self._peer_hash_entered():
+ self.listen_status.set_text(("connected_status", "%s Listening, ready to receive from or send to the entered peer" % dot))
+ else:
+ self.listen_status.set_text(("warning_text", "%s Listening, enter the peer's hash to accept their test" % dot))
+ else:
+ self.listen_status.set_text(("inactive_text", "Not listening"))
+
+ def after_keypress(self):
+ self._update_listen_status()
+
+ def _log(self, message, attr="body_text"):
+ self.output.log(message, attr)
+
+ def clear_output(self):
+ self.output.clear()
+
+ def _draw(self):
+ try:
+ self.app.ui.loop.draw_screen()
+ except Exception:
+ pass
+
+ def _schedule(self, fn):
+ try:
+ self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw()))
+ except Exception:
+ try:
+ fn()
+ except Exception:
+ pass
+
+ def _set_progress(self, pct):
+ try:
+ self.progress_bar.set_completion(max(0, min(100, pct)))
+ except Exception:
+ pass
+
+ def _update_address(self):
+ if self.server_destination is not None:
+ self.address_text.set_text(("value", RNS.prettyhexrep(self.server_destination.hash)))
+ else:
+ self.address_text.set_text(("value", "-"))
+
+ def start_listening(self):
+ if self.server_destination is None:
+ try:
+ self.server_destination = RNS.Destination(self.app.identity, RNS.Destination.IN, RNS.Destination.SINGLE, SPEEDTEST_APP_NAME, *SPEEDTEST_ASPECTS)
+ self.server_destination.set_link_established_callback(self._on_incoming_link)
+ try:
+ self.server_destination.accepts_links(True)
+ except Exception:
+ pass
+ except Exception as e:
+ self._log("Could not start listener: %s" % str(e), "error_text")
+ self.server_destination = None
+ self._listening = True
+ self._update_address()
+ self._update_listen_status()
+
+ def stop_listening(self):
+ self._listening = False
+ self._recv_polling = False
+ try:
+ if self._incoming_link is not None:
+ self._incoming_link.teardown()
+ except Exception:
+ pass
+ self._incoming_link = None
+ self._recv_link = None
+ self._recv_resource = None
+ try:
+ if self.server_destination is not None:
+ RNS.Transport.deregister_destination(self.server_destination)
+ except Exception:
+ pass
+ self.server_destination = None
+ self._update_address()
+ self._update_listen_status()
+
+ def _log_link_mtu(self, link):
+ try:
+ parts = []
+ mtu = getattr(link, "mtu", None)
+ mdu = getattr(link, "mdu", None)
+ if mtu:
+ parts.append("MTU: %s (MDU %s)" % (RNS.prettysize(mtu), RNS.prettysize(mdu) if mdu else "?"))
+ rtt = getattr(link, "rtt", None)
+ if rtt:
+ if hasattr(RNS, "prettyshorttime"):
+ rtt_str = RNS.prettyshorttime(rtt)
+ else:
+ rtt_str = "%.0f ms" % (rtt * 1000) if rtt < 1 else "%.3f s" % rtt
+ parts.append("RTT: %s" % rtt_str)
+ if parts:
+ self._log("Link " + ", ".join(parts))
+ except Exception:
+ pass
+
+ def _is_active(self):
+ try:
+ md = self.app.ui.main_display
+ return self._listening and md.sub_displays.active_display is self.parent and getattr(self.parent, "active", None) == "speedtest"
+ except Exception:
+ return False
+
+ def _entered_peer_hash(self):
+ hexhash = self.hash_edit.edit_text.strip()
+ dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
+ if len(hexhash) != dest_len:
+ return None
+ try:
+ return bytes.fromhex(hexhash)
+ except Exception:
+ return None
+
+ def go_back(self):
+ self.cancel(silent=True)
+ self.stop_listening()
+ if self.parent is not None:
+ self.parent.show_landing()
+
+ def go_back_to_header(self):
+ try:
+ self.app.ui.main_display.frame.focus_position = "header"
+ except Exception:
+ pass
+
+ def _cleanup_temp(self):
+ if self._tmppath:
+ try:
+ os.remove(self._tmppath)
+ except Exception:
+ pass
+ self._tmppath = None
+
+ def _on_incoming_link(self, link):
+ if not self._is_active():
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ return
+ self._incoming_link = link
+ link.set_remote_identified_callback(self._on_remote_identified)
+ self.app.ui.loop.set_alarm_in(max(8, self.app.rns.get_medium_path_timeout()), lambda l, u: self._incoming_timeout(link))
+
+ def _incoming_timeout(self, link):
+ if self._recv_link is link:
+ return
+ try:
+ link.teardown()
+ except Exception:
+ pass
+
+ def _on_remote_identified(self, link, identity):
+ self._schedule(lambda: self._verify_incoming(link, identity))
+
+ def _verify_incoming(self, link, identity):
+ expected_hash = self._entered_peer_hash()
+ try:
+ identified_hash = RNS.Destination.hash_from_name_and_identity(SPEEDTEST_FULL_NAME, identity)
+ except Exception:
+ identified_hash = None
+ if expected_hash is None or identified_hash is None or identified_hash != expected_hash:
+ self._log("Rejected speed test from unauthorized peer", "warning_text")
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ return
+ self._recv_link = link
+ self._recv_started = None
+ self._recv_resource = None
+ link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
+ link.set_resource_started_callback(self._on_recv_started)
+ link.set_resource_concluded_callback(self._on_recv_concluded)
+ self._set_progress(0)
+ self._log("Incoming speed test from %s" % RNS.prettyhexrep(identity.hash), "connected_status")
+ self._log_link_mtu(link)
+
+ def _on_recv_started(self, resource):
+ self._schedule(lambda: self._recv_started_now(resource))
+
+ def _recv_started_now(self, resource):
+ self._recv_resource = resource
+ if self._recv_started is None:
+ self._recv_started = time.time()
+ self.rate_text.set_text(("body_text", "Receiving…"))
+ self._update_listen_status()
+ if not self._recv_polling:
+ self._recv_polling = True
+ self.app.ui.loop.set_alarm_in(0.3, self._recv_poll)
+
+ def _recv_poll(self, loop, user_data):
+ if not self._recv_polling or self._recv_resource is None:
+ return
+ failed = False
+ try:
+ if self._recv_resource.status in (RNS.Resource.FAILED, RNS.Resource.CORRUPT):
+ failed = True
+ elif self._recv_link is not None and self._recv_link.status == RNS.Link.CLOSED:
+ failed = True
+ except Exception:
+ pass
+ if failed:
+ self._recv_failed()
+ return
+ try:
+ pct = int(self._recv_resource.get_progress() * 100)
+ except Exception:
+ pct = 0
+ self._set_progress(pct)
+ self.rate_text.set_text(("body_text", "Downloading… %d%%" % max(0, min(100, pct))))
+ self._draw()
+ if self._recv_polling:
+ loop.set_alarm_in(0.3, self._recv_poll)
+
+ def _on_recv_concluded(self, resource):
+ self._schedule(lambda: self._recv_concluded(resource))
+
+ def _recv_failed(self):
+ self._recv_polling = False
+ self._log("Incoming transfer failed", "warning_text")
+ self.rate_text.set_text("")
+ self._recv_resource = None
+ self._recv_link = None
+ self._update_listen_status()
+
+ def _recv_concluded(self, resource):
+ try:
+ complete = resource.status == RNS.Resource.COMPLETE
+ except Exception:
+ complete = False
+ if not complete:
+ self._recv_failed()
+ return
+ try:
+ if resource.segment_index < resource.total_segments:
+ return
+ except Exception:
+ pass
+ self._recv_polling = False
+ size = self._resource_size(resource)
+ elapsed = max(0.001, time.time() - (self._recv_started or time.time()))
+ self._set_progress(100)
+ rate = _fmt_bitrate(size / elapsed)
+ self._log("Download: %s (%s in %.2fs)" % (rate, _size_str(size), elapsed), "connected_status")
+ self.rate_text.set_text(("connected_status", "Download: %s" % rate))
+ self._recv_resource = None
+ self._recv_link = None
+ self._update_listen_status()
+
+ def _resource_size(self, resource):
+ for getter in ("get_data_size", "get_transfer_size"):
+ try:
+ fn = getattr(resource, getter, None)
+ if fn:
+ v = fn()
+ if v:
+ return v
+ except Exception:
+ pass
+ try:
+ return getattr(resource, "size", 0) or len(resource.data)
+ except Exception:
+ return 0
+
+ def on_start(self, button=None):
+ if self._sending:
+ self.cancel()
+ return
+ self.update_interface_status()
+
+ dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
+ hexhash = self.hash_edit.edit_text.strip()
+ if len(hexhash) != dest_len:
+ self._log("Peer hash must be %d hex characters" % dest_len, "error_text")
+ return
+ try:
+ self._dest_hash = bytes.fromhex(hexhash)
+ except Exception:
+ self._log("Invalid peer hash", "error_text")
+ return
+
+ label = self.size_dropdown.get_value()
+ self._send_bytes = SPEEDTEST_BYTES_BY_LABEL.get(label, 1024 * 1024)
+
+ self._send_seq += 1
+ self._sending = True
+ self.start_button.original_widget.set_label("Cancel")
+ self._set_progress(0)
+ self.rate_text.set_text("")
+ self._update_listen_status()
+
+ if RNS.Transport.has_path(self._dest_hash):
+ self._establish_link()
+ else:
+ try:
+ RNS.Transport.request_path(self._dest_hash)
+ except Exception:
+ pass
+ self._log("Requesting path to %s …" % RNS.prettyhexrep(self._dest_hash))
+ self._deadline = time.time() + self._timeout()
+ self.app.ui.loop.set_alarm_in(0.25, self._await_path)
+
+ def _timeout(self):
+ try:
+ return max(DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash), self.app.rns.get_medium_path_timeout())
+ except Exception:
+ return DEFAULT_PROBE_TIMEOUT
+
+ def _await_path(self, loop, user_data):
+ if not self._sending:
+ return
+ if RNS.Transport.has_path(self._dest_hash):
+ self._establish_link()
+ elif time.time() > self._deadline:
+ self._log("Path not found", "warning_text")
+ self._finish()
+ else:
+ loop.set_alarm_in(0.25, self._await_path)
+
+ def _establish_link(self):
+ identity = RNS.Identity.recall(self._dest_hash)
+ if identity is None:
+ self._log("Could not recall identity for peer", "error_text")
+ self._finish()
+ return
+ try:
+ destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, SPEEDTEST_APP_NAME, *SPEEDTEST_ASPECTS)
+ self._log("Establishing link with %s …" % RNS.prettyhexrep(self._dest_hash))
+ self._client_link = RNS.Link(destination, established_callback=self._on_client_link_up, closed_callback=self._on_client_link_closed)
+ except Exception as e:
+ self._log("Could not establish link: %s" % str(e), "error_text")
+ self._finish()
+ return
+ self._link_deadline = time.time() + max(self._timeout(), self._client_link.establishment_timeout)
+ self.app.ui.loop.set_alarm_in(0.5, self._check_link)
+
+ def _check_link(self, loop, user_data):
+ if not self._sending or self._client_resource is not None:
+ return
+ try:
+ active = self._client_link is not None and self._client_link.status == RNS.Link.ACTIVE
+ except Exception:
+ active = False
+ if active:
+ return
+ if time.time() > self._link_deadline:
+ self._log("Link establishment timed out", "warning_text")
+ try:
+ self._client_link.teardown()
+ except Exception:
+ pass
+ self._finish()
+ else:
+ loop.set_alarm_in(0.5, self._check_link)
+
+ def _on_client_link_up(self, link):
+ self._schedule(lambda: self._client_ready(link))
+
+ def _client_ready(self, link):
+ if not self._sending:
+ return
+ try:
+ link.identify(self.app.identity)
+ except Exception:
+ pass
+ self._log_link_mtu(link)
+ seq = self._send_seq
+ self.app.ui.loop.set_alarm_in(0.6, lambda l, u: self._prepare_and_send(link, seq))
+
+ def _prepare_and_send(self, link, seq):
+ if not self._sending or seq != self._send_seq or self._client_resource is not None:
+ return
+ nbytes = self._send_bytes
+ if nbytes <= SPEEDTEST_INMEM_MAX:
+ self._begin_resource(link, os.urandom(nbytes), None, seq)
+ return
+ self._log("Preparing %s payload …" % _size_str(nbytes))
+
+ def worker():
+ path = None
+ try:
+ fd, path = tempfile.mkstemp(prefix="nn_speedtest_")
+ written = 0
+ with os.fdopen(fd, "wb") as f:
+ while written < nbytes and self._sending and seq == self._send_seq:
+ chunk = min(1024 * 1024, nbytes - written)
+ f.write(os.urandom(chunk))
+ written += chunk
+ except Exception as e:
+ if path:
+ try:
+ os.remove(path)
+ except Exception:
+ pass
+ self._schedule(lambda: (self._log("Could not prepare payload: %s" % str(e), "error_text"), self._finish()))
+ return
+ if not self._sending or seq != self._send_seq:
+ if path:
+ try:
+ os.remove(path)
+ except Exception:
+ pass
+ return
+ self._schedule(lambda: self._begin_resource(link, None, path, seq))
+
+ threading.Thread(target=worker, daemon=True).start()
+
+ def _begin_resource(self, link, data_bytes, tmppath, seq):
+ if not self._sending or seq != self._send_seq:
+ if tmppath:
+ try:
+ os.remove(tmppath)
+ except Exception:
+ pass
+ return
+ self._tmppath = tmppath
+ try:
+ src = data_bytes if data_bytes is not None else open(tmppath, "rb")
+ self._send_started = time.time()
+ self._client_resource = RNS.Resource(src, link, callback=self._on_send_done, progress_callback=self._on_send_progress, auto_compress=False)
+ self._log("Sending %s test payload …" % _size_str(self._send_bytes))
+ except Exception as e:
+ self._log("Could not start transfer: %s" % str(e), "error_text")
+ self._cleanup_temp()
+ self._finish()
+
+ def _on_send_progress(self, resource):
+ self._schedule(lambda: self._send_progress(resource))
+
+ def _send_progress(self, resource):
+ try:
+ pct = int(resource.get_progress() * 100)
+ except Exception:
+ pct = 0
+ self._set_progress(pct)
+ self.rate_text.set_text(("body_text", "Uploading… %d%%" % max(0, min(100, pct))))
+
+ def _on_send_done(self, resource):
+ self._schedule(lambda: self._send_done(resource))
+
+ def _send_done(self, resource):
+ if not self._sending:
+ return
+ try:
+ complete = resource.status == RNS.Resource.COMPLETE
+ except Exception:
+ complete = False
+ if complete:
+ self._set_progress(100)
+ elapsed = max(0.001, time.time() - (self._send_started or time.time()))
+ rate = _fmt_bitrate(self._send_bytes / elapsed)
+ self._log("Upload: %s (%s in %.2fs)" % (rate, _size_str(self._send_bytes), elapsed), "connected_status")
+ self.rate_text.set_text(("connected_status", "Upload: %s" % rate))
+ else:
+ self._log("Transfer failed or was not accepted", "warning_text")
+ self.rate_text.set_text("")
+ try:
+ if self._client_link is not None:
+ self._client_link.teardown()
+ except Exception:
+ pass
+ self._cleanup_temp()
+ self._finish()
+
+ def _on_client_link_closed(self, link):
+ self._schedule(self._client_closed)
+
+ def _client_closed(self):
+ if self._sending and self._client_resource is None:
+ self._log("Link closed before the test started", "warning_text")
+ self._finish()
+
+ def cancel(self, silent=False):
+ if not self._sending:
+ return
+ self._send_seq += 1
+ self._sending = False
+ try:
+ if self._client_resource is not None:
+ self._client_resource.cancel()
+ except Exception:
+ pass
+ try:
+ if self._client_link is not None:
+ self._client_link.teardown()
+ except Exception:
+ pass
+ self._client_resource = None
+ self._cleanup_temp()
+ if not silent:
+ self._log("Speed test cancelled", "warning_text")
+ self.rate_text.set_text("")
+ self._set_progress(0)
+ self.start_button.original_widget.set_label("Send test file")
+ self._update_listen_status()
+ self._draw()
+
+ def _finish(self):
+ self._sending = False
+ self._client_resource = None
+ self.start_button.original_widget.set_label("Send test file")
+ self._update_listen_status()
+ self._draw()
diff --git a/nomadnet/ui/textui/__init__.py b/nomadnet/ui/textui/__init__.py
index e3164b0..72302cb 100644
--- a/nomadnet/ui/textui/__init__.py
+++ b/nomadnet/ui/textui/__init__.py
@@ -1,5 +1,7 @@
import os
import glob
-modules = glob.glob(os.path.dirname(__file__)+"/*.py")
-__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')]
+py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
+pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
+modules = py_modules+pyc_modules
+__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
diff --git a/nomadnet/ui/textui/images/__init__.py b/nomadnet/ui/textui/images/__init__.py
new file mode 100644
index 0000000..619be17
--- /dev/null
+++ b/nomadnet/ui/textui/images/__init__.py
@@ -0,0 +1,18 @@
+# A minimal, dependency-free image widget for urwid, rendering images through
+# the Kitty terminal graphics protocol.
+
+from ._termlib import get_cell_size, is_kitty_supported, reset_caches, diagnostics
+from ._imagedata import ImageData, parse_image_header
+from .widget import ImageWidget, ImageCanvas, UI_MARGIN
+from .screen import ImageScreen
+
+__all__ = ( "ImageWidget",
+ "ImageCanvas",
+ "ImageScreen",
+ "ImageData",
+ "parse_image_header",
+ "is_kitty_supported",
+ "get_cell_size",
+ "diagnostics",
+ "reset_caches",
+ "UI_MARGIN" )
diff --git a/nomadnet/ui/textui/images/_ctlseqs.py b/nomadnet/ui/textui/images/_ctlseqs.py
new file mode 100644
index 0000000..564b7d2
--- /dev/null
+++ b/nomadnet/ui/textui/images/_ctlseqs.py
@@ -0,0 +1,78 @@
+# Kitty Terminal Graphics Protocol control sequences
+#
+# Minimal set of control sequences needed for the Kitty graphics protocol
+# side-channel image rendering used by the urwid image widget.
+
+from base64 import standard_b64encode
+import re
+
+# Basic building blocks
+APC = b"\x1b_" # Application Program Command
+ST = b"\x1b\\" # String Terminator
+CSI = b"\x1b["
+
+# Kitty graphics: transmit-and-display with chunking support.
+# ESC _ G ; ESC \
+KITTY_TRANSMISSION = b"\x1b_G%s;%s\x1b\\"
+
+# Delete images: d=A (all placements visible on screen), d=i (one
+# placement, data kept), d=I (whole image, data freed).
+KITTY_DELETE_ALL = b"\x1b_Ga=d,d=A\x1b\\"
+KITTY_DELETE_PLACEMENT = b"\x1b_Ga=d,d=i,i=%d,p=%d\x1b\\"
+KITTY_DELETE_IMAGE = b"\x1b_Ga=d,d=I,i=%d\x1b\\"
+
+# Support query (payload is base64 of 4 zero bytes; expects "i=31;OK")
+KITTY_QUERY = b"\x1b_Ga=q,t=d,i=31,f=24,s=1,v=1,C=1,c=1,r=1;AAAA\x1b\\"
+KITTY_QUERY_RESPONSE_RE = re.compile(b"\x1b_Gi=31;(?P[^\x1b]*)\x1b\\\\")
+
+# Cursor: absolute positioning, erase characters, cursor forward
+CURSOR_POSITION = b"\x1b[%d;%dH"
+ERASE_CHARS = b"\x1b[%dX"
+
+# Synchronized output (DEC 2026) - reduces flicker/tearing when redrawing
+BEGIN_SYNCED_UPDATE = b"\x1b[?2026h"
+END_SYNCED_UPDATE = b"\x1b[?2026l"
+
+# Terminal queries: cell size in pixels (XTWINOPS 16) + primary DA
+CELL_SIZE_PX = b"\x1b[16t"
+DA1 = b"\x1b[c"
+# Response: CSI 4 ; ; t
+CELL_SIZE_RESPONSE_RE = re.compile(b"\x1b\\[4;(?P\\d+);(?P\\d+)t")
+
+# Maximum payload (base64) per transmission chunk, as recommended by the
+# protocol documentation
+CHUNK_SIZE = 4096
+
+# Control data for a transmit-only (a=t) with a quiet mode (q=1)
+def kitty_transmit_control(image_id): return b"a=t,i=%d,f=100,t=d,q=1" % image_id
+
+# A placement command (a=p) for a previously transmitted image.
+# Re-sending the same (image id, placement id) pair replaces the
+# placement in place - used to move/resize/slice without flicker.
+# y/h select the source rectangle (image pixels) when a slice of
+# the image is displayed.
+def kitty_placement(image_id, placement_id, cols, rows, z, y=None, h=None):
+ control = b"a=p,i=%d,p=%d,c=%d,r=%d,z=%d,C=1,q=1" % (image_id, placement_id, cols, rows, z)
+ if y is not None and h is not None: control += b",y=%d,h=%d" % (y, h)
+ return KITTY_TRANSMISSION % (control, b"")
+
+# Encodes a kitty graphics transmission, chunking the base64-encoded
+# payload into ``chunk_size``-byte pieces.
+def kitty_transmission(control, payload, chunk_size=CHUNK_SIZE):
+ encoded = standard_b64encode(payload)
+ chunks = [encoded[i:i + chunk_size] for i in range(0, len(encoded), chunk_size)]
+ if not chunks:
+ chunks = [b""]
+
+ def control_for(index, n_chunks):
+ if n_chunks == 1:
+ return control
+ if index == 0:
+ # The control data appears only in the first chunk; the chunk
+ # marker says more chunks are coming.
+ return control + b",m=1"
+ if index == n_chunks - 1:
+ return b"m=0"
+ return b"m=1"
+
+ return b"".join(KITTY_TRANSMISSION % (control_for(i, len(chunks)), chunk) for i, chunk in enumerate(chunks))
diff --git a/nomadnet/ui/textui/images/_imagedata.py b/nomadnet/ui/textui/images/_imagedata.py
new file mode 100644
index 0000000..a78045a
--- /dev/null
+++ b/nomadnet/ui/textui/images/_imagedata.py
@@ -0,0 +1,142 @@
+# Image file handling for the side-channel renderer
+#
+# The Kitty graphics protocol can decode and scale images in the terminal
+# itself (data format ``f=100``), which means the widget needs no image
+# decoding library at all. It only needs the *dimensions* of the image to lay
+# it out correctly in the urwid canvas; those are trivially parseable from the
+# file headers of the supported formats.
+#
+# Supported source formats:
+# - WebP (VP8, VP8L and VP8X)
+# - PNG
+#
+# The payload transmitted to the terminal is always a PNG file: PNG sources
+# are passed through verbatim, and WebP sources are converted transparently
+# by the multi-backend conversion chain in :mod:`._webp` (no required
+# dependencies). The conversion result is cached on disk, so each unique
+# WebP is converted at most once per system.
+
+import hashlib
+import os
+
+MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
+MAX_CONVERTED_PAYLOAD_BYTES = 16 * 1024 * 1024
+_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
+
+# Returns (width, height) from a PNG file.
+def _parse_png(data):
+ if not data.startswith(_PNG_SIGNATURE): raise ValueError("not a PNG")
+ if len(data) < 24 or data[12:16] != b"IHDR": raise ValueError("PNG missing IHDR chunk")
+ width = int.from_bytes(data[16:20], "big")
+ height = int.from_bytes(data[20:24], "big")
+ if not (width > 0 and height > 0): raise ValueError("PNG has null dimensions")
+ return width, height
+
+# Returns (width, height) from a WebP file (VP8, VP8L or VP8X).
+def _parse_webp(data):
+ if not data.startswith(b"RIFF") or data[8:12] != b"WEBP": raise ValueError("not a WebP")
+ if len(data) < 30: raise ValueError("WebP file too short")
+ fourcc = data[12:16]
+ if fourcc == b"VP8X":
+ # 10-byte chunk: flags(1) reserved(3) canvas-w-1(3 LE) canvas-h-1(3 LE)
+ width = int.from_bytes(data[24:27], "little") + 1
+ height = int.from_bytes(data[27:30], "little") + 1
+ elif fourcc == b"VP8 ":
+ # frame tag(3) start code 0x9d012a(3) width(2 LE, 14 bits) height(2 LE, 14 bits)
+ width = int.from_bytes(data[26:28], "little") & 0x3FFF
+ height = int.from_bytes(data[28:30], "little") & 0x3FFF
+ elif fourcc == b"VP8L":
+ # signature(1) then 4 bytes: w-1 (14 bits) | h-1 (14 bits) | ...
+ bits = int.from_bytes(data[21:25], "little")
+ width = (bits & 0x3FFF) + 1
+ height = ((bits >> 14) & 0x3FFF) + 1
+ else: raise ValueError("unsupported WebP variant %r" % fourcc)
+ if not (width > 0 and height > 0): raise ValueError("WebP has null dimensions")
+ return width, height
+
+
+def parse_image_header(data):
+ if data[:8] == _PNG_SIGNATURE:
+ w, h = _parse_png(data)
+ return "PNG", w, h
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
+ w, h = _parse_webp(data)
+ return "WebP", w, h
+ raise ValueError("unsupported image format")
+
+# Loads an image file and exposes its PNG bytes and dimensions.
+# The payload is always a PNG file, transmitted verbatim to the terminal
+# (Kitty's f=100 data format). The dimensions are used only for layout.
+# WebP sources are converted transparently to PNG on load.
+class ImageData(object):
+ def __init__(self, path, max_bytes=MAX_PAYLOAD_BYTES, remote_source=False):
+ self.path = path
+ self.error = None
+ self.format = None
+ self.source_format = None
+ self.conversion_backend = None
+ self.width = 0
+ self.height = 0
+ self.data = None
+ try:
+ with open(path, "rb") as f: data = f.read()
+ except OSError as e:
+ self.error = "Could not read file: %s" % e
+ return
+
+ if len(data) > max_bytes:
+ self.error = "File too large (%.1f MiB > %d MiB)" % (len(data) / (1024 * 1024), max_bytes // (1024 * 1024))
+ return
+
+ if not data:
+ self.error = "Empty file"
+ return
+
+ try: self.format, self.width, self.height = parse_image_header(data)
+ except ValueError as e:
+ self.error = str(e)
+ return
+
+ self.source_format = self.format
+ if self.format == "WebP":
+ from . import _webp
+ png = _webp.convert_webp_to_png(data)
+ if png is None:
+ self.error = "WebP conversion failed (no working backend available)"
+ return
+ if len(png) > MAX_CONVERTED_PAYLOAD_BYTES:
+ self.error = "Converted image too large (%.1f MiB > %d MiB)" % (len(png) / (1024 * 1024), MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024))
+ return
+
+ self.data = png
+ self.format = "PNG"
+ self.conversion_backend = _webp.last_backend()
+
+ else:
+ if remote_source:
+ self.error = "Invalid image format, remote images must be WebP"
+ return
+ else:
+ self.data = data
+
+ # Content key for de-duplication: identical bytes = same key, so
+ # the same image transmitted once can be placed many times.
+ self._key = hashlib.sha256(self.data).hexdigest()
+
+ @property
+ def ok(self): return self.data is not None
+
+ @property
+ def key(self): return getattr(self, "_key", None)
+
+ @property
+ def size(self): return (self.width, self.height)
+
+ def description(self):
+ if self.ok:
+ if self.source_format == "WebP":
+ return "WebP %dx%d -> PNG (%s)" % (self.width, self.height, self.conversion_backend or "cached")
+ else:
+ return "%s %dx%d" % (self.format, self.width, self.height)
+
+ return self.error
diff --git a/nomadnet/ui/textui/images/_imagestore.py b/nomadnet/ui/textui/images/_imagestore.py
new file mode 100644
index 0000000..78935b5
--- /dev/null
+++ b/nomadnet/ui/textui/images/_imagestore.py
@@ -0,0 +1,90 @@
+# Registry of terminal-side image data
+#
+# Tracks which image payloads have been transmitted to the terminal (Kitty
+# image ids), de-duplicates identical image data across widgets (one payload
+# transmitted once, placed many times), and counts live widget references so
+# that orphaned data can be evicted from the terminal's buffers.
+#
+# This module only tracks *state*; all protocol output is performed by
+# ImageScreen, which reads and updates the store.
+#
+# Eviction:
+#
+# - Automatic, widgets unregister when closed/destroyed; the screen evicts
+# (deletes from the terminal) any registered data with no live references.
+# - Explicit, the browser can call ImageScreen.purge_images() on page
+# navigation to drop everything at once.
+
+DEFAULT_IMAGE_ID_MAX = 2 ** 32 - 1 # Protocol limit for image ids
+
+class ImageStore(object):
+ def __init__(self):
+ self._entries = {} # content key -> {"image_id", "data", "refs", "transmitted"}
+ self._next_image_id = 1
+
+ #############
+ # Lifecycle #
+ #############
+
+ # Registers image data and returns (key, is_new).
+ # If identical data is already registered, the existing entry is
+ # reused (de-duplication) and its reference count is incremented.
+ def register(self, key, data):
+ entry = self._entries.get(key)
+ if entry is None:
+ image_id = self._next_image_id
+ self._next_image_id += 1
+ if image_id > DEFAULT_IMAGE_ID_MAX: raise OverflowError("image id space exhausted")
+ entry = { "image_id": image_id, "data": data,
+ "refs": 0, "transmitted": False }
+ self._entries[key] = entry
+ is_new = True
+ else: is_new = False
+
+ entry["refs"] += 1
+ return key, is_new
+
+ # Decrements the reference count of registered data
+ def unregister(self, key):
+ entry = self._entries.get(key)
+ if entry is None: return
+ entry["refs"] = max(0, entry["refs"] - 1)
+
+ ###########
+ # Queries #
+ ###########
+
+ def entry_for(self, key): return self._entries.get(key)
+
+ def transmitted(self): return [key for key, entry in self._entries.items() if entry["transmitted"]]
+
+ # Returns [(key, image_id), ...] for entries with no live
+ # widget references that are still present in the terminal.
+ def evict_orphans(self):
+ return [ (key, entry["image_id"])
+ for key, entry in self._entries.items()
+ if entry["refs"] == 0 and entry["transmitted"] ]
+
+ ############
+ # Mutation #
+ ############
+
+ def mark_transmitted(self, key):
+ entry = self._entries.get(key)
+ if entry is not None:
+ entry["transmitted"] = True
+
+ def mark_all_untransmitted(self):
+ for entry in self._entries.values():
+ entry["transmitted"] = False
+
+ # Drops the entry for evicted data (frees the payload bytes)
+ def evict(self, key): self._entries.pop(key, None)
+
+ # Forgets all state (used by tests)
+ def reset(self):
+ self._entries.clear()
+ self._next_image_id = 1
+
+# Module-level singleton shared by widgets and the screen.
+image_store = ImageStore()
diff --git a/nomadnet/ui/textui/images/_selftest.py b/nomadnet/ui/textui/images/_selftest.py
new file mode 100644
index 0000000..ce132c4
--- /dev/null
+++ b/nomadnet/ui/textui/images/_selftest.py
@@ -0,0 +1,718 @@
+#!/usr/bin/env python3
+"""
+.. Images - headless self test
+
+Exercises the widget + screen machinery without a terminal: image header
+parsing, display sizing, shard scanning, APC construction/chunking and the
+transmit/delete state machine at various scroll positions.
+
+Run::
+
+ python3 -m nomadnet.ui.textui.images._selftest [image-file]
+"""
+
+import base64
+import io
+import os
+import shutil
+import struct
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
+
+import urwid
+
+from nomadnet.ui.textui.images import _ctlseqs
+from nomadnet.ui.textui.images import _imagestore
+from nomadnet.ui.textui.images import _termlib
+from nomadnet.ui.textui.images._imagedata import ImageData, parse_image_header
+from nomadnet.ui.textui.images.screen import ImageScreen
+from nomadnet.ui.textui.images.widget import ImageWidget
+
+DEFAULT_IMAGE = os.path.expanduser("~/Scratchpad/test.png")
+VIEWPORT = (80, 24)
+CELL_SIZE = (1, 2) # forced fallback for deterministic layout
+
+PASS = 0
+FAIL = 0
+
+
+def check(cond, label):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(" ok - %s" % label)
+ else:
+ FAIL += 1
+ print(" FAIL - %s" % label)
+
+
+def build_pile(image_path, wrap_attrmap=False):
+ widgets = []
+ for i in range(12):
+ widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % i)) # 3 rows each
+ img = ImageWidget(image_path)
+ if wrap_attrmap:
+ img = urwid.AttrMap(img, "body")
+ widgets.append(urwid.Divider(" "))
+ widgets.append(img)
+ widgets.append(urwid.Divider(" "))
+ for i in range(12):
+ widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % (13 + i)))
+ return urwid.Pile(widgets)
+
+
+class RecordingScreen(ImageScreen):
+ def __init__(self):
+ super().__init__()
+ self.log = []
+
+ def write(self, data):
+ if isinstance(data, bytes):
+ data = data.decode("ascii")
+ self.log.append(data)
+ return data
+
+ def flush(self):
+ pass
+
+
+def extract_transmit(log):
+ """Returns (image_id, joined_b64_payload) of the a=t transmission, or
+ None if no transmission is present."""
+ image_id = None
+ payloads = []
+ for item in log:
+ if item.startswith("\x1b_Ga=t"):
+ for piece in item.split("\x1b_G")[1:]:
+ body = piece[:-2] # strip the trailing ST (ESC \)
+ head, sep, payload = body.partition(";")
+ if not sep:
+ continue
+ if image_id is None:
+ image_id = int(head.split("i=")[1].split(",")[0])
+ payloads.append(payload)
+ if image_id is None:
+ return None
+ return image_id, "".join(payloads)
+
+
+def extract_placements(log):
+ """Returns the control data of every a=p placement command."""
+ return [
+ item[3:-2]
+ for item in log
+ if item.startswith("\x1b_Ga=p")
+ ]
+
+
+def extract_deletes(log):
+ """Returns the control data of every a=d delete command."""
+ return [
+ item[3:-2]
+ for item in log
+ if item.startswith("\x1b_Ga=d")
+ ]
+
+
+def screen_probe_placement_key(widget, row, col, canvas_cols, trim_top, vis_rows):
+ """Computes an ImageScreen placement key without a screen instance."""
+ return ImageScreen._placement_key(
+ None, widget, row, col, canvas_cols, trim_top, vis_rows)
+
+
+def test_header_parse(image_path):
+ print("== header parsing ==")
+ with open(image_path, "rb") as f:
+ data = f.read()
+ fmt, w, h = parse_image_header(data)
+ check(fmt == "PNG", "detected PNG")
+ check(w == 1280 and h == 960, "dimensions %dx%d" % (w, h))
+ imgdata = ImageData(image_path)
+ check(imgdata.ok, "ImageData loads without error")
+ check(imgdata.size == (w, h), "ImageData size matches")
+
+
+def test_transmission_encoding(image_path):
+ print("== APC encoding/chunking ==")
+ small = b"\x89PNG\r\n\x1a\n" + b"x" * 100
+ single = _ctlseqs.kitty_transmission(b"a=T,f=100,c=80,r=30,z=1", small)
+ check(single.count(b"\x1b_G") == 1, "single-chunk transmission has one APC")
+ check(single.startswith(b"\x1b_Ga=T,f=100,c=80,r=30,z=1;"), "control data correct")
+ check(single.endswith(b"\x1b\\"), "ends with ST")
+ payload = single.split(b";", 1)[1][:-2]
+ check(base64.b64decode(payload) == small, "payload round-trips to original file")
+
+ with open(image_path, "rb") as f:
+ data = f.read()
+ chunks = _ctlseqs.kitty_transmission(b"a=T,f=100", data, chunk_size=16)
+ apcs = chunks.split(b"\x1b\\")[:-1]
+ check(len(apcs) > 1, "multi-chunk transmission splits into %d APCs" % len(apcs))
+ first, last = apcs[0], apcs[-1]
+ heads = [a.split(b";", 1)[0][3:] for a in apcs]
+ check(b",m=1;" in first, "first chunk carries control + m=1")
+ check(last.startswith(b"\x1b_Gm=0;"), "last chunk carries m=0")
+ check(sum(b"a=T" in h for h in heads) == 1, "control data appears only once")
+ check(all(h == b"m=1" or h == b"m=0" for h in heads[1:]), "continuation chunks carry only the chunk marker")
+ joined_b64 = b"".join(a.split(b";", 1)[1] for a in apcs)
+ check(base64.b64decode(joined_b64) == data, "multi-chunk payload reassembles")
+
+
+_TINY_WEBP = base64.b64decode(
+ "UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuUAAA=")
+
+
+def _webp_fixture():
+ """Returns (webp_bytes, (width, height)): a 4x4 WebP generated with
+ Pillow when available (more representative), else a 1x1 constant."""
+ try:
+ from PIL import Image
+ if _webp_module_available():
+ image = Image.new("RGBA", (4, 4))
+ for y in range(4):
+ for x in range(4):
+ image.putpixel((x, y), (x * 60, y * 60, x * y * 10, 255))
+ buf = io.BytesIO()
+ image.save(buf, format="WEBP")
+ data = buf.getvalue()
+ if parse_image_header(data)[0] == "WebP":
+ return data, (4, 4)
+ except Exception:
+ pass
+ return _TINY_WEBP, (1, 1)
+
+
+def _webp_module_available():
+ try:
+ from PIL import features
+ return bool(features.check("webp"))
+ except Exception:
+ return False
+
+
+def test_webp_conversion():
+ print("== webp conversion ==")
+ from nomadnet.ui.textui.images import _webp
+ from nomadnet.ui.textui.images._imagedata import MAX_CONVERTED_PAYLOAD_BYTES
+
+ fmt, w, h = parse_image_header(_TINY_WEBP)
+ check((fmt, w, h) == ("WebP", 1, 1), "embedded fixture is a 1x1 WebP")
+
+ fixture, dims = _webp_fixture()
+ check(parse_image_header(fixture)[1:] == dims,
+ "webp fixture has expected dimensions")
+
+ cache_tmp = tempfile.mkdtemp(prefix="imgw-test-cache-")
+ prev_cache_env = os.environ.get("NOMADNET_IMAGE_CACHE")
+ os.environ["NOMADNET_IMAGE_CACHE"] = cache_tmp
+ try:
+ # Each available backend must convert the fixture to a valid PNG
+ # with matching dimensions. Backends are invoked directly, since
+ # the on-disk cache would otherwise mask all but the first one.
+ _webp.reset()
+ present = [name for name, avail in _webp.available_backends() if avail]
+ converted = 0
+ for name in present:
+ png = _webp._BACKENDS[name](fixture, dims)
+ ok = (png is not None and png.startswith(b"\x89PNG")
+ and _webp._png_dims(png) == dims)
+ check(ok, "%s backend converts to a valid PNG" % name)
+ if ok:
+ converted += 1
+ check(converted >= 1, "at least one backend converts webp (got %d)" % converted)
+
+ # Repeat conversion is served from the on-disk cache: the backend
+ # table is disabled and the conversion still succeeds.
+ _webp.reset()
+ png1 = _webp.convert_webp_to_png(fixture)
+ check(png1 is not None, "first conversion succeeds")
+ orig_backends = dict(_webp._BACKENDS)
+ _webp._BACKENDS = {name: (lambda *a, **k: None) for name in _webp.BACKENDS}
+ png2 = _webp.convert_webp_to_png(fixture)
+ check(png1 == png2 and _webp.stats()["cache_hits"] >= 1,
+ "repeat conversion served from the on-disk cache")
+ _webp._BACKENDS = orig_backends
+
+ # ImageData-level transparency: a webp file loads as a PNG.
+ _webp.reset()
+ fd, webp_path = tempfile.mkstemp(suffix=".webp")
+ with os.fdopen(fd, "wb") as f:
+ f.write(fixture)
+ d = ImageData(webp_path)
+ check(d.ok and d.format == "PNG" and d.size == dims,
+ "ImageData converts webp transparently to PNG")
+ check(d.data.startswith(b"\x89PNG\r\n\x1a\n") and d.key is not None,
+ "converted payload is a keyed PNG")
+ check("-> PNG" in d.description(), "description notes the conversion")
+ os.unlink(webp_path)
+
+ # Converted-payload cap.
+ _webp.reset()
+ orig_convert = _webp.convert_webp_to_png
+ _webp.convert_webp_to_png = lambda data: b"x" * (MAX_CONVERTED_PAYLOAD_BYTES + 1)
+ fd, webp_path = tempfile.mkstemp(suffix=".webp")
+ with os.fdopen(fd, "wb") as f:
+ f.write(fixture)
+ d = ImageData(webp_path)
+ check(not d.ok and "Converted image too large" in d.error,
+ "converted payload capped at %d MiB"
+ % (MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024)))
+ os.unlink(webp_path)
+ _webp.convert_webp_to_png = orig_convert
+
+ # Undecodable webp (valid header/dimensions, garbage bitstream).
+ _webp.reset()
+ junk = (b"RIFF" + struct.pack(" (10, 20)")
+ check(_termlib._cell_size_from_winsize([24, 80, 960, 480]) == (12, 20),
+ "correct winsize -> (12, 20)")
+ check(_termlib._cell_size_from_winsize([24, 80, 0, 0]) is None,
+ "zero pixel fields -> None")
+ check(_termlib._cell_size_from_winsize([40, 100, 800, 1000]) == (8, 25),
+ "portrait window cell keeps valid aspect")
+ check(_termlib._cell_size_from_winsize([30, 80, 1200, 240]) is None,
+ "implausible aspect (broken reporting) -> None")
+ check(_termlib._cell_size_from_winsize([0, 0, 0, 0]) is None,
+ "empty winsize -> None")
+
+
+def test_widget_layout(image_path):
+ print("== widget layout ==")
+ _termlib.kitty_supported = True
+ _termlib.cell_size = CELL_SIZE
+ # Force a deterministic terminal size for the height-percent cases.
+ _orig_terminal_size = _termlib.get_terminal_size
+ _termlib.get_terminal_size = lambda: (80, 24)
+ _orig_ui_margin = None
+ from nomadnet.ui.textui.images import widget as _widget_mod
+ _orig_ui_margin = _widget_mod.UI_MARGIN
+ _widget_mod.UI_MARGIN = 8
+ try:
+ imgdata = ImageData(image_path)
+ w = ImageWidget(image_path)
+ iw, ih = imgdata.size
+ expected_rows = max(1, round(80 * CELL_SIZE[0] * ih / (CELL_SIZE[1] * iw)))
+ rows = w.rows((80,))
+ check(rows == expected_rows,
+ "default: rows((80,)) == %d, got %d" % (expected_rows, rows))
+ canvas = w.render((80,))
+ check(isinstance(canvas, urwid.SolidCanvas), "render returns a solid canvas")
+ check(canvas.rows() == rows and canvas.cols() == 80, "canvas sized 80x%d" % rows)
+ check(w._ti_rows == rows and w._ti_cols == 80, "widget stores display size")
+ w.close()
+
+ # width as absolute columns (aspect preserved); the canvas fills the
+ # allocated width while the image rect is the resolved size.
+ w = ImageWidget(image_path, width=40)
+ c, r = w._display_size(80)
+ check((c, r) == (40, 15), "width=40 -> (40, 15), got (%d, %d)" % (c, r))
+ canvas = w.render((80,))
+ check((canvas.cols(), canvas.rows()) == (80, 15),
+ "width=40 canvas fills the row (80, 15), got (%d, %d)"
+ % (canvas.cols(), canvas.rows()))
+ check((w._ti_cols, w._ti_rows) == (40, 15),
+ "width=40 image rect stored (40, 15)")
+ w.close()
+
+ # width as percent of the layout width
+ w = ImageWidget(image_path, width="50%")
+ c, r = w._display_size(80)
+ check((c, r) == (40, 15), "width=50%% -> (40, 15), got (%d, %d)" % (c, r))
+ w.close()
+
+ # height as absolute rows (width derived, aspect preserved)
+ w = ImageWidget(image_path, height=20)
+ c, r = w._display_size(80)
+ check(r == 20 and c == 53, "height=20 -> (53, 20), got (%d, %d)" % (c, r))
+ w.close()
+
+ # height as percent of the visible page height
+ # page = terminal lines (24) - UI_MARGIN (8) = 16; 50% -> 8 rows
+ w = ImageWidget(image_path, height="50%")
+ c, r = w._display_size(80)
+ check(r == 8 and c == 21, "height=50%% -> (21, 8), got (%d, %d)" % (c, r))
+ w.close()
+
+ # both given: stretch to the exact rectangle
+ w = ImageWidget(image_path, width=40, height=30)
+ c, r = w._display_size(80)
+ check((c, r) == (40, 30), "width=40,height=30 stretches -> (40, 30), got (%d, %d)" % (c, r))
+ w.close()
+
+ # stretching is clamped uniformly when overflowing the space
+ w = ImageWidget(image_path, width=400, height=200)
+ c, r = w._display_size(80)
+ check((c, r) == (80, 40), "overflowing stretch clamped -> (80, 40), got (%d, %d)" % (c, r))
+ w.close()
+
+ # BOX layout: the canvas fills the box; the image rect is clamped
+ # and re-derived (width from the clamped height).
+ w = ImageWidget(image_path, name="box")
+ canvas = w.render((80, 10))
+ check((canvas.cols(), canvas.rows()) == (80, 10),
+ "BOX render((80,10)) canvas fills the box, got (%d, %d)"
+ % (canvas.cols(), canvas.rows()))
+ check((w._ti_cols, w._ti_rows) == (27, 10),
+ "BOX image rect clamped -> (27, 10), got (%d, %d)"
+ % (w._ti_cols, w._ti_rows))
+ w.close()
+
+ # invalid specifications
+ def raises(ctor, want):
+ try:
+ ctor()
+ except want:
+ return True
+ except Exception:
+ return False
+ return False
+ check(raises(lambda: ImageWidget(image_path, width="xx"), ValueError),
+ "width='xx' raises ValueError")
+ check(raises(lambda: ImageWidget(image_path, height=0), ValueError),
+ "height=0 raises ValueError")
+ check(raises(lambda: ImageWidget(image_path, width=1.5), TypeError),
+ "width=1.5 raises TypeError")
+ check(raises(lambda: ImageWidget(image_path, height="50"), ValueError),
+ "height='50' raises ValueError")
+
+ # settable properties re-resolve and invalidate
+ w = ImageWidget(image_path)
+ w.width = "50%"
+ c, r = w._display_size(80)
+ check((c, r) == (40, 15), "set width=50%% property -> (40, 15)")
+ w.height = 10
+ c, r = w._display_size(80)
+ check((c, r) == (40, 10), "width+height set -> stretch (40, 10), got (%d, %d)" % (c, r))
+ w.width = None
+ w.height = 10
+ c, r = w._display_size(80)
+ check((c, r) == (27, 10), "height-only after reset -> (27, 10), got (%d, %d)" % (c, r))
+ w.close()
+
+ # native resolution specification ("n"): display at the image's own
+ # pixel size, quantized to the terminal cell grid. Cell = (1, 2),
+ # small fixture 64x32 -> native 64 cols, 16 rows.
+ from nomadnet.ui.textui.images import _webp
+ small_png = _webp._encode_png_rgba(bytes(64 * 32 * 4), 64, 32)
+ fd, small_path = tempfile.mkstemp(suffix=".png")
+ with os.fdopen(fd, "wb") as f:
+ f.write(small_png)
+ try:
+ wn = ImageWidget(small_path, width="n")
+ wn.render((80,)) # sets _ti_cols/_ti_rows
+ check((wn._ti_cols, wn._ti_rows) == (64, 16),
+ "width='n' displays native size (64, 16), got (%d, %d)"
+ % (wn._ti_cols, wn._ti_rows))
+ wn.close()
+ hn = ImageWidget(small_path, height="n")
+ hn.render((80,))
+ check((hn._ti_cols, hn._ti_rows) == (64, 16),
+ "height='n' displays native size (64, 16), got (%d, %d)"
+ % (hn._ti_cols, hn._ti_rows))
+ hn.close()
+ bn = ImageWidget(small_path, width="n", height="n")
+ bn.render((80,))
+ check((bn._ti_cols, bn._ti_rows) == (64, 16),
+ "width='n' height='n' displays native size (64, 16), got (%d, %d)"
+ % (bn._ti_cols, bn._ti_rows))
+ bn.close()
+ big = ImageWidget(image_path, width="n") # 1280x960, overflows
+ c, r = big._display_size(80)
+ check((c, r) == (80, 30),
+ "native of a large image clamps to available width (80, 30), got (%d, %d)" % (c, r))
+ big.close()
+ check(raises(lambda: ImageWidget(image_path, width="x"), ValueError),
+ "width='x' raises ValueError")
+ finally:
+ os.unlink(small_path)
+
+ # alignment: placement column of a narrowed image inside its canvas
+ w = ImageWidget(image_path, width=40) # default center
+ w.render((80,)) # sets _ti_cols/_ti_rows
+ check(w._ti_align == "|", "default alignment is center")
+ key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
+ check(key == (3, 20, 40, 15, None, None),
+ "centered placement col -> %s" % (key,))
+ w.align = "left"
+ key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
+ check(key == (3, 0, 40, 15, None, None), "left placement col -> 0")
+ w.align = "right"
+ key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
+ check(key == (3, 40, 40, 15, None, None), "right placement col -> 40")
+ check(raises(lambda: ImageWidget(image_path, align="top"), ValueError),
+ "align='top' raises ValueError")
+ w.close()
+ finally:
+ _termlib.get_terminal_size = _orig_terminal_size
+ if _orig_ui_margin is not None:
+ _widget_mod.UI_MARGIN = _orig_ui_margin
+
+
+def find_image_widget(pile):
+ for widget, _opts in pile.contents:
+ child = widget
+ if isinstance(child, urwid.AttrMap):
+ child = child.original_widget
+ if isinstance(child, ImageWidget):
+ return child
+ return None
+
+
+def run_screen_test(image_path, wrap_attrmap, label):
+ print("== screen state machine (%s) ==" % label)
+ _termlib.kitty_supported = True
+ _termlib.cell_size = CELL_SIZE
+ _imagestore.image_store.reset()
+
+ pile = build_pile(image_path, wrap_attrmap=wrap_attrmap)
+ scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
+ screen = RecordingScreen()
+
+ img_widget = find_image_widget(pile)
+ check(img_widget is not None, "image widget found in pile")
+ z = img_widget._ti_z
+ entry = _imagestore.image_store.entry_for(img_widget._ti_key)
+ check(entry is not None and entry["refs"] == 1, "widget registered with image store")
+ image_id = entry["image_id"]
+ b64 = base64.b64encode(img_widget._ti_data.data).decode("ascii")
+
+ def entries_at(offset):
+ scrollable.set_scrollpos(offset)
+ canvas = scrollable.render(VIEWPORT)
+ return screen._ti_scan(canvas)
+
+ # Build a timeline of image visibility across all scroll offsets.
+ # state = (trim_top, row, visible_rows)
+ timeline = []
+ max_offset = 200
+ for off in range(max_offset + 1):
+ entries = entries_at(off)
+ if not entries:
+ timeline.append((off, None))
+ continue
+ widget, row, col, canvas_cols, trim_top, vis_rows = entries[0]
+ timeline.append((off, (trim_top, row, vis_rows)))
+ image_rows = img_widget._ti_rows
+ visible = [t for t in timeline if t[1] is not None]
+ check(len(visible) > 0, "image is visible at some scroll offset")
+ if not visible:
+ return
+
+ def first_offset(pred):
+ for off, state in timeline:
+ if state is not None and pred(state):
+ return off
+ return None
+
+ off_enter = first_offset(lambda s: s[0] == 0) # trim_top == 0
+ off_clip = first_offset(lambda s: s[0] > 0) # top-clipped
+ check(off_enter is not None, "found offset with image fully entered")
+ check(off_clip is not None, "found offset with image top-clipped")
+ if off_enter is None or off_clip is None:
+ return
+ # State at the entry offset: (trim_top, row, visible_rows)
+ off_enter_vis_rows = timeline[off_enter][1][2]
+
+ last_visible_off = visible[-1][0]
+ off_away = last_visible_off + 1 if last_visible_off < max_offset else None
+ check(off_away is not None, "found offset past the image")
+
+ def update(offset):
+ screen.log = []
+ scrollable.set_scrollpos(offset)
+ canvas = scrollable.render(VIEWPORT)
+ screen._ti_update(canvas)
+ return list(screen.log)
+
+ # 1. before the image is visible: nothing
+ log = update(0)
+ check(not any("a=t" in l or "a=p" in l for l in log),
+ "nothing written while out of view")
+ check(not screen._ti_images, "nothing tracked while out of view")
+
+ # 2. image entered (bottom-clipped): transmit once, place the visible
+ # slice (y=0 crop) at the bottom.
+ log = update(off_enter)
+ trans = extract_transmit(log)
+ placements = extract_placements(log)
+ check(trans is not None, "image data transmitted on entry")
+ if trans:
+ trans_id, pb64 = trans
+ check(trans_id == image_id, "transmit uses the store's image id")
+ check(pb64 == b64, "transmitted payload is the original file")
+ check(len(placements) == 1, "one placement on entry, got %d" % len(placements))
+ if placements:
+ ctrl = placements[0]
+ check("i=%d" % image_id in ctrl and "p=%d" % z in ctrl,
+ "placement references image id and placement id")
+ check("c=80" in ctrl and "r=%d" % off_enter_vis_rows in ctrl,
+ "placement has display size")
+ check("y=0," in ctrl and "h=" in ctrl, "entry slice uses a y=0 crop")
+ check("z=%d" % z in ctrl and "C=1" in ctrl and "q=1" in ctrl,
+ "placement has z-index, C=1 and quiet mode")
+ check(len(screen._ti_images) == 1 and
+ screen._ti_images[id(img_widget)]["drawn"] is not None,
+ "image tracked as drawn")
+ check(entry["transmitted"], "store entry marked transmitted")
+
+ # 3. move while visible: in-place placement update, no re-transmit,
+ # no delete.
+ log = update(off_enter + 1)
+ check(extract_transmit(log) is None, "position change does not re-transmit")
+ check(len(extract_placements(log)) == 1, "position change re-places in place")
+ check(not extract_deletes(log), "position change does not delete")
+
+ # 4. top-clipped: placement with a y>0 crop.
+ log = update(off_clip)
+ placements = extract_placements(log)
+ check(len(placements) == 1, "top-clip places a slice, got %d" % len(placements))
+ if placements:
+ ctrl = placements[0]
+ y_px = int(ctrl.split("y=")[1].split(",")[0].split(";")[0])
+ check(",y=" in ctrl and "h=" in ctrl and y_px > 0,
+ "top-clipped placement has y>0 crop (y=%d)" % y_px)
+
+ # 5. scrolled past: placement deleted, image data kept.
+ log = update(off_away)
+ deletes = extract_deletes(log)
+ check(deletes == ["a=d,d=i,i=%d,p=%d" % (image_id, z)],
+ "scroll-away deletes the placement only: %s" % deletes)
+ check(extract_transmit(log) is None, "scroll-away does not transmit")
+ check(id(img_widget) not in screen._ti_images, "widget untracked after scroll-away")
+
+ # 6. scroll back: re-placed from the kept data, no re-transmission.
+ log = update(off_enter)
+ check(extract_transmit(log) is None, "scroll-back does not re-transmit")
+ check(len(extract_placements(log)) == 1, "scroll-back re-places the image")
+
+ # 7. still visible, unchanged position: nothing written.
+ log = update(off_enter)
+ check(not extract_placements(log) and not extract_deletes(log),
+ "unchanged position writes nothing")
+
+ # 8. widget closed -> orphaned data evicted from the terminal.
+ img_widget.close()
+ log = update(off_away)
+ deletes = extract_deletes(log)
+ check("a=d,d=I,i=%d" % image_id in deletes,
+ "orphaned image data evicted with d=I")
+ check(_imagestore.image_store.entry_for(img_widget._ti_key) is None,
+ "evicted entry dropped from the store")
+
+
+def test_eviction_and_purge(image_path):
+ print("== eviction and purge ==")
+ _termlib.kitty_supported = True
+ _termlib.cell_size = CELL_SIZE
+ _imagestore.image_store.reset()
+
+ # Shared source: two widgets with the same file share one store entry.
+ w1 = ImageWidget(image_path)
+ w2 = ImageWidget(image_path)
+ entry = _imagestore.image_store.entry_for(w1._ti_key)
+ check(entry["refs"] == 2, "identical sources share one registry entry (refs=%d)"
+ % entry["refs"])
+ check(w2._ti_key == w1._ti_key, "identical sources have the same content key")
+
+ w1.close()
+ check(entry["refs"] == 1, "closing one widget decrements refcount")
+ check(entry["transmitted"] is False, "no data transmitted yet")
+
+ # Place w2 via a screen update and verify orphan eviction after close.
+ pile = urwid.Pile([urwid.Text("x"), urwid.Divider(" "), w2,
+ urwid.Divider(" "), urwid.Text("y")])
+ scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
+ screen = RecordingScreen()
+ image_id = entry["image_id"]
+
+ def update(offset):
+ screen.log = []
+ scrollable.set_scrollpos(offset)
+ screen._ti_update(scrollable.render(VIEWPORT))
+ return list(screen.log)
+
+ log = update(2)
+ check(extract_transmit(log) is not None, "image transmitted when widget visible")
+ check(entry["transmitted"], "entry marked transmitted")
+
+ w2.close()
+ log = update(0)
+ deletes = extract_deletes(log)
+ check("a=d,d=I,i=%d" % image_id in deletes, "last reference closed -> d=I eviction")
+ check(_imagestore.image_store.entry_for(w2._ti_key) is None,
+ "entry dropped after eviction")
+
+ # Purge: after transmitting, purge_images() deletes everything and the
+ # next redraw re-transmits for still-visible widgets.
+ _imagestore.image_store.reset()
+ w3 = ImageWidget(image_path)
+ pile = urwid.Pile([urwid.Text("x"), urwid.Divider(" "), w3,
+ urwid.Divider(" "), urwid.Text("y")])
+ scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
+ screen2 = RecordingScreen()
+
+ def update2(offset):
+ screen2.log = []
+ scrollable.set_scrollpos(offset)
+ screen2._ti_update(scrollable.render(VIEWPORT))
+ return list(screen2.log)
+
+ log = update2(2)
+ check(extract_transmit(log) is not None, "purge-test: image transmitted")
+ screen2.log = []
+ screen2.purge_images()
+ check(any("a=d,d=A" in l for l in screen2.log), "purge deletes all (d=A)")
+ check(_imagestore.image_store.transmitted() == [], "purge marks all untransmitted")
+
+ log = update2(2)
+ check(extract_transmit(log) is not None, "visible image re-transmitted after purge")
+ check(len(extract_placements(log)) == 1, "visible image re-placed after purge")
+ w3.close()
+
+
+def main():
+ image_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_IMAGE
+ print("Using image: %s" % image_path)
+ _termlib.reset_caches()
+ print("ambient cell size (env): %s" % (_termlib._query_cell_size(),))
+
+ test_header_parse(image_path)
+ test_transmission_encoding(image_path)
+ test_webp_conversion()
+ test_winsize_parsing()
+ test_widget_layout(image_path)
+ run_screen_test(image_path, wrap_attrmap=False, label="bare widget")
+ run_screen_test(image_path, wrap_attrmap=True, label="attrmap-wrapped")
+ test_eviction_and_purge(image_path)
+
+ print("\n%d passed, %d failed" % (PASS, FAIL))
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/nomadnet/ui/textui/images/_termlib.py b/nomadnet/ui/textui/images/_termlib.py
new file mode 100644
index 0000000..a974e84
--- /dev/null
+++ b/nomadnet/ui/textui/images/_termlib.py
@@ -0,0 +1,199 @@
+# Terminal queries for capability detection and layout metrics.
+#
+# All values are resolved once and cached; subsequent calls are instant.
+# Queries should be performed *before* starting the urwid main loop, because
+# they read directly from the terminal and would otherwise race with urwid's
+# own input handling. If no terminal is available, or the queries time out,
+# sensible fallbacks are used.
+
+import os
+import select
+import shutil
+import sys
+import time
+
+try: import termios
+except: termios = None
+
+from . import _ctlseqs
+
+QUERY_TIMEOUT = 0.3
+
+kitty_supported = None
+cell_size = None
+
+# Last raw terminal query response (bytes), for diagnostics.
+last_query_response = bytearray()
+last_kitty_response = bytearray()
+
+# Last TIOCGWINSZ buffer (rows, cols, xpixel, ypixel), for diagnostics.
+winsize = [0, 0, 0, 0]
+
+# Number of times a query attempt has been made; used to avoid repeated
+# slow queries in a broken environment.
+_query_failed = False
+
+def _open_tty():
+ # Opens the active terminal for reading and writing.
+ for stream in ("out", "in", "err"):
+ try: return os.open(os.ttyname(getattr(sys, "__std%s__" % stream).fileno()), os.O_RDWR)
+ except (OSError, AttributeError): continue
+ try: return os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
+ except OSError: return -1
+
+
+def query_tty(request, timeout=QUERY_TIMEOUT):
+ # Sends a query to the terminal and returns the raw response.
+ global _query_failed
+ if _query_failed or not termios: return b""
+
+ fd = _open_tty()
+ if fd == -1:
+ _query_failed = True
+ return b""
+
+ # Never block indefinitely, even on a broken pseudo-terminal,
+ # select has a deadline and reads are non-blocking.
+ try: os.set_blocking(fd, False)
+ except OSError: pass
+
+ old_attr = termios.tcgetattr(fd)
+ new_attr = termios.tcgetattr(fd)
+ # Raw-ish mode; disable canonical mode and echo. In canonical mode the
+ # terminal buffers input until a newline, which terminal query responses
+ # (for example. kitty's "i=31;OK") never contain. The read would block until
+ # timeout and the query would always "fail".
+ new_attr[3] &= ~(termios.ECHO | termios.ICANON)
+ new_attr[6][termios.VMIN] = 0
+ new_attr[6][termios.VTIME] = 0
+ try:
+ # TCSANOW (not TCSAFLUSH): TCSAFLUSH drains the output queue first,
+ # which can block indefinitely on some pseudo-terminals.
+ termios.tcsetattr(fd, termios.TCSANOW, new_attr)
+ try: os.write(fd, request)
+ except (BlockingIOError, OSError): return b""
+ response = bytearray()
+ deadline = time.monotonic() + timeout
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0: break
+ try: ready, _, _ = select.select([fd], [], [], remaining)
+ except (OSError, ValueError): break
+ if not ready: break
+ try: chunk = os.read(fd, 4096)
+ except (BlockingIOError, OSError): continue
+ if not chunk: break
+ response += chunk
+ if response.endswith(b"\x1b\\") or response.endswith(b"c"): break
+ last_query_response[:] = response
+ return bytes(response)
+
+ finally:
+ try: termios.tcsetattr(fd, termios.TCSANOW, old_attr)
+ except termios.error: pass
+ finally: os.close(fd)
+
+def is_kitty_supported():
+ global kitty_supported
+ if kitty_supported is None:
+ response = query_tty(_ctlseqs.KITTY_QUERY)
+ last_kitty_response[:] = response
+ kitty_supported = bool(response and _ctlseqs.KITTY_QUERY_RESPONSE_RE.search(response))
+ return kitty_supported
+
+def force_kitty_supported(status):
+ # Forces the support status (used by tests).
+ global kitty_supported
+ kitty_supported = bool(status)
+
+def _cell_size_from_winsize(buf):
+ # Interprets a TIOCGWINSZ buffer as (cell_width_px, cell_height_px).
+ # The struct layout is (rows, cols, xpixel, ypixel). Returns None if the
+ # terminal does not report pixel dimensions, or if the result is
+ # implausible (e.g. several times wider than tall - a sign of broken
+ # winsize reporting).
+ rows, cols, xpix, ypix = buf
+ if not (cols and rows and xpix and ypix): return None
+ cell_w, cell_h = max(1, xpix // cols), max(1, ypix // rows)
+ if not (0.1 <= cell_w / cell_h <= 1.5):
+ # Real terminal cells should be as wide as, or narrower than,
+ # they are tall; anything far outside that range is bogus.
+ return None
+ return (cell_w, cell_h)
+
+def _query_cell_size():
+ # Determines the current cell size in pixels, or None if unknown.
+ if not termios: return None
+ fd = _open_tty()
+ if fd != -1:
+ # First, try the TIOCGWINSZ pixel fields (fast, no query)
+ import array
+ import fcntl
+ try:
+ buf = array.array("H", [0, 0, 0, 0])
+ fcntl.ioctl(fd, termios.TIOCGWINSZ, buf)
+ winsize[:] = buf
+ cell = _cell_size_from_winsize(buf)
+ if cell is not None: return cell
+
+ except OSError: pass
+ finally: os.close(fd)
+
+ # Fall back to XTWINOPS 16 (cell size in pixels); DA1 is appended to
+ # speed up the response (terminals treat queries as FIFO).
+ response = query_tty(_ctlseqs.CELL_SIZE_PX + _ctlseqs.DA1)
+ if response:
+ match = _ctlseqs.CELL_SIZE_RESPONSE_RE.search(response)
+ if match: return (int(match.group("w")), int(match.group("h")))
+ return None
+
+def get_cell_size():
+ # Returns the terminal cell size in pixels as (width, height), or None
+ # if it could not be determined.
+ global cell_size
+ if cell_size is None: cell_size = _query_cell_size()
+ return cell_size
+
+
+def get_terminal_size():
+ # Returns the active terminal size as (columns, lines).
+ # Uses the tty directly when available (correct even with redirected
+ # output), falling back to shutil.get_terminal_size.
+
+ fd = _open_tty()
+ if fd != -1:
+ try:
+ size = os.get_terminal_size(fd)
+ return (size.columns, size.lines)
+ except OSError: pass
+ finally: os.close(fd)
+ size = shutil.get_terminal_size(fallback=(80, 24))
+ return (size.columns, size.lines)
+
+def reset_caches():
+ # Clears all cached capability/metric values (used by tests).
+ global kitty_supported, cell_size, _query_failed
+ kitty_supported = None
+ cell_size = None
+ _query_failed = False
+ last_query_response[:] = b""
+ last_kitty_response[:] = b""
+ winsize[:] = (0, 0, 0, 0)
+
+def diagnostics():
+ # Returns a list of diagnostic strings about the terminal environment,
+ # for display/debugging.
+ import os
+ lines = [ "TERM=%r" % os.environ.get("TERM"),
+ "TERM_PROGRAM=%r TERM_PROGRAM_VERSION=%r" % (os.environ.get("TERM_PROGRAM"), os.environ.get("TERM_PROGRAM_VERSION")),
+ "kitty graphics protocol supported: %s" % is_kitty_supported(),
+ "kitty query response: %r" % bytes(last_kitty_response),
+ "cell size (px): %s" % (get_cell_size(),),
+ "cell size query response: %r" % bytes(last_query_response),
+ "winsize (rows, cols, xpix, ypix): %r" % (tuple(winsize),) ]
+ try:
+ from . import _webp
+ lines.append("webp conversion: %s" % _webp.describe())
+ except Exception: pass
+
+ return lines
diff --git a/nomadnet/ui/textui/images/_webp.py b/nomadnet/ui/textui/images/_webp.py
new file mode 100644
index 0000000..9238216
--- /dev/null
+++ b/nomadnet/ui/textui/images/_webp.py
@@ -0,0 +1,367 @@
+# Multi-backend WebP decoding
+#
+# Kitty's graphics protocol decodes PNG natively but not WebP, so WebP
+# sources are converted transparently at the ImageData layer. This module
+# provides a conversion chain that *should* work almost anywhere with
+# zero extra dependencies:
+#
+# - Pillow/PIL, if installed with WebP support
+# - libwebp via ctypes, decoding to RGBA, paired with a pure-Python PNG
+# encoder. The system libwebp is present on most Linux/macOS systems
+# as a transitive dependency of common graphics stacks and browsers
+# - sips - the macOS built-in image tool
+# - dwebp - from the system PATH (the "official" WebP tool)
+# - ImageMagick / GraphicsMagick (magick, convert, gm convert)
+# - ffmpeg / avconv
+# - gdk-pixbuf-thumbnailer (GTK/GNOME systems)
+#
+# Every backend's output is validated (it must be a valid PNG with the same
+# dimensions as the source WebP), so missing tools, or tools built without
+# WebP support, are simply skipped. The first backend that produces a valid
+# conversion is remembered and preferred for subsequent conversions, but the
+# preference is replaced whenever a different backend wins for a particular
+# image.
+#
+# Environment overrides:
+#
+# NOMADNET_IMAGE_BACKEND force a specific backend (strict)
+
+import ctypes
+import ctypes.util
+import hashlib
+import io
+import os
+import shutil
+import struct
+import subprocess
+import tempfile
+import zlib
+
+MAX_CACHE_BYTES = 192 * 1024 * 1024
+MAX_CACHE_FILES = 512
+CONVERSION_TIMEOUT = 5
+
+# libwebp soname candidates, in order of preference.
+_LIBWEBP_NAMES = [ "libwebp.so.7",
+ "libwebp.so.6",
+ "libwebp.so.5",
+ "libwebp.so",
+ "libwebp.dylib",
+ "libwebp-7.dll",
+ "libwebp.dll" ]
+
+# The built-in backend chain, in preference order.
+BACKENDS = ( "pil",
+ "libwebp-ctypes",
+ "sips",
+ "dwebp",
+ "magick",
+ "convert",
+ "gm",
+ "ffmpeg",
+ "avconv",
+ "gdk-pixbuf" )
+
+_ENV_BACKEND = os.environ.get("NOMADNET_IMAGE_BACKEND")
+_winner = None # name of the currently preferred backend
+_last_backend = None # backend used for the most recent conversion
+_convert_count = 0
+_cache_hits = 0
+
+_libwebp_cache = {"lib": None, "probed": False}
+
+###############################
+# PNG validation and encoding #
+###############################
+
+# Returns (width, height) if png is a valid PNG, else None
+def _png_dims(png):
+ if not png.startswith(b"\x89PNG\r\n\x1a\n") or len(png) < 24:
+ return None
+ if png[12:16] != b"IHDR":
+ return None
+ width = int.from_bytes(png[16:20], "big")
+ height = int.from_bytes(png[20:24], "big")
+ if not (width > 0 and height > 0):
+ return None
+ return (width, height)
+
+
+# Encodes RGBA pixel data as a PNG (8-bit RGBA, no filtering)
+def _encode_png_rgba(rgba, width, height):
+ def chunk(tag, payload):
+ return (struct.pack(">I", len(payload)) + tag + payload +
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
+
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
+ stride = width * 4
+ raw = bytearray(height * (stride + 1))
+ pos = 0
+ for row in range(height):
+ raw[pos] = 0 # filter type: none
+ pos += 1
+ start = row * stride
+ raw[pos:pos + stride] = rgba[start:start + stride]
+ pos += stride
+
+ return (b"\x89PNG\r\n\x1a\n" +
+ chunk(b"IHDR", ihdr) +
+ chunk(b"IDAT", zlib.compress(bytes(raw), 9)) +
+ chunk(b"IEND", b""))
+
+
+####################
+# Conversion cache #
+####################
+
+CONV_CACHE_BASE = None
+def cache_dir():
+ global CONV_CACHE_BASE
+ if not CONV_CACHE_BASE:
+ from nomadnet.NomadNetworkApp import NomadNetworkApp
+ CONV_CACHE_BASE = NomadNetworkApp._shared_instance.dispcachepath
+ return CONV_CACHE_BASE
+
+def _cache_path(source_data):
+ return os.path.join(cache_dir(), hashlib.sha256(source_data).hexdigest() + ".png")
+
+def _cache_get(source_data):
+ try:
+ with open(_cache_path(source_data), "rb") as f: png = f.read()
+ if _png_dims(png) is not None: return png
+ except OSError: pass
+ return None
+
+def _cache_put(source_data, png):
+ try:
+ directory = cache_dir()
+ os.makedirs(directory, exist_ok=True)
+ fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
+ try:
+ with os.fdopen(fd, "wb") as f: f.write(png)
+ os.replace(tmp_path, _cache_path(source_data))
+ except OSError:
+ try: os.unlink(tmp_path)
+ except OSError: pass
+ _prune_cache()
+ except OSError: pass
+
+# Evicts least-recently-used entries beyond the size/file limits
+def _prune_cache():
+ try:
+ entries = []
+ for name in os.listdir(cache_dir()):
+ path = os.path.join(cache_dir(), name)
+ try:
+ st = os.stat(path)
+ if st.st_size > 0:
+ entries.append((st.st_mtime, st.st_size, path))
+ except OSError: pass
+
+ total = sum(e[1] for e in entries)
+ entries.sort()
+ while len(entries) > MAX_CACHE_FILES or total > MAX_CACHE_BYTES:
+ _, size, path = entries.pop(0)
+ total -= size
+ try: os.unlink(path)
+ except OSError: pass
+ except OSError: pass
+
+#######################
+# Individual backends #
+#######################
+
+# Runs a conversion CLI over temp files
+def _run_cli(argv, data):
+ if shutil.which(argv[0]) is None: return None
+ try:
+ with tempfile.TemporaryDirectory(prefix="nomadnet-img-") as td:
+ src = os.path.join(td, "source.webp")
+ dst = os.path.join(td, "output.png")
+ with open(src, "wb") as f: f.write(data)
+ args = [a.replace("{src}", src).replace("{dst}", dst) for a in argv]
+ subprocess.run(
+ args,
+ timeout=CONVERSION_TIMEOUT,
+ # stdin must be closed: some tools (e.g. ffmpeg) block
+ # on inherited stdin otherwise.
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ with open(dst, "rb") as f: return f.read()
+ except (OSError, subprocess.TimeoutExpired, ValueError): return None
+
+def _backend_pil(data, dims):
+ try:
+ from PIL import Image
+ from PIL import features
+ if not features.check("webp"): return None
+ except ImportError: return None
+ try:
+ image = Image.open(io.BytesIO(data))
+ image.load()
+ buf = io.BytesIO()
+ image.save(buf, format="PNG")
+ return buf.getvalue()
+ except Exception: return None
+
+# Loads the system libwebp via ctypes, caching the result.
+def _get_libwebp():
+ if _libwebp_cache["probed"]: return _libwebp_cache["lib"]
+ _libwebp_cache["probed"] = True
+ lib = None
+ names = list(_LIBWEBP_NAMES)
+ found = ctypes.util.find_library("webp")
+ if found and found not in names: names.insert(0, found)
+ for name in names:
+ try:
+ lib = ctypes.CDLL(name)
+ break
+ except OSError: continue
+
+ if lib is not None:
+ lib.WebPGetInfo.restype = ctypes.c_int
+ lib.WebPGetInfo.argtypes = [ ctypes.c_char_p, ctypes.c_size_t,
+ ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int) ]
+
+ lib.WebPDecodeRGBA.restype = ctypes.c_void_p
+ lib.WebPDecodeRGBA.argtypes = [ ctypes.c_char_p, ctypes.c_size_t,
+ ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int) ]
+ lib.WebPFree.restype = None
+ lib.WebPFree.argtypes = [ctypes.c_void_p]
+
+ _libwebp_cache["lib"] = lib
+ return lib
+
+def _backend_ctypes(data, dims):
+ lib = _get_libwebp()
+ if lib is None: return None
+ try:
+ buf = ctypes.create_string_buffer(data, len(data))
+ width = ctypes.c_int(0)
+ height = ctypes.c_int(0)
+ if not lib.WebPGetInfo(buf, len(data), ctypes.byref(width), ctypes.byref(height)): return None
+ if width.value <= 0 or height.value <= 0: return None
+ ptr = lib.WebPDecodeRGBA(buf, len(data), ctypes.byref(width), ctypes.byref(height))
+ if not ptr: return None
+ try: rgba = ctypes.string_at(ptr, width.value * height.value * 4)
+ finally: lib.WebPFree(ptr)
+ return _encode_png_rgba(rgba, width.value, height.value)
+ except Exception: return None
+
+def _backend_gdkpixbuf(data, dims):
+ if dims is None: return None
+ width, height = dims
+ return _run_cli(["gdk-pixbuf-thumbnailer", "-s", "%dx%d" % (width, height), "{src}", "{dst}"], data)
+
+def _backend_sips(data, dims): return _run_cli(["sips", "-s", "format", "png", "{src}", "--out", "{dst}"], data)
+def _backend_dwebp(data, dims): return _run_cli(["dwebp", "{src}", "-o", "{dst}"], data)
+def _backend_magick(data, dims): return _run_cli(["magick", "{src}", "{dst}"], data)
+def _backend_convert(data, dims): return _run_cli(["convert", "{src}", "{dst}"], data)
+def _backend_gm(data, dims): return _run_cli(["gm", "convert", "{src}", "{dst}"], data)
+def _backend_ffmpeg(data, dims): return _run_cli(["ffmpeg", "-y", "-loglevel", "error", "-i", "{src}", "-frames:v", "1", "{dst}"], data)
+def _backend_avconv(data, dims): return _run_cli(["avconv", "-y", "-loglevel", "error", "-i", "{src}", "-frames:v", "1", "{dst}"], data)
+
+_BACKENDS = { "pil": _backend_pil,
+ "libwebp-ctypes": _backend_ctypes,
+ "sips": _backend_sips,
+ "dwebp": _backend_dwebp,
+ "magick": _backend_magick,
+ "convert": _backend_convert,
+ "gm": _backend_gm,
+ "ffmpeg": _backend_ffmpeg,
+ "avconv": _backend_avconv,
+ "gdk-pixbuf": _backend_gdkpixbuf }
+
+#####################
+# Conversion driver #
+#####################
+
+def _webp_dims(data):
+ from ._imagedata import parse_image_header
+ try: fmt, width, height = parse_image_header(data)
+ except ValueError: return None
+ if fmt != "WebP": return None
+ return (width, height)
+
+def _backend_order():
+ if _ENV_BACKEND: return [_ENV_BACKEND] if _ENV_BACKEND in BACKENDS else []
+ order = list(BACKENDS)
+ if _winner in order:
+ order.remove(_winner)
+ order.insert(0, _winner)
+ return order
+
+# Converts WebP data to PNG bytes, or None on failure
+def convert_webp_to_png(data):
+ global _cache_hits
+ png = _cache_get(data)
+ if png is not None:
+ _cache_hits += 1
+ return png
+ return _convert_uncached(data)
+
+def _convert_uncached(data):
+ global _convert_count, _winner, _last_backend
+ dims = _webp_dims(data)
+ if dims is None: return None
+ for name in _backend_order():
+ try: png = _BACKENDS[name](data, dims)
+ except Exception: png = None
+ if png is not None and _png_dims(png) == dims:
+ _convert_count += 1
+ _last_backend = name
+ if not _ENV_BACKEND: _winner = name
+ _cache_put(data, png)
+ return png
+
+ return None
+
+##################################
+# Introspection and test support #
+##################################
+
+# Returns [(name, available), ...] for the built-in chain
+def available_backends():
+ out = []
+ for name in BACKENDS:
+ if name == "pil":
+ try:
+ from PIL import features
+ out.append((name, bool(features.check("webp"))))
+ except Exception: out.append((name, False))
+ elif name == "libwebp-ctypes": out.append((name, _get_libwebp() is not None))
+ elif name == "gdk-pixbuf": out.append((name, shutil.which("gdk-pixbuf-thumbnailer") is not None))
+ else: out.append((name, shutil.which(name) is not None))
+
+ return out
+
+# A diagnostic string describing the conversion setup
+def describe():
+ if _ENV_BACKEND: return "backend forced via NOMADNET_IMAGE_BACKEND: %s" % _ENV_BACKEND
+ present = [name for name, avail in available_backends() if avail]
+ if not present: return "no conversion backend available (WebP images will not render)"
+ return "backend(s) available: %s; preferred: %s" % (", ".join(present), _last_backend or _winner or "none yet")
+
+# Usage statistics for diagnostics
+def stats():
+ return { "backend": _last_backend, "conversions": _convert_count,
+ "cache_hits": _cache_hits, "cache_dir": cache_dir() }
+
+def last_backend(): return _last_backend
+
+# Forces a specific backend name (testing/debugging); None to clear.
+def force_backend(name):
+ global _ENV_BACKEND
+ _ENV_BACKEND = name if name in BACKENDS else None
+
+# Clears dynamic state (winner, counters); used by tests.
+def reset():
+ global _winner, _last_backend, _convert_count, _cache_hits
+ _winner = None
+ _last_backend = None
+ _convert_count = 0
+ _cache_hits = 0
+ _libwebp_cache["lib"] = None
+ _libwebp_cache["probed"] = False
diff --git a/nomadnet/ui/textui/images/demo.webp b/nomadnet/ui/textui/images/demo.webp
new file mode 100644
index 0000000..82b04ab
Binary files /dev/null and b/nomadnet/ui/textui/images/demo.webp differ
diff --git a/nomadnet/ui/textui/images/screen.py b/nomadnet/ui/textui/images/screen.py
new file mode 100644
index 0000000..c7a9f59
--- /dev/null
+++ b/nomadnet/ui/textui/images/screen.py
@@ -0,0 +1,210 @@
+import urwid
+
+from . import _ctlseqs
+from ._imagestore import image_store
+from ._termlib import is_kitty_supported
+from .widget import ImageWidget
+
+# A raw display screen subclass with Kitty TGP side-channel support
+class ImageScreen(urwid.raw_display.Screen):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._ti_images = {}
+
+ ##########
+ # Output #
+ ##########
+
+ def write(self, data):
+ if isinstance(data, bytes): data = data.decode("ascii")
+ return super().write(data)
+
+ #############
+ # Lifecycle #
+ #############
+
+ def clear(self):
+ # Clears all images and forces a full repaint
+ if is_kitty_supported():
+ self.write(_ctlseqs.KITTY_DELETE_ALL)
+ self._ti_images = {}
+ image_store.mark_all_untransmitted()
+ return super().clear()
+
+ def _start(self, *args, **kwargs):
+ ret = super()._start(*args, **kwargs)
+ if is_kitty_supported():
+ # Clear any images left over from a previous run/instance.
+ self.write(_ctlseqs.KITTY_DELETE_ALL)
+ self._ti_images = {}
+ image_store.mark_all_untransmitted()
+ return ret
+
+ def _stop(self):
+ self.purge_images()
+ try: return super()._stop()
+ finally: self._ti_images = {}
+
+ def purge_images(self):
+ # Purges all image data from the terminal buffer.
+ if not is_kitty_supported(): return
+ if self._ti_images or image_store.transmitted():
+ self.write(_ctlseqs.KITTY_DELETE_ALL)
+ self._ti_images = {}
+ image_store.mark_all_untransmitted()
+
+ ###########
+ # Drawing #
+ ###########
+
+ def draw_screen(self, size, canvas):
+ # Draws the canvas, then updates on-screen images within a
+ # synchronized-update region to prevent flicker.
+ self.write(_ctlseqs.BEGIN_SYNCED_UPDATE)
+ try:
+ super().draw_screen(size, canvas)
+ self._ti_update(canvas)
+ finally:
+ self.write(_ctlseqs.END_SYNCED_UPDATE)
+ self.flush()
+
+ def _ti_update(self, canvas):
+ # Places/updates/deletes image placements to match the current
+ # canvas, and evicts orphaned image data.
+ if not is_kitty_supported(): return
+
+ entries = self._ti_scan(canvas)
+ current = {}
+ for widget, row, col, canvas_cols, trim_top, vis_rows in entries:
+ current[id(widget)] = (widget, row, col, canvas_cols, trim_top, vis_rows)
+
+ # For widgets that scrolled completely out of view, remove their
+ # placement only. The image data is kept in the terminal so that
+ # scrolling back just re-places it.
+ for wid, info in list(self._ti_images.items()):
+ if wid in current: continue
+ self._ti_unplace(info["widget"])
+ del self._ti_images[wid]
+
+ # For visible widgets, place/update the visible slice in place.
+ for wid, (widget, row, col, canvas_cols, trim_top, vis_rows) in current.items():
+ info = self._ti_images.setdefault(wid, {"widget": widget, "drawn": None})
+ if vis_rows <= 0 or trim_top >= widget._ti_rows:
+ if info["drawn"] is not None:
+ self._ti_unplace(widget)
+ info["drawn"] = None
+ continue
+
+ key = self._placement_key(widget, row, col, canvas_cols, trim_top, vis_rows)
+ if key is None:
+ if info["drawn"] is not None:
+ self._ti_unplace(widget)
+ info["drawn"] = None
+ continue
+
+ if info["drawn"] == key: continue
+ self._ti_place(widget, key)
+ info["drawn"] = key
+
+ # Evict image data that no live widget references any more
+ for key, image_id in image_store.evict_orphans():
+ self.write(_ctlseqs.KITTY_DELETE_IMAGE % image_id)
+ image_store.evict(key)
+
+ #############
+ # Internals #
+ #############
+
+ def _ti_scan(self, canvas):
+ # Walks the canvas shards and returns a list of
+ # (widget, row, col, canvas_cols, trim_top, visible_rows)
+ # for every visible image canvas.
+ found = []
+ if not isinstance(canvas, urwid.CompositeCanvas): return found
+
+ def process_shard_tails():
+ nonlocal col
+ while col in shard_tails:
+ *trim, cols, rows, canv = shard_tails[col]
+ if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv)
+ else: del shard_tails[col]
+ col += cols
+
+ shard_tails = {}
+ row = 0
+ for n_rows, cviews in canvas.shards:
+ col = 0
+ for cview in cviews:
+ process_shard_tails()
+ *trim, cols, rows, _, canv = cview
+ widget = self._ti_widget_of(canv)
+ if widget is not None: found.append((widget, row, col, cols, trim[1], rows))
+ if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv)
+ col += cols
+
+ process_shard_tails()
+ row += n_rows
+
+ return found
+
+ @staticmethod
+ def _ti_widget_of(canv):
+ # Returns the ImageWidget for a leaf canvas, or None
+ widget_info = getattr(canv, "_widget_info", None)
+ if not widget_info: return None
+ widget = widget_info[0]
+ if isinstance(widget, ImageWidget) and widget._ti_data.ok and not widget._ti_closed: return widget
+ return None
+
+ def _placement_key(self, widget, row, col, canvas_cols, trim_top, vis_rows):
+ # Compute placement parameters for the visible slice of
+ # an image, including the alignment offset.
+
+ cols = widget._ti_cols
+ rows = widget._ti_rows
+ if not (cols and rows): return None
+ place_col = col
+ if cols < canvas_cols:
+ align = widget._ti_align
+ spare = canvas_cols - cols
+ if align == ">": place_col += spare
+ elif align == "|": place_col += spare // 2
+
+ vis_rows = max(1, min(vis_rows, rows))
+ y_px = h_px = None
+ if not (trim_top == 0 and vis_rows == rows):
+ # Crop to the visible slice of the image, in image pixels
+ ih = widget._ti_data.height
+ y_px = min(ih - 1, round(trim_top * ih / rows))
+ h_px = max(1, min(ih - y_px, round(vis_rows * ih / rows)))
+
+ return (row, place_col, cols, vis_rows, y_px, h_px)
+
+ def _ti_place(self, widget, key):
+ # Place (or in-place update) the visible slice of an image
+ row, place_col, cols, vis_rows, y_px, h_px = key
+ entry = image_store.entry_for(widget._ti_key)
+ if entry is None:
+ # This should not happen, but if the image data was evicted
+ # while the image was still alive, re-register it so the
+ # data can be re-transmitted to the terminal buffer.
+ image_store.register(widget._ti_key, widget._ti_data.data)
+ entry = image_store.entry_for(widget._ti_key)
+ image_id = entry["image_id"]
+
+ if not entry["transmitted"]:
+ payload = widget._ti_data.data
+ b64_len = (len(payload) * 4 + 2) // 3
+ n_chunks = max(1, (b64_len + _ctlseqs.CHUNK_SIZE - 1) // _ctlseqs.CHUNK_SIZE)
+ self.write(_ctlseqs.kitty_transmission(_ctlseqs.kitty_transmit_control(image_id), payload))
+ entry["transmitted"] = True
+
+ self.write(_ctlseqs.CURSOR_POSITION % (row + 1, place_col + 1))
+ self.write(_ctlseqs.kitty_placement(image_id, widget._ti_z, cols, vis_rows, widget._ti_z, y=y_px, h=h_px))
+
+ def _ti_unplace(self, widget):
+ # Delete a widget's placement, but keep the
+ # image data in the terminal buffer.
+ entry = image_store.entry_for(widget._ti_key)
+ if entry is None: return
+ self.write(_ctlseqs.KITTY_DELETE_PLACEMENT % (entry["image_id"], widget._ti_z))
diff --git a/nomadnet/ui/textui/images/widget.py b/nomadnet/ui/textui/images/widget.py
new file mode 100644
index 0000000..30dd3d0
--- /dev/null
+++ b/nomadnet/ui/textui/images/widget.py
@@ -0,0 +1,283 @@
+# An urwid widget that occupies the space an image should fill in a layout,
+# without embedding any escape sequences in its canvas. The actual terminal
+# graphics are transmitted by the ImageScreen class which tracks the widget's
+# on-screen position after every draw.
+#
+# This separation keeps the canvas trivially simple (urwid sees only blank
+# rows) and lets the screen handle the hard parts; positioning, deletion when
+# scrolled away, terminal resize and redraws - all of which are driven by
+# urwid's own shard bookkeeping rather than by fragile escape-sequence
+# manipulation inside canvas rows.
+#
+# When the Kitty graphics protocol is unavailable (or the image cannot be
+# loaded), the widget renders a simple text placeholder instead.
+
+import os
+import re
+import urwid
+
+from . import _termlib
+from ._imagestore import image_store
+
+DEFAULT_CELL_SIZE = (1, 2) # Fallback, assume a 1:2 cell aspect ratio
+MAX_Z_INDEX = 2 ** 31 - 1
+UI_MARGIN = 8
+_SIZE_SPEC_RE = re.compile(r"(\d+(?:\.\d+)?)%")
+_ALIGNS = { "left": "<", "<": "<",
+ "center": "|", "|": "|",
+ "right": ">", ">": ">" }
+
+def _check_align(value):
+ if value not in _ALIGNS: raise ValueError("invalid alignment %r (expected 'left', 'center' or 'right')" % (value,))
+ return _ALIGNS[value]
+
+
+def _check_size_spec(value, name):
+ # None = auto, int = absolute cells/rows, "NN%" = percent, "n" = native resolution
+ if value is None: return None
+
+ if isinstance(value, int):
+ if value <= 0: raise ValueError("%s must be a positive integer, got %r" % (name, value))
+ return value
+
+ if isinstance(value, str):
+ if value.lower() == "n": return "n"
+ match = _SIZE_SPEC_RE.fullmatch(value)
+ if not match or float(match.group(1)) <= 0:
+ raise ValueError("invalid %s specification %r (expected a positive integer, a percent string like '50%%' or 'n' for native resolution)" % (name, value))
+ return value
+
+ raise TypeError("invalid type for %s (got %s)" % (name, type(value).__name__))
+
+
+class ImageWidget(urwid.Widget):
+ # A widget that displays an image via the Kitty graphics protocol.
+ # The widget is a plain FLOW/BOX widget; its canvas is blank. The
+ # ImageScreen class performs the actual drawing over the Kitty
+ # TGP side-channel.
+
+ placeholder_glyph = ""
+
+ _sizing = frozenset((urwid.FLOW, urwid.BOX))
+ ignore_focus = True
+
+ # Z-index allocation. Each widget instance gets a unique z-index so that
+ # the screen can delete exactly one image when it scrolls away or moves.
+ _ti_next_z = 1
+ _ti_free_z = set()
+
+ def __init__(self, path, name=None, width=None, height=None, align="center"):
+ if path and not isinstance(path, (str, os.PathLike)): raise TypeError("path must be a string or path-like object")
+ self.path = path
+ self._notice = None
+ self._ti_path = None
+ self._ti_data = None
+ self._ti_width = _check_size_spec(width, "width")
+ self._ti_height = _check_size_spec(height, "height")
+ self._ti_align = _check_align(align)
+ self._ti_name = name if name is not None else ""
+ self._ti_z = self._ti_alloc_z()
+ self.image_id = self._ti_z
+
+ # Display size (cols, rows) from the most recent render.
+ # Used by the screen to place the image.
+ self._ti_cols = None
+ self._ti_rows = None
+ self._ti_closed = False
+ self._ti_key = None
+ super().__init__()
+ if self.path: self.load()
+
+ def load(self, path=None, remote_source=False):
+ if path: self.path = path
+ if self.path:
+ from ._imagedata import ImageData
+ self._ti_path = os.path.abspath(os.fspath(self.path))
+ self._ti_data = ImageData(self._ti_path, remote_source=remote_source)
+
+ if self._ti_data.ok:
+ # Register with the image store (de-duplicates identical data
+ # and allocates the terminal-side image id on first use).
+ self._ti_key = self._ti_data.key
+ image_store.register(self._ti_key, self._ti_data.data)
+ self._invalidate()
+ else:
+ error_msg = self._ti_data.error or "Unknown error"
+ self.notice(f"Error: {error_msg}")
+
+ def notice(self, msg=""):
+ if not msg: self._notice = None
+ else: self._notice = str(msg)
+ self._invalidate()
+
+ def __del__(self):
+ try: type(self)._ti_free_z.add(self._ti_z)
+ except Exception: pass
+ try: self.close()
+ except Exception: pass
+
+ def close(self):
+ # Releases the widget's reference to its image data.
+ # The data is evicted from the terminal by the screen as soon as no
+ # live widget references it any more. Safe to call multiple times and
+ # from __del__.
+ try:
+ if not self._ti_closed:
+ self._ti_closed = True
+ if self._ti_key is not None: image_store.unregister(self._ti_key)
+ except Exception: pass
+
+ @classmethod
+ def _ti_alloc_z(cls):
+ if cls._ti_free_z: return cls._ti_free_z.pop()
+ z = cls._ti_next_z
+ if z >= MAX_Z_INDEX: raise RuntimeError("Too many image widgets")
+ cls._ti_next_z += 1
+ return z
+
+ ##########
+ # Sizing #
+ ##########
+
+ width = property(
+ lambda self: self._ti_width,
+ doc="""Display width specification: None (full width), a positive
+ int (columns), a percent string ("NN%") of the available layout
+ width, or "n" for native resolution.""",
+ )
+
+ @width.setter
+ def width(self, value):
+ self._ti_width = _check_size_spec(value, "width")
+ self._invalidate()
+
+ height = property(
+ lambda self: self._ti_height,
+ doc="""Display height specification: None (derived, aspect
+ preserving), a positive int (rows), a percent string ("NN%") of
+ the visible page height, or "n" for native resolution.""",
+ )
+
+ @height.setter
+ def height(self, value):
+ self._ti_height = _check_size_spec(value, "height")
+ self._invalidate()
+
+ align = property(
+ lambda self: self._ti_align,
+ doc="""Horizontal alignment of the image within its allocated
+ space: ``"<"`` (left), ``"|"`` (center) or ``">"`` (right).""",
+ )
+
+ @align.setter
+ def align(self, value):
+ self._ti_align = _check_align(value)
+ self._invalidate()
+
+ def _short(self): return "img#%x" % (id(self) & 0xFFFFF)
+
+ def _display_size(self, maxcol, maxrow=None):
+ if not self._ti_data.ok: return (min(maxcol, 1), 1)
+ iw, ih = self._ti_data.size
+ cell_w, cell_h = _termlib.get_cell_size() or DEFAULT_CELL_SIZE
+ w_spec, h_spec = self._ti_width, self._ti_height
+
+ def _pct(value): return float(value[:-1]) / 100.0
+
+ def _raw_width():
+ if w_spec is None: return float(maxcol)
+ if isinstance(w_spec, int): return float(w_spec)
+ if w_spec == "n": return iw / cell_w
+ return maxcol * _pct(w_spec)
+
+ def _raw_height():
+ if h_spec is None: return None
+ if isinstance(h_spec, int): return float(h_spec)
+ if h_spec == "n": return ih / cell_h
+ columns, lines = _termlib.get_terminal_size()
+ page = max(1, lines - UI_MARGIN)
+ return page * _pct(h_spec)
+
+ def _height_for_width(cols): return cols * cell_w * ih / (cell_h * iw)
+ def _width_for_height(rows): return rows * cell_h * iw / (cell_w * ih)
+
+ if w_spec is not None and h_spec is not None:
+ # Stretch mode: Target rectangle, uniformly scaled down only if
+ # it would overflow the available space.
+ target_w = max(1.0, _raw_width())
+ target_h = max(1.0, _raw_height())
+ scale = min(1.0, maxcol / target_w)
+ if maxrow is not None: scale = min(scale, maxrow / target_h)
+ cols = max(1, round(target_w * scale))
+ rows = max(1, round(target_h * scale))
+ elif w_spec is not None:
+ # Width given: Height derived, aspect preserving.
+ cols = max(1, min(maxcol, round(_raw_width())))
+ rows = max(1, round(_height_for_width(cols)))
+ if maxrow is not None and rows > maxrow:
+ rows = maxrow
+ cols = max(1, min(maxcol, round(_width_for_height(rows))))
+ elif h_spec is not None:
+ # Height given: Width derived, aspect preserving.
+ rows = max(1, round(_raw_height()))
+ cols = max(1, min(maxcol, round(_width_for_height(rows))))
+ if maxrow is not None and rows > maxrow:
+ rows = maxrow
+ cols = max(1, min(maxcol, round(_width_for_height(rows))))
+ else:
+ # Default: Full width, height derived.
+ cols = maxcol
+ rows = max(1, round(_height_for_width(cols)))
+ if maxrow is not None and rows > maxrow:
+ rows = maxrow
+ cols = max(1, min(maxcol, round(_width_for_height(rows))))
+
+ return (cols, rows)
+
+ def rows(self, size, focus=False):
+ maxcol = size[0]
+ if not (self._ti_data and self._ti_data.ok and _termlib.is_kitty_supported()):
+ return len(self._placeholder_lines())
+ n = self._display_size(maxcol)[1]
+ return n
+
+ def render(self, size, focus=False):
+ if len(size) == 2:
+ maxcol, maxrow = size
+ is_box = True
+ else:
+ maxcol, maxrow = size[0], None
+ is_box = False
+
+ if not (self._ti_data and self._ti_data.ok and _termlib.is_kitty_supported()):
+ return self._placeholder_canvas(maxcol, maxrow, focus)
+
+ cols, rows = self._display_size(maxcol, maxrow)
+ self._ti_cols, self._ti_rows = cols, rows
+ # urwid requires canvases to fill the allocated space exactly
+ # (width == maxcol for FLOW, (maxcol, maxrow) for BOX). The image
+ # itself is placed by the screen at its resolved size inside the
+ # canvas, left-aligned; the rest of the canvas stays blank.
+ if is_box: return ImageCanvas(maxcol, maxrow)
+ else: return ImageCanvas(maxcol, rows)
+
+ #########################
+ # Placeholder rendering #
+ #########################
+
+ def _placeholder_lines(self):
+ return [f"{self.placeholder_glyph}{self._notice or self._ti_name}"]
+
+ def _placeholder_canvas(self, maxcol, maxrow, focus):
+ align = {"<": "left", "|": "center", ">": "right"}.get(self._ti_align, "center")
+ text = urwid.Text("\n".join(self._placeholder_lines()), align=align)
+ if maxrow is not None: return urwid.Filler(text).render((maxcol, maxrow), focus)
+ else: return text.render((maxcol,), focus)
+
+
+class ImageCanvas(urwid.SolidCanvas):
+ # A blank canvas marking the space occupied by an image.
+ # Urwid sets _widget_info on this canvas to the owning
+ # ImageWidget. ImageScreen finds image canvases through
+ # that reference during its shard scan.
+ def __init__(self, cols, rows): super().__init__(" ", cols, rows)
diff --git a/nomadnet/util.py b/nomadnet/util.py
new file mode 100644
index 0000000..4c3a079
--- /dev/null
+++ b/nomadnet/util.py
@@ -0,0 +1,222 @@
+import os
+import re
+import unicodedata
+
+
+def write_config_atomic(config):
+ path = getattr(config, "filename", None)
+ if not path:
+ config.write()
+ return
+
+ directory = os.path.dirname(path) or "."
+ tmp = os.path.join(directory, "."+os.path.basename(path)+".tmp")
+ try:
+ try:
+ existing_mode = os.stat(path).st_mode
+ except OSError:
+ existing_mode = None
+
+ with open(tmp, "wb") as tmp_file:
+ config.write(tmp_file)
+ tmp_file.flush()
+ try: os.fsync(tmp_file.fileno())
+ except Exception: pass
+
+ if existing_mode is not None:
+ try: os.chmod(tmp, existing_mode)
+ except Exception: pass
+
+ os.replace(tmp, path)
+ except Exception:
+ try: os.remove(tmp)
+ except Exception: pass
+ raise
+
+
+invalid_rendering = ["🕵️", "☝"]
+
+# Unicode blocks to strip (symbols, dingbats, emoji ranges)
+STRIP_BLOCKS_RE = re.compile(
+ '['
+ '\U0001F600-\U0001F64F' # Emoticons
+ '\U0001F300-\U0001F5FF' # Misc Symbols & Pictographs
+ '\U0001F680-\U0001F6FF' # Transport & Map Symbols
+ '\U0001F700-\U0001F77F' # Alchemical Symbols
+ '\U0001F780-\U0001F7FF' # Geometric Shapes Extended
+ '\U0001F800-\U0001F8FF' # Supplemental Arrows-C
+ '\U0001F900-\U0001F9FF' # Supplemental Symbols & Pictographs
+ '\U0001FA00-\U0001FA6F' # Chess Symbols
+ '\U0001FA70-\U0001FAFF' # Symbols & Pictographs Extended-A
+ '\U0001F1E0-\U0001F1FF' # Flags (iOS/regional indicators)
+ '\u2600-\u26FF' # Misc Symbols (☀, ☁, ☂, etc.)
+ '\u2700-\u27BF' # Dingbats (✂, ✈, ✉, ✌, etc.)
+ '\uFE00-\uFE0F' # Variation Selectors
+ '\U000E0100-\U000E01EF' # Variation Selectors Supplement
+ '\U0001F3FB-\U0001F3FF' # Emoji modifiers (skin tones)
+ ']+',
+ flags=re.UNICODE
+)
+
+# Control characters and zero-width characters to strip
+STRIP_CONTROL_RE = re.compile(
+ '['
+ '\x00-\x08' # C0 controls (NUL-BS)
+ '\x0B\x0C' # VT, FF
+ '\x0E-\x1F' # C0 controls (SO-US)
+ '\x7F-\x9F' # DEL and C1 controls
+ '\u200B-\u200F' # Zero-width chars, LRM, RLM, etc.
+ '\u202A-\u202E' # Bidi embedding controls
+ '\u2060-\u206F' # Format chars (word joiner, etc.)
+ '\uFEFF' # BOM / Zero Width NBSP
+ '\uFFF0-\uFFF8' # Specials
+ ']+',
+ flags=re.UNICODE
+)
+
+# Surrogates and private use areas
+# Shouldn't appear in valid UTF-8, but strip just in case
+STRIP_PRIVATE_RE = re.compile(
+ '['
+ '\uD800-\uDFFF' # Surrogates
+ '\uE000-\uF8FF' # Private Use Area
+ '\uF900-\uFAFF' # CJK Compatibility Ideographs (keep? strip for safety)
+ '\uFE10-\uFE1F' # Vertical Forms
+ '\uFE20-\uFE2F' # Combining Half Marks
+ '\U000F0000-\U000FFFFF' # Supplementary Private Use Area-A
+ '\U00100000-\U0010FFFF' # Supplementary Private Use Area-B
+ ']+',
+ flags=re.UNICODE
+)
+
+def strip_modifiers(text):
+ def process_characters(text):
+ result = []
+ i = 0
+ while i < len(text):
+ char = text[i]
+ category = unicodedata.category(char)
+
+ if category.startswith(('L', 'N', 'P', 'S')):
+ result.append(char)
+ i += 1
+
+ elif category.startswith(('M', 'Sk', 'Cf')) or char in '\u200d\u200c':
+ i += 1
+
+ else:
+ result.append(char)
+ i += 1
+
+ return ''.join(result)
+
+ if text == None: return None
+
+ for char in invalid_rendering:
+ text = text.replace(char, " ")
+
+ stripped = process_characters(text)
+ stripped = re.sub(r'[\uFE00-\uFE0F]', '', stripped)
+ stripped = re.sub(r'[\U000E0100-\U000E01EF]', '', stripped, flags=re.UNICODE)
+ stripped = re.sub(r'[\U0001F3FB-\U0001F3FF]', '', stripped, flags=re.UNICODE)
+ stripped = re.sub(r'[\u200D\u200C]', '', stripped)
+ stripped = re.sub(r'\r\n?', '\n', stripped)
+ stripped = STRIP_CONTROL_RE.sub('', stripped)
+
+ return stripped.strip()
+
+def sanitize_name(name):
+ if name is None: return None
+
+ # Convert to string and normalize to NFKC
+ # NFKC: Compatibility decomposition followed by canonical composition
+ # This handles: ① to 1, Ⅰ to I, etc., while keeping composed forms
+ name = str(name)
+ name = unicodedata.normalize('NFKC', name)
+
+ # Build result using category-based filtering
+ result = []
+ for char in name:
+ cat = unicodedata.category(char)
+ cat_prefix = cat[0] if cat else 'C'
+
+ # Allow letters (L*), numbers (N*), and punctuation (P*)
+ if cat_prefix in ('L', 'N', 'P'): result.append(char)
+
+ # Allow space separator, normalize to regular space
+ elif cat == 'Zs': result.append(' ')
+
+ # Convert line/paragraph separators to space
+ elif cat in ('Zl', 'Zp'): result.append(' ')
+
+ # Allow spacing combining marks (Mc) for Indic, Hebrew, etc.
+ elif cat == 'Mc': result.append(char)
+
+ # Allow modifier letters (Lm) - e.g., ʰ, ʱ, ː
+ elif cat == 'Lm': result.append(char)
+
+ # Strip everything else:
+ # - Mn (Nonspacing Mark): diacritics, combining marks (Zalgo)
+ # - Me (Enclosing Mark): enclosing combining marks
+ # - C* (Controls, Format, Surrogates, Private Use, Unassigned)
+ # - S* (Symbols: currency, math, modifiers, other)
+
+ name = ''.join(result)
+
+ # Additional block-based stripping for symbols that categories missed
+ name = STRIP_BLOCKS_RE.sub('', name)
+ name = STRIP_CONTROL_RE.sub('', name)
+ name = STRIP_PRIVATE_RE.sub('', name)
+
+ # Collapse multiple whitespace characters
+ name = re.sub(r'\s+', ' ', name)
+
+ # Strip leading/trailing whitespace
+ name = name.strip()
+
+ return name
+
+def strip_micron(text):
+ text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text)
+ text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text)
+ text = re.sub(r'`[!*_=]', '', text)
+ text = re.sub(r'`f`b', '', text)
+ text = re.sub(r'`f', '', text)
+ text = re.sub(r'`b', '', text)
+ text = re.sub(r'`<', '', text)
+ text = re.sub(r'`>', '', text)
+ text = re.sub(r'`{', '', text)
+ return text
+
+def strip_escaped_micron(text):
+ text = re.sub(r'¦[FB][0-9a-fA-F]{3}', '', text)
+ text = re.sub(r'¦[FB]T[0-9a-fA-F]{6}', '', text)
+ text = re.sub(r'¦[!*_=]', '', text)
+ text = re.sub(r'¦f`b', '', text)
+ text = re.sub(r'¦f', '', text)
+ text = re.sub(r'¦b', '', text)
+ text = re.sub(r'¦<', '', text)
+ text = re.sub(r'¦>', '', text)
+ text = re.sub(r'¦{', '', text)
+ return text
+
+def unescape_micron(text):
+ text = re.sub(r'¦([FB][0-9a-fA-F]{3})', r'`\1', text)
+ text = re.sub(r'¦([FB]T[0-9a-fA-F]{6})', r'`\1', text)
+ text = re.sub(r'¦([!*_=])', r'`\1', text)
+ text = re.sub(r'¦(f`b)', r'`\1', text)
+ text = re.sub(r'¦(f)', r'`\1', text)
+ text = re.sub(r'¦(b)', r'`\1', text)
+ text = re.sub(r'¦(<)', r'`\1', text)
+ text = re.sub(r'¦(>)', r'`\1', text)
+ text = re.sub(r'¦({)', r'`\1', text)
+ return text
+
+def strip_non_formatting_tags(text):
+ text = re.sub(r'`<', '', text)
+ text = re.sub(r'`>', '', text)
+ text = re.sub(r'`{', '', text)
+ text = re.sub(r'`r', '', text)
+ text = re.sub(r'`c', '', text)
+ text = re.sub(r'`l', '', text)
+ return text
\ No newline at end of file
diff --git a/nomadnet/vendor/AsciiChart.py b/nomadnet/vendor/AsciiChart.py
new file mode 100644
index 0000000..cd24d56
--- /dev/null
+++ b/nomadnet/vendor/AsciiChart.py
@@ -0,0 +1,91 @@
+from __future__ import division
+from math import ceil, floor, isnan
+# Derived from asciichartpy | https://github.com/kroitor/asciichart/blob/master/asciichartpy/__init__.py
+class AsciiChart:
+ def __init__(self, glyphset="unicode"):
+ self.symbols = ['┼', '┤', '╶', '╴', '─', '╰', '╭', '╮', '╯', '│']
+ if glyphset == "plain":
+ self.symbols = ['+', '|', '-', '-', '-', '\'', ',', '.', '`', '|']
+ def plot(self, series, cfg=None):
+ if len(series) == 0:
+ return ''
+ if not isinstance(series[0], list):
+ if all(isnan(n) for n in series):
+ return ''
+ else:
+ series = [series]
+ cfg = cfg or {}
+ minimum = cfg.get('min', min(filter(lambda n: not isnan(n), [j for i in series for j in i])))
+ maximum = cfg.get('max', max(filter(lambda n: not isnan(n), [j for i in series for j in i])))
+ symbols = cfg.get('symbols', self.symbols)
+ if minimum > maximum:
+ raise ValueError('The min value cannot exceed the max value.')
+ interval = maximum - minimum
+ offset = cfg.get('offset', 3)
+ height = cfg.get('height', interval)
+ ratio = height / interval if interval > 0 else 1
+
+ min2 = int(floor(minimum * ratio))
+ max2 = int(ceil(maximum * ratio))
+
+ def clamp(n):
+ return min(max(n, minimum), maximum)
+
+ def scaled(y):
+ return int(round(clamp(y) * ratio) - min2)
+
+ rows = max2 - min2
+
+ width = 0
+ for i in range(0, len(series)):
+ width = max(width, len(series[i]))
+ width += offset
+
+ placeholder = cfg.get('format', '{:8.2f} ')
+
+ result = [[' '] * width for i in range(rows + 1)]
+
+ for y in range(min2, max2 + 1):
+ if callable(placeholder):
+ label = placeholder(maximum - ((y - min2) * interval / (rows if rows else 1))).rjust(12)
+ else:
+ label = placeholder.format(maximum - ((y - min2) * interval / (rows if rows else 1)))
+
+ result[y - min2][max(offset - len(label), 0)] = label
+ result[y - min2][offset - 1] = symbols[0] if y == 0 else symbols[1]
+
+ d0 = series[0][0]
+ if not isnan(d0):
+ result[rows - scaled(d0)][offset - 1] = symbols[0]
+
+ for i in range(0, len(series)):
+ for x in range(0, len(series[i]) - 1):
+ d0 = series[i][x + 0]
+ d1 = series[i][x + 1]
+
+ if isnan(d0) and isnan(d1):
+ continue
+
+ if isnan(d0) and not isnan(d1):
+ result[rows - scaled(d1)][x + offset] = symbols[2]
+ continue
+
+ if not isnan(d0) and isnan(d1):
+ result[rows - scaled(d0)][x + offset] = symbols[3]
+ continue
+
+ y0 = scaled(d0)
+ y1 = scaled(d1)
+ if y0 == y1:
+ result[rows - y0][x + offset] = symbols[4]
+ continue
+
+ result[rows - y1][x + offset] = symbols[5] if y0 > y1 else symbols[6]
+ result[rows - y0][x + offset] = symbols[7] if y0 > y1 else symbols[8]
+
+ start = min(y0, y1) + 1
+ end = max(y0, y1)
+ for y in range(start, end):
+ result[rows - y][x + offset] = symbols[9]
+
+ return '\n'.join([''.join(row).rstrip() for row in result])
\ No newline at end of file
diff --git a/nomadnet/vendor/Scrollable.py b/nomadnet/vendor/Scrollable.py
new file mode 100644
index 0000000..45ea781
--- /dev/null
+++ b/nomadnet/vendor/Scrollable.py
@@ -0,0 +1,583 @@
+# 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 RNS
+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
+ if self._trim_top != 0 or self._scroll_action is not None:
+ # Reset potentially stale _trim_top
+ self._trim_top = 0
+ self._scroll_action = None
+ return canv
+
+ self._adjust_trim_top(canv, size)
+
+ # 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 = 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)
+
+ self._debug_hz = 0
+ self._debug_hz_smoothed = 0
+ self._debug_scroll_ms = 0
+ self._debug_scroll_started = 0
+
+ def render(self, size, focus=False):
+ maxcol, maxrow = size
+
+ 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)
+
+ if self._debug_scroll_started:
+ total_ms = (time.time() - self._debug_scroll_started) * 1000
+ # RNS.log(f" accel={self._accel} , hz={RNS.prettyfrequency(self._debug_hz)} smoothed={RNS.prettyfrequency(self._debug_hz_smoothed)} scroll={self._debug_scroll_ms:.1f}ms total={total_ms:.1f}ms", RNS.LOG_DEBUG)
+ self._debug_scroll_started = 0
+
+ self._original_widget_size = ow_size
+
+ pos = ow_base.get_scrollpos(ow_size, focus)
+ posmax = ow_rows_max - maxrow
+
+ # Clamp position to valid scroll range. The position
+ # reported by the underlying widget can transiently
+ # fall outside [0, posmax] if its layout state is
+ # inconsistent.
+ if pos < 0: pos = 0
+ elif pos > posmax: pos = posmax
+
+ # Thumb shrinks/grows according to the ratio of
+ # /
+ thumb_weight = min(1, maxrow / max(1, ow_rows_max))
+ 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. Guard against
+ # negative values so the scrollbar canvas can
+ # never be taller than maxrow.
+ bottom_height = maxrow - thumb_height - top_height
+ if bottom_height < 0: bottom_height = 0
+ assert thumb_height + top_height + bottom_height <= maxrow
+
+ # Create scrollbar canvas
+ # Creating SolidCanvases of correct height may result in "cviews do not
+ # 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)
+ self._debug_hz = sevt_freq
+ self._debug_hz_smoothed = freq_smth
+ if len(self._sfreq_deque) >= self._sfreq_deque.maxlen:
+ if freq_smth > 25.0: self._accel = max(self._accel, 3)
+ elif freq_smth > 12.0: self._accel = max(self._accel, 2)
+ else: self._accel = max(self._accel, 1)
+
+ step = self._accel
+ if button in (4, 5):
+ scroll_started = time.time()
+ pos = ow.get_scrollpos(ow_size)
+ if button == 4: newpos = max(0, pos - step)
+ else: newpos = pos + step
+ ow.set_scrollpos(newpos)
+ self._debug_scroll_started = scroll_started
+ self._debug_scroll_ms = (time.time() - scroll_started) * 1000
+ return True
+
+ return False
+
+ def _set_scrollpos_from_row(self, row):
+ ow_base = self.scrolling_base_widget
+ if not hasattr(ow_base, 'set_scrollpos'):
+ return
+ posmax = self._sb_ow_rows_max - self._sb_maxrow
+ if posmax <= 0:
+ return
+ track_len = max(1, self._sb_maxrow - self._sb_thumb_height)
+ top = max(0, min(track_len, row - self._sb_drag_offset))
+ new_pos = int(round(top * posmax / track_len))
+ new_pos = max(0, min(posmax, new_pos))
+ ow_base.set_scrollpos(new_pos)
diff --git a/nomadnet/vendor/__init__.py b/nomadnet/vendor/__init__.py
index e3164b0..72302cb 100644
--- a/nomadnet/vendor/__init__.py
+++ b/nomadnet/vendor/__init__.py
@@ -1,5 +1,7 @@
import os
import glob
-modules = glob.glob(os.path.dirname(__file__)+"/*.py")
-__all__ = [ os.path.basename(f)[:-3] for f in modules if not f.endswith('__init__.py')]
+py_modules = glob.glob(os.path.dirname(__file__)+"/*.py")
+pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
+modules = py_modules+pyc_modules
+__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py
new file mode 100644
index 0000000..9ab5dea
--- /dev/null
+++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py
@@ -0,0 +1,575 @@
+import urwid
+import nomadnet
+
+from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit
+
+def dropdown_indicator():
+ try:
+ return nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs["dropdown"]
+ except Exception:
+ return " ▾"
+
+class DialogLineBox(urwid.LineBox):
+ def __init__(self, body, parent=None, title="?"):
+ super().__init__(body, title=title)
+ self.parent = parent
+
+ def keypress(self, size, key):
+ if key == "esc":
+ if self.parent and hasattr(self.parent, "dismiss_dialog"):
+ self.parent.dismiss_dialog()
+ return None
+ return super().keypress(size, key)
+
+class Placeholder(ReadlineMixin, urwid.Edit):
+ def __init__(self, caption="", edit_text="", placeholder="", **kwargs):
+ super().__init__(caption, edit_text, **kwargs)
+ self.placeholder = placeholder
+
+ def render(self, size, focus=False):
+ if not self.edit_text and not focus:
+ placeholder_widget = urwid.Text(("placeholder", self.placeholder))
+ return placeholder_widget.render(size, focus)
+ else:
+ return super().render(size, focus)
+
+class Dropdown(urwid.WidgetWrap):
+ signals = ['change'] # emit for urwid.connect_signal fn
+
+ def __init__(self, label, options, default=None):
+ self.label = label
+ self.options = options
+ self.selected = default if default is not None else options[0]
+
+ self.main_text = f"{self.selected}"
+ self.main_button = urwid.SelectableIcon(self.main_text, 0)
+ self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus")
+
+ self.indicator_widget = urwid.Text(dropdown_indicator())
+ self.main_row = urwid.Columns([
+ ('pack', self.main_button),
+ ('pack', self.indicator_widget),
+ ('weight', 1, urwid.Text("")),
+ ])
+
+ self.option_widgets = []
+ for opt in options:
+ icon = urwid.SelectableIcon(opt, 0)
+ icon = urwid.AttrMap(icon, "list_normal", "list_focus")
+ self.option_widgets.append(icon)
+
+ self.options_walker = urwid.SimpleFocusListWalker(self.option_widgets)
+ self.options_listbox = urwid.ListBox(self.options_walker)
+ self.dropdown_box = None # will be created on open_dropdown
+
+ self.pile = urwid.Pile([self.main_row])
+ self.dropdown_visible = False
+
+ super().__init__(self.pile)
+
+ def open_dropdown(self):
+ if not self.dropdown_visible:
+ height = len(self.options)
+ self.dropdown_box = urwid.BoxAdapter(self.options_listbox, height)
+ self.pile.contents.append((self.dropdown_box, self.pile.options()))
+ self.dropdown_visible = True
+ self.pile.focus_position = 1
+ self.options_walker.set_focus(0)
+
+ def close_dropdown(self):
+ if self.dropdown_visible:
+ self.pile.contents.pop() # remove the dropdown_box
+ self.dropdown_visible = False
+ self.pile.focus_position = 0
+ self.dropdown_box = None
+
+ def keypress(self, size, key):
+ if not self.dropdown_visible:
+ if key == "enter":
+ self.open_dropdown()
+ return None
+ return self.main_button.keypress(size, key)
+ else:
+ if key == "enter":
+ focus_result = self.options_walker.get_focus()
+ if focus_result is not None:
+ focus_widget = focus_result[0]
+ new_val = focus_widget.base_widget.text
+ old_val = self.selected
+ self.selected = new_val
+ self.main_button.base_widget.set_text(f"{self.selected}")
+
+ if old_val != new_val:
+ self._emit('change', new_val)
+
+ self.close_dropdown()
+ return None
+ return self.dropdown_box.keypress(size, key)
+
+ def get_value(self):
+ return self.selected
+
+class ValidationError(urwid.Text):
+ def __init__(self, message=""):
+ super().__init__(("error", message))
+
+class FormField:
+ def __init__(self, config_key, transform=None):
+ self.config_key = config_key
+ self.transform = transform or (lambda x: x)
+
+class FormEdit(Placeholder, FormField):
+ def __init__(self, config_key, caption="", edit_text="", placeholder="", validation_types=None, transform=None, **kwargs):
+ Placeholder.__init__(self, caption, edit_text, placeholder, **kwargs)
+ FormField.__init__(self, config_key, transform)
+ self.validation_types = validation_types or []
+ self.error_widget = urwid.Text("")
+ self.error = None
+
+ def get_value(self):
+ return self.transform(self.edit_text.strip())
+
+ def validate(self):
+ value = self.edit_text.strip()
+ self.error = None
+
+ for validation in self.validation_types:
+ if validation == "required":
+ if not value:
+ self.error = "This field is required"
+ break
+ elif validation == "number":
+ try:
+ if value:
+ int(value)
+ except ValueError:
+ self.error = "This field must be a whole number"
+ break
+ elif validation == "stamp_cost":
+ if value and value.lower() != "none":
+ try:
+ if not 1 <= int(value) <= 255:
+ raise ValueError
+ except ValueError:
+ self.error = "This field must be 1-255 or None"
+ break
+ elif validation == "float":
+ try:
+ if value:
+ float(value)
+ except ValueError:
+ self.error = "This field must be decimal number"
+ break
+
+ self.error_widget.set_text(("error", self.error or ""))
+ return self.error is None
+
+class FormCheckbox(urwid.CheckBox, FormField):
+ def __init__(self, config_key, label="", state=False, validation_types=None, transform=None, **kwargs):
+ urwid.CheckBox.__init__(self, label, state, **kwargs)
+ FormField.__init__(self, config_key, transform)
+ self.validation_types = validation_types or []
+ self.error_widget = urwid.Text("")
+ self.error = None
+
+ def get_value(self):
+ return self.transform(self.get_state())
+
+ def validate(self):
+
+ value = self.get_state()
+ self.error = None
+
+ for validation in self.validation_types:
+ if validation == "required":
+ if not value:
+ self.error = "This field is required"
+ break
+
+ self.error_widget.set_text(("error", self.error or ""))
+ return self.error is None
+
+class FormDropdown(Dropdown, FormField):
+ signals = ['change']
+
+ def __init__(self, config_key, label, options, default=None, validation_types=None, transform=None):
+ self.options = [str(opt) for opt in options]
+
+ if default is not None:
+ default_str = str(default)
+ if default_str in self.options:
+ default = default_str
+ elif transform:
+ try:
+ default_transformed = transform(default_str)
+ for opt in self.options:
+ if transform(opt) == default_transformed:
+ default = opt
+ break
+ except:
+ default = self.options[0]
+ else:
+ default = self.options[0]
+ else:
+ default = self.options[0]
+
+ Dropdown.__init__(self, label, self.options, default)
+ FormField.__init__(self, config_key, transform)
+
+ self.validation_types = validation_types or []
+ self.error_widget = urwid.Text("")
+ self.error = None
+
+ if hasattr(self, 'main_button'):
+ self.main_button.base_widget.set_text(str(default))
+
+ def get_value(self):
+ return self.transform(self.selected)
+
+ def validate(self):
+ value = self.get_value()
+ self.error = None
+
+ for validation in self.validation_types:
+ if validation == "required":
+ if not value:
+ self.error = "This field is required"
+ break
+
+ self.error_widget.set_text(("error", self.error or ""))
+ return self.error is None
+
+ def open_dropdown(self):
+ if not self.dropdown_visible:
+ super().open_dropdown()
+ try:
+ current_index = self.options.index(self.selected)
+ self.options_walker.set_focus(current_index)
+ except ValueError:
+ pass
+
+class FormMultiList(urwid.Pile, FormField):
+ def __init__(self, config_key, placeholder="", validation_types=None, transform=None, **kwargs):
+ self.entries = []
+ self.error_widget = urwid.Text("")
+ self.error = None
+ self.placeholder = placeholder
+ self.validation_types = validation_types or []
+
+ first_entry = self.create_entry_row()
+ self.entries.append(first_entry)
+
+ self.add_button = urwid.Button("+ Add Another", on_press=self.add_entry)
+ add_button_padded = urwid.Padding(self.add_button, left=2, right=2)
+
+ pile_widgets = [first_entry, add_button_padded]
+ urwid.Pile.__init__(self, pile_widgets)
+ FormField.__init__(self, config_key, transform)
+
+ def create_entry_row(self, text=""):
+ edit = ReadlineEdit("", text)
+ entry_row = urwid.Columns([
+ ('weight', 1, edit),
+ (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))),
+ ])
+ return entry_row
+
+ def set_value(self, value):
+ self.entries = []
+ if not value:
+ self.entries.append(self.create_entry_row())
+ else:
+ if isinstance(value, str):
+ value = [value]
+ for item in value:
+ self.entries.append(self.create_entry_row(str(item)))
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def remove_entry(self, button, entry_row):
+ if len(self.entries) > 1:
+ self.entries.remove(entry_row)
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def add_entry(self, button):
+ new_entry = self.create_entry_row()
+ self.entries.append(new_entry)
+
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def get_pile_widgets(self):
+ return self.entries + [urwid.Padding(self.add_button, left=2, right=2)]
+
+ def get_value(self):
+ values = []
+ for entry in self.entries:
+ edit_widget = entry.contents[0][0]
+ value = edit_widget.edit_text.strip()
+ if value:
+ values.append(value)
+ return self.transform(values)
+
+ def validate(self):
+ values = self.get_value()
+ self.error = None
+
+ for validation in self.validation_types:
+ if validation == "required" and not values:
+ self.error = "At least one entry is required"
+ break
+
+ self.error_widget.set_text(("error", self.error or ""))
+ return self.error is None
+
+
+class FormMultiTable(urwid.Pile, FormField):
+ def __init__(self, config_key, fields, validation_types=None, transform=None, **kwargs):
+ self.entries = []
+ self.fields = fields
+ self.error_widget = urwid.Text("")
+ self.error = None
+ self.validation_types = validation_types or []
+
+ header_columns = [('weight', 3, urwid.Text(("list_focus", "Name")))]
+ for field_key, field_config in self.fields.items():
+ header_columns.append(('weight', 2, urwid.Text(("list_focus", field_config.get("label", field_key)))))
+ header_columns.append((4, urwid.Text(("list_focus", ""))))
+
+ self.header_row = urwid.Columns(header_columns)
+
+ first_entry = self.create_entry_row()
+ self.entries.append(first_entry)
+
+ self.add_button = urwid.Button("+ Add ", on_press=self.add_entry)
+ add_button_padded = urwid.Padding(self.add_button, left=2, right=2)
+
+ pile_widgets = [
+ self.header_row,
+ urwid.Divider("-"),
+ first_entry,
+ add_button_padded
+ ]
+
+ urwid.Pile.__init__(self, pile_widgets)
+ FormField.__init__(self, config_key, transform)
+
+ def create_entry_row(self, name="", values=None):
+ if values is None:
+ values = {}
+
+ name_edit = ReadlineEdit("", name)
+
+ columns = [('weight', 3, name_edit)]
+
+ field_widgets = {}
+ for field_key, field_config in self.fields.items():
+ field_value = values.get(field_key, "")
+
+ if field_config.get("type") == "checkbox":
+ widget = urwid.CheckBox("", state=bool(field_value))
+ elif field_config.get("type") == "dropdown":
+ # TODO: dropdown in MultiTable
+ widget = ReadlineEdit("", str(field_value))
+ else:
+ widget = ReadlineEdit("", str(field_value))
+
+ field_widgets[field_key] = widget
+ columns.append(('weight', 2, widget))
+
+ remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))
+ columns.append((4, remove_button))
+
+ entry_row = urwid.Columns(columns)
+ entry_row.name_edit = name_edit
+ entry_row.field_widgets = field_widgets
+
+ return entry_row
+
+ def remove_entry(self, button, entry_row):
+ if len(self.entries) > 1:
+ self.entries.remove(entry_row)
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def add_entry(self, button):
+ new_entry = self.create_entry_row()
+ self.entries.append(new_entry)
+
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def get_pile_widgets(self):
+ return [
+ self.header_row,
+ urwid.Divider("-")
+ ] + self.entries + [
+ urwid.Padding(self.add_button, left=2, right=2)
+ ]
+
+ def get_value(self):
+ values = {}
+ for entry in self.entries:
+ name = entry.name_edit.edit_text.strip()
+ if name:
+ subinterface = {}
+ subinterface["interface_enabled"] = True
+
+ for field_key, widget in entry.field_widgets.items():
+ field_config = self.fields.get(field_key, {})
+
+ if hasattr(widget, "get_state"):
+ value = widget.get_state()
+ elif hasattr(widget, "edit_text"):
+ value = widget.edit_text.strip()
+
+ transform = field_config.get("transform")
+ if transform and value:
+ try:
+ value = transform(value)
+ except (ValueError, TypeError):
+ value = ""
+
+ if value:
+ subinterface[field_key] = value
+
+ values[name] = subinterface
+
+ return self.transform(values) if self.transform else values
+
+ def set_value(self, value):
+ self.entries = []
+
+ if not value:
+ self.entries.append(self.create_entry_row())
+ else:
+ for name, config in value.items():
+ self.entries.append(self.create_entry_row(name=name, values=config))
+
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def validate(self):
+ values = self.get_value()
+ self.error = None
+
+ for validation in self.validation_types:
+ if validation == "required" and not values:
+ self.error = "At least one subinterface is required"
+ break
+
+ self.error_widget.set_text(("error", self.error or ""))
+ return self.error is None
+
+
+class FormKeyValuePairs(urwid.Pile, FormField):
+ def __init__(self, config_key, validation_types=None, transform=None, **kwargs):
+ self.entries = []
+ self.error_widget = urwid.Text("")
+ self.error = None
+ self.validation_types = validation_types or []
+
+ header_columns = [
+ ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Key"), "multitable_header")),
+ ('weight', 1, urwid.AttrMap(urwid.Text("Parameter Value"), "multitable_header")),
+ (4, urwid.AttrMap(urwid.Text("Action"), "multitable_header"))
+ ]
+
+ self.header_row = urwid.AttrMap(urwid.Columns(header_columns), "multitable_header")
+
+ first_entry = self.create_entry_row()
+ self.entries.append(first_entry)
+
+ self.add_button = urwid.Button("+ Add Parameter", on_press=self.add_entry)
+ add_button_padded = urwid.Padding(self.add_button, left=2, right=2)
+
+ pile_widgets = [
+ self.header_row,
+ urwid.Divider("-"),
+ first_entry,
+ add_button_padded
+ ]
+
+ urwid.Pile.__init__(self, pile_widgets)
+ FormField.__init__(self, config_key, transform)
+
+ def create_entry_row(self, key="", value=""):
+ key_edit = ReadlineEdit("", key)
+ value_edit = ReadlineEdit("", value)
+
+ remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))
+
+ entry_row = urwid.Columns([
+ ('weight', 1, key_edit),
+ ('weight', 1, value_edit),
+ (4, remove_button)
+ ])
+
+ entry_row.key_edit = key_edit
+ entry_row.value_edit = value_edit
+
+ return entry_row
+
+ def remove_entry(self, button, entry_row):
+ if len(self.entries) > 1:
+ self.entries.remove(entry_row)
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def add_entry(self, button):
+ new_entry = self.create_entry_row()
+ self.entries.append(new_entry)
+
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def get_pile_widgets(self):
+ return [
+ self.header_row,
+ urwid.Divider("-")
+ ] + self.entries + [
+ urwid.Padding(self.add_button, left=2, right=2)
+ ]
+
+ def get_value(self):
+ values = {}
+ for entry in self.entries:
+ key = entry.key_edit.edit_text.strip()
+ value = entry.value_edit.edit_text.strip()
+
+ if key:
+ if value.isdigit():
+ values[key] = int(value)
+ elif value.replace('.', '', 1).isdigit() and value.count('.') <= 1:
+ values[key] = float(value)
+ elif value.lower() == 'true':
+ values[key] = True
+ elif value.lower() == 'false':
+ values[key] = False
+ else:
+ values[key] = value
+
+ return self.transform(values) if self.transform else values
+
+ def set_value(self, value):
+ self.entries = []
+
+ if not value or not isinstance(value, dict):
+ self.entries.append(self.create_entry_row())
+ else:
+ for key, val in value.items():
+ self.entries.append(self.create_entry_row(key=key, value=str(val)))
+
+ self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
+
+ def validate(self):
+ values = self.get_value()
+ self.error = None
+
+ keys = [entry.key_edit.edit_text.strip() for entry in self.entries
+ if entry.key_edit.edit_text.strip()]
+ if len(keys) != len(set(keys)):
+ self.error = "Duplicate keys are not allowed"
+ self.error_widget.set_text(("error", self.error))
+ return False
+
+ for validation in self.validation_types:
+ if validation == "required" and not values:
+ self.error = "Atleast one parameter is required"
+ break
+
+ self.error_widget.set_text(("error", self.error or ""))
+ return self.error is None
\ No newline at end of file
diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py
index d4f18dc..c0a95b0 100644
--- a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py
+++ b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py
@@ -257,10 +257,15 @@ class IndicativeListBox(urwid.WidgetWrap):
# An event is changed to a modified one ('mouse press' => 'ctrl mouse press'). This prevents the widget from responding
# when mouse buttons are also used to navigate between widgets.
if event == self._modifier_key.prepend_to("mouse press"):
- # Store the focus position before passing the input to the contained list box. That way, it can be compared with the
+ # Store the focus position before passing the input to the contained list box. That way, it can be compared with the
# position after the input is processed. If the list box body is empty, store None.
focus_position_before_input = self.get_selected_position()
-
+
+ # Also remember the currently selected item itself. A button 1 press makes the contained list box move the focus, but
+ # the off focus highlighting is only (un)done during 'render()' while the widget has the focus. When the selection is
+ # changed by mouse while the widget is unfocused, the highlight would otherwise stay stuck on the previous item.
+ item_before_input = self.get_selected_item()
+
# left mouse button, if not top bar or bottom bar.
if (button == 1.0) and (topBar_rows <= row < (size[1] - bottomBar_rows)):
# Because 'row' includes the top bar, the offset must be substracted before passing it to the contained list box.
@@ -269,18 +274,29 @@ class IndicativeListBox(urwid.WidgetWrap):
# mousewheel up
elif button == 4.0:
- was_handeled = self._pass_key_to_contained_listbox(modified_size, "page up")
+ # was_handeled = self._pass_key_to_contained_listbox(modified_size, "page up")
+ was_handeled = self._pass_key_to_contained_listbox(modified_size, "up")
# mousewheel down
elif button == 5.0:
- was_handeled = self._pass_key_to_contained_listbox(modified_size, "page down")
+ # was_handeled = self._pass_key_to_contained_listbox(modified_size, "page down")
+ was_handeled = self._pass_key_to_contained_listbox(modified_size, "down")
focus_position_after_input = self.get_selected_position()
-
- # If the focus position has changed, execute the hook (if existing).
- if (focus_position_before_input != focus_position_after_input) and (self.on_selection_change is not None):
- self.on_selection_change(focus_position_before_input,
- focus_position_after_input)
+
+ if focus_position_before_input != focus_position_after_input:
+ # The mouse moved the selection. If off focus highlighting is currently applied (the widget is unfocused), restore
+ # the previously selected item and clear the stored state, so the next 'render()' re-applies the highlight to the
+ # newly selected item instead of leaving it stuck on the old one.
+ if (not self._last_focus_state) and (self._original_item_attr_map is not None) and (item_before_input is not None):
+ item_before_input.set_attr_map(self._original_item_attr_map)
+ self._original_item_attr_map = None
+ self._last_focus_state = None
+
+ # If the focus position has changed, execute the hook (if existing).
+ if self.on_selection_change is not None:
+ self.on_selection_change(focus_position_before_input,
+ focus_position_after_input)
return was_handeled
diff --git a/nomadnet/vendor/cbor.py b/nomadnet/vendor/cbor.py
new file mode 100644
index 0000000..6c63096
--- /dev/null
+++ b/nomadnet/vendor/cbor.py
@@ -0,0 +1,430 @@
+# https://github.com/brianolson/cbor_py
+# Copyright 2014-2015 Brian Olson
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import datetime
+import re
+import struct
+from io import BytesIO
+
+
+CBOR_TYPE_MASK = 0xE0 # top 3 bits
+CBOR_INFO_BITS = 0x1F # low 5 bits
+
+
+CBOR_UINT = 0x00
+CBOR_NEGINT = 0x20
+CBOR_BYTES = 0x40
+CBOR_TEXT = 0x60
+CBOR_ARRAY = 0x80
+CBOR_MAP = 0xA0
+CBOR_TAG = 0xC0
+CBOR_7 = 0xE0 # float and other types
+
+CBOR_UINT8_FOLLOWS = 24 # 0x18
+CBOR_UINT16_FOLLOWS = 25 # 0x19
+CBOR_UINT32_FOLLOWS = 26 # 0x1a
+CBOR_UINT64_FOLLOWS = 27 # 0x1b
+CBOR_VAR_FOLLOWS = 31 # 0x1f
+
+CBOR_BREAK = 0xFF
+
+CBOR_FALSE = (CBOR_7 | 20)
+CBOR_TRUE = (CBOR_7 | 21)
+CBOR_NULL = (CBOR_7 | 22)
+CBOR_UNDEFINED = (CBOR_7 | 23) # js 'undefined' value
+
+CBOR_FLOAT16 = (CBOR_7 | 25)
+CBOR_FLOAT32 = (CBOR_7 | 26)
+CBOR_FLOAT64 = (CBOR_7 | 27)
+
+CBOR_TAG_DATE_STRING = 0 # RFC3339
+CBOR_TAG_DATE_ARRAY = 1 # any number, seconds since 1970-01-01T00:00:00 UTC
+CBOR_TAG_BIGNUM = 2 # big-endian byte string follows
+CBOR_TAG_NEGBIGNUM = 3 # big-endian byte string follows
+CBOR_TAG_DECIMAL = 4
+CBOR_TAG_BIGFLOAT = 5
+CBOR_TAG_BASE64URL = 21
+CBOR_TAG_BASE64 = 22
+CBOR_TAG_BASE16 = 23
+CBOR_TAG_CBOR = 24
+
+CBOR_TAG_URI = 32
+CBOR_TAG_REGEX = 35
+CBOR_TAG_MIME = 36
+CBOR_TAG_CBOR_FILEHEADER = 55799 # 0xd9d9f7
+
+_CBOR_TAG_BIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_BIGNUM)
+_CBOR_TAG_NEGBIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_NEGBIGNUM)
+
+
+def _dumps_bignum_to_bytearray(val):
+ out = []
+ while val > 0:
+ out.insert(0, val & 0x0ff)
+ val = val >> 8
+ return bytes(out)
+
+
+def dumps_int(val):
+ "return bytes representing int val in CBOR"
+ if val >= 0:
+ if val <= 23:
+ return struct.pack('B', val)
+ if val <= 0x0ff:
+ return struct.pack('BB', CBOR_UINT8_FOLLOWS, val)
+ if val <= 0x0ffff:
+ return struct.pack('!BH', CBOR_UINT16_FOLLOWS, val)
+ if val <= 0x0ffffffff:
+ return struct.pack('!BI', CBOR_UINT32_FOLLOWS, val)
+ if val <= 0x0ffffffffffffffff:
+ return struct.pack('!BQ', CBOR_UINT64_FOLLOWS, val)
+ outb = _dumps_bignum_to_bytearray(val)
+ return _CBOR_TAG_BIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb
+ val = -1 - val
+ return _encode_type_num(CBOR_NEGINT, val)
+
+
+def dumps_float(val):
+ return struct.pack("!Bd", CBOR_FLOAT64, val)
+
+
+def _encode_type_num(cbor_type, val):
+ """For some CBOR primary type [0..7] and an auxiliary unsigned number,
+ return CBOR encoded bytes."""
+ assert val >= 0
+ if val <= 23:
+ return struct.pack('B', cbor_type | val)
+ if val <= 0x0ff:
+ return struct.pack('BB', cbor_type | CBOR_UINT8_FOLLOWS, val)
+ if val <= 0x0ffff:
+ return struct.pack('!BH', cbor_type | CBOR_UINT16_FOLLOWS, val)
+ if val <= 0x0ffffffff:
+ return struct.pack('!BI', cbor_type | CBOR_UINT32_FOLLOWS, val)
+ if (((cbor_type == CBOR_NEGINT) and (val <= 0x07fffffffffffffff)) or
+ ((cbor_type != CBOR_NEGINT) and (val <= 0x0ffffffffffffffff))):
+ return struct.pack('!BQ', cbor_type | CBOR_UINT64_FOLLOWS, val)
+ if cbor_type != CBOR_NEGINT:
+ raise Exception("value too big for CBOR unsigned number: {0!r}".format(val))
+ outb = _dumps_bignum_to_bytearray(val)
+ return _CBOR_TAG_NEGBIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb
+
+
+def dumps_string(val, is_text=None, is_bytes=None):
+ if isinstance(val, str):
+ val = val.encode('utf8')
+ is_text = True
+ is_bytes = False
+ if (is_bytes) or not (is_text == True):
+ return _encode_type_num(CBOR_BYTES, len(val)) + val
+ return _encode_type_num(CBOR_TEXT, len(val)) + val
+
+
+def dumps_array(arr, sort_keys=False):
+ head = _encode_type_num(CBOR_ARRAY, len(arr))
+ parts = [dumps(x, sort_keys=sort_keys) for x in arr]
+ return head + b''.join(parts)
+
+
+def dumps_dict(d, sort_keys=False):
+ head = _encode_type_num(CBOR_MAP, len(d))
+ parts = [head]
+ if sort_keys:
+ for k in sorted(d.keys()):
+ v = d[k]
+ parts.append(dumps(k, sort_keys=sort_keys))
+ parts.append(dumps(v, sort_keys=sort_keys))
+ else:
+ for k, v in d.items():
+ parts.append(dumps(k, sort_keys=sort_keys))
+ parts.append(dumps(v, sort_keys=sort_keys))
+ return b''.join(parts)
+
+
+def dumps_bool(b):
+ if b:
+ return struct.pack('B', CBOR_TRUE)
+ return struct.pack('B', CBOR_FALSE)
+
+
+def dumps_tag(t, sort_keys=False):
+ return _encode_type_num(CBOR_TAG, t.tag) + dumps(t.value, sort_keys=sort_keys)
+
+
+def dumps(ob, sort_keys=False):
+ if ob is None:
+ return struct.pack('B', CBOR_NULL)
+ if isinstance(ob, bool):
+ return dumps_bool(ob)
+ if isinstance(ob, (str, bytes, bytearray)):
+ if isinstance(ob, bytearray):
+ ob = bytes(ob)
+ return dumps_string(ob)
+ if isinstance(ob, (list, tuple)):
+ return dumps_array(ob, sort_keys=sort_keys)
+ if isinstance(ob, dict):
+ return dumps_dict(ob, sort_keys=sort_keys)
+ if isinstance(ob, float):
+ return dumps_float(ob)
+ if isinstance(ob, int):
+ return dumps_int(ob)
+ if isinstance(ob, Tag):
+ return dumps_tag(ob, sort_keys=sort_keys)
+ raise Exception("don't know how to cbor serialize object of type %s" % type(ob))
+
+
+def dump(obj, fp, sort_keys=False):
+ """obj: Python object to serialize. fp: file-like object capable of .write(bytes)."""
+ blob = dumps(obj, sort_keys=sort_keys)
+ fp.write(blob)
+
+
+class Tag(object):
+ def __init__(self, tag=None, value=None):
+ self.tag = tag
+ self.value = value
+
+ def __repr__(self):
+ return "Tag({0!r}, {1!r})".format(self.tag, self.value)
+
+ def __eq__(self, other):
+ if not isinstance(other, Tag):
+ return False
+ return (self.tag == other.tag) and (self.value == other.value)
+
+ def __hash__(self):
+ return hash((self.tag, self.value)) if not isinstance(self.value, (list, dict, bytearray)) else id(self)
+
+
+def loads(data):
+ """Parse CBOR bytes and return Python objects."""
+ if data is None:
+ raise ValueError("got None for buffer to decode in loads")
+ if isinstance(data, (bytes, bytearray, memoryview)):
+ fp = BytesIO(bytes(data))
+ else:
+ fp = data
+ return _loads(fp)[0]
+
+
+def load(fp):
+ """Parse and return object from fp, a file-like object supporting .read(n)."""
+ return _loads(fp)[0]
+
+
+_MAX_DEPTH = 100
+
+
+def _tag_aux(fp, tb):
+ bytes_read = 1
+ tag = tb & CBOR_TYPE_MASK
+ tag_aux = tb & CBOR_INFO_BITS
+ if tag_aux <= 23:
+ aux = tag_aux
+ elif tag_aux == CBOR_UINT8_FOLLOWS:
+ data = fp.read(1)
+ aux = struct.unpack_from("!B", data, 0)[0]
+ bytes_read += 1
+ elif tag_aux == CBOR_UINT16_FOLLOWS:
+ data = fp.read(2)
+ aux = struct.unpack_from("!H", data, 0)[0]
+ bytes_read += 2
+ elif tag_aux == CBOR_UINT32_FOLLOWS:
+ data = fp.read(4)
+ aux = struct.unpack_from("!I", data, 0)[0]
+ bytes_read += 4
+ elif tag_aux == CBOR_UINT64_FOLLOWS:
+ data = fp.read(8)
+ aux = struct.unpack_from("!Q", data, 0)[0]
+ bytes_read += 8
+ else:
+ assert tag_aux == CBOR_VAR_FOLLOWS, "bogus tag {0:02x}".format(tb)
+ aux = None
+
+ return tag, tag_aux, aux, bytes_read
+
+
+def _read_byte(fp):
+ tb = fp.read(1)
+ if len(tb) == 0:
+ raise EOFError()
+ return tb[0]
+
+
+def _loads_var_array(fp, limit, depth, returntags, bytes_read):
+ ob = []
+ tb = _read_byte(fp)
+ while tb != CBOR_BREAK:
+ (subob, sub_len) = _loads_tb(fp, tb, limit, depth, returntags)
+ bytes_read += 1 + sub_len
+ ob.append(subob)
+ tb = _read_byte(fp)
+ return (ob, bytes_read + 1)
+
+
+def _loads_var_map(fp, limit, depth, returntags, bytes_read):
+ ob = {}
+ tb = _read_byte(fp)
+ while tb != CBOR_BREAK:
+ (subk, sub_len) = _loads_tb(fp, tb, limit, depth, returntags)
+ bytes_read += 1 + sub_len
+ (subv, sub_len) = _loads(fp, limit, depth, returntags)
+ bytes_read += sub_len
+ ob[subk] = subv
+ tb = _read_byte(fp)
+ return (ob, bytes_read + 1)
+
+
+def _loads_array(fp, limit, depth, returntags, aux, bytes_read):
+ ob = []
+ for _ in range(aux):
+ subob, subpos = _loads(fp, limit, depth, returntags)
+ bytes_read += subpos
+ ob.append(subob)
+ return ob, bytes_read
+
+
+def _loads_map(fp, limit, depth, returntags, aux, bytes_read):
+ ob = {}
+ for _ in range(aux):
+ subk, subpos = _loads(fp, limit, depth, returntags)
+ bytes_read += subpos
+ subv, subpos = _loads(fp, limit, depth, returntags)
+ bytes_read += subpos
+ ob[subk] = subv
+ return ob, bytes_read
+
+
+def _loads(fp, limit=None, depth=0, returntags=False):
+ "return (object, bytes read)"
+ if depth > _MAX_DEPTH:
+ raise Exception("hit CBOR loads recursion depth limit")
+ tb = _read_byte(fp)
+ return _loads_tb(fp, tb, limit, depth, returntags)
+
+
+def _loads_tb(fp, tb, limit=None, depth=0, returntags=False):
+ # Some special cases of CBOR_7 best handled by special struct.unpack logic here
+ if tb == CBOR_FLOAT16:
+ data = fp.read(2)
+ hibyte, lowbyte = struct.unpack_from("BB", data, 0)
+ exp = (hibyte >> 2) & 0x1F
+ mant = ((hibyte & 0x03) << 8) | lowbyte
+ if exp == 0:
+ val = mant * (2.0 ** -24)
+ elif exp == 31:
+ val = float('Inf') if mant == 0 else float('NaN')
+ else:
+ val = (mant + 1024.0) * (2 ** (exp - 25))
+ if hibyte & 0x80:
+ val = -1.0 * val
+ return (val, 3)
+ elif tb == CBOR_FLOAT32:
+ data = fp.read(4)
+ pf = struct.unpack_from("!f", data, 0)
+ return (pf[0], 5)
+ elif tb == CBOR_FLOAT64:
+ data = fp.read(8)
+ pf = struct.unpack_from("!d", data, 0)
+ return (pf[0], 9)
+
+ tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb)
+
+ if tag == CBOR_UINT:
+ return (aux, bytes_read)
+ elif tag == CBOR_NEGINT:
+ return (-1 - aux, bytes_read)
+ elif tag == CBOR_BYTES:
+ ob, subpos = loads_bytes(fp, aux)
+ return (ob, bytes_read + subpos)
+ elif tag == CBOR_TEXT:
+ raw, subpos = loads_bytes(fp, aux, btag=CBOR_TEXT)
+ ob = raw.decode('utf8')
+ return (ob, bytes_read + subpos)
+ elif tag == CBOR_ARRAY:
+ if aux is None:
+ return _loads_var_array(fp, limit, depth, returntags, bytes_read)
+ return _loads_array(fp, limit, depth, returntags, aux, bytes_read)
+ elif tag == CBOR_MAP:
+ if aux is None:
+ return _loads_var_map(fp, limit, depth, returntags, bytes_read)
+ return _loads_map(fp, limit, depth, returntags, aux, bytes_read)
+ elif tag == CBOR_TAG:
+ ob, subpos = _loads(fp, limit, depth + 1, returntags)
+ bytes_read += subpos
+ if returntags:
+ ob = Tag(aux, ob)
+ else:
+ ob = tagify(ob, aux)
+ return ob, bytes_read
+ elif tag == CBOR_7:
+ if tb == CBOR_TRUE:
+ return (True, bytes_read)
+ if tb == CBOR_FALSE:
+ return (False, bytes_read)
+ if tb == CBOR_NULL:
+ return (None, bytes_read)
+ if tb == CBOR_UNDEFINED:
+ return (None, bytes_read)
+ raise ValueError("unknown cbor tag 7 byte: {:02x}".format(tb))
+
+
+def loads_bytes(fp, aux, btag=CBOR_BYTES):
+ if aux is not None:
+ ob = fp.read(aux)
+ return (ob, aux)
+ chunklist = []
+ total_bytes_read = 0
+ while True:
+ tb = fp.read(1)[0]
+ if tb == CBOR_BREAK:
+ total_bytes_read += 1
+ break
+ tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb)
+ assert tag == btag, 'variable length value contains unexpected component'
+ ob = fp.read(aux)
+ chunklist.append(ob)
+ total_bytes_read += bytes_read + aux
+ return (b''.join(chunklist), total_bytes_read)
+
+
+def _bytes_to_biguint(bs):
+ out = 0
+ for ch in bs:
+ out = out << 8
+ out = out | ch
+ return out
+
+
+def tagify(ob, aux):
+ if aux == CBOR_TAG_DATE_STRING:
+ # RFC3339 date string parsing not implemented; return as Tag.
+ return Tag(aux, ob)
+ if aux == CBOR_TAG_DATE_ARRAY:
+ return datetime.datetime.fromtimestamp(ob, tz=datetime.timezone.utc)
+ if aux == CBOR_TAG_BIGNUM:
+ return _bytes_to_biguint(ob)
+ if aux == CBOR_TAG_NEGBIGNUM:
+ return -1 - _bytes_to_biguint(ob)
+ if aux == CBOR_TAG_REGEX:
+ return re.compile(ob)
+ return Tag(aux, ob)
+
+
+def encode(obj, sort_keys=False):
+ return dumps(obj, sort_keys=sort_keys)
+
+
+def decode(data):
+ return loads(data)
diff --git a/nomadnet/vendor/configobj.py b/nomadnet/vendor/configobj.py
deleted file mode 100644
index 58eb7d0..0000000
--- a/nomadnet/vendor/configobj.py
+++ /dev/null
@@ -1,2483 +0,0 @@
-# configobj.py
-# A config file reader/writer that supports nested sections in config files.
-# Copyright (C) 2005-2014:
-# (name) : (email)
-# Michael Foord: fuzzyman AT voidspace DOT org DOT uk
-# Nicola Larosa: nico AT tekNico DOT net
-# Rob Dennis: rdennis AT gmail DOT com
-# Eli Courtwright: eli AT courtwright DOT org
-
-# This software is licensed under the terms of the BSD license.
-# http://opensource.org/licenses/BSD-3-Clause
-
-# ConfigObj 5 - main repository for documentation and issue tracking:
-# https://github.com/DiffSK/configobj
-
-import os
-import re
-import sys
-
-from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF16_BE, BOM_UTF16_LE
-
-import six
-__version__ = '5.0.6'
-
-# imported lazily to avoid startup performance hit if it isn't used
-compiler = None
-
-# A dictionary mapping BOM to
-# the encoding to decode with, and what to set the
-# encoding attribute to.
-BOMS = {
- BOM_UTF8: ('utf_8', None),
- BOM_UTF16_BE: ('utf16_be', 'utf_16'),
- BOM_UTF16_LE: ('utf16_le', 'utf_16'),
- BOM_UTF16: ('utf_16', 'utf_16'),
- }
-# All legal variants of the BOM codecs.
-# TODO: the list of aliases is not meant to be exhaustive, is there a
-# better way ?
-BOM_LIST = {
- 'utf_16': 'utf_16',
- 'u16': 'utf_16',
- 'utf16': 'utf_16',
- 'utf-16': 'utf_16',
- 'utf16_be': 'utf16_be',
- 'utf_16_be': 'utf16_be',
- 'utf-16be': 'utf16_be',
- 'utf16_le': 'utf16_le',
- 'utf_16_le': 'utf16_le',
- 'utf-16le': 'utf16_le',
- 'utf_8': 'utf_8',
- 'u8': 'utf_8',
- 'utf': 'utf_8',
- 'utf8': 'utf_8',
- 'utf-8': 'utf_8',
- }
-
-# Map of encodings to the BOM to write.
-BOM_SET = {
- 'utf_8': BOM_UTF8,
- 'utf_16': BOM_UTF16,
- 'utf16_be': BOM_UTF16_BE,
- 'utf16_le': BOM_UTF16_LE,
- None: BOM_UTF8
- }
-
-
-def match_utf8(encoding):
- return BOM_LIST.get(encoding.lower()) == 'utf_8'
-
-
-# Quote strings used for writing values
-squot = "'%s'"
-dquot = '"%s"'
-noquot = "%s"
-wspace_plus = ' \r\n\v\t\'"'
-tsquot = '"""%s"""'
-tdquot = "'''%s'''"
-
-# Sentinel for use in getattr calls to replace hasattr
-MISSING = object()
-
-__all__ = (
- 'DEFAULT_INDENT_TYPE',
- 'DEFAULT_INTERPOLATION',
- 'ConfigObjError',
- 'NestingError',
- 'ParseError',
- 'DuplicateError',
- 'ConfigspecError',
- 'ConfigObj',
- 'SimpleVal',
- 'InterpolationError',
- 'InterpolationLoopError',
- 'MissingInterpolationOption',
- 'RepeatSectionError',
- 'ReloadError',
- 'UnreprError',
- 'UnknownType',
- 'flatten_errors',
- 'get_extra_values'
-)
-
-DEFAULT_INTERPOLATION = 'configparser'
-DEFAULT_INDENT_TYPE = ' '
-MAX_INTERPOL_DEPTH = 10
-
-OPTION_DEFAULTS = {
- 'interpolation': True,
- 'raise_errors': False,
- 'list_values': True,
- 'create_empty': False,
- 'file_error': False,
- 'configspec': None,
- 'stringify': True,
- # option may be set to one of ('', ' ', '\t')
- 'indent_type': None,
- 'encoding': None,
- 'default_encoding': None,
- 'unrepr': False,
- 'write_empty_values': False,
-}
-
-# this could be replaced if six is used for compatibility, or there are no
-# more assertions about items being a string
-
-
-def getObj(s):
- global compiler
- if compiler is None:
- import compiler
- s = "a=" + s
- p = compiler.parse(s)
- return p.getChildren()[1].getChildren()[0].getChildren()[1]
-
-
-class UnknownType(Exception):
- pass
-
-
-class Builder(object):
-
- def build(self, o):
- if m is None:
- raise UnknownType(o.__class__.__name__)
- return m(o)
-
- def build_List(self, o):
- return list(map(self.build, o.getChildren()))
-
- def build_Const(self, o):
- return o.value
-
- def build_Dict(self, o):
- d = {}
- i = iter(map(self.build, o.getChildren()))
- for el in i:
- d[el] = next(i)
- return d
-
- def build_Tuple(self, o):
- return tuple(self.build_List(o))
-
- def build_Name(self, o):
- if o.name == 'None':
- return None
- if o.name == 'True':
- return True
- if o.name == 'False':
- return False
-
- # An undefined Name
- raise UnknownType('Undefined Name')
-
- def build_Add(self, o):
- real, imag = list(map(self.build_Const, o.getChildren()))
- try:
- real = float(real)
- except TypeError:
- raise UnknownType('Add')
- if not isinstance(imag, complex) or imag.real != 0.0:
- raise UnknownType('Add')
- return real+imag
-
- def build_Getattr(self, o):
- parent = self.build(o.expr)
- return getattr(parent, o.attrname)
-
- def build_UnarySub(self, o):
- return -self.build_Const(o.getChildren()[0])
-
- def build_UnaryAdd(self, o):
- return self.build_Const(o.getChildren()[0])
-
-
-_builder = Builder()
-
-
-def unrepr(s):
- if not s:
- return s
-
- # this is supposed to be safe
- import ast
- return ast.literal_eval(s)
-
-
-class ConfigObjError(SyntaxError):
- """
- This is the base class for all errors that ConfigObj raises.
- It is a subclass of SyntaxError.
- """
- def __init__(self, message='', line_number=None, line=''):
- self.line = line
- self.line_number = line_number
- SyntaxError.__init__(self, message)
-
-
-class NestingError(ConfigObjError):
- """
- This error indicates a level of nesting that doesn't match.
- """
-
-
-class ParseError(ConfigObjError):
- """
- This error indicates that a line is badly written.
- It is neither a valid ``key = value`` line,
- nor a valid section marker line.
- """
-
-
-class ReloadError(IOError):
- """
- A 'reload' operation failed.
- This exception is a subclass of ``IOError``.
- """
- def __init__(self):
- IOError.__init__(self, 'reload failed, filename is not set.')
-
-
-class DuplicateError(ConfigObjError):
- """
- The keyword or section specified already exists.
- """
-
-
-class ConfigspecError(ConfigObjError):
- """
- An error occured whilst parsing a configspec.
- """
-
-
-class InterpolationError(ConfigObjError):
- """Base class for the two interpolation errors."""
-
-
-class InterpolationLoopError(InterpolationError):
- """Maximum interpolation depth exceeded in string interpolation."""
-
- def __init__(self, option):
- InterpolationError.__init__(
- self,
- 'interpolation loop detected in value "%s".' % option)
-
-
-class RepeatSectionError(ConfigObjError):
- """
- This error indicates additional sections in a section with a
- ``__many__`` (repeated) section.
- """
-
-
-class MissingInterpolationOption(InterpolationError):
- """A value specified for interpolation was missing."""
- def __init__(self, option):
- msg = 'missing option "%s" in interpolation.' % option
- InterpolationError.__init__(self, msg)
-
-
-class UnreprError(ConfigObjError):
- """An error parsing in unrepr mode."""
-
-
-
-class InterpolationEngine(object):
- """
- A helper class to help perform string interpolation.
-
- This class is an abstract base class; its descendants perform
- the actual work.
- """
-
- # compiled regexp to use in self.interpolate()
- _KEYCRE = re.compile(r"%\(([^)]*)\)s")
- _cookie = '%'
-
- def __init__(self, section):
- # the Section instance that "owns" this engine
- self.section = section
-
-
- def interpolate(self, key, value):
- # short-cut
- if not self._cookie in value:
- return value
-
- def recursive_interpolate(key, value, section, backtrail):
- """The function that does the actual work.
-
- ``value``: the string we're trying to interpolate.
- ``section``: the section in which that string was found
- ``backtrail``: a dict to keep track of where we've been,
- to detect and prevent infinite recursion loops
-
- This is similar to a depth-first-search algorithm.
- """
- # Have we been here already?
- if (key, section.name) in backtrail:
- # Yes - infinite loop detected
- raise InterpolationLoopError(key)
- # Place a marker on our backtrail so we won't come back here again
- backtrail[(key, section.name)] = 1
-
- # Now start the actual work
- match = self._KEYCRE.search(value)
- while match:
- # The actual parsing of the match is implementation-dependent,
- # so delegate to our helper function
- k, v, s = self._parse_match(match)
- if k is None:
- # That's the signal that no further interpolation is needed
- replacement = v
- else:
- # Further interpolation may be needed to obtain final value
- replacement = recursive_interpolate(k, v, s, backtrail)
- # Replace the matched string with its final value
- start, end = match.span()
- value = ''.join((value[:start], replacement, value[end:]))
- new_search_start = start + len(replacement)
- # Pick up the next interpolation key, if any, for next time
- # through the while loop
- match = self._KEYCRE.search(value, new_search_start)
-
- # Now safe to come back here again; remove marker from backtrail
- del backtrail[(key, section.name)]
-
- return value
-
- # Back in interpolate(), all we have to do is kick off the recursive
- # function with appropriate starting values
- value = recursive_interpolate(key, value, self.section, {})
- return value
-
-
- def _fetch(self, key):
- """Helper function to fetch values from owning section.
-
- Returns a 2-tuple: the value, and the section where it was found.
- """
- # switch off interpolation before we try and fetch anything !
- save_interp = self.section.main.interpolation
- self.section.main.interpolation = False
-
- # Start at section that "owns" this InterpolationEngine
- current_section = self.section
- while True:
- # try the current section first
- val = current_section.get(key)
- if val is not None and not isinstance(val, Section):
- break
- # try "DEFAULT" next
- val = current_section.get('DEFAULT', {}).get(key)
- if val is not None and not isinstance(val, Section):
- break
- # move up to parent and try again
- # top-level's parent is itself
- if current_section.parent is current_section:
- # reached top level, time to give up
- break
- current_section = current_section.parent
-
- # restore interpolation to previous value before returning
- self.section.main.interpolation = save_interp
- if val is None:
- raise MissingInterpolationOption(key)
- return val, current_section
-
-
- def _parse_match(self, match):
- """Implementation-dependent helper function.
-
- Will be passed a match object corresponding to the interpolation
- key we just found (e.g., "%(foo)s" or "$foo"). Should look up that
- key in the appropriate config file section (using the ``_fetch()``
- helper function) and return a 3-tuple: (key, value, section)
-
- ``key`` is the name of the key we're looking for
- ``value`` is the value found for that key
- ``section`` is a reference to the section where it was found
-
- ``key`` and ``section`` should be None if no further
- interpolation should be performed on the resulting value
- (e.g., if we interpolated "$$" and returned "$").
- """
- raise NotImplementedError()
-
-
-
-class ConfigParserInterpolation(InterpolationEngine):
- """Behaves like ConfigParser."""
- _cookie = '%'
- _KEYCRE = re.compile(r"%\(([^)]*)\)s")
-
- def _parse_match(self, match):
- key = match.group(1)
- value, section = self._fetch(key)
- return key, value, section
-
-
-
-class TemplateInterpolation(InterpolationEngine):
- """Behaves like string.Template."""
- _cookie = '$'
- _delimiter = '$'
- _KEYCRE = re.compile(r"""
- \$(?:
- (?P\$) | # Two $ signs
- (?P[_a-z][_a-z0-9]*) | # $name format
- {(?P[^}]*)} # ${name} format
- )
- """, re.IGNORECASE | re.VERBOSE)
-
- def _parse_match(self, match):
- # Valid name (in or out of braces): fetch value from section
- key = match.group('named') or match.group('braced')
- if key is not None:
- value, section = self._fetch(key)
- return key, value, section
- # Escaped delimiter (e.g., $$): return single delimiter
- if match.group('escaped') is not None:
- # Return None for key and section to indicate it's time to stop
- return None, self._delimiter, None
- # Anything else: ignore completely, just return it unchanged
- return None, match.group(), None
-
-
-interpolation_engines = {
- 'configparser': ConfigParserInterpolation,
- 'template': TemplateInterpolation,
-}
-
-
-def __newobj__(cls, *args):
- # Hack for pickle
- return cls.__new__(cls, *args)
-
-class Section(dict):
- """
- A dictionary-like object that represents a section in a config file.
-
- It does string interpolation if the 'interpolation' attribute
- of the 'main' object is set to True.
-
- Interpolation is tried first from this object, then from the 'DEFAULT'
- section of this object, next from the parent and its 'DEFAULT' section,
- and so on until the main object is reached.
-
- A Section will behave like an ordered dictionary - following the
- order of the ``scalars`` and ``sections`` attributes.
- You can use this to change the order of members.
-
- Iteration follows the order: scalars, then sections.
- """
-
-
- def __setstate__(self, state):
- dict.update(self, state[0])
- self.__dict__.update(state[1])
-
- def __reduce__(self):
- state = (dict(self), self.__dict__)
- return (__newobj__, (self.__class__,), state)
-
-
- def __init__(self, parent, depth, main, indict=None, name=None):
- """
- * parent is the section above
- * depth is the depth level of this section
- * main is the main ConfigObj
- * indict is a dictionary to initialise the section with
- """
- if indict is None:
- indict = {}
- dict.__init__(self)
- # used for nesting level *and* interpolation
- self.parent = parent
- # used for the interpolation attribute
- self.main = main
- # level of nesting depth of this Section
- self.depth = depth
- # purely for information
- self.name = name
- #
- self._initialise()
- # we do this explicitly so that __setitem__ is used properly
- # (rather than just passing to ``dict.__init__``)
- for entry, value in indict.items():
- self[entry] = value
-
-
- def _initialise(self):
- # the sequence of scalar values in this Section
- self.scalars = []
- # the sequence of sections in this Section
- self.sections = []
- # for comments :-)
- self.comments = {}
- self.inline_comments = {}
- # the configspec
- self.configspec = None
- # for defaults
- self.defaults = []
- self.default_values = {}
- self.extra_values = []
- self._created = False
-
-
- def _interpolate(self, key, value):
- try:
- # do we already have an interpolation engine?
- engine = self._interpolation_engine
- except AttributeError:
- # not yet: first time running _interpolate(), so pick the engine
- name = self.main.interpolation
- if name == True: # note that "if name:" would be incorrect here
- # backwards-compatibility: interpolation=True means use default
- name = DEFAULT_INTERPOLATION
- name = name.lower() # so that "Template", "template", etc. all work
- class_ = interpolation_engines.get(name, None)
- if class_ is None:
- # invalid value for self.main.interpolation
- self.main.interpolation = False
- return value
- else:
- # save reference to engine so we don't have to do this again
- engine = self._interpolation_engine = class_(self)
- # let the engine do the actual work
- return engine.interpolate(key, value)
-
-
- def __getitem__(self, key):
- """Fetch the item and do string interpolation."""
- val = dict.__getitem__(self, key)
- if self.main.interpolation:
- if isinstance(val, six.string_types):
- return self._interpolate(key, val)
- if isinstance(val, list):
- def _check(entry):
- if isinstance(entry, six.string_types):
- return self._interpolate(key, entry)
- return entry
- new = [_check(entry) for entry in val]
- if new != val:
- return new
- return val
-
-
- def __setitem__(self, key, value, unrepr=False):
- """
- Correctly set a value.
-
- Making dictionary values Section instances.
- (We have to special case 'Section' instances - which are also dicts)
-
- Keys must be strings.
- Values need only be strings (or lists of strings) if
- ``main.stringify`` is set.
-
- ``unrepr`` must be set when setting a value to a dictionary, without
- creating a new sub-section.
- """
- if not isinstance(key, six.string_types):
- raise ValueError('The key "%s" is not a string.' % key)
-
- # add the comment
- if key not in self.comments:
- self.comments[key] = []
- self.inline_comments[key] = ''
- # remove the entry from defaults
- if key in self.defaults:
- self.defaults.remove(key)
- #
- if isinstance(value, Section):
- if key not in self:
- self.sections.append(key)
- dict.__setitem__(self, key, value)
- elif isinstance(value, dict) and not unrepr:
- # First create the new depth level,
- # then create the section
- if key not in self:
- self.sections.append(key)
- new_depth = self.depth + 1
- dict.__setitem__(
- self,
- key,
- Section(
- self,
- new_depth,
- self.main,
- indict=value,
- name=key))
- else:
- if key not in self:
- self.scalars.append(key)
- if not self.main.stringify:
- if isinstance(value, six.string_types):
- pass
- elif isinstance(value, (list, tuple)):
- for entry in value:
- if not isinstance(entry, six.string_types):
- raise TypeError('Value is not a string "%s".' % entry)
- else:
- raise TypeError('Value is not a string "%s".' % value)
- dict.__setitem__(self, key, value)
-
-
- def __delitem__(self, key):
- """Remove items from the sequence when deleting."""
- dict. __delitem__(self, key)
- if key in self.scalars:
- self.scalars.remove(key)
- else:
- self.sections.remove(key)
- del self.comments[key]
- del self.inline_comments[key]
-
-
- def get(self, key, default=None):
- """A version of ``get`` that doesn't bypass string interpolation."""
- try:
- return self[key]
- except KeyError:
- return default
-
-
- def update(self, indict):
- """
- A version of update that uses our ``__setitem__``.
- """
- for entry in indict:
- self[entry] = indict[entry]
-
-
- def pop(self, key, default=MISSING):
- """
- 'D.pop(k[,d]) -> v, remove specified key and return the corresponding value.
- If key is not found, d is returned if given, otherwise KeyError is raised'
- """
- try:
- val = self[key]
- except KeyError:
- if default is MISSING:
- raise
- val = default
- else:
- del self[key]
- return val
-
-
- def popitem(self):
- """Pops the first (key,val)"""
- sequence = (self.scalars + self.sections)
- if not sequence:
- raise KeyError(": 'popitem(): dictionary is empty'")
- key = sequence[0]
- val = self[key]
- del self[key]
- return key, val
-
-
- def clear(self):
- """
- A version of clear that also affects scalars/sections
- Also clears comments and configspec.
-
- Leaves other attributes alone :
- depth/main/parent are not affected
- """
- dict.clear(self)
- self.scalars = []
- self.sections = []
- self.comments = {}
- self.inline_comments = {}
- self.configspec = None
- self.defaults = []
- self.extra_values = []
-
-
- def setdefault(self, key, default=None):
- """A version of setdefault that sets sequence if appropriate."""
- try:
- return self[key]
- except KeyError:
- self[key] = default
- return self[key]
-
-
- def items(self):
- """D.items() -> list of D's (key, value) pairs, as 2-tuples"""
- return list(zip((self.scalars + self.sections), list(self.values())))
-
-
- def keys(self):
- """D.keys() -> list of D's keys"""
- return (self.scalars + self.sections)
-
-
- def values(self):
- """D.values() -> list of D's values"""
- return [self[key] for key in (self.scalars + self.sections)]
-
-
- def iteritems(self):
- """D.iteritems() -> an iterator over the (key, value) items of D"""
- return iter(list(self.items()))
-
-
- def iterkeys(self):
- """D.iterkeys() -> an iterator over the keys of D"""
- return iter((self.scalars + self.sections))
-
- __iter__ = iterkeys
-
-
- def itervalues(self):
- """D.itervalues() -> an iterator over the values of D"""
- return iter(list(self.values()))
-
-
- def __repr__(self):
- """x.__repr__() <==> repr(x)"""
- def _getval(key):
- try:
- return self[key]
- except MissingInterpolationOption:
- return dict.__getitem__(self, key)
- return '{%s}' % ', '.join([('%s: %s' % (repr(key), repr(_getval(key))))
- for key in (self.scalars + self.sections)])
-
- __str__ = __repr__
- __str__.__doc__ = "x.__str__() <==> str(x)"
-
-
- # Extra methods - not in a normal dictionary
-
- def dict(self):
- """
- Return a deepcopy of self as a dictionary.
-
- All members that are ``Section`` instances are recursively turned to
- ordinary dictionaries - by calling their ``dict`` method.
-
- >>> n = a.dict()
- >>> n == a
- 1
- >>> n is a
- 0
- """
- newdict = {}
- for entry in self:
- this_entry = self[entry]
- if isinstance(this_entry, Section):
- this_entry = this_entry.dict()
- elif isinstance(this_entry, list):
- # create a copy rather than a reference
- this_entry = list(this_entry)
- elif isinstance(this_entry, tuple):
- # create a copy rather than a reference
- this_entry = tuple(this_entry)
- newdict[entry] = this_entry
- return newdict
-
-
- def merge(self, indict):
- """
- A recursive update - useful for merging config files.
-
- >>> a = '''[section1]
- ... option1 = True
- ... [[subsection]]
- ... more_options = False
- ... # end of file'''.splitlines()
- >>> b = '''# File is user.ini
- ... [section1]
- ... option1 = False
- ... # end of file'''.splitlines()
- >>> c1 = ConfigObj(b)
- >>> c2 = ConfigObj(a)
- >>> c2.merge(c1)
- >>> c2
- ConfigObj({'section1': {'option1': 'False', 'subsection': {'more_options': 'False'}}})
- """
- for key, val in list(indict.items()):
- if (key in self and isinstance(self[key], dict) and
- isinstance(val, dict)):
- self[key].merge(val)
- else:
- self[key] = val
-
-
- def rename(self, oldkey, newkey):
- """
- Change a keyname to another, without changing position in sequence.
-
- Implemented so that transformations can be made on keys,
- as well as on values. (used by encode and decode)
-
- Also renames comments.
- """
- if oldkey in self.scalars:
- the_list = self.scalars
- elif oldkey in self.sections:
- the_list = self.sections
- else:
- raise KeyError('Key "%s" not found.' % oldkey)
- pos = the_list.index(oldkey)
- #
- val = self[oldkey]
- dict.__delitem__(self, oldkey)
- dict.__setitem__(self, newkey, val)
- the_list.remove(oldkey)
- the_list.insert(pos, newkey)
- comm = self.comments[oldkey]
- inline_comment = self.inline_comments[oldkey]
- del self.comments[oldkey]
- del self.inline_comments[oldkey]
- self.comments[newkey] = comm
- self.inline_comments[newkey] = inline_comment
-
-
- def walk(self, function, raise_errors=True,
- call_on_sections=False, **keywargs):
- """
- Walk every member and call a function on the keyword and value.
-
- Return a dictionary of the return values
-
- If the function raises an exception, raise the errror
- unless ``raise_errors=False``, in which case set the return value to
- ``False``.
-
- Any unrecognised keyword arguments you pass to walk, will be pased on
- to the function you pass in.
-
- Note: if ``call_on_sections`` is ``True`` then - on encountering a
- subsection, *first* the function is called for the *whole* subsection,
- and then recurses into it's members. This means your function must be
- able to handle strings, dictionaries and lists. This allows you
- to change the key of subsections as well as for ordinary members. The
- return value when called on the whole subsection has to be discarded.
-
- See the encode and decode methods for examples, including functions.
-
- .. admonition:: caution
-
- You can use ``walk`` to transform the names of members of a section
- but you mustn't add or delete members.
-
- >>> config = '''[XXXXsection]
- ... XXXXkey = XXXXvalue'''.splitlines()
- >>> cfg = ConfigObj(config)
- >>> cfg
- ConfigObj({'XXXXsection': {'XXXXkey': 'XXXXvalue'}})
- >>> def transform(section, key):
- ... val = section[key]
- ... newkey = key.replace('XXXX', 'CLIENT1')
- ... section.rename(key, newkey)
- ... if isinstance(val, (tuple, list, dict)):
- ... pass
- ... else:
- ... val = val.replace('XXXX', 'CLIENT1')
- ... section[newkey] = val
- >>> cfg.walk(transform, call_on_sections=True)
- {'CLIENT1section': {'CLIENT1key': None}}
- >>> cfg
- ConfigObj({'CLIENT1section': {'CLIENT1key': 'CLIENT1value'}})
- """
- out = {}
- # scalars first
- for i in range(len(self.scalars)):
- entry = self.scalars[i]
- try:
- val = function(self, entry, **keywargs)
- # bound again in case name has changed
- entry = self.scalars[i]
- out[entry] = val
- except Exception:
- if raise_errors:
- raise
- else:
- entry = self.scalars[i]
- out[entry] = False
- # then sections
- for i in range(len(self.sections)):
- entry = self.sections[i]
- if call_on_sections:
- try:
- function(self, entry, **keywargs)
- except Exception:
- if raise_errors:
- raise
- else:
- entry = self.sections[i]
- out[entry] = False
- # bound again in case name has changed
- entry = self.sections[i]
- # previous result is discarded
- out[entry] = self[entry].walk(
- function,
- raise_errors=raise_errors,
- call_on_sections=call_on_sections,
- **keywargs)
- return out
-
-
- def as_bool(self, key):
- """
- Accepts a key as input. The corresponding value must be a string or
- the objects (``True`` or 1) or (``False`` or 0). We allow 0 and 1 to
- retain compatibility with Python 2.2.
-
- If the string is one of ``True``, ``On``, ``Yes``, or ``1`` it returns
- ``True``.
-
- If the string is one of ``False``, ``Off``, ``No``, or ``0`` it returns
- ``False``.
-
- ``as_bool`` is not case sensitive.
-
- Any other input will raise a ``ValueError``.
-
- >>> a = ConfigObj()
- >>> a['a'] = 'fish'
- >>> a.as_bool('a')
- Traceback (most recent call last):
- ValueError: Value "fish" is neither True nor False
- >>> a['b'] = 'True'
- >>> a.as_bool('b')
- 1
- >>> a['b'] = 'off'
- >>> a.as_bool('b')
- 0
- """
- val = self[key]
- if val == True:
- return True
- elif val == False:
- return False
- else:
- try:
- if not isinstance(val, six.string_types):
- # TODO: Why do we raise a KeyError here?
- raise KeyError()
- else:
- return self.main._bools[val.lower()]
- except KeyError:
- raise ValueError('Value "%s" is neither True nor False' % val)
-
-
- def as_int(self, key):
- """
- A convenience method which coerces the specified value to an integer.
-
- If the value is an invalid literal for ``int``, a ``ValueError`` will
- be raised.
-
- >>> a = ConfigObj()
- >>> a['a'] = 'fish'
- >>> a.as_int('a')
- Traceback (most recent call last):
- ValueError: invalid literal for int() with base 10: 'fish'
- >>> a['b'] = '1'
- >>> a.as_int('b')
- 1
- >>> a['b'] = '3.2'
- >>> a.as_int('b')
- Traceback (most recent call last):
- ValueError: invalid literal for int() with base 10: '3.2'
- """
- return int(self[key])
-
-
- def as_float(self, key):
- """
- A convenience method which coerces the specified value to a float.
-
- If the value is an invalid literal for ``float``, a ``ValueError`` will
- be raised.
-
- >>> a = ConfigObj()
- >>> a['a'] = 'fish'
- >>> a.as_float('a') #doctest: +IGNORE_EXCEPTION_DETAIL
- Traceback (most recent call last):
- ValueError: invalid literal for float(): fish
- >>> a['b'] = '1'
- >>> a.as_float('b')
- 1.0
- >>> a['b'] = '3.2'
- >>> a.as_float('b') #doctest: +ELLIPSIS
- 3.2...
- """
- return float(self[key])
-
-
- def as_list(self, key):
- """
- A convenience method which fetches the specified value, guaranteeing
- that it is a list.
-
- >>> a = ConfigObj()
- >>> a['a'] = 1
- >>> a.as_list('a')
- [1]
- >>> a['a'] = (1,)
- >>> a.as_list('a')
- [1]
- >>> a['a'] = [1]
- >>> a.as_list('a')
- [1]
- """
- result = self[key]
- if isinstance(result, (tuple, list)):
- return list(result)
- return [result]
-
-
- def restore_default(self, key):
- """
- Restore (and return) default value for the specified key.
-
- This method will only work for a ConfigObj that was created
- with a configspec and has been validated.
-
- If there is no default value for this key, ``KeyError`` is raised.
- """
- default = self.default_values[key]
- dict.__setitem__(self, key, default)
- if key not in self.defaults:
- self.defaults.append(key)
- return default
-
-
- def restore_defaults(self):
- """
- Recursively restore default values to all members
- that have them.
-
- This method will only work for a ConfigObj that was created
- with a configspec and has been validated.
-
- It doesn't delete or modify entries without default values.
- """
- for key in self.default_values:
- self.restore_default(key)
-
- for section in self.sections:
- self[section].restore_defaults()
-
-
-class ConfigObj(Section):
- """An object to read, create, and write config files."""
-
- _keyword = re.compile(r'''^ # line start
- (\s*) # indentation
- ( # keyword
- (?:".*?")| # double quotes
- (?:'.*?')| # single quotes
- (?:[^'"=].*?) # no quotes
- )
- \s*=\s* # divider
- (.*) # value (including list values and comments)
- $ # line end
- ''',
- re.VERBOSE)
-
- _sectionmarker = re.compile(r'''^
- (\s*) # 1: indentation
- ((?:\[\s*)+) # 2: section marker open
- ( # 3: section name open
- (?:"\s*\S.*?\s*")| # at least one non-space with double quotes
- (?:'\s*\S.*?\s*')| # at least one non-space with single quotes
- (?:[^'"\s].*?) # at least one non-space unquoted
- ) # section name close
- ((?:\s*\])+) # 4: section marker close
- \s*(\#.*)? # 5: optional comment
- $''',
- re.VERBOSE)
-
- # this regexp pulls list values out as a single string
- # or single values and comments
- # FIXME: this regex adds a '' to the end of comma terminated lists
- # workaround in ``_handle_value``
- _valueexp = re.compile(r'''^
- (?:
- (?:
- (
- (?:
- (?:
- (?:".*?")| # double quotes
- (?:'.*?')| # single quotes
- (?:[^'",\#][^,\#]*?) # unquoted
- )
- \s*,\s* # comma
- )* # match all list items ending in a comma (if any)
- )
- (
- (?:".*?")| # double quotes
- (?:'.*?')| # single quotes
- (?:[^'",\#\s][^,]*?)| # unquoted
- (?:(? 1:
- msg = "Parsing failed with several errors.\nFirst error %s" % info
- error = ConfigObjError(msg)
- else:
- error = self._errors[0]
- # set the errors attribute; it's a list of tuples:
- # (error_type, message, line_number)
- error.errors = self._errors
- # set the config attribute
- error.config = self
- raise error
- # delete private attributes
- del self._errors
-
- if configspec is None:
- self.configspec = None
- else:
- self._handle_configspec(configspec)
-
-
- def _initialise(self, options=None):
- if options is None:
- options = OPTION_DEFAULTS
-
- # initialise a few variables
- self.filename = None
- self._errors = []
- self.raise_errors = options['raise_errors']
- self.interpolation = options['interpolation']
- self.list_values = options['list_values']
- self.create_empty = options['create_empty']
- self.file_error = options['file_error']
- self.stringify = options['stringify']
- self.indent_type = options['indent_type']
- self.encoding = options['encoding']
- self.default_encoding = options['default_encoding']
- self.BOM = False
- self.newlines = None
- self.write_empty_values = options['write_empty_values']
- self.unrepr = options['unrepr']
-
- self.initial_comment = []
- self.final_comment = []
- self.configspec = None
-
- if self._inspec:
- self.list_values = False
-
- # Clear section attributes as well
- Section._initialise(self)
-
-
- def __repr__(self):
- def _getval(key):
- try:
- return self[key]
- except MissingInterpolationOption:
- return dict.__getitem__(self, key)
- return ('ConfigObj({%s})' %
- ', '.join([('%s: %s' % (repr(key), repr(_getval(key))))
- for key in (self.scalars + self.sections)]))
-
-
- def _handle_bom(self, infile):
- """
- Handle any BOM, and decode if necessary.
-
- If an encoding is specified, that *must* be used - but the BOM should
- still be removed (and the BOM attribute set).
-
- (If the encoding is wrongly specified, then a BOM for an alternative
- encoding won't be discovered or removed.)
-
- If an encoding is not specified, UTF8 or UTF16 BOM will be detected and
- removed. The BOM attribute will be set. UTF16 will be decoded to
- unicode.
-
- NOTE: This method must not be called with an empty ``infile``.
-
- Specifying the *wrong* encoding is likely to cause a
- ``UnicodeDecodeError``.
-
- ``infile`` must always be returned as a list of lines, but may be
- passed in as a single string.
- """
-
- if ((self.encoding is not None) and
- (self.encoding.lower() not in BOM_LIST)):
- # No need to check for a BOM
- # the encoding specified doesn't have one
- # just decode
- return self._decode(infile, self.encoding)
-
- if isinstance(infile, (list, tuple)):
- line = infile[0]
- else:
- line = infile
-
- if isinstance(line, six.text_type):
- # it's already decoded and there's no need to do anything
- # else, just use the _decode utility method to handle
- # listifying appropriately
- return self._decode(infile, self.encoding)
-
- if self.encoding is not None:
- # encoding explicitly supplied
- # And it could have an associated BOM
- # TODO: if encoding is just UTF16 - we ought to check for both
- # TODO: big endian and little endian versions.
- enc = BOM_LIST[self.encoding.lower()]
- if enc == 'utf_16':
- # For UTF16 we try big endian and little endian
- for BOM, (encoding, final_encoding) in list(BOMS.items()):
- if not final_encoding:
- # skip UTF8
- continue
- if infile.startswith(BOM):
- ### BOM discovered
- ##self.BOM = True
- # Don't need to remove BOM
- return self._decode(infile, encoding)
-
- # If we get this far, will *probably* raise a DecodeError
- # As it doesn't appear to start with a BOM
- return self._decode(infile, self.encoding)
-
- # Must be UTF8
- BOM = BOM_SET[enc]
- if not line.startswith(BOM):
- return self._decode(infile, self.encoding)
-
- newline = line[len(BOM):]
-
- # BOM removed
- if isinstance(infile, (list, tuple)):
- infile[0] = newline
- else:
- infile = newline
- self.BOM = True
- return self._decode(infile, self.encoding)
-
- # No encoding specified - so we need to check for UTF8/UTF16
- for BOM, (encoding, final_encoding) in list(BOMS.items()):
- if not isinstance(line, six.binary_type) or not line.startswith(BOM):
- # didn't specify a BOM, or it's not a bytestring
- continue
- else:
- # BOM discovered
- self.encoding = final_encoding
- if not final_encoding:
- self.BOM = True
- # UTF8
- # remove BOM
- newline = line[len(BOM):]
- if isinstance(infile, (list, tuple)):
- infile[0] = newline
- else:
- infile = newline
- # UTF-8
- if isinstance(infile, six.text_type):
- return infile.splitlines(True)
- elif isinstance(infile, six.binary_type):
- return infile.decode('utf-8').splitlines(True)
- else:
- return self._decode(infile, 'utf-8')
- # UTF16 - have to decode
- return self._decode(infile, encoding)
-
-
- if six.PY2 and isinstance(line, str):
- # don't actually do any decoding, since we're on python 2 and
- # returning a bytestring is fine
- return self._decode(infile, None)
- # No BOM discovered and no encoding specified, default to UTF-8
- if isinstance(infile, six.binary_type):
- return infile.decode('utf-8').splitlines(True)
- else:
- return self._decode(infile, 'utf-8')
-
-
- def _a_to_u(self, aString):
- """Decode ASCII strings to unicode if a self.encoding is specified."""
- if isinstance(aString, six.binary_type) and self.encoding:
- return aString.decode(self.encoding)
- else:
- return aString
-
-
- def _decode(self, infile, encoding):
- """
- Decode infile to unicode. Using the specified encoding.
-
- if is a string, it also needs converting to a list.
- """
- if isinstance(infile, six.string_types):
- return infile.splitlines(True)
- if isinstance(infile, six.binary_type):
- # NOTE: Could raise a ``UnicodeDecodeError``
- if encoding:
- return infile.decode(encoding).splitlines(True)
- else:
- return infile.splitlines(True)
-
- if encoding:
- for i, line in enumerate(infile):
- if isinstance(line, six.binary_type):
- # NOTE: The isinstance test here handles mixed lists of unicode/string
- # NOTE: But the decode will break on any non-string values
- # NOTE: Or could raise a ``UnicodeDecodeError``
- infile[i] = line.decode(encoding)
- return infile
-
-
- def _decode_element(self, line):
- """Decode element to unicode if necessary."""
- if isinstance(line, six.binary_type) and self.default_encoding:
- return line.decode(self.default_encoding)
- else:
- return line
-
-
- # TODO: this may need to be modified
- def _str(self, value):
- """
- Used by ``stringify`` within validate, to turn non-string values
- into strings.
- """
- if not isinstance(value, six.string_types):
- # intentially 'str' because it's just whatever the "normal"
- # string type is for the python version we're dealing with
- return str(value)
- else:
- return value
-
-
- def _parse(self, infile):
- """Actually parse the config file."""
- temp_list_values = self.list_values
- if self.unrepr:
- self.list_values = False
-
- comment_list = []
- done_start = False
- this_section = self
- maxline = len(infile) - 1
- cur_index = -1
- reset_comment = False
-
- while cur_index < maxline:
- if reset_comment:
- comment_list = []
- cur_index += 1
- line = infile[cur_index]
- sline = line.strip()
- # do we have anything on the line ?
- if not sline or sline.startswith('#'):
- reset_comment = False
- comment_list.append(line)
- continue
-
- if not done_start:
- # preserve initial comment
- self.initial_comment = comment_list
- comment_list = []
- done_start = True
-
- reset_comment = True
- # first we check if it's a section marker
- mat = self._sectionmarker.match(line)
- if mat is not None:
- # is a section line
- (indent, sect_open, sect_name, sect_close, comment) = mat.groups()
- if indent and (self.indent_type is None):
- self.indent_type = indent
- cur_depth = sect_open.count('[')
- if cur_depth != sect_close.count(']'):
- self._handle_error("Cannot compute the section depth",
- NestingError, infile, cur_index)
- continue
-
- if cur_depth < this_section.depth:
- # the new section is dropping back to a previous level
- try:
- parent = self._match_depth(this_section,
- cur_depth).parent
- except SyntaxError:
- self._handle_error("Cannot compute nesting level",
- NestingError, infile, cur_index)
- continue
- elif cur_depth == this_section.depth:
- # the new section is a sibling of the current section
- parent = this_section.parent
- elif cur_depth == this_section.depth + 1:
- # the new section is a child the current section
- parent = this_section
- else:
- self._handle_error("Section too nested",
- NestingError, infile, cur_index)
- continue
-
- sect_name = self._unquote(sect_name)
- if sect_name in parent:
- self._handle_error('Duplicate section name',
- DuplicateError, infile, cur_index)
- continue
-
- # create the new section
- this_section = Section(
- parent,
- cur_depth,
- self,
- name=sect_name)
- parent[sect_name] = this_section
- parent.inline_comments[sect_name] = comment
- parent.comments[sect_name] = comment_list
- continue
- #
- # it's not a section marker,
- # so it should be a valid ``key = value`` line
- mat = self._keyword.match(line)
- if mat is None:
- self._handle_error(
- 'Invalid line ({0!r}) (matched as neither section nor keyword)'.format(line),
- ParseError, infile, cur_index)
- else:
- # is a keyword value
- # value will include any inline comment
- (indent, key, value) = mat.groups()
- if indent and (self.indent_type is None):
- self.indent_type = indent
- # check for a multiline value
- if value[:3] in ['"""', "'''"]:
- try:
- value, comment, cur_index = self._multiline(
- value, infile, cur_index, maxline)
- except SyntaxError:
- self._handle_error(
- 'Parse error in multiline value',
- ParseError, infile, cur_index)
- continue
- else:
- if self.unrepr:
- comment = ''
- try:
- value = unrepr(value)
- except Exception as e:
- if type(e) == UnknownType:
- msg = 'Unknown name or type in value'
- else:
- msg = 'Parse error from unrepr-ing multiline value'
- self._handle_error(msg, UnreprError, infile,
- cur_index)
- continue
- else:
- if self.unrepr:
- comment = ''
- try:
- value = unrepr(value)
- except Exception as e:
- if isinstance(e, UnknownType):
- msg = 'Unknown name or type in value'
- else:
- msg = 'Parse error from unrepr-ing value'
- self._handle_error(msg, UnreprError, infile,
- cur_index)
- continue
- else:
- # extract comment and lists
- try:
- (value, comment) = self._handle_value(value)
- except SyntaxError:
- self._handle_error(
- 'Parse error in value',
- ParseError, infile, cur_index)
- continue
- #
- key = self._unquote(key)
- if key in this_section:
- self._handle_error(
- 'Duplicate keyword name',
- DuplicateError, infile, cur_index)
- continue
- # add the key.
- # we set unrepr because if we have got this far we will never
- # be creating a new section
- this_section.__setitem__(key, value, unrepr=True)
- this_section.inline_comments[key] = comment
- this_section.comments[key] = comment_list
- continue
- #
- if self.indent_type is None:
- # no indentation used, set the type accordingly
- self.indent_type = ''
-
- # preserve the final comment
- if not self and not self.initial_comment:
- self.initial_comment = comment_list
- elif not reset_comment:
- self.final_comment = comment_list
- self.list_values = temp_list_values
-
-
- def _match_depth(self, sect, depth):
- """
- Given a section and a depth level, walk back through the sections
- parents to see if the depth level matches a previous section.
-
- Return a reference to the right section,
- or raise a SyntaxError.
- """
- while depth < sect.depth:
- if sect is sect.parent:
- # we've reached the top level already
- raise SyntaxError()
- sect = sect.parent
- if sect.depth == depth:
- return sect
- # shouldn't get here
- raise SyntaxError()
-
-
- def _handle_error(self, text, ErrorClass, infile, cur_index):
- """
- Handle an error according to the error settings.
-
- Either raise the error or store it.
- The error will have occured at ``cur_index``
- """
- line = infile[cur_index]
- cur_index += 1
- message = '{0} at line {1}.'.format(text, cur_index)
- error = ErrorClass(message, cur_index, line)
- if self.raise_errors:
- # raise the error - parsing stops here
- raise error
- # store the error
- # reraise when parsing has finished
- self._errors.append(error)
-
-
- def _unquote(self, value):
- """Return an unquoted version of a value"""
- if not value:
- # should only happen during parsing of lists
- raise SyntaxError
- if (value[0] == value[-1]) and (value[0] in ('"', "'")):
- value = value[1:-1]
- return value
-
-
- def _quote(self, value, multiline=True):
- """
- Return a safely quoted version of a value.
-
- Raise a ConfigObjError if the value cannot be safely quoted.
- If multiline is ``True`` (default) then use triple quotes
- if necessary.
-
- * Don't quote values that don't need it.
- * Recursively quote members of a list and return a comma joined list.
- * Multiline is ``False`` for lists.
- * Obey list syntax for empty and single member lists.
-
- If ``list_values=False`` then the value is only quoted if it contains
- a ``\\n`` (is multiline) or '#'.
-
- If ``write_empty_values`` is set, and the value is an empty string, it
- won't be quoted.
- """
- if multiline and self.write_empty_values and value == '':
- # Only if multiline is set, so that it is used for values not
- # keys, and not values that are part of a list
- return ''
-
- if multiline and isinstance(value, (list, tuple)):
- if not value:
- return ','
- elif len(value) == 1:
- return self._quote(value[0], multiline=False) + ','
- return ', '.join([self._quote(val, multiline=False)
- for val in value])
- if not isinstance(value, six.string_types):
- if self.stringify:
- # intentially 'str' because it's just whatever the "normal"
- # string type is for the python version we're dealing with
- value = str(value)
- else:
- raise TypeError('Value "%s" is not a string.' % value)
-
- if not value:
- return '""'
-
- no_lists_no_quotes = not self.list_values and '\n' not in value and '#' not in value
- need_triple = multiline and ((("'" in value) and ('"' in value)) or ('\n' in value ))
- hash_triple_quote = multiline and not need_triple and ("'" in value) and ('"' in value) and ('#' in value)
- check_for_single = (no_lists_no_quotes or not need_triple) and not hash_triple_quote
-
- if check_for_single:
- if not self.list_values:
- # we don't quote if ``list_values=False``
- quot = noquot
- # for normal values either single or double quotes will do
- elif '\n' in value:
- # will only happen if multiline is off - e.g. '\n' in key
- raise ConfigObjError('Value "%s" cannot be safely quoted.' % value)
- elif ((value[0] not in wspace_plus) and
- (value[-1] not in wspace_plus) and
- (',' not in value)):
- quot = noquot
- else:
- quot = self._get_single_quote(value)
- else:
- # if value has '\n' or "'" *and* '"', it will need triple quotes
- quot = self._get_triple_quote(value)
-
- if quot == noquot and '#' in value and self.list_values:
- quot = self._get_single_quote(value)
-
- return quot % value
-
-
- def _get_single_quote(self, value):
- if ("'" in value) and ('"' in value):
- raise ConfigObjError('Value "%s" cannot be safely quoted.' % value)
- elif '"' in value:
- quot = squot
- else:
- quot = dquot
- return quot
-
-
- def _get_triple_quote(self, value):
- if (value.find('"""') != -1) and (value.find("'''") != -1):
- raise ConfigObjError('Value "%s" cannot be safely quoted.' % value)
- if value.find('"""') == -1:
- quot = tdquot
- else:
- quot = tsquot
- return quot
-
-
- def _handle_value(self, value):
- """
- Given a value string, unquote, remove comment,
- handle lists. (including empty and single member lists)
- """
- if self._inspec:
- # Parsing a configspec so don't handle comments
- return (value, '')
- # do we look for lists in values ?
- if not self.list_values:
- mat = self._nolistvalue.match(value)
- if mat is None:
- raise SyntaxError()
- # NOTE: we don't unquote here
- return mat.groups()
- #
- mat = self._valueexp.match(value)
- if mat is None:
- # the value is badly constructed, probably badly quoted,
- # or an invalid list
- raise SyntaxError()
- (list_values, single, empty_list, comment) = mat.groups()
- if (list_values == '') and (single is None):
- # change this if you want to accept empty values
- raise SyntaxError()
- # NOTE: note there is no error handling from here if the regex
- # is wrong: then incorrect values will slip through
- if empty_list is not None:
- # the single comma - meaning an empty list
- return ([], comment)
- if single is not None:
- # handle empty values
- if list_values and not single:
- # FIXME: the '' is a workaround because our regex now matches
- # '' at the end of a list if it has a trailing comma
- single = None
- else:
- single = single or '""'
- single = self._unquote(single)
- if list_values == '':
- # not a list value
- return (single, comment)
- the_list = self._listvalueexp.findall(list_values)
- the_list = [self._unquote(val) for val in the_list]
- if single is not None:
- the_list += [single]
- return (the_list, comment)
-
-
- def _multiline(self, value, infile, cur_index, maxline):
- """Extract the value, where we are in a multiline situation."""
- quot = value[:3]
- newvalue = value[3:]
- single_line = self._triple_quote[quot][0]
- multi_line = self._triple_quote[quot][1]
- mat = single_line.match(value)
- if mat is not None:
- retval = list(mat.groups())
- retval.append(cur_index)
- return retval
- elif newvalue.find(quot) != -1:
- # somehow the triple quote is missing
- raise SyntaxError()
- #
- while cur_index < maxline:
- cur_index += 1
- newvalue += '\n'
- line = infile[cur_index]
- if line.find(quot) == -1:
- newvalue += line
- else:
- # end of multiline, process it
- break
- else:
- # we've got to the end of the config, oops...
- raise SyntaxError()
- mat = multi_line.match(line)
- if mat is None:
- # a badly formed line
- raise SyntaxError()
- (value, comment) = mat.groups()
- return (newvalue + value, comment, cur_index)
-
-
- def _handle_configspec(self, configspec):
- """Parse the configspec."""
- # FIXME: Should we check that the configspec was created with the
- # correct settings ? (i.e. ``list_values=False``)
- if not isinstance(configspec, ConfigObj):
- try:
- configspec = ConfigObj(configspec,
- raise_errors=True,
- file_error=True,
- _inspec=True)
- except ConfigObjError as e:
- # FIXME: Should these errors have a reference
- # to the already parsed ConfigObj ?
- raise ConfigspecError('Parsing configspec failed: %s' % e)
- except IOError as e:
- raise IOError('Reading configspec failed: %s' % e)
-
- self.configspec = configspec
-
-
-
- def _set_configspec(self, section, copy):
- """
- Called by validate. Handles setting the configspec on subsections
- including sections to be validated by __many__
- """
- configspec = section.configspec
- many = configspec.get('__many__')
- if isinstance(many, dict):
- for entry in section.sections:
- if entry not in configspec:
- section[entry].configspec = many
-
- for entry in configspec.sections:
- if entry == '__many__':
- continue
- if entry not in section:
- section[entry] = {}
- section[entry]._created = True
- if copy:
- # copy comments
- section.comments[entry] = configspec.comments.get(entry, [])
- section.inline_comments[entry] = configspec.inline_comments.get(entry, '')
-
- # Could be a scalar when we expect a section
- if isinstance(section[entry], Section):
- section[entry].configspec = configspec[entry]
-
-
- def _write_line(self, indent_string, entry, this_entry, comment):
- """Write an individual line, for the write method"""
- # NOTE: the calls to self._quote here handles non-StringType values.
- if not self.unrepr:
- val = self._decode_element(self._quote(this_entry))
- else:
- val = repr(this_entry)
- return '%s%s%s%s%s' % (indent_string,
- self._decode_element(self._quote(entry, multiline=False)),
- self._a_to_u(' = '),
- val,
- self._decode_element(comment))
-
-
- def _write_marker(self, indent_string, depth, entry, comment):
- """Write a section marker line"""
- return '%s%s%s%s%s' % (indent_string,
- self._a_to_u('[' * depth),
- self._quote(self._decode_element(entry), multiline=False),
- self._a_to_u(']' * depth),
- self._decode_element(comment))
-
-
- def _handle_comment(self, comment):
- """Deal with a comment."""
- if not comment:
- return ''
- start = self.indent_type
- if not comment.startswith('#'):
- start += self._a_to_u(' # ')
- return (start + comment)
-
-
- # Public methods
-
- def write(self, outfile=None, section=None):
- """
- Write the current ConfigObj as a file
-
- tekNico: FIXME: use StringIO instead of real files
-
- >>> filename = a.filename
- >>> a.filename = 'test.ini'
- >>> a.write()
- >>> a.filename = filename
- >>> a == ConfigObj('test.ini', raise_errors=True)
- 1
- >>> import os
- >>> os.remove('test.ini')
- """
- if self.indent_type is None:
- # this can be true if initialised from a dictionary
- self.indent_type = DEFAULT_INDENT_TYPE
-
- out = []
- cs = self._a_to_u('#')
- csp = self._a_to_u('# ')
- if section is None:
- int_val = self.interpolation
- self.interpolation = False
- section = self
- for line in self.initial_comment:
- line = self._decode_element(line)
- stripped_line = line.strip()
- if stripped_line and not stripped_line.startswith(cs):
- line = csp + line
- out.append(line)
-
- indent_string = self.indent_type * section.depth
- for entry in (section.scalars + section.sections):
- if entry in section.defaults:
- # don't write out default values
- continue
- for comment_line in section.comments[entry]:
- comment_line = self._decode_element(comment_line.lstrip())
- if comment_line and not comment_line.startswith(cs):
- comment_line = csp + comment_line
- out.append(indent_string + comment_line)
- this_entry = section[entry]
- comment = self._handle_comment(section.inline_comments[entry])
-
- if isinstance(this_entry, Section):
- # a section
- out.append(self._write_marker(
- indent_string,
- this_entry.depth,
- entry,
- comment))
- out.extend(self.write(section=this_entry))
- else:
- out.append(self._write_line(
- indent_string,
- entry,
- this_entry,
- comment))
-
- if section is self:
- for line in self.final_comment:
- line = self._decode_element(line)
- stripped_line = line.strip()
- if stripped_line and not stripped_line.startswith(cs):
- line = csp + line
- out.append(line)
- self.interpolation = int_val
-
- if section is not self:
- return out
-
- if (self.filename is None) and (outfile is None):
- # output a list of lines
- # might need to encode
- # NOTE: This will *screw* UTF16, each line will start with the BOM
- if self.encoding:
- out = [l.encode(self.encoding) for l in out]
- if (self.BOM and ((self.encoding is None) or
- (BOM_LIST.get(self.encoding.lower()) == 'utf_8'))):
- # Add the UTF8 BOM
- if not out:
- out.append('')
- out[0] = BOM_UTF8 + out[0]
- return out
-
- # Turn the list to a string, joined with correct newlines
- newline = self.newlines or os.linesep
- if (getattr(outfile, 'mode', None) is not None and outfile.mode == 'w'
- and sys.platform == 'win32' and newline == '\r\n'):
- # Windows specific hack to avoid writing '\r\r\n'
- newline = '\n'
- output = self._a_to_u(newline).join(out)
- if not output.endswith(newline):
- output += newline
-
- if isinstance(output, six.binary_type):
- output_bytes = output
- else:
- output_bytes = output.encode(self.encoding or
- self.default_encoding or
- 'ascii')
-
- if self.BOM and ((self.encoding is None) or match_utf8(self.encoding)):
- # Add the UTF8 BOM
- output_bytes = BOM_UTF8 + output_bytes
-
- if outfile is not None:
- outfile.write(output_bytes)
- else:
- with open(self.filename, 'wb') as h:
- h.write(output_bytes)
-
- def validate(self, validator, preserve_errors=False, copy=False,
- section=None):
- """
- Test the ConfigObj against a configspec.
-
- It uses the ``validator`` object from *validate.py*.
-
- To run ``validate`` on the current ConfigObj, call: ::
-
- test = config.validate(validator)
-
- (Normally having previously passed in the configspec when the ConfigObj
- was created - you can dynamically assign a dictionary of checks to the
- ``configspec`` attribute of a section though).
-
- It returns ``True`` if everything passes, or a dictionary of
- pass/fails (True/False). If every member of a subsection passes, it
- will just have the value ``True``. (It also returns ``False`` if all
- members fail).
-
- In addition, it converts the values from strings to their native
- types if their checks pass (and ``stringify`` is set).
-
- If ``preserve_errors`` is ``True`` (``False`` is default) then instead
- of a marking a fail with a ``False``, it will preserve the actual
- exception object. This can contain info about the reason for failure.
- For example the ``VdtValueTooSmallError`` indicates that the value
- supplied was too small. If a value (or section) is missing it will
- still be marked as ``False``.
-
- You must have the validate module to use ``preserve_errors=True``.
-
- You can then use the ``flatten_errors`` function to turn your nested
- results dictionary into a flattened list of failures - useful for
- displaying meaningful error messages.
- """
- if section is None:
- if self.configspec is None:
- raise ValueError('No configspec supplied.')
- if preserve_errors:
- # We do this once to remove a top level dependency on the validate module
- # Which makes importing configobj faster
- from validate import VdtMissingValue
- self._vdtMissingValue = VdtMissingValue
-
- section = self
-
- if copy:
- section.initial_comment = section.configspec.initial_comment
- section.final_comment = section.configspec.final_comment
- section.encoding = section.configspec.encoding
- section.BOM = section.configspec.BOM
- section.newlines = section.configspec.newlines
- section.indent_type = section.configspec.indent_type
-
- #
- # section.default_values.clear() #??
- configspec = section.configspec
- self._set_configspec(section, copy)
-
-
- def validate_entry(entry, spec, val, missing, ret_true, ret_false):
- section.default_values.pop(entry, None)
-
- try:
- section.default_values[entry] = validator.get_default_value(configspec[entry])
- except (KeyError, AttributeError, validator.baseErrorClass):
- # No default, bad default or validator has no 'get_default_value'
- # (e.g. SimpleVal)
- pass
-
- try:
- check = validator.check(spec,
- val,
- missing=missing
- )
- except validator.baseErrorClass as e:
- if not preserve_errors or isinstance(e, self._vdtMissingValue):
- out[entry] = False
- else:
- # preserve the error
- out[entry] = e
- ret_false = False
- ret_true = False
- else:
- ret_false = False
- out[entry] = True
- if self.stringify or missing:
- # if we are doing type conversion
- # or the value is a supplied default
- if not self.stringify:
- if isinstance(check, (list, tuple)):
- # preserve lists
- check = [self._str(item) for item in check]
- elif missing and check is None:
- # convert the None from a default to a ''
- check = ''
- else:
- check = self._str(check)
- if (check != val) or missing:
- section[entry] = check
- if not copy and missing and entry not in section.defaults:
- section.defaults.append(entry)
- return ret_true, ret_false
-
- #
- out = {}
- ret_true = True
- ret_false = True
-
- unvalidated = [k for k in section.scalars if k not in configspec]
- incorrect_sections = [k for k in configspec.sections if k in section.scalars]
- incorrect_scalars = [k for k in configspec.scalars if k in section.sections]
-
- for entry in configspec.scalars:
- if entry in ('__many__', '___many___'):
- # reserved names
- continue
- if (not entry in section.scalars) or (entry in section.defaults):
- # missing entries
- # or entries from defaults
- missing = True
- val = None
- if copy and entry not in section.scalars:
- # copy comments
- section.comments[entry] = (
- configspec.comments.get(entry, []))
- section.inline_comments[entry] = (
- configspec.inline_comments.get(entry, ''))
- #
- else:
- missing = False
- val = section[entry]
-
- ret_true, ret_false = validate_entry(entry, configspec[entry], val,
- missing, ret_true, ret_false)
-
- many = None
- if '__many__' in configspec.scalars:
- many = configspec['__many__']
- elif '___many___' in configspec.scalars:
- many = configspec['___many___']
-
- if many is not None:
- for entry in unvalidated:
- val = section[entry]
- ret_true, ret_false = validate_entry(entry, many, val, False,
- ret_true, ret_false)
- unvalidated = []
-
- for entry in incorrect_scalars:
- ret_true = False
- if not preserve_errors:
- out[entry] = False
- else:
- ret_false = False
- msg = 'Value %r was provided as a section' % entry
- out[entry] = validator.baseErrorClass(msg)
- for entry in incorrect_sections:
- ret_true = False
- if not preserve_errors:
- out[entry] = False
- else:
- ret_false = False
- msg = 'Section %r was provided as a single value' % entry
- out[entry] = validator.baseErrorClass(msg)
-
- # Missing sections will have been created as empty ones when the
- # configspec was read.
- for entry in section.sections:
- # FIXME: this means DEFAULT is not copied in copy mode
- if section is self and entry == 'DEFAULT':
- continue
- if section[entry].configspec is None:
- unvalidated.append(entry)
- continue
- if copy:
- section.comments[entry] = configspec.comments.get(entry, [])
- section.inline_comments[entry] = configspec.inline_comments.get(entry, '')
- check = self.validate(validator, preserve_errors=preserve_errors, copy=copy, section=section[entry])
- out[entry] = check
- if check == False:
- ret_true = False
- elif check == True:
- ret_false = False
- else:
- ret_true = False
-
- section.extra_values = unvalidated
- if preserve_errors and not section._created:
- # If the section wasn't created (i.e. it wasn't missing)
- # then we can't return False, we need to preserve errors
- ret_false = False
- #
- if ret_false and preserve_errors and out:
- # If we are preserving errors, but all
- # the failures are from missing sections / values
- # then we can return False. Otherwise there is a
- # real failure that we need to preserve.
- ret_false = not any(out.values())
- if ret_true:
- return True
- elif ret_false:
- return False
- return out
-
-
- def reset(self):
- """Clear ConfigObj instance and restore to 'freshly created' state."""
- self.clear()
- self._initialise()
- # FIXME: Should be done by '_initialise', but ConfigObj constructor (and reload)
- # requires an empty dictionary
- self.configspec = None
- # Just to be sure ;-)
- self._original_configspec = None
-
-
- def reload(self):
- """
- Reload a ConfigObj from file.
-
- This method raises a ``ReloadError`` if the ConfigObj doesn't have
- a filename attribute pointing to a file.
- """
- if not isinstance(self.filename, six.string_types):
- raise ReloadError()
-
- filename = self.filename
- current_options = {}
- for entry in OPTION_DEFAULTS:
- if entry == 'configspec':
- continue
- current_options[entry] = getattr(self, entry)
-
- configspec = self._original_configspec
- current_options['configspec'] = configspec
-
- self.clear()
- self._initialise(current_options)
- self._load(filename, configspec)
-
-
-
-class SimpleVal(object):
- """
- A simple validator.
- Can be used to check that all members expected are present.
-
- To use it, provide a configspec with all your members in (the value given
- will be ignored). Pass an instance of ``SimpleVal`` to the ``validate``
- method of your ``ConfigObj``. ``validate`` will return ``True`` if all
- members are present, or a dictionary with True/False meaning
- present/missing. (Whole missing sections will be replaced with ``False``)
- """
-
- def __init__(self):
- self.baseErrorClass = ConfigObjError
-
- def check(self, check, member, missing=False):
- """A dummy check method, always returns the value unchanged."""
- if missing:
- raise self.baseErrorClass()
- return member
-
-
-def flatten_errors(cfg, res, levels=None, results=None):
- """
- An example function that will turn a nested dictionary of results
- (as returned by ``ConfigObj.validate``) into a flat list.
-
- ``cfg`` is the ConfigObj instance being checked, ``res`` is the results
- dictionary returned by ``validate``.
-
- (This is a recursive function, so you shouldn't use the ``levels`` or
- ``results`` arguments - they are used by the function.)
-
- Returns a list of keys that failed. Each member of the list is a tuple::
-
- ([list of sections...], key, result)
-
- If ``validate`` was called with ``preserve_errors=False`` (the default)
- then ``result`` will always be ``False``.
-
- *list of sections* is a flattened list of sections that the key was found
- in.
-
- If the section was missing (or a section was expected and a scalar provided
- - or vice-versa) then key will be ``None``.
-
- If the value (or section) was missing then ``result`` will be ``False``.
-
- If ``validate`` was called with ``preserve_errors=True`` and a value
- was present, but failed the check, then ``result`` will be the exception
- object returned. You can use this as a string that describes the failure.
-
- For example *The value "3" is of the wrong type*.
- """
- if levels is None:
- # first time called
- levels = []
- results = []
- if res == True:
- return sorted(results)
- if res == False or isinstance(res, Exception):
- results.append((levels[:], None, res))
- if levels:
- levels.pop()
- return sorted(results)
- for (key, val) in list(res.items()):
- if val == True:
- continue
- if isinstance(cfg.get(key), dict):
- # Go down one level
- levels.append(key)
- flatten_errors(cfg[key], val, levels, results)
- continue
- results.append((levels[:], key, val))
- #
- # Go up one level
- if levels:
- levels.pop()
- #
- return sorted(results)
-
-
-def get_extra_values(conf, _prepend=()):
- """
- Find all the values and sections not in the configspec from a validated
- ConfigObj.
-
- ``get_extra_values`` returns a list of tuples where each tuple represents
- either an extra section, or an extra value.
-
- The tuples contain two values, a tuple representing the section the value
- is in and the name of the extra values. For extra values in the top level
- section the first member will be an empty tuple. For values in the 'foo'
- section the first member will be ``('foo',)``. For members in the 'bar'
- subsection of the 'foo' section the first member will be ``('foo', 'bar')``.
-
- NOTE: If you call ``get_extra_values`` on a ConfigObj instance that hasn't
- been validated it will return an empty list.
- """
- out = []
-
- out.extend([(_prepend, name) for name in conf.extra_values])
- for name in conf.sections:
- if name not in conf.extra_values:
- out.extend(get_extra_values(conf[name], _prepend + (name,)))
- return out
-
-
-"""*A programming language is a medium of expression.* - Paul Graham"""
diff --git a/nomadnet/vendor/quotes.py b/nomadnet/vendor/quotes.py
index 9584516..4c87571 100644
--- a/nomadnet/vendor/quotes.py
+++ b/nomadnet/vendor/quotes.py
@@ -1,4 +1,7 @@
quotes = [
("I want the wisdom that wise men revere. I want more.", "Faithless"),
- ("That's enough entropy for you my friend", "Unknown")
+ ("That's enough entropy for you my friend", "Unknown"),
+ ("Any time two people connect online, it's financed by a third person who believes they can manipulate the first two", "Jaron Lanier"),
+ ("The landscape of the future is set, but how one will march across it is not determined", "Terence McKenna")
+ ("Freedom originates in the division of power, despotism in its concentration.", "John Acton")
]
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 60403ea..45721bd 100644
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,4 @@
+import sys
import setuptools
exec(open("nomadnet/_version.py", "r").read())
@@ -5,6 +6,17 @@ exec(open("nomadnet/_version.py", "r").read())
with open("README.md", "r") as fh:
long_description = fh.read()
+if "--getversion" in sys.argv:
+ print(__version__, end="")
+ exit(0)
+
+package_data = {
+"": [
+ "examples/messageboard/*",
+ "ui/textui/images/demo.webp",
+ ]
+}
+
setuptools.setup(
name="nomadnet",
version=__version__,
@@ -15,6 +27,7 @@ setuptools.setup(
long_description_content_type="text/markdown",
url="https://github.com/markqvist/nomadnet",
packages=setuptools.find_packages(),
+ package_data=package_data,
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
@@ -23,6 +36,6 @@ setuptools.setup(
entry_points= {
'console_scripts': ['nomadnet=nomadnet.nomadnet:main']
},
- install_requires=['rns>=0.2.1', 'lxmf>=0.0.6', 'urwid>=2.1.2'],
- python_requires='>=3.5',
-)
\ No newline at end of file
+ install_requires=["rns>=1.5.5", "lxmf>=1.2.0", "urwid>=3.0.5", "qrcode"],
+ python_requires=">=3.8",
+)
diff --git a/tools/colorgen.py b/tools/colorgen.py
new file mode 100644
index 0000000..0ff636a
--- /dev/null
+++ b/tools/colorgen.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+"""
+Color Table Generator with Perceptual Normalization
+
+Generates HSL color tables with optional normalization to uniform perceptual
+intensity (CIELAB L*) followed by global lightness scaling.
+"""
+
+import argparse
+import colorsys
+import math
+import sys
+
+
+def hsl_to_rgb(h, s, l):
+ """Convert HSL (0-360, 0-100, 0-100) to RGB (0.0-1.0)."""
+ h_norm = (h % 360) / 360.0
+ s_norm = max(0, min(100, s)) / 100.0
+ l_norm = max(0, min(100, l)) / 100.0
+ r, g, b = colorsys.hls_to_rgb(h_norm, l_norm, s_norm)
+ return (r, g, b)
+
+
+def rgb_to_hex(r, g, b):
+ """Convert RGB 0.0-1.0 to lowercase hex string."""
+ r = max(0, min(1, r))
+ g = max(0, min(1, g))
+ b = max(0, min(1, b))
+ return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
+
+
+# CIELAB conversion functions
+def srgb_to_linear(c):
+ if c <= 0.04045:
+ return c / 12.92
+ return ((c + 0.055) / 1.055) ** 2.4
+
+
+def linear_to_srgb(c):
+ if c <= 0.0031308:
+ return c * 12.92
+ return 1.055 * (c ** (1/2.4)) - 0.055
+
+
+def rgb_to_lab(r, g, b):
+ """Convert sRGB (0-1) to CIELAB (L: 0-100, a,b: ~-128 to 127). D65 illuminant."""
+ # sRGB to Linear
+ r_lin = srgb_to_linear(r)
+ g_lin = srgb_to_linear(g)
+ b_lin = srgb_to_linear(b)
+
+ # Linear to XYZ (D65)
+ X = 0.4124564 * r_lin + 0.3575761 * g_lin + 0.1804375 * b_lin
+ Y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin
+ Z = 0.0193339 * r_lin + 0.1191920 * g_lin + 0.9503041 * b_lin
+
+ # XYZ to LAB
+ Xn, Yn, Zn = 95.047, 100.0, 108.883
+ delta = 6/29
+
+ def f(t):
+ if t > delta**3:
+ return t ** (1/3)
+ return t / (3 * delta**2) + 4/29
+
+ L = 116 * f(Y / Yn) - 16
+ a = 500 * (f(X / Xn) - f(Y / Yn))
+ b_val = 200 * (f(Y / Yn) - f(Z / Zn))
+
+ return (L, a, b_val)
+
+
+def lab_to_rgb(L, a, b_val):
+ """Convert CIELAB to sRGB (0-1)."""
+ Xn, Yn, Zn = 95.047, 100.0, 108.883
+ delta = 6/29
+
+ def inv_f(y):
+ if y > delta:
+ return y ** 3
+ return 3 * delta**2 * (y - 4/29)
+
+ L_adj = (L + 16) / 116
+ X = Xn * inv_f(L_adj + a / 500)
+ Y = Yn * inv_f(L_adj)
+ Z = Zn * inv_f(L_adj - b_val / 200)
+
+ # XYZ to Linear RGB
+ r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z
+ g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z
+ b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z
+
+ r = linear_to_srgb(r_lin)
+ g = linear_to_srgb(g_lin)
+ b = linear_to_srgb(b_lin)
+
+ return (r, g, b)
+
+
+def generate_colors(
+ hues=None,
+ hue_start=0.0,
+ hue_step=30.0,
+ sat_start=70.0,
+ sat_step=0.0,
+ sat_steps=1,
+ light_start=50.0,
+ light_step=0.0,
+ light_steps=1,
+ perceptual_multiplier=1.0,
+ use_hsl_space=False,
+ normalize=False,
+ normalize_target=None,
+ discards=[]
+):
+ """
+ Generate color table with optional perceptual normalization.
+
+ Pipeline:
+ 1. Generate HSL variations
+ 2. If normalize: Convert to LAB, set all L* to target (mean or specified)
+ 3. Apply global perceptual multiplier to L*
+ 4. Convert to RGB and output hex
+ """
+ # Determine hue list
+ if hues is not None:
+ hue_list = [float(h) % 360 for h in hues]
+ else:
+ hue_list = []
+ current = hue_start % 360
+ if hue_step <= 0:
+ raise ValueError("hue_step must be positive when using start/step mode")
+ while current < 360:
+ hue_list.append(current)
+ current += hue_step
+
+ # Determine if we need LAB processing
+ needs_lab = normalize or (perceptual_multiplier != 1.0) or not use_hsl_space
+
+ colors = []
+ lab_colors = [] # Store as (L, a, b) tuples if processing needed
+
+ for h in hue_list:
+ for i in range(sat_steps):
+ s = sat_start + (i * sat_step)
+ s = max(0, min(100, s))
+
+ for j in range(light_steps):
+ # l = light_start + (j * light_step)
+ l = light_start + (i * light_step)
+ l = max(0, min(100, l))
+
+ r, g, b = hsl_to_rgb(h, s, l)
+
+ if not needs_lab:
+ colors.append(rgb_to_hex(r, g, b))
+ else:
+ L, a, b_lab = rgb_to_lab(r, g, b)
+ lab_colors.append((L, a, b_lab))
+
+ filtered_colors = []
+ filtered_lab_colors = []
+ i = 0
+ for c in colors:
+ i += 1
+ if not str(i) in discards:
+ filtered_colors.append(c)
+
+ i = 0
+ for c in lab_colors:
+ i += 1
+ if not str(i) in discards:
+ filtered_lab_colors.append(c)
+
+ colors = filtered_colors
+ lab_colors = filtered_lab_colors
+
+ if not needs_lab:
+ return colors
+
+ # Step 2: Perceptual Normalization (equalize intensity)
+ if normalize:
+ if normalize_target is not None:
+ target_L = normalize_target
+ else:
+ # Calculate mean perceptual lightness
+ target_L = sum(lab[0] for lab in lab_colors) / len(lab_colors)
+ print(f"Target L*: {target_L}")
+
+ # Clamp target to valid LAB range
+ target_L = max(0, min(100, target_L))
+
+ # Normalize all colors to target L, preserving hue (a, b)
+ lab_colors = [(target_L, a, b) for (L, a, b) in lab_colors]
+
+ # Step 3: Apply final global perceptual multiplier
+ final_colors = []
+ for L, a, b in lab_colors:
+ L_final = L * perceptual_multiplier
+ L_final = max(0, min(100, L_final))
+ r, g, b = lab_to_rgb(L_final, a, b)
+ final_colors.append(rgb_to_hex(r, g, b))
+
+ return final_colors
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate perceptually normalized color tables",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Pipeline: HSL Generation → [Normalize to uniform L*] → Global Multiplier → Hex
+
+Examples:
+ # 32 colors normalized to same perceptual intensity, then darkened 20%
+ python3 colorgen.py --hue-start 0 --hue-step 11.25 --sat-start 75 --normalize --perceptual-multiplier 0.8
+
+ # Force all colors to exactly L*=55, then scale by 1.1
+ python3 colorgen.py --hues "0,90,180,270" --normalize --normalize-target 55 --perceptual-multiplier 1.1
+
+ # Generate with lightness variation, normalize to mean intensity, output brightened
+ python3 colorgen.py --hue-start 15 --hue-step 30 --light-start 40 --light-step 10 --light-steps 3 --normalize --perceptual-multiplier 1.2
+ """
+ )
+
+ # Hue configuration
+ hue_group = parser.add_mutually_exclusive_group()
+ hue_group.add_argument("--hues", type=str, metavar="LIST",
+ help='Comma-separated hue values, e.g., "0,60,120"')
+ hue_group.add_argument("--hue-start", type=float, default=0.0,
+ help="Initial hue offset in degrees")
+ parser.add_argument("--hue-step", type=float, default=30.0,
+ help="Separation between hues")
+
+ # Saturation/Lightness configuration
+ parser.add_argument("--sat-start", type=float, default=75.0,
+ help="Initial saturation (0-100)")
+ parser.add_argument("--sat-step", type=float, default=0.0,
+ help="Saturation step")
+ parser.add_argument("--sat-steps", type=int, default=1,
+ help="Number of saturation variations")
+ parser.add_argument("--light-start", type=float, default=50.0,
+ help="Initial lightness (0-100)")
+ parser.add_argument("--light-step", type=float, default=0.0,
+ help="Lightness step")
+ parser.add_argument("--light-steps", type=int, default=1,
+ help="Number of lightness variations")
+
+ # Perceptual processing
+ parser.add_argument("--normalize", action="store_true",
+ help="Normalize all colors to same perceptual intensity (L*) before applying multiplier")
+ parser.add_argument("--normalize-target", type=float, default=None, metavar="L",
+ help="Target L* value (0-100) for normalization. Default: mean of generated colors")
+ parser.add_argument("--perceptual-multiplier", type=float, default=1.0,
+ help="Final global lightness multiplier (1.0=no change)")
+ parser.add_argument("--hsl-space", action="store_true",
+ help="Skip LAB conversion if not normalizing (faster, less accurate perceptually)")
+
+ # Output
+ parser.add_argument("--python-list", action="store_true",
+ help="Output as Python list")
+ parser.add_argument("--separator", type=str, default="\n",
+ help="Separator between colors")
+ parser.add_argument("--stats", action="store_true",
+ help="Print generation stats to stderr")
+ hue_group.add_argument("--discard", type=str, metavar="LIST",
+ help='Discard output indexes, e.g., "20,2,33"')
+
+ args = parser.parse_args()
+
+ if args.hsl_space and args.normalize:
+ print("Warning: --hsl-space ignored because --normalize requires LAB space", file=sys.stderr)
+ args.hsl_space = False
+
+ try:
+ if args.discard: discards = args.discard.split(",")
+ else: discards = []
+
+ colors = generate_colors(
+ hues=args.hues.split(",") if args.hues else None,
+ hue_start=args.hue_start,
+ hue_step=args.hue_step,
+ sat_start=args.sat_start,
+ sat_step=args.sat_step,
+ sat_steps=args.sat_steps,
+ light_start=args.light_start,
+ light_step=args.light_step,
+ light_steps=args.light_steps,
+ perceptual_multiplier=args.perceptual_multiplier,
+ use_hsl_space=args.hsl_space,
+ normalize=args.normalize,
+ normalize_target=args.normalize_target,
+ discards=discards,
+ )
+
+ i = 0
+ # Enable to test on light background
+ # print(f"#!bg=fff\n")
+ if args.python_list: print("colors = [", end="")
+ for c in colors:
+ i+=1
+ if args.python_list:
+ print(f"\"{c}\", ", end="")
+
+ else: print(f"`FT{c}colored {i}`f")
+
+ if args.python_list: print("]")
+
+ if args.stats:
+ total = len(colors)
+ mode = "normalized + " if args.normalize else ""
+ print(f"# Generated {total} colors ({mode}L* × {args.perceptual_multiplier})",
+ file=sys.stderr)
+
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/tools/rrc-log-dump.py b/tools/rrc-log-dump.py
new file mode 100755
index 0000000..30c9bb8
--- /dev/null
+++ b/tools/rrc-log-dump.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""Dump the contents of rrc on-disk room history logs.
+
+Usage:
+ rrc-log-dump.py
+
+ may be:
+ - a single .log file
+ - a hub directory under rrc_history/
+ - the rrc_history/ root (or any ancestor; .log files are found recursively)
+"""
+
+import os
+import sys
+import time
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, os.path.join(SCRIPT_DIR, "..", "nomadnet", "vendor"))
+import cbor
+
+
+H_KIND = "k"
+H_SRC = "s"
+H_NICK = "n"
+H_TEXT = "t"
+H_TS = "ts"
+H_MENTION = "m"
+
+KIND_COLOR = {
+ "msg": "",
+ "system": "\033[2m",
+ "notice": "\033[36m",
+ "error": "\033[31m",
+}
+RESET = "\033[0m"
+
+
+def _fmt_ts(ts_ms):
+ try:
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts_ms) // 1000))
+ except Exception:
+ return "????-??-?? ??:??:??"
+
+
+def _fmt_speaker(entry):
+ nick = entry.get(H_NICK)
+ src = entry.get(H_SRC)
+ src_hex = src.hex()[:12] if isinstance(src, (bytes, bytearray)) else None
+ if nick and src_hex:
+ return "%s (%s)" % (nick, src_hex)
+ if nick:
+ return nick
+ if src_hex:
+ return src_hex
+ return ""
+
+
+def dump_file(path, use_color):
+ print("=== %s ===" % path)
+ count = 0
+ truncated = False
+ with open(path, "rb") as f:
+ while True:
+ try:
+ entry = cbor.load(f)
+ except EOFError:
+ break
+ except Exception as ex:
+ truncated = True
+ print(" [corrupt record after entry %d: %s]" % (count, ex), file=sys.stderr)
+ break
+ if not isinstance(entry, dict):
+ print(" [skipping non-dict entry: %r]" % (entry,), file=sys.stderr)
+ continue
+ kind = entry.get(H_KIND) or "msg"
+ ts = _fmt_ts(entry.get(H_TS, 0))
+ speaker = _fmt_speaker(entry)
+ text = entry.get(H_TEXT) or ""
+ mention = " *" if entry.get(H_MENTION) else " "
+ color = KIND_COLOR.get(kind, "") if use_color else ""
+ end = RESET if (color and use_color) else ""
+ line = "%s %-6s%s %-32s %s" % (ts, kind, mention, speaker, text)
+ print(color + line + end)
+ count += 1
+ suffix = " (truncated)" if truncated else ""
+ print("--- %d entries%s ---" % (count, suffix))
+
+
+def main(argv):
+ if len(argv) != 2 or argv[1] in ("-h", "--help"):
+ print(__doc__, file=sys.stderr)
+ return 2
+ target = argv[1]
+ use_color = sys.stdout.isatty()
+ if os.path.isfile(target):
+ dump_file(target, use_color)
+ return 0
+ if os.path.isdir(target):
+ files = []
+ for root, _dirs, names in os.walk(target):
+ for name in names:
+ if name.endswith(".log"):
+ files.append(os.path.join(root, name))
+ files.sort()
+ if not files:
+ print("no .log files found under %s" % target, file=sys.stderr)
+ return 1
+ for i, path in enumerate(files):
+ if i > 0:
+ print()
+ dump_file(path, use_color)
+ return 0
+ print("not a file or directory: %s" % target, file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))