Compare commits

..

25 Commits

Author SHA1 Message Date
Ivan
58eef8d925 bump 2025-05-11 15:02:19 -05:00
Ivan
252407b0c8 fix 2025-05-11 15:01:54 -05:00
Ivan
b6b1a6d050 add retry 2025-05-11 15:01:16 -05:00
Ivan
4fe8b43df1 fix 2025-05-11 14:59:44 -05:00
Ivan
6b3713e58f flatpak 2025-05-11 14:56:33 -05:00
Ivan
09bd78e194 node 22 and python 3.13 2025-05-11 14:43:47 -05:00
Ivan
3288fea934 update 2025-05-11 14:31:50 -05:00
Ivan
47f544e2ee ruff format and fixes.
Improve page download.
2025-05-11 14:28:57 -05:00
Ivan
be3d8f1e80 update 2025-05-11 14:22:39 -05:00
Ivan
01b1251589 dark mode by default 2025-05-11 14:22:35 -05:00
Ivan
936c298e15 update 2025-05-11 14:21:01 -05:00
Ivan
f5dc06ab88 update 2025-05-11 14:20:10 -05:00
Ivan
24e2ac9c65 update 2025-05-09 18:45:33 -05:00
Ivan
349f50b87f update 2025-05-09 18:18:36 -05:00
Ivan
5f8c476f18 fix 2025-04-22 17:58:46 -05:00
Ivan
dbf5361fe4 fix 2025-04-22 17:55:30 -05:00
Ivan
54a92ad5d5 update 2025-04-22 17:54:45 -05:00
Ivan
d59e91ced3 update 2025-04-22 17:53:27 -05:00
Ivan
31dacb357f update 2025-04-22 17:51:42 -05:00
Ivan
daeda58b80 add bearer 2025-04-22 17:47:22 -05:00
Ivan
195daf343d update 2025-04-22 17:47:03 -05:00
Ivan
c41e022e4f use my image 2025-04-22 17:46:54 -05:00
Ivan
15c4355a58 update package-lock 2025-04-22 17:46:39 -05:00
Ivan
a23f64067a update 2025-04-22 17:34:08 -05:00
Ivan
cf72ac1ec8 update 2025-04-22 17:20:07 -05:00
43 changed files with 3091 additions and 2800 deletions

View File

@@ -1,55 +0,0 @@
# Documentation
README.md
LICENSE
donate.md
screenshots/
# Development files
.github/
electron/
# Build artifacts and cache
public/
node_modules/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git/
.gitignore
# Docker files
Dockerfile*
docker-compose*.yml
.dockerignore
# Logs
*.log
# Temporary files
*.tmp
*.temp

View File

@@ -1,20 +0,0 @@
name: Bearer PR Check
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
security-events: write
jobs:
rule_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Bearer
uses: bearer/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2
with:
diff: true

36
.github/workflows/bearer.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Security Scan
on:
push:
branches:
- master
pull_request:
branches:
- master
schedule:
- cron: '0 0 * * 0' # Run weekly on Sunday
permissions:
contents: read
security-events: write
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Bearer Security Scan
uses: bearer/bearer-action@v2
with:
scanner: sast
format: sarif
output: bearer.sarif
severity: critical,high
path: .
exit-code: 0
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: bearer.sarif

View File

