Compare commits

...

92 Commits

Author SHA1 Message Date
5374a62e96 Update package-lock.json to version 2.32.3, reflecting the version change from 2.32.2. 2025-11-25 12:22:19 -06:00
2e0dfe8700 Update package.json to version 2.32.3 2025-11-25 12:19:52 -06:00
2f30be1490 remove workflow 2025-11-25 12:19:45 -06:00
79aa2bbaa5 Fix Interface error 2025-11-25 12:18:43 -06:00
c92a86015c Update package.json and package-lock.json to version 2.32.2, including dependency updates for Vue and Vuetify packages. 2025-11-25 12:05:36 -06:00
432845195d Update requirements.txt: bump rns version from 1.0.3 to 1.0.4 2025-11-25 12:04:26 -06:00
50a1bf6b3f Fix setup.py: Make build relocatable and portable by adding path replacement and zipping packages 2025-11-25 12:04:05 -06:00
8c39fdf190 Update build.yml: Change Python version from 3.13 to 3.12 2025-11-25 12:03:48 -06:00
9e352e5058 fix prop node app data parsing 2025-11-21 11:51:14 -06:00
0543bd6044 Update package-lock.json and package.json to version 2.32.1, including dependency updates and new package versions. 2025-11-21 11:28:31 -06:00
17d25b2e0a Update README.md: Simplify content and update features for Reticulum MeshChatX fork 2025-11-21 11:26:27 -06:00
1a4b99b201 add 2025-11-21 11:25:47 -06:00
9115f6ecfa Update requirements.txt: bump lxmf to 0.9.3 and rns to 1.0.3 2025-11-21 11:25:40 -06:00
918fcb051c 2.32.0 2025-11-11 08:24:49 -06:00
acbe3597d6 Fix Dockerfile: Add linux-headers and python3-dev to build dependencies 2025-11-11 08:19:51 -06:00
3566c6b2da Add system resource tracking and download speed estimation to ReticulumMeshChat
- Integrated psutil for memory and network statistics.
- Enhanced app info API to include memory usage, network stats, and download statistics.
- Implemented download speed tracking for files in NomadNetworkPage.
- Added utility functions for formatting numbers and bytes per second.
- Updated frontend components to display new statistics in real-time.
2025-11-11 08:04:07 -06:00
8b044f6dab Update requirements.txt: replace rns version with 1.0.2 and add psutil 7.1.3 2025-11-11 07:50:55 -06:00
4c4b963aef Add .pyc files to .gitignore to prevent Python bytecode from being tracked 2025-11-11 07:50:37 -06:00
38ac972960 update 2025-11-11 07:40:01 -06:00
becd3aa15d Add GitHub Actions workflows for Bearer checks 2025-11-11 07:33:26 -06:00
ac907308c0 update 2025-11-11 07:29:17 -06:00
fa2fe6a15d 2.31.0 2025-11-11 07:24:00 -06:00
927255f44c 2.30.0 2025-11-11 07:23:32 -06:00
0318cb7e4a 2.3.0 2025-11-11 07:23:15 -06:00
442ac41841 Update Node.js and Python versions in GitHub Actions workflow: bump Node.js from 18 to 22 and Python from 3.11 to 3.13 2025-11-11 07:16:33 -06:00
40f286621d Update dependencies in requirements.txt: bump lxmf from 0.8.0 to 0.9.2 and rns from 1.0.0 to 1.0.1 2025-11-09 00:14:22 -06:00
9944e9bd63 Merge pull request #16 from Sudo-Ivan/dependabot/npm_and_yarn/cross-spawn-7.0.6
Bump cross-spawn from 7.0.3 to 7.0.6
2025-11-08 12:02:03 -06:00
dependabot[bot]
ec0b5a0924 Bump cross-spawn from 7.0.3 to 7.0.6
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-version: 7.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-08 18:01:36 +00:00
bae4e96d2a Merge pull request #15 from Sudo-Ivan/dependabot/npm_and_yarn/vite-6.4.1
Bump vite from 6.0.5 to 6.4.1
2025-11-08 12:00:29 -06:00
fa15b8f7a3 Merge pull request #4 from Sudo-Ivan/dependabot/npm_and_yarn/tmp-0.2.5
Bump tmp from 0.2.3 to 0.2.5
2025-11-08 12:00:20 -06:00
2ee27557bd Merge pull request #3 from Sudo-Ivan/dependabot/npm_and_yarn/brace-expansion-1.1.12
Bump brace-expansion from 1.1.11 to 1.1.12
2025-11-08 12:00:11 -06:00
8b82a66315 Merge pull request #2 from Sudo-Ivan/dependabot/npm_and_yarn/form-data-4.0.4
Bump form-data from 4.0.0 to 4.0.4
2025-11-08 12:00:01 -06:00
72b0f95cf5 Merge pull request #5 from Sudo-Ivan/dependabot/npm_and_yarn/electron-35.7.5
Bump electron from 30.3.1 to 35.7.5
2025-11-08 11:59:40 -06:00
1f8ec5aa2f Merge pull request #1 from Sudo-Ivan/dependabot/npm_and_yarn/axios-1.12.0
Bump axios from 1.10.0 to 1.12.0
2025-11-08 11:59:27 -06:00
dependabot[bot]
6827ae9c84 Bump vite from 6.0.5 to 6.4.1
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.0.5 to 6.4.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 00:04:24 +00:00
95ef0935da Remove None values from I2PInterface config before saving 2025-10-01 21:34:11 -05:00
5a5d4b9283 Merge pull request #14 from Sudo-Ivan/i2p-ifac-fix
Fix: Prevent writing None to I2PInterface config
2025-10-01 21:09:22 -05:00
51eaa83301 Fix: Prevent writing None to I2PInterface config 2025-10-01 21:02:54 -05:00
b034b937cd Merge pull request #13 from Sudo-Ivan/actions-full-length-sha
Fix to use step to update repo owner to lower case
2025-10-01 20:01:11 -05:00
69d8bab9e4 Fix to use step to update repo owner to lower case 2025-10-01 20:00:08 -05:00
adac0e5bb1 Merge pull request #12 from Sudo-Ivan/debian-packaging
Add debian packaging support.
2025-10-01 19:51:07 -05:00
12313d34ee Merge pull request #11 from Sudo-Ivan/docker-improvements
Add .dockerignore file and update Dockerfile to use Alpine images for…
2025-10-01 19:50:58 -05:00
55126eaf82 Merge pull request #10 from Sudo-Ivan/actions-full-length-sha
Actions full length sha
2025-10-01 19:50:43 -05:00
aa774f3511 Update manual docker build to use dynamic repo owner 2025-10-01 19:46:27 -05:00
e0e2bbf091 Make owner lowercase 2025-10-01 19:33:20 -05:00
61ada872c0 Add debian packaging support. 2025-10-01 16:07:47 -05:00
dependabot[bot]
3260bffd60 Bump electron from 30.3.1 to 35.7.5
---
updated-dependencies:
- dependency-name: electron
  dependency-version: 35.7.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:43:03 +00:00
