Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0543bd6044
|
|||
|
17d25b2e0a
|
|||
|
1a4b99b201
|
|||
|
9115f6ecfa
|
|||
|
918fcb051c
|
|||
|
acbe3597d6
|
|||
|
3566c6b2da
|
|||
|
8b044f6dab
|
|||
|
4c4b963aef
|
|||
|
38ac972960
|
|||
|
becd3aa15d
|
|||
|
ac907308c0
|
|||
|
fa2fe6a15d
|
|||
|
927255f44c
|
|||
|
0318cb7e4a
|
|||
|
442ac41841
|
|||
|
40f286621d
|
|||
| 9944e9bd63 | |||
|
|
ec0b5a0924 | ||
| bae4e96d2a | |||
| fa15b8f7a3 | |||
| 2ee27557bd | |||
| 8b82a66315 | |||
| 72b0f95cf5 | |||
| 1f8ec5aa2f | |||
|
|
6827ae9c84 | ||
| 95ef0935da | |||
| 5a5d4b9283 | |||
| 51eaa83301 | |||
| b034b937cd | |||
| 69d8bab9e4 | |||
| adac0e5bb1 | |||
| 12313d34ee | |||
| 55126eaf82 | |||
| aa774f3511 | |||
| e0e2bbf091 | |||
| 61ada872c0 | |||
|
|
3260bffd60 | ||
|
|
bbc1eec48e | ||
|
|
72266680a2 | ||
|
|
f0336873db | ||
|
|
d9a39f1ea9 | ||
| f0edb4bc8d | |||
| e9d45f257e | |||
| 00e0461a16 | |||
| c56b982df5 | |||
|
|
002360399c | ||
|
|
c9f4ef64c1 | ||
|
|
ffe2cb884d | ||
|
|
d6847d262a | ||
|
|
65df111b87 | ||
|
|
747236ae8b | ||
|
|
4e55006084 | ||
|
|
dcaffe2594 | ||
|
|
094f6cb5ec | ||
|
|
0c0f059ec4 | ||
|
|
9031c1a3d7 | ||
|
|
64adad27f8 | ||
|
|
4734e62468 | ||
|
|
37cc6aa158 | ||
|
|
f3bf0abd84 | ||
|
|
90445467e1 | ||
|
|
51bdd35f01 | ||
|
|
817d5b5e59 | ||
|
|
a094a741a8 | ||
|
|
24acbaf223 | ||
|
|
0bb171a81b | ||
|
|
b5a54dd120 | ||
|
|
86cfddce52 | ||
|
|
97071c7edb | ||
|
|
a58f73357a | ||
|
|
6b3639dcd2 | ||
|
|
47a84fc110 | ||
|
|
588780d632 | ||
|
|
5b783399f8 | ||
|
|
df533fb1bf | ||
|
|
e757a2f022 | ||
|
|
ce56c205c6 | ||
|
|
66b619c398 | ||
|
|
458a387517 | ||
|
|
e97352713d | ||
|
|
07a41215be | ||
|
|
e9a9e9f831 |
55
.dockerignore
Normal file
55
.dockerignore
Normal 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
20
.github/workflows/bearer-pr.yml
vendored
Normal 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
|
||||||
35
.github/workflows/bearer.yml
vendored
35
.github/workflows/bearer.yml
vendored
@@ -1,36 +1,29 @@
|
|||||||
name: Security Scan
|
name: Bearer Master
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
schedule:
|
|
||||||
- cron: '0 0 * * 0' # Run weekly on Sunday
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
|
||||||
security-events: write
|
security-events: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
security-scan:
|
rule_check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Bearer Security Scan
|
- name: Bearer
|
||||||
uses: bearer/bearer-action@v2
|
uses: bearer/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2
|
||||||
with:
|
with:
|
||||||
scanner: sast
|
|
||||||
format: sarif
|
format: sarif
|
||||||
output: bearer.sarif
|
output: results.sarif
|
||||||
severity: critical,high
|
|
||||||
path: .
|
- name: Upload SARIF file
|
||||||
exit-code: 0
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@2827891b2e5e0510dceab8c3619f4fe255451277 # v4
|
||||||
- name: Upload SARIF results
|
|
||||||
uses: github/codeql-action/upload-sarif@v2
|
|
||||||
with:
|
with:
|
||||||
sarif_file: bearer.sarif
|
sarif_file: results.sarif
|
||||||
|
category: bearer-security-scan
|
||||||
|
|||||||
107
.github/workflows/build.yml
vendored
107
.github/workflows/build.yml
vendored
@@ -12,15 +12,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: 22
|
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.13"
|
python-version: "3.13"
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -44,43 +44,43 @@ jobs:
|
|||||||
omitNameDuringUpdate: true
|
omitNameDuringUpdate: true
|
||||||
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
|
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
|
||||||
|
|
||||||
# build_mac:
|
build_mac:
|
||||||
# runs-on: macos-13
|
runs-on: macos-13
|
||||||
# permissions:
|
permissions:
|
||||||
# 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: 20
|
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.13"
|
python-version: "3.11"
|
||||||
|
|
||||||
# - name: Install Python Deps
|
- name: Install Python Deps
|
||||||
# run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
# - name: Install NodeJS Deps
|
- name: Install NodeJS Deps
|
||||||
# run: npm install
|
run: npm install
|
||||||
|
|
||||||
# - name: Build Electron App
|
- name: Build Electron App
|
||||||
# run: npm run dist
|
run: npm run dist
|
||||||
|
|
||||||
# - 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/*-mac.dmg"
|
artifacts: "dist/*-mac.dmg"
|
||||||
|
|
||||||
build_linux:
|
build_linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -88,15 +88,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: 22
|
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.13"
|
python-version: "3.13"
|
||||||
|
|
||||||
@@ -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/sudo-ivan/reticulum-meshchat:latest
|
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
|
||||||
ghcr.io/sudo-ivan/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/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
|
||||||
|
|||||||
27
.github/workflows/manual-docker-build.yml
vendored
27
.github/workflows/manual-docker-build.yml
vendored
@@ -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/sudo-ivan/reticulum-meshchat:latest
|
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
|
||||||
ghcr.io/sudo-ivan/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/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,6 +10,4 @@ node_modules
|
|||||||
# local storage
|
# local storage
|
||||||
storage/
|
storage/
|
||||||
|
|
||||||
__pycache__/
|
*.pyc
|
||||||
|
|
||||||
config/
|
|
||||||
60
Dockerfile
60
Dockerfile
@@ -1,51 +1,45 @@
|
|||||||
|
# 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:22-alpine AS build-frontend
|
FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy required source files
|
# Copy required source files
|
||||||
COPY --chown=node:node *.json .
|
COPY *.json .
|
||||||
COPY --chown=node:node *.js .
|
COPY *.js .
|
||||||
COPY --chown=node:node src/frontend ./src/frontend
|
COPY src/frontend ./src/frontend
|
||||||
|
|
||||||
# Fix permissions and install NodeJS deps
|
# Install NodeJS deps, exluding electron
|
||||||
USER root
|
|
||||||
RUN chown -R node:node /src
|
|
||||||
USER node
|
|
||||||
RUN npm install --omit=dev && \
|
RUN npm install --omit=dev && \
|
||||||
npm run build-frontend
|
npm run build-frontend
|
||||||
|
|
||||||
# Main app build
|
# Main app build
|
||||||
FROM python:3.13-alpine
|
FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
gcc \
|
|
||||||
musl-dev \
|
|
||||||
python3-dev \
|
|
||||||
libffi-dev \
|
|
||||||
openssl-dev
|
|
||||||
|
|
||||||
# Create config directories with proper permissions
|
|
||||||
RUN mkdir -p /config/.reticulum /config/.meshchat && \
|
|
||||||
chown -R 1000:1000 /config
|
|
||||||
|
|
||||||
# Install Python deps
|
# Install Python deps
|
||||||
COPY --chown=1000:1000 ./requirements.txt .
|
COPY ./requirements.txt .
|
||||||
RUN pip install --no-cache-dir -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
|
||||||
|
|
||||||
# Create public directory and copy frontend
|
# Copy prebuilt frontend
|
||||||
RUN mkdir -p /app/public
|
COPY --from=build-frontend /src/public public
|
||||||
COPY --from=build-frontend --chown=1000:1000 /src/public/ /app/public/
|
|
||||||
|
|
||||||
# Copy other required source files
|
# Copy other required source files
|
||||||
COPY --chown=1000:1000 *.py .
|
COPY *.py .
|
||||||
COPY --chown=1000:1000 src/__init__.py ./src/__init__.py
|
COPY src/__init__.py ./src/__init__.py
|
||||||
COPY --chown=1000:1000 src/backend ./src/backend
|
COPY src/backend ./src/backend
|
||||||
COPY --chown=1000:1000 *.json .
|
COPY *.json .
|
||||||
|
|
||||||
USER 1000
|
CMD ["python", "meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
|
||||||
ENTRYPOINT ["python"]
|
|
||||||
CMD ["meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
|
|
||||||
|
|||||||
23
Makefile
Normal file
23
Makefile
Normal 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
321
README.md
321
README.md
@@ -1,317 +1,36 @@
|
|||||||
# Ivans Fork Edition
|
# Reticulum MeshChatX
|
||||||
|
|
||||||
## Changes
|
Custom fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat)
|
||||||
|
|
||||||
- Drop unnecassary permissions (compose)
|
## Features of this fork
|
||||||
- Rootless (user 1000:1000)
|
|
||||||
- Resource Limits (compose)
|
|
||||||
- Alpine Image Variants.
|
|
||||||
- Updated Dependencies.
|
|
||||||
- Dark mode by default.
|
|
||||||
- Python 3.13 and Node 20.
|
|
||||||
- Ruff formatting and fixes.
|
|
||||||
|
|
||||||
## Security
|
- 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.
|
||||||
|
|
||||||
- Bearer Security Scan Action
|
## Usage
|
||||||
- [Socket](https://socket.dev/) Supply Chain Security/Analysis
|
|
||||||
|
|
||||||
---
|
Check [releases](https://github.com/Sudo-Ivan/reticulum-meshchatX/releases) for pre-built binaries or appimages.
|
||||||
|
|
||||||
<p align="center">
|
## Building
|
||||||
<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>
|
```bash
|
||||||
|
make install
|
||||||
<p align="center">
|
make build
|
||||||
<a href="https://discord.gg/APQSQZNV7t"><img src="https://img.shields.io/badge/Discord-Liam%20Cottle's%20Discord-%237289DA?style=flat&logo=discord" alt="discord"/></a>
|
|
||||||
<a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a>
|
|
||||||
<br/>
|
|
||||||
<a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a>
|
|
||||||
<a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## What is Reticulum MeshChat?
|
|
||||||
|
|
||||||
A simple mesh network communications app powered by the [Reticulum Network Stack](https://github.com/markqvist/Reticulum).
|
|
||||||
|
|
||||||
<img src="./screenshots/screenshot.png">
|
|
||||||
|
|
||||||
## What does it do?
|
|
||||||
|
|
||||||
- It can send and receive messages, files and audio calls with peers;
|
|
||||||
- Over your local network through Ethernet and WiFi, completely automatically.
|
|
||||||
- Over the internet by connecting through a server [hosted by yourself](https://reticulum.network/manual/interfaces.html#tcp-server-interface) or [the community](https://reticulum.network/connect.html).
|
|
||||||
- Over low-powered, license-free, ISM band LoRa Radio, with an [RNode](https://github.com/markqvist/RNode_Firmware).
|
|
||||||
- ...and via [any other interface](https://reticulum.network/manual/interfaces.html) supported by the Reticulum Network Stack.
|
|
||||||
- It communicates securely. Messages can only be decrypted by the intended destination.
|
|
||||||
- It can communicate with any other existing [LXMF](https://github.com/markqvist/lxmf) client, such as [Sideband](https://github.com/markqvist/Sideband/) and [Nomadnet](https://github.com/markqvist/nomadnet).
|
|
||||||
- It can download files and browse micron pages (decentralised websites) hosted on [Nomad Network](https://github.com/markqvist/nomadnet) nodes.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Supports sending and receiving messages between [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat), [Sideband](https://github.com/markqvist/Sideband/) and [Nomadnet](https://github.com/markqvist/nomadnet).
|
|
||||||
- Supports receiving and saving images and attachments sent from Sideband.
|
|
||||||
- Supports sending images, voice recordings and file attachments.
|
|
||||||
- Supports saving inbound and outbound messages to a local database.
|
|
||||||
- Supports sending an announce to the network.
|
|
||||||
- Supports setting a custom display name to send in your announce.
|
|
||||||
- Supports viewing and searching peers discovered from announces.
|
|
||||||
- Supports auto resending undelivered messages when an announce is received from the recipient.
|
|
||||||
- Supports sending messages to and syncing messages from [LXMF Propagation Nodes](https://github.com/markqvist/lxmf?tab=readme-ov-file#propagation-nodes).
|
|
||||||
- Supports running a local LXMF Propagation Node so other users can use your device for message storage and retrieval.
|
|
||||||
- Support for browsing pages, and downloading files hosted on Nomad Network Nodes.
|
|
||||||
|
|
||||||
## Beta Features
|
|
||||||
|
|
||||||
- Support for Audio Calls to other [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat) users.
|
|
||||||
- Audio is encoded with [codec2](https://github.com/drowe67/codec2) to support low bandwidth links.
|
|
||||||
- Using a microphone requires using the web ui over localhost or https, due to [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet) secure context.
|
|
||||||
- I have tested two-way audio calls over LoRa with a single hop. It works well when a [reasonable bitrate](https://unsigned.io/understanding-lora-parameters/) is configured on the RNode.
|
|
||||||
- Some browsers such as FireFox don't work as expected. Try using a Chromium based browser if running via the command line.
|
|
||||||
|
|
||||||
## Download
|
|
||||||
|
|
||||||
You can download the latest version for Windows, Mac and Linux from the [releases](https://github.com/liamcottle/reticulum-meshchat/releases) page.
|
|
||||||
|
|
||||||
Alternatively, you can download the source and run it manually from a command line.
|
|
||||||
|
|
||||||
See the ["How to use it?"](#how-to-use-it) section, further down on how to do this.
|
|
||||||
|
|
||||||
## Other Installation Methods
|
|
||||||
|
|
||||||
- [Running MeshChat on Docker](./docs/meshchat_on_docker.md)
|
|
||||||
- [Running MeshChat on a Raspberry Pi](./docs/meshchat_on_raspberry_pi.md)
|
|
||||||
- [Running MeshChat on Android with Termux](./docs/meshchat_on_android_with_termux.md)
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
Once you've downloaded, installed and launched Reticulum MeshChat, there's a few things you need to do in order to start communicating with other people on the network.
|
|
||||||
|
|
||||||
1. Create an Identity
|
|
||||||
2. Configure your Display Name
|
|
||||||
3. Send an Announce
|
|
||||||
4. Discover Peers and start sending messages
|
|
||||||
5. Configuring additional Network Interfaces
|
|
||||||
|
|
||||||
**Create an Identity**
|
|
||||||
|
|
||||||
On the Reticulum Network, anyone can have any number of Identities. You may opt to use your real name, or you may decide to be completely anonymous. The choice is yours.
|
|
||||||
|
|
||||||
A Reticulum Identity is a public/private key-pair. You control the private key used to generate destination addresses, encrypt content and prove receipt of data with unforgeable delivery acknowledgements.
|
|
||||||
|
|
||||||
Your public key is shared with the network when you send an announce, and allows others on the network to automatically discover a route to a destination you control.
|
|
||||||
|
|
||||||
At this time, Reticulum MeshChat generates a new Identity the first time you launch it. A future update will allow you to create and manage multiple identities.
|
|
||||||
|
|
||||||
For now, if you want to change, or reset your identity, you can access the identity file at `~/.reticulum-meshchat/identity`.
|
|
||||||
|
|
||||||
**Configure your Display Name**
|
|
||||||
|
|
||||||
The next thing you should do, is set a display name. Your display name is what everyone else on the network will see when looking for someone to communicate with from the Peers list.
|
|
||||||
|
|
||||||
You can do this in the `My Identity` section in the bottom left corner. Enter a new display name, and then press `Save`.
|
|
||||||
|
|
||||||
**Send an Announce**
|
|
||||||
|
|
||||||
When using the Reticulum Network, in order to be contactable, you need to send an `Announce`. You can send an announce as often, or as infrequently as you like.
|
|
||||||
|
|
||||||
Sending an announce allows other peers on the network to discover the next-hop across the network their packets should take to arrive at a destination that your identity controls.
|
|
||||||
|
|
||||||
If you never send an announce, you will be invisible and no one will ever be able to send anything to you.
|
|
||||||
|
|
||||||
When you move across the network, and change entrypoints, such as moving from your home WiFi network, to plugging in to an Ethernet port in a local library or even climbing a mountain and using an RNode over LoRa radio, other peers on the network will only know the previous path to your destinations.
|
|
||||||
|
|
||||||
To allow them to discover the new path their packets should take to reach you, you should send an announce.
|
|
||||||
|
|
||||||
**Discover Peers and start sending messages**
|
|
||||||
|
|
||||||
In the Reticulum Network, you can control an unlimited number of destination addresses. One of these can be an [LXMF](https://github.com/markqvist/lxmf) delivery address.
|
|
||||||
|
|
||||||
Your Reticulum Identity allows you to have an LXMF address. Think of an LXMF address as your very own, secure, end-to-end encrypted, unspoofable, email address routed over a mesh network.
|
|
||||||
|
|
||||||
When someone else on the network announces themselves (more specifically, their LXMF address), they will show up in the Peers tab.
|
|
||||||
|
|
||||||
You can click on any of these discovered peers to open a messaging interface. From here, you can send text messages, files and inline images. If they respond, their messages will show up there too.
|
|
||||||
|
|
||||||
As well as being able to announce your LXMF address and discover others, Reticulum MeshChat can also discover [Nomad Network](https://github.com/markqvist/nomadnet) nodes hosted by other users. From the Nodes tab, you are free to explore pages and download files they may be publicly sharing on the network.
|
|
||||||
|
|
||||||
A future update is planned to allow you to host your own Node and share pages and files with other peers on the network. For now, you could use the official [Nomad Network](https://github.com/markqvist/nomadnet) client to do this.
|
|
||||||
|
|
||||||
Remember, in order to connect with other peers or nodes, they must announce on the network. So don't forget to announce if you want to be discovered!
|
|
||||||
|
|
||||||
**Configuring additional Network Interfaces**
|
|
||||||
|
|
||||||
> TODO: this section is yet to be written. For now, you can check out the [official documentation for configuring interfaces](https://reticulum.network/manual/interfaces.html) in the Reticulum config file. This file is located at `~/.reticulum/config`
|
|
||||||
|
|
||||||
## How does it work?
|
|
||||||
|
|
||||||
- A python script ([meshchat.py](./meshchat.py)) runs a Reticulum instance and a WebSocket server.
|
|
||||||
- The web page sends and receives LXMF packets encoded in json via the WebSocket.
|
|
||||||
- Web Browser -> WebSocket -> Python Reticulum -> (configured interfaces) -> (destination)
|
|
||||||
- LXMF messages sent and received are saved to a local SQLite database.
|
|
||||||
|
|
||||||
## How to use it?
|
|
||||||
|
|
||||||
It is recommended that you [download](#download) a standalone application.
|
|
||||||
|
|
||||||
If you don't want to, or a release is unavailable for your device, you will need to;
|
|
||||||
|
|
||||||
- install [Python 3](https://www.python.org/downloads/)
|
|
||||||
- install [NodeJS v18+](https://nodejs.org/en)
|
|
||||||
- clone the source code from this repo
|
|
||||||
- install all dependencies
|
|
||||||
- then run `meshchat.py`.
|
|
||||||
|
|
||||||
```
|
|
||||||
# clone repo
|
|
||||||
git clone https://github.com/liamcottle/reticulum-meshchat
|
|
||||||
cd reticulum-meshchat
|
|
||||||
|
|
||||||
# install nodejs deps
|
|
||||||
# if you want to build electron binaries, remove "--omit=dev"
|
|
||||||
# if you're using termux, add "--ignore-scripts" to fix error with esbuild
|
|
||||||
npm install --omit=dev
|
|
||||||
|
|
||||||
# build frontend vue components
|
|
||||||
npm run build-frontend
|
|
||||||
|
|
||||||
# install python deps
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# run meshchat
|
|
||||||
python meshchat.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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
|
|
||||||
|
|||||||
90
database.py
90
database.py
@@ -4,47 +4,40 @@ from peewee import *
|
|||||||
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
|
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
|
||||||
|
|
||||||
latest_version = 5 # increment each time new database migrations are added
|
latest_version = 5 # increment each time new database migrations are added
|
||||||
database = (
|
database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py
|
||||||
DatabaseProxy()
|
|
||||||
) # use a proxy object, as we will init real db client inside meshchat.py
|
|
||||||
migrator = SqliteMigrator(database)
|
migrator = SqliteMigrator(database)
|
||||||
|
|
||||||
|
|
||||||
# migrates the database
|
# migrates the database
|
||||||
def migrate(current_version):
|
def migrate(current_version):
|
||||||
|
|
||||||
# migrate to version 2
|
# migrate to version 2
|
||||||
if current_version < 2:
|
if current_version < 2:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column(
|
migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts),
|
||||||
"lxmf_messages", "delivery_attempts", LxmfMessage.delivery_attempts
|
migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at),
|
||||||
),
|
|
||||||
migrator.add_column(
|
|
||||||
"lxmf_messages",
|
|
||||||
"next_delivery_attempt_at",
|
|
||||||
LxmfMessage.next_delivery_attempt_at,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 3
|
# migrate to version 3
|
||||||
if current_version < 3:
|
if current_version < 3:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("lxmf_messages", "rssi", LxmfMessage.rssi),
|
migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi),
|
||||||
migrator.add_column("lxmf_messages", "snr", LxmfMessage.snr),
|
migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr),
|
||||||
migrator.add_column("lxmf_messages", "quality", LxmfMessage.quality),
|
migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality),
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 4
|
# migrate to version 4
|
||||||
if current_version < 4:
|
if current_version < 4:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("lxmf_messages", "method", LxmfMessage.method),
|
migrator.add_column("lxmf_messages", 'method', LxmfMessage.method),
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 5
|
# migrate to version 5
|
||||||
if current_version < 5:
|
if current_version < 5:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("announces", "rssi", Announce.rssi),
|
migrator.add_column("announces", 'rssi', Announce.rssi),
|
||||||
migrator.add_column("announces", "snr", Announce.snr),
|
migrator.add_column("announces", 'snr', Announce.snr),
|
||||||
migrator.add_column("announces", "quality", Announce.quality),
|
migrator.add_column("announces", 'quality', Announce.quality),
|
||||||
)
|
)
|
||||||
|
|
||||||
return latest_version
|
return latest_version
|
||||||
@@ -56,6 +49,7 @@ class BaseModel(Model):
|
|||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
key = CharField(unique=True)
|
key = CharField(unique=True)
|
||||||
value = TextField()
|
value = TextField()
|
||||||
@@ -68,19 +62,12 @@ class Config(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Announce(BaseModel):
|
class Announce(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
destination_hash = CharField(
|
destination_hash = CharField(unique=True) # unique destination hash that was announced
|
||||||
unique=True
|
aspect = TextField(index=True) # aspect is not included in announce, but we want to filter saved announces by aspect
|
||||||
) # unique destination hash that was announced
|
identity_hash = CharField(index=True) # identity hash that announced the destination
|
||||||
aspect = TextField(
|
identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually
|
||||||
index=True
|
|
||||||
) # aspect is not included in announce, but we want to filter saved announces by aspect
|
|
||||||
identity_hash = CharField(
|
|
||||||
index=True
|
|
||||||
) # identity hash that announced the destination
|
|
||||||
identity_public_key = (
|
|
||||||
CharField()
|
|
||||||
) # base64 encoded public key, incase we want to recreate the identity manually
|
|
||||||
app_data = TextField(null=True) # base64 encoded app data bytes
|
app_data = TextField(null=True) # base64 encoded app data bytes
|
||||||
rssi = IntegerField(null=True)
|
rssi = IntegerField(null=True)
|
||||||
snr = FloatField(null=True)
|
snr = FloatField(null=True)
|
||||||
@@ -95,6 +82,7 @@ class Announce(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CustomDestinationDisplayName(BaseModel):
|
class CustomDestinationDisplayName(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
destination_hash = CharField(unique=True) # unique destination hash
|
||||||
display_name = CharField() # custom display name for the destination hash
|
display_name = CharField() # custom display name for the destination hash
|
||||||
@@ -107,31 +95,37 @@ 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()
|
||||||
hash = CharField(unique=True) # unique lxmf message hash
|
hash = CharField(unique=True) # unique lxmf message hash
|
||||||
source_hash = CharField(index=True)
|
source_hash = CharField(index=True)
|
||||||
destination_hash = CharField(index=True)
|
destination_hash = CharField(index=True)
|
||||||
state = (
|
state = CharField() # state is converted from internal int to a human friendly string
|
||||||
CharField()
|
|
||||||
) # state is converted from internal int to a human friendly string
|
|
||||||
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
||||||
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
|
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
|
||||||
method = CharField(
|
method = CharField(null=True) # what method is being used to send the message, e.g: direct, propagated
|
||||||
null=True
|
delivery_attempts = IntegerField(default=0) # how many times delivery has been attempted for this message
|
||||||
) # what method is being used to send the message, e.g: direct, propagated
|
next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again
|
||||||
delivery_attempts = IntegerField(
|
|
||||||
default=0
|
|
||||||
) # how many times delivery has been attempted for this message
|
|
||||||
next_delivery_attempt_at = FloatField(
|
|
||||||
null=True
|
|
||||||
) # timestamp of when the message will attempt delivery again
|
|
||||||
title = TextField()
|
title = TextField()
|
||||||
content = TextField()
|
content = TextField()
|
||||||
fields = TextField() # json string
|
fields = TextField() # json string
|
||||||
timestamp = (
|
timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent)
|
||||||
FloatField()
|
|
||||||
) # timestamp of when the message was originally created (before ever being sent)
|
|
||||||
rssi = IntegerField(null=True)
|
rssi = IntegerField(null=True)
|
||||||
snr = FloatField(null=True)
|
snr = FloatField(null=True)
|
||||||
quality = FloatField(null=True)
|
quality = FloatField(null=True)
|
||||||
@@ -144,6 +138,7 @@ class LxmfMessage(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LxmfConversationReadState(BaseModel):
|
class LxmfConversationReadState(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
destination_hash = CharField(unique=True) # unique destination hash
|
||||||
last_read_at = DateTimeField()
|
last_read_at = DateTimeField()
|
||||||
@@ -157,13 +152,12 @@ class LxmfConversationReadState(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LxmfUserIcon(BaseModel):
|
class LxmfUserIcon(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
destination_hash = CharField(unique=True) # unique destination hash
|
||||||
icon_name = CharField() # material design icon name for the destination hash
|
icon_name = CharField() # material design icon name for the destination hash
|
||||||
foreground_colour = CharField() # hex colour to use for foreground (icon colour)
|
foreground_colour = CharField() # hex colour to use for foreground (icon colour)
|
||||||
background_colour = (
|
background_colour = CharField() # hex colour to use for background (background colour)
|
||||||
CharField()
|
|
||||||
) # hex colour to use for background (background colour)
|
|
||||||
|
|
||||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
services:
|
|
||||||
reticulum-meshchat:
|
|
||||||
container_name: reticulum-meshchat-dev
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
pull_policy: never
|
|
||||||
restart: unless-stopped
|
|
||||||
user: "1000:1000"
|
|
||||||
# Make the meshchat web interface accessible from the host on port 8000
|
|
||||||
ports:
|
|
||||||
- 0.0.0.0:8000:8000
|
|
||||||
volumes:
|
|
||||||
- meshchat-config:/config:rw
|
|
||||||
- .:/app:delegated
|
|
||||||
- /app/public
|
|
||||||
# Uncomment if you have a USB device connected, such as an RNode
|
|
||||||
# devices:
|
|
||||||
# - /dev/ttyUSB0:/dev/ttyUSB0
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
- NET_RAW
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
reservations:
|
|
||||||
cpus: '0.25'
|
|
||||||
memory: 128M
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
meshchat-config:
|
|
||||||
driver: local
|
|
||||||
driver_opts:
|
|
||||||
type: none
|
|
||||||
o: bind
|
|
||||||
device: ${PWD}/config
|
|
||||||
@@ -1,31 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
reticulum-meshchat:
|
reticulum-meshchat:
|
||||||
container_name: reticulum-meshchat
|
container_name: reticulum-meshchat
|
||||||
image: ghcr.io/sudo-ivan/reticulum-meshchat:latest
|
image: ghcr.io/liamcottle/reticulum-meshchat:latest
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "1000:1000"
|
|
||||||
# Make the meshchat web interface accessible from the host on port 8000
|
# Make the meshchat web interface accessible from the host on port 8000
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8000:8000
|
- 0.0.0.0:8000:8000
|
||||||
volumes:
|
volumes:
|
||||||
- meshchat-config:/config:rw
|
- meshchat-config:/config
|
||||||
# Uncomment if you have a USB device connected, such as an RNode
|
# Uncomment if you have a USB device connected, such as an RNode
|
||||||
# devices:
|
# devices:
|
||||||
# - /dev/ttyUSB0:/dev/ttyUSB0
|
# - /dev/ttyUSB0:/dev/ttyUSB0
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
- NET_RAW
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1'
|
|
||||||
memory: 512M
|
|
||||||
reservations:
|
|
||||||
cpus: '0.25'
|
|
||||||
memory: 128M
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
meshchat-config:
|
meshchat-config:
|
||||||
|
|||||||
@@ -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,9 +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 ignoredArguments = ["--no-sandbox"];
|
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
|
||||||
const userProvidedArguments = process.argv.slice(1)
|
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
|
||||||
.filter(arg => !ignoredArguments.includes(arg));
|
|
||||||
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
||||||
|
|
||||||
if(!shouldLaunchHeadless){
|
if(!shouldLaunchHeadless){
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
2797
meshchat.py
2797
meshchat.py
File diff suppressed because it is too large
Load Diff
1406
package-lock.json
generated
1406
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "reticulum-meshchat",
|
"name": "reticulum-meshchat",
|
||||||
"version": "1.22.2",
|
"version": "2.32.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"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",
|
||||||
@@ -96,23 +100,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.9.0",
|
"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",
|
||||||
"protobufjs": "^7.5.1",
|
"protobufjs": "^7.4.0",
|
||||||
"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.3.5",
|
"vite": "^6.4.1",
|
||||||
"vite-plugin-vuetify": "^2.0.4",
|
"vite-plugin-vuetify": "^2.0.4",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.0",
|
||||||
"vuetify": "^3.8.4"
|
"vuetify": "^3.7.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
aiohttp>=3.11.18
|
aiohttp>=3.12.14
|
||||||
cx_freeze>=7.0.0
|
cx_freeze>=7.0.0
|
||||||
lxmf>=0.6.3
|
lxmf>=0.9.3
|
||||||
peewee>=3.18.1
|
peewee>=3.18.1
|
||||||
rns>=0.9.5
|
psutil>=7.1.3
|
||||||
websockets>=15.0.1
|
rns>=1.0.3
|
||||||
|
websockets>=14.2
|
||||||
|
|||||||
38
setup.py
38
setup.py
@@ -1,44 +1,44 @@
|
|||||||
from cx_Freeze import setup, Executable
|
from cx_Freeze import setup, Executable
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="ReticulumMeshChat",
|
name='ReticulumMeshChat',
|
||||||
version="1.0.0",
|
version='1.0.0',
|
||||||
description="A simple mesh network communications app powered by the Reticulum Network Stack",
|
description='A simple mesh network communications app powered by the Reticulum Network Stack',
|
||||||
executables=[
|
executables=[
|
||||||
Executable(
|
Executable(
|
||||||
script="meshchat.py", # this script to run
|
script='meshchat.py', # this script to run
|
||||||
base=None, # we are running a console application, not a gui
|
base=None, # we are running a console application, not a gui
|
||||||
target_name="ReticulumMeshChat", # creates ReticulumMeshChat.exe
|
target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe
|
||||||
shortcut_name="ReticulumMeshChat", # name shown in shortcut
|
shortcut_name='ReticulumMeshChat', # name shown in shortcut
|
||||||
shortcut_dir="ProgramMenuFolder", # put the shortcut in windows start menu
|
shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu
|
||||||
icon="logo/icon.ico", # set the icon for the exe
|
icon='logo/icon.ico', # set the icon for the exe
|
||||||
copyright="Copyright (c) 2024 Liam Cottle",
|
copyright='Copyright (c) 2024 Liam Cottle',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"build_exe": {
|
'build_exe': {
|
||||||
# libs that are required
|
# libs that are required
|
||||||
"packages": [
|
'packages': [
|
||||||
# required for dynamic import fix
|
# required for dynamic import fix
|
||||||
# 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',
|
||||||
],
|
],
|
||||||
# files that are required
|
# files that are required
|
||||||
"include_files": [
|
'include_files': [
|
||||||
"package.json", # used to determine app version from python
|
'package.json', # used to determine app version from python
|
||||||
"public/", # static files served by web server
|
'public/', # static files served by web server
|
||||||
],
|
],
|
||||||
# slim down the build by excluding these unused libs
|
# slim down the build by excluding these unused libs
|
||||||
"excludes": [
|
'excludes': [
|
||||||
"PIL", # saves ~200MB
|
'PIL', # saves ~200MB
|
||||||
],
|
],
|
||||||
# this has the same effect as the -O command line option when executing CPython directly.
|
# this has the same effect as the -O command line option when executing CPython directly.
|
||||||
# it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
|
# it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
|
||||||
# https://stackoverflow.com/a/57948104
|
# https://stackoverflow.com/a/57948104
|
||||||
"optimize": 2,
|
"optimize": 2,
|
||||||
# change where exe is built to
|
# change where exe is built to
|
||||||
"build_exe": "build/exe",
|
'build_exe': 'build/exe',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import sys
|
|||||||
|
|
||||||
# this class forces stream writes to be flushed immediately
|
# this class forces stream writes to be flushed immediately
|
||||||
class ImmediateFlushingStreamWrapper:
|
class ImmediateFlushingStreamWrapper:
|
||||||
|
|
||||||
def __init__(self, stream):
|
def __init__(self, stream):
|
||||||
self.stream = stream
|
self.stream = stream
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
# an announce handler that forwards announces to a provided callback for the provided aspect filter
|
# an announce handler that forwards announces to a provided callback for the provided aspect filter
|
||||||
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
||||||
class AnnounceHandler:
|
class AnnounceHandler:
|
||||||
|
|
||||||
def __init__(self, aspect_filter: str, received_announce_callback):
|
def __init__(self, aspect_filter: str, received_announce_callback):
|
||||||
self.aspect_filter = aspect_filter
|
self.aspect_filter = aspect_filter
|
||||||
self.received_announce_callback = received_announce_callback
|
self.received_announce_callback = received_announce_callback
|
||||||
|
|
||||||
# we will just pass the received announce back to the provided callback
|
# we will just pass the received announce back to the provided callback
|
||||||
def received_announce(
|
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash):
|
||||||
self, destination_hash, announced_identity, app_data, announce_packet_hash
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
# handle received announce
|
# handle received announce
|
||||||
self.received_announce_callback(
|
self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
|
||||||
self.aspect_filter,
|
|
||||||
destination_hash,
|
|
||||||
announced_identity,
|
|
||||||
app_data,
|
|
||||||
announce_packet_hash,
|
|
||||||
)
|
|
||||||
except:
|
except:
|
||||||
# ignore failure to handle received announce
|
# ignore failure to handle received announce
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,23 +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
|
|
||||||
# it will run the async function on the existing event loop if available, otherwise it will start a new event loop
|
|
||||||
@staticmethod
|
|
||||||
def run_async(coroutine):
|
|
||||||
# attempt to get existing event loop
|
|
||||||
existing_event_loop = None
|
|
||||||
try:
|
|
||||||
existing_event_loop = asyncio.get_running_loop()
|
|
||||||
except RuntimeError:
|
|
||||||
# 'RuntimeError: no running event loop'
|
|
||||||
pass
|
|
||||||
|
|
||||||
# if there is an existing event loop running, submit the coroutine to that loop
|
# remember main loop
|
||||||
if existing_event_loop and existing_event_loop.is_running():
|
main_loop: asyncio.AbstractEventLoop | None = None
|
||||||
existing_event_loop.create_task(coroutine)
|
|
||||||
|
@staticmethod
|
||||||
|
def set_main_loop(loop: asyncio.AbstractEventLoop):
|
||||||
|
AsyncUtils.main_loop = loop
|
||||||
|
|
||||||
|
# this method allows running the provided async coroutine from within a sync function
|
||||||
|
# it will run the async function on the main event loop if possible, otherwise it logs a warning
|
||||||
|
@staticmethod
|
||||||
|
def run_async(coroutine: Coroutine):
|
||||||
|
|
||||||
|
# run provided coroutine on main event loop, ensuring thread safety
|
||||||
|
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
||||||
|
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
|
||||||
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.")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class CallFailedException(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class AudioCall:
|
class AudioCall:
|
||||||
|
|
||||||
def __init__(self, link: RNS.Link, is_outbound: bool):
|
def __init__(self, link: RNS.Link, is_outbound: bool):
|
||||||
self.link = link
|
self.link = link
|
||||||
self.is_outbound = is_outbound
|
self.is_outbound = is_outbound
|
||||||
@@ -40,25 +41,21 @@ class AudioCall:
|
|||||||
|
|
||||||
# handle packet received over link
|
# handle packet received over link
|
||||||
def on_packet(self, message, packet):
|
def on_packet(self, message, packet):
|
||||||
|
|
||||||
# send audio received from call initiator to all audio packet listeners
|
# send audio received from call initiator to all audio packet listeners
|
||||||
for audio_packet_listener in self.audio_packet_listeners:
|
for audio_packet_listener in self.audio_packet_listeners:
|
||||||
audio_packet_listener(message)
|
audio_packet_listener(message)
|
||||||
|
|
||||||
# send an audio packet over the link
|
# send an audio packet over the link
|
||||||
def send_audio_packet(self, data):
|
def send_audio_packet(self, data):
|
||||||
|
|
||||||
# do nothing if link is not active
|
# do nothing if link is not active
|
||||||
if self.is_active() is False:
|
if self.is_active() is False:
|
||||||
return
|
return
|
||||||
|
|
||||||
# drop audio packet if it is too big to send
|
# drop audio packet if it is too big to send
|
||||||
if len(data) > RNS.Link.MDU:
|
if len(data) > RNS.Link.MDU:
|
||||||
print(
|
print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
|
||||||
"[AudioCall] dropping audio packet "
|
|
||||||
+ str(len(data))
|
|
||||||
+ " bytes exceeds the link packet MDU of "
|
|
||||||
+ str(RNS.Link.MDU)
|
|
||||||
+ " bytes"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# send codec2 audio received from call receiver to call initiator over reticulum link
|
# send codec2 audio received from call receiver to call initiator over reticulum link
|
||||||
@@ -80,7 +77,9 @@ class AudioCall:
|
|||||||
|
|
||||||
|
|
||||||
class AudioCallManager:
|
class AudioCallManager:
|
||||||
|
|
||||||
def __init__(self, identity: RNS.Identity):
|
def __init__(self, identity: RNS.Identity):
|
||||||
|
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
self.on_incoming_call_callback = None
|
self.on_incoming_call_callback = None
|
||||||
self.on_outgoing_call_callback = None
|
self.on_outgoing_call_callback = None
|
||||||
@@ -92,10 +91,7 @@ class AudioCallManager:
|
|||||||
# announces the audio call destination
|
# announces the audio call destination
|
||||||
def announce(self, app_data=None):
|
def announce(self, app_data=None):
|
||||||
self.audio_call_receiver.destination.announce(app_data)
|
self.audio_call_receiver.destination.announce(app_data)
|
||||||
print(
|
print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash))
|
||||||
"[AudioCallManager] announced destination: "
|
|
||||||
+ RNS.prettyhexrep(self.audio_call_receiver.destination.hash)
|
|
||||||
)
|
|
||||||
|
|
||||||
# set the callback for incoming calls
|
# set the callback for incoming calls
|
||||||
def register_incoming_call_callback(self, callback):
|
def register_incoming_call_callback(self, callback):
|
||||||
@@ -107,6 +103,7 @@ class AudioCallManager:
|
|||||||
|
|
||||||
# handle incoming calls from audio call receiver
|
# handle incoming calls from audio call receiver
|
||||||
def handle_incoming_call(self, audio_call: AudioCall):
|
def handle_incoming_call(self, audio_call: AudioCall):
|
||||||
|
|
||||||
# remember it
|
# remember it
|
||||||
self.audio_calls.append(audio_call)
|
self.audio_calls.append(audio_call)
|
||||||
|
|
||||||
@@ -116,6 +113,7 @@ class AudioCallManager:
|
|||||||
|
|
||||||
# handle outgoing calls
|
# handle outgoing calls
|
||||||
def handle_outgoing_call(self, audio_call: AudioCall):
|
def handle_outgoing_call(self, audio_call: AudioCall):
|
||||||
|
|
||||||
# remember it
|
# remember it
|
||||||
self.audio_calls.append(audio_call)
|
self.audio_calls.append(audio_call)
|
||||||
|
|
||||||
@@ -147,22 +145,19 @@ class AudioCallManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# attempts to initiate a call to the provided destination and returns the link hash on success
|
# attempts to initiate a call to the provided destination and returns the link hash on success
|
||||||
async def initiate(
|
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall:
|
||||||
self, destination_hash: bytes, timeout_seconds: int = 15
|
|
||||||
) -> AudioCall:
|
|
||||||
# determine when to timeout
|
# determine when to timeout
|
||||||
timeout_after_seconds = time.time() + timeout_seconds
|
timeout_after_seconds = time.time() + timeout_seconds
|
||||||
|
|
||||||
# check if we have a path to the destination
|
# check if we have a path to the destination
|
||||||
if not RNS.Transport.has_path(destination_hash):
|
if not RNS.Transport.has_path(destination_hash):
|
||||||
|
|
||||||
# we don't have a path, so we need to request it
|
# we don't have a path, so we need to request it
|
||||||
RNS.Transport.request_path(destination_hash)
|
RNS.Transport.request_path(destination_hash)
|
||||||
|
|
||||||
# wait until we have a path, or give up after the configured timeout
|
# wait until we have a path, or give up after the configured timeout
|
||||||
while (
|
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds:
|
||||||
not RNS.Transport.has_path(destination_hash)
|
|
||||||
and time.time() < timeout_after_seconds
|
|
||||||
):
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# if we still don't have a path, we can't establish a link, so bail out
|
# if we still don't have a path, we can't establish a link, so bail out
|
||||||
@@ -176,16 +171,14 @@ class AudioCallManager:
|
|||||||
RNS.Destination.OUT,
|
RNS.Destination.OUT,
|
||||||
RNS.Destination.SINGLE,
|
RNS.Destination.SINGLE,
|
||||||
"call",
|
"call",
|
||||||
"audio",
|
"audio"
|
||||||
)
|
)
|
||||||
|
|
||||||
# create link
|
# create link
|
||||||
link = RNS.Link(server_destination)
|
link = RNS.Link(server_destination)
|
||||||
|
|
||||||
# wait until we have established a link, or give up after the configured timeout
|
# wait until we have established a link, or give up after the configured timeout
|
||||||
while (
|
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
|
||||||
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
|
|
||||||
):
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# if we still haven't established a link, bail out
|
# if we still haven't established a link, bail out
|
||||||
@@ -205,7 +198,9 @@ class AudioCallManager:
|
|||||||
|
|
||||||
|
|
||||||
class AudioCallReceiver:
|
class AudioCallReceiver:
|
||||||
|
|
||||||
def __init__(self, manager: AudioCallManager):
|
def __init__(self, manager: AudioCallManager):
|
||||||
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
|
|
||||||
# create destination for receiving audio calls
|
# create destination for receiving audio calls
|
||||||
@@ -229,6 +224,7 @@ class AudioCallReceiver:
|
|||||||
|
|
||||||
# client connected to us, set up an audio call instance
|
# client connected to us, set up an audio call instance
|
||||||
def client_connected(self, link: RNS.Link):
|
def client_connected(self, link: RNS.Link):
|
||||||
|
|
||||||
# todo: this can be optional, it's only being sent by default for ui, can be removed
|
# todo: this can be optional, it's only being sent by default for ui, can be removed
|
||||||
link.identify(self.manager.identity)
|
link.identify(self.manager.identity)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
class ColourUtils:
|
class ColourUtils:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hex_colour_to_byte_array(hex_colour):
|
def hex_colour_to_byte_array(hex_colour):
|
||||||
|
|
||||||
# remove leading "#"
|
# remove leading "#"
|
||||||
hex_colour = hex_colour.lstrip("#")
|
hex_colour = hex_colour.lstrip('#')
|
||||||
|
|
||||||
# convert the remaining hex string to bytes
|
# convert the remaining hex string to bytes
|
||||||
return bytes.fromhex(hex_colour)
|
return bytes.fromhex(hex_colour)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import RNS.vendor.configobj
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceConfigParser:
|
class InterfaceConfigParser:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(text):
|
def parse(text):
|
||||||
|
|
||||||
# get lines from provided text
|
# get lines from provided text
|
||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ class InterfaceConfigParser:
|
|||||||
# process interfaces
|
# process interfaces
|
||||||
interfaces = []
|
interfaces = []
|
||||||
for interface_name in config_interfaces:
|
for interface_name in config_interfaces:
|
||||||
|
|
||||||
# ensure interface has a name
|
# ensure interface has a name
|
||||||
interface_config = config_interfaces[interface_name]
|
interface_config = config_interfaces[interface_name]
|
||||||
interface_config["name"] = interface_name
|
interface_config["name"] = interface_name
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class InterfaceEditor:
|
class InterfaceEditor:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_value(interface_details: dict, data: dict, key: str):
|
def update_value(interface_details: dict, data: dict, key: str):
|
||||||
|
|
||||||
# update value if provided and not empty
|
# update value if provided and not empty
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
if value is not None and value != "":
|
if value is not None and value != "":
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from websockets.sync.connection import Connection
|
|||||||
|
|
||||||
|
|
||||||
class WebsocketClientInterface(Interface):
|
class WebsocketClientInterface(Interface):
|
||||||
|
|
||||||
# TODO: required?
|
# TODO: required?
|
||||||
DEFAULT_IFAC_SIZE = 16
|
DEFAULT_IFAC_SIZE = 16
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ class WebsocketClientInterface(Interface):
|
|||||||
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
||||||
|
|
||||||
def __init__(self, owner, configuration, websocket: Connection = None):
|
def __init__(self, owner, configuration, websocket: Connection = None):
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
@@ -24,8 +26,8 @@ class WebsocketClientInterface(Interface):
|
|||||||
|
|
||||||
self.IN = True
|
self.IN = True
|
||||||
self.OUT = False
|
self.OUT = False
|
||||||
self.HW_MTU = 262144 # 256KiB
|
self.HW_MTU = 262144 # 256KiB
|
||||||
self.bitrate = 1_000_000_000 # 1Gbps
|
self.bitrate = 1_000_000_000 # 1Gbps
|
||||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||||
|
|
||||||
# parse config
|
# parse config
|
||||||
@@ -46,6 +48,7 @@ class WebsocketClientInterface(Interface):
|
|||||||
|
|
||||||
# called when a full packet has been received over the websocket
|
# called when a full packet has been received over the websocket
|
||||||
def process_incoming(self, data):
|
def process_incoming(self, data):
|
||||||
|
|
||||||
# do nothing if offline or detached
|
# do nothing if offline or detached
|
||||||
if not self.online or self.detached:
|
if not self.online or self.detached:
|
||||||
return
|
return
|
||||||
@@ -62,6 +65,7 @@ class WebsocketClientInterface(Interface):
|
|||||||
|
|
||||||
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
|
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
|
||||||
def process_outgoing(self, data):
|
def process_outgoing(self, data):
|
||||||
|
|
||||||
# do nothing if offline or detached
|
# do nothing if offline or detached
|
||||||
if not self.online or self.detached:
|
if not self.online or self.detached:
|
||||||
return
|
return
|
||||||
@@ -70,9 +74,7 @@ class WebsocketClientInterface(Interface):
|
|||||||
try:
|
try:
|
||||||
self.websocket.send(data)
|
self.websocket.send(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log(
|
RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR)
|
||||||
f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR
|
|
||||||
)
|
|
||||||
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
|
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ class WebsocketClientInterface(Interface):
|
|||||||
|
|
||||||
# connect to the configured websocket server
|
# connect to the configured websocket server
|
||||||
def connect(self):
|
def connect(self):
|
||||||
|
|
||||||
# do nothing if interface is detached
|
# do nothing if interface is detached
|
||||||
if self.detached:
|
if self.detached:
|
||||||
return
|
return
|
||||||
@@ -92,9 +95,7 @@ class WebsocketClientInterface(Interface):
|
|||||||
# connect to websocket server
|
# connect to websocket server
|
||||||
try:
|
try:
|
||||||
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
|
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
|
||||||
self.websocket = connect(
|
self.websocket = connect(f"{self.target_url}", max_size=None, compression=None)
|
||||||
f"{self.target_url}", max_size=None, compression=None
|
|
||||||
)
|
|
||||||
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
|
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
|
||||||
self.read_loop()
|
self.read_loop()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -106,6 +107,7 @@ class WebsocketClientInterface(Interface):
|
|||||||
self.connect()
|
self.connect()
|
||||||
|
|
||||||
def read_loop(self):
|
def read_loop(self):
|
||||||
|
|
||||||
self.online = True
|
self.online = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -117,6 +119,7 @@ class WebsocketClientInterface(Interface):
|
|||||||
self.online = False
|
self.online = False
|
||||||
|
|
||||||
def detach(self):
|
def detach(self):
|
||||||
|
|
||||||
# mark as offline
|
# mark as offline
|
||||||
self.online = False
|
self.online = False
|
||||||
|
|
||||||
@@ -127,6 +130,5 @@ class WebsocketClientInterface(Interface):
|
|||||||
# mark as detached
|
# mark as detached
|
||||||
self.detached = True
|
self.detached = True
|
||||||
|
|
||||||
|
|
||||||
# set interface class RNS should use when importing this external interface
|
# set interface class RNS should use when importing this external interface
|
||||||
interface_class = WebsocketClientInterface
|
interface_class = WebsocketClientInterface
|
||||||
|
|||||||
@@ -11,25 +11,25 @@ from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInter
|
|||||||
|
|
||||||
|
|
||||||
class WebsocketServerInterface(Interface):
|
class WebsocketServerInterface(Interface):
|
||||||
|
|
||||||
# TODO: required?
|
# TODO: required?
|
||||||
DEFAULT_IFAC_SIZE = 16
|
DEFAULT_IFAC_SIZE = 16
|
||||||
|
|
||||||
RESTART_DELAY_SECONDS = 5
|
RESTART_DELAY_SECONDS = 5
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
return f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
|
||||||
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, owner, configuration):
|
def __init__(self, owner, configuration):
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
|
|
||||||
self.IN = True
|
self.IN = True
|
||||||
self.OUT = False
|
self.OUT = False
|
||||||
self.HW_MTU = 262144 # 256KiB
|
self.HW_MTU = 262144 # 256KiB
|
||||||
self.bitrate = 1_000_000_000 # 1Gbps
|
self.bitrate = 1_000_000_000 # 1Gbps
|
||||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||||
|
|
||||||
self.server: Server | None = None
|
self.server: Server | None = None
|
||||||
@@ -80,19 +80,17 @@ class WebsocketServerInterface(Interface):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def serve(self):
|
def serve(self):
|
||||||
|
|
||||||
# handle new websocket client connections
|
# handle new websocket client connections
|
||||||
def on_websocket_client_connected(websocket: ServerConnection):
|
def on_websocket_client_connected(websocket: ServerConnection):
|
||||||
|
|
||||||
# create new child interface
|
# create new child interface
|
||||||
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
|
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
|
||||||
spawned_interface = WebsocketClientInterface(
|
spawned_interface = WebsocketClientInterface(self.owner, {
|
||||||
self.owner,
|
"name": f"Client on {self.name}",
|
||||||
{
|
"target_host": websocket.remote_address[0],
|
||||||
"name": f"Client on {self.name}",
|
"target_port": str(websocket.remote_address[1]),
|
||||||
"target_host": websocket.remote_address[0],
|
}, websocket=websocket)
|
||||||
"target_port": str(websocket.remote_address[1]),
|
|
||||||
},
|
|
||||||
websocket=websocket,
|
|
||||||
)
|
|
||||||
|
|
||||||
# configure child interface
|
# configure child interface
|
||||||
spawned_interface.IN = self.IN
|
spawned_interface.IN = self.IN
|
||||||
@@ -112,10 +110,7 @@ class WebsocketServerInterface(Interface):
|
|||||||
# todo announce rates?
|
# todo announce rates?
|
||||||
|
|
||||||
# activate child interface
|
# activate child interface
|
||||||
RNS.log(
|
RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE)
|
||||||
f"Spawned new WebsocketClientInterface: {spawned_interface}",
|
|
||||||
RNS.LOG_VERBOSE,
|
|
||||||
)
|
|
||||||
RNS.Transport.interfaces.append(spawned_interface)
|
RNS.Transport.interfaces.append(spawned_interface)
|
||||||
|
|
||||||
# associate child interface with this interface
|
# associate child interface with this interface
|
||||||
@@ -132,12 +127,7 @@ class WebsocketServerInterface(Interface):
|
|||||||
# run websocket server
|
# run websocket server
|
||||||
try:
|
try:
|
||||||
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
|
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
|
||||||
with serve(
|
with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server:
|
||||||
on_websocket_client_connected,
|
|
||||||
self.listen_ip,
|
|
||||||
self.listen_port,
|
|
||||||
compression=None,
|
|
||||||
) as server:
|
|
||||||
self.online = True
|
self.online = True
|
||||||
self.server = server
|
self.server = server
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
@@ -151,6 +141,7 @@ class WebsocketServerInterface(Interface):
|
|||||||
self.serve()
|
self.serve()
|
||||||
|
|
||||||
def detach(self):
|
def detach(self):
|
||||||
|
|
||||||
# mark as offline
|
# mark as offline
|
||||||
self.online = False
|
self.online = False
|
||||||
|
|
||||||
@@ -161,6 +152,5 @@ class WebsocketServerInterface(Interface):
|
|||||||
# mark as detached
|
# mark as detached
|
||||||
self.detached = True
|
self.detached = True
|
||||||
|
|
||||||
|
|
||||||
# set interface class RNS should use when importing this external interface
|
# set interface class RNS should use when importing this external interface
|
||||||
interface_class = WebsocketServerInterface
|
interface_class = WebsocketServerInterface
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import List
|
|||||||
|
|
||||||
# helper class for passing around an lxmf audio field
|
# helper class for passing around an lxmf audio field
|
||||||
class LxmfAudioField:
|
class LxmfAudioField:
|
||||||
|
|
||||||
def __init__(self, audio_mode: int, audio_bytes: bytes):
|
def __init__(self, audio_mode: int, audio_bytes: bytes):
|
||||||
self.audio_mode = audio_mode
|
self.audio_mode = audio_mode
|
||||||
self.audio_bytes = audio_bytes
|
self.audio_bytes = audio_bytes
|
||||||
@@ -10,6 +11,7 @@ class LxmfAudioField:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf image field
|
# helper class for passing around an lxmf image field
|
||||||
class LxmfImageField:
|
class LxmfImageField:
|
||||||
|
|
||||||
def __init__(self, image_type: str, image_bytes: bytes):
|
def __init__(self, image_type: str, image_bytes: bytes):
|
||||||
self.image_type = image_type
|
self.image_type = image_type
|
||||||
self.image_bytes = image_bytes
|
self.image_bytes = image_bytes
|
||||||
@@ -17,6 +19,7 @@ class LxmfImageField:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf file attachment
|
# helper class for passing around an lxmf file attachment
|
||||||
class LxmfFileAttachment:
|
class LxmfFileAttachment:
|
||||||
|
|
||||||
def __init__(self, file_name: str, file_bytes: bytes):
|
def __init__(self, file_name: str, file_bytes: bytes):
|
||||||
self.file_name = file_name
|
self.file_name = file_name
|
||||||
self.file_bytes = file_bytes
|
self.file_bytes = file_bytes
|
||||||
@@ -24,5 +27,7 @@ class LxmfFileAttachment:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf file attachments field
|
# helper class for passing around an lxmf file attachments field
|
||||||
class LxmfFileAttachmentsField:
|
class LxmfFileAttachmentsField:
|
||||||
|
|
||||||
def __init__(self, file_attachments: List[LxmfFileAttachment]):
|
def __init__(self, file_attachments: List[LxmfFileAttachment]):
|
||||||
self.file_attachments = file_attachments
|
self.file_attachments = file_attachments
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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){
|
||||||
|
|||||||
Reference in New Issue
Block a user