├── .github
└── workflows
│ ├── build.yml
│ └── manual-docker-build.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── database.py
├── docker-compose.yml
├── docs
├── meshchat_on_android_with_termux.md
├── meshchat_on_docker.md
└── meshchat_on_raspberry_pi.md
├── donate.md
├── electron
├── assets
│ ├── images
│ │ └── logo.png
│ └── js
│ │ └── tailwindcss
│ │ └── tailwind-v3.4.3-forms-v0.5.7.js
├── build
│ └── icon.png
├── loading.html
├── main.js
└── preload.js
├── logo
├── icon.ico
├── logo-chat-bubble.png
├── logo.afdesign
└── logo.png
├── meshchat.py
├── package-lock.json
├── package.json
├── postcss.config.js
├── requirements.txt
├── screenshots
└── screenshot.png
├── setup.py
├── src
├── __init__.py
├── backend
│ ├── announce_handler.py
│ ├── async_utils.py
│ ├── audio_call_manager.py
│ ├── colour_utils.py
│ ├── interface_config_parser.py
│ ├── interface_editor.py
│ ├── interfaces
│ │ ├── WebsocketClientInterface.py
│ │ └── WebsocketServerInterface.py
│ ├── lxmf_message_fields.py
│ └── sideband_commands.py
└── frontend
│ ├── call.html
│ ├── call.js
│ ├── components
│ ├── App.vue
│ ├── ColourPickerDropdown.vue
│ ├── DropDownMenu.vue
│ ├── DropDownMenuItem.vue
│ ├── IconButton.vue
│ ├── LxmfUserIcon.vue
│ ├── MaterialDesignIcon.vue
│ ├── SidebarLink.vue
│ ├── about
│ │ └── AboutPage.vue
│ ├── call
│ │ └── CallPage.vue
│ ├── forms
│ │ ├── FormLabel.vue
│ │ └── FormSubLabel.vue
│ ├── interfaces
│ │ ├── AddInterfacePage.vue
│ │ ├── ExpandingSection.vue
│ │ ├── ImportInterfacesModal.vue
│ │ ├── Interface.vue
│ │ └── InterfacesPage.vue
│ ├── messages
│ │ ├── AddAudioButton.vue
│ │ ├── AddImageButton.vue
│ │ ├── ConversationDropDownMenu.vue
│ │ ├── ConversationViewer.vue
│ │ ├── MessagesPage.vue
│ │ ├── MessagesSidebar.vue
│ │ └── SendMessageButton.vue
│ ├── network-visualiser
│ │ ├── NetworkVisualiser.vue
│ │ └── NetworkVisualiserPage.vue
│ ├── nomadnetwork
│ │ ├── NomadNetworkPage.vue
│ │ └── NomadNetworkSidebar.vue
│ ├── ping
│ │ └── PingPage.vue
│ ├── profile
│ │ └── ProfileIconPage.vue
│ ├── propagation-nodes
│ │ └── PropagationNodesPage.vue
│ ├── settings
│ │ └── SettingsPage.vue
│ └── tools
│ │ └── ToolsPage.vue
│ ├── fonts
│ └── RobotoMonoNerdFont
│ │ ├── RobotoMonoNerdFont-Regular.ttf
│ │ └── font.css
│ ├── index.html
│ ├── js
│ ├── DialogUtils.js
│ ├── DownloadUtils.js
│ ├── ElectronUtils.js
│ ├── GlobalEmitter.js
│ ├── GlobalState.js
│ ├── MicronParser.js
│ ├── MicrophoneRecorder.js
│ ├── NotificationUtils.js
│ ├── Utils.js
│ └── WebSocketConnection.js
│ ├── main.js
│ ├── public
│ ├── assets
│ │ ├── images
│ │ │ ├── logo-chat-bubble.png
│ │ │ ├── logo.png
│ │ │ ├── network-visualiser
│ │ │ │ ├── interface_connected.png
│ │ │ │ ├── interface_disconnected.png
│ │ │ │ ├── server.png
│ │ │ │ ├── server_1hop.png
│ │ │ │ ├── user.png
│ │ │ │ └── user_1hop.png
│ │ │ └── reticulum_logo_512.png
│ │ ├── js
│ │ │ ├── codec2-emscripten
│ │ │ │ ├── c2dec.js
│ │ │ │ ├── c2dec.wasm
│ │ │ │ ├── c2enc.js
│ │ │ │ ├── c2enc.wasm
│ │ │ │ ├── codec2-lib.js
│ │ │ │ ├── codec2-microphone-recorder.js
│ │ │ │ ├── index.html
│ │ │ │ ├── processor.js
│ │ │ │ ├── sox.js
│ │ │ │ ├── sox.wasm
│ │ │ │ └── wav-encoder.js
│ │ │ └── tailwindcss
│ │ │ │ └── tailwind-v3.4.3-forms-v0.5.7.js
│ │ └── proto
│ │ │ └── audio_call.proto
│ ├── favicons
│ │ └── favicon-512x512.png
│ ├── manifest.json
│ ├── rnode-flasher
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── index.html
│ │ ├── js
│ │ │ ├── crypto-js@3.9.1-1
│ │ │ │ ├── core.js
│ │ │ │ └── md5.js
│ │ │ ├── esptool-js@0.4.5
│ │ │ │ └── bundle.js
│ │ │ ├── nrf52_dfu_flasher.js
│ │ │ ├── rnode.js
│ │ │ ├── tailwindcss
│ │ │ │ └── tailwind-v3.4.3-forms-v0.5.7.js
│ │ │ ├── vue@3.4.26
│ │ │ │ └── dist
│ │ │ │ │ └── vue.global.js
│ │ │ ├── web-serial-polyfill@1.0.15
│ │ │ │ └── dist
│ │ │ │ │ └── serial.js
│ │ │ └── zip.min.js
│ │ └── reticulum_logo_512.png
│ └── service-worker.js
│ └── style.css
├── tailwind.config.js
└── vite.config.js
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | jobs:
9 | build_windows:
10 | runs-on: windows-latest
11 | permissions:
12 | contents: write
13 | steps:
14 | - name: Clone Repo
15 | uses: actions/checkout@v1
16 |
17 | - name: Install NodeJS
18 | uses: actions/setup-node@v1
19 | with:
20 | node-version: 18
21 |
22 | - name: Install Python
23 | uses: actions/setup-python@v5
24 | with:
25 | python-version: "3.11"
26 |
27 | - name: Install Python Deps
28 | run: pip install -r requirements.txt
29 |
30 | - name: Install NodeJS Deps
31 | run: npm install
32 |
33 | - name: Build Electron App
34 | run: npm run dist
35 |
36 | - name: Create Release
37 | id: create_release
38 | uses: ncipollo/release-action@v1
39 | with:
40 | draft: true
41 | allowUpdates: true
42 | replacesArtifacts: true
43 | omitDraftDuringUpdate: true
44 | omitNameDuringUpdate: true
45 | artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
46 |
47 | build_mac:
48 | runs-on: macos-13
49 | permissions:
50 | contents: write
51 | steps:
52 | - name: Clone Repo
53 | uses: actions/checkout@v1
54 |
55 | - name: Install NodeJS
56 | uses: actions/setup-node@v1
57 | with:
58 | node-version: 18
59 |
60 | - name: Install Python
61 | uses: actions/setup-python@v5
62 | with:
63 | python-version: "3.11"
64 |
65 | - name: Install Python Deps
66 | run: pip install -r requirements.txt
67 |
68 | - name: Install NodeJS Deps
69 | run: npm install
70 |
71 | - name: Build Electron App
72 | run: npm run dist
73 |
74 | - name: Create Release
75 | id: create_release
76 | uses: ncipollo/release-action@v1
77 | with:
78 | draft: true
79 | allowUpdates: true
80 | replacesArtifacts: true
81 | omitDraftDuringUpdate: true
82 | omitNameDuringUpdate: true
83 | artifacts: "dist/*-mac.dmg"
84 |
85 | build_linux:
86 | runs-on: ubuntu-latest
87 | permissions:
88 | contents: write
89 | steps:
90 | - name: Clone Repo
91 | uses: actions/checkout@v1
92 |
93 | - name: Install NodeJS
94 | uses: actions/setup-node@v1
95 | with:
96 | node-version: 18
97 |
98 | - name: Install Python
99 | uses: actions/setup-python@v5
100 | with:
101 | python-version: "3.11"
102 |
103 | - name: Install Python Deps
104 | run: pip install -r requirements.txt
105 |
106 | - name: Install NodeJS Deps
107 | run: npm install
108 |
109 | - name: Build Electron App
110 | run: npm run dist
111 |
112 | - name: Create Release
113 | id: create_release
114 | uses: ncipollo/release-action@v1
115 | with:
116 | draft: true
117 | allowUpdates: true
118 | replacesArtifacts: true
119 | omitDraftDuringUpdate: true
120 | omitNameDuringUpdate: true
121 | artifacts: "dist/*-linux.AppImage"
122 |
123 | build_docker:
124 | runs-on: ubuntu-latest
125 | permissions:
126 | packages: write
127 | contents: read
128 | steps:
129 | - name: Clone Repo
130 | uses: actions/checkout@v4
131 |
132 | - name: Set up QEMU
133 | uses: docker/setup-qemu-action@v3
134 |
135 | - name: Set up Docker Buildx
136 | uses: docker/setup-buildx-action@v3
137 |
138 | - name: Log in to the GitHub Container registry
139 | uses: docker/login-action@v3
140 | with:
141 | registry: ghcr.io
142 | username: ${{ github.actor }}
143 | password: ${{ secrets.GITHUB_TOKEN }}
144 |
145 | - name: Build and push Docker images
146 | uses: docker/build-push-action@v5
147 | with:
148 | context: .
149 | platforms: linux/amd64,linux/arm64
150 | push: true
151 | tags: |
152 | ghcr.io/liamcottle/reticulum-meshchat:latest
153 | ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
154 | labels: |
155 | org.opencontainers.image.title=Reticulum MeshChat
156 | org.opencontainers.image.description=Docker image for Reticulum MeshChat
157 | org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
158 |
--------------------------------------------------------------------------------
/.github/workflows/manual-docker-build.yml:
--------------------------------------------------------------------------------
1 | name: Temporary manual trigger for Docker build
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | build_docker:
8 | runs-on: ubuntu-latest
9 | permissions:
10 | packages: write
11 | contents: read
12 | steps:
13 | - name: Clone Repo
14 | uses: actions/checkout@v4
15 |
16 | - name: Set up QEMU
17 | uses: docker/setup-qemu-action@v3
18 |
19 | - name: Set up Docker Buildx
20 | uses: docker/setup-buildx-action@v3
21 |
22 | - name: Log in to the GitHub Container registry
23 | uses: docker/login-action@v3
24 | with:
25 | registry: ghcr.io
26 | username: ${{ github.actor }}
27 | password: ${{ secrets.GITHUB_TOKEN }}
28 |
29 | - name: Build and push Docker images
30 | uses: docker/build-push-action@v5
31 | with:
32 | context: .
33 | platforms: linux/amd64,linux/arm64
34 | push: true
35 | tags: |
36 | ghcr.io/liamcottle/reticulum-meshchat:latest
37 | ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
38 | labels: |
39 | org.opencontainers.image.title=Reticulum MeshChat
40 | org.opencontainers.image.description=Docker image for Reticulum MeshChat
41 | org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
42 |
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | node_modules
3 |
4 | # build files
5 | /build/
6 | /dist/
7 | /public/
8 | /electron/build/exe/
9 |
10 | # local storage
11 | storage/
12 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build the frontend
2 | FROM node:20-bookworm-slim AS build-frontend
3 |
4 | WORKDIR /src
5 |
6 | # Copy required source files
7 | COPY *.json .
8 | COPY *.js .
9 | COPY src/frontend ./src/frontend
10 |
11 | # Install NodeJS deps, exluding electron
12 | RUN npm install --omit=dev && \
13 | npm run build-frontend
14 |
15 | # Main app build
16 | FROM python:3.11-bookworm
17 |
18 | WORKDIR /app
19 |
20 | # Install Python deps
21 | COPY ./requirements.txt .
22 | RUN pip install -r requirements.txt
23 |
24 | # Copy prebuilt frontend
25 | COPY --from=build-frontend /src/public public
26 |
27 | # Copy other required source files
28 | COPY *.py .
29 | COPY src/__init__.py ./src/__init__.py
30 | COPY src/backend ./src/backend
31 | COPY *.json .
32 |
33 | CMD ["python", "meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Liam Cottle
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/database.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timezone
2 |
3 | from peewee import *
4 | from playhouse.migrate import migrate as migrate_database, SqliteMigrator
5 |
6 | latest_version = 5 # increment each time new database migrations are added
7 | database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py
8 | migrator = SqliteMigrator(database)
9 |
10 |
11 | # migrates the database
12 | def migrate(current_version):
13 |
14 | # migrate to version 2
15 | if current_version < 2:
16 | migrate_database(
17 | migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts),
18 | migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at),
19 | )
20 |
21 | # migrate to version 3
22 | if current_version < 3:
23 | migrate_database(
24 | migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi),
25 | migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr),
26 | migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality),
27 | )
28 |
29 | # migrate to version 4
30 | if current_version < 4:
31 | migrate_database(
32 | migrator.add_column("lxmf_messages", 'method', LxmfMessage.method),
33 | )
34 |
35 | # migrate to version 5
36 | if current_version < 5:
37 | migrate_database(
38 | migrator.add_column("announces", 'rssi', Announce.rssi),
39 | migrator.add_column("announces", 'snr', Announce.snr),
40 | migrator.add_column("announces", 'quality', Announce.quality),
41 | )
42 |
43 | return latest_version
44 |
45 |
46 | class BaseModel(Model):
47 | class Meta:
48 | database = database
49 |
50 |
51 | class Config(BaseModel):
52 |
53 | id = BigAutoField()
54 | key = CharField(unique=True)
55 | value = TextField()
56 | created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
57 | updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
58 |
59 | # define table name
60 | class Meta:
61 | table_name = "config"
62 |
63 |
64 | class Announce(BaseModel):
65 |
66 | id = BigAutoField()
67 | destination_hash = CharField(unique=True) # unique destination hash that was announced
68 | aspect = TextField(index=True) # aspect is not included in announce, but we want to filter saved announces by aspect
69 | identity_hash = CharField(index=True) # identity hash that announced the destination
70 | identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually
71 | app_data = TextField(null=True) # base64 encoded app data bytes
72 | rssi = IntegerField(null=True)
73 | snr = FloatField(null=True)
74 | quality = FloatField(null=True)
75 |
76 | created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
77 | updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
78 |
79 | # define table name
80 | class Meta:
81 | table_name = "announces"
82 |
83 |
84 | class CustomDestinationDisplayName(BaseModel):
85 |
86 | id = BigAutoField()
87 | destination_hash = CharField(unique=True) # unique destination hash
88 | display_name = CharField() # custom display name for the destination hash
89 |
90 | created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
91 | updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
92 |
93 | # define table name
94 | class Meta:
95 | table_name = "custom_destination_display_names"
96 |
97 |
98 | class LxmfMessage(BaseModel):
99 |
100 | id = BigAutoField()
101 | hash = CharField(unique=True) # unique lxmf message hash
102 | source_hash = CharField(index=True)
103 | destination_hash = CharField(index=True)
104 | state = CharField() # state is converted from internal int to a human friendly string
105 | progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
106 | is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
107 | method = CharField(null=True) # what method is being used to send the message, e.g: direct, propagated
108 | delivery_attempts = IntegerField(default=0) # how many times delivery has been attempted for this message
109 | next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again
110 | title = TextField()
111 | content = TextField()
112 | fields = TextField() # json string
113 | timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent)
114 | rssi = IntegerField(null=True)
115 | snr = FloatField(null=True)
116 | quality = FloatField(null=True)
117 | created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
118 | updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
119 |
120 | # define table name
121 | class Meta:
122 | table_name = "lxmf_messages"
123 |
124 |
125 | class LxmfConversationReadState(BaseModel):
126 |
127 | id = BigAutoField()
128 | destination_hash = CharField(unique=True) # unique destination hash
129 | last_read_at = DateTimeField()
130 |
131 | created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
132 | updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
133 |
134 | # define table name
135 | class Meta:
136 | table_name = "lxmf_conversation_read_state"
137 |
138 |
139 | class LxmfUserIcon(BaseModel):
140 |
141 | id = BigAutoField()
142 | destination_hash = CharField(unique=True) # unique destination hash
143 | icon_name = CharField() # material design icon name for the destination hash
144 | foreground_colour = CharField() # hex colour to use for foreground (icon colour)
145 | background_colour = CharField() # hex colour to use for background (background colour)
146 |
147 | created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
148 | updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
149 |
150 | # define table name
151 | class Meta:
152 | table_name = "lxmf_user_icons"
153 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | reticulum-meshchat:
3 | container_name: reticulum-meshchat
4 | image: ghcr.io/liamcottle/reticulum-meshchat:latest
5 | pull_policy: always
6 | restart: unless-stopped
7 | # Make the meshchat web interface accessible from the host on port 8000
8 | ports:
9 | - 0.0.0.0:8000:8000
10 | volumes:
11 | - meshchat-config:/config
12 | # Uncomment if you have a USB device connected, such as an RNode
13 | # devices:
14 | # - /dev/ttyUSB0:/dev/ttyUSB0
15 |
16 | volumes:
17 | meshchat-config:
18 |
--------------------------------------------------------------------------------
/docs/meshchat_on_android_with_termux.md:
--------------------------------------------------------------------------------
1 | # MeshChat on Android
2 |
3 | It's possible to run on Android from source, using [Termux](https://termux.dev/).
4 |
5 | You will need to install a few extra dependencies and make a change to `requirements.txt`.
6 |
7 | ```
8 | pkg upgrade
9 | pkg install git
10 | pkg install nodejs-lts
11 | pkg install python-pip
12 | pkg install rust
13 | pkg install binutils
14 | pkg install build-essential
15 | ```
16 |
17 | You should now be able to follow the [how to use it](../README.md#how-to-use-it) instructions above.
18 |
19 | Before running `pip install -r requirements.txt`, you will need to comment out the `cx_freeze` dependency. It failed to build on my Android tablet, and is not actually required for running from source.
20 |
21 | ```
22 | nano requirements.txt
23 | ```
24 |
25 | Ensure the `cx_freeze` line is updated to `#cx_freeze`
26 |
27 | > Note: Building wheel for cryptography may take a while on Android.
28 |
29 | Once MeshChat is running via Termux, open your favourite Android web browser, and navigate to http://localhost:8000
30 |
31 | > Note: The default `AutoInterface` may not work on your Android device. You will need to configure another interface such as `TCPClientInterface`.
32 |
--------------------------------------------------------------------------------
/docs/meshchat_on_docker.md:
--------------------------------------------------------------------------------
1 | # MeshChat on Docker
2 |
3 | A docker image is automatically built by GitHub actions, and can be downloaded from the GitHub container registry.
4 |
5 | ```
6 | docker pull ghcr.io/liamcottle/reticulum-meshchat:latest
7 | ```
8 |
9 | Additionally, an example [docker-compose.yml](../docker-compose.yml) is available.
10 |
11 | The example automatically generates a new reticulum config file in the `meshchat-config` volume. The MeshChat database is also stored in this volume.
12 |
--------------------------------------------------------------------------------
/docs/meshchat_on_raspberry_pi.md:
--------------------------------------------------------------------------------
1 | # MeshChat on a Raspberry Pi
2 |
3 | A simple guide to install [MeshChat](https://github.com/liamcottle/reticulum-meshchat) on a Raspberry Pi.
4 |
5 | This would allow you to connect an [RNode](https://github.com/markqvist/RNode_Firmware) (such as a Heltec v3) to the Rasbperry Pi via USB, and then access the MeshChat Web UI from another machine on your network.
6 |
7 | My intended use case is to run the Pi + RNode combo from my solar-powered shed, and access the MeshChat Web UI via WiFi.
8 |
9 | > Note: This has been tested on a Raspberry Pi 4 Model B
10 |
11 | ## Install Raspberry Pi OS
12 |
13 | If you haven't already done so, the first step is to install Raspberry Pi OS onto an sdcard, and then boot up the Pi. Once booted, follow the below commands.
14 |
15 | ## Update System
16 |
17 | ```
18 | sudo apt update
19 | sudo apt upgrade
20 | ```
21 |
22 | ## Install System Dependencies
23 |
24 | ```
25 | sudo apt install git
26 | sudo apt install python3-pip
27 | ```
28 |
29 | ## Install NodeJS v22
30 |
31 | ```
32 | curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/nodesource.gpg
33 | NODE_MAJOR=22
34 | echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
35 | sudo apt update
36 | sudo apt install nodejs
37 | ```
38 |
39 | ## Install MeshChat
40 |
41 | ```
42 | git clone https://github.com/liamcottle/reticulum-meshchat
43 | cd reticulum-meshchat
44 | pip install -r requirements.txt --break-system-packages
45 | npm install --omit=dev
46 | npm run build-frontend
47 | ```
48 |
49 | ## Run MeshChat
50 |
51 | ```
52 | python meshchat.py --headless --host 0.0.0.0
53 | ```
54 |
55 | ## Configure Service
56 |
57 | Adding a `systemd` service will allow MeshChat to run in the background when you disconnect from the Pi's terminal.
58 |
59 | ```
60 | sudo nano /etc/systemd/system/reticulum-meshchat.service
61 | ```
62 |
63 | ```
64 | [Unit]
65 | Description=reticulum-meshchat
66 | After=network.target
67 | StartLimitIntervalSec=0
68 |
69 | [Service]
70 | Type=simple
71 | Restart=always
72 | RestartSec=1
73 | User=liamcottle
74 | Group=liamcottle
75 | WorkingDirectory=/home/liamcottle/reticulum-meshchat
76 | ExecStart=/usr/bin/env python /home/liamcottle/reticulum-meshchat/meshchat.py --headless --host 0.0.0.0
77 |
78 | [Install]
79 | WantedBy=multi-user.target
80 | ```
81 |
82 | > Note: Make sure to update the usernames in the service file if needed.
83 |
84 | ```
85 | sudo systemctl enable reticulum-meshchat.service
86 | sudo systemctl start reticulum-meshchat.service
87 | sudo systemctl status reticulum-meshchat.service
88 | ```
89 |
90 | You should now be able to access MeshChat via your Pi's IP address.
91 |
92 | > Note: Don't forget to include the default port `8000`
--------------------------------------------------------------------------------
/donate.md:
--------------------------------------------------------------------------------
1 | # Donate
2 |
3 | Thank you for considering donating, this helps support my work on this project 😁
4 |
5 | ## How can I donate?
6 |
7 | - Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q
8 | - Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D
9 | - Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle)
10 | - Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle)
11 |
--------------------------------------------------------------------------------
/electron/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/electron/assets/images/logo.png
--------------------------------------------------------------------------------
/electron/build/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/electron/build/icon.png
--------------------------------------------------------------------------------
/electron/loading.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Reticulum MeshChat
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |

16 |
17 |
18 |
19 |
20 |
Reticulum MeshChat
21 |
Developed by Liam Cottle
22 |
23 |
24 |
25 |
31 |
32 |
33 |
34 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/electron/main.js:
--------------------------------------------------------------------------------
1 | const { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } = require('electron');
2 | const electronPrompt = require('electron-prompt');
3 | const { spawn } = require('child_process');
4 | const fs = require('fs');
5 | const path = require('node:path');
6 |
7 | // remember main window
8 | var mainWindow = null;
9 |
10 | // remember child process for exe so we can kill it when app exits
11 | var exeChildProcess = null;
12 |
13 | // allow fetching app version via ipc
14 | ipcMain.handle('app-version', () => {
15 | return app.getVersion();
16 | });
17 |
18 | // add support for showing an alert window via ipc
19 | ipcMain.handle('alert', async(event, message) => {
20 | return await dialog.showMessageBox(mainWindow, {
21 | message: message,
22 | });
23 | });
24 |
25 | // add support for showing a prompt window via ipc
26 | ipcMain.handle('prompt', async(event, message) => {
27 | return await electronPrompt({
28 | title: message,
29 | label: '',
30 | value: '',
31 | type: 'input',
32 | inputAttrs: {
33 | type: 'text',
34 | },
35 | });
36 | });
37 |
38 | // allow relaunching app via ipc
39 | ipcMain.handle('relaunch', () => {
40 | app.relaunch();
41 | app.exit();
42 | });
43 |
44 | // allow showing a file path in os file manager
45 | ipcMain.handle('showPathInFolder', (event, path) => {
46 | shell.showItemInFolder(path);
47 | });
48 |
49 | function log(message) {
50 |
51 | // log to stdout of this process
52 | console.log(message);
53 |
54 | // make sure main window exists
55 | if(!mainWindow){
56 | return;
57 | }
58 |
59 | // make sure window is not destroyed
60 | if(mainWindow.isDestroyed()){
61 | return;
62 | }
63 |
64 | // log to web console
65 | mainWindow.webContents.send('log', message);
66 |
67 | }
68 |
69 | function getDefaultStorageDir() {
70 |
71 | // if we are running a windows portable exe, we want to use .reticulum-meshchat in the portable exe dir
72 | // e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum-meshchat"
73 | const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
74 | if(process.platform === "win32" && portableExecutableDir != null){
75 | return path.join(portableExecutableDir, '.reticulum-meshchat');
76 | }
77 |
78 | // otherwise, we will fall back to putting the storage dir in the users home directory
79 | // e.g: ~/.reticulum-meshchat
80 | return path.join(app.getPath('home'), '.reticulum-meshchat');
81 |
82 | }
83 |
84 | function getDefaultReticulumConfigDir() {
85 |
86 | // if we are running a windows portable exe, we want to use .reticulum in the portable exe dir
87 | // e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum"
88 | const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
89 | if(process.platform === "win32" && portableExecutableDir != null){
90 | return path.join(portableExecutableDir, '.reticulum');
91 | }
92 |
93 | // otherwise, we will fall back to using the .reticulum folder in the users home directory
94 | // e.g: ~/.reticulum
95 | return path.join(app.getPath('home'), '.reticulum');
96 |
97 | }
98 |
99 | app.whenReady().then(async () => {
100 |
101 | // get arguments passed to application, and remove the provided application path
102 | const userProvidedArguments = process.argv.slice(1);
103 | const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
104 |
105 | if(!shouldLaunchHeadless){
106 |
107 | // create browser window
108 | mainWindow = new BrowserWindow({
109 | width: 1500,
110 | height: 800,
111 | webPreferences: {
112 | // used to inject logging over ipc
113 | preload: path.join(__dirname, 'preload.js'),
114 | },
115 | });
116 |
117 | // open external links in default web browser instead of electron
118 | mainWindow.webContents.setWindowOpenHandler(({ url }) => {
119 |
120 | var shouldShowInNewElectronWindow = false;
121 |
122 | // we want to open call.html in a new electron window
123 | // but all other target="_blank" links should open in the system web browser
124 | // we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
125 | if(url.startsWith("http://localhost") && url.includes("/call.html")){
126 | shouldShowInNewElectronWindow = true;
127 | }
128 |
129 | // we want to open blob urls in a new electron window
130 | else if(url.startsWith("blob:")) {
131 | shouldShowInNewElectronWindow = true;
132 | }
133 |
134 | // open in new electron window
135 | if(shouldShowInNewElectronWindow){
136 | return {
137 | action: "allow",
138 | };
139 | }
140 |
141 | // fallback to opening any other url in external browser
142 | shell.openExternal(url);
143 | return {
144 | action: "deny",
145 | };
146 |
147 | });
148 |
149 | // navigate to loading page
150 | await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
151 |
152 | // ask mac users for microphone access for audio calls to work
153 | if(process.platform === "darwin"){
154 | await systemPreferences.askForMediaAccess('microphone');
155 | }
156 |
157 | }
158 |
159 | // find path to python/cxfreeze reticulum meshchat executable
160 | const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
161 | var exe = path.join(__dirname, `build/exe/${exeName}`);
162 |
163 | // if dist exe doesn't exist, check local build
164 | if(!fs.existsSync(exe)){
165 | exe = path.join(__dirname, '..', `build/exe/${exeName}`);
166 | }
167 |
168 | try {
169 |
170 | // arguments we always want to pass in
171 | const requiredArguments = [
172 | '--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron
173 | '--port', '9337', // FIXME: let system pick a random unused port?
174 | // '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog
175 | ];
176 |
177 | // if user didn't provide reticulum config dir, we should provide it
178 | if(!userProvidedArguments.includes("--reticulum-config-dir")){
179 | requiredArguments.push("--reticulum-config-dir", getDefaultReticulumConfigDir());
180 | }
181 |
182 | // if user didn't provide storage dir, we should provide it
183 | if(!userProvidedArguments.includes("--storage-dir")){
184 | requiredArguments.push("--storage-dir", getDefaultStorageDir());
185 | }
186 |
187 | // spawn executable
188 | exeChildProcess = await spawn(exe, [
189 | ...requiredArguments, // always provide required arguments
190 | ...userProvidedArguments, // also include any user provided arguments
191 | ]);
192 |
193 | // log stdout
194 | var stdoutLines = [];
195 | exeChildProcess.stdout.setEncoding('utf8');
196 | exeChildProcess.stdout.on('data', function(data) {
197 |
198 | // log
199 | log(data.toString());
200 |
201 | // keep track of last 10 stdout lines
202 | stdoutLines.push(data.toString());
203 | if(stdoutLines.length > 10){
204 | stdoutLines.shift();
205 | }
206 |
207 | });
208 |
209 | // log stderr
210 | var stderrLines = [];
211 | exeChildProcess.stderr.setEncoding('utf8');
212 | exeChildProcess.stderr.on('data', function(data) {
213 |
214 | // log
215 | log(data.toString());
216 |
217 | // keep track of last 10 stderr lines
218 | stderrLines.push(data.toString());
219 | if(stderrLines.length > 10){
220 | stderrLines.shift();
221 | }
222 |
223 | });
224 |
225 | // log errors
226 | exeChildProcess.on('error', function(error) {
227 | log(error);
228 | });
229 |
230 | // quit electron app if exe dies
231 | exeChildProcess.on('exit', async function(code) {
232 |
233 | // if no exit code provided, we wanted exit to happen, so do nothing
234 | if(code == null){
235 | return;
236 | }
237 |
238 | // tell user that Visual C++ redistributable needs to be installed on Windows
239 | if(code === 3221225781 && process.platform === "win32"){
240 | await dialog.showMessageBox(mainWindow, {
241 | message: "Microsoft Visual C++ redistributable must be installed to run this application.",
242 | });
243 | app.quit();
244 | return;
245 | }
246 |
247 | // show crash log
248 | const stdout = stdoutLines.join("");
249 | const stderr = stderrLines.join("");
250 | await dialog.showMessageBox(mainWindow, {
251 | message: [
252 | "MeshChat Crashed!",
253 | "",
254 | `Exit Code: ${code}`,
255 | "",
256 | `----- stdout -----`,
257 | "",
258 | stdout,
259 | `----- stderr -----`,
260 | "",
261 | stderr,
262 | ].join("\n"),
263 | });
264 |
265 | // quit after dismissing error dialog
266 | app.quit();
267 |
268 | });
269 |
270 | } catch(e) {
271 | log(e);
272 | }
273 |
274 | });
275 |
276 | function quit() {
277 |
278 | // kill python process
279 | if(exeChildProcess){
280 | exeChildProcess.kill("SIGKILL");
281 | }
282 |
283 | // quit electron app
284 | app.quit();
285 |
286 | }
287 |
288 | // quit electron if all windows are closed
289 | app.on('window-all-closed', () => {
290 | quit();
291 | });
292 |
293 | // make sure child process is killed if app is quiting
294 | app.on('quit', () => {
295 | quit();
296 | });
297 |
--------------------------------------------------------------------------------
/electron/preload.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer, contextBridge } = require('electron');
2 |
3 | // forward logs received from exe to web console
4 | ipcRenderer.on('log', (event, message) => console.log(message));
5 |
6 | contextBridge.exposeInMainWorld('electron', {
7 |
8 | // allow fetching app version in electron browser window
9 | appVersion: async function() {
10 | return await ipcRenderer.invoke('app-version');
11 | },
12 |
13 | // show an alert dialog in electron browser window, this fixes a bug where alert breaks input fields on windows
14 | alert: async function(message) {
15 | return await ipcRenderer.invoke('alert', message);
16 | },
17 |
18 | // add support for using "prompt" in electron browser window
19 | prompt: async function(message) {
20 | return await ipcRenderer.invoke('prompt', message);
21 | },
22 |
23 | // allow relaunching app in electron browser window
24 | relaunch: async function() {
25 | return await ipcRenderer.invoke('relaunch');
26 | },
27 |
28 | // allow showing a file path in os file manager
29 | showPathInFolder: async function(path) {
30 | return await ipcRenderer.invoke('showPathInFolder', path);
31 | },
32 |
33 | });
34 |
--------------------------------------------------------------------------------
/logo/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/logo/icon.ico
--------------------------------------------------------------------------------
/logo/logo-chat-bubble.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/logo/logo-chat-bubble.png
--------------------------------------------------------------------------------
/logo/logo.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/logo/logo.afdesign
--------------------------------------------------------------------------------
/logo/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/logo/logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reticulum-meshchat",
3 | "version": "1.20.0",
4 | "description": "",
5 | "main": "electron/main.js",
6 | "scripts": {
7 | "watch": "npm run build-frontend -- --watch",
8 | "build-frontend": "vite build",
9 | "build-backend": "python setup.py build",
10 | "build": "npm run build-frontend && npm run build-backend",
11 | "electron-postinstall": "electron-builder install-app-deps",
12 | "electron": "npm run electron-postinstall && npm run build && electron .",
13 | "dist": "npm run electron-postinstall && npm run build && electron-builder --publish=never"
14 | },
15 | "license": "MIT",
16 | "engines": {
17 | "node": ">=18"
18 | },
19 | "devDependencies": {
20 | "electron": "^30.0.8",
21 | "electron-builder": "^24.6.3"
22 | },
23 | "build": {
24 | "appId": "com.liamcottle.reticulummeshchat",
25 | "productName": "Reticulum MeshChat",
26 | "asar": false,
27 | "files": [
28 | "electron/**/*"
29 | ],
30 | "directories": {
31 | "buildResources": "electron/build"
32 | },
33 | "mac": {
34 | "target": "dmg",
35 | "identity": null,
36 | "artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
37 | "extendInfo": {
38 | "NSMicrophoneUsageDescription": "Microphone access is only needed for Audio Calls",
39 | "com.apple.security.device.audio-input": true
40 | },
41 | "extraFiles": [
42 | {
43 | "from": "build/exe",
44 | "to": "Resources/app/electron/build/exe",
45 | "filter": [
46 | "**/*"
47 | ]
48 | }
49 | ]
50 | },
51 | "win": {
52 | "artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
53 | "target": [
54 | {
55 | "target": "portable"
56 | },
57 | {
58 | "target": "nsis"
59 | }
60 | ],
61 | "extraFiles": [
62 | {
63 | "from": "build/exe",
64 | "to": "Resources/app/electron/build/exe",
65 | "filter": [
66 | "**/*"
67 | ]
68 | }
69 | ]
70 | },
71 | "linux": {
72 | "artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
73 | "target": "AppImage",
74 | "extraFiles": [
75 | {
76 | "from": "build/exe",
77 | "to": "resources/app/electron/build/exe",
78 | "filter": [
79 | "**/*"
80 | ]
81 | }
82 | ]
83 | },
84 | "dmg": {
85 | "writeUpdateInfo": false
86 | },
87 | "portable": {
88 | "artifactName": "ReticulumMeshChat-v${version}-${os}-portable.${ext}"
89 | },
90 | "nsis": {
91 | "artifactName": "ReticulumMeshChat-v${version}-${os}-installer.${ext}",
92 | "oneClick": false,
93 | "allowToChangeInstallationDirectory": true
94 | }
95 | },
96 | "dependencies": {
97 | "@mdi/js": "^7.4.47",
98 | "@tailwindcss/forms": "^0.5.9",
99 | "@vitejs/plugin-vue": "^5.2.1",
100 | "autoprefixer": "^10.4.20",
101 | "axios": "^1.7.9",
102 | "click-outside-vue3": "^4.0.1",
103 | "compressorjs": "^1.2.1",
104 | "electron-prompt": "^1.7.0",
105 | "mitt": "^3.0.1",
106 | "moment": "^2.30.1",
107 | "postcss": "^8.4.49",
108 | "protobufjs": "^7.4.0",
109 | "tailwindcss": "^3.4.17",
110 | "vis-data": "^7.1.9",
111 | "vis-network": "^9.1.9",
112 | "vite": "^6.0.5",
113 | "vite-plugin-vuetify": "^2.0.4",
114 | "vue-router": "^4.5.0",
115 | "vuetify": "^3.7.6"
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp>=3.9.5
2 | cx_freeze>=7.0.0
3 | lxmf>=0.6.2
4 | peewee>=3.17.3
5 | rns>=0.9.1
6 | websockets>=14.2
7 |
--------------------------------------------------------------------------------
/screenshots/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/screenshots/screenshot.png
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from cx_Freeze import setup, Executable
2 |
3 | setup(
4 | name='ReticulumMeshChat',
5 | version='1.0.0',
6 | description='A simple mesh network communications app powered by the Reticulum Network Stack',
7 | executables=[
8 | Executable(
9 | script='meshchat.py', # this script to run
10 | base=None, # we are running a console application, not a gui
11 | target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe
12 | shortcut_name='ReticulumMeshChat', # name shown in shortcut
13 | shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu
14 | icon='logo/icon.ico', # set the icon for the exe
15 | copyright='Copyright (c) 2024 Liam Cottle',
16 | ),
17 | ],
18 | options={
19 | 'build_exe': {
20 | # libs that are required
21 | 'packages': [
22 | # required for dynamic import fix
23 | # https://github.com/marcelotduarte/cx_Freeze/discussions/2039
24 | # https://github.com/marcelotduarte/cx_Freeze/issues/2041
25 | 'RNS',
26 | ],
27 | # files that are required
28 | 'include_files': [
29 | 'package.json', # used to determine app version from python
30 | 'public/', # static files served by web server
31 | ],
32 | # slim down the build by excluding these unused libs
33 | 'excludes': [
34 | 'PIL', # saves ~200MB
35 | ],
36 | # this has the same effect as the -O command line option when executing CPython directly.
37 | # it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
38 | # https://stackoverflow.com/a/57948104
39 | "optimize": 2,
40 | # change where exe is built to
41 | 'build_exe': 'build/exe',
42 | },
43 | },
44 | )
45 |
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | # NOTE: this class is required to be able to use print/log commands and have them flush to stdout and stderr immediately
4 | # without wrapper stdout and stderr, when using `childProcess.stdout.on('data', ...)` in NodeJS script, we never get
5 | # any events fired until the process exits. However, force flushing the streams does fire the callbacks in NodeJS.
6 |
7 |
8 | # this class forces stream writes to be flushed immediately
9 | class ImmediateFlushingStreamWrapper:
10 |
11 | def __init__(self, stream):
12 | self.stream = stream
13 |
14 | # force write to flush immediately
15 | def write(self, data):
16 | self.stream.write(data)
17 | self.stream.flush()
18 |
19 | # force writelines to flush immediately
20 | def writelines(self, lines):
21 | self.stream.writelines(lines)
22 | self.stream.flush()
23 |
24 | def __getattr__(self, attr):
25 | return getattr(self.stream, attr)
26 |
27 |
28 | # wrap stdout and stderr with our custom wrapper
29 | sys.stdout = ImmediateFlushingStreamWrapper(sys.stdout)
30 | sys.stderr = ImmediateFlushingStreamWrapper(sys.stderr)
31 |
--------------------------------------------------------------------------------
/src/backend/announce_handler.py:
--------------------------------------------------------------------------------
1 | # an announce handler that forwards announces to a provided callback for the provided aspect filter
2 | # this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
3 | class AnnounceHandler:
4 |
5 | def __init__(self, aspect_filter: str, received_announce_callback):
6 | self.aspect_filter = aspect_filter
7 | self.received_announce_callback = received_announce_callback
8 |
9 | # we will just pass the received announce back to the provided callback
10 | def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash):
11 | try:
12 | # handle received announce
13 | self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
14 | except:
15 | # ignore failure to handle received announce
16 | pass
17 |
--------------------------------------------------------------------------------
/src/backend/async_utils.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 |
4 | class AsyncUtils:
5 |
6 | # this method allows running the provided async coroutine from within a sync function
7 | # it will run the async function on the existing event loop if available, otherwise it will start a new event loop
8 | @staticmethod
9 | def run_async(coroutine):
10 |
11 | # attempt to get existing event loop
12 | existing_event_loop = None
13 | try:
14 | existing_event_loop = asyncio.get_running_loop()
15 | except RuntimeError:
16 | # 'RuntimeError: no running event loop'
17 | pass
18 |
19 | # if there is an existing event loop running, submit the coroutine to that loop
20 | if existing_event_loop and existing_event_loop.is_running():
21 | existing_event_loop.create_task(coroutine)
22 | return
23 |
24 | # otherwise start a new event loop to run the coroutine
25 | asyncio.run(coroutine)
26 |
--------------------------------------------------------------------------------
/src/backend/audio_call_manager.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import time
3 | from typing import List
4 |
5 | import RNS
6 |
7 | # todo optionally identity self over link
8 | # todo allowlist/denylist for incoming calls
9 |
10 |
11 | class CallFailedException(Exception):
12 | pass
13 |
14 |
15 | class AudioCall:
16 |
17 | def __init__(self, link: RNS.Link, is_outbound: bool):
18 | self.link = link
19 | self.is_outbound = is_outbound
20 | self.link.set_link_closed_callback(self.on_link_closed)
21 | self.link.set_packet_callback(self.on_packet)
22 | self.audio_packet_listeners = []
23 | self.hangup_listeners = []
24 |
25 | def register_audio_packet_listener(self, callback):
26 | self.audio_packet_listeners.append(callback)
27 |
28 | def unregister_audio_packet_listener(self, callback):
29 | self.audio_packet_listeners.remove(callback)
30 |
31 | def register_hangup_listener(self, callback):
32 | self.hangup_listeners.append(callback)
33 |
34 | # handle link being closed
35 | def on_link_closed(self, link):
36 | print("[AudioCall] on_link_closed")
37 |
38 | # call all hangup listeners
39 | for hangup_listener in self.hangup_listeners:
40 | hangup_listener()
41 |
42 | # handle packet received over link
43 | def on_packet(self, message, packet):
44 |
45 | # send audio received from call initiator to all audio packet listeners
46 | for audio_packet_listener in self.audio_packet_listeners:
47 | audio_packet_listener(message)
48 |
49 | # send an audio packet over the link
50 | def send_audio_packet(self, data):
51 |
52 | # do nothing if link is not active
53 | if self.is_active() is False:
54 | return
55 |
56 | # drop audio packet if it is too big to send
57 | if len(data) > RNS.Link.MDU:
58 | print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
59 | return
60 |
61 | # send codec2 audio received from call receiver to call initiator over reticulum link
62 | RNS.Packet(self.link, data).send()
63 |
64 | # gets the identity of the other person, or returns None if they did not identify
65 | def get_remote_identity(self):
66 | return self.link.get_remote_identity()
67 |
68 | # determine if this call is still active
69 | def is_active(self):
70 | return self.link.status == RNS.Link.ACTIVE
71 |
72 | # handle hanging up the call
73 | def hangup(self):
74 | print("[AudioCall] hangup")
75 | self.link.teardown()
76 | pass
77 |
78 |
79 | class AudioCallManager:
80 |
81 | def __init__(self, identity: RNS.Identity):
82 |
83 | self.identity = identity
84 | self.on_incoming_call_callback = None
85 | self.on_outgoing_call_callback = None
86 | self.audio_call_receiver = AudioCallReceiver(manager=self)
87 |
88 | # remember audio calls
89 | self.audio_calls: List[AudioCall] = []
90 |
91 | # announces the audio call destination
92 | def announce(self, app_data=None):
93 | self.audio_call_receiver.destination.announce(app_data)
94 | print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash))
95 |
96 | # set the callback for incoming calls
97 | def register_incoming_call_callback(self, callback):
98 | self.on_incoming_call_callback = callback
99 |
100 | # set the callback for outgoing calls
101 | def register_outgoing_call_callback(self, callback):
102 | self.on_outgoing_call_callback = callback
103 |
104 | # handle incoming calls from audio call receiver
105 | def handle_incoming_call(self, audio_call: AudioCall):
106 |
107 | # remember it
108 | self.audio_calls.append(audio_call)
109 |
110 | # fire callback
111 | if self.on_incoming_call_callback is not None:
112 | self.on_incoming_call_callback(audio_call)
113 |
114 | # handle outgoing calls
115 | def handle_outgoing_call(self, audio_call: AudioCall):
116 |
117 | # remember it
118 | self.audio_calls.append(audio_call)
119 |
120 | # fire callback
121 | if self.on_outgoing_call_callback is not None:
122 | self.on_outgoing_call_callback(audio_call)
123 |
124 | # find an existing audio call from the provided link hash
125 | def find_audio_call_by_link_hash(self, link_hash: bytes):
126 | for audio_call in self.audio_calls:
127 | if audio_call.link.hash == link_hash:
128 | return audio_call
129 | return None
130 |
131 | # delete an existing audio call from the provided link hash
132 | def delete_audio_call_by_link_hash(self, link_hash: bytes):
133 | audio_call = self.find_audio_call_by_link_hash(link_hash)
134 | if audio_call is not None:
135 | self.delete_audio_call(audio_call)
136 |
137 | # delete an existing audio call
138 | def delete_audio_call(self, audio_call: AudioCall):
139 | self.audio_calls.remove(audio_call)
140 |
141 | # hangup all calls
142 | def hangup_all(self):
143 | for audio_call in self.audio_calls:
144 | audio_call.hangup()
145 | return None
146 |
147 | # attempts to initiate a call to the provided destination and returns the link hash on success
148 | async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall:
149 |
150 | # determine when to timeout
151 | timeout_after_seconds = time.time() + timeout_seconds
152 |
153 | # check if we have a path to the destination
154 | if not RNS.Transport.has_path(destination_hash):
155 |
156 | # we don't have a path, so we need to request it
157 | RNS.Transport.request_path(destination_hash)
158 |
159 | # wait until we have a path, or give up after the configured timeout
160 | while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds:
161 | await asyncio.sleep(0.1)
162 |
163 | # if we still don't have a path, we can't establish a link, so bail out
164 | if not RNS.Transport.has_path(destination_hash):
165 | raise CallFailedException("Could not find path to destination.")
166 |
167 | # create outbound destination to initiate audio calls
168 | server_identity = RNS.Identity.recall(destination_hash)
169 | server_destination = RNS.Destination(
170 | server_identity,
171 | RNS.Destination.OUT,
172 | RNS.Destination.SINGLE,
173 | "call",
174 | "audio"
175 | )
176 |
177 | # create link
178 | link = RNS.Link(server_destination)
179 |
180 | # wait until we have established a link, or give up after the configured timeout
181 | while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
182 | await asyncio.sleep(0.1)
183 |
184 | # if we still haven't established a link, bail out
185 | if link.status is not RNS.Link.ACTIVE:
186 | raise CallFailedException("Could not establish link to destination.")
187 |
188 | # link is now established, create audio call
189 | audio_call = AudioCall(link, is_outbound=True)
190 |
191 | # handle new outgoing call
192 | self.handle_outgoing_call(audio_call)
193 |
194 | # todo: this can be optional, it's only being sent by default for ui, can be removed
195 | link.identify(self.identity)
196 |
197 | return audio_call
198 |
199 |
200 | class AudioCallReceiver:
201 |
202 | def __init__(self, manager: AudioCallManager):
203 |
204 | self.manager = manager
205 |
206 | # create destination for receiving audio calls
207 | self.destination = RNS.Destination(
208 | self.manager.identity,
209 | RNS.Destination.IN,
210 | RNS.Destination.SINGLE,
211 | "call",
212 | "audio",
213 | )
214 |
215 | # register link state callbacks
216 | self.destination.set_link_established_callback(self.client_connected)
217 |
218 | # find an existing audio call from the provided link
219 | def find_audio_call_by_link_hash(self, link_hash: bytes):
220 | for audio_call in self.manager.audio_calls:
221 | if audio_call.link.hash == link_hash:
222 | return audio_call
223 | return None
224 |
225 | # client connected to us, set up an audio call instance
226 | def client_connected(self, link: RNS.Link):
227 |
228 | # todo: this can be optional, it's only being sent by default for ui, can be removed
229 | link.identify(self.manager.identity)
230 |
231 | # create audio call
232 | audio_call = AudioCall(link, is_outbound=False)
233 |
234 | # pass to manager
235 | self.manager.handle_incoming_call(audio_call)
236 |
--------------------------------------------------------------------------------
/src/backend/colour_utils.py:
--------------------------------------------------------------------------------
1 | class ColourUtils:
2 |
3 | @staticmethod
4 | def hex_colour_to_byte_array(hex_colour):
5 |
6 | # remove leading "#"
7 | hex_colour = hex_colour.lstrip('#')
8 |
9 | # convert the remaining hex string to bytes
10 | return bytes.fromhex(hex_colour)
11 |
--------------------------------------------------------------------------------
/src/backend/interface_config_parser.py:
--------------------------------------------------------------------------------
1 | import RNS.vendor.configobj
2 |
3 |
4 | class InterfaceConfigParser:
5 |
6 | @staticmethod
7 | def parse(text):
8 |
9 | # get lines from provided text
10 | lines = text.splitlines()
11 |
12 | # ensure [interfaces] section exists
13 | if "[interfaces]" not in lines:
14 | lines.insert(0, "[interfaces]")
15 |
16 | # parse lines as rns config object
17 | config = RNS.vendor.configobj.ConfigObj(lines)
18 |
19 | # get interfaces from config
20 | config_interfaces = config.get("interfaces")
21 |
22 | # process interfaces
23 | interfaces = []
24 | for interface_name in config_interfaces:
25 |
26 | # ensure interface has a name
27 | interface_config = config_interfaces[interface_name]
28 | interface_config["name"] = interface_name
29 | interfaces.append(interface_config)
30 |
31 | return interfaces
32 |
--------------------------------------------------------------------------------
/src/backend/interface_editor.py:
--------------------------------------------------------------------------------
1 | class InterfaceEditor:
2 |
3 | @staticmethod
4 | def update_value(interface_details: dict, data: dict, key: str):
5 |
6 | # update value if provided and not empty
7 | value = data.get(key)
8 | if value is not None and value != "":
9 | interface_details[key] = value
10 | return
11 |
12 | # otherwise remove existing value
13 | if key in interface_details:
14 | del interface_details[key]
15 |
--------------------------------------------------------------------------------
/src/backend/interfaces/WebsocketClientInterface.py:
--------------------------------------------------------------------------------
1 | import threading
2 | import time
3 |
4 | import RNS
5 | from RNS.Interfaces.Interface import Interface
6 | from websockets.sync.client import connect
7 | from websockets.sync.connection import Connection
8 |
9 |
10 | class WebsocketClientInterface(Interface):
11 |
12 | # TODO: required?
13 | DEFAULT_IFAC_SIZE = 16
14 |
15 | RECONNECT_DELAY_SECONDS = 5
16 |
17 | def __str__(self):
18 | return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
19 |
20 | def __init__(self, owner, configuration, websocket: Connection = None):
21 |
22 | super().__init__()
23 |
24 | self.owner = owner
25 | self.parent_interface = None
26 |
27 | self.IN = True
28 | self.OUT = False
29 | self.HW_MTU = 262144 # 256KiB
30 | self.bitrate = 1_000_000_000 # 1Gbps
31 | self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
32 |
33 | # parse config
34 | ifconf = Interface.get_config_obj(configuration)
35 | self.name = ifconf.get("name")
36 | self.target_url = ifconf.get("target_url", None)
37 |
38 | # ensure target url is provided
39 | if self.target_url is None:
40 | raise SystemError(f"target_url is required for interface '{self.name}'")
41 |
42 | # connect to websocket server if an existing connection was not provided
43 | self.websocket = websocket
44 | if self.websocket is None:
45 | thread = threading.Thread(target=self.connect)
46 | thread.daemon = True
47 | thread.start()
48 |
49 | # called when a full packet has been received over the websocket
50 | def process_incoming(self, data):
51 |
52 | # do nothing if offline or detached
53 | if not self.online or self.detached:
54 | return
55 |
56 | # update received bytes counter
57 | self.rxb += len(data)
58 |
59 | # update received bytes counter for parent interface
60 | if self.parent_interface is not None:
61 | self.parent_interface.rxb += len(data)
62 |
63 | # send received data to transport instance
64 | self.owner.inbound(data, self)
65 |
66 | # the running reticulum transport instance will call this method whenever the interface must transmit a packet
67 | def process_outgoing(self, data):
68 |
69 | # do nothing if offline or detached
70 | if not self.online or self.detached:
71 | return
72 |
73 | # send to websocket server
74 | try:
75 | self.websocket.send(data)
76 | except Exception as e:
77 | RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR)
78 | RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
79 | return
80 |
81 | # update sent bytes counter
82 | self.txb += len(data)
83 |
84 | # update received bytes counter for parent interface
85 | if self.parent_interface is not None:
86 | self.parent_interface.txb += len(data)
87 |
88 | # connect to the configured websocket server
89 | def connect(self):
90 |
91 | # do nothing if interface is detached
92 | if self.detached:
93 | return
94 |
95 | # connect to websocket server
96 | try:
97 | RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
98 | self.websocket = connect(f"{self.target_url}", max_size=None, compression=None)
99 | RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
100 | self.read_loop()
101 | except Exception as e:
102 | RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
103 |
104 | # auto reconnect after delay
105 | RNS.log(f"Websocket disconnected for {str(self)}...", RNS.LOG_DEBUG)
106 | time.sleep(self.RECONNECT_DELAY_SECONDS)
107 | self.connect()
108 |
109 | def read_loop(self):
110 |
111 | self.online = True
112 |
113 | try:
114 | for message in self.websocket:
115 | self.process_incoming(message)
116 | except Exception as e:
117 | RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR)
118 |
119 | self.online = False
120 |
121 | def detach(self):
122 |
123 | # mark as offline
124 | self.online = False
125 |
126 | # close websocket
127 | if self.websocket is not None:
128 | self.websocket.close()
129 |
130 | # mark as detached
131 | self.detached = True
132 |
133 | # set interface class RNS should use when importing this external interface
134 | interface_class = WebsocketClientInterface
135 |
--------------------------------------------------------------------------------
/src/backend/interfaces/WebsocketServerInterface.py:
--------------------------------------------------------------------------------
1 | import threading
2 | import time
3 |
4 | import RNS
5 | from RNS.Interfaces.Interface import Interface
6 | from websockets.sync.server import Server
7 | from websockets.sync.server import serve
8 | from websockets.sync.server import ServerConnection
9 |
10 | from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
11 |
12 |
13 | class WebsocketServerInterface(Interface):
14 |
15 | # TODO: required?
16 | DEFAULT_IFAC_SIZE = 16
17 |
18 | RESTART_DELAY_SECONDS = 5
19 |
20 | def __str__(self):
21 | return f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
22 |
23 | def __init__(self, owner, configuration):
24 |
25 | super().__init__()
26 |
27 | self.owner = owner
28 |
29 | self.IN = True
30 | self.OUT = False
31 | self.HW_MTU = 262144 # 256KiB
32 | self.bitrate = 1_000_000_000 # 1Gbps
33 | self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
34 |
35 | self.server: Server | None = None
36 | self.spawned_interfaces: [WebsocketClientInterface] = []
37 |
38 | # parse config
39 | ifconf = Interface.get_config_obj(configuration)
40 | self.name = ifconf.get("name")
41 | self.listen_ip = ifconf.get("listen_ip", None)
42 | self.listen_port = ifconf.get("listen_port", None)
43 |
44 | # ensure listen ip is provided
45 | if self.listen_ip is None:
46 | raise SystemError(f"listen_ip is required for interface '{self.name}'")
47 |
48 | # ensure listen port is provided
49 | if self.listen_port is None:
50 | raise SystemError(f"listen_port is required for interface '{self.name}'")
51 |
52 | # convert listen port to int
53 | self.listen_port = int(self.listen_port)
54 |
55 | # run websocket server
56 | thread = threading.Thread(target=self.serve)
57 | thread.daemon = True
58 | thread.start()
59 |
60 | @property
61 | def clients(self):
62 | return len(self.spawned_interfaces)
63 |
64 | # todo docs
65 | def received_announce(self, from_spawned=False):
66 | if from_spawned:
67 | self.ia_freq_deque.append(time.time())
68 |
69 | # todo docs
70 | def sent_announce(self, from_spawned=False):
71 | if from_spawned:
72 | self.oa_freq_deque.append(time.time())
73 |
74 | # do nothing as the spawned child interface will take care of rx/tx
75 | def process_incoming(self, data):
76 | pass
77 |
78 | # do nothing as the spawned child interface will take care of rx/tx
79 | def process_outgoing(self, data):
80 | pass
81 |
82 | def serve(self):
83 |
84 | # handle new websocket client connections
85 | def on_websocket_client_connected(websocket: ServerConnection):
86 |
87 | # create new child interface
88 | RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
89 | spawned_interface = WebsocketClientInterface(self.owner, {
90 | "name": f"Client on {self.name}",
91 | "target_host": websocket.remote_address[0],
92 | "target_port": str(websocket.remote_address[1]),
93 | }, websocket=websocket)
94 |
95 | # configure child interface
96 | spawned_interface.IN = self.IN
97 | spawned_interface.OUT = self.OUT
98 | spawned_interface.HW_MTU = self.HW_MTU
99 | spawned_interface.bitrate = self.bitrate
100 | spawned_interface.mode = self.mode
101 | spawned_interface.parent_interface = self
102 | spawned_interface.online = True
103 |
104 | # todo implement?
105 | spawned_interface.announce_rate_target = None
106 | spawned_interface.announce_rate_grace = None
107 | spawned_interface.announce_rate_penalty = None
108 |
109 | # todo ifac?
110 | # todo announce rates?
111 |
112 | # activate child interface
113 | RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE)
114 | RNS.Transport.interfaces.append(spawned_interface)
115 |
116 | # associate child interface with this interface
117 | while spawned_interface in self.spawned_interfaces:
118 | self.spawned_interfaces.remove(spawned_interface)
119 | self.spawned_interfaces.append(spawned_interface)
120 |
121 | # run read loop
122 | spawned_interface.read_loop()
123 |
124 | # client must have disconnected as the read loop finished, so forget the spawned interface
125 | self.spawned_interfaces.remove(spawned_interface)
126 |
127 | # run websocket server
128 | try:
129 | RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
130 | with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server:
131 | self.online = True
132 | self.server = server
133 | server.serve_forever()
134 | except Exception as e:
135 | RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
136 |
137 | # websocket server is no longer running, let's restart it
138 | self.online = False
139 | RNS.log(f"Websocket server stopped for {str(self)}...", RNS.LOG_DEBUG)
140 | time.sleep(self.RESTART_DELAY_SECONDS)
141 | self.serve()
142 |
143 | def detach(self):
144 |
145 | # mark as offline
146 | self.online = False
147 |
148 | # stop websocket server
149 | if self.server is not None:
150 | self.server.shutdown()
151 |
152 | # mark as detached
153 | self.detached = True
154 |
155 | # set interface class RNS should use when importing this external interface
156 | interface_class = WebsocketServerInterface
157 |
--------------------------------------------------------------------------------
/src/backend/lxmf_message_fields.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 |
4 | # helper class for passing around an lxmf audio field
5 | class LxmfAudioField:
6 |
7 | def __init__(self, audio_mode: int, audio_bytes: bytes):
8 | self.audio_mode = audio_mode
9 | self.audio_bytes = audio_bytes
10 |
11 |
12 | # helper class for passing around an lxmf image field
13 | class LxmfImageField:
14 |
15 | def __init__(self, image_type: str, image_bytes: bytes):
16 | self.image_type = image_type
17 | self.image_bytes = image_bytes
18 |
19 |
20 | # helper class for passing around an lxmf file attachment
21 | class LxmfFileAttachment:
22 |
23 | def __init__(self, file_name: str, file_bytes: bytes):
24 | self.file_name = file_name
25 | self.file_bytes = file_bytes
26 |
27 |
28 | # helper class for passing around an lxmf file attachments field
29 | class LxmfFileAttachmentsField:
30 |
31 | def __init__(self, file_attachments: List[LxmfFileAttachment]):
32 | self.file_attachments = file_attachments
33 |
34 |
--------------------------------------------------------------------------------
/src/backend/sideband_commands.py:
--------------------------------------------------------------------------------
1 | # https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
2 | class SidebandCommands:
3 | TELEMETRY_REQUEST = 0x01
4 |
--------------------------------------------------------------------------------
/src/frontend/call.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Phone | Reticulum MeshChat
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/frontend/call.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import {createApp} from 'vue';
3 | import "./style.css";
4 | import CallPage from "./components/call/CallPage.vue";
5 |
6 | // provide axios globally
7 | window.axios = axios;
8 |
9 | createApp(CallPage)
10 | .mount('#app');
11 |
--------------------------------------------------------------------------------
/src/frontend/components/ColourPickerDropdown.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
92 |
--------------------------------------------------------------------------------
/src/frontend/components/DropDownMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
93 |
--------------------------------------------------------------------------------
/src/frontend/components/DropDownMenuItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/src/frontend/components/IconButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/src/frontend/components/LxmfUserIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
24 |
--------------------------------------------------------------------------------
/src/frontend/components/MaterialDesignIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
35 |
--------------------------------------------------------------------------------
/src/frontend/components/SidebarLink.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
44 |
--------------------------------------------------------------------------------
/src/frontend/components/about/AboutPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
App Info
8 |
9 |
10 |
11 |
12 |
13 |
Versions
14 |
15 | MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }}
16 |
17 |
18 |
26 |
27 |
28 |
29 |
30 |
31 |
Reticulum Config Path
32 |
{{ appInfo.reticulum_config_path }}
33 |
34 |
35 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
Database Path
47 |
{{ appInfo.database_path }}
48 |
49 |
50 |
55 |
56 |
57 |
58 |
59 |
60 |
Database File Size
61 |
{{ formatBytes(appInfo.database_file_size) }}
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
Reticulum Status
70 |
71 |
72 |
73 |
74 |
Instance Mode
75 |
76 | Connected to Shared Instance
77 | Running as Standalone Instance
78 |
79 |
80 |
81 |
82 |
83 |
Transport Mode
84 |
85 | Transport Enabled
86 | Transport Disabled
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
My Addresses
96 |
97 |
98 |
Identity Hash
99 |
{{ config.identity_hash }}
100 |
101 |
102 |
LXMF Address
103 |
{{ config.lxmf_address_hash }}
104 |
105 |
106 |
LXMF Propagation Node Address
107 |
{{ config.lxmf_local_propagation_node_address_hash }}
108 |
109 |
110 |
Audio Call Address
111 |
{{ config.audio_call_address_hash }}
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
177 |
--------------------------------------------------------------------------------
/src/frontend/components/forms/FormLabel.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/src/frontend/components/forms/FormSubLabel.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/frontend/components/interfaces/ExpandingSection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/frontend/components/messages/AddAudioButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 |
21 |
22 |
23 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
83 |
--------------------------------------------------------------------------------
/src/frontend/components/messages/AddImageButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
136 |
--------------------------------------------------------------------------------
/src/frontend/components/messages/ConversationDropDownMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
18 | Start a Call
19 |
20 |
21 |
22 |
23 |
24 |
27 | Ping Destination
28 |
29 |
30 |
31 |
32 |
35 | Set Custom Display Name
36 |
37 |
38 |
39 |
40 |
41 |
44 | Delete Message History
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
143 |
--------------------------------------------------------------------------------
/src/frontend/components/messages/MessagesPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
13 |
20 |
21 |
22 |
23 |
24 |
25 |
260 |
--------------------------------------------------------------------------------
/src/frontend/components/messages/SendMessageButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 |
15 |
20 |
21 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
71 |
--------------------------------------------------------------------------------
/src/frontend/components/network-visualiser/NetworkVisualiserPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
15 |
--------------------------------------------------------------------------------
/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
14 |
15 |
16 |
26 |
31 |
32 |
{{ node.display_name }}
33 |
{{ formatTimeAgo(node.updated_at) }}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
45 |
No Nodes Discovered
46 |
Waiting for a node to announce!
47 |
48 |
49 |
50 |
55 |
No Search Results
56 |
Your search didn't match any Nodes!
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
113 |
--------------------------------------------------------------------------------
/src/frontend/components/ping/PingPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
Ping
8 |
9 | Only lxmf.delivery destinations can be pinged.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
Destination Hash
19 |
20 |
21 |
22 |
23 |
24 |
25 |
Ping Timeout (seconds)
26 |
27 |
28 |
29 |
30 |
31 |
32 |
35 |
38 |
41 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
Results
52 |
53 |
{{ pingResult }}
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
224 |
--------------------------------------------------------------------------------
/src/frontend/components/profile/ProfileIconPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
Customise your Profile Icon
8 |
9 |
10 |
11 | - Personalise your profile with a custom coloured icon.
12 | - This icon will be sent in all of your outgoing messages.
13 | - When you send someone a message, they will see your new icon.
14 | - You can remove your icon, however it will still show for anyone that already received it.
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
Select your Colours
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
Background Colour
32 |
{{ iconBackgroundColour }}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
Icon Colour
43 |
{{ iconForegroundColour }}
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
Select your Icon
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
{{ mdiIconName }}
63 |
64 |
No icons match your search.
65 |
A maximum of {{ maxSearchResults }} icons are shown.
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
193 |
--------------------------------------------------------------------------------
/src/frontend/components/propagation-nodes/PropagationNodesPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
{{ propagationNode.operator_display_name ?? "Unknown Operator" }}
16 |
<{{ propagationNode.destination_hash }}>
17 |
18 |
19 |
22 |
25 |
26 |
27 |
28 |
29 | Announced {{ formatTimeAgo(propagationNode.updated_at) }}
30 |
31 | • Disabled by Operator
32 |
33 |
34 | • Preferred
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
50 |
No Propagation Nodes
51 |
Check back later, once someone has announced.
52 |
53 |
56 |
57 |
58 |
59 |
60 |
61 |
66 |
No Search Results
67 |
Your search didn't match any Propagation Nodes!
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
173 |
--------------------------------------------------------------------------------
/src/frontend/components/tools/ToolsPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
53 |
54 |
55 |
61 |
--------------------------------------------------------------------------------
/src/frontend/fonts/RobotoMonoNerdFont/RobotoMonoNerdFont-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/fonts/RobotoMonoNerdFont/RobotoMonoNerdFont-Regular.ttf
--------------------------------------------------------------------------------
/src/frontend/fonts/RobotoMonoNerdFont/font.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Roboto Mono Nerd Font';
3 | src: url('./RobotoMonoNerdFont-Regular.ttf') format('truetype');
4 | font-weight: 400;
5 | font-style: normal;
6 | }
7 |
--------------------------------------------------------------------------------
/src/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Reticulum MeshChat
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/frontend/js/DialogUtils.js:
--------------------------------------------------------------------------------
1 | class DialogUtils {
2 |
3 | static alert(message) {
4 | if(window.electron){
5 | // running inside electron, use ipc alert
6 | window.electron.alert(message);
7 | } else {
8 | // running inside normal browser, use browser alert
9 | window.alert(message);
10 | }
11 | }
12 |
13 | static async prompt(message) {
14 | if(window.electron){
15 | // running inside electron, use ipc prompt
16 | return await window.electron.prompt(message);
17 | } else {
18 | // running inside normal browser, use browser prompt
19 | return window.prompt(message);
20 | }
21 | }
22 |
23 | }
24 |
25 | export default DialogUtils;
26 |
--------------------------------------------------------------------------------
/src/frontend/js/DownloadUtils.js:
--------------------------------------------------------------------------------
1 | class DownloadUtils {
2 |
3 | static downloadFile(filename, blob) {
4 |
5 | // create object url for blob
6 | const objectUrl = URL.createObjectURL(blob);
7 |
8 | // create hidden link element to download blob
9 | const link = document.createElement('a');
10 | link.href = objectUrl;
11 | link.download = filename;
12 | link.style.display = "none";
13 | document.body.append(link);
14 |
15 | // click link to download file in browser
16 | link.click();
17 |
18 | // link element is no longer needed
19 | link.remove();
20 |
21 | // revoke object url to clear memory
22 | setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
23 |
24 | }
25 |
26 | }
27 |
28 | export default DownloadUtils;
29 |
--------------------------------------------------------------------------------
/src/frontend/js/ElectronUtils.js:
--------------------------------------------------------------------------------
1 | class ElectronUtils {
2 |
3 | static isElectron() {
4 | return window.electron != null;
5 | }
6 |
7 | static relaunch() {
8 | if(window.electron){
9 | window.electron.relaunch();
10 | }
11 | }
12 |
13 | static showPathInFolder(path) {
14 | if(window.electron){
15 | window.electron.showPathInFolder(path);
16 | }
17 | }
18 |
19 | }
20 |
21 | export default ElectronUtils;
22 |
--------------------------------------------------------------------------------
/src/frontend/js/GlobalEmitter.js:
--------------------------------------------------------------------------------
1 | import mitt from 'mitt';
2 |
3 | class GlobalEmitter {
4 |
5 | constructor() {
6 | this.emitter = mitt();
7 | }
8 |
9 | // add event listener
10 | on(event, handler) {
11 | this.emitter.on(event, handler);
12 | }
13 |
14 | // remove event listener
15 | off(event, handler) {
16 | this.emitter.off(event, handler);
17 | }
18 |
19 | // emit event
20 | emit(type, event) {
21 | this.emitter.emit(type, event);
22 | }
23 |
24 | }
25 |
26 | export default new GlobalEmitter();
27 |
--------------------------------------------------------------------------------
/src/frontend/js/GlobalState.js:
--------------------------------------------------------------------------------
1 | import { reactive } from "vue";
2 |
3 | // global state
4 | const globalState = reactive({
5 | unreadConversationsCount: 0,
6 | });
7 |
8 | export default globalState;
9 |
--------------------------------------------------------------------------------
/src/frontend/js/MicrophoneRecorder.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A simple class for recording microphone input and returning the audio.
3 | */
4 | class MicrophoneRecorder {
5 |
6 | constructor() {
7 | this.audioChunks = [];
8 | this.microphoneMediaStream = null;
9 | this.mediaRecorder = null;
10 | }
11 |
12 | async start() {
13 | try {
14 |
15 | // request access to the microphone
16 | this.microphoneMediaStream = await navigator.mediaDevices.getUserMedia({
17 | audio: true,
18 | });
19 |
20 | // create media recorder
21 | this.mediaRecorder = new MediaRecorder(this.microphoneMediaStream);
22 |
23 | // handle received audio from media recorder
24 | this.mediaRecorder.ondataavailable = (event) => {
25 | this.audioChunks.push(event.data);
26 | };
27 |
28 | // start recording
29 | this.mediaRecorder.start();
30 |
31 | // successfully started recording
32 | return true;
33 |
34 | } catch(e) {
35 | return false;
36 | }
37 | }
38 |
39 | async stop() {
40 | return new Promise((resolve, reject) => {
41 | try {
42 |
43 | // handle media recording stopped
44 | this.mediaRecorder.onstop = () => {
45 |
46 | // stop using microphone
47 | if(this.microphoneMediaStream){
48 | this.microphoneMediaStream.getTracks().forEach(track => track.stop());
49 | }
50 |
51 | // create blob from audio chunks
52 | const blob = new Blob(this.audioChunks, {
53 | type: this.mediaRecorder.mimeType, // likely to be "audio/webm;codecs=opus" in chromium
54 | });
55 |
56 | // resolve promise
57 | resolve(blob);
58 |
59 | };
60 |
61 | // stop recording
62 | this.mediaRecorder.stop();
63 |
64 | } catch(e) {
65 | reject(e);
66 | }
67 | });
68 | }
69 |
70 | }
71 |
72 | export default MicrophoneRecorder;
73 |
--------------------------------------------------------------------------------
/src/frontend/js/NotificationUtils.js:
--------------------------------------------------------------------------------
1 | class NotificationUtils {
2 |
3 | static showIncomingCallNotification() {
4 | Notification.requestPermission().then((result) => {
5 | if(result === "granted"){
6 | new window.Notification("Incoming Call", {
7 | body: "Someone is calling you.",
8 | tag: "new_audio_call", // only ever show one incoming call notification at a time
9 | });
10 | }
11 | });
12 | }
13 |
14 | static showNewMessageNotification() {
15 | Notification.requestPermission().then((result) => {
16 | if(result === "granted"){
17 | new window.Notification("New Message", {
18 | body: "Someone sent you a message.",
19 | tag: "new_message", // only ever show one new message notification at a time
20 | });
21 | }
22 | });
23 | }
24 |
25 | }
26 |
27 | export default NotificationUtils;
28 |
--------------------------------------------------------------------------------
/src/frontend/js/Utils.js:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 |
3 | class Utils {
4 |
5 | static formatBytes(bytes) {
6 |
7 | if(bytes === 0){
8 | return '0 Bytes';
9 | }
10 |
11 | const k = 1024;
12 | const decimals = 0;
13 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
14 |
15 | const i = Math.floor(Math.log(bytes) / Math.log(k));
16 |
17 | return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
18 |
19 | }
20 |
21 | static parseSeconds(secondsToFormat) {
22 | secondsToFormat = Number(secondsToFormat);
23 | var days = Math.floor(secondsToFormat / (3600 * 24));
24 | var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
25 | var minutes = Math.floor((secondsToFormat % 3600) / 60);
26 | var seconds = Math.floor(secondsToFormat % 60);
27 | return {
28 | days: days,
29 | hours: hours,
30 | minutes: minutes,
31 | seconds: seconds,
32 | };
33 | }
34 |
35 | static formatSeconds(seconds) {
36 |
37 | const parsedSeconds = this.parseSeconds(seconds);
38 |
39 | if(parsedSeconds.days > 0){
40 | if(parsedSeconds.days === 1){
41 | return "1 day ago";
42 | } else {
43 | return parsedSeconds.days + " days ago";
44 | }
45 | }
46 |
47 | if(parsedSeconds.hours > 0){
48 | if(parsedSeconds.hours === 1){
49 | return "1 hour ago";
50 | } else {
51 | return parsedSeconds.hours + " hours ago";
52 | }
53 | }
54 |
55 | if(parsedSeconds.minutes > 0){
56 | if(parsedSeconds.minutes === 1){
57 | return "1 min ago";
58 | } else {
59 | return parsedSeconds.minutes + " mins ago";
60 | }
61 | }
62 |
63 | if(parsedSeconds.seconds <= 1){
64 | return "a second ago";
65 | } else {
66 | return parsedSeconds.seconds + " seconds ago";
67 | }
68 |
69 | }
70 |
71 | static formatTimeAgo(datetimeString) {
72 | const millisecondsAgo = Date.now() - new Date(datetimeString).getTime();
73 | const secondsAgo = Math.round(millisecondsAgo / 1000);
74 | return this.formatSeconds(secondsAgo);
75 | }
76 |
77 | static formatSecondsAgo(seconds) {
78 | const secondsAgo = Math.round((Date.now() / 1000) - seconds);
79 | return this.formatSeconds(secondsAgo);
80 | }
81 |
82 | static formatMinutesSeconds(seconds) {
83 | const parsedSeconds = this.parseSeconds(seconds);
84 | const paddedMinutes = parsedSeconds.minutes.toString().padStart(2, "0");
85 | const paddedSeconds = parsedSeconds.seconds.toString().padStart(2, "0");
86 | return `${paddedMinutes}:${paddedSeconds}`;
87 | }
88 |
89 | static convertUnixMillisToLocalDateTimeString(unixTimestampInMilliseconds) {
90 | return moment(unixTimestampInMilliseconds, "x").local().format('YYYY-MM-DD hh:mm A')
91 | }
92 |
93 | static convertDateTimeToLocalDateTimeString(dateTime) {
94 | return this.convertUnixMillisToLocalDateTimeString(dateTime.getTime());
95 | }
96 |
97 | static arrayBufferToBase64(arrayBuffer) {
98 | var binary = '';
99 | var bytes = new Uint8Array(arrayBuffer);
100 | var len = bytes.byteLength;
101 | for(var i = 0; i < len; i++){
102 | binary += String.fromCharCode(bytes[i]);
103 | }
104 | return window.btoa(binary);
105 | }
106 |
107 | static formatBitsPerSecond(bits) {
108 |
109 | if(bits === 0){
110 | return '0 bps';
111 | }
112 |
113 | const k = 1000; // Use 1000 instead of 1024 for network speeds
114 | const decimals = 0;
115 | const sizes = ['bps', 'kbps', 'Mbps', 'Gbps', 'Tbps', 'Pbps', 'Ebps', 'Zbps', 'Ybps'];
116 |
117 | const i = Math.floor(Math.log(bits) / Math.log(k));
118 |
119 | return parseFloat((bits / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
120 |
121 | }
122 |
123 | static formatFrequency(hz) {
124 |
125 | if(hz === 0 || hz == null){
126 | return '0 Hz';
127 | }
128 |
129 | const k = 1000;
130 | const sizes = ['Hz', 'kHz', 'MHz', 'GHz', 'THz', 'PHz', 'EHz', 'ZHz', 'YHz'];
131 | const i = Math.floor(Math.log(hz) / Math.log(k));
132 |
133 | return parseFloat((hz / Math.pow(k, i))) + ' ' + sizes[i];
134 |
135 | }
136 |
137 | static decodeBase64ToUtf8String(base64) {
138 | // support for decoding base64 as a utf8 string to support emojis and cyrillic characters etc
139 | return decodeURIComponent(atob(base64).split('').map(function(c) {
140 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
141 | }).join(''));
142 | }
143 |
144 | static isInterfaceEnabled(iface) {
145 | const rawValue = iface.enabled ?? iface.interface_enabled;
146 | const value = rawValue?.toString()?.toLowerCase();
147 | return value === "on" || value === "yes" || value === "true";
148 | }
149 |
150 | }
151 |
152 | export default Utils;
153 |
--------------------------------------------------------------------------------
/src/frontend/js/WebSocketConnection.js:
--------------------------------------------------------------------------------
1 | import mitt from 'mitt';
2 |
3 | class WebSocketConnection {
4 |
5 | constructor() {
6 |
7 | this.emitter = mitt();
8 | this.reconnect();
9 |
10 | /**
11 | * ping websocket server every 30 seconds
12 | * this helps to prevent the underlying tcp connection from going stale when there's no traffic for a long time
13 | */
14 | setInterval(() => {
15 | this.ping();
16 | }, 30000);
17 |
18 | }
19 |
20 | // add event listener
21 | on(event, handler) {
22 | this.emitter.on(event, handler);
23 | }
24 |
25 | // remove event listener
26 | off(event, handler) {
27 | this.emitter.off(event, handler);
28 | }
29 |
30 | // emit event
31 | emit(type, event) {
32 | this.emitter.emit(type, event);
33 | }
34 |
35 | reconnect() {
36 |
37 | // connect to websocket
38 | this.ws = new WebSocket(location.origin.replace(/^http/, 'ws') + "/ws");
39 |
40 | // auto reconnect when websocket closes
41 | this.ws.addEventListener('close', () => {
42 | setTimeout(() => {
43 | this.reconnect();
44 | }, 1000);
45 | });
46 |
47 | // emit data received from websocket
48 | this.ws.onmessage = (message) => {
49 | this.emit("message", message);
50 | };
51 |
52 | }
53 |
54 | send(message) {
55 | if(this.ws != null && this.ws.readyState === WebSocket.OPEN){
56 | this.ws.send(message);
57 | }
58 | }
59 |
60 | ping() {
61 | try {
62 | this.send(JSON.stringify({
63 | "type": "ping",
64 | }));
65 | } catch(e) {
66 | // ignore error
67 | }
68 | }
69 |
70 | }
71 |
72 | export default new WebSocketConnection();
73 |
--------------------------------------------------------------------------------
/src/frontend/main.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { createApp, defineAsyncComponent } from 'vue';
3 | import { createRouter, createWebHashHistory } from 'vue-router';
4 | import vClickOutside from "click-outside-vue3";
5 | import "./style.css";
6 | import "./fonts/RobotoMonoNerdFont/font.css";
7 |
8 | import App from './components/App.vue';
9 |
10 | // init vuetify
11 | import { createVuetify } from 'vuetify';
12 | const vuetify = createVuetify();
13 |
14 | // provide axios globally
15 | window.axios = axios;
16 |
17 | const router = createRouter({
18 | history: createWebHashHistory(),
19 | routes: [
20 | {
21 | path: '/',
22 | redirect: '/messages',
23 | },
24 | {
25 | name: "about",
26 | path: '/about',
27 | component: defineAsyncComponent(() => import("./components/about/AboutPage.vue")),
28 | },
29 | {
30 | name: "interfaces",
31 | path: '/interfaces',
32 | component: defineAsyncComponent(() => import("./components/interfaces/InterfacesPage.vue")),
33 | },
34 | {
35 | name: "interfaces.add",
36 | path: '/interfaces/add',
37 | component: defineAsyncComponent(() => import("./components/interfaces/AddInterfacePage.vue")),
38 | },
39 | {
40 | name: "interfaces.edit",
41 | path: '/interfaces/edit',
42 | component: defineAsyncComponent(() => import("./components/interfaces/AddInterfacePage.vue")),
43 | props: {
44 | interface_name: String,
45 | },
46 | },
47 | {
48 | name: "messages",
49 | path: '/messages/:destinationHash?',
50 | props: true,
51 | component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
52 | },
53 | {
54 | name: "network-visualiser",
55 | path: '/network-visualiser',
56 | component: defineAsyncComponent(() => import("./components/network-visualiser/NetworkVisualiserPage.vue")),
57 | },
58 | {
59 | name: "nomadnetwork",
60 | path: '/nomadnetwork/:destinationHash?',
61 | props: true,
62 | component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
63 | },
64 | {
65 | name: "propagation-nodes",
66 | path: '/propagation-nodes',
67 | component: defineAsyncComponent(() => import("./components/propagation-nodes/PropagationNodesPage.vue")),
68 | },
69 | {
70 | name: "ping",
71 | path: '/ping',
72 | component: defineAsyncComponent(() => import("./components/ping/PingPage.vue")),
73 | },
74 | {
75 | name: "profile.icon",
76 | path: '/profile/icon',
77 | component: defineAsyncComponent(() => import("./components/profile/ProfileIconPage.vue")),
78 | },
79 | {
80 | name: "settings",
81 | path: '/settings',
82 | component: defineAsyncComponent(() => import("./components/settings/SettingsPage.vue")),
83 | },
84 | {
85 | name: "tools",
86 | path: '/tools',
87 | component: defineAsyncComponent(() => import("./components/tools/ToolsPage.vue")),
88 | },
89 | ],
90 | })
91 |
92 | createApp(App)
93 | .use(router)
94 | .use(vuetify)
95 | .use(vClickOutside)
96 | .mount('#app');
97 |
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/logo-chat-bubble.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/logo-chat-bubble.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/logo.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/network-visualiser/interface_connected.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/network-visualiser/interface_connected.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/network-visualiser/interface_disconnected.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/network-visualiser/interface_disconnected.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/network-visualiser/server.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/network-visualiser/server.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/network-visualiser/server_1hop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/network-visualiser/server_1hop.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/network-visualiser/user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/network-visualiser/user.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/network-visualiser/user_1hop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/network-visualiser/user_1hop.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/images/reticulum_logo_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/images/reticulum_logo_512.png
--------------------------------------------------------------------------------
/src/frontend/public/assets/js/codec2-emscripten/c2dec.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/js/codec2-emscripten/c2dec.wasm
--------------------------------------------------------------------------------
/src/frontend/public/assets/js/codec2-emscripten/c2enc.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/js/codec2-emscripten/c2enc.wasm
--------------------------------------------------------------------------------
/src/frontend/public/assets/js/codec2-emscripten/codec2-lib.js:
--------------------------------------------------------------------------------
1 | class Codec2Lib {
2 |
3 | static arrayBufferToBase64(buffer) {
4 | let binary = "";
5 | let bytes = new Uint8Array(buffer);
6 | for (let byte of bytes) {
7 | binary += String.fromCharCode(byte);
8 | }
9 | return window.btoa(binary);
10 | }
11 |
12 | static base64ToArrayBuffer(base64) {
13 | let binary = window.atob(base64);
14 | let bytes = new Uint8Array(binary.length);
15 | for (let i = 0; i < binary.length; i++) {
16 | bytes[i] = binary.charCodeAt(i);
17 | }
18 | return bytes.buffer;
19 | }
20 |
21 | static readFileAsArrayBuffer(file) {
22 | return new Promise((resolve, reject) => {
23 | const reader = new FileReader();
24 | reader.onload = () => {
25 | resolve(reader.result);
26 | };
27 | reader.readAsArrayBuffer(file);
28 | });
29 | }
30 |
31 | static runDecode(mode, data) {
32 | return new Promise((resolve, reject) => {
33 | const module = {
34 | arguments: [mode, "input.bit", "output.raw"],
35 | preRun: () => {
36 | module.FS.writeFile("input.bit", new Uint8Array(data));
37 | },
38 | postRun: () => {
39 | let buffer = module.FS.readFile("output.raw", {
40 | encoding: "binary",
41 | });
42 | resolve(buffer);
43 | },
44 | };
45 | createC2Dec(module);
46 | });
47 | }
48 |
49 | static runEncode(mode, data) {
50 | return new Promise((resolve, reject) => {
51 | const module = {
52 | arguments: [mode, "input.raw", "output.bit"],
53 | preRun: () => {
54 | module.FS.writeFile("input.raw", new Uint8Array(data));
55 | },
56 | postRun: () => {
57 | let buffer = module.FS.readFile("output.bit", {
58 | encoding: "binary",
59 | });
60 | resolve(buffer);
61 | },
62 | };
63 | createC2Enc(module);
64 | });
65 | }
66 |
67 | static rawToWav(buffer) {
68 | return new Promise((resolve, reject) => {
69 | const module = {
70 | arguments: [
71 | "-r",
72 | "8000",
73 | "-L",
74 | "-e",
75 | "signed-integer",
76 | "-b",
77 | "16",
78 | "-c",
79 | "1",
80 | "input.raw",
81 | "output.wav",
82 | ],
83 | preRun: () => {
84 | module.FS.writeFile("input.raw", new Uint8Array(buffer));
85 | },
86 | postRun: () => {
87 | let output = module.FS.readFile("output.wav", {
88 | encoding: "binary",
89 | });
90 | resolve(output);
91 | },
92 | };
93 | SOXModule(module);
94 | });
95 | }
96 |
97 | static audioFileToRaw(buffer, filename) {
98 | return new Promise((resolve, reject) => {
99 | const module = {
100 | arguments: [
101 | filename,
102 | "-r",
103 | "8000",
104 | "-L",
105 | "-e",
106 | "signed-integer",
107 | "-b",
108 | "16",
109 | "-c",
110 | "1",
111 | "output.raw",
112 | ],
113 | preRun: () => {
114 | module.FS.writeFile(filename, new Uint8Array(buffer));
115 | },
116 | postRun: () => {
117 | let output = module.FS.readFile("output.raw", {
118 | encoding: "binary",
119 | });
120 | resolve(output);
121 | },
122 | };
123 | SOXModule(module);
124 | });
125 | }
126 |
127 | }
128 |
--------------------------------------------------------------------------------
/src/frontend/public/assets/js/codec2-emscripten/codec2-microphone-recorder.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A simple class for recording microphone input and returning the audio encoded in codec2
3 | */
4 | class Codec2MicrophoneRecorder {
5 |
6 | constructor() {
7 |
8 | this.sampleRate = 8000;
9 | this.codec2Mode = "1200";
10 | this.audioChunks = [];
11 |
12 | this.audioContext = null;
13 | this.audioWorkletNode = null;
14 | this.microphoneMediaStream = null;
15 | this.mediaStreamSource = null;
16 |
17 | }
18 |
19 | async start() {
20 | try {
21 |
22 | // load audio worklet module
23 | this.audioContext = new AudioContext({ sampleRate: this.sampleRate });
24 | await this.audioContext.audioWorklet.addModule('assets/js/codec2-emscripten/processor.js');
25 | this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
26 |
27 | // handle audio received from audio worklet
28 | this.audioWorkletNode.port.onmessage = async (event) => {
29 | this.audioChunks.push(event.data);
30 | };
31 |
32 | // request access to the microphone
33 | this.microphoneMediaStream = await navigator.mediaDevices.getUserMedia({
34 | audio: true,
35 | });
36 |
37 | // send mic audio to audio worklet
38 | this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.microphoneMediaStream);
39 | this.mediaStreamSource.connect(this.audioWorkletNode);
40 |
41 | // successfully started recording
42 | return true;
43 |
44 | } catch(e) {
45 | console.log(e);
46 | return false;
47 | }
48 | }
49 |
50 | async stop() {
51 |
52 | // disconnect media stream source
53 | if(this.mediaStreamSource){
54 | this.mediaStreamSource.disconnect();
55 | }
56 |
57 | // stop using microphone
58 | if(this.microphoneMediaStream){
59 | this.microphoneMediaStream.getTracks().forEach(track => track.stop());
60 | }
61 |
62 | // disconnect the audio worklet node
63 | if(this.audioWorkletNode){
64 | this.audioWorkletNode.disconnect();
65 | }
66 |
67 | // close audio context
68 | if(this.audioContext && this.audioContext.state !== "closed"){
69 | this.audioContext.close();
70 | }
71 |
72 | // concatenate all audio chunks into a single array
73 | var fullAudio = [];
74 | for(const chunk of this.audioChunks){
75 | fullAudio = [
76 | ...fullAudio,
77 | ...chunk,
78 | ]
79 | }
80 |
81 | // convert audio to wav
82 | const buffer = WavEncoder.encodeWAV(fullAudio, this.sampleRate);
83 |
84 | // convert wav audio to codec2
85 | const rawBuffer = await Codec2Lib.audioFileToRaw(buffer, "audio.wav");
86 | const encoded = await Codec2Lib.runEncode(this.codec2Mode, rawBuffer);
87 |
88 | return encoded;
89 |
90 | }
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/src/frontend/public/assets/js/codec2-emscripten/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Select a *.wav audio file.
7 |
8 |
9 |
10 |
11 | Select Codec2 Mode:
12 |
23 |
24 |
25 |
26 |
Click to encode audio file as Codec2
27 |
28 |
29 |
30 |
31 |
Codec2 audio represented as Base64
32 |
33 |
34 |
35 |
36 |
Click to decode Codec2 audio back to WAVE audio
37 |
38 |
39 |
40 |
41 |
Decoded audio available to listen to
42 |
43 |
44 |
45 |
46 |
Input File Size: 0 Bytes
47 |
Encoded Data Size: 0 Bytes
48 |
Decoded Data Size: 0 Bytes
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
126 |
127 |
--------------------------------------------------------------------------------
/src/frontend/public/assets/js/codec2-emscripten/processor.js:
--------------------------------------------------------------------------------
1 | class AudioProcessor extends AudioWorkletProcessor {
2 |
3 | constructor() {
4 | super();
5 | this.bufferSize = 4096; // Adjust the buffer size as needed
6 | this.sampleRate = 8000; // Target sample rate
7 | this.inputBuffer = new Float32Array(this.bufferSize);
8 | this.bufferIndex = 0;
9 | }
10 |
11 | process(inputs, outputs, parameters) {
12 | const input = inputs[0];
13 | if (input.length > 0) {
14 | const inputData = input[0];
15 | for (let i = 0; i < inputData.length; i++) {
16 | if (this.bufferIndex < this.bufferSize) {
17 | this.inputBuffer[this.bufferIndex++] = inputData[i];
18 | }
19 | if (this.bufferIndex === this.bufferSize) {
20 | // Downsample the buffer and send to the main thread
21 | const downsampledBuffer = this.downsampleBuffer(this.inputBuffer, this.sampleRate);
22 | this.port.postMessage(downsampledBuffer);
23 | this.bufferIndex = 0;
24 | }
25 | }
26 | }
27 | return true;
28 | }
29 |
30 | downsampleBuffer(buffer, targetSampleRate) {
31 | if (targetSampleRate === this.sampleRate) {
32 | return buffer;
33 | }
34 | const sampleRateRatio = this.sampleRate / targetSampleRate;
35 | const newLength = Math.round(buffer.length / sampleRateRatio);
36 | const result = new Float32Array(newLength);
37 | let offsetResult = 0;
38 | let offsetBuffer = 0;
39 | while (offsetResult < result.length) {
40 | const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
41 | let accum = 0;
42 | let count = 0;
43 | for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
44 | accum += buffer[i];
45 | count++;
46 | }
47 | result[offsetResult] = accum / count;
48 | offsetResult++;
49 | offsetBuffer = nextOffsetBuffer;
50 | }
51 | return result;
52 | }
53 | }
54 |
55 | registerProcessor('audio-processor', AudioProcessor);
56 |
--------------------------------------------------------------------------------
/src/frontend/public/assets/js/codec2-emscripten/sox.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/assets/js/codec2-emscripten/sox.wasm
--------------------------------------------------------------------------------
/src/frontend/public/assets/js/codec2-emscripten/wav-encoder.js:
--------------------------------------------------------------------------------
1 | class WavEncoder {
2 |
3 | static encodeWAV(samples, sampleRate = 8000, numChannels = 1) {
4 |
5 | const buffer = new ArrayBuffer(44 + samples.length * 2);
6 | const view = new DataView(buffer);
7 |
8 | // RIFF chunk descriptor
9 | this.writeString(view, 0, 'RIFF');
10 | view.setUint32(4, 36 + samples.length * 2, true); // file length
11 | this.writeString(view, 8, 'WAVE');
12 |
13 | // fmt sub-chunk
14 | this.writeString(view, 12, 'fmt ');
15 | view.setUint32(16, 16, true); // sub-chunk size
16 | view.setUint16(20, 1, true); // audio format (1 = PCM)
17 | view.setUint16(22, numChannels, true); // number of channels
18 | view.setUint32(24, sampleRate, true); // sample rate
19 | view.setUint32(28, sampleRate * numChannels * 2, true); // byte rate
20 | view.setUint16(32, numChannels * 2, true); // block align
21 | view.setUint16(34, 16, true); // bits per sample
22 |
23 | // data sub-chunk
24 | this.writeString(view, 36, 'data');
25 | view.setUint32(40, samples.length * 2, true); // data chunk length
26 |
27 | // write the PCM samples
28 | this.floatTo16BitPCM(view, 44, samples);
29 |
30 | return buffer;
31 |
32 | }
33 |
34 | static writeString(view, offset, string) {
35 | for(let i = 0; i < string.length; i++){
36 | view.setUint8(offset + i, string.charCodeAt(i));
37 | }
38 | }
39 |
40 | static floatTo16BitPCM(output, offset, input) {
41 | for(let i = 0; i < input.length; i++, offset += 2){
42 | const s = Math.max(-1, Math.min(1, input[i]));
43 | output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
44 | }
45 | }
46 |
47 | }
48 |
--------------------------------------------------------------------------------
/src/frontend/public/assets/proto/audio_call.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto2";
2 |
3 | // raw payload sent over the websocket
4 | message AudioCallPayload {
5 | optional AudioData audioData = 1;
6 | }
7 |
8 | // a message containing some sort of audio data
9 | message AudioData {
10 | optional Codec2Audio codec2Audio = 1;
11 | }
12 |
13 | // audio encoded with codec2
14 | message Codec2Audio {
15 |
16 | required Mode mode = 1; // codec2 mode used for encoding
17 | required bytes encoded = 2; // audio encoded as codec2
18 |
19 | enum Mode {
20 | MODE_3200 = 0;
21 | MODE_2400 = 1;
22 | MODE_1600 = 2;
23 | MODE_1400 = 3;
24 | MODE_1300 = 4;
25 | MODE_1200 = 5;
26 | MODE_700C = 6;
27 | MODE_450 = 7;
28 | MODE_450PWB = 8;
29 | }
30 |
31 | }
--------------------------------------------------------------------------------
/src/frontend/public/favicons/favicon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/favicons/favicon-512x512.png
--------------------------------------------------------------------------------
/src/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "MeshChat",
3 | "short_name": "MeshChat",
4 | "description": "A simple mesh network communications app powered by the Reticulum Network Stack.",
5 | "scope": "/",
6 | "start_url": "/",
7 | "icons": [
8 | {
9 | "src": "/favicons/favicon-512x512.png",
10 | "sizes": "512x512",
11 | "type": "image/png"
12 | }
13 | ],
14 | "display": "standalone",
15 | "theme_color": "#FFFFFF",
16 | "background_color": "#FFFFFF"
17 | }
18 |
--------------------------------------------------------------------------------
/src/frontend/public/rnode-flasher/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Liam Cottle
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/frontend/public/rnode-flasher/README.md:
--------------------------------------------------------------------------------
1 | # RNode Flasher
2 |
3 | A _work-in-progress_ web based firmware flasher for [Reticulum](https://github.com/markqvist/Reticulum) / [RNode_Firmware](https://github.com/markqvist/RNode_Firmware).
4 |
5 | - It is written in javascript and uses the [Web Serial APIs](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API).
6 | - It supports putting relevant devices into DFU mode.
7 | - It supports flashing firmware from a zip file.
8 |
9 | At this time, it does not support flashing bootloaders or softdevices for the nRF boards.
10 |
11 | ## How does it work?
12 |
13 | I wanted something simple, for flashing RNode firmware to a nRF52 RAK4631 in a web browser.
14 |
15 | So, I spent a bit of time working through the source code of [adafruit-nrfutil](https://github.com/adafruit/Adafruit_nRF52_nrfutil) and wrote a javascript implementation of [dfu_transport_serial.py](https://github.com/adafruit/Adafruit_nRF52_nrfutil/blob/master/nordicsemi/dfu/dfu_transport_serial.py)
16 |
17 | Generally, you would use the following command to flash a firmware.zip to your device;
18 |
19 | ```
20 | adafruit-nrfutil dfu serial --package firmware.zip -p /dev/cu.usbmodem14401 -b 115200 -t 1200
21 | ```
22 |
23 | The [nrf52_dfu_flasher.js](js/nrf52_dfu_flasher.js) in this project implements a javascript, web based version of the above command.
24 |
25 | There was an existing package called [pc-nrf-dfu-js](https://github.com/NordicSemiconductor/pc-nrf-dfu-js), however this repo had been archived and didn't appear to support the latest DFU protocol.
26 |
27 | ## How to use it?
28 |
29 | - Open https://liamcottle.github.io/rnode-flasher/ in your web browser.
30 | - Select your device.
31 | - Put your device into DFU mode (for nRF52 boards)
32 | - Select a firmware file and click flash.
33 | - Once flashed, your device should reboot into the new firmware.
34 | - For new devices that have never been provisioned, you should click "Provision" to configure the EEPROM.
35 | - Every time you flash new firmware, you should also click "Set Firmware Hash".
36 |
37 | > Note: At this time, firmware hashes for RNode are not automatically configured.
38 |
39 | ## What is needed to set up a new RNode?
40 |
41 | > Note: This is a technical overview of how the RNode device provisioning works.
42 | > Most of this is taken care of by the code base, and this section just makes it easier to understand what is going on.
43 |
44 | To set up a new RNode device, you will need to do a few things;
45 |
46 | - Obtain supported hardware, such as a RAK4631
47 | - Obtain an RNode firmware file
48 | - Put your device into DFU mode
49 | - Flash the firmware file
50 | - Provision the EEPROM
51 |
52 | Once the firmware is flashed to the device, you will need to provision the EEPROM;
53 |
54 | - Set firmware hash in eeprom
55 | - Collect device info
56 | - `product`
57 | - `model`
58 | - `hardware_revision`
59 | - `serial_number`
60 | - `made` (unix timestamp of device creation)
61 | - Write device info to eeprom
62 | - Create an MD5 checksum of the device info
63 | - Write 16 byte device info checksum to eeprom
64 | - Sign device info checksum with signing key to use as signature
65 | - Write 128 byte signature to eeprom
66 | - Write `ROM.INFO_LOCK_BYTE` to `ROM.ADDR_INFO_LOCK` in eeprom
67 | - Read eeprom and validate checksums and signatures to ensure all is correct
68 |
69 | ## TODO
70 |
71 | - support configuring eeprom with device signatures and firmware hashes
72 | - support flashing existing firmware files from api
73 | - calculate on air bitrate based on tnc settings
74 | - try using [web-serial-polyfill](https://github.com/google/web-serial-polyfill) to support flashing from Android device?
75 |
76 | ## License
77 |
78 | MIT
79 |
80 | ## References
81 |
82 | - https://github.com/adafruit/Adafruit_nRF52_nrfutil
83 | - https://github.com/adafruit/Adafruit_nRF52_nrfutil/blob/master/nordicsemi/dfu/dfu_transport_serial.py
84 | - https://github.com/markqvist/RNode_Firmware/blob/master/RNode_Firmware.ino
85 | - https://github.com/markqvist/RNode_Firmware/blob/master/Framing.h
86 | - https://github.com/markqvist/RNode_Firmware/blob/master/Utilities.h
87 |
--------------------------------------------------------------------------------
/src/frontend/public/rnode-flasher/js/crypto-js@3.9.1-1/md5.js:
--------------------------------------------------------------------------------
1 | ;(function (root, factory) {
2 | if (typeof exports === "object") {
3 | // CommonJS
4 | module.exports = exports = factory(require("./core"));
5 | }
6 | else if (typeof define === "function" && define.amd) {
7 | // AMD
8 | define(["./core"], factory);
9 | }
10 | else {
11 | // Global (browser)
12 | factory(root.CryptoJS);
13 | }
14 | }(this, function (CryptoJS) {
15 |
16 | (function (Math) {
17 | // Shortcuts
18 | var C = CryptoJS;
19 | var C_lib = C.lib;
20 | var WordArray = C_lib.WordArray;
21 | var Hasher = C_lib.Hasher;
22 | var C_algo = C.algo;
23 |
24 | // Constants table
25 | var T = [];
26 |
27 | // Compute constants
28 | (function () {
29 | for (var i = 0; i < 64; i++) {
30 | T[i] = (Math.abs(Math.sin(i + 1)) * 0x100000000) | 0;
31 | }
32 | }());
33 |
34 | /**
35 | * MD5 hash algorithm.
36 | */
37 | var MD5 = C_algo.MD5 = Hasher.extend({
38 | _doReset: function () {
39 | this._hash = new WordArray.init([
40 | 0x67452301, 0xefcdab89,
41 | 0x98badcfe, 0x10325476
42 | ]);
43 | },
44 |
45 | _doProcessBlock: function (M, offset) {
46 | // Swap endian
47 | for (var i = 0; i < 16; i++) {
48 | // Shortcuts
49 | var offset_i = offset + i;
50 | var M_offset_i = M[offset_i];
51 |
52 | M[offset_i] = (
53 | (((M_offset_i << 8) | (M_offset_i >>> 24)) & 0x00ff00ff) |
54 | (((M_offset_i << 24) | (M_offset_i >>> 8)) & 0xff00ff00)
55 | );
56 | }
57 |
58 | // Shortcuts
59 | var H = this._hash.words;
60 |
61 | var M_offset_0 = M[offset + 0];
62 | var M_offset_1 = M[offset + 1];
63 | var M_offset_2 = M[offset + 2];
64 | var M_offset_3 = M[offset + 3];
65 | var M_offset_4 = M[offset + 4];
66 | var M_offset_5 = M[offset + 5];
67 | var M_offset_6 = M[offset + 6];
68 | var M_offset_7 = M[offset + 7];
69 | var M_offset_8 = M[offset + 8];
70 | var M_offset_9 = M[offset + 9];
71 | var M_offset_10 = M[offset + 10];
72 | var M_offset_11 = M[offset + 11];
73 | var M_offset_12 = M[offset + 12];
74 | var M_offset_13 = M[offset + 13];
75 | var M_offset_14 = M[offset + 14];
76 | var M_offset_15 = M[offset + 15];
77 |
78 | // Working varialbes
79 | var a = H[0];
80 | var b = H[1];
81 | var c = H[2];
82 | var d = H[3];
83 |
84 | // Computation
85 | a = FF(a, b, c, d, M_offset_0, 7, T[0]);
86 | d = FF(d, a, b, c, M_offset_1, 12, T[1]);
87 | c = FF(c, d, a, b, M_offset_2, 17, T[2]);
88 | b = FF(b, c, d, a, M_offset_3, 22, T[3]);
89 | a = FF(a, b, c, d, M_offset_4, 7, T[4]);
90 | d = FF(d, a, b, c, M_offset_5, 12, T[5]);
91 | c = FF(c, d, a, b, M_offset_6, 17, T[6]);
92 | b = FF(b, c, d, a, M_offset_7, 22, T[7]);
93 | a = FF(a, b, c, d, M_offset_8, 7, T[8]);
94 | d = FF(d, a, b, c, M_offset_9, 12, T[9]);
95 | c = FF(c, d, a, b, M_offset_10, 17, T[10]);
96 | b = FF(b, c, d, a, M_offset_11, 22, T[11]);
97 | a = FF(a, b, c, d, M_offset_12, 7, T[12]);
98 | d = FF(d, a, b, c, M_offset_13, 12, T[13]);
99 | c = FF(c, d, a, b, M_offset_14, 17, T[14]);
100 | b = FF(b, c, d, a, M_offset_15, 22, T[15]);
101 |
102 | a = GG(a, b, c, d, M_offset_1, 5, T[16]);
103 | d = GG(d, a, b, c, M_offset_6, 9, T[17]);
104 | c = GG(c, d, a, b, M_offset_11, 14, T[18]);
105 | b = GG(b, c, d, a, M_offset_0, 20, T[19]);
106 | a = GG(a, b, c, d, M_offset_5, 5, T[20]);
107 | d = GG(d, a, b, c, M_offset_10, 9, T[21]);
108 | c = GG(c, d, a, b, M_offset_15, 14, T[22]);
109 | b = GG(b, c, d, a, M_offset_4, 20, T[23]);
110 | a = GG(a, b, c, d, M_offset_9, 5, T[24]);
111 | d = GG(d, a, b, c, M_offset_14, 9, T[25]);
112 | c = GG(c, d, a, b, M_offset_3, 14, T[26]);
113 | b = GG(b, c, d, a, M_offset_8, 20, T[27]);
114 | a = GG(a, b, c, d, M_offset_13, 5, T[28]);
115 | d = GG(d, a, b, c, M_offset_2, 9, T[29]);
116 | c = GG(c, d, a, b, M_offset_7, 14, T[30]);
117 | b = GG(b, c, d, a, M_offset_12, 20, T[31]);
118 |
119 | a = HH(a, b, c, d, M_offset_5, 4, T[32]);
120 | d = HH(d, a, b, c, M_offset_8, 11, T[33]);
121 | c = HH(c, d, a, b, M_offset_11, 16, T[34]);
122 | b = HH(b, c, d, a, M_offset_14, 23, T[35]);
123 | a = HH(a, b, c, d, M_offset_1, 4, T[36]);
124 | d = HH(d, a, b, c, M_offset_4, 11, T[37]);
125 | c = HH(c, d, a, b, M_offset_7, 16, T[38]);
126 | b = HH(b, c, d, a, M_offset_10, 23, T[39]);
127 | a = HH(a, b, c, d, M_offset_13, 4, T[40]);
128 | d = HH(d, a, b, c, M_offset_0, 11, T[41]);
129 | c = HH(c, d, a, b, M_offset_3, 16, T[42]);
130 | b = HH(b, c, d, a, M_offset_6, 23, T[43]);
131 | a = HH(a, b, c, d, M_offset_9, 4, T[44]);
132 | d = HH(d, a, b, c, M_offset_12, 11, T[45]);
133 | c = HH(c, d, a, b, M_offset_15, 16, T[46]);
134 | b = HH(b, c, d, a, M_offset_2, 23, T[47]);
135 |
136 | a = II(a, b, c, d, M_offset_0, 6, T[48]);
137 | d = II(d, a, b, c, M_offset_7, 10, T[49]);
138 | c = II(c, d, a, b, M_offset_14, 15, T[50]);
139 | b = II(b, c, d, a, M_offset_5, 21, T[51]);
140 | a = II(a, b, c, d, M_offset_12, 6, T[52]);
141 | d = II(d, a, b, c, M_offset_3, 10, T[53]);
142 | c = II(c, d, a, b, M_offset_10, 15, T[54]);
143 | b = II(b, c, d, a, M_offset_1, 21, T[55]);
144 | a = II(a, b, c, d, M_offset_8, 6, T[56]);
145 | d = II(d, a, b, c, M_offset_15, 10, T[57]);
146 | c = II(c, d, a, b, M_offset_6, 15, T[58]);
147 | b = II(b, c, d, a, M_offset_13, 21, T[59]);
148 | a = II(a, b, c, d, M_offset_4, 6, T[60]);
149 | d = II(d, a, b, c, M_offset_11, 10, T[61]);
150 | c = II(c, d, a, b, M_offset_2, 15, T[62]);
151 | b = II(b, c, d, a, M_offset_9, 21, T[63]);
152 |
153 | // Intermediate hash value
154 | H[0] = (H[0] + a) | 0;
155 | H[1] = (H[1] + b) | 0;
156 | H[2] = (H[2] + c) | 0;
157 | H[3] = (H[3] + d) | 0;
158 | },
159 |
160 | _doFinalize: function () {
161 | // Shortcuts
162 | var data = this._data;
163 | var dataWords = data.words;
164 |
165 | var nBitsTotal = this._nDataBytes * 8;
166 | var nBitsLeft = data.sigBytes * 8;
167 |
168 | // Add padding
169 | dataWords[nBitsLeft >>> 5] |= 0x80 << (24 - nBitsLeft % 32);
170 |
171 | var nBitsTotalH = Math.floor(nBitsTotal / 0x100000000);
172 | var nBitsTotalL = nBitsTotal;
173 | dataWords[(((nBitsLeft + 64) >>> 9) << 4) + 15] = (
174 | (((nBitsTotalH << 8) | (nBitsTotalH >>> 24)) & 0x00ff00ff) |
175 | (((nBitsTotalH << 24) | (nBitsTotalH >>> 8)) & 0xff00ff00)
176 | );
177 | dataWords[(((nBitsLeft + 64) >>> 9) << 4) + 14] = (
178 | (((nBitsTotalL << 8) | (nBitsTotalL >>> 24)) & 0x00ff00ff) |
179 | (((nBitsTotalL << 24) | (nBitsTotalL >>> 8)) & 0xff00ff00)
180 | );
181 |
182 | data.sigBytes = (dataWords.length + 1) * 4;
183 |
184 | // Hash final blocks
185 | this._process();
186 |
187 | // Shortcuts
188 | var hash = this._hash;
189 | var H = hash.words;
190 |
191 | // Swap endian
192 | for (var i = 0; i < 4; i++) {
193 | // Shortcut
194 | var H_i = H[i];
195 |
196 | H[i] = (((H_i << 8) | (H_i >>> 24)) & 0x00ff00ff) |
197 | (((H_i << 24) | (H_i >>> 8)) & 0xff00ff00);
198 | }
199 |
200 | // Return final computed hash
201 | return hash;
202 | },
203 |
204 | clone: function () {
205 | var clone = Hasher.clone.call(this);
206 | clone._hash = this._hash.clone();
207 |
208 | return clone;
209 | }
210 | });
211 |
212 | function FF(a, b, c, d, x, s, t) {
213 | var n = a + ((b & c) | (~b & d)) + x + t;
214 | return ((n << s) | (n >>> (32 - s))) + b;
215 | }
216 |
217 | function GG(a, b, c, d, x, s, t) {
218 | var n = a + ((b & d) | (c & ~d)) + x + t;
219 | return ((n << s) | (n >>> (32 - s))) + b;
220 | }
221 |
222 | function HH(a, b, c, d, x, s, t) {
223 | var n = a + (b ^ c ^ d) + x + t;
224 | return ((n << s) | (n >>> (32 - s))) + b;
225 | }
226 |
227 | function II(a, b, c, d, x, s, t) {
228 | var n = a + (c ^ (b | ~d)) + x + t;
229 | return ((n << s) | (n >>> (32 - s))) + b;
230 | }
231 |
232 | /**
233 | * Shortcut function to the hasher's object interface.
234 | *
235 | * @param {WordArray|string} message The message to hash.
236 | *
237 | * @return {WordArray} The hash.
238 | *
239 | * @static
240 | *
241 | * @example
242 | *
243 | * var hash = CryptoJS.MD5('message');
244 | * var hash = CryptoJS.MD5(wordArray);
245 | */
246 | C.MD5 = Hasher._createHelper(MD5);
247 |
248 | /**
249 | * Shortcut function to the HMAC's object interface.
250 | *
251 | * @param {WordArray|string} message The message to hash.
252 | * @param {WordArray|string} key The secret key.
253 | *
254 | * @return {WordArray} The HMAC.
255 | *
256 | * @static
257 | *
258 | * @example
259 | *
260 | * var hmac = CryptoJS.HmacMD5(message, key);
261 | */
262 | C.HmacMD5 = Hasher._createHmacHelper(MD5);
263 | }(Math));
264 |
265 |
266 | return CryptoJS.MD5;
267 |
268 | }));
--------------------------------------------------------------------------------
/src/frontend/public/rnode-flasher/reticulum_logo_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markqvist/reticulum-meshchat/9ea98eb0f045dbf4b4c2177c355db79a78c12bc3/src/frontend/public/rnode-flasher/reticulum_logo_512.png
--------------------------------------------------------------------------------
/src/frontend/public/service-worker.js:
--------------------------------------------------------------------------------
1 | self.addEventListener('fetch',function() {
2 | // this is required to meet the requirements for an installable pwa
3 | // it allows the browser to ask the user if they want to install to their homescreen
4 | });
5 |
--------------------------------------------------------------------------------
/src/frontend/style.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import formsPlugin from '@tailwindcss/forms';
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | darkMode: 'selector',
6 | content: [
7 | "./src/frontend/index.html",
8 | "./src/**/*.{vue,js,ts,jsx,tsx,html}",
9 | ],
10 | theme: {
11 | extend: {},
12 | },
13 | plugins: [
14 | formsPlugin,
15 | ],
16 | }
17 |
18 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import vue from '@vitejs/plugin-vue';
3 | import vuetify from 'vite-plugin-vuetify';
4 |
5 | export default {
6 |
7 | plugins: [
8 | vue(),
9 | vuetify(),
10 | ],
11 |
12 | // vite app is loaded from /src/frontend
13 | root: path.join(__dirname, "src", "frontend"),
14 |
15 | build: {
16 |
17 | // we want to compile vite app to /public which is bundled and served by the python executable
18 | outDir: path.join(__dirname, "public"),
19 | emptyOutDir: true,
20 |
21 | rollupOptions: {
22 | input: {
23 |
24 | // we want to use /src/frontend/index.html as the entrypoint for this vite app
25 | app: path.join(__dirname, "src", "frontend", "index.html"),
26 |
27 | // we want to use /src/frontend/call.html as the entrypoint for the phone call app
28 | call: path.join(__dirname, "src", "frontend", "call.html"),
29 |
30 | },
31 | },
32 | },
33 |
34 | }
35 |
--------------------------------------------------------------------------------