├── .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 |
26 | 27 | 28 | 29 | 30 |
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 | 26 | 27 | 92 | -------------------------------------------------------------------------------- /src/frontend/components/DropDownMenu.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 93 | -------------------------------------------------------------------------------- /src/frontend/components/DropDownMenuItem.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/frontend/components/IconButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/frontend/components/LxmfUserIcon.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /src/frontend/components/MaterialDesignIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 35 | -------------------------------------------------------------------------------- /src/frontend/components/SidebarLink.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 44 | -------------------------------------------------------------------------------- /src/frontend/components/about/AboutPage.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 177 | -------------------------------------------------------------------------------- /src/frontend/components/forms/FormLabel.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/frontend/components/forms/FormSubLabel.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/frontend/components/interfaces/ExpandingSection.vue: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /src/frontend/components/messages/AddAudioButton.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 83 | -------------------------------------------------------------------------------- /src/frontend/components/messages/AddImageButton.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 136 | -------------------------------------------------------------------------------- /src/frontend/components/messages/ConversationDropDownMenu.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 143 | -------------------------------------------------------------------------------- /src/frontend/components/messages/MessagesPage.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 260 | -------------------------------------------------------------------------------- /src/frontend/components/messages/SendMessageButton.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 71 | -------------------------------------------------------------------------------- /src/frontend/components/network-visualiser/NetworkVisualiserPage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 113 | -------------------------------------------------------------------------------- /src/frontend/components/ping/PingPage.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 224 | -------------------------------------------------------------------------------- /src/frontend/components/profile/ProfileIconPage.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 193 | -------------------------------------------------------------------------------- /src/frontend/components/propagation-nodes/PropagationNodesPage.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 173 | -------------------------------------------------------------------------------- /src/frontend/components/tools/ToolsPage.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------