dependabot[bot]
bbc1eec48e Bump tmp from 0.2.3 to 0.2.5
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.3 to 0.2.5.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.3...v0.2.5)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:42:55 +00:00
dependabot[bot]
72266680a2 Bump brace-expansion from 1.1.11 to 1.1.12
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:42:55 +00:00
dependabot[bot]
f0336873db Bump form-data from 4.0.0 to 4.0.4
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.0 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:42:54 +00:00
dependabot[bot]
d9a39f1ea9 Bump axios from 1.10.0 to 1.12.0
Bumps [axios](https://github.com/axios/axios) from 1.10.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.10.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:42:52 +00:00
f0edb4bc8d Add .dockerignore file and update Dockerfile to use Alpine images for Node.js and Python with SHA256 2025-10-01 14:36:24 -05:00
e9d45f257e Update GitHub Actions workflows to use dynamic repository references for Docker image tags and URLs 2025-10-01 14:28:52 -05:00
00e0461a16 Update 2025-10-01 14:16:24 -05:00
c56b982df5 Update GitHub Actions to use full-length SHA hashes with version comments 2025-10-01 14:11:33 -05:00
liamcottle
002360399c add docs 2025-08-01 23:35:32 +12:00
liamcottle
c9f4ef64c1 update peewee to v3.18.1 2025-07-28 22:34:39 +12:00
liamcottle
ffe2cb884d update aiohttp to v3.12.14 2025-07-28 22:11:24 +12:00
liamcottle
d6847d262a add python version to about screen 2025-07-28 21:38:43 +12:00
liamcottle
65df111b87 rework async utils to always use main event loop in threadsafe manner 2025-07-28 19:01:15 +12:00
liamcottle
747236ae8b add try catch for fallback file download parsing, so client can show as unsupported 2025-07-28 17:23:03 +12:00
liamcottle
4e55006084 fix bug where downloading files from cicada forums was not working 2025-07-28 17:21:52 +12:00
liamcottle
dcaffe2594 2.2.1 2025-07-27 21:52:08 +12:00
liamcottle
094f6cb5ec added custom confirm dialog as js confirm in electron on windows causes all text fields to be disabled 2025-07-27 21:45:27 +12:00
liamcottle
0c0f059ec4 2.2.0 2025-07-27 20:18:38 +12:00
liamcottle
9031c1a3d7 add dropdown menu to nomadnetwork favourites list to rename and remove 2025-07-27 20:17:08 +12:00
liamcottle
64adad27f8 limit nomadnetwork announces list to 500 recent nodes 2025-07-25 23:22:56 +12:00
liamcottle
4734e62468 implement favourites system for nomadnetwork nodes 2025-07-25 23:02:05 +12:00
liamcottle
37cc6aa158 add button to identify self to nomad network node 2025-07-25 21:56:02 +12:00
liamcottle
f3bf0abd84 2.1.0 2025-07-18 21:45:35 +12:00
liamcottle
90445467e1 remove todos 2025-07-18 20:34:26 +12:00
liamcottle
51bdd35f01 fix downloading files from nomadnet by handling buffered reader responses 2025-07-18 20:29:10 +12:00
liamcottle
817d5b5e59 don't use await in websocket handler as it blocks all other requests 2025-07-18 19:47:47 +12:00
liamcottle
a094a741a8 don't use await in websocket handler as it blocks all other requests 2025-07-18 19:37:59 +12:00
liamcottle
24acbaf223 update axios to v1.10.0 2025-07-18 19:16:57 +12:00
Liam Cottle
0bb171a81b Merge pull request #82 from Amlor/fix-airtime-limit-description
Fix Airtime Limit fields placeholders
2025-07-18 18:24:37 +12:00
Liam Cottle
b5a54dd120 Merge pull request #89 from kujeger/wayland_flag
add `ozone-platform-hint=auto` to known flags
2025-07-18 16:42:14 +12:00
liamcottle
86cfddce52 update micron-parser to v1.0.2 2025-07-18 16:21:56 +12:00
liamcottle
97071c7edb update lxmf to v0.8.0 2025-07-18 16:19:28 +12:00
liamcottle
a58f73357a update rns to v1.0.0 2025-07-18 16:16:36 +12:00
liamcottle
6b3639dcd2 2.0.0 2025-07-13 19:28:29 +12:00
liamcottle
47a84fc110 update rns to v0.9.6 2025-07-13 18:20:39 +12:00
Nikolai Vincent Vaags
588780d632 add ozone-platform-hint=auto to known flags
This allows electron to run natively under wayland
2025-07-09 11:22:31 +02:00
Viacheslav Komarov
5b783399f8 Fix long window placeholder to say minutes 2025-06-07 11:48:54 +04:00
liamcottle
df533fb1bf update lxmf to v0.7.1 2025-05-24 17:00:16 +12:00
liamcottle
e757a2f022 update lxmf to v0.7.0 2025-05-16 02:46:35 +12:00
liamcottle
ce56c205c6 1.22.0 2025-05-11 21:42:06 +12:00
liamcottle
66b619c398 single line 2025-05-11 21:04:26 +12:00
liamcottle
458a387517 update rns to v0.9.5 2025-05-11 21:02:35 +12:00
Liam Cottle
e97352713d Merge pull request #81 from stephen304/ignore-known-flatpak-flags
filter out known flags that should not be passed to python. fixes #61
2025-05-11 20:59:13 +12:00
Amlor
07a41215be Fix Airtime Limit fields placeholders 2025-04-20 13:31:21 +04:00
Stephen Smith
e9a9e9f831 filter out known flags that should not be passed to python. fixes #61 2025-04-18 11:52:32 -04:00
29 changed files with 2605 additions and 1239 deletions

55
.dockerignore Normal file
View File

@@ -0,0 +1,55 @@
# 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

20
.github/workflows/bearer-pr.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
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

View File

@@ -12,17 +12,17 @@ jobs:
contents: write contents: write
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@v1 uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
- name: Install NodeJS - name: Install NodeJS
uses: actions/setup-node@v1 uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
with: with:
node-version: 18 node-version: 22
- name: Install Python - name: Install Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: "3.11" python-version: "3.12"
- name: Install Python Deps - name: Install Python Deps
run: pip install -r requirements.txt run: pip install -r requirements.txt
@@ -35,7 +35,7 @@ jobs:
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
with: with:
draft: true draft: true
allowUpdates: true allowUpdates: true
@@ -50,15 +50,15 @@ jobs:
contents: write contents: write
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@v1 uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
- name: Install NodeJS - name: Install NodeJS
uses: actions/setup-node@v1 uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
with: with:
node-version: 18 node-version: 18
- name: Install Python - name: Install Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: "3.11" python-version: "3.11"
@@ -73,7 +73,7 @@ jobs:
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
with: with:
draft: true draft: true
allowUpdates: true allowUpdates: true
@@ -88,17 +88,17 @@ jobs:
contents: write contents: write
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@v1 uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
- name: Install NodeJS - name: Install NodeJS
uses: actions/setup-node@v1 uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
with: with:
node-version: 18 node-version: 22
- name: Install Python - name: Install Python
uses: actions/setup-python@v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: "3.11" python-version: "3.12"
- name: Install Python Deps - name: Install Python Deps
run: pip install -r requirements.txt run: pip install -r requirements.txt
@@ -111,14 +111,14 @@ jobs:
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
with: with:
draft: true draft: true
allowUpdates: true allowUpdates: true
replacesArtifacts: true replacesArtifacts: true
omitDraftDuringUpdate: true omitDraftDuringUpdate: true
omitNameDuringUpdate: true omitNameDuringUpdate: true
artifacts: "dist/*-linux.AppImage" artifacts: "dist/*-linux.AppImage,dist/*-linux.deb"
build_docker: build_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -127,31 +127,34 @@ jobs:
contents: read contents: read
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set lowercase repository owner
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: Log in to the GitHub Container registry - name: Log in to the GitHub Container registry
uses: docker/login-action@v3 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images - name: Build and push Docker images
uses: docker/build-push-action@v5 uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: >-
ghcr.io/liamcottle/reticulum-meshchat:latest ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }} ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
labels: | labels: >-
org.opencontainers.image.title=Reticulum MeshChat org.opencontainers.image.title=Reticulum MeshChat,
org.opencontainers.image.description=Docker image for Reticulum MeshChat org.opencontainers.image.description=Docker image for Reticulum MeshChat,
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/ org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/