@@ -4,6 +4,13 @@ on:
push:
tags:
- "*"
workflow_dispatch:
inputs:
retry_failed:
description: 'Retry failed jobs'
required: false
type: boolean
default: false
jobs:
build_windows:
@@ -12,17 +19,17 @@ jobs:
contents: write
steps:
- name: Clone Repo
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
uses: actions/checkout@v1
- name: Install NodeJS
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
uses: actions/setup-node@v1
with:
node-version: 22
- name: Install Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"
- name: Install Python Deps
run: pip install -r requirements.txt
@@ -35,7 +42,7 @@ jobs:
- name: Create Release
id: create_release
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
uses: ncipollo/release-action@v1
with:
draft: true
allowUpdates: true
@@ -44,43 +51,43 @@ jobs:
omitNameDuringUpdate: true
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
build_mac:
runs-on: macos-13
permissions:
contents: write
steps:
- name: Clone Repo
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
# build_mac:
# runs-on: macos-13
# permissions:
# contents: write
# steps:
# - name: Clone Repo
# uses: actions/checkout@v1
- name: Install NodeJS
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
with:
node-version: 18
# - name: Install NodeJS
# uses: actions/setup-node@v1
# with:
# node-version: 20
- name: Install Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
# - name: Install Python
# uses: actions/setup-python@v5
# with:
# python-version: "3.13"
- name: Install Python Deps
run: pip install -r requirements.txt
# - name: Install Python Deps
# run: pip install -r requirements.txt
- name: Install NodeJS Deps
run: npm install
# - name: Install NodeJS Deps
# run: npm install
- name: Build Electron App
run: npm run dist
# - name: Build Electron App
# run: npm run dist
- name: Create Release
id: create_release
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
with:
draft: true
allowUpdates: true
replacesArtifacts: true
omitDraftDuringUpdate: true
omitNameDuringUpdate: true
artifacts: "dist/*-mac.dmg"
# - name: Create Release
# id: create_release
# uses: ncipollo/release-action@v1
# with:
# draft: true
# allowUpdates: true
# replacesArtifacts: true
# omitDraftDuringUpdate: true
# omitNameDuringUpdate: true
# artifacts: "dist/*-mac.dmg"
build_linux:
runs-on: ubuntu-latest
@@ -88,17 +95,17 @@ jobs:
contents: write
steps:
- name: Clone Repo
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
uses: actions/checkout@v1
- name: Install NodeJS
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
uses: actions/setup-node@v1
with:
node-version: 22
- name: Install Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"
- name: Install Python Deps
run: pip install -r requirements.txt
@@ -109,16 +116,22 @@ jobs:
- name: Build Electron App
run: npm run dist
- name: Upload Flatpak Artifact
uses: actions/upload-artifact@v4
with:
name: flatpak
path: dist/*.flatpak
- name: Create Release
id: create_release
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
uses: ncipollo/release-action@v1
with:
draft: true
allowUpdates: true
replacesArtifacts: true
omitDraftDuringUpdate: true
omitNameDuringUpdate: true
artifacts: "dist/*-linux.AppImage,dist/*-linux.deb"
artifacts: "dist/*-linux.AppImage"
build_docker:
runs-on: ubuntu-latest
@@ -127,34 +140,31 @@ jobs:
contents: read
steps:
- name: Clone Repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set lowercase repository owner
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
uses: docker/setup-buildx-action@v3
- name: Log in to the GitHub Container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: >-
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
labels: >-
org.opencontainers.image.title=Reticulum MeshChat,
org.opencontainers.image.description=Docker image for Reticulum MeshChat,
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
tags: |
ghcr.io/sudo-ivan/reticulum-meshchat:latest
ghcr.io/sudo-ivan/reticulum-meshchat:${{ github.ref_name }}
labels: |
org.opencontainers.image.title=Reticulum MeshChat
org.opencontainers.image.description=Docker image for Reticulum MeshChat
org.opencontainers.image.url=https://github.com/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/

View File

@@ -11,35 +11,32 @@ jobs:
contents: read
steps:
- name: Clone Repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set lowercase repository owner
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
uses: docker/setup-buildx-action@v3
- name: Log in to the GitHub Container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: >-
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
labels: >-
org.opencontainers.image.title=Reticulum MeshChat,
org.opencontainers.image.description=Docker image for Reticulum MeshChat,
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
tags: |
ghcr.io/sudo-ivan/reticulum-meshchat:latest
ghcr.io/sudo-ivan/reticulum-meshchat:${{ github.ref_name }}
labels: |
org.opencontainers.image.title=Reticulum MeshChat
org.opencontainers.image.description=Docker image for Reticulum MeshChat
org.opencontainers.image.url=https://github.com/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/

4
.gitignore vendored
View File

@@ -10,4 +10,6 @@ node_modules
# local storage
storage/
*.pyc
__pycache__/
config/

View File

@@ -1,45 +1,51 @@
# Build arguments
ARG NODE_VERSION=20
ARG NODE_ALPINE_SHA256=sha256:6a91081a440be0b57336fbc4ee87f3dab1a2fd6f80cdb355dcf960e13bda3b59
ARG PYTHON_VERSION=3.11
ARG PYTHON_ALPINE_SHA256=sha256:822ceb965f026bc47ee667e50a44309d2d81087780bbbf64f2005521781a3621
# Build the frontend
FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
FROM node:22-alpine AS build-frontend
WORKDIR /src
# Copy required source files
COPY *.json .
COPY *.js .
COPY src/frontend ./src/frontend
COPY --chown=node:node *.json .
COPY --chown=node:node *.js .
COPY --chown=node:node src/frontend ./src/frontend
# Install NodeJS deps, exluding electron
# Fix permissions and install NodeJS deps
USER root
RUN chown -R node:node /src
USER node
RUN npm install --omit=dev && \
npm run build-frontend
# Main app build
FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256}
FROM python:3.13-alpine
WORKDIR /app
# Install Python deps
COPY ./requirements.txt .
RUN apk add --no-cache --virtual .build-deps \
gcc \
musl-dev \
linux-headers \
python3-dev && \
pip install -r requirements.txt && \
apk del .build-deps
# Install system dependencies
RUN apk add --no-cache \
gcc \
musl-dev \
python3-dev \
libffi-dev \
openssl-dev
# Copy prebuilt frontend
COPY --from=build-frontend /src/public public
# Create config directories with proper permissions
RUN mkdir -p /config/.reticulum /config/.meshchat && \
chown -R 1000:1000 /config
# Install Python deps
COPY --chown=1000:1000 ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Create public directory and copy frontend
RUN mkdir -p /app/public
COPY --from=build-frontend --chown=1000:1000 /src/public/ /app/public/
# Copy other required source files
COPY *.py .
COPY src/__init__.py ./src/__init__.py
COPY src/backend ./src/backend
COPY *.json .
COPY --chown=1000:1000 *.py .
COPY --chown=1000:1000 src/__init__.py ./src/__init__.py
COPY --chown=1000:1000 src/backend ./src/backend
COPY --chown=1000:1000 *.json .
CMD ["python", "meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
USER 1000
ENTRYPOINT ["python"]
CMD ["meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]

View File

@@ -1,23 +0,0 @@
.PHONY: install run clean
VENV = venv
PYTHON = $(VENV)/bin/python
PIP = $(VENV)/bin/pip
install: $(VENV)
npm install
$(VENV):
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
$(PIP) install -r requirements.txt
run: install
$(PYTHON) meshchat.py
clean:
rm -rf $(VENV)
rm -rf node_modules

324
README.md
View File

@@ -1,36 +1,320 @@
# Reticulum MeshChatX
# Ivans Custom Fork Edition
Custom fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat)
highly experimental and customized, only use if you live on the edge.
## Features of this fork
## Changes
- More stats in about page.
- Exe and Appimage builds with Python 3.13 and Node.js 22
- Actions are pinned to full-length SHA hashes.
- Docker images are smaller and use SHA256 hashes for the images.
- Drop unnecassary permissions (compose)
- Rootless (user 1000:1000)
- Resource Limits (compose)
- Alpine Image Variants.
- Updated Dependencies.
- Dark mode by default.
- Python 3.13 and Node 20.
- Ruff formatting and fixes.
- Flatpak support. (WIP)
## Usage
## Security
Check [releases](https://github.com/Sudo-Ivan/reticulum-meshchatX/releases) for pre-built binaries or appimages.
- Bearer Security Scan Action
- [Socket](https://socket.dev/) Supply Chain Security/Analysis
## Building
---
```bash
make install
make build
<p align="center">
<a href="https://github.com/liamcottle/reticulum-meshchat"><img src="./logo/logo-chat-bubble.png" width="150"></a>
</p>
<h2 align="center">Reticulum MeshChat</h2>
<p align="center">
<a href="https://discord.gg/APQSQZNV7t"><img src="https://img.shields.io/badge/Discord-Liam%20Cottle's%20Discord-%237289DA?style=flat&logo=discord" alt="discord"/></a>
<a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a>
<br/>
<a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a>
<a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a>
</p>
## What is Reticulum MeshChat?
A simple mesh network communications app powered by the [Reticulum Network Stack](https://github.com/markqvist/Reticulum).
<img src="./screenshots/screenshot.png">
## What does it do?
- It can send and receive messages, files and audio calls with peers;
- Over your local network through Ethernet and WiFi, completely automatically.
- Over the internet by connecting through a server [hosted by yourself](https://reticulum.network/manual/interfaces.html#tcp-server-interface) or [the community](https://reticulum.network/connect.html).
- Over low-powered, license-free, ISM band LoRa Radio, with an [RNode](https://github.com/markqvist/RNode_Firmware).
- ...and via [any other interface](https://reticulum.network/manual/interfaces.html) supported by the Reticulum Network Stack.
- It communicates securely. Messages can only be decrypted by the intended destination.
- It can communicate with any other existing [LXMF](https://github.com/markqvist/lxmf) client, such as [Sideband](https://github.com/markqvist/Sideband/) and [Nomadnet](https://github.com/markqvist/nomadnet).
- It can download files and browse micron pages (decentralised websites) hosted on [Nomad Network](https://github.com/markqvist/nomadnet) nodes.
## Features
- Supports sending and receiving messages between [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat), [Sideband](https://github.com/markqvist/Sideband/) and [Nomadnet](https://github.com/markqvist/nomadnet).
- Supports receiving and saving images and attachments sent from Sideband.
- Supports sending images, voice recordings and file attachments.
- Supports saving inbound and outbound messages to a local database.
- Supports sending an announce to the network.
- Supports setting a custom display name to send in your announce.
- Supports viewing and searching peers discovered from announces.
- Supports auto resending undelivered messages when an announce is received from the recipient.
- Supports sending messages to and syncing messages from [LXMF Propagation Nodes](https://github.com/markqvist/lxmf?tab=readme-ov-file#propagation-nodes).
- Supports running a local LXMF Propagation Node so other users can use your device for message storage and retrieval.
- Support for browsing pages, and downloading files hosted on Nomad Network Nodes.
## Beta Features
- Support for Audio Calls to other [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat) users.
- Audio is encoded with [codec2](https://github.com/drowe67/codec2) to support low bandwidth links.
- Using a microphone requires using the web ui over localhost or https, due to [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet) secure context.
- I have tested two-way audio calls over LoRa with a single hop. It works well when a [reasonable bitrate](https://unsigned.io/understanding-lora-parameters/) is configured on the RNode.
- Some browsers such as FireFox don't work as expected. Try using a Chromium based browser if running via the command line.
## Download
You can download the latest version for Windows, Mac and Linux from the [releases](https://github.com/liamcottle/reticulum-meshchat/releases) page.
Alternatively, you can download the source and run it manually from a command line.
See the ["How to use it?"](#how-to-use-it) section, further down on how to do this.
## Other Installation Methods
- [Running MeshChat on Docker](./docs/meshchat_on_docker.md)
- [Running MeshChat on a Raspberry Pi](./docs/meshchat_on_raspberry_pi.md)
- [Running MeshChat on Android with Termux](./docs/meshchat_on_android_with_termux.md)
## Getting Started
Once you've downloaded, installed and launched Reticulum MeshChat, there's a few things you need to do in order to start communicating with other people on the network.
1. Create an Identity
2. Configure your Display Name
3. Send an Announce
4. Discover Peers and start sending messages
5. Configuring additional Network Interfaces
**Create an Identity**
On the Reticulum Network, anyone can have any number of Identities. You may opt to use your real name, or you may decide to be completely anonymous. The choice is yours.
A Reticulum Identity is a public/private key-pair. You control the private key used to generate destination addresses, encrypt content and prove receipt of data with unforgeable delivery acknowledgements.
Your public key is shared with the network when you send an announce, and allows others on the network to automatically discover a route to a destination you control.
At this time, Reticulum MeshChat generates a new Identity the first time you launch it. A future update will allow you to create and manage multiple identities.
For now, if you want to change, or reset your identity, you can access the identity file at `~/.reticulum-meshchat/identity`.
**Configure your Display Name**
The next thing you should do, is set a display name. Your display name is what everyone else on the network will see when looking for someone to communicate with from the Peers list.
You can do this in the `My Identity` section in the bottom left corner. Enter a new display name, and then press `Save`.
**Send an Announce**
When using the Reticulum Network, in order to be contactable, you need to send an `Announce`. You can send an announce as often, or as infrequently as you like.
Sending an announce allows other peers on the network to discover the next-hop across the network their packets should take to arrive at a destination that your identity controls.
If you never send an announce, you will be invisible and no one will ever be able to send anything to you.
When you move across the network, and change entrypoints, such as moving from your home WiFi network, to plugging in to an Ethernet port in a local library or even climbing a mountain and using an RNode over LoRa radio, other peers on the network will only know the previous path to your destinations.
To allow them to discover the new path their packets should take to reach you, you should send an announce.
**Discover Peers and start sending messages**
In the Reticulum Network, you can control an unlimited number of destination addresses. One of these can be an [LXMF](https://github.com/markqvist/lxmf) delivery address.
Your Reticulum Identity allows you to have an LXMF address. Think of an LXMF address as your very own, secure, end-to-end encrypted, unspoofable, email address routed over a mesh network.
When someone else on the network announces themselves (more specifically, their LXMF address), they will show up in the Peers tab.
You can click on any of these discovered peers to open a messaging interface. From here, you can send text messages, files and inline images. If they respond, their messages will show up there too.
As well as being able to announce your LXMF address and discover others, Reticulum MeshChat can also discover [Nomad Network](https://github.com/markqvist/nomadnet) nodes hosted by other users. From the Nodes tab, you are free to explore pages and download files they may be publicly sharing on the network.
A future update is planned to allow you to host your own Node and share pages and files with other peers on the network. For now, you could use the official [Nomad Network](https://github.com/markqvist/nomadnet) client to do this.
Remember, in order to connect with other peers or nodes, they must announce on the network. So don't forget to announce if you want to be discovered!
**Configuring additional Network Interfaces**
> TODO: this section is yet to be written. For now, you can check out the [official documentation for configuring interfaces](https://reticulum.network/manual/interfaces.html) in the Reticulum config file. This file is located at `~/.reticulum/config`
## How does it work?
- A python script ([meshchat.py](./meshchat.py)) runs a Reticulum instance and a WebSocket server.
- The web page sends and receives LXMF packets encoded in json via the WebSocket.
- Web Browser -> WebSocket -> Python Reticulum -> (configured interfaces) -> (destination)
- LXMF messages sent and received are saved to a local SQLite database.
## How to use it?
It is recommended that you [download](#download) a standalone application.
If you don't want to, or a release is unavailable for your device, you will need to;
- install [Python 3](https://www.python.org/downloads/)
- install [NodeJS v18+](https://nodejs.org/en)
- clone the source code from this repo
- install all dependencies
- then run `meshchat.py`.
```
# clone repo
git clone https://github.com/liamcottle/reticulum-meshchat
cd reticulum-meshchat
# install nodejs deps
# if you want to build electron binaries, remove "--omit=dev"
# if you're using termux, add "--ignore-scripts" to fix error with esbuild
npm install --omit=dev
# build frontend vue components
npm run build-frontend
# install python deps
pip install -r requirements.txt
# run meshchat
python meshchat.py
```
### Building in Docker
> NOTE: You should now be able to access the web interface at http://localhost:8000
```bash
make docker-build
For a full list of command line options, you can run;
```
python meshchat.py --help
```
The build will be in the `dist` directory.
```
usage: meshchat.py [-h] [--host [HOST]] [--port [PORT]] [--headless] [--identity-file IDENTITY_FILE] [--identity-base64 IDENTITY_BASE64] [--generate-identity-file GENERATE_IDENTITY_FILE] [--generate-identity-base64]
[--reticulum-config-dir RETICULUM_CONFIG_DIR] [--storage-dir STORAGE_DIR]
## Development
ReticulumMeshChat
```bash
make develop
options:
-h, --help show this help message and exit
--host [HOST] The address the web server should listen on.
--port [PORT] The port the web server should listen on.
--headless Web browser will not automatically launch when this flag is passed.
--identity-file IDENTITY_FILE
Path to a Reticulum Identity file to use as your LXMF address.
--identity-base64 IDENTITY_BASE64
A base64 encoded Reticulum Identity to use as your LXMF address.
--generate-identity-file GENERATE_IDENTITY_FILE
Generates and saves a new Reticulum Identity to the provided file path and then exits.
--generate-identity-base64
Outputs a randomly generated Reticulum Identity as base64 and then exits.
--reticulum-config-dir RETICULUM_CONFIG_DIR
Path to a Reticulum config directory for the RNS stack to use (e.g: ~/.reticulum)
--storage-dir STORAGE_DIR
Path to a directory for storing databases and config files (default: ./storage)
```
## Using an existing Reticulum Identity
The first time you run this application, a new Reticulum identity is generated and saved to `storage/identity`.
If you want to use an existing identity;
- You can overwrite `storage/identity` with another identity file.
- Or, you can pass in a custom identity file path as a command line argument.
To use a custom identity file, provide the `--identity-file` argument followed by the path to your custom identity file.
```
python meshchat.py --identity-file ./custom_identity_file
```
If you would like to generate a new identity, you can use the [rnid](https://reticulum.network/manual/using.html#the-rnid-utility) utility provided by Reticulum.
```
rnid --generate ./new_identity_file
```
If you don't have access to the `rnid` command, you can use the following:
```
python meshchat.py --generate-identity-file ./new_identity_file
```
Alternatively, you can provide a base64 encoded private key, like so;
```
python meshchat.py --identity-base64 "GCN6mMhVemdNIK/fw97C1zvU17qjQPFTXRBotVckeGmoOwQIF8VOjXwNNem3CUOJZCQQpJuc/4U94VSsC39Phw=="
```
> NOTE: this is a randomly generated identity for example purposes. Do not use it, it has been leaked!
## Build Electron Application
Reticulum MeshChat can be run from source via a command line, as explained above, or as a standalone application.
To run as a standalone application, we need to compile the python script and dependencies to an executable with [cxfreeze](https://github.com/marcelotduarte/cx_Freeze) and then build an [Electron](https://www.electronjs.org/) app which includes a bundled browser that can interact with the compiled python executable.
This allows for the entire application to be run by double clicking a single file without the need for a user to manually install python, nor run any commands in a command line application.
To build a `.exe` when running on Windows or a `.dmg` when running on a Mac, run the following;
```
pip install -r requirements.txt
npm install
npm run dist
```
> Note: cxfreeze only supports building an executable for the current platform. You will need a Mac to build for Mac, and a Windows PC to build for Windows.
Once completed, you should have a `.exe` or a `.dmg` in the `dist` folder.
## Local Development
I normally run the following commands to work on the project locally.
**Install dependencies**
```
pip install -r requirements.txt
npm install
```
**Build and run Electron App**
```
npm run electron
```
**or; Build and run MeshChat Server**
```
npm run build-frontend
python3 meshchat.py --headless
```
I build the vite app everytime without hot reload, since MeshChat expects everything over its own port, not the vite server port. I will attempt to fix this in the future.
## TODO
- [ ] button to forget announces
# Notes
**LXMF Router**
- By default, the LXMF router rejects inbound messages larger than 1mb.
- LXMF clients are likely to have [this default limit](https://github.com/markqvist/LXMF/blob/c426c93cc5d63a3dae18ad2264b1299a7ad9e46c/LXMF/LXMRouter.py#L38), and your messages will [fail to send](https://github.com/markqvist/LXMF/blob/c426c93cc5d63a3dae18ad2264b1299a7ad9e46c/LXMF/LXMRouter.py#L1428).
- MeshChat has increased the receive limit to 10mb to allow for larger attachments.
## License
MIT

View File

@@ -4,40 +4,47 @@ from peewee import *
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
latest_version = 5 # increment each time new database migrations are added
database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py
database = (
DatabaseProxy()
) # use a proxy object, as we will init real db client inside meshchat.py
migrator = SqliteMigrator(database)
# migrates the database
def migrate(current_version):
# migrate to version 2
if current_version < 2:
migrate_database(
migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts),
migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at),
migrator.add_column(
"lxmf_messages", "delivery_attempts", LxmfMessage.delivery_attempts
),
migrator.add_column(
"lxmf_messages",
"next_delivery_attempt_at",
LxmfMessage.next_delivery_attempt_at,
),
)
# migrate to version 3
if current_version < 3:
migrate_database(
migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi),
migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr),
migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality),
migrator.add_column("lxmf_messages", "rssi", LxmfMessage.rssi),
migrator.add_column("lxmf_messages", "snr", LxmfMessage.snr),
migrator.add_column("lxmf_messages", "quality", LxmfMessage.quality),
)
# migrate to version 4
if current_version < 4:
migrate_database(
migrator.add_column("lxmf_messages", 'method', LxmfMessage.method),
migrator.add_column("lxmf_messages", "method", LxmfMessage.method),
)
# migrate to version 5
if current_version < 5:
migrate_database(
migrator.add_column("announces", 'rssi', Announce.rssi),
migrator.add_column("announces", 'snr', Announce.snr),
migrator.add_column("announces", 'quality', Announce.quality),
migrator.add_column("announces", "rssi", Announce.rssi),
migrator.add_column("announces", "snr", Announce.snr),
migrator.add_column("announces", "quality", Announce.quality),
)
return latest_version
@@ -49,7 +56,6 @@ class BaseModel(Model):
class Config(BaseModel):
id = BigAutoField()
key = CharField(unique=True)
value = TextField()
@@ -62,12 +68,19 @@ class Config(BaseModel):
class Announce(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash that was announced
aspect = TextField(index=True) # aspect is not included in announce, but we want to filter saved announces by aspect
identity_hash = CharField(index=True) # identity hash that announced the destination
identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually
destination_hash = CharField(
unique=True
) # unique destination hash that was announced
aspect = TextField(
index=True
) # aspect is not included in announce, but we want to filter saved announces by aspect
identity_hash = CharField(
index=True
) # identity hash that announced the destination
identity_public_key = (
CharField()
) # base64 encoded public key, incase we want to recreate the identity manually
app_data = TextField(null=True) # base64 encoded app data bytes
rssi = IntegerField(null=True)
snr = FloatField(null=True)
@@ -82,7 +95,6 @@ class Announce(BaseModel):
class CustomDestinationDisplayName(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
display_name = CharField() # custom display name for the destination hash
@@ -95,37 +107,31 @@ class CustomDestinationDisplayName(BaseModel):
table_name = "custom_destination_display_names"
class FavouriteDestination(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
display_name = CharField() # custom display name for the destination hash
aspect = CharField() # e.g: nomadnetwork.node
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "favourite_destinations"
class LxmfMessage(BaseModel):
id = BigAutoField()
hash = CharField(unique=True) # unique lxmf message hash
source_hash = CharField(index=True)
destination_hash = CharField(index=True)
state = CharField() # state is converted from internal int to a human friendly string
state = (
CharField()
) # state is converted from internal int to a human friendly string
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
method = CharField(null=True) # what method is being used to send the message, e.g: direct, propagated
delivery_attempts = IntegerField(default=0) # how many times delivery has been attempted for this message
next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again
method = CharField(
null=True
) # what method is being used to send the message, e.g: direct, propagated
delivery_attempts = IntegerField(
default=0
) # how many times delivery has been attempted for this message
next_delivery_attempt_at = FloatField(
null=True
) # timestamp of when the message will attempt delivery again
title = TextField()
content = TextField()
fields = TextField() # json string
timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent)
timestamp = (
FloatField()
) # timestamp of when the message was originally created (before ever being sent)
rssi = IntegerField(null=True)
snr = FloatField(null=True)
quality = FloatField(null=True)
@@ -138,7 +144,6 @@ class LxmfMessage(BaseModel):
class LxmfConversationReadState(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
last_read_at = DateTimeField()
@@ -152,12 +157,13 @@ class LxmfConversationReadState(BaseModel):
class LxmfUserIcon(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
icon_name = CharField() # material design icon name for the destination hash
foreground_colour = CharField() # hex colour to use for foreground (icon colour)
background_colour = CharField() # hex colour to use for background (background colour)
background_colour = (
CharField()
) # hex colour to use for background (background colour)
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))

40
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
reticulum-meshchat:
container_name: reticulum-meshchat-dev
build:
context: .
dockerfile: Dockerfile
pull_policy: never
restart: unless-stopped
user: "1000:1000"
# Make the meshchat web interface accessible from the host on port 8000
ports:
- 0.0.0.0:8000:8000
volumes:
- meshchat-config:/config:rw
- .:/app:delegated
- /app/public
# Uncomment if you have a USB device connected, such as an RNode
# devices:
# - /dev/ttyUSB0:/dev/ttyUSB0
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_RAW
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
volumes:
meshchat-config:
driver: local
driver_opts:
type: none
o: bind
device: ${PWD}/config

View File

@@ -1,17 +1,31 @@
services:
reticulum-meshchat:
container_name: reticulum-meshchat
image: ghcr.io/liamcottle/reticulum-meshchat:latest
image: ghcr.io/sudo-ivan/reticulum-meshchat:latest
pull_policy: always
restart: unless-stopped
user: "1000:1000"
# Make the meshchat web interface accessible from the host on port 8000
ports:
- 0.0.0.0:8000:8000
volumes:
- meshchat-config:/config
- meshchat-config:/config:rw
# Uncomment if you have a USB device connected, such as an RNode
# devices:
# - /dev/ttyUSB0:/dev/ttyUSB0
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_RAW
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
volumes:
meshchat-config:

View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Name=Reticulum MeshChat
Comment=Decentralized chat over Reticulum networks
Exec=reticulum-meshchat
Icon=icon
Terminal=false
Type=Application
Categories=Network;Chat;

View File

@@ -0,0 +1,28 @@
{
"app-id": "com.liamcottle.reticulummeshchat",
"runtime": "org.freedesktop.Platform",
"runtime-version": "23.08",
"sdk": "org.freedesktop.Sdk",
"command": "reticulum-meshchat",
"finish-args": [
"--share=network",
"--socket=x11",
"--socket=wayland",
"--device=all"
],
"modules": [
{
"name": "reticulum-meshchat",
"buildsystem": "simple",
"build-commands": [
"install -Dm755 dist/ReticulumMeshChat-v$FLATPAK_APP_VERSION-linux.AppImage /app/bin/reticulum-meshchat"
],
"sources": [
{
"type": "file",
"path": "dist/ReticulumMeshChat-v$FLATPAK_APP_VERSION-linux.AppImage"
}
]
}
]
}

View File

@@ -22,27 +22,6 @@ ipcMain.handle('alert', async(event, message) => {
});
});
// add support for showing a confirm window via ipc
ipcMain.handle('confirm', async(event, message) => {
// show confirm dialog
const result = await dialog.showMessageBox(mainWindow, {
type: "question",
title: "Confirm",
message: message,
cancelId: 0, // esc key should press cancel button
defaultId: 1, // enter key should press ok button
buttons: [
"Cancel", // 0
"OK", // 1
],
});
// check if user clicked OK
return result.response === 1;
});
// add support for showing a prompt window via ipc
ipcMain.handle('prompt', async(event, message) => {
return await electronPrompt({
@@ -120,8 +99,9 @@ function getDefaultReticulumConfigDir() {
app.whenReady().then(async () => {
// get arguments passed to application, and remove the provided application path
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
const ignoredArguments = ["--no-sandbox"];
const userProvidedArguments = process.argv.slice(1)
.filter(arg => !ignoredArguments.includes(arg));
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
if(!shouldLaunchHeadless){

View File

@@ -15,11 +15,6 @@ contextBridge.exposeInMainWorld('electron', {
return await ipcRenderer.invoke('alert', message);
},
// show a confirm dialog in electron browser window, this fixes a bug where confirm breaks input fields on windows
confirm: async function(message) {
return await ipcRenderer.invoke('confirm', message);
},
// add support for using "prompt" in electron browser window
prompt: async function(message) {
return await ipcRenderer.invoke('prompt', message);

View File

File diff suppressed because it is too large Load Diff

1382
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "reticulum-meshchat",
"version": "2.32.3",
"version": "1.23.2",
"description": "",
"main": "electron/main.js",
"scripts": {
@@ -14,10 +14,10 @@
},
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=22"
},
"devDependencies": {
"electron": "^35.7.5",
"electron": "^30.0.8",
"electron-builder": "^24.6.3"
},
"build": {
@@ -72,9 +72,10 @@
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
"target": [
"AppImage",
"deb"
"flatpak"
],
"maintainer": "Liam Cottle <liam@liamcottle.com>",
"category": "Network",
"icon": "electron/build/icon.png",
"extraFiles": [
{
"from": "build/exe",
@@ -95,28 +96,36 @@
"artifactName": "ReticulumMeshChat-v${version}-${os}-installer.${ext}",
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"flatpak": {
"finishArgs": [
"--share=network",
"--socket=x11",
"--socket=wayland",
"--device=all"
]
}
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.9",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.20",
"axios": "^1.12.0",
"axios": "^1.9.0",
"click-outside-vue3": "^4.0.1",
"compressorjs": "^1.2.1",
"electron-prompt": "^1.7.0",
"micron-parser": "^1.0.2",
"micron-parser": "^1.0.1",
"mitt": "^3.0.1",
"moment": "^2.30.1",
"postcss": "^8.4.49",
"protobufjs": "^7.4.0",
"protobufjs": "^7.5.1",
"tailwindcss": "^3.4.17",
"vis-data": "^7.1.9",
"vis-network": "^9.1.9",
"vite": "^6.4.1",
"vite": "^6.3.5",
"vite-plugin-vuetify": "^2.0.4",
"vue-router": "^4.5.0",
"vuetify": "^3.7.6"
"vue-router": "^4.5.1",
"vuetify": "^3.8.4"
}
}

View File

@@ -1,7 +1,6 @@
aiohttp>=3.12.14
aiohttp>=3.11.18
cx_freeze>=7.0.0
lxmf>=0.9.3
lxmf>=0.6.3
peewee>=3.18.1
psutil>=7.1.3
rns>=1.0.4
websockets>=14.2
rns>=0.9.5
websockets>=15.0.1

View File

@@ -1,50 +1,44 @@
from cx_Freeze import setup, Executable
setup(
name='ReticulumMeshChat',
version='1.0.0',
description='A simple mesh network communications app powered by the Reticulum Network Stack',
name="ReticulumMeshChat",
version="1.0.0",
description="A simple mesh network communications app powered by the Reticulum Network Stack",
executables=[
Executable(
script='meshchat.py', # this script to run
base=None, # we are running a console application, not a gui
target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe
shortcut_name='ReticulumMeshChat', # name shown in shortcut
shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu
icon='logo/icon.ico', # set the icon for the exe
copyright='Copyright (c) 2024 Liam Cottle',
script="meshchat.py", # this script to run
base=None, # we are running a console application, not a gui
target_name="ReticulumMeshChat", # creates ReticulumMeshChat.exe
shortcut_name="ReticulumMeshChat", # name shown in shortcut
shortcut_dir="ProgramMenuFolder", # put the shortcut in windows start menu
icon="logo/icon.ico", # set the icon for the exe
copyright="Copyright (c) 2024 Liam Cottle",
),
],
options={
'build_exe': {
"build_exe": {
# libs that are required
'packages': [
"packages": [
# required for dynamic import fix
# https://github.com/marcelotduarte/cx_Freeze/discussions/2039
# https://github.com/marcelotduarte/cx_Freeze/issues/2041
'RNS',
'RNS.Interfaces',
'LXMF',
"RNS",
],
# files that are required
'include_files': [
'package.json', # used to determine app version from python
'public/', # static files served by web server
"include_files": [
"package.json", # used to determine app version from python
"public/", # static files served by web server
],
# slim down the build by excluding these unused libs
'excludes': [
'PIL', # saves ~200MB
"excludes": [
"PIL", # saves ~200MB
],
# this has the same effect as the -O command line option when executing CPython directly.
# it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
# https://stackoverflow.com/a/57948104
"optimize": 2,
# change where exe is built to
'build_exe': 'build/exe',
# make the build relocatable by replacing absolute paths
'replace_paths': [
('*', ''),
],
"build_exe": "build/exe",
},
},
)

View File

@@ -7,7 +7,6 @@ import sys
# this class forces stream writes to be flushed immediately
class ImmediateFlushingStreamWrapper:
def __init__(self, stream):
self.stream = stream

View File

@@ -1,16 +1,23 @@
# an announce handler that forwards announces to a provided callback for the provided aspect filter
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
class AnnounceHandler:
def __init__(self, aspect_filter: str, received_announce_callback):
self.aspect_filter = aspect_filter
self.received_announce_callback = received_announce_callback
# we will just pass the received announce back to the provided callback
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash):
def received_announce(
self, destination_hash, announced_identity, app_data, announce_packet_hash
):
try:
# handle received announce
self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
self.received_announce_callback(
self.aspect_filter,
destination_hash,
announced_identity,
app_data,
announce_packet_hash,
)
except:
# ignore failure to handle received announce
pass

View File

@@ -1,25 +1,23 @@
import asyncio
from typing import Coroutine
class AsyncUtils:
# remember main loop
main_loop: asyncio.AbstractEventLoop | None = None
@staticmethod
def set_main_loop(loop: asyncio.AbstractEventLoop):
AsyncUtils.main_loop = loop
# this method allows running the provided async coroutine from within a sync function
# it will run the async function on the main event loop if possible, otherwise it logs a warning
# it will run the async function on the existing event loop if available, otherwise it will start a new event loop
@staticmethod
def run_async(coroutine: Coroutine):
def run_async(coroutine):
# attempt to get existing event loop
existing_event_loop = None
try:
existing_event_loop = asyncio.get_running_loop()
except RuntimeError:
# 'RuntimeError: no running event loop'
pass
# run provided coroutine on main event loop, ensuring thread safety
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
# if there is an existing event loop running, submit the coroutine to that loop
if existing_event_loop and existing_event_loop.is_running():
existing_event_loop.create_task(coroutine)
return
# main event loop not running...
print("WARNING: Main event loop not available. Could not schedule task.")
# otherwise start a new event loop to run the coroutine
asyncio.run(coroutine)

View File

@@ -13,7 +13,6 @@ class CallFailedException(Exception):
class AudioCall:
def __init__(self, link: RNS.Link, is_outbound: bool):
self.link = link
self.is_outbound = is_outbound
@@ -41,21 +40,25 @@ class AudioCall:
# handle packet received over link
def on_packet(self, message, packet):
# send audio received from call initiator to all audio packet listeners
for audio_packet_listener in self.audio_packet_listeners:
audio_packet_listener(message)
# send an audio packet over the link
def send_audio_packet(self, data):
# do nothing if link is not active
if self.is_active() is False:
return
# drop audio packet if it is too big to send
if len(data) > RNS.Link.MDU:
print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
print(
"[AudioCall] dropping audio packet "
+ str(len(data))
+ " bytes exceeds the link packet MDU of "
+ str(RNS.Link.MDU)
+ " bytes"
)
return
# send codec2 audio received from call receiver to call initiator over reticulum link
@@ -77,9 +80,7 @@ class AudioCall:
class AudioCallManager:
def __init__(self, identity: RNS.Identity):
self.identity = identity
self.on_incoming_call_callback = None
self.on_outgoing_call_callback = None
@@ -91,7 +92,10 @@ class AudioCallManager:
# announces the audio call destination
def announce(self, app_data=None):
self.audio_call_receiver.destination.announce(app_data)
print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash))
print(
"[AudioCallManager] announced destination: "
+ RNS.prettyhexrep(self.audio_call_receiver.destination.hash)
)
# set the callback for incoming calls
def register_incoming_call_callback(self, callback):
@@ -103,7 +107,6 @@ class AudioCallManager:
# handle incoming calls from audio call receiver
def handle_incoming_call(self, audio_call: AudioCall):
# remember it
self.audio_calls.append(audio_call)
@@ -113,7 +116,6 @@ class AudioCallManager:
# handle outgoing calls
def handle_outgoing_call(self, audio_call: AudioCall):
# remember it
self.audio_calls.append(audio_call)
@@ -145,19 +147,22 @@ class AudioCallManager:
return None
# attempts to initiate a call to the provided destination and returns the link hash on success
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall:
async def initiate(
self, destination_hash: bytes, timeout_seconds: int = 15
) -> AudioCall:
# determine when to timeout
timeout_after_seconds = time.time() + timeout_seconds
# check if we have a path to the destination
if not RNS.Transport.has_path(destination_hash):
# we don't have a path, so we need to request it
RNS.Transport.request_path(destination_hash)
# wait until we have a path, or give up after the configured timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds:
while (
not RNS.Transport.has_path(destination_hash)
and time.time() < timeout_after_seconds
):
await asyncio.sleep(0.1)
# if we still don't have a path, we can't establish a link, so bail out
@@ -171,14 +176,16 @@ class AudioCallManager:
RNS.Destination.OUT,
RNS.Destination.SINGLE,
"call",
"audio"
"audio",
)
# create link
link = RNS.Link(server_destination)
# wait until we have established a link, or give up after the configured timeout
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
while (
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
):
await asyncio.sleep(0.1)
# if we still haven't established a link, bail out
@@ -198,9 +205,7 @@ class AudioCallManager:
class AudioCallReceiver:
def __init__(self, manager: AudioCallManager):
self.manager = manager
# create destination for receiving audio calls
@@ -224,7 +229,6 @@ class AudioCallReceiver:
# client connected to us, set up an audio call instance
def client_connected(self, link: RNS.Link):
# todo: this can be optional, it's only being sent by default for ui, can be removed
link.identify(self.manager.identity)

View File

@@ -1,10 +1,8 @@
class ColourUtils:
@staticmethod
def hex_colour_to_byte_array(hex_colour):
# remove leading "#"
hex_colour = hex_colour.lstrip('#')
hex_colour = hex_colour.lstrip("#")
# convert the remaining hex string to bytes
return bytes.fromhex(hex_colour)

View File

@@ -2,10 +2,8 @@ import RNS.vendor.configobj
class InterfaceConfigParser:
@staticmethod
def parse(text):
# get lines from provided text
lines = text.splitlines()
@@ -22,7 +20,6 @@ class InterfaceConfigParser:
# process interfaces
interfaces = []
for interface_name in config_interfaces:
# ensure interface has a name
interface_config = config_interfaces[interface_name]
interface_config["name"] = interface_name

View File

@@ -1,8 +1,6 @@
class InterfaceEditor:
@staticmethod
def update_value(interface_details: dict, data: dict, key: str):
# update value if provided and not empty
value = data.get(key)
if value is not None and value != "":

View File

@@ -8,7 +8,6 @@ from websockets.sync.connection import Connection
class WebsocketClientInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
@@ -18,7 +17,6 @@ class WebsocketClientInterface(Interface):
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
def __init__(self, owner, configuration, websocket: Connection = None):
super().__init__()
self.owner = owner
@@ -26,8 +24,8 @@ class WebsocketClientInterface(Interface):
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
# parse config
@@ -48,7 +46,6 @@ class WebsocketClientInterface(Interface):
# called when a full packet has been received over the websocket
def process_incoming(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
@@ -65,7 +62,6 @@ class WebsocketClientInterface(Interface):
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
def process_outgoing(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
@@ -74,7 +70,9 @@ class WebsocketClientInterface(Interface):
try:
self.websocket.send(data)
except Exception as e:
RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR)
RNS.log(
f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR
)
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
return
@@ -87,7 +85,6 @@ class WebsocketClientInterface(Interface):
# connect to the configured websocket server
def connect(self):
# do nothing if interface is detached
if self.detached:
return
@@ -95,7 +92,9 @@ class WebsocketClientInterface(Interface):
# connect to websocket server
try:
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
self.websocket = connect(f"{self.target_url}", max_size=None, compression=None)
self.websocket = connect(
f"{self.target_url}", max_size=None, compression=None
)
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
self.read_loop()
except Exception as e:
@@ -107,7 +106,6 @@ class WebsocketClientInterface(Interface):
self.connect()
def read_loop(self):
self.online = True
try:
@@ -119,7 +117,6 @@ class WebsocketClientInterface(Interface):
self.online = False
def detach(self):
# mark as offline
self.online = False
@@ -130,5 +127,6 @@ class WebsocketClientInterface(Interface):
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketClientInterface

View File

@@ -11,25 +11,25 @@ from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInter
class WebsocketServerInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RESTART_DELAY_SECONDS = 5
def __str__(self):
return f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
return (
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
)
def __init__(self, owner, configuration):
super().__init__()
self.owner = owner
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.server: Server | None = None
@@ -80,17 +80,19 @@ class WebsocketServerInterface(Interface):
pass
def serve(self):
# handle new websocket client connections
def on_websocket_client_connected(websocket: ServerConnection):
# create new child interface
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
spawned_interface = WebsocketClientInterface(self.owner, {
"name": f"Client on {self.name}",
"target_host": websocket.remote_address[0],
"target_port": str(websocket.remote_address[1]),
}, websocket=websocket)
spawned_interface = WebsocketClientInterface(
self.owner,
{
"name": f"Client on {self.name}",
"target_host": websocket.remote_address[0],
"target_port": str(websocket.remote_address[1]),
},
websocket=websocket,
)
# configure child interface
spawned_interface.IN = self.IN
@@ -110,7 +112,10 @@ class WebsocketServerInterface(Interface):
# todo announce rates?
# activate child interface
RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE)
RNS.log(
f"Spawned new WebsocketClientInterface: {spawned_interface}",
RNS.LOG_VERBOSE,
)
RNS.Transport.interfaces.append(spawned_interface)
# associate child interface with this interface
@@ -127,7 +132,12 @@ class WebsocketServerInterface(Interface):
# run websocket server
try:
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server:
with serve(
on_websocket_client_connected,
self.listen_ip,
self.listen_port,
compression=None,
) as server:
self.online = True
self.server = server
server.serve_forever()
@@ -141,7 +151,6 @@ class WebsocketServerInterface(Interface):
self.serve()
def detach(self):
# mark as offline
self.online = False
@@ -152,5 +161,6 @@ class WebsocketServerInterface(Interface):
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketServerInterface

View File

@@ -3,7 +3,6 @@ from typing import List
# helper class for passing around an lxmf audio field
class LxmfAudioField:
def __init__(self, audio_mode: int, audio_bytes: bytes):
self.audio_mode = audio_mode
self.audio_bytes = audio_bytes
@@ -11,7 +10,6 @@ class LxmfAudioField:
# helper class for passing around an lxmf image field
class LxmfImageField:
def __init__(self, image_type: str, image_bytes: bytes):
self.image_type = image_type
self.image_bytes = image_bytes
@@ -19,7 +17,6 @@ class LxmfImageField:
# helper class for passing around an lxmf file attachment
class LxmfFileAttachment:
def __init__(self, file_name: str, file_bytes: bytes):
self.file_name = file_name
self.file_bytes = file_bytes
@@ -27,7 +24,5 @@ class LxmfFileAttachment:
# helper class for passing around an lxmf file attachments field
class LxmfFileAttachmentsField:
def __init__(self, file_attachments: List[LxmfFileAttachment]):
self.file_attachments = file_attachments

View File

@@ -448,7 +448,7 @@ export default {
// ask to stop syncing if already syncing
if(this.isSyncingPropagationNode){
if(await DialogUtils.confirm("Are you sure you want to stop syncing?")){
if(confirm("Are you sure you want to stop syncing?")){
await this.stopSyncingPropagationNode();
}
return;
@@ -529,7 +529,7 @@ export default {
async hangupAllCalls() {
// confirm user wants to hang up calls
if(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
return;
}

View File

@@ -12,7 +12,7 @@
<div class="mr-auto">
<div>Versions</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }}
</div>
</div>
<div class="hidden sm:block mx-2 my-auto">
@@ -64,141 +64,6 @@
</div>
</div>
<!-- system resources -->
<div v-if="appInfo && appInfo.memory_usage" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
System Resources
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- memory usage -->
<div class="flex p-1">
<div class="mr-auto">
<div>Memory Usage (RSS)</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
</div>
</div>
<!-- virtual memory -->
<div class="flex p-1">
<div class="mr-auto">
<div>Virtual Memory Size</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
</div>
</div>
</div>
</div>
<!-- network statistics -->
<div v-if="appInfo && appInfo.network_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Network Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- bytes sent -->
<div class="flex p-1">
<div class="mr-auto">
<div>Data Sent</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
</div>
</div>
<!-- bytes received -->
<div class="flex p-1">
<div class="mr-auto">
<div>Data Received</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
</div>
</div>
<!-- packets sent -->
<div class="p-1">
<div>Packets Sent</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
</div>
<!-- packets received -->
<div class="p-1">
<div>Packets Received</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
</div>
</div>
</div>
<!-- reticulum statistics -->
<div v-if="appInfo && appInfo.reticulum_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Reticulum Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- total paths -->
<div class="p-1">
<div>Total Paths</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
</div>
<!-- announces per second -->
<div class="p-1">
<div>Announces per Second</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}</div>
</div>
<!-- announces per minute -->
<div class="p-1">
<div>Announces per Minute</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}</div>
</div>
<!-- announces per hour -->
<div class="p-1">
<div>Announces per Hour</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}</div>
</div>
</div>
</div>
<!-- download statistics -->
<div v-if="appInfo && appInfo.download_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Download Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- average download speed -->
<div class="p-1">
<div>Average Download Speed</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">
<span v-if="appInfo.download_stats.avg_download_speed_bps !== null">
{{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }}
</span>
<span v-else>No downloads yet</span>
</div>
</div>
</div>
</div>
<!-- reticulum status -->
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">Reticulum Status</div>
@@ -261,21 +126,11 @@ export default {
return {
appInfo: null,
config: null,
updateInterval: null,
};
},
mounted() {
this.getAppInfo();
this.getConfig();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
}, 5000);
},
beforeUnmount() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
},
methods: {
async getAppInfo() {
@@ -311,12 +166,6 @@ export default {
formatBytes: function(bytes) {
return Utils.formatBytes(bytes);
},
formatNumber: function(num) {
return Utils.formatNumber(num);
},
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
},
computed: {
isElectron() {

View File

@@ -259,7 +259,6 @@
<script>
import protobuf from "protobufjs";
import DialogUtils from "../../js/DialogUtils";
export default {
name: 'CallPage',
data() {
@@ -489,7 +488,7 @@ export default {
async hangupCall(callHash) {
// confirm user wants to hang up call
if(!await DialogUtils.confirm("Are you sure you want to hang up this call?")){
if(!confirm("Are you sure you want to hang up this call?")){
return;
}
@@ -682,7 +681,7 @@ export default {
async deleteCall(callHash) {
// confirm user wants to delete call
if(!await DialogUtils.confirm("Are you sure you want to delete this call?")){
if(!confirm("Are you sure you want to delete this call?")){
return;
}
@@ -702,7 +701,7 @@ export default {
async clearCallHistory() {
// confirm user wants to clear call history
if(!await DialogUtils.confirm("Are you sure you want to clear your call history?")){
if(!confirm("Are you sure you want to clear your call history?")){
return;
}

View File

@@ -690,7 +690,7 @@
<input
type="number"
v-model="newInterfaceAirtimeLimitShort"
placeholder="Enter short airtime limit (% of a rolling 15 seconds window)"
placeholder="Enter short airtime limit (seconds)"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white"
/>
</div>
@@ -699,7 +699,7 @@
<input
type="number"
v-model="newInterfaceAirtimeLimitLong"
placeholder="Enter long airtime limit (% of a rolling 60 minutes window)"
placeholder="Enter long airtime limit (seconds)"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white"
/>
</div>

View File

@@ -191,7 +191,7 @@ export default {
async deleteInterface(interfaceName) {
// ask user to confirm deleting conversation history
if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){
if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){
return;
}

View File

@@ -73,7 +73,7 @@ export default {
async onDeleteMessageHistory() {
// ask user to confirm deleting conversation history
if(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
return;
}

View File

@@ -997,7 +997,7 @@ export default {
try {
// ask user to confirm deleting message
if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){
if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){
return;
}
@@ -1056,11 +1056,7 @@ export default {
if(this.newMessageImage){
imageTotalSize = this.newMessageImage.size;
fields["image"] = {
// Reticulum sends image type as "jpg", "png", "webp" etc and not "image/jpg" or "image/png"
// From memory, Sideband would not display images if the image type has the "image/" prefix
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/docs/example_plugins/view.py#L78
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/main.py#L1900
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/ui/messages.py#L783
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png"
"image_type": this.newMessageImage.type.replace("image/", ""),
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
};
@@ -1082,7 +1078,7 @@ export default {
// ask user if they still want to send message if it may be rejected by sender
if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
if(!await DialogUtils.confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
if(!confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
return;
}
}
@@ -1213,10 +1209,10 @@ export default {
clearFileInput: function() {
this.$refs["file-input"].value = null;
},
async removeImageAttachment() {
removeImageAttachment: function() {
// ask user to confirm removing image attachment
if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){
if(!confirm("Are you sure you want to remove this image attachment?")){
return;
}
@@ -1248,7 +1244,7 @@ export default {
}
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
if(this.newMessageAudio && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
return;
}
@@ -1390,10 +1386,10 @@ export default {
}
},
async removeAudioAttachment() {
removeAudioAttachment: function() {
// ask user to confirm removing audio attachment
if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){
if(!confirm("Are you sure you want to remove this audio attachment?")){
return;
}

View File

@@ -3,61 +3,22 @@
<!-- nomadnetwork sidebar -->
<NomadNetworkSidebar
:nodes="nodes"
:favourites="favourites"
:selected-destination-hash="selectedNode?.destination_hash"
@node-click="onNodeClick"
@rename-favourite="onRenameFavourite"
@remove-favourite="onRemoveFavourite"/>
@node-click="onNodeClick"/>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
<!-- node -->
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
<!-- header -->
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
<!-- favourite button -->
<div class="my-auto mr-2">
<div v-if="isFavourite(selectedNode.destination_hash)" @click="removeFavourite(selectedNode)" class="cursor-pointer">
<div class="flex text-yellow-500 dark:text-yellow-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<div v-else @click="addFavourite(selectedNode)" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</div>
</div>
</div>
</div>
<!-- node info -->
<div class="my-auto dark:text-gray-100">
<span class="font-semibold">{{ selectedNode.display_name }}</span>
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
</div>
<!-- identify button -->
<div class="my-auto ml-auto mr-2">
<div @click="identify(selectedNode.destination_hash)" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 0 0 4.5 10.5a7.464 7.464 0 0 1-1.15 3.993m1.989 3.559A11.209 11.209 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.666 18.666 0 0 1-2.485 5.33" />
</svg>
</div>
</div>
</div>
</div>
<!-- close button -->
<div class="my-auto mr-2">
<div class="my-auto ml-auto mr-2">
<div @click="onCloseNodeViewer" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
@@ -124,12 +85,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div class="my-auto">
Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)
<span v-if="nodeFileDownloadSpeed !== null" class="ml-2 text-sm">
- {{ formatBytesPerSecond(nodeFileDownloadSpeed) }}
</span>
</div>
<div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div>
</div>
</div>
@@ -184,7 +140,6 @@ import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
import Utils from "../../js/Utils";
export default {
name: 'NomadNetworkPage',
@@ -197,14 +152,10 @@ export default {
data() {
return {
reloadInterval: null,
nodes: {},
selectedNode: null,
selectedNodePath: null,
favourites: [],
isLoadingNodePage: false,
isShowingNodePageSource: false,
defaultNodePagePath: "/page/index.mu",
@@ -219,10 +170,6 @@ export default {
isDownloadingNodeFile: false,
nodeFilePath: null,
nodeFileProgress: 0,
nodeFileDownloadStartTime: null,
nodeFileLastProgressTime: null,
nodeFileLastProgressValue: 0,
nodeFileDownloadSpeed: null,
nomadnetPageDownloadCallbacks: {},
nomadnetFileDownloadCallbacks: {},
@@ -231,8 +178,6 @@ export default {
},
beforeUnmount() {
clearInterval(this.reloadInterval);
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
@@ -257,14 +202,8 @@ export default {
})();
}
this.getFavourites();
this.getNomadnetworkNodeAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getFavourites();
}, 5000);
},
methods: {
onElementClick(event) {
@@ -370,54 +309,6 @@ export default {
onDestinationPathClick: function(path) {
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
},
async getFavourites() {
try {
const response = await window.axios.get("/api/v1/favourites", {
params: {
aspect: "nomadnetwork.node",
},
});
this.favourites = response.data.favourites;
} catch(e) {
// do nothing if failed to load favourites
console.log(e);
}
},
isFavourite(destinationHash) {
return this.favourites.find((favourite) => {
return favourite.destination_hash === destinationHash;
}) != null;
},
async addFavourite(node) {
// add to favourites
try {
await window.axios.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
display_name: node.display_name,
aspect: "nomadnetwork.node",
});
} catch(e) {
console.log(e);
}
// update favourites
this.getFavourites();
},
async removeFavourite(node) {
// remove from favourites
try {
await window.axios.delete(`/api/v1/favourites/${node.destination_hash}`);
} catch(e) {
console.log(e);
}
// update favourites
this.getFavourites();
},
async getNomadnetworkNodeAnnounces() {
try {
@@ -425,7 +316,6 @@ export default {
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "nomadnetwork.node",
limit: 500, // limit ui to showing 500 latest announces
},
});
@@ -766,72 +656,26 @@ export default {
this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFileProgress = 0;
this.nodeFileDownloadStartTime = Date.now();
this.nodeFileLastProgressTime = Date.now();
this.nodeFileLastProgressValue = 0;
this.nodeFileDownloadSpeed = null;
// start file download
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
// Calculate final download speed based on actual file size
if (this.nodeFileDownloadStartTime) {
const totalTime = (Date.now() - this.nodeFileDownloadStartTime) / 1000; // seconds
const fileSizeBytes = atob(fileBytesBase64).length;
if (totalTime > 0) {
this.nodeFileDownloadSpeed = fileSizeBytes / totalTime;
}
}
// no longer downloading
this.isDownloadingNodeFile = false;
// download file to browser
this.downloadFileFromBase64(fileName, fileBytesBase64);
// Clear speed after a moment
setTimeout(() => {
this.nodeFileDownloadSpeed = null;
}, 2000);
}, (failureReason) => {
// no longer downloading
this.isDownloadingNodeFile = false;
this.nodeFileDownloadSpeed = null;
// show error message
DialogUtils.alert(`Failed to download file: ${failureReason}`);
}, (progress) => {
const currentTime = Date.now();
const progressValue = progress;
this.nodeFileProgress = Math.round(progressValue * 100);
// Calculate estimated download speed based on progress rate
if (this.nodeFileDownloadStartTime && progressValue > 0) {
const elapsedTime = (currentTime - this.nodeFileDownloadStartTime) / 1000; // seconds
if (elapsedTime > 0.5) { // Only calculate after at least 0.5 seconds
// Estimate total file size based on progress rate
// If we've downloaded progressValue in elapsedTime, estimate total time
const estimatedTotalTime = elapsedTime / progressValue;
// Estimate file size based on average download speed assumption
// We'll refine this when download completes with actual size
// For now, estimate based on typical mesh network file sizes (100KB-10MB range)
// Use a conservative estimate that will be updated when download completes
const estimatedFileSize = 500 * 1024; // Start with 500KB estimate
const estimatedBytesDownloaded = estimatedFileSize * progressValue;
const estimatedSpeed = estimatedBytesDownloaded / elapsedTime;
// Only update if we have a reasonable estimate
if (estimatedSpeed > 0 && estimatedSpeed < 100 * 1024 * 1024) { // Cap at 100MB/s
this.nodeFileDownloadSpeed = estimatedSpeed;
}
}
}
this.nodeFileLastProgressTime = currentTime;
this.nodeFileLastProgressValue = progressValue;
this.nodeFileProgress = Math.round(progress * 100);
});
return;
@@ -885,9 +729,6 @@ export default {
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
},
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onNodeClick: function(node) {
// update selected node
@@ -896,40 +737,6 @@ export default {
// load default node page
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
},
async onRenameFavourite(favourite) {
// ask user for new display name
const displayName = await DialogUtils.prompt("Rename this favourite");
if(displayName == null){
return;
}
try {
// rename on server
await axios.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, {
display_name: displayName,
});
// reload favourites
await this.getFavourites();
} catch(e) {
console.log(e);
DialogUtils.alert("Failed to rename favourite");
}
},
async onRemoveFavourite(favourite) {
// ask user to confirm
if(!await DialogUtils.confirm("Are you sure you want to remove this favourite?")){
return;
}
this.removeFavourite(favourite);
},
onCloseNodeViewer: function() {
@@ -966,24 +773,6 @@ export default {
}
},
async identify(destinationHash) {
try {
// ask user to confirm
if(!await DialogUtils.confirm("Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent.")){
return;
}
// identify self to nomadnetwork node
await window.axios.post(`/api/v1/nomadnetwork/${destinationHash}/identify`);
// reload page
this.reloadNodePage();
} catch(e) {
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
}
},
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
try {

View File

@@ -1,99 +1,6 @@
<template>
<div class="flex flex-col w-80 min-w-80">
<!-- tabs -->
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
<div class="-mb-px flex">
<div @click="tab = 'favourites'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'favourites' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Favourites</div>
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Announces</div>
</div>
</div>
<!-- favourites -->
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
<!-- search -->
<div v-if="favourites.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} Favourites...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
</div>
<!-- peers -->
<div class="flex h-full overflow-y-auto">
<div v-if="searchedFavourites.length > 0" class="w-full">
<div @click="onFavouriteClick(favourite)" v-for="favourite of searchedFavourites" class="flex cursor-pointer p-2 border-l-2" :class="[ favourite.destination_hash === selectedDestinationHash ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400' : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600' ]">
<div class="my-auto mr-2">
<div class="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
</div>
</div>
<div>
<div class="text-gray-900 dark:text-gray-100">{{ favourite.display_name }}</div>
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatDestinationHash(favourite.destination_hash) }}</div>
</div>
<div class="ml-auto my-auto">
<DropDownMenu>
<template v-slot:button>
<IconButton class="bg-transparent dark:bg-transparent">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</svg>
</IconButton>
</template>
<template v-slot:items>
<!-- rename button -->
<DropDownMenuItem @click="onRenameFavourite(favourite)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M5.25 2.25a3 3 0 0 0-3 3v4.318a3 3 0 0 0 .879 2.121l9.58 9.581c.92.92 2.39 1.186 3.548.428a18.849 18.849 0 0 0 5.441-5.44c.758-1.16.492-2.629-.428-3.548l-9.58-9.581a3 3 0 0 0-2.122-.879H5.25ZM6.375 7.5a1.125 1.125 0 1 0 0-2.25 1.125 1.125 0 0 0 0 2.25Z" clip-rule="evenodd" />
</svg>
<span>Rename Favourite</span>
</DropDownMenuItem>
<!-- remove favourite button -->
<div>
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
</svg>
<span class="text-red-500">Remove Favourite</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</div>
</div>
</div>
<div v-else class="mx-auto my-auto text-center leading-5">
<!-- no favourites at all -->
<div v-if="favourites.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</div>
<div class="font-semibold">No Favourites</div>
<div>Discover nodes on the Announces tab.</div>
</div>
<!-- is searching, but no results -->
<div v-if="favouritesSearchTerm !== '' && favourites.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<div class="font-semibold">No Search Results</div>
<div>Your search didn't match any Favourites!</div>
</div>
</div>
</div>
</div>
<!-- announces -->
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
<div class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
<!-- search -->
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
<input
@@ -151,7 +58,6 @@
</div>
</div>
</div>
</div>
</template>
@@ -159,22 +65,16 @@
import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DropDownMenu from "../DropDownMenu.vue";
import IconButton from "../IconButton.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
export default {
name: 'NomadNetworkSidebar',
components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
components: {MaterialDesignIcon},
props: {
nodes: Object,
favourites: Array,
selectedDestinationHash: String,
},
data() {
return {
tab: "favourites",
favouritesSearchTerm: "",
nodesSearchTerm: "",
};
},
@@ -182,21 +82,9 @@ export default {
onNodeClick(node) {
this.$emit("node-click", node);
},
onFavouriteClick(favourite) {
this.onNodeClick(favourite);
},
onRenameFavourite(favourite) {
this.$emit("rename-favourite", favourite);
},
onRemoveFavourite(favourite) {
this.$emit("remove-favourite", favourite);
},
formatTimeAgo: function(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
formatDestinationHash: function(destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
},
computed: {
nodesCount() {
@@ -219,15 +107,6 @@ export default {
return matchesDisplayName || matchesDestinationHash;
});
},
searchedFavourites() {
return this.favourites.filter((favourite) => {
const search = this.favouritesSearchTerm.toLowerCase();
const matchesDisplayName = favourite.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName = favourite.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
},
}
</script>

View File

@@ -145,7 +145,7 @@ export default {
}
// confirm user wants to update their icon
if(!await DialogUtils.confirm("Are you sure you want to set this as your profile icon?")){
if(!confirm("Are you sure you want to set this as your profile icon?")){
return;
}
@@ -160,7 +160,7 @@ export default {
async removeProfileIcon() {
// confirm user wants to remove their icon
if(!await DialogUtils.confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
if(!confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
return;
}

View File

@@ -10,16 +10,6 @@ class DialogUtils {
}
}
static confirm(message) {
if(window.electron){
// running inside electron, use ipc confirm
return window.electron.confirm(message);
} else {
// running inside normal browser, use browser alert
return window.confirm(message);
}
}
static async prompt(message) {
if(window.electron){
// running inside electron, use ipc prompt

View File

@@ -2,13 +2,6 @@ import moment from "moment";
class Utils {
static formatDestinationHash(destinationHashHex) {
const bytesPerSide = 4;
const leftSide = destinationHashHex.substring(0, bytesPerSide * 2);
const rightSide = destinationHashHex.substring(destinationHashHex.length - bytesPerSide * 2);
return `<${leftSide}...${rightSide}>`
}
static formatBytes(bytes) {
if(bytes === 0){
@@ -25,13 +18,6 @@ class Utils {
}
static formatNumber(num) {
if(num === 0){
return '0';
}
return num.toLocaleString();
}
static parseSeconds(secondsToFormat) {
secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24));
@@ -134,22 +120,6 @@ class Utils {
}
static formatBytesPerSecond(bytesPerSecond) {
if(bytesPerSecond === 0 || bytesPerSecond == null){
return '0 B/s';
}
const k = 1024;
const decimals = 1;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
static formatFrequency(hz) {
if(hz === 0 || hz == null){