View File

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

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ node_modules
# local storage # local storage
storage/ storage/
*.pyc

View File

@@ -1,5 +1,11 @@
# 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 # Build the frontend
FROM node:20-bookworm-slim AS build-frontend FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
WORKDIR /src WORKDIR /src
@@ -13,13 +19,19 @@ RUN npm install --omit=dev && \
npm run build-frontend npm run build-frontend
# Main app build # Main app build
FROM python:3.11-bookworm FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256}
WORKDIR /app WORKDIR /app
# Install Python deps # Install Python deps
COPY ./requirements.txt . COPY ./requirements.txt .
RUN pip install -r 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
# Copy prebuilt frontend # Copy prebuilt frontend
COPY --from=build-frontend /src/public public COPY --from=build-frontend /src/public public

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
.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

301
README.md
View File

@@ -1,297 +1,36 @@
<p align="center"> # Reticulum MeshChatX
<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> Custom fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat)
<p align="center"> ## Features of this fork
<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? - 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.
A simple mesh network communications app powered by the [Reticulum Network Stack](https://github.com/markqvist/Reticulum). ## Usage
<img src="./screenshots/screenshot.png"> Check [releases](https://github.com/Sudo-Ivan/reticulum-meshchatX/releases) for pre-built binaries or appimages.
## What does it do? ## Building
- It can send and receive messages, files and audio calls with peers; ```bash
- Over your local network through Ethernet and WiFi, completely automatically. make install
- 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). make build
- 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
``` ```
> NOTE: You should now be able to access the web interface at http://localhost:8000 ### Building in Docker
For a full list of command line options, you can run; ```bash
make docker-build
```
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]
ReticulumMeshChat ## Development
options: ```bash
-h, --help show this help message and exit make develop
--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

@@ -95,6 +95,21 @@ class CustomDestinationDisplayName(BaseModel):
table_name = "custom_destination_display_names" 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): class LxmfMessage(BaseModel):
id = BigAutoField() id = BigAutoField()

View File

@@ -22,6 +22,27 @@ 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 // add support for showing a prompt window via ipc
ipcMain.handle('prompt', async(event, message) => { ipcMain.handle('prompt', async(event, message) => {
return await electronPrompt({ return await electronPrompt({
@@ -99,7 +120,8 @@ function getDefaultReticulumConfigDir() {
app.whenReady().then(async () => { app.whenReady().then(async () => {
// get arguments passed to application, and remove the provided application path // get arguments passed to application, and remove the provided application path
const userProvidedArguments = process.argv.slice(1); const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
const shouldLaunchHeadless = userProvidedArguments.includes("--headless"); const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
if(!shouldLaunchHeadless){ if(!shouldLaunchHeadless){

View File

@@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('electron', {
return await ipcRenderer.invoke('alert', message); 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 // add support for using "prompt" in electron browser window
prompt: async function(message) { prompt: async function(message) {
return await ipcRenderer.invoke('prompt', message); return await ipcRenderer.invoke('prompt', message);

View File

@@ -1,8 +1,10 @@
#!/usr/bin/env python #!/usr/bin/env python
import argparse import argparse
import io
import json import json
import os import os
import platform
import sys import sys
import threading import threading
import time import time
@@ -20,6 +22,7 @@ import webbrowser
from peewee import SqliteDatabase from peewee import SqliteDatabase
from serial.tools import list_ports from serial.tools import list_ports
import psutil
import database import database
from src.backend.announce_handler import AnnounceHandler from src.backend.announce_handler import AnnounceHandler
@@ -77,6 +80,7 @@ class ReticulumMeshChat:
database.Config, database.Config,
database.Announce, database.Announce,
database.CustomDestinationDisplayName, database.CustomDestinationDisplayName,
database.FavouriteDestination,
database.LxmfMessage, database.LxmfMessage,
database.LxmfConversationReadState, database.LxmfConversationReadState,
database.LxmfUserIcon, database.LxmfUserIcon,
@@ -144,6 +148,12 @@ class ReticulumMeshChat:
# remember websocket clients # remember websocket clients
self.websocket_clients: List[web.WebSocketResponse] = [] self.websocket_clients: List[web.WebSocketResponse] = []
# track announce timestamps for rate calculation
self.announce_timestamps = []
# track download speeds for nomadnetwork files (list of tuples: (file_size_bytes, duration_seconds))
self.download_speeds = []
# register audio call identity # register audio call identity
self.audio_call_manager = AudioCallManager(identity=self.identity) self.audio_call_manager = AudioCallManager(identity=self.identity)
self.audio_call_manager.register_incoming_call_callback(self.on_incoming_audio_call) self.audio_call_manager.register_incoming_call_callback(self.on_incoming_audio_call)
@@ -371,6 +381,13 @@ class ReticulumMeshChat:
if "interface_enabled" in interface: if "interface_enabled" in interface:
interface["interface_enabled"] = "true" interface["interface_enabled"] = "true"
keys_to_remove = []
for key, value in interface.items():
if value is None:
keys_to_remove.append(key)
for key in keys_to_remove:
del interface[key]
# save config # save config
self.reticulum.config.write() self.reticulum.config.write()
@@ -394,6 +411,13 @@ class ReticulumMeshChat:
if "interface_enabled" in interface: if "interface_enabled" in interface:
interface["interface_enabled"] = "false" interface["interface_enabled"] = "false"
keys_to_remove = []
for key, value in interface.items():
if value is None:
keys_to_remove.append(key)
for key in keys_to_remove:
del interface[key]
# save config # save config
self.reticulum.config.write() self.reticulum.config.write()
@@ -505,7 +529,7 @@ class ReticulumMeshChat:
# handle I2P interface # handle I2P interface
if interface_type == "I2PInterface": if interface_type == "I2PInterface":
interface_details['connectable'] = "True" interface_details['connectable'] = "True"
interface_details["peers"] = data.get('peers') InterfaceEditor.update_value(interface_details, data, "peers")
# handle tcp server interface # handle tcp server interface
if interface_type == "TCPServerInterface": if interface_type == "TCPServerInterface":
@@ -937,17 +961,65 @@ class ReticulumMeshChat:
# get app info # get app info
@routes.get("/api/v1/app/info") @routes.get("/api/v1/app/info")
async def index(request): async def index(request):
# Get memory usage for current process
process = psutil.Process()
memory_info = process.memory_info()
# Get network I/O statistics
net_io = psutil.net_io_counters()
# Get total paths
path_table = self.reticulum.get_path_table()
total_paths = len(path_table)
# Calculate announce rates
current_time = time.time()
announces_per_second = len([t for t in self.announce_timestamps if current_time - t <= 1.0])
announces_per_minute = len([t for t in self.announce_timestamps if current_time - t <= 60.0])
announces_per_hour = len([t for t in self.announce_timestamps if current_time - t <= 3600.0])
# Clean up old announce timestamps (older than 1 hour)
self.announce_timestamps = [t for t in self.announce_timestamps if current_time - t <= 3600.0]
# Calculate average download speed
avg_download_speed_bps = None
if self.download_speeds:
total_bytes = sum(size for size, _ in self.download_speeds)
total_duration = sum(duration for _, duration in self.download_speeds)
if total_duration > 0:
avg_download_speed_bps = total_bytes / total_duration
return web.json_response({ return web.json_response({
"app_info": { "app_info": {
"version": self.get_app_version(), "version": self.get_app_version(),
"lxmf_version": LXMF.__version__, "lxmf_version": LXMF.__version__,
"rns_version": RNS.__version__, "rns_version": RNS.__version__,
"python_version": platform.python_version(),
"storage_path": self.storage_path, "storage_path": self.storage_path,
"database_path": self.database_path, "database_path": self.database_path,
"database_file_size": os.path.getsize(self.database_path), "database_file_size": os.path.getsize(self.database_path),
"reticulum_config_path": self.reticulum.configpath, "reticulum_config_path": self.reticulum.configpath,
"is_connected_to_shared_instance": self.reticulum.is_connected_to_shared_instance, "is_connected_to_shared_instance": self.reticulum.is_connected_to_shared_instance,
"is_transport_enabled": self.reticulum.transport_enabled(), "is_transport_enabled": self.reticulum.transport_enabled(),
"memory_usage": {
"rss": memory_info.rss, # Resident Set Size (bytes)
"vms": memory_info.vms, # Virtual Memory Size (bytes)
},
"network_stats": {
"bytes_sent": net_io.bytes_sent,
"bytes_recv": net_io.bytes_recv,
"packets_sent": net_io.packets_sent,
"packets_recv": net_io.packets_recv,
},
"reticulum_stats": {
"total_paths": total_paths,
"announces_per_second": announces_per_second,
"announces_per_minute": announces_per_minute,
"announces_per_hour": announces_per_hour,
},
"download_stats": {
"avg_download_speed_bps": avg_download_speed_bps,
},
}, },
}) })
@@ -1232,6 +1304,98 @@ class ReticulumMeshChat:
"announces": announces, "announces": announces,
}) })
# serve favourites
@routes.get("/api/v1/favourites")
async def index(request):
# get query params
aspect = request.query.get("aspect", None)
# build favourites database query
query = database.FavouriteDestination.select()
# filter by provided aspect
if aspect is not None:
query = query.where(database.FavouriteDestination.aspect == aspect)
# order favourites alphabetically
query_results = query.order_by(database.FavouriteDestination.display_name.asc())
# process favourites
favourites = []
for favourite in query_results:
favourites.append(self.convert_db_favourite_to_dict(favourite))
return web.json_response({
"favourites": favourites,
})
# add favourite
@routes.post("/api/v1/favourites/add")
async def index(request):
# get request data
data = await request.json()
destination_hash = data.get("destination_hash", None)
display_name = data.get("display_name", None)
aspect = data.get("aspect", None)
# destination hash is required
if destination_hash is None:
return web.json_response({
"message": "destination_hash is required",
}, status=422)
# display name is required
if display_name is None:
return web.json_response({
"message": "display_name is required",
}, status=422)
# aspect is required
if aspect is None:
return web.json_response({
"message": "aspect is required",
}, status=422)
# upsert favourite
self.db_upsert_favourite(destination_hash, display_name, aspect)
return web.json_response({
"message": "Favourite has been added!",
})
# rename favourite
@routes.post("/api/v1/favourites/{destination_hash}/rename")
async def index(request):
# get path params
destination_hash = request.match_info.get("destination_hash", "")
# get request data
data = await request.json()
display_name = data.get("display_name")
# update display name if provided
if len(display_name) > 0:
database.FavouriteDestination.update(display_name=display_name).where(database.FavouriteDestination.destination_hash == destination_hash).execute()
return web.json_response({
"message": "Favourite has been renamed",
})
# delete favourite
@routes.delete("/api/v1/favourites/{destination_hash}")
async def index(request):
# get path params
destination_hash = request.match_info.get("destination_hash", "")
# delete favourite
database.FavouriteDestination.delete().where(database.FavouriteDestination.destination_hash == destination_hash).execute()
return web.json_response({
"message": "Favourite has been added!",
})
# propagation node status # propagation node status
@routes.get("/api/v1/lxmf/propagation-node/status") @routes.get("/api/v1/lxmf/propagation-node/status")
async def index(request): async def index(request):
@@ -1757,6 +1921,30 @@ class ReticulumMeshChat:
"lxmf_message": lxmf_message, "lxmf_message": lxmf_message,
}) })
# identify self on existing nomadnetwork link
@routes.post("/api/v1/nomadnetwork/{destination_hash}/identify")
async def index(request):
# get path params
destination_hash = request.match_info.get("destination_hash", "")
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
# identify to existing active link
if destination_hash in nomadnet_cached_links:
link = nomadnet_cached_links[destination_hash]
if link.status is RNS.Link.ACTIVE:
link.identify(self.identity)
return web.json_response({
"message": "Identity has been sent!",
})
# failed to identify
return web.json_response({
"message": "Failed to identify. No active link to destination.",
}, status=500)
# delete lxmf message # delete lxmf message
@routes.delete("/api/v1/lxmf-messages/{hash}") @routes.delete("/api/v1/lxmf-messages/{hash}")
async def index(request): async def index(request):
@@ -1920,6 +2108,9 @@ class ReticulumMeshChat:
# called when web app has started # called when web app has started
async def on_startup(app): async def on_startup(app):
# remember main event loop
AsyncUtils.set_main_loop(asyncio.get_event_loop())
# auto launch web browser # auto launch web browser
if launch_browser: if launch_browser:
try: try:
@@ -2108,6 +2299,16 @@ class ReticulumMeshChat:
# handle successful file download # handle successful file download
def on_file_download_success(file_name, file_bytes): def on_file_download_success(file_name, file_bytes):
# Track download speed
download_size = len(file_bytes)
if hasattr(downloader, 'start_time') and downloader.start_time:
download_duration = time.time() - downloader.start_time
if download_duration > 0:
self.download_speeds.append((download_size, download_duration))
# Keep only last 100 downloads for average calculation
if len(self.download_speeds) > 100:
self.download_speeds.pop(0)
AsyncUtils.run_async(client.send_str(json.dumps({ AsyncUtils.run_async(client.send_str(json.dumps({
"type": "nomadnet.file.download", "type": "nomadnet.file.download",
"nomadnet_file_download": { "nomadnet_file_download": {
@@ -2143,11 +2344,10 @@ class ReticulumMeshChat:
}, },
}))) })))
# todo: handle file download progress
# download the file # download the file
downloader = NomadnetFileDownloader(destination_hash, file_path, on_file_download_success, on_file_download_failure, on_file_download_progress) downloader = NomadnetFileDownloader(destination_hash, file_path, on_file_download_success, on_file_download_failure, on_file_download_progress)
await downloader.download() downloader.start_time = time.time()
AsyncUtils.run_async(downloader.download())
# handle downloading a page from a nomadnet node # handle downloading a page from a nomadnet node
elif _type == "nomadnet.page.download": elif _type == "nomadnet.page.download":
@@ -2217,11 +2417,9 @@ class ReticulumMeshChat:
}, },
}))) })))
# todo: handle page download progress
# download the page # download the page
downloader = NomadnetPageDownloader(destination_hash, page_path_to_download, combined_data, on_page_download_success, on_page_download_failure, on_page_download_progress) downloader = NomadnetPageDownloader(destination_hash, page_path_to_download, combined_data, on_page_download_success, on_page_download_failure, on_page_download_progress)
await downloader.download() AsyncUtils.run_async(downloader.download())
# unhandled type # unhandled type
else: else:
@@ -2507,6 +2705,17 @@ class ReticulumMeshChat:
"updated_at": announce.updated_at, "updated_at": announce.updated_at,
} }
# convert database favourite to a dictionary
def convert_db_favourite_to_dict(self, favourite: database.FavouriteDestination):
return {
"id": favourite.id,
"destination_hash": favourite.destination_hash,
"display_name": favourite.display_name,
"aspect": favourite.aspect,
"created_at": favourite.created_at,
"updated_at": favourite.updated_at,
}
# convert database lxmf message to a dictionary # convert database lxmf message to a dictionary
def convert_db_lxmf_message_to_dict(self, db_lxmf_message: database.LxmfMessage): def convert_db_lxmf_message_to_dict(self, db_lxmf_message: database.LxmfMessage):
@@ -2717,6 +2926,22 @@ class ReticulumMeshChat:
query = query.on_conflict(conflict_target=[database.CustomDestinationDisplayName.destination_hash], update=data) query = query.on_conflict(conflict_target=[database.CustomDestinationDisplayName.destination_hash], update=data)
query.execute() query.execute()
# upserts a custom destination display name to the database
def db_upsert_favourite(self, destination_hash: str, display_name: str, aspect: str):
# prepare data to insert or update
data = {
"destination_hash": destination_hash,
"display_name": display_name,
"aspect": aspect,
"updated_at": datetime.now(timezone.utc),
}
# upsert to database
query = database.FavouriteDestination.insert(data)
query = query.on_conflict(conflict_target=[database.FavouriteDestination.destination_hash], update=data)
query.execute()
# upserts lxmf conversation read state to the database # upserts lxmf conversation read state to the database
def db_mark_lxmf_conversation_as_read(self, destination_hash: str): def db_mark_lxmf_conversation_as_read(self, destination_hash: str):
@@ -2894,6 +3119,9 @@ class ReticulumMeshChat:
# log received announce # log received announce
print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [call.audio]") print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [call.audio]")
# track announce timestamp
self.announce_timestamps.append(time.time())
# upsert announce to database # upsert announce to database
self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash)
@@ -2915,6 +3143,9 @@ class ReticulumMeshChat:
# log received announce # log received announce
print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.delivery]") print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.delivery]")
# track announce timestamp
self.announce_timestamps.append(time.time())
# upsert announce to database # upsert announce to database
self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash)
@@ -2940,6 +3171,9 @@ class ReticulumMeshChat:
# log received announce # log received announce
print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.propagation]") print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.propagation]")
# track announce timestamp
self.announce_timestamps.append(time.time())
# upsert announce to database # upsert announce to database
self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash)
@@ -3024,6 +3258,9 @@ class ReticulumMeshChat:
# log received announce # log received announce
print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [nomadnetwork.node]") print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [nomadnetwork.node]")
# track announce timestamp
self.announce_timestamps.append(time.time())
# upsert announce to database # upsert announce to database
self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash)
@@ -3102,9 +3339,9 @@ class ReticulumMeshChat:
app_data_bytes = base64.b64decode(app_data_base64) app_data_bytes = base64.b64decode(app_data_base64)
data = msgpack.unpackb(app_data_bytes) data = msgpack.unpackb(app_data_bytes)
return { return {
"enabled": bool(data[0]), "enabled": bool(data[2]),
"timebase": int(data[1]), "timebase": int(data[1]),
"per_transfer_limit": int(data[2]), "per_transfer_limit": int(data[3]),
} }
except: except:
return None return None
@@ -3271,7 +3508,7 @@ class Config:
nomadnet_cached_links = {} nomadnet_cached_links = {}
class NomadnetDownloader: class NomadnetDownloader:
def __init__(self, destination_hash: bytes, path: str, data: str|None, on_download_success: Callable[[bytes], None], on_download_failure: Callable[[str], None], on_progress_update: Callable[[float], None], timeout: int|None = None): def __init__(self, destination_hash: bytes, path: str, data: str|None, on_download_success: Callable[[RNS.RequestReceipt], None], on_download_failure: Callable[[str], None], on_progress_update: Callable[[float], None], timeout: int|None = None):
self.app_name = "nomadnetwork" self.app_name = "nomadnetwork"
self.aspects = "node" self.aspects = "node"
self.destination_hash = destination_hash self.destination_hash = destination_hash
@@ -3353,8 +3590,8 @@ class NomadnetDownloader:
) )
# handle successful download # handle successful download
def on_response(self, request_receipt): def on_response(self, request_receipt: RNS.RequestReceipt):
self.on_download_success(request_receipt.response) self.on_download_success(request_receipt)
# handle failure # handle failure
def on_failed(self, request_receipt=None): def on_failed(self, request_receipt=None):
@@ -3373,8 +3610,8 @@ class NomadnetPageDownloader(NomadnetDownloader):
super().__init__(destination_hash, page_path, data, self.on_download_success, self.on_download_failure, on_progress_update, timeout) super().__init__(destination_hash, page_path, data, self.on_download_success, self.on_download_failure, on_progress_update, timeout)
# page download was successful, decode the response and send to provided callback # page download was successful, decode the response and send to provided callback
def on_download_success(self, response_bytes): def on_download_success(self, request_receipt: RNS.RequestReceipt):
micron_markup_response = response_bytes.decode("utf-8") micron_markup_response = request_receipt.response.decode("utf-8")
self.on_page_download_success(micron_markup_response) self.on_page_download_success(micron_markup_response)
# page download failed, send error to provided callback # page download failed, send error to provided callback
@@ -3390,10 +3627,52 @@ class NomadnetFileDownloader(NomadnetDownloader):
super().__init__(destination_hash, page_path, None, self.on_download_success, self.on_download_failure, on_progress_update, timeout) super().__init__(destination_hash, page_path, None, self.on_download_success, self.on_download_failure, on_progress_update, timeout)
# file download was successful, decode the response and send to provided callback # file download was successful, decode the response and send to provided callback
def on_download_success(self, response): def on_download_success(self, request_receipt: RNS.RequestReceipt):
file_name: str = response[0]
file_data: bytes = response[1] # get response
self.on_file_download_success(file_name, file_data) response = request_receipt.response
# handle buffered reader response
if isinstance(response, io.BufferedReader):
# get file name from metadata
file_name = "downloaded_file"
metadata = request_receipt.metadata
if metadata is not None and "name" in metadata:
file_path = metadata["name"].decode("utf-8")
file_name = os.path.basename(file_path)
# get file data
file_data: bytes = response.read()
self.on_file_download_success(file_name, file_data)
return
# check for list response with bytes in position 0, and metadata dict in position 1
# e.g: [file_bytes, {name: "filename.ext"}]
if isinstance(response, list) and isinstance(response[1], dict):
file_data: bytes = response[0]
metadata: dict = response[1]
# get file name from metadata
file_name = "downloaded_file"
if metadata is not None and "name" in metadata:
file_path = metadata["name"].decode("utf-8")
file_name = os.path.basename(file_path)
self.on_file_download_success(file_name, file_data)
return
# try using original response format
# unsure if this is actually used anymore now that a buffered reader is provided
# have left here just in case...
try:
file_name: str = response[0]
file_data: bytes = response[1]
self.on_file_download_success(file_name, file_data)
except:
self.on_download_failure("unsupported_response")
# page download failed, send error to provided callback # page download failed, send error to provided callback
def on_download_failure(self, failure_reason): def on_download_failure(self, failure_reason):

2337
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", "name": "reticulum-meshchat",
"version": "1.21.0", "version": "2.32.3",
"description": "", "description": "",
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
@@ -17,7 +17,7 @@
"node": ">=18" "node": ">=18"
}, },
"devDependencies": { "devDependencies": {
"electron": "^30.0.8", "electron": "^35.7.5",
"electron-builder": "^24.6.3" "electron-builder": "^24.6.3"
}, },
"build": { "build": {
@@ -70,7 +70,11 @@
}, },
"linux": { "linux": {
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}", "artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
"target": "AppImage", "target": [
"AppImage",
"deb"
],
"maintainer": "Liam Cottle <liam@liamcottle.com>",
"extraFiles": [ "extraFiles": [
{ {
"from": "build/exe", "from": "build/exe",
@@ -98,11 +102,11 @@
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.7.9", "axios": "^1.12.0",
"click-outside-vue3": "^4.0.1", "click-outside-vue3": "^4.0.1",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",
"electron-prompt": "^1.7.0", "electron-prompt": "^1.7.0",
"micron-parser": "^1.0.1", "micron-parser": "^1.0.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
@@ -110,7 +114,7 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vis-data": "^7.1.9", "vis-data": "^7.1.9",
"vis-network": "^9.1.9", "vis-network": "^9.1.9",
"vite": "^6.0.5", "vite": "^6.4.1",
"vite-plugin-vuetify": "^2.0.4", "vite-plugin-vuetify": "^2.0.4",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vuetify": "^3.7.6" "vuetify": "^3.7.6"

View File

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

View File

@@ -23,6 +23,8 @@ setup(
# https://github.com/marcelotduarte/cx_Freeze/discussions/2039 # https://github.com/marcelotduarte/cx_Freeze/discussions/2039
# https://github.com/marcelotduarte/cx_Freeze/issues/2041 # https://github.com/marcelotduarte/cx_Freeze/issues/2041
'RNS', 'RNS',
'RNS.Interfaces',
'LXMF',
], ],
# files that are required # files that are required
'include_files': [ 'include_files': [
@@ -39,6 +41,10 @@ setup(
"optimize": 2, "optimize": 2,
# change where exe is built to # change where exe is built to
'build_exe': 'build/exe', 'build_exe': 'build/exe',
# make the build relocatable by replacing absolute paths
'replace_paths': [
('*', ''),
],
}, },
}, },
) )

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
<div class="mr-auto"> <div class="mr-auto">
<div>Versions</div> <div>Versions</div>
<div class="text-sm text-gray-700 dark:text-zinc-400"> <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 }} MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}
</div> </div>
</div> </div>
<div class="hidden sm:block mx-2 my-auto"> <div class="hidden sm:block mx-2 my-auto">
@@ -64,6 +64,141 @@
</div> </div>
</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 --> <!-- reticulum status -->
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow"> <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> <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>
@@ -126,11 +261,21 @@ export default {
return { return {
appInfo: null, appInfo: null,
config: null, config: null,
updateInterval: null,
}; };
}, },
mounted() { mounted() {
this.getAppInfo(); this.getAppInfo();
this.getConfig(); this.getConfig();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
}, 5000);
},
beforeUnmount() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
}, },
methods: { methods: {
async getAppInfo() { async getAppInfo() {
@@ -166,6 +311,12 @@ export default {
formatBytes: function(bytes) { formatBytes: function(bytes) {
return Utils.formatBytes(bytes); return Utils.formatBytes(bytes);
}, },
formatNumber: function(num) {
return Utils.formatNumber(num);
},
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
}, },
computed: { computed: {
isElectron() { isElectron() {

View File

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

View File

@@ -690,7 +690,7 @@
<input <input
type="number" type="number"
v-model="newInterfaceAirtimeLimitShort" v-model="newInterfaceAirtimeLimitShort"
placeholder="Enter short airtime limit (seconds)" placeholder="Enter short airtime limit (% of a rolling 15 seconds window)"
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" 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> </div>
@@ -699,7 +699,7 @@
<input <input
type="number" type="number"
v-model="newInterfaceAirtimeLimitLong" v-model="newInterfaceAirtimeLimitLong"
placeholder="Enter long airtime limit (seconds)" placeholder="Enter long airtime limit (% of a rolling 60 minutes window)"
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" 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> </div>

View File

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

View File

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

View File

@@ -997,7 +997,7 @@ export default {
try { try {
// ask user to confirm deleting message // ask user to confirm deleting message
if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){ if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){
return; return;
} }
@@ -1056,7 +1056,11 @@ export default {
if(this.newMessageImage){ if(this.newMessageImage){
imageTotalSize = this.newMessageImage.size; imageTotalSize = this.newMessageImage.size;
fields["image"] = { fields["image"] = {
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png" // 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
"image_type": this.newMessageImage.type.replace("image/", ""), "image_type": this.newMessageImage.type.replace("image/", ""),
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()), "image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
}; };
@@ -1078,7 +1082,7 @@ export default {
// ask user if they still want to send message if it may be rejected by sender // 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(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
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?`)){ 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?`)){
return; return;
} }
} }
@@ -1209,10 +1213,10 @@ export default {
clearFileInput: function() { clearFileInput: function() {
this.$refs["file-input"].value = null; this.$refs["file-input"].value = null;
}, },
removeImageAttachment: function() { async removeImageAttachment() {
// ask user to confirm removing image attachment // ask user to confirm removing image attachment
if(!confirm("Are you sure you want to remove this image attachment?")){ if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){
return; return;
} }
@@ -1244,7 +1248,7 @@ export default {
} }
// ask user to confirm recording new audio attachment, if an existing audio attachment exists // ask user to confirm recording new audio attachment, if an existing audio attachment exists
if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){ if(this.newMessageAudio && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
return; return;
} }
@@ -1386,10 +1390,10 @@ export default {
} }
}, },
removeAudioAttachment: function() { async removeAudioAttachment() {
// ask user to confirm removing audio attachment // ask user to confirm removing audio attachment
if(!confirm("Are you sure you want to remove this audio attachment?")){ if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){
return; return;
} }

View File

@@ -3,22 +3,61 @@
<!-- nomadnetwork sidebar --> <!-- nomadnetwork sidebar -->
<NomadNetworkSidebar <NomadNetworkSidebar
:nodes="nodes" :nodes="nodes"
:favourites="favourites"
:selected-destination-hash="selectedNode?.destination_hash" :selected-destination-hash="selectedNode?.destination_hash"
@node-click="onNodeClick"/> @node-click="onNodeClick"
@rename-favourite="onRenameFavourite"
@remove-favourite="onRemoveFavourite"/>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950"> <div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
<!-- node --> <!-- 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"> <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 --> <!-- header -->
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800"> <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 --> <!-- node info -->
<div class="my-auto dark:text-gray-100"> <div class="my-auto dark:text-gray-100">
<span class="font-semibold">{{ selectedNode.display_name }}</span> <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> <span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
</div> </div>
<!-- close button --> <!-- identify button -->
<div class="my-auto ml-auto mr-2"> <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 @click="onCloseNodeViewer" class="cursor-pointer"> <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 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> <div>
@@ -85,7 +124,12 @@
<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> <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> </svg>
</div> </div>
<div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div> <div class="my-auto">
Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)
<span v-if="nodeFileDownloadSpeed !== null" class="ml-2 text-sm">
- {{ formatBytesPerSecond(nodeFileDownloadSpeed) }}
</span>
</div>
</div> </div>
</div> </div>
@@ -140,6 +184,7 @@ import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection"; import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue"; import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
import GlobalEmitter from "../../js/GlobalEmitter"; import GlobalEmitter from "../../js/GlobalEmitter";
import Utils from "../../js/Utils";
export default { export default {
name: 'NomadNetworkPage', name: 'NomadNetworkPage',
@@ -152,10 +197,14 @@ export default {
data() { data() {
return { return {
reloadInterval: null,
nodes: {}, nodes: {},
selectedNode: null, selectedNode: null,
selectedNodePath: null, selectedNodePath: null,
favourites: [],
isLoadingNodePage: false, isLoadingNodePage: false,
isShowingNodePageSource: false, isShowingNodePageSource: false,
defaultNodePagePath: "/page/index.mu", defaultNodePagePath: "/page/index.mu",
@@ -170,6 +219,10 @@ export default {
isDownloadingNodeFile: false, isDownloadingNodeFile: false,
nodeFilePath: null, nodeFilePath: null,
nodeFileProgress: 0, nodeFileProgress: 0,
nodeFileDownloadStartTime: null,
nodeFileLastProgressTime: null,
nodeFileLastProgressValue: 0,
nodeFileDownloadSpeed: null,
nomadnetPageDownloadCallbacks: {}, nomadnetPageDownloadCallbacks: {},
nomadnetFileDownloadCallbacks: {}, nomadnetFileDownloadCallbacks: {},
@@ -178,6 +231,8 @@ export default {
}, },
beforeUnmount() { beforeUnmount() {
clearInterval(this.reloadInterval);
// stop listening for websocket messages // stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage); WebSocketConnection.off("message", this.onWebsocketMessage);
@@ -202,8 +257,14 @@ export default {
})(); })();
} }
this.getFavourites();
this.getNomadnetworkNodeAnnounces(); this.getNomadnetworkNodeAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getFavourites();
}, 5000);
}, },
methods: { methods: {
onElementClick(event) { onElementClick(event) {
@@ -309,6 +370,54 @@ export default {
onDestinationPathClick: function(path) { onDestinationPathClick: function(path) {
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`); 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() { async getNomadnetworkNodeAnnounces() {
try { try {
@@ -316,6 +425,7 @@ export default {
const response = await window.axios.get(`/api/v1/announces`, { const response = await window.axios.get(`/api/v1/announces`, {
params: { params: {
aspect: "nomadnetwork.node", aspect: "nomadnetwork.node",
limit: 500, // limit ui to showing 500 latest announces
}, },
}); });
@@ -656,26 +766,72 @@ export default {
this.isDownloadingNodeFile = true; this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop(); this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFileProgress = 0; this.nodeFileProgress = 0;
this.nodeFileDownloadStartTime = Date.now();
this.nodeFileLastProgressTime = Date.now();
this.nodeFileLastProgressValue = 0;
this.nodeFileDownloadSpeed = null;
// start file download // start file download
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => { 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 // no longer downloading
this.isDownloadingNodeFile = false; this.isDownloadingNodeFile = false;
// download file to browser // download file to browser
this.downloadFileFromBase64(fileName, fileBytesBase64); this.downloadFileFromBase64(fileName, fileBytesBase64);
// Clear speed after a moment
setTimeout(() => {
this.nodeFileDownloadSpeed = null;
}, 2000);
}, (failureReason) => { }, (failureReason) => {
// no longer downloading // no longer downloading
this.isDownloadingNodeFile = false; this.isDownloadingNodeFile = false;
this.nodeFileDownloadSpeed = null;
// show error message // show error message
DialogUtils.alert(`Failed to download file: ${failureReason}`); DialogUtils.alert(`Failed to download file: ${failureReason}`);
}, (progress) => { }, (progress) => {
this.nodeFileProgress = Math.round(progress * 100); 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;
}); });
return; return;
@@ -729,6 +885,9 @@ export default {
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000); setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
}, },
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onNodeClick: function(node) { onNodeClick: function(node) {
// update selected node // update selected node
@@ -737,6 +896,40 @@ export default {
// load default node page // load default node page
this.loadNodePage(node.destination_hash, this.defaultNodePagePath); 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() { onCloseNodeViewer: function() {
@@ -773,6 +966,24 @@ 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) { downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
try { try {

View File

@@ -1,6 +1,99 @@
<template> <template>
<div class="flex flex-col w-80 min-w-80"> <div class="flex flex-col w-80 min-w-80">
<div class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
<!-- 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">
<!-- search --> <!-- search -->
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800"> <div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
<input <input
@@ -58,6 +151,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -65,16 +159,22 @@
import Utils from "../../js/Utils"; import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue"; import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DropDownMenu from "../DropDownMenu.vue";
import IconButton from "../IconButton.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
export default { export default {
name: 'NomadNetworkSidebar', name: 'NomadNetworkSidebar',
components: {MaterialDesignIcon}, components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
props: { props: {
nodes: Object, nodes: Object,
favourites: Array,
selectedDestinationHash: String, selectedDestinationHash: String,
}, },
data() { data() {
return { return {
tab: "favourites",
favouritesSearchTerm: "",
nodesSearchTerm: "", nodesSearchTerm: "",
}; };
}, },
@@ -82,9 +182,21 @@ export default {
onNodeClick(node) { onNodeClick(node) {
this.$emit("node-click", 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) { formatTimeAgo: function(datetimeString) {
return Utils.formatTimeAgo(datetimeString); return Utils.formatTimeAgo(datetimeString);
}, },
formatDestinationHash: function(destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
}, },
computed: { computed: {
nodesCount() { nodesCount() {
@@ -107,6 +219,15 @@ export default {
return matchesDisplayName || matchesDestinationHash; 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> </script>

View File

@@ -145,7 +145,7 @@ export default {
} }
// confirm user wants to update their icon // confirm user wants to update their icon
if(!confirm("Are you sure you want to set this as your profile icon?")){ if(!await DialogUtils.confirm("Are you sure you want to set this as your profile icon?")){
return; return;
} }
@@ -160,7 +160,7 @@ export default {
async removeProfileIcon() { async removeProfileIcon() {
// confirm user wants to remove their icon // confirm user wants to remove their 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.")){ 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.")){
return; return;
} }

View File

@@ -10,6 +10,16 @@ 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) { static async prompt(message) {
if(window.electron){ if(window.electron){
// running inside electron, use ipc prompt // running inside electron, use ipc prompt

View File

@@ -2,6 +2,13 @@ import moment from "moment";
class Utils { 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) { static formatBytes(bytes) {
if(bytes === 0){ if(bytes === 0){
@@ -18,6 +25,13 @@ class Utils {
} }
static formatNumber(num) {
if(num === 0){
return '0';
}
return num.toLocaleString();
}
static parseSeconds(secondsToFormat) { static parseSeconds(secondsToFormat) {
secondsToFormat = Number(secondsToFormat); secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24)); var days = Math.floor(secondsToFormat / (3600 * 24));
@@ -120,6 +134,22 @@ 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) { static formatFrequency(hz) {
if(hz === 0 || hz == null){ if(hz === 0 || hz == null){