This commit is contained in:
2026-01-01 15:25:23 -06:00
parent 9df89fe862
commit 23dbe7b789
52 changed files with 4540 additions and 2999 deletions

View File

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

View File

@@ -1,343 +1,343 @@
name: Build and Release name: Build and Release
on: on:
push: push:
tags: tags:
- "*" - "*"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
build_windows: build_windows:
description: 'Build Windows' description: "Build Windows"
required: false required: false
default: 'true' default: "true"
type: boolean type: boolean
build_mac: build_mac:
description: 'Build macOS' description: "Build macOS"
required: false required: false
default: 'true' default: "true"
type: boolean type: boolean
build_linux: build_linux:
description: 'Build Linux' description: "Build Linux"
required: false required: false
default: 'true' default: "true"
type: boolean type: boolean
build_docker: build_docker:
description: 'Build Docker' description: "Build Docker"
required: false required: false
default: 'true' default: "true"
type: boolean type: boolean
permissions: permissions:
contents: read contents: read
jobs: jobs:
build_frontend: build_frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1 uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
- name: Install NodeJS - name: Install NodeJS
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1 uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
with: with:
node-version: 22 node-version: 22
- name: Install Python - name: Install Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: "3.12" python-version: "3.12"
- name: Sync versions - name: Sync versions
run: python scripts/sync_version.py run: python scripts/sync_version.py
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 9
- name: Install NodeJS Deps - name: Install NodeJS Deps
run: pnpm install run: pnpm install
- name: Build Frontend - name: Build Frontend
run: pnpm run build-frontend run: pnpm run build-frontend
- name: Upload frontend artifact - name: Upload frontend artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: frontend-build name: frontend-build
path: meshchatx/public path: meshchatx/public
if-no-files-found: error if-no-files-found: error
build_desktop: build_desktop:
name: Build Desktop (${{ matrix.name }}) name: Build Desktop (${{ matrix.name }})
needs: build_frontend needs: build_frontend
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- name: windows - name: windows
os: windows-latest os: windows-latest
node: 22 node: 22
python: "3.13" python: "3.13"
release_artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe" release_artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
build_input: build_windows build_input: build_windows
dist_script: dist-prebuilt dist_script: dist-prebuilt
variant: standard variant: standard
electron_version: "39.2.4" electron_version: "39.2.4"
- name: mac - name: mac
os: macos-14 os: macos-14
node: 22 node: 22
python: "3.13" python: "3.13"
release_artifacts: "dist/*-mac-*.dmg" release_artifacts: "dist/*-mac-*.dmg"
build_input: build_mac build_input: build_mac
dist_script: dist:mac-universal dist_script: dist:mac-universal
variant: standard variant: standard
electron_version: "39.2.4" electron_version: "39.2.4"
- name: linux - name: linux
os: ubuntu-latest os: ubuntu-latest
node: 22 node: 22
python: "3.13" python: "3.13"
release_artifacts: "dist/*-linux.AppImage,dist/*-linux.deb,python-dist/*.whl" release_artifacts: "dist/*-linux.AppImage,dist/*-linux.deb,python-dist/*.whl"
build_input: build_linux build_input: build_linux
dist_script: dist-prebuilt dist_script: dist-prebuilt
variant: standard variant: standard
electron_version: "39.2.4" electron_version: "39.2.4"
- name: windows-legacy - name: windows-legacy
os: windows-latest os: windows-latest
node: 18 node: 18
python: "3.11" python: "3.11"
release_artifacts: "dist/*-win-installer*.exe,dist/*-win-portable*.exe" release_artifacts: "dist/*-win-installer*.exe,dist/*-win-portable*.exe"
build_input: build_windows build_input: build_windows
dist_script: dist-prebuilt dist_script: dist-prebuilt
variant: legacy variant: legacy
electron_version: "30.0.8" electron_version: "30.0.8"
- name: linux-legacy - name: linux-legacy
os: ubuntu-latest os: ubuntu-latest
node: 18 node: 18
python: "3.11" python: "3.11"
release_artifacts: "dist/*-linux*.AppImage,dist/*-linux*.deb,python-dist/*.whl" release_artifacts: "dist/*-linux*.AppImage,dist/*-linux*.deb,python-dist/*.whl"
build_input: build_linux build_input: build_linux
dist_script: dist-prebuilt dist_script: dist-prebuilt
variant: legacy variant: legacy
electron_version: "30.0.8" electron_version: "30.0.8"
permissions: permissions:
contents: write contents: write
steps: steps:
- name: Clone Repo - name: Clone Repo
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1 uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
- name: Set legacy Electron version - name: Set legacy Electron version
if: | if: |
matrix.variant == 'legacy' && matrix.variant == 'legacy' &&
(github.event_name == 'push' || (github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
shell: bash shell: bash
run: | run: |
node -e "const fs=require('fs');const pkg=require('./package.json');pkg.devDependencies.electron='${{ matrix.electron_version }}';fs.writeFileSync('package.json', JSON.stringify(pkg,null,2));" node -e "const fs=require('fs');const pkg=require('./package.json');pkg.devDependencies.electron='${{ matrix.electron_version }}';fs.writeFileSync('package.json', JSON.stringify(pkg,null,2));"
if [ -f pnpm-lock.yaml ]; then rm pnpm-lock.yaml; fi if [ -f pnpm-lock.yaml ]; then rm pnpm-lock.yaml; fi
- name: Install NodeJS - name: Install NodeJS
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1 uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install Python - name: Install Python
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
- name: Install Poetry - name: Install Poetry
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: python -m pip install --upgrade pip poetry run: python -m pip install --upgrade pip poetry
- name: Sync versions - name: Sync versions
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: python scripts/sync_version.py run: python scripts/sync_version.py
- name: Install Python Deps - name: Install Python Deps
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: python -m poetry install run: python -m poetry install
- name: Install pnpm - name: Install pnpm
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 9
- name: Install NodeJS Deps - name: Install NodeJS Deps
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: pnpm install run: pnpm install
- name: Prepare frontend directory - name: Prepare frontend directory
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: python scripts/prepare_frontend_dir.py run: python scripts/prepare_frontend_dir.py
- name: Download frontend artifact - name: Download frontend artifact
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
name: frontend-build name: frontend-build
path: meshchatx/public path: meshchatx/public
- name: Install patchelf - name: Install patchelf
if: | if: |
startsWith(matrix.name, 'linux') && startsWith(matrix.name, 'linux') &&
(github.event_name == 'push' || (github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
run: sudo apt-get update && sudo apt-get install -y patchelf run: sudo apt-get update && sudo apt-get install -y patchelf
- name: Build Python wheel - name: Build Python wheel
if: | if: |
startsWith(matrix.name, 'linux') && startsWith(matrix.name, 'linux') &&
(github.event_name == 'push' || (github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
run: | run: |
python -m poetry build -f wheel python -m poetry build -f wheel
mkdir -p python-dist mkdir -p python-dist
mv dist/*.whl python-dist/ mv dist/*.whl python-dist/
rm -rf dist rm -rf dist
- name: Build Electron App (Universal) - name: Build Electron App (Universal)
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: pnpm run ${{ matrix.dist_script }} run: pnpm run ${{ matrix.dist_script }}
- name: Rename artifacts for legacy build - name: Rename artifacts for legacy build
if: | if: |
matrix.variant == 'legacy' && matrix.variant == 'legacy' &&
(github.event_name == 'push' || (github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
run: ./scripts/rename_legacy_artifacts.sh run: ./scripts/rename_legacy_artifacts.sh
- name: Upload build artifacts - name: Upload build artifacts
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true) (github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: build-${{ matrix.name }} name: build-${{ matrix.name }}
path: | path: |
dist/*-win-installer*.exe dist/*-win-installer*.exe
dist/*-win-portable*.exe dist/*-win-portable*.exe
dist/*-mac-*.dmg dist/*-mac-*.dmg
dist/*-linux*.AppImage dist/*-linux*.AppImage
dist/*-linux*.deb dist/*-linux*.deb
python-dist/*.whl python-dist/*.whl
if-no-files-found: ignore if-no-files-found: ignore
create_release: create_release:
name: Create Release name: Create Release
needs: build_desktop needs: build_desktop
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'push' if: github.event_name == 'push'
permissions: permissions:
contents: write contents: write
steps: steps:
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
path: artifacts path: artifacts
- name: Display structure of downloaded files - name: Display structure of downloaded files
run: ls -R artifacts run: ls -R artifacts
- name: Prepare release assets - name: Prepare release assets
run: | run: |
mkdir -p release-assets mkdir -p release-assets
find artifacts -type f \( -name "*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.whl" \) -exec cp {} release-assets/ \; find artifacts -type f \( -name "*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.whl" \) -exec cp {} release-assets/ \;
ls -lh release-assets/ ls -lh release-assets/
- name: Generate SHA256 checksums - name: Generate SHA256 checksums
run: | run: |
cd release-assets cd release-assets
echo "## SHA256 Checksums" > release-body.md echo "## SHA256 Checksums" > release-body.md
echo "" >> release-body.md echo "" >> release-body.md
for file in *.exe *.dmg *.AppImage *.deb *.whl; do for file in *.exe *.dmg *.AppImage *.deb *.whl; do
if [ -f "$file" ]; then if [ -f "$file" ]; then
sha256sum "$file" | tee "${file}.sha256" sha256sum "$file" | tee "${file}.sha256"
echo "\`$(cat "${file}.sha256")\`" >> release-body.md echo "\`$(cat "${file}.sha256")\`" >> release-body.md
fi fi
done done
echo "" >> release-body.md
echo "Individual \`.sha256\` files are included for each artifact." >> release-body.md
cat release-body.md
echo ""
echo "Generated .sha256 files:"
ls -1 *.sha256 2>/dev/null || echo "No .sha256 files found"
- name: Create Release echo "" >> release-body.md
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1 echo "Individual \`.sha256\` files are included for each artifact." >> release-body.md
with:
draft: true
artifacts: "release-assets/*"
bodyFile: "release-assets/release-body.md"
build_docker: cat release-body.md
runs-on: ubuntu-latest echo ""
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_docker == 'true') echo "Generated .sha256 files:"
permissions: ls -1 *.sha256 2>/dev/null || echo "No .sha256 files found"
packages: write
contents: read
steps:
- name: Clone Repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set lowercase repository owner - name: Create Release
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
with:
draft: true
artifacts: "release-assets/*"
bodyFile: "release-assets/release-body.md"
- name: Set up QEMU build_docker:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 runs-on: ubuntu-latest
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_docker == 'true')
permissions:
packages: write
contents: read
steps:
- name: Clone Repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set up Docker Buildx - name: Set lowercase repository owner
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
- name: Log in to the GitHub Container registry - name: Set up QEMU
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images - name: Set up Docker Buildx
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
with:
context: . - name: Log in to the GitHub Container registry
platforms: linux/amd64,linux/arm64 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
push: true with:
tags: >- registry: ghcr.io
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:latest, username: ${{ github.actor }}
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:${{ github.ref_name }} password: ${{ secrets.GITHUB_TOKEN }}
labels: >-
org.opencontainers.image.title=Reticulum MeshChatX, - name: Build and push Docker images
org.opencontainers.image.description=Docker image for Reticulum MeshChatX, uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchatx/ with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: >-
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:latest,
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:${{ github.ref_name }}
labels: >-
org.opencontainers.image.title=Reticulum MeshChatX,
org.opencontainers.image.description=Docker image for Reticulum MeshChatX,
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchatx/

View File

@@ -1,22 +1,22 @@
name: 'Dependency review' name: "Dependency review"
on: on:
pull_request: pull_request:
branches: [ "master" ] branches: ["master"]
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
jobs: jobs:
dependency-review: dependency-review:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 'Checkout repository' - name: "Checkout repository"
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: 'Dependency Review' - name: "Dependency Review"
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4 uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4
with: with:
comment-summary-in-pr: always comment-summary-in-pr: always

View File

@@ -1,9 +1,8 @@
{ {
"semi": true, "semi": true,
"tabWidth": 4, "tabWidth": 4,
"singleQuote": false, "singleQuote": false,
"printWidth": 120, "printWidth": 120,
"trailingComma": "es5", "trailingComma": "es5",
"endOfLine": "auto" "endOfLine": "auto"
} }

View File

@@ -2,11 +2,13 @@
A heavily customized and updated fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat). A heavily customized and updated fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat).
This project is seperate from the original Reticulum MeshChat project, and is not affiliated with the original project.
## Features of this Fork ## Features of this Fork
### Major ### Major
- Full LXST support. - Full LXST support w/ custom voicemail support.
- Map (w/ MBTiles support for offline) - Map (w/ MBTiles support for offline)
- Security improvements - Security improvements
- Custom UI/UX - Custom UI/UX
@@ -27,12 +29,12 @@ A heavily customized and updated fork of [Reticulum MeshChat](https://github.com
- [ ] Spam filter (based on keywords) - [ ] Spam filter (based on keywords)
- [ ] Multi-identity support. - [ ] Multi-identity support.
- [ ] TAK tool/integration - [ ] TAK tool/integration
- [ ] RNS Tunnel - tunnel your regular services over RNS to another MeshchatX user. - [ ] RNS Tunnel - tunnel your regular services over RNS to another MeshchatX user.
- [ ] RNS Filesync - P2P file sync - [ ] RNS Filesync - P2P file sync
## Usage ## Usage
Check [releases](https://git.quad4.io/RNS-Things/reticulum-meshchatX/releases) for pre-built binaries or appimages. Check [releases](https://git.quad4.io/Ivan/MeshChatX/releases) for pre-built binaries or appimages.
## Building ## Building
@@ -47,28 +49,29 @@ You can run `task run` or `task develop` (a thin alias) to start the backend + f
### Available Tasks ### Available Tasks
| Task | Description | | Task | Description |
|------|-------------| | ---------------------------- | ------------------------------------------------------------------------------- |
| `task install` | Install all dependencies (syncs version, installs node modules and python deps) | | `task install` | Install all dependencies (syncs version, installs node modules and python deps) |
| `task node_modules` | Install Node.js dependencies only | | `task node_modules` | Install Node.js dependencies only |
| `task python` | Install Python dependencies using Poetry only | | `task python` | Install Python dependencies using Poetry only |
| `task sync-version` | Sync version numbers across project files | | `task sync-version` | Sync version numbers across project files |
| `task run` | Run the application | | `task run` | Run the application |
| `task develop` | Run the application in development mode (alias for `run`) | | `task develop` | Run the application in development mode (alias for `run`) |
| `task build` | Build the application (frontend and backend) | | `task build` | Build the application (frontend and backend) |
| `task build-frontend` | Build only the frontend | | `task build-frontend` | Build only the frontend |
| `task clean` | Clean build artifacts and dependencies | | `task clean` | Clean build artifacts and dependencies |
| `task wheel` | Build Python wheel package (outputs to `python-dist/`) | | `task wheel` | Build Python wheel package (outputs to `python-dist/`) |
| `task build-appimage` | Build Linux AppImage | | `task build-appimage` | Build Linux AppImage |
| `task build-exe` | Build Windows portable executable | | `task build-exe` | Build Windows portable executable |
| `task dist` | Build distribution (defaults to AppImage) | | `task dist` | Build distribution (defaults to AppImage) |
| `task electron-legacy` | Install legacy Electron version | | `task electron-legacy` | Install legacy Electron version |
| `task build-appimage-legacy` | Build Linux AppImage with legacy Electron version | | `task build-appimage-legacy` | Build Linux AppImage with legacy Electron version |
| `task build-exe-legacy` | Build Windows portable executable with legacy Electron version | | `task build-exe-legacy` | Build Windows portable executable with legacy Electron version |
| `task build-docker` | Build Docker image using buildx | | `task build-docker` | Build Docker image using buildx |
| `task run-docker` | Run Docker container using docker-compose | | `task run-docker` | Run Docker container using docker-compose |
All tasks support environment variable overrides. For example: All tasks support environment variable overrides. For example:
- `PYTHON=python3.12 task install` - `PYTHON=python3.12 task install`
- `DOCKER_PLATFORMS=linux/amd64,linux/arm64 task build-docker` - `DOCKER_PLATFORMS=linux/amd64,linux/arm64 task build-docker`
@@ -130,11 +133,12 @@ The `cx_setup.py` script uses cx_Freeze for creating standalone executables (App
## Internationalization (i18n) ## Internationalization (i18n)
Multi-language support is in progress. We use `vue-i18n` for the frontend. Multi-language support is in progress. We use `vue-i18n` for the frontend.
Translation files are located in `meshchatx/src/frontend/locales/`. Translation files are located in `meshchatx/src/frontend/locales/`.
Currently supported languages: Currently supported languages:
- English (Primary) - English (Primary)
- Russian - Russian
- German - German

View File

@@ -1,153 +1,153 @@
version: '3' version: "3"
vars: vars:
PYTHON: PYTHON:
sh: echo "${PYTHON:-python}" sh: echo "${PYTHON:-python}"
NPM: NPM:
sh: echo "${NPM:-pnpm}" sh: echo "${NPM:-pnpm}"
LEGACY_ELECTRON_VERSION: LEGACY_ELECTRON_VERSION:
sh: echo "${LEGACY_ELECTRON_VERSION:-30.0.8}" sh: echo "${LEGACY_ELECTRON_VERSION:-30.0.8}"
DOCKER_COMPOSE_CMD: DOCKER_COMPOSE_CMD:
sh: echo "${DOCKER_COMPOSE_CMD:-docker compose}" sh: echo "${DOCKER_COMPOSE_CMD:-docker compose}"
DOCKER_COMPOSE_FILE: DOCKER_COMPOSE_FILE:
sh: echo "${DOCKER_COMPOSE_FILE:-docker-compose.yml}" sh: echo "${DOCKER_COMPOSE_FILE:-docker-compose.yml}"
DOCKER_IMAGE: DOCKER_IMAGE:
sh: echo "${DOCKER_IMAGE:-reticulum-meshchatx:local}" sh: echo "${DOCKER_IMAGE:-reticulum-meshchatx:local}"
DOCKER_BUILDER: DOCKER_BUILDER:
sh: echo "${DOCKER_BUILDER:-meshchatx-builder}" sh: echo "${DOCKER_BUILDER:-meshchatx-builder}"
DOCKER_PLATFORMS: DOCKER_PLATFORMS:
sh: echo "${DOCKER_PLATFORMS:-linux/amd64}" sh: echo "${DOCKER_PLATFORMS:-linux/amd64}"
DOCKER_BUILD_FLAGS: DOCKER_BUILD_FLAGS:
sh: echo "${DOCKER_BUILD_FLAGS:---load}" sh: echo "${DOCKER_BUILD_FLAGS:---load}"
DOCKER_BUILD_ARGS: DOCKER_BUILD_ARGS:
sh: echo "${DOCKER_BUILD_ARGS:-}" sh: echo "${DOCKER_BUILD_ARGS:-}"
DOCKER_CONTEXT: DOCKER_CONTEXT:
sh: echo "${DOCKER_CONTEXT:-.}" sh: echo "${DOCKER_CONTEXT:-.}"
DOCKERFILE: DOCKERFILE:
sh: echo "${DOCKERFILE:-Dockerfile}" sh: echo "${DOCKERFILE:-Dockerfile}"
tasks: tasks:
default: default:
desc: Show available tasks desc: Show available tasks
cmds: cmds:
- task --list - task --list
install: install:
desc: Install all dependencies (syncs version, installs node modules and python deps) desc: Install all dependencies (syncs version, installs node modules and python deps)
deps: [sync-version, node_modules, python] deps: [sync-version, node_modules, python]
node_modules: node_modules:
desc: Install Node.js dependencies desc: Install Node.js dependencies
cmds: cmds:
- '{{.NPM}} install' - "{{.NPM}} install"
python: python:
desc: Install Python dependencies using Poetry desc: Install Python dependencies using Poetry
cmds: cmds:
- '{{.PYTHON}} -m poetry install' - "{{.PYTHON}} -m poetry install"
run: run:
desc: Run the application desc: Run the application
deps: [install] deps: [install]
cmds: cmds:
- '{{.PYTHON}} -m poetry run meshchat' - "{{.PYTHON}} -m poetry run meshchat"
develop: develop:
desc: Run the application in development mode desc: Run the application in development mode
cmds: cmds:
- task: run - task: run
build: build:
desc: Build the application (frontend and backend) desc: Build the application (frontend and backend)
deps: [install] deps: [install]
cmds: cmds:
- '{{.NPM}} run build' - "{{.NPM}} run build"
build-frontend: build-frontend:
desc: Build only the frontend desc: Build only the frontend
deps: [node_modules] deps: [node_modules]
cmds: cmds:
- '{{.NPM}} run build-frontend' - "{{.NPM}} run build-frontend"
wheel: wheel:
desc: Build Python wheel package desc: Build Python wheel package
deps: [install] deps: [install]
cmds: cmds:
- '{{.PYTHON}} -m poetry build -f wheel' - "{{.PYTHON}} -m poetry build -f wheel"
- '{{.PYTHON}} scripts/move_wheels.py' - "{{.PYTHON}} scripts/move_wheels.py"
build-appimage: build-appimage:
desc: Build Linux AppImage desc: Build Linux AppImage
deps: [build] deps: [build]
cmds: cmds:
- '{{.NPM}} run electron-postinstall' - "{{.NPM}} run electron-postinstall"
- '{{.NPM}} run dist -- --linux AppImage' - "{{.NPM}} run dist -- --linux AppImage"
build-exe: build-exe:
desc: Build Windows portable executable desc: Build Windows portable executable
deps: [build] deps: [build]
cmds: cmds:
- '{{.NPM}} run electron-postinstall' - "{{.NPM}} run electron-postinstall"
- '{{.NPM}} run dist -- --win portable' - "{{.NPM}} run dist -- --win portable"
dist: dist:
desc: Build distribution (defaults to AppImage) desc: Build distribution (defaults to AppImage)
cmds: cmds:
- task: build-appimage - task: build-appimage
electron-legacy: electron-legacy:
desc: Install legacy Electron version desc: Install legacy Electron version
cmds: cmds:
- '{{.NPM}} install --no-save electron@{{.LEGACY_ELECTRON_VERSION}}' - "{{.NPM}} install --no-save electron@{{.LEGACY_ELECTRON_VERSION}}"
build-appimage-legacy: build-appimage-legacy:
desc: Build Linux AppImage with legacy Electron version desc: Build Linux AppImage with legacy Electron version
deps: [build, electron-legacy] deps: [build, electron-legacy]
cmds: cmds:
- '{{.NPM}} run electron-postinstall' - "{{.NPM}} run electron-postinstall"
- '{{.NPM}} run dist -- --linux AppImage' - "{{.NPM}} run dist -- --linux AppImage"
- './scripts/rename_legacy_artifacts.sh' - "./scripts/rename_legacy_artifacts.sh"
build-exe-legacy: build-exe-legacy:
desc: Build Windows portable executable with legacy Electron version desc: Build Windows portable executable with legacy Electron version
deps: [build, electron-legacy] deps: [build, electron-legacy]
cmds: cmds:
- '{{.NPM}} run electron-postinstall' - "{{.NPM}} run electron-postinstall"
- '{{.NPM}} run dist -- --win portable' - "{{.NPM}} run dist -- --win portable"
- './scripts/rename_legacy_artifacts.sh' - "./scripts/rename_legacy_artifacts.sh"
clean: clean:
desc: Clean build artifacts and dependencies desc: Clean build artifacts and dependencies
cmds: cmds:
- rm -rf node_modules - rm -rf node_modules
- rm -rf build - rm -rf build
- rm -rf dist - rm -rf dist
- rm -rf python-dist - rm -rf python-dist
- rm -rf meshchatx/public - rm -rf meshchatx/public
sync-version: sync-version:
desc: Sync version numbers across project files desc: Sync version numbers across project files
cmds: cmds:
- '{{.PYTHON}} scripts/sync_version.py' - "{{.PYTHON}} scripts/sync_version.py"
build-docker: build-docker:
desc: Build Docker image using buildx desc: Build Docker image using buildx
cmds: cmds:
- | - |
if ! docker buildx inspect {{.DOCKER_BUILDER}} >/dev/null 2>&1; then if ! docker buildx inspect {{.DOCKER_BUILDER}} >/dev/null 2>&1; then
docker buildx create --name {{.DOCKER_BUILDER}} --use >/dev/null docker buildx create --name {{.DOCKER_BUILDER}} --use >/dev/null
else else
docker buildx use {{.DOCKER_BUILDER}} docker buildx use {{.DOCKER_BUILDER}}
fi fi
- | - |
docker buildx build --builder {{.DOCKER_BUILDER}} --platform {{.DOCKER_PLATFORMS}} \ docker buildx build --builder {{.DOCKER_BUILDER}} --platform {{.DOCKER_PLATFORMS}} \
{{.DOCKER_BUILD_FLAGS}} \ {{.DOCKER_BUILD_FLAGS}} \
-t {{.DOCKER_IMAGE}} \ -t {{.DOCKER_IMAGE}} \
{{.DOCKER_BUILD_ARGS}} \ {{.DOCKER_BUILD_ARGS}} \
-f {{.DOCKERFILE}} \ -f {{.DOCKERFILE}} \
{{.DOCKER_CONTEXT}} {{.DOCKER_CONTEXT}}
run-docker: run-docker:
desc: Run Docker container using docker-compose desc: Run Docker container using docker-compose
cmds: cmds:
- 'MESHCHAT_IMAGE="{{.DOCKER_IMAGE}}" {{.DOCKER_COMPOSE_CMD}} -f {{.DOCKER_COMPOSE_FILE}} up --remove-orphans --pull never reticulum-meshchatx' - 'MESHCHAT_IMAGE="{{.DOCKER_IMAGE}}" {{.DOCKER_COMPOSE_CMD}} -f {{.DOCKER_COMPOSE_FILE}} up --remove-orphans --pull never reticulum-meshchatx'

View File

@@ -1,17 +1,17 @@
services: services:
reticulum-meshchatx: reticulum-meshchatx:
container_name: reticulum-meshchatx container_name: reticulum-meshchatx
image: ${MESHCHAT_IMAGE:-ghcr.io/sudo-ivan/reticulum-meshchatx:latest} image: ${MESHCHAT_IMAGE:-ghcr.io/sudo-ivan/reticulum-meshchatx:latest}
pull_policy: always pull_policy: always
restart: unless-stopped restart: unless-stopped
# Make the meshchat web interface accessible from the host on port 8000 # Make the meshchat web interface accessible from the host on port 8000
ports: ports:
- 127.0.0.1:8000:8000 - 127.0.0.1:8000:8000
volumes: volumes:
- meshchat-config:/config - meshchat-config:/config
# Uncomment if you have a USB device connected, such as an RNode # Uncomment if you have a USB device connected, such as an RNode
# devices: # devices:
# - /dev/ttyUSB0:/dev/ttyUSB0 # - /dev/ttyUSB0:/dev/ttyUSB0
volumes: volumes:
meshchat-config: meshchat-config:

View File

@@ -96,4 +96,4 @@ sudo systemctl status reticulum-meshchat.service
You should now be able to access MeshChat via your Pi's IP address. You should now be able to access MeshChat via your Pi's IP address.
> Note: Don't forget to include the default port `8000` > Note: Don't forget to include the default port `8000`

View File

@@ -1,161 +1,205 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark" />
<title>MeshChatX</title> <title>MeshChatX</title>
<script src="./assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script> <script src="./assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
</head> </head>
<body class="min-h-screen bg-slate-100 text-gray-900 antialiased dark:bg-zinc-950 dark:text-zinc-50 transition-colors"> <body
class="min-h-screen bg-slate-100 text-gray-900 antialiased dark:bg-zinc-950 dark:text-zinc-50 transition-colors"
<div class="absolute inset-0 -z-10 overflow-hidden"> >
<div class="absolute -left-32 -top-40 h-80 w-80 rounded-full bg-gradient-to-br from-blue-500/30 via-indigo-500/20 to-purple-500/30 blur-3xl dark:from-blue-600/25 dark:via-indigo-600/25 dark:to-purple-600/25"></div> <div class="absolute inset-0 -z-10 overflow-hidden">
<div class="absolute -right-24 top-20 h-64 w-64 rounded-full bg-gradient-to-br from-emerald-400/30 via-cyan-500/20 to-blue-500/30 blur-3xl dark:from-emerald-500/25 dark:via-cyan-500/25 dark:to-blue-500/25"></div> <div
</div> class="absolute -left-32 -top-40 h-80 w-80 rounded-full bg-gradient-to-br from-blue-500/30 via-indigo-500/20 to-purple-500/30 blur-3xl dark:from-blue-600/25 dark:via-indigo-600/25 dark:to-purple-600/25"
></div>
<main class="relative flex min-h-screen items-center justify-center px-6 py-10"> <div
<div class="w-full max-w-xl"> class="absolute -right-24 top-20 h-64 w-64 rounded-full bg-gradient-to-br from-emerald-400/30 via-cyan-500/20 to-blue-500/30 blur-3xl dark:from-emerald-500/25 dark:via-cyan-500/25 dark:to-blue-500/25"
<div class="rounded-3xl border border-slate-200/80 bg-white/80 shadow-2xl backdrop-blur-xl ring-1 ring-white/60 dark:border-zinc-800/70 dark:bg-zinc-900/70 dark:ring-zinc-800/70 transition-colors"> ></div>
<div class="p-8 space-y-6">
<div class="flex items-center gap-4">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500 via-indigo-500 to-purple-500 shadow-lg ring-4 ring-white/60 dark:ring-zinc-800/70">
<img class="h-10 w-10 object-contain" src="./assets/images/logo.png" alt="MeshChatX logo">
</div>
<div class="space-y-1">
<p class="text-xs uppercase tracking-[0.2em] text-blue-600 dark:text-blue-300">MeshChatX</p>
<div class="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">MeshChatX</div>
<div class="text-sm text-gray-600 dark:text-gray-300">Custom fork by Sudo-Ivan</div>
</div>
</div>
<div class="flex items-center justify-between rounded-2xl border border-dashed border-slate-200/90 bg-slate-50/70 px-4 py-3 text-sm text-gray-700 dark:border-zinc-800/80 dark:bg-zinc-900/70 dark:text-gray-200 transition-colors">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-blue-500 animate-pulse"></span>
<span>Preparing your node</span>
</div>
<div class="inline-flex items-center gap-2 rounded-full bg-blue-100/80 px-3 py-1 text-xs font-semibold text-blue-700 shadow-sm dark:bg-blue-900/50 dark:text-blue-200">
<span class="h-2 w-2 rounded-full bg-blue-500"></span>
<span id="status-text">Starting services</span>
</div>
</div>
<div class="flex items-center gap-4">
<div class="relative inline-flex h-14 w-14 items-center justify-center">
<span class="absolute inset-0 rounded-full border-4 border-blue-500/25 dark:border-blue-500/20"></span>
<span class="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-blue-500 dark:border-t-blue-400"></span>
<span class="absolute inset-2 rounded-full bg-blue-500/10 dark:bg-blue-500/15"></span>
</div>
<div class="flex-1 space-y-1">
<div class="text-base font-medium text-gray-900 dark:text-white">Loading services</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Waiting for the MeshChatX API to come online.</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="rounded-2xl border border-slate-200/90 bg-white/70 p-4 dark:border-zinc-800/80 dark:bg-zinc-900/70 transition-colors">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Version</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white" id="app-version">v0.0.0</div>
</div>
<div class="rounded-2xl border border-slate-200/90 bg-white/70 p-4 text-right dark:border-zinc-800/80 dark:bg-zinc-900/70 transition-colors">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Status</div>
<div class="mt-1 text-lg font-semibold text-emerald-600 dark:text-emerald-300" id="status-badge">Booting</div>
</div>
</div>
</div>
</div> </div>
</div>
</main>
<script> <main class="relative flex min-h-screen items-center justify-center px-6 py-10">
const statusText = document.getElementById("status-text"); <div class="w-full max-w-xl">
const statusBadge = document.getElementById("status-badge"); <div
class="rounded-3xl border border-slate-200/80 bg-white/80 shadow-2xl backdrop-blur-xl ring-1 ring-white/60 dark:border-zinc-800/70 dark:bg-zinc-900/70 dark:ring-zinc-800/70 transition-colors"
>
<div class="p-8 space-y-6">
<div class="flex items-center gap-4">
<div
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500 via-indigo-500 to-purple-500 shadow-lg ring-4 ring-white/60 dark:ring-zinc-800/70"
>
<img
class="h-10 w-10 object-contain"
src="./assets/images/logo.png"
alt="MeshChatX logo"
/>
</div>
<div class="space-y-1">
<p class="text-xs uppercase tracking-[0.2em] text-blue-600 dark:text-blue-300">
MeshChatX
</p>
<div class="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
MeshChatX
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">Custom fork by Sudo-Ivan</div>
</div>
</div>
applyTheme(detectPreferredTheme()); <div
showAppVersion(); class="flex items-center justify-between rounded-2xl border border-dashed border-slate-200/90 bg-slate-50/70 px-4 py-3 text-sm text-gray-700 dark:border-zinc-800/80 dark:bg-zinc-900/70 dark:text-gray-200 transition-colors"
check(); >
listenForSystemThemeChanges(); <div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-blue-500 animate-pulse"></span>
<span>Preparing your node</span>
</div>
<div
class="inline-flex items-center gap-2 rounded-full bg-blue-100/80 px-3 py-1 text-xs font-semibold text-blue-700 shadow-sm dark:bg-blue-900/50 dark:text-blue-200"
>
<span class="h-2 w-2 rounded-full bg-blue-500"></span>
<span id="status-text">Starting services</span>
</div>
</div>
async function showAppVersion() { <div class="flex items-center gap-4">
const appVersion = await window.electron.appVersion(); <div class="relative inline-flex h-14 w-14 items-center justify-center">
document.getElementById("app-version").innerText = "v" + appVersion; <span
} class="absolute inset-0 rounded-full border-4 border-blue-500/25 dark:border-blue-500/20"
></span>
<span
class="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-blue-500 dark:border-t-blue-400"
></span>
<span class="absolute inset-2 rounded-full bg-blue-500/10 dark:bg-blue-500/15"></span>
</div>
<div class="flex-1 space-y-1">
<div class="text-base font-medium text-gray-900 dark:text-white">Loading services</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Waiting for the MeshChatX API to come online.
</div>
</div>
</div>
function detectPreferredTheme() { <div class="grid grid-cols-2 gap-4 text-sm">
try { <div
const storedTheme = localStorage.getItem("meshchat.theme") || localStorage.getItem("meshchatx.theme"); class="rounded-2xl border border-slate-200/90 bg-white/70 p-4 dark:border-zinc-800/80 dark:bg-zinc-900/70 transition-colors"
if (storedTheme === "dark" || storedTheme === "light") { >
return storedTheme; <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Version
</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white" id="app-version">
v0.0.0
</div>
</div>
<div
class="rounded-2xl border border-slate-200/90 bg-white/70 p-4 text-right dark:border-zinc-800/80 dark:bg-zinc-900/70 transition-colors"
>
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Status
</div>
<div
class="mt-1 text-lg font-semibold text-emerald-600 dark:text-emerald-300"
id="status-badge"
>
Booting
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script>
const statusText = document.getElementById("status-text");
const statusBadge = document.getElementById("status-badge");
applyTheme(detectPreferredTheme());
showAppVersion();
check();
listenForSystemThemeChanges();
async function showAppVersion() {
const appVersion = await window.electron.appVersion();
document.getElementById("app-version").innerText = "v" + appVersion;
} }
} catch (e) {}
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) { function detectPreferredTheme() {
const isDark = theme === "dark"; try {
document.documentElement.classList.toggle("dark", isDark); const storedTheme =
document.body.dataset.theme = isDark ? "dark" : "light"; localStorage.getItem("meshchat.theme") || localStorage.getItem("meshchatx.theme");
} if (storedTheme === "dark" || storedTheme === "light") {
return storedTheme;
}
} catch (e) {}
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function listenForSystemThemeChanges() { function applyTheme(theme) {
if (!window.matchMedia) { const isDark = theme === "dark";
return; document.documentElement.classList.toggle("dark", isDark);
} document.body.dataset.theme = isDark ? "dark" : "light";
const media = window.matchMedia("(prefers-color-scheme: dark)"); }
media.addEventListener("change", (event) => {
applyTheme(event.matches ? "dark" : "light");
});
}
let detectedProtocol = "http"; function listenForSystemThemeChanges() {
if (!window.matchMedia) {
async function check() {
const protocols = ["https", "http"];
for (const protocol of protocols) {
try {
const result = await fetch(`${protocol}://localhost:9337/api/v1/status`, {
cache: "no-store",
});
const status = result.status;
const data = await result.json();
if (status === 200 && data.status === "ok") {
detectedProtocol = protocol;
statusText.innerText = "Launching UI";
statusBadge.innerText = "Ready";
syncThemeFromConfig();
setTimeout(onReady, 200);
return; return;
} }
} catch (e) { const media = window.matchMedia("(prefers-color-scheme: dark)");
continue; media.addEventListener("change", (event) => {
applyTheme(event.matches ? "dark" : "light");
});
} }
}
setTimeout(check, 300);
}
function onReady() { let detectedProtocol = "http";
const timestamp = (new Date()).getTime();
window.location.href = `${detectedProtocol}://localhost:9337/?nocache=${timestamp}`;
}
async function syncThemeFromConfig() { async function check() {
try { const protocols = ["https", "http"];
const response = await fetch(`${detectedProtocol}://localhost:9337/api/v1/config`, { cache: "no-store" }); for (const protocol of protocols) {
if (!response.ok) { try {
return; const result = await fetch(`${protocol}://localhost:9337/api/v1/status`, {
cache: "no-store",
});
const status = result.status;
const data = await result.json();
if (status === 200 && data.status === "ok") {
detectedProtocol = protocol;
statusText.innerText = "Launching UI";
statusBadge.innerText = "Ready";
syncThemeFromConfig();
setTimeout(onReady, 200);
return;
}
} catch (e) {
continue;
}
}
setTimeout(check, 300);
} }
const config = await response.json();
if (config && (config.theme === "dark" || config.theme === "light")) { function onReady() {
applyTheme(config.theme); const timestamp = new Date().getTime();
window.location.href = `${detectedProtocol}://localhost:9337/?nocache=${timestamp}`;
}
async function syncThemeFromConfig() {
try { try {
localStorage.setItem("meshchat.theme", config.theme); const response = await fetch(`${detectedProtocol}://localhost:9337/api/v1/config`, {
cache: "no-store",
});
if (!response.ok) {
return;
}
const config = await response.json();
if (config && (config.theme === "dark" || config.theme === "light")) {
applyTheme(config.theme);
try {
localStorage.setItem("meshchat.theme", config.theme);
} catch (e) {}
}
} catch (e) {} } catch (e) {}
} }
} catch (e) {} </script>
} </body>
</script> </html>
</body>
</html>

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 110 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

View File

Binary file not shown.

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 289 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

View File

@@ -63,6 +63,7 @@ from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from meshchatx.src.backend.sideband_commands import SidebandCommands from meshchatx.src.backend.sideband_commands import SidebandCommands
from meshchatx.src.backend.telephone_manager import TelephoneManager from meshchatx.src.backend.telephone_manager import TelephoneManager
from meshchatx.src.backend.translator_handler import TranslatorHandler from meshchatx.src.backend.translator_handler import TranslatorHandler
from meshchatx.src.backend.voicemail_manager import VoicemailManager
from meshchatx.src.version import __version__ as app_version from meshchatx.src.version import __version__ as app_version
@@ -193,7 +194,9 @@ class ReticulumMeshChat:
# init database # init database
self.database = Database(self.database_path) self.database = Database(self.database_path)
self.db = self.database # keep for compatibility with parts I haven't changed yet self.db = (
self.database
) # keep for compatibility with parts I haven't changed yet
try: try:
self.database.initialize() self.database.initialize()
@@ -218,7 +221,7 @@ class ReticulumMeshChat:
self.announce_manager = AnnounceManager(self.database) self.announce_manager = AnnounceManager(self.database)
self.archiver_manager = ArchiverManager(self.database) self.archiver_manager = ArchiverManager(self.database)
self.map_manager = MapManager(self.config, self.storage_dir) self.map_manager = MapManager(self.config, self.storage_dir)
self.forwarding_manager = None # will init after lxmf router self.forwarding_manager = None # will init after lxmf router
# remember if authentication is enabled # remember if authentication is enabled
self.auth_enabled = auth_enabled or self.config.auth_enabled.get() self.auth_enabled = auth_enabled or self.config.auth_enabled.get()
@@ -327,6 +330,17 @@ class ReticulumMeshChat:
) )
self.telephone_manager.init_telephone() self.telephone_manager.init_telephone()
# init Voicemail Manager
self.voicemail_manager = VoicemailManager(
db=self.database,
telephone_manager=self.telephone_manager,
storage_dir=self.storage_path,
)
# Monkey patch VoicemailManager to use our get_name_for_identity_hash
self.voicemail_manager.get_name_for_identity_hash = (
self.get_name_for_identity_hash
)
# init RNCP handler # init RNCP handler
self.rncp_handler = RNCPHandler( self.rncp_handler = RNCPHandler(
reticulum_instance=self.reticulum, reticulum_instance=self.reticulum,
@@ -345,7 +359,9 @@ class ReticulumMeshChat:
# init Translator handler # init Translator handler
libretranslate_url = self.config.get("libretranslate_url", None) libretranslate_url = self.config.get("libretranslate_url", None)
self.translator_handler = TranslatorHandler(libretranslate_url=libretranslate_url) self.translator_handler = TranslatorHandler(
libretranslate_url=libretranslate_url
)
# start background thread for auto announce loop # start background thread for auto announce loop
thread = threading.Thread(target=asyncio.run, args=(self.announce_loop(),)) thread = threading.Thread(target=asyncio.run, args=(self.announce_loop(),))
@@ -552,7 +568,8 @@ class ReticulumMeshChat:
def backup_identity(self): def backup_identity(self):
identity_bytes = self._get_identity_bytes() identity_bytes = self._get_identity_bytes()
target_path = self.identity_file_path or os.path.join( target_path = self.identity_file_path or os.path.join(
self.storage_dir, "identity", self.storage_dir,
"identity",
) )
os.makedirs(os.path.dirname(target_path), exist_ok=True) os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, "wb") as f: with open(target_path, "wb") as f:
@@ -567,7 +584,8 @@ class ReticulumMeshChat:
def restore_identity_from_bytes(self, identity_bytes: bytes): def restore_identity_from_bytes(self, identity_bytes: bytes):
target_path = self.identity_file_path or os.path.join( target_path = self.identity_file_path or os.path.join(
self.storage_dir, "identity", self.storage_dir,
"identity",
) )
os.makedirs(os.path.dirname(target_path), exist_ok=True) os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, "wb") as f: with open(target_path, "wb") as f:
@@ -690,9 +708,13 @@ class ReticulumMeshChat:
if self.config.crawler_enabled.get(): if self.config.crawler_enabled.get():
# Proactively queue any known nodes from the database that haven't been queued yet # Proactively queue any known nodes from the database that haven't been queued yet
# get known propagation nodes from database # get known propagation nodes from database
known_nodes = self.database.announces.get_announces(aspect="nomadnetwork.node") known_nodes = self.database.announces.get_announces(
aspect="nomadnetwork.node"
)
for node in known_nodes: for node in known_nodes:
self.queue_crawler_task(node["destination_hash"], "/page/index.mu") self.queue_crawler_task(
node["destination_hash"], "/page/index.mu"
)
# process pending or failed tasks # process pending or failed tasks
# ensure we handle potential string comparison issues in SQLite # ensure we handle potential string comparison issues in SQLite
@@ -702,7 +724,9 @@ class ReticulumMeshChat:
) )
# process tasks concurrently up to the limit # process tasks concurrently up to the limit
await asyncio.gather(*[self.process_crawler_task(task) for task in tasks]) await asyncio.gather(
*[self.process_crawler_task(task) for task in tasks]
)
except Exception as e: except Exception as e:
print(f"Error in crawler loop: {e}") print(f"Error in crawler loop: {e}")
@@ -713,12 +737,16 @@ class ReticulumMeshChat:
async def process_crawler_task(self, task): async def process_crawler_task(self, task):
# mark as crawling # mark as crawling
task_id = task["id"] task_id = task["id"]
self.database.misc.update_crawl_task(task_id, status="crawling", last_retry_at=datetime.now(UTC)) self.database.misc.update_crawl_task(
task_id, status="crawling", last_retry_at=datetime.now(UTC)
)
destination_hash = task["destination_hash"] destination_hash = task["destination_hash"]
page_path = task["page_path"] page_path = task["page_path"]
print(f"Crawler: Archiving {destination_hash}:{page_path} (Attempt {task['retry_count'] + 1})") print(
f"Crawler: Archiving {destination_hash}:{page_path} (Attempt {task['retry_count'] + 1})"
)
# completion event # completion event
done_event = asyncio.Event() done_event = asyncio.Event()
@@ -762,17 +790,23 @@ class ReticulumMeshChat:
await download_task await download_task
except Exception as e: except Exception as e:
print(f"Crawler: Error during download for {destination_hash}:{page_path}: {e}") print(
f"Crawler: Error during download for {destination_hash}:{page_path}: {e}"
)
failure_reason[0] = str(e) failure_reason[0] = str(e)
done_event.set() done_event.set()
if success[0]: if success[0]:
print(f"Crawler: Successfully archived {destination_hash}:{page_path}") print(f"Crawler: Successfully archived {destination_hash}:{page_path}")
self.archive_page(destination_hash, page_path, content_received[0], is_manual=False) self.archive_page(
destination_hash, page_path, content_received[0], is_manual=False
)
task.status = "completed" task.status = "completed"
task.save() task.save()
else: else:
print(f"Crawler: Failed to archive {destination_hash}:{page_path} - {failure_reason[0]}") print(
f"Crawler: Failed to archive {destination_hash}:{page_path} - {failure_reason[0]}"
)
task.retry_count += 1 task.retry_count += 1
task.status = "failed" task.status = "failed"
@@ -911,13 +945,17 @@ class ReticulumMeshChat:
# returns the latest message for the provided destination hash # returns the latest message for the provided destination hash
def get_conversation_latest_message(self, destination_hash: str): def get_conversation_latest_message(self, destination_hash: str):
local_hash = self.identity.hexhash local_hash = self.identity.hexhash
messages = self.message_handler.get_conversation_messages(local_hash, destination_hash, limit=1) messages = self.message_handler.get_conversation_messages(
local_hash, destination_hash, limit=1
)
return messages[0] if messages else None return messages[0] if messages else None
# returns true if the conversation with the provided destination hash has any attachments # returns true if the conversation with the provided destination hash has any attachments
def conversation_has_attachments(self, destination_hash: str): def conversation_has_attachments(self, destination_hash: str):
local_hash = self.identity.hexhash local_hash = self.identity.hexhash
messages = self.message_handler.get_conversation_messages(local_hash, destination_hash) messages = self.message_handler.get_conversation_messages(
local_hash, destination_hash
)
for message in messages: for message in messages:
if self.message_fields_have_attachments(message["fields"]): if self.message_fields_have_attachments(message["fields"]):
return True return True
@@ -957,9 +995,13 @@ class ReticulumMeshChat:
matches.add(message["source_hash"]) matches.add(message["source_hash"])
# also check custom display names # also check custom display names
custom_names = self.database.announces.get_announces() # Or more specific if needed custom_names = (
self.database.announces.get_announces()
) # Or more specific if needed
for announce in custom_names: for announce in custom_names:
custom_name = self.database.announces.get_custom_display_name(announce["destination_hash"]) custom_name = self.database.announces.get_custom_display_name(
announce["destination_hash"]
)
if custom_name and search_term.lower() in custom_name.lower(): if custom_name and search_term.lower() in custom_name.lower():
matches.add(announce["destination_hash"]) matches.add(announce["destination_hash"])
@@ -974,6 +1016,9 @@ class ReticulumMeshChat:
# handle receiving a new audio call # handle receiving a new audio call
def on_incoming_telephone_call(self, caller_identity: RNS.Identity): def on_incoming_telephone_call(self, caller_identity: RNS.Identity):
# Trigger voicemail handling
self.voicemail_manager.handle_incoming_call(caller_identity)
print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}") print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}")
AsyncUtils.run_async( AsyncUtils.run_async(
self.websocket_broadcast( self.websocket_broadcast(
@@ -998,7 +1043,12 @@ class ReticulumMeshChat:
) )
def on_telephone_call_ended(self, caller_identity: RNS.Identity): def on_telephone_call_ended(self, caller_identity: RNS.Identity):
print(f"on_telephone_call_ended: {caller_identity.hash.hex() if caller_identity else 'Unknown'}") # Stop voicemail recording if active
self.voicemail_manager.stop_recording()
print(
f"on_telephone_call_ended: {caller_identity.hash.hex() if caller_identity else 'Unknown'}"
)
# Record call history # Record call history
if caller_identity: if caller_identity:
@@ -2474,7 +2524,9 @@ class ReticulumMeshChat:
"remote_identity_hash": remote_identity_hash, "remote_identity_hash": remote_identity_hash,
"remote_identity_name": remote_identity_name, "remote_identity_name": remote_identity_name,
"audio_profile_id": self.telephone_manager.telephone.transmit_codec.profile "audio_profile_id": self.telephone_manager.telephone.transmit_codec.profile
if hasattr(self.telephone_manager.telephone.transmit_codec, "profile") if hasattr(
self.telephone_manager.telephone.transmit_codec, "profile"
)
else None, else None,
"tx_packets": getattr(telephone_active_call, "tx", 0), "tx_packets": getattr(telephone_active_call, "tx", 0),
"rx_packets": getattr(telephone_active_call, "rx", 0), "rx_packets": getattr(telephone_active_call, "rx", 0),
@@ -2482,6 +2534,7 @@ class ReticulumMeshChat:
"rx_bytes": getattr(telephone_active_call, "rxbytes", 0), "rx_bytes": getattr(telephone_active_call, "rxbytes", 0),
"is_mic_muted": self.telephone_manager.telephone.transmit_muted, "is_mic_muted": self.telephone_manager.telephone.transmit_muted,
"is_speaker_muted": self.telephone_manager.telephone.receive_muted, "is_speaker_muted": self.telephone_manager.telephone.receive_muted,
"is_voicemail": self.voicemail_manager.is_recording,
} }
return web.json_response( return web.json_response(
@@ -2492,6 +2545,10 @@ class ReticulumMeshChat:
"active_call": active_call, "active_call": active_call,
"is_mic_muted": self.telephone_manager.telephone.transmit_muted, "is_mic_muted": self.telephone_manager.telephone.transmit_muted,
"is_speaker_muted": self.telephone_manager.telephone.receive_muted, "is_speaker_muted": self.telephone_manager.telephone.receive_muted,
"voicemail": {
"is_recording": self.voicemail_manager.is_recording,
"unread_count": self.database.voicemails.get_unread_count(),
},
}, },
) )
@@ -2506,7 +2563,9 @@ class ReticulumMeshChat:
caller_identity = active_call.get_remote_identity() caller_identity = active_call.get_remote_identity()
# answer call # answer call
await asyncio.to_thread(self.telephone_manager.telephone.answer, caller_identity) await asyncio.to_thread(
self.telephone_manager.telephone.answer, caller_identity
)
return web.json_response( return web.json_response(
{ {
@@ -2563,9 +2622,12 @@ class ReticulumMeshChat:
profile_id = request.match_info.get("profile_id") profile_id = request.match_info.get("profile_id")
try: try:
await asyncio.to_thread( await asyncio.to_thread(
self.telephone_manager.telephone.switch_profile, int(profile_id) self.telephone_manager.telephone.switch_profile,
int(profile_id),
)
return web.json_response(
{"message": f"Switched to profile {profile_id}"}
) )
return web.json_response({"message": f"Switched to profile {profile_id}"})
except Exception as e: except Exception as e:
return web.json_response({"message": str(e)}, status=500) return web.json_response({"message": str(e)}, status=500)
@@ -2602,9 +2664,11 @@ class ReticulumMeshChat:
identity_hash_bytes = bytes.fromhex(announce["identity_hash"]) identity_hash_bytes = bytes.fromhex(announce["identity_hash"])
# calculate telephony destination hash # calculate telephony destination hash
telephony_destination_hash = RNS.Destination.hash_from_name_and_identity( telephony_destination_hash = (
f"{LXST.APP_NAME}.telephony", RNS.Destination.hash_from_name_and_identity(
identity_hash_bytes, f"{LXST.APP_NAME}.telephony",
identity_hash_bytes,
)
) )
# request path to telephony destination # request path to telephony destination
@@ -2673,6 +2737,83 @@ class ReticulumMeshChat:
}, },
) )
# voicemail status
@routes.get("/api/v1/telephone/voicemail/status")
async def telephone_voicemail_status(request):
return web.json_response(
{
"has_espeak": self.voicemail_manager.has_espeak,
"has_ffmpeg": self.voicemail_manager.has_ffmpeg,
"is_recording": self.voicemail_manager.is_recording,
},
)
# list voicemails
@routes.get("/api/v1/telephone/voicemails")
async def telephone_voicemails(request):
limit = int(request.query.get("limit", 50))
offset = int(request.query.get("offset", 0))
voicemails = self.database.voicemails.get_voicemails(
limit=limit, offset=offset
)
return web.json_response(
{
"voicemails": [dict(row) for row in voicemails],
"unread_count": self.database.voicemails.get_unread_count(),
},
)
# mark voicemail as read
@routes.post("/api/v1/telephone/voicemails/{id}/read")
async def telephone_voicemail_mark_read(request):
voicemail_id = request.match_info.get("id")
self.database.voicemails.mark_as_read(voicemail_id)
return web.json_response({"message": "Voicemail marked as read"})
# delete voicemail
@routes.delete("/api/v1/telephone/voicemails/{id}")
async def telephone_voicemail_delete(request):
voicemail_id = request.match_info.get("id")
voicemail = self.database.voicemails.get_voicemail(voicemail_id)
if voicemail:
filepath = os.path.join(
self.voicemail_manager.recordings_dir, voicemail["filename"]
)
if os.path.exists(filepath):
os.remove(filepath)
self.database.voicemails.delete_voicemail(voicemail_id)
return web.json_response({"message": "Voicemail deleted"})
return web.json_response({"message": "Voicemail not found"}, status=404)
# serve voicemail audio
@routes.get("/api/v1/telephone/voicemails/{id}/audio")
async def telephone_voicemail_audio(request):
voicemail_id = request.match_info.get("id")
voicemail = self.database.voicemails.get_voicemail(voicemail_id)
if voicemail:
filepath = os.path.join(
self.voicemail_manager.recordings_dir, voicemail["filename"]
)
if os.path.exists(filepath):
return web.FileResponse(filepath)
return web.json_response(
{"message": "Voicemail audio not found"}, status=404
)
# generate greeting
@routes.post("/api/v1/telephone/voicemail/generate-greeting")
async def telephone_voicemail_generate_greeting(request):
try:
text = self.config.voicemail_greeting.get()
path = await asyncio.to_thread(
self.voicemail_manager.generate_greeting, text
)
return web.json_response(
{"message": "Greeting generated", "path": path}
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
# announce # announce
@routes.get("/api/v1/announce") @routes.get("/api/v1/announce")
async def announce_trigger(request): async def announce_trigger(request):
@@ -2694,7 +2835,9 @@ class ReticulumMeshChat:
search_query = request.query.get("search", None) search_query = request.query.get("search", None)
limit = request.query.get("limit", None) limit = request.query.get("limit", None)
offset = request.query.get("offset", None) offset = request.query.get("offset", None)
include_blocked = request.query.get("include_blocked", "false").lower() == "true" include_blocked = (
request.query.get("include_blocked", "false").lower() == "true"
)
blocked_identity_hashes = None blocked_identity_hashes = None
if not include_blocked: if not include_blocked:
@@ -2721,7 +2864,8 @@ class ReticulumMeshChat:
# process announces # process announces
announces = [ announces = [
self.convert_db_announce_to_dict(announce) for announce in paginated_results self.convert_db_announce_to_dict(announce)
for announce in paginated_results
] ]
return web.json_response( return web.json_response(
@@ -2742,8 +2886,7 @@ class ReticulumMeshChat:
# process favourites # process favourites
favourites = [ favourites = [
self.convert_db_favourite_to_dict(favourite) self.convert_db_favourite_to_dict(favourite) for favourite in results
for favourite in results
] ]
return web.json_response( return web.json_response(
@@ -2789,7 +2932,9 @@ class ReticulumMeshChat:
) )
# upsert favourite # upsert favourite
self.database.announces.upsert_favourite(destination_hash, display_name, aspect) self.database.announces.upsert_favourite(
destination_hash, display_name, aspect
)
return web.json_response( return web.json_response(
{ {
"message": "Favourite has been added!", "message": "Favourite has been added!",
@@ -2808,7 +2953,9 @@ class ReticulumMeshChat:
# update display name if provided # update display name if provided
if len(display_name) > 0: if len(display_name) > 0:
self.database.announces.upsert_custom_display_name(destination_hash, display_name) self.database.announces.upsert_custom_display_name(
destination_hash, display_name
)
return web.json_response( return web.json_response(
{ {
@@ -2853,31 +3000,43 @@ class ReticulumMeshChat:
archives = [] archives = []
for archive in archives_results: for archive in archives_results:
# find node name from announces or custom display names # find node name from announces or custom display names
node_name = self.get_custom_destination_display_name(archive["destination_hash"]) node_name = self.get_custom_destination_display_name(
archive["destination_hash"]
)
if not node_name: if not node_name:
db_announce = self.database.announces.get_announce_by_hash(archive["destination_hash"]) db_announce = self.database.announces.get_announce_by_hash(
archive["destination_hash"]
)
if db_announce and db_announce["aspect"] == "nomadnetwork.node": if db_announce and db_announce["aspect"] == "nomadnetwork.node":
node_name = ReticulumMeshChat.parse_nomadnetwork_node_display_name(db_announce["app_data"]) node_name = (
ReticulumMeshChat.parse_nomadnetwork_node_display_name(
db_announce["app_data"]
)
)
archives.append({ archives.append(
"id": archive["id"], {
"destination_hash": archive["destination_hash"], "id": archive["id"],
"node_name": node_name or "Unknown Node", "destination_hash": archive["destination_hash"],
"page_path": archive["page_path"], "node_name": node_name or "Unknown Node",
"content": archive["content"], "page_path": archive["page_path"],
"hash": archive["hash"], "content": archive["content"],
"created_at": archive["created_at"], "hash": archive["hash"],
}) "created_at": archive["created_at"],
}
)
return web.json_response({ return web.json_response(
"archives": archives, {
"pagination": { "archives": archives,
"page": page, "pagination": {
"limit": limit, "page": page,
"total_count": total_count, "limit": limit,
"total_pages": total_pages, "total_count": total_count,
}, "total_pages": total_pages,
}) },
}
)
@routes.get("/api/v1/lxmf/propagation-node/status") @routes.get("/api/v1/lxmf/propagation-node/status")
async def propagation_node_status(request): async def propagation_node_status(request):
@@ -2937,7 +3096,7 @@ class ReticulumMeshChat:
# limit results # limit results
if limit is not None: if limit is not None:
results = results[:int(limit)] results = results[: int(limit)]
# process announces # process announces
lxmf_propagation_nodes = [] lxmf_propagation_nodes = []
@@ -2947,14 +3106,20 @@ class ReticulumMeshChat:
aspect="lxmf.delivery", aspect="lxmf.delivery",
identity_hash=announce["identity_hash"], identity_hash=announce["identity_hash"],
) )
lxmf_delivery_announce = lxmf_delivery_results[0] if lxmf_delivery_results else None lxmf_delivery_announce = (
lxmf_delivery_results[0] if lxmf_delivery_results else None
)
# find a nomadnetwork.node announce for the same identity hash, so we can use that as an "operated by" name # find a nomadnetwork.node announce for the same identity hash, so we can use that as an "operated by" name
nomadnetwork_node_results = self.database.announces.get_filtered_announces( nomadnetwork_node_results = (
aspect="nomadnetwork.node", self.database.announces.get_filtered_announces(
identity_hash=announce["identity_hash"], aspect="nomadnetwork.node",
identity_hash=announce["identity_hash"],
)
)
nomadnetwork_node_announce = (
nomadnetwork_node_results[0] if nomadnetwork_node_results else None
) )
nomadnetwork_node_announce = nomadnetwork_node_results[0] if nomadnetwork_node_results else None
# get a display name from other announces belonging to the propagation nodes identity # get a display name from other announces belonging to the propagation nodes identity
operator_display_name = None operator_display_name = None
@@ -2970,9 +3135,11 @@ class ReticulumMeshChat:
nomadnetwork_node_announce is not None nomadnetwork_node_announce is not None
and nomadnetwork_node_announce["app_data"] is not None and nomadnetwork_node_announce["app_data"] is not None
): ):
operator_display_name = ReticulumMeshChat.parse_nomadnetwork_node_display_name( operator_display_name = (
nomadnetwork_node_announce["app_data"], ReticulumMeshChat.parse_nomadnetwork_node_display_name(
None, nomadnetwork_node_announce["app_data"],
None,
)
) )
# parse app_data so we can see if propagation is enabled or disabled for this node # parse app_data so we can see if propagation is enabled or disabled for this node
@@ -3097,18 +3264,26 @@ class ReticulumMeshChat:
updated_at = None updated_at = None
# get latest announce from database for the provided destination hash # get latest announce from database for the provided destination hash
latest_announce = self.database.announces.get_announce_by_hash(destination_hash) latest_announce = self.database.announces.get_announce_by_hash(
destination_hash
)
# get latest lxmf message from database sent to us from the provided destination hash # get latest lxmf message from database sent to us from the provided destination hash
local_hash = self.local_lxmf_destination.hexhash local_hash = self.local_lxmf_destination.hexhash
messages = self.message_handler.get_conversation_messages(local_hash, destination_hash, limit=1) messages = self.message_handler.get_conversation_messages(
local_hash, destination_hash, limit=1
)
# Filter for incoming messages only # Filter for incoming messages only
latest_lxmf_message = next((m for m in messages if m["source_hash"] == destination_hash), None) latest_lxmf_message = next(
(m for m in messages if m["source_hash"] == destination_hash), None
)
# determine when latest announce was received # determine when latest announce was received
latest_announce_at = None latest_announce_at = None
if latest_announce is not None: if latest_announce is not None:
latest_announce_at = datetime.fromisoformat(latest_announce["updated_at"]) latest_announce_at = datetime.fromisoformat(
latest_announce["updated_at"]
)
if latest_announce_at.tzinfo is not None: if latest_announce_at.tzinfo is not None:
latest_announce_at = latest_announce_at.replace(tzinfo=None) latest_announce_at = latest_announce_at.replace(tzinfo=None)
@@ -3392,7 +3567,10 @@ class ReticulumMeshChat:
@routes.get("/api/v1/rnstatus") @routes.get("/api/v1/rnstatus")
async def rnstatus(request): async def rnstatus(request):
include_link_stats = request.query.get("include_link_stats", "false") in ("true", "1") include_link_stats = request.query.get("include_link_stats", "false") in (
"true",
"1",
)
sorting = request.query.get("sorting") sorting = request.query.get("sorting")
sort_reverse = request.query.get("sort_reverse", "false") in ("true", "1") sort_reverse = request.query.get("sort_reverse", "false") in ("true", "1")
@@ -3453,13 +3631,17 @@ class ReticulumMeshChat:
async def translator_languages(request): async def translator_languages(request):
try: try:
libretranslate_url = request.query.get("libretranslate_url") libretranslate_url = request.query.get("libretranslate_url")
languages = self.translator_handler.get_supported_languages(libretranslate_url=libretranslate_url) languages = self.translator_handler.get_supported_languages(
return web.json_response({ libretranslate_url=libretranslate_url
"languages": languages, )
"has_argos": self.translator_handler.has_argos, return web.json_response(
"has_argos_lib": self.translator_handler.has_argos_lib, {
"has_argos_cli": self.translator_handler.has_argos_cli, "languages": languages,
}) "has_argos": self.translator_handler.has_argos,
"has_argos_lib": self.translator_handler.has_argos_lib,
"has_argos_cli": self.translator_handler.has_argos_cli,
}
)
except Exception as e: except Exception as e:
return web.json_response( return web.json_response(
{"message": str(e)}, {"message": str(e)},
@@ -3575,7 +3757,9 @@ class ReticulumMeshChat:
lxmf_stamp_cost = None lxmf_stamp_cost = None
announce = self.database.announces.get_announce_by_hash(destination_hash) announce = self.database.announces.get_announce_by_hash(destination_hash)
if announce is not None: if announce is not None:
lxmf_stamp_cost = ReticulumMeshChat.parse_lxmf_stamp_cost(announce["app_data"]) lxmf_stamp_cost = ReticulumMeshChat.parse_lxmf_stamp_cost(
announce["app_data"]
)
# get outbound ticket expiry for this lxmf destination # get outbound ticket expiry for this lxmf destination
lxmf_outbound_ticket_expiry = ( lxmf_outbound_ticket_expiry = (
@@ -3760,7 +3944,9 @@ class ReticulumMeshChat:
# get lxmf message from database # get lxmf message from database
lxmf_message = None lxmf_message = None
db_lxmf_message = self.database.messages.get_lxmf_message_by_hash(message_hash) db_lxmf_message = self.database.messages.get_lxmf_message_by_hash(
message_hash
)
if db_lxmf_message is not None: if db_lxmf_message is not None:
lxmf_message = self.convert_db_lxmf_message_to_dict(db_lxmf_message) lxmf_message = self.convert_db_lxmf_message_to_dict(db_lxmf_message)
@@ -3864,7 +4050,9 @@ class ReticulumMeshChat:
file_index = request.query.get("file_index") file_index = request.query.get("file_index")
# find message from database # find message from database
db_lxmf_message = self.database.messages.get_lxmf_message_by_hash(message_hash) db_lxmf_message = self.database.messages.get_lxmf_message_by_hash(
message_hash
)
if db_lxmf_message is None: if db_lxmf_message is None:
return web.json_response({"message": "Message not found"}, status=404) return web.json_response({"message": "Message not found"}, status=404)
@@ -3959,14 +4147,16 @@ class ReticulumMeshChat:
latest_message_title = db_message["title"] latest_message_title = db_message["title"]
latest_message_preview = db_message["content"] latest_message_preview = db_message["content"]
latest_message_timestamp = db_message["timestamp"] latest_message_timestamp = db_message["timestamp"]
latest_message_has_attachments = ( latest_message_has_attachments = self.message_fields_have_attachments(
self.message_fields_have_attachments(db_message["fields"]) db_message["fields"]
) )
# using timestamp (sent time) for updated_at as it is more reliable across restarts # using timestamp (sent time) for updated_at as it is more reliable across restarts
# and represents the actual time the message was created by the sender. # and represents the actual time the message was created by the sender.
# we convert it to ISO format for the frontend. # we convert it to ISO format for the frontend.
updated_at = datetime.fromtimestamp(latest_message_timestamp, UTC).isoformat() updated_at = datetime.fromtimestamp(
latest_message_timestamp, UTC
).isoformat()
# check if conversation has attachments # check if conversation has attachments
has_attachments = self.conversation_has_attachments(other_user_hash) has_attachments = self.conversation_has_attachments(other_user_hash)
@@ -4260,7 +4450,12 @@ class ReticulumMeshChat:
self.map_manager.close() self.map_manager.close()
self.config.map_offline_path.set(file_path) self.config.map_offline_path.set(file_path)
self.config.map_offline_enabled.set(True) self.config.map_offline_enabled.set(True)
return web.json_response({"message": "Active map updated", "metadata": self.map_manager.get_metadata()}) return web.json_response(
{
"message": "Active map updated",
"metadata": self.map_manager.get_metadata(),
}
)
return web.json_response({"error": "File not found"}, status=404) return web.json_response({"error": "File not found"}, status=404)
# upload offline map # upload offline map
@@ -4274,7 +4469,9 @@ class ReticulumMeshChat:
filename = field.filename filename = field.filename
if not filename.endswith(".mbtiles"): if not filename.endswith(".mbtiles"):
return web.json_response({"error": "Invalid file format, must be .mbtiles"}, status=400) return web.json_response(
{"error": "Invalid file format, must be .mbtiles"}, status=400
)
# save to mbtiles dir # save to mbtiles dir
mbtiles_dir = self.map_manager.get_mbtiles_dir() mbtiles_dir = self.map_manager.get_mbtiles_dir()
@@ -4307,12 +4504,19 @@ class ReticulumMeshChat:
os.remove(dest_path) os.remove(dest_path)
self.config.map_offline_path.set(None) self.config.map_offline_path.set(None)
self.config.map_offline_enabled.set(False) self.config.map_offline_enabled.set(False)
return web.json_response({"error": "Invalid MBTiles file or unsupported format (vector maps not supported)"}, status=400) return web.json_response(
{
"error": "Invalid MBTiles file or unsupported format (vector maps not supported)"
},
status=400,
)
return web.json_response({ return web.json_response(
"message": "Map uploaded successfully", {
"metadata": metadata, "message": "Map uploaded successfully",
}) "metadata": metadata,
}
)
except Exception as e: except Exception as e:
RNS.log(f"Error uploading map: {e}", RNS.LOG_ERROR) RNS.log(f"Error uploading map: {e}", RNS.LOG_ERROR)
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
@@ -4322,7 +4526,7 @@ class ReticulumMeshChat:
async def start_map_export(request): async def start_map_export(request):
try: try:
data = await request.json() data = await request.json()
bbox = data.get("bbox") # [min_lon, min_lat, max_lon, max_lat] bbox = data.get("bbox") # [min_lon, min_lat, max_lon, max_lat]
min_zoom = int(data.get("min_zoom", 0)) min_zoom = int(data.get("min_zoom", 0))
max_zoom = int(data.get("max_zoom", 10)) max_zoom = int(data.get("max_zoom", 10))
name = data.get("name", "Exported Map") name = data.get("name", "Exported Map")
@@ -4360,7 +4564,9 @@ class ReticulumMeshChat:
"Content-Disposition": f'attachment; filename="map_export_{export_id}.mbtiles"', "Content-Disposition": f'attachment; filename="map_export_{export_id}.mbtiles"',
}, },
) )
return web.json_response({"error": "File not ready or not found"}, status=404) return web.json_response(
{"error": "File not ready or not found"}, status=404
)
# MIME type fix middleware - ensures JavaScript files have correct Content-Type # MIME type fix middleware - ensures JavaScript files have correct Content-Type
@web.middleware @web.middleware
@@ -4433,7 +4639,9 @@ class ReticulumMeshChat:
) )
# add other middlewares # add other middlewares
app.middlewares.extend([auth_middleware, mime_type_middleware, security_middleware]) app.middlewares.extend(
[auth_middleware, mime_type_middleware, security_middleware]
)
app.add_routes(routes) app.add_routes(routes)
app.add_routes( app.add_routes(
@@ -4606,10 +4814,14 @@ class ReticulumMeshChat:
self.config.page_archiver_enabled.set(bool(data["page_archiver_enabled"])) self.config.page_archiver_enabled.set(bool(data["page_archiver_enabled"]))
if "page_archiver_max_versions" in data: if "page_archiver_max_versions" in data:
self.config.page_archiver_max_versions.set(int(data["page_archiver_max_versions"])) self.config.page_archiver_max_versions.set(
int(data["page_archiver_max_versions"])
)
if "archives_max_storage_gb" in data: if "archives_max_storage_gb" in data:
self.config.archives_max_storage_gb.set(int(data["archives_max_storage_gb"])) self.config.archives_max_storage_gb.set(
int(data["archives_max_storage_gb"])
)
# update crawler settings # update crawler settings
if "crawler_enabled" in data: if "crawler_enabled" in data:
@@ -4619,7 +4831,9 @@ class ReticulumMeshChat:
self.config.crawler_max_retries.set(int(data["crawler_max_retries"])) self.config.crawler_max_retries.set(int(data["crawler_max_retries"]))
if "crawler_retry_delay_seconds" in data: if "crawler_retry_delay_seconds" in data:
self.config.crawler_retry_delay_seconds.set(int(data["crawler_retry_delay_seconds"])) self.config.crawler_retry_delay_seconds.set(
int(data["crawler_retry_delay_seconds"])
)
if "crawler_max_concurrent" in data: if "crawler_max_concurrent" in data:
self.config.crawler_max_concurrent.set(int(data["crawler_max_concurrent"])) self.config.crawler_max_concurrent.set(int(data["crawler_max_concurrent"]))
@@ -4695,7 +4909,13 @@ class ReticulumMeshChat:
return data return data
# archives a page version # archives a page version
def archive_page(self, destination_hash: str, page_path: str, content: str, is_manual: bool = False): def archive_page(
self,
destination_hash: str,
page_path: str,
content: str,
is_manual: bool = False,
):
if not is_manual and not self.config.page_archiver_enabled.get(): if not is_manual and not self.config.page_archiver_enabled.get():
return return
@@ -4709,7 +4929,9 @@ class ReticulumMeshChat:
# returns archived page versions for a given destination and path # returns archived page versions for a given destination and path
def get_archived_page_versions(self, destination_hash: str, page_path: str): def get_archived_page_versions(self, destination_hash: str, page_path: str):
return self.database.misc.get_archived_page_versions(destination_hash, page_path) return self.database.misc.get_archived_page_versions(
destination_hash, page_path
)
# flushes all archived pages # flushes all archived pages
def flush_all_archived_pages(self): def flush_all_archived_pages(self):
@@ -4780,7 +5002,9 @@ class ReticulumMeshChat:
{ {
"id": archive.id, "id": archive.id,
"hash": archive.hash, "hash": archive.hash,
"created_at": archive.created_at.isoformat() if hasattr(archive.created_at, "isoformat") else str(archive.created_at), "created_at": archive.created_at.isoformat()
if hasattr(archive.created_at, "isoformat")
else str(archive.created_at),
} }
for archive in archives for archive in archives
], ],
@@ -5030,7 +5254,8 @@ class ReticulumMeshChat:
has_archives = ( has_archives = (
len( len(
self.get_archived_page_versions( self.get_archived_page_versions(
destination_hash.hex(), page_path, destination_hash.hex(),
page_path,
), ),
) )
> 0 > 0
@@ -5243,7 +5468,9 @@ class ReticulumMeshChat:
identity = self.recall_identity(identity_hash) identity = self.recall_identity(identity_hash)
if identity is not None: if identity is not None:
# get lxmf.delivery destination hash # get lxmf.delivery destination hash
lxmf_destination_hash = RNS.Destination.hash(identity, "lxmf", "delivery").hex() lxmf_destination_hash = RNS.Destination.hash(
identity, "lxmf", "delivery"
).hex()
# use custom name if available # use custom name if available
custom_name = self.database.announces.get_custom_display_name( custom_name = self.database.announces.get_custom_display_name(
@@ -5510,7 +5737,9 @@ class ReticulumMeshChat:
# find lxmf user icon from database # find lxmf user icon from database
lxmf_user_icon = None lxmf_user_icon = None
db_lxmf_user_icon = self.database.misc.get_user_icon(announce["destination_hash"]) db_lxmf_user_icon = self.database.misc.get_user_icon(
announce["destination_hash"]
)
if db_lxmf_user_icon: if db_lxmf_user_icon:
lxmf_user_icon = { lxmf_user_icon = {
"icon_name": db_lxmf_user_icon["icon_name"], "icon_name": db_lxmf_user_icon["icon_name"],
@@ -5634,7 +5863,7 @@ class ReticulumMeshChat:
created_at = str(db_lxmf_message["created_at"]) created_at = str(db_lxmf_message["created_at"])
if created_at and "+" not in created_at and "Z" not in created_at: if created_at and "+" not in created_at and "Z" not in created_at:
created_at += "Z" created_at += "Z"
updated_at = str(db_lxmf_message["updated_at"]) updated_at = str(db_lxmf_message["updated_at"])
if updated_at and "+" not in updated_at and "Z" not in updated_at: if updated_at and "+" not in updated_at and "Z" not in updated_at:
updated_at += "Z" updated_at += "Z"
@@ -5790,7 +6019,9 @@ class ReticulumMeshChat:
print(e) print(e)
# find message from database # find message from database
db_lxmf_message = self.database.messages.get_lxmf_message_by_hash(lxmf_message.hash.hex()) db_lxmf_message = self.database.messages.get_lxmf_message_by_hash(
lxmf_message.hash.hex()
)
if not db_lxmf_message: if not db_lxmf_message:
return return
@@ -5820,7 +6051,9 @@ class ReticulumMeshChat:
destination_hash = lxmf_message.destination_hash.hex() destination_hash = lxmf_message.destination_hash.hex()
# check if this message is for an alias identity (REPLY PATH) # check if this message is for an alias identity (REPLY PATH)
mapping = self.database.messages.get_forwarding_mapping(alias_hash=destination_hash) mapping = self.database.messages.get_forwarding_mapping(
alias_hash=destination_hash
)
if mapping: if mapping:
# this is a reply from User C to User B (alias). Forward to User A. # this is a reply from User C to User B (alias). Forward to User A.
@@ -5840,11 +6073,16 @@ class ReticulumMeshChat:
# check if this message matches a forwarding rule (FORWARD PATH) # check if this message matches a forwarding rule (FORWARD PATH)
# we check for rules that apply to the destination of this message # we check for rules that apply to the destination of this message
rules = self.database.misc.get_forwarding_rules(identity_hash=destination_hash, active_only=True) rules = self.database.misc.get_forwarding_rules(
identity_hash=destination_hash, active_only=True
)
for rule in rules: for rule in rules:
# check source filter if set # check source filter if set
if rule["source_filter_hash"] and rule["source_filter_hash"] != source_hash: if (
rule["source_filter_hash"]
and rule["source_filter_hash"] != source_hash
):
continue continue
# find or create mapping for this (Source, Final Recipient) pair # find or create mapping for this (Source, Final Recipient) pair
@@ -6303,7 +6541,9 @@ class ReticulumMeshChat:
# resends all messages that previously failed to send to the provided destination hash # resends all messages that previously failed to send to the provided destination hash
async def resend_failed_messages_for_destination(self, destination_hash: str): async def resend_failed_messages_for_destination(self, destination_hash: str):
# get messages that failed to send to this destination # get messages that failed to send to this destination
failed_messages = self.database.messages.get_failed_messages_for_destination(destination_hash) failed_messages = self.database.messages.get_failed_messages_for_destination(
destination_hash
)
# resend failed messages # resend failed messages
for failed_message in failed_messages: for failed_message in failed_messages:
@@ -6361,7 +6601,9 @@ class ReticulumMeshChat:
) )
# remove original failed message from database # remove original failed message from database
self.database.messages.delete_lxmf_message_by_hash(failed_message["hash"]) self.database.messages.delete_lxmf_message_by_hash(
failed_message["hash"]
)
# tell all websocket clients that old failed message was deleted so it can remove from ui # tell all websocket clients that old failed message was deleted so it can remove from ui
await self.websocket_broadcast( await self.websocket_broadcast(
@@ -6439,7 +6681,9 @@ class ReticulumMeshChat:
# gets the custom display name a user has set for the provided destination hash # gets the custom display name a user has set for the provided destination hash
def get_custom_destination_display_name(self, destination_hash: str): def get_custom_destination_display_name(self, destination_hash: str):
db_destination_display_name = self.database.announces.get_custom_display_name(destination_hash) db_destination_display_name = self.database.announces.get_custom_display_name(
destination_hash
)
if db_destination_display_name is not None: if db_destination_display_name is not None:
return db_destination_display_name.display_name return db_destination_display_name.display_name
@@ -6448,10 +6692,14 @@ class ReticulumMeshChat:
# get name to show for an lxmf conversation # get name to show for an lxmf conversation
# currently, this will use the app data from the most recent announce # currently, this will use the app data from the most recent announce
# TODO: we should fetch this from our contacts database, when it gets implemented, and if not found, fallback to app data # TODO: we should fetch this from our contacts database, when it gets implemented, and if not found, fallback to app data
def get_lxmf_conversation_name(self, destination_hash, default_name: str | None = "Anonymous Peer"): def get_lxmf_conversation_name(
self, destination_hash, default_name: str | None = "Anonymous Peer"
):
# get lxmf.delivery announce from database for the provided destination hash # get lxmf.delivery announce from database for the provided destination hash
results = self.database.announces.get_announces(aspect="lxmf.delivery") results = self.database.announces.get_announces(aspect="lxmf.delivery")
lxmf_announce = next((a for a in results if a["destination_hash"] == destination_hash), None) lxmf_announce = next(
(a for a in results if a["destination_hash"] == destination_hash), None
)
# if app data is available in database, it should be base64 encoded text that was announced # if app data is available in database, it should be base64 encoded text that was announced
# we will return the parsed lxmf display name as the conversation name # we will return the parsed lxmf display name as the conversation name
@@ -7018,7 +7266,12 @@ def main():
return return
enable_https = not args.no_https enable_https = not args.no_https
reticulum_meshchat.run(args.host, args.port, launch_browser=args.headless is False, enable_https=enable_https) reticulum_meshchat.run(
args.host,
args.port,
launch_browser=args.headless is False,
enable_https=enable_https,
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -7,7 +7,15 @@ class AnnounceManager:
def __init__(self, db: Database): def __init__(self, db: Database):
self.db = db self.db = db
def upsert_announce(self, reticulum, identity, destination_hash, aspect, app_data, announce_packet_hash): def upsert_announce(
self,
reticulum,
identity,
destination_hash,
aspect,
app_data,
announce_packet_hash,
):
# get rssi, snr and signal quality if available # get rssi, snr and signal quality if available
rssi = reticulum.get_packet_rssi(announce_packet_hash) rssi = reticulum.get_packet_rssi(announce_packet_hash)
snr = reticulum.get_packet_snr(announce_packet_hash) snr = reticulum.get_packet_snr(announce_packet_hash)
@@ -15,7 +23,9 @@ class AnnounceManager:
# prepare data to insert or update # prepare data to insert or update
data = { data = {
"destination_hash": destination_hash.hex() if isinstance(destination_hash, bytes) else destination_hash, "destination_hash": destination_hash.hex()
if isinstance(destination_hash, bytes)
else destination_hash,
"aspect": aspect, "aspect": aspect,
"identity_hash": identity.hash.hex(), "identity_hash": identity.hash.hex(),
"identity_public_key": base64.b64encode(identity.get_public_key()).decode( "identity_public_key": base64.b64encode(identity.get_public_key()).decode(
@@ -32,7 +42,14 @@ class AnnounceManager:
self.db.announces.upsert_announce(data) self.db.announces.upsert_announce(data)
def get_filtered_announces(self, aspect=None, identity_hash=None, destination_hash=None, query=None, blocked_identity_hashes=None): def get_filtered_announces(
self,
aspect=None,
identity_hash=None,
destination_hash=None,
query=None,
blocked_identity_hashes=None,
):
sql = "SELECT * FROM announces WHERE 1=1" sql = "SELECT * FROM announces WHERE 1=1"
params = [] params = []
@@ -56,4 +73,3 @@ class AnnounceManager:
sql += " ORDER BY updated_at DESC" sql += " ORDER BY updated_at DESC"
return self.db.provider.fetchall(sql, params) return self.db.provider.fetchall(sql, params)

View File

@@ -7,7 +7,9 @@ class ArchiverManager:
def __init__(self, db: Database): def __init__(self, db: Database):
self.db = db self.db = db
def archive_page(self, destination_hash, page_path, content, max_versions=5, max_storage_gb=1): def archive_page(
self, destination_hash, page_path, content, max_versions=5, max_storage_gb=1
):
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
# Check if already exists # Check if already exists
@@ -27,18 +29,25 @@ class ArchiverManager:
# Delete older versions # Delete older versions
to_delete = versions[max_versions:] to_delete = versions[max_versions:]
for version in to_delete: for version in to_delete:
self.db.provider.execute("DELETE FROM archived_pages WHERE id = ?", (version["id"],)) self.db.provider.execute(
"DELETE FROM archived_pages WHERE id = ?", (version["id"],)
)
# Enforce total storage limit (approximate) # Enforce total storage limit (approximate)
total_size_row = self.db.provider.fetchone("SELECT SUM(LENGTH(content)) as total_size FROM archived_pages") total_size_row = self.db.provider.fetchone(
"SELECT SUM(LENGTH(content)) as total_size FROM archived_pages"
)
total_size = total_size_row["total_size"] or 0 total_size = total_size_row["total_size"] or 0
max_bytes = max_storage_gb * 1024 * 1024 * 1024 max_bytes = max_storage_gb * 1024 * 1024 * 1024
while total_size > max_bytes: while total_size > max_bytes:
oldest = self.db.provider.fetchone("SELECT id, LENGTH(content) as size FROM archived_pages ORDER BY created_at ASC LIMIT 1") oldest = self.db.provider.fetchone(
"SELECT id, LENGTH(content) as size FROM archived_pages ORDER BY created_at ASC LIMIT 1"
)
if oldest: if oldest:
self.db.provider.execute("DELETE FROM archived_pages WHERE id = ?", (oldest["id"],)) self.db.provider.execute(
"DELETE FROM archived_pages WHERE id = ?", (oldest["id"],)
)
total_size -= oldest["size"] total_size -= oldest["size"]
else: else:
break break

View File

@@ -1,4 +1,3 @@
class ConfigManager: class ConfigManager:
def __init__(self, db): def __init__(self, db):
self.db = db self.db = db
@@ -6,75 +5,139 @@ class ConfigManager:
# all possible config items # all possible config items
self.database_version = self.IntConfig(self, "database_version", None) self.database_version = self.IntConfig(self, "database_version", None)
self.display_name = self.StringConfig(self, "display_name", "Anonymous Peer") self.display_name = self.StringConfig(self, "display_name", "Anonymous Peer")
self.auto_announce_enabled = self.BoolConfig(self, "auto_announce_enabled", False) self.auto_announce_enabled = self.BoolConfig(
self.auto_announce_interval_seconds = self.IntConfig(self, "auto_announce_interval_seconds", 0) self, "auto_announce_enabled", False
)
self.auto_announce_interval_seconds = self.IntConfig(
self, "auto_announce_interval_seconds", 0
)
self.last_announced_at = self.IntConfig(self, "last_announced_at", None) self.last_announced_at = self.IntConfig(self, "last_announced_at", None)
self.theme = self.StringConfig(self, "theme", "light") self.theme = self.StringConfig(self, "theme", "light")
self.language = self.StringConfig(self, "language", "en") self.language = self.StringConfig(self, "language", "en")
self.auto_resend_failed_messages_when_announce_received = self.BoolConfig( self.auto_resend_failed_messages_when_announce_received = self.BoolConfig(
self, "auto_resend_failed_messages_when_announce_received", True, self,
"auto_resend_failed_messages_when_announce_received",
True,
) )
self.allow_auto_resending_failed_messages_with_attachments = self.BoolConfig( self.allow_auto_resending_failed_messages_with_attachments = self.BoolConfig(
self, "allow_auto_resending_failed_messages_with_attachments", False, self,
"allow_auto_resending_failed_messages_with_attachments",
False,
) )
self.auto_send_failed_messages_to_propagation_node = self.BoolConfig( self.auto_send_failed_messages_to_propagation_node = self.BoolConfig(
self, "auto_send_failed_messages_to_propagation_node", False, self,
"auto_send_failed_messages_to_propagation_node",
False,
) )
self.show_suggested_community_interfaces = self.BoolConfig( self.show_suggested_community_interfaces = self.BoolConfig(
self, "show_suggested_community_interfaces", True, self,
"show_suggested_community_interfaces",
True,
) )
self.lxmf_delivery_transfer_limit_in_bytes = self.IntConfig( self.lxmf_delivery_transfer_limit_in_bytes = self.IntConfig(
self, "lxmf_delivery_transfer_limit_in_bytes", 1000 * 1000 * 10, self,
"lxmf_delivery_transfer_limit_in_bytes",
1000 * 1000 * 10,
) # 10MB ) # 10MB
self.lxmf_preferred_propagation_node_destination_hash = self.StringConfig( self.lxmf_preferred_propagation_node_destination_hash = self.StringConfig(
self, "lxmf_preferred_propagation_node_destination_hash", None, self,
"lxmf_preferred_propagation_node_destination_hash",
None,
) )
self.lxmf_preferred_propagation_node_auto_sync_interval_seconds = self.IntConfig( self.lxmf_preferred_propagation_node_auto_sync_interval_seconds = (
self, "lxmf_preferred_propagation_node_auto_sync_interval_seconds", 0, self.IntConfig(
self,
"lxmf_preferred_propagation_node_auto_sync_interval_seconds",
0,
)
) )
self.lxmf_preferred_propagation_node_last_synced_at = self.IntConfig( self.lxmf_preferred_propagation_node_last_synced_at = self.IntConfig(
self, "lxmf_preferred_propagation_node_last_synced_at", None, self,
"lxmf_preferred_propagation_node_last_synced_at",
None,
) )
self.lxmf_local_propagation_node_enabled = self.BoolConfig( self.lxmf_local_propagation_node_enabled = self.BoolConfig(
self, "lxmf_local_propagation_node_enabled", False, self,
"lxmf_local_propagation_node_enabled",
False,
) )
self.lxmf_user_icon_name = self.StringConfig(self, "lxmf_user_icon_name", None) self.lxmf_user_icon_name = self.StringConfig(self, "lxmf_user_icon_name", None)
self.lxmf_user_icon_foreground_colour = self.StringConfig( self.lxmf_user_icon_foreground_colour = self.StringConfig(
self, "lxmf_user_icon_foreground_colour", None, self,
"lxmf_user_icon_foreground_colour",
None,
) )
self.lxmf_user_icon_background_colour = self.StringConfig( self.lxmf_user_icon_background_colour = self.StringConfig(
self, "lxmf_user_icon_background_colour", None, self,
"lxmf_user_icon_background_colour",
None,
) )
self.lxmf_inbound_stamp_cost = self.IntConfig( self.lxmf_inbound_stamp_cost = self.IntConfig(
self, "lxmf_inbound_stamp_cost", 8, self,
"lxmf_inbound_stamp_cost",
8,
) # for direct delivery messages ) # for direct delivery messages
self.lxmf_propagation_node_stamp_cost = self.IntConfig( self.lxmf_propagation_node_stamp_cost = self.IntConfig(
self, "lxmf_propagation_node_stamp_cost", 16, self,
"lxmf_propagation_node_stamp_cost",
16,
) # for propagation node messages ) # for propagation node messages
self.page_archiver_enabled = self.BoolConfig(self, "page_archiver_enabled", True) self.page_archiver_enabled = self.BoolConfig(
self.page_archiver_max_versions = self.IntConfig(self, "page_archiver_max_versions", 5) self, "page_archiver_enabled", True
self.archives_max_storage_gb = self.IntConfig(self, "archives_max_storage_gb", 1) )
self.page_archiver_max_versions = self.IntConfig(
self, "page_archiver_max_versions", 5
)
self.archives_max_storage_gb = self.IntConfig(
self, "archives_max_storage_gb", 1
)
self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False) self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False)
self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3) self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3)
self.crawler_retry_delay_seconds = self.IntConfig(self, "crawler_retry_delay_seconds", 3600) self.crawler_retry_delay_seconds = self.IntConfig(
self, "crawler_retry_delay_seconds", 3600
)
self.crawler_max_concurrent = self.IntConfig(self, "crawler_max_concurrent", 1) self.crawler_max_concurrent = self.IntConfig(self, "crawler_max_concurrent", 1)
self.auth_enabled = self.BoolConfig(self, "auth_enabled", False) self.auth_enabled = self.BoolConfig(self, "auth_enabled", False)
self.auth_password_hash = self.StringConfig(self, "auth_password_hash", None) self.auth_password_hash = self.StringConfig(self, "auth_password_hash", None)
self.auth_session_secret = self.StringConfig(self, "auth_session_secret", None) self.auth_session_secret = self.StringConfig(self, "auth_session_secret", None)
# voicemail config
self.voicemail_enabled = self.BoolConfig(self, "voicemail_enabled", False)
self.voicemail_greeting = self.StringConfig(
self,
"voicemail_greeting",
"Hello, I am not available right now. Please leave a message after the beep.",
)
self.voicemail_auto_answer_delay_seconds = self.IntConfig(
self,
"voicemail_auto_answer_delay_seconds",
20,
)
self.voicemail_max_recording_seconds = self.IntConfig(
self,
"voicemail_max_recording_seconds",
60,
)
# map config # map config
self.map_offline_enabled = self.BoolConfig(self, "map_offline_enabled", False) self.map_offline_enabled = self.BoolConfig(self, "map_offline_enabled", False)
self.map_offline_path = self.StringConfig(self, "map_offline_path", None) self.map_offline_path = self.StringConfig(self, "map_offline_path", None)
self.map_mbtiles_dir = self.StringConfig(self, "map_mbtiles_dir", None) self.map_mbtiles_dir = self.StringConfig(self, "map_mbtiles_dir", None)
self.map_tile_cache_enabled = self.BoolConfig(self, "map_tile_cache_enabled", True) self.map_tile_cache_enabled = self.BoolConfig(
self, "map_tile_cache_enabled", True
)
self.map_default_lat = self.StringConfig(self, "map_default_lat", "0.0") self.map_default_lat = self.StringConfig(self, "map_default_lat", "0.0")
self.map_default_lon = self.StringConfig(self, "map_default_lon", "0.0") self.map_default_lon = self.StringConfig(self, "map_default_lon", "0.0")
self.map_default_zoom = self.IntConfig(self, "map_default_zoom", 2) self.map_default_zoom = self.IntConfig(self, "map_default_zoom", 2)
self.map_tile_server_url = self.StringConfig( self.map_tile_server_url = self.StringConfig(
self, "map_tile_server_url", "https://tile.openstreetmap.org/{z}/{x}/{y}.png", self,
"map_tile_server_url",
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
) )
self.map_nominatim_api_url = self.StringConfig( self.map_nominatim_api_url = self.StringConfig(
self, "map_nominatim_api_url", "https://nominatim.openstreetmap.org", self,
"map_nominatim_api_url",
"https://nominatim.openstreetmap.org",
) )
def get(self, key: str, default_value=None) -> str | None: def get(self, key: str, default_value=None) -> str | None:
@@ -128,4 +191,3 @@ class ConfigManager:
def set(self, value: int): def set(self, value: int):
self.manager.set(self.key, str(value)) self.manager.set(self.key, str(value))

View File

@@ -6,6 +6,7 @@ from .misc import MiscDAO
from .provider import DatabaseProvider from .provider import DatabaseProvider
from .schema import DatabaseSchema from .schema import DatabaseSchema
from .telephone import TelephoneDAO from .telephone import TelephoneDAO
from .voicemails import VoicemailDAO
class Database: class Database:
@@ -17,12 +18,15 @@ class Database:
self.announces = AnnounceDAO(self.provider) self.announces = AnnounceDAO(self.provider)
self.misc = MiscDAO(self.provider) self.misc = MiscDAO(self.provider)
self.telephone = TelephoneDAO(self.provider) self.telephone = TelephoneDAO(self.provider)
self.voicemails = VoicemailDAO(self.provider)
def initialize(self): def initialize(self):
self.schema.initialize() self.schema.initialize()
def migrate_from_legacy(self, reticulum_config_dir, identity_hash_hex): def migrate_from_legacy(self, reticulum_config_dir, identity_hash_hex):
migrator = LegacyMigrator(self.provider, reticulum_config_dir, identity_hash_hex) migrator = LegacyMigrator(
self.provider, reticulum_config_dir, identity_hash_hex
)
if migrator.should_migrate(): if migrator.should_migrate():
return migrator.migrate() return migrator.migrate()
return False return False
@@ -32,4 +36,3 @@ class Database:
def close(self): def close(self):
self.provider.close() self.provider.close()

View File

@@ -13,16 +13,26 @@ class AnnounceDAO:
data = dict(data) data = dict(data)
fields = [ fields = [
"destination_hash", "aspect", "identity_hash", "identity_public_key", "destination_hash",
"app_data", "rssi", "snr", "quality", "aspect",
"identity_hash",
"identity_public_key",
"app_data",
"rssi",
"snr",
"quality",
] ]
# These are safe as they are from a hardcoded list # These are safe as they are from a hardcoded list
columns = ", ".join(fields) columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields)) placeholders = ", ".join(["?"] * len(fields))
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "destination_hash"]) update_set = ", ".join(
[f"{f} = EXCLUDED.{f}" for f in fields if f != "destination_hash"]
)
query = f"INSERT INTO announces ({columns}, updated_at) VALUES ({placeholders}, ?) " \ query = (
f"ON CONFLICT(destination_hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at" # noqa: S608 f"INSERT INTO announces ({columns}, updated_at) VALUES ({placeholders}, ?) "
f"ON CONFLICT(destination_hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at"
) # noqa: S608
params = [data.get(f) for f in fields] params = [data.get(f) for f in fields]
params.append(datetime.now(UTC)) params.append(datetime.now(UTC))
@@ -30,13 +40,19 @@ class AnnounceDAO:
def get_announces(self, aspect=None): def get_announces(self, aspect=None):
if aspect: if aspect:
return self.provider.fetchall("SELECT * FROM announces WHERE aspect = ?", (aspect,)) return self.provider.fetchall(
"SELECT * FROM announces WHERE aspect = ?", (aspect,)
)
return self.provider.fetchall("SELECT * FROM announces") return self.provider.fetchall("SELECT * FROM announces")
def get_announce_by_hash(self, destination_hash): def get_announce_by_hash(self, destination_hash):
return self.provider.fetchone("SELECT * FROM announces WHERE destination_hash = ?", (destination_hash,)) return self.provider.fetchone(
"SELECT * FROM announces WHERE destination_hash = ?", (destination_hash,)
)
def get_filtered_announces(self, aspect=None, search_term=None, limit=None, offset=0): def get_filtered_announces(
self, aspect=None, search_term=None, limit=None, offset=0
):
query = "SELECT * FROM announces WHERE 1=1" query = "SELECT * FROM announces WHERE 1=1"
params = [] params = []
if aspect: if aspect:
@@ -58,33 +74,49 @@ class AnnounceDAO:
# Custom Display Names # Custom Display Names
def upsert_custom_display_name(self, destination_hash, display_name): def upsert_custom_display_name(self, destination_hash, display_name):
now = datetime.now(UTC) now = datetime.now(UTC)
self.provider.execute(""" self.provider.execute(
"""
INSERT INTO custom_destination_display_names (destination_hash, display_name, updated_at) INSERT INTO custom_destination_display_names (destination_hash, display_name, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, updated_at = EXCLUDED.updated_at ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, updated_at = EXCLUDED.updated_at
""", (destination_hash, display_name, now)) """,
(destination_hash, display_name, now),
)
def get_custom_display_name(self, destination_hash): def get_custom_display_name(self, destination_hash):
row = self.provider.fetchone("SELECT display_name FROM custom_destination_display_names WHERE destination_hash = ?", (destination_hash,)) row = self.provider.fetchone(
"SELECT display_name FROM custom_destination_display_names WHERE destination_hash = ?",
(destination_hash,),
)
return row["display_name"] if row else None return row["display_name"] if row else None
def delete_custom_display_name(self, destination_hash): def delete_custom_display_name(self, destination_hash):
self.provider.execute("DELETE FROM custom_destination_display_names WHERE destination_hash = ?", (destination_hash,)) self.provider.execute(
"DELETE FROM custom_destination_display_names WHERE destination_hash = ?",
(destination_hash,),
)
# Favourites # Favourites
def upsert_favourite(self, destination_hash, display_name, aspect): def upsert_favourite(self, destination_hash, display_name, aspect):
now = datetime.now(UTC) now = datetime.now(UTC)
self.provider.execute(""" self.provider.execute(
"""
INSERT INTO favourite_destinations (destination_hash, display_name, aspect, updated_at) INSERT INTO favourite_destinations (destination_hash, display_name, aspect, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, aspect = EXCLUDED.aspect, updated_at = EXCLUDED.updated_at ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, aspect = EXCLUDED.aspect, updated_at = EXCLUDED.updated_at
""", (destination_hash, display_name, aspect, now)) """,
(destination_hash, display_name, aspect, now),
)
def get_favourites(self, aspect=None): def get_favourites(self, aspect=None):
if aspect: if aspect:
return self.provider.fetchall("SELECT * FROM favourite_destinations WHERE aspect = ?", (aspect,)) return self.provider.fetchall(
"SELECT * FROM favourite_destinations WHERE aspect = ?", (aspect,)
)
return self.provider.fetchall("SELECT * FROM favourite_destinations") return self.provider.fetchall("SELECT * FROM favourite_destinations")
def delete_favourite(self, destination_hash): def delete_favourite(self, destination_hash):
self.provider.execute("DELETE FROM favourite_destinations WHERE destination_hash = ?", (destination_hash,)) self.provider.execute(
"DELETE FROM favourite_destinations WHERE destination_hash = ?",
(destination_hash,),
)

View File

@@ -24,4 +24,3 @@ class ConfigDAO:
def delete(self, key): def delete(self, key):
self.provider.execute("DELETE FROM config WHERE key = ?", (key,)) self.provider.execute("DELETE FROM config WHERE key = ?", (key,))

View File

@@ -8,8 +8,7 @@ class LegacyMigrator:
self.identity_hash_hex = identity_hash_hex self.identity_hash_hex = identity_hash_hex
def get_legacy_db_path(self): def get_legacy_db_path(self):
"""Detect the path to the legacy database based on the Reticulum config directory. """Detect the path to the legacy database based on the Reticulum config directory."""
"""
possible_dirs = [] possible_dirs = []
if self.reticulum_config_dir: if self.reticulum_config_dir:
possible_dirs.append(self.reticulum_config_dir) possible_dirs.append(self.reticulum_config_dir)
@@ -21,7 +20,9 @@ class LegacyMigrator:
# Check each directory # Check each directory
for config_dir in possible_dirs: for config_dir in possible_dirs:
legacy_path = os.path.join(config_dir, "identities", self.identity_hash_hex, "database.db") legacy_path = os.path.join(
config_dir, "identities", self.identity_hash_hex, "database.db"
)
if os.path.exists(legacy_path): if os.path.exists(legacy_path):
# Ensure it's not the same as our current DB path # Ensure it's not the same as our current DB path
# (though this is unlikely given the different base directories) # (though this is unlikely given the different base directories)
@@ -58,8 +59,7 @@ class LegacyMigrator:
return True return True
def migrate(self): def migrate(self):
"""Perform the migration from the legacy database. """Perform the migration from the legacy database."""
"""
legacy_path = self.get_legacy_db_path() legacy_path = self.get_legacy_db_path()
if not legacy_path: if not legacy_path:
return False return False
@@ -100,11 +100,23 @@ class LegacyMigrator:
if res: if res:
# Get columns from both databases to ensure compatibility # Get columns from both databases to ensure compatibility
# These PRAGMA calls are safe as they use controlled table/alias names # These PRAGMA calls are safe as they use controlled table/alias names
legacy_columns = [row["name"] for row in self.provider.fetchall(f"PRAGMA {alias}.table_info({table})")] legacy_columns = [
current_columns = [row["name"] for row in self.provider.fetchall(f"PRAGMA table_info({table})")] row["name"]
for row in self.provider.fetchall(
f"PRAGMA {alias}.table_info({table})"
)
]
current_columns = [
row["name"]
for row in self.provider.fetchall(
f"PRAGMA table_info({table})"
)
]
# Find common columns # Find common columns
common_columns = [col for col in legacy_columns if col in current_columns] common_columns = [
col for col in legacy_columns if col in current_columns
]
if common_columns: if common_columns:
cols_str = ", ".join(common_columns) cols_str = ", ".join(common_columns)
@@ -112,9 +124,13 @@ class LegacyMigrator:
# The table and columns are controlled by us # The table and columns are controlled by us
migrate_query = f"INSERT OR IGNORE INTO {table} ({cols_str}) SELECT {cols_str} FROM {alias}.{table}" # noqa: S608 migrate_query = f"INSERT OR IGNORE INTO {table} ({cols_str}) SELECT {cols_str} FROM {alias}.{table}" # noqa: S608
self.provider.execute(migrate_query) self.provider.execute(migrate_query)
print(f" - Migrated table: {table} ({len(common_columns)} columns)") print(
f" - Migrated table: {table} ({len(common_columns)} columns)"
)
else: else:
print(f" - Skipping table {table}: No common columns found") print(
f" - Skipping table {table}: No common columns found"
)
except Exception as e: except Exception as e:
print(f" - Failed to migrate table {table}: {e}") print(f" - Failed to migrate table {table}: {e}")

View File

@@ -15,17 +15,33 @@ class MessageDAO:
# Ensure all required fields are present and handle defaults # Ensure all required fields are present and handle defaults
fields = [ fields = [
"hash", "source_hash", "destination_hash", "state", "progress", "hash",
"is_incoming", "method", "delivery_attempts", "next_delivery_attempt_at", "source_hash",
"title", "content", "fields", "timestamp", "rssi", "snr", "quality", "is_spam", "destination_hash",
"state",
"progress",
"is_incoming",
"method",
"delivery_attempts",
"next_delivery_attempt_at",
"title",
"content",
"fields",
"timestamp",
"rssi",
"snr",
"quality",
"is_spam",
] ]
columns = ", ".join(fields) columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields)) placeholders = ", ".join(["?"] * len(fields))
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "hash"]) update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "hash"])
query = f"INSERT INTO lxmf_messages ({columns}, updated_at) VALUES ({placeholders}, ?) " \ query = (
f"ON CONFLICT(hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at" # noqa: S608 f"INSERT INTO lxmf_messages ({columns}, updated_at) VALUES ({placeholders}, ?) "
f"ON CONFLICT(hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at"
) # noqa: S608
params = [] params = []
for f in fields: for f in fields:
@@ -38,10 +54,14 @@ class MessageDAO:
self.provider.execute(query, params) self.provider.execute(query, params)
def get_lxmf_message_by_hash(self, message_hash): def get_lxmf_message_by_hash(self, message_hash):
return self.provider.fetchone("SELECT * FROM lxmf_messages WHERE hash = ?", (message_hash,)) return self.provider.fetchone(
"SELECT * FROM lxmf_messages WHERE hash = ?", (message_hash,)
)
def delete_lxmf_message_by_hash(self, message_hash): def delete_lxmf_message_by_hash(self, message_hash):
self.provider.execute("DELETE FROM lxmf_messages WHERE hash = ?", (message_hash,)) self.provider.execute(
"DELETE FROM lxmf_messages WHERE hash = ?", (message_hash,)
)
def get_conversation_messages(self, destination_hash, limit=100, offset=0): def get_conversation_messages(self, destination_hash, limit=100, offset=0):
return self.provider.fetchall( return self.provider.fetchall(
@@ -73,13 +93,16 @@ class MessageDAO:
) )
def is_conversation_unread(self, destination_hash): def is_conversation_unread(self, destination_hash):
row = self.provider.fetchone(""" row = self.provider.fetchone(
"""
SELECT m.timestamp, r.last_read_at SELECT m.timestamp, r.last_read_at
FROM lxmf_messages m FROM lxmf_messages m
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = ? LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = ?
WHERE (m.destination_hash = ? OR m.source_hash = ?) WHERE (m.destination_hash = ? OR m.source_hash = ?)
ORDER BY m.timestamp DESC LIMIT 1 ORDER BY m.timestamp DESC LIMIT 1
""", (destination_hash, destination_hash, destination_hash)) """,
(destination_hash, destination_hash, destination_hash),
)
if not row: if not row:
return False return False
@@ -93,13 +116,16 @@ class MessageDAO:
return row["timestamp"] > last_read_at.timestamp() return row["timestamp"] > last_read_at.timestamp()
def mark_stuck_messages_as_failed(self): def mark_stuck_messages_as_failed(self):
self.provider.execute(""" self.provider.execute(
"""
UPDATE lxmf_messages UPDATE lxmf_messages
SET state = 'failed', updated_at = ? SET state = 'failed', updated_at = ?
WHERE state = 'outbound' WHERE state = 'outbound'
OR (state = 'sent' AND method = 'opportunistic') OR (state = 'sent' AND method = 'opportunistic')
OR state = 'sending' OR state = 'sending'
""", (datetime.now(UTC).isoformat(),)) """,
(datetime.now(UTC).isoformat(),),
)
def get_failed_messages_for_destination(self, destination_hash): def get_failed_messages_for_destination(self, destination_hash):
return self.provider.fetchall( return self.provider.fetchall(
@@ -115,9 +141,14 @@ class MessageDAO:
return row["count"] if row else 0 return row["count"] if row else 0
# Forwarding Mappings # Forwarding Mappings
def get_forwarding_mapping(self, alias_hash=None, original_sender_hash=None, final_recipient_hash=None): def get_forwarding_mapping(
self, alias_hash=None, original_sender_hash=None, final_recipient_hash=None
):
if alias_hash: if alias_hash:
return self.provider.fetchone("SELECT * FROM lxmf_forwarding_mappings WHERE alias_hash = ?", (alias_hash,)) return self.provider.fetchone(
"SELECT * FROM lxmf_forwarding_mappings WHERE alias_hash = ?",
(alias_hash,),
)
if original_sender_hash and final_recipient_hash: if original_sender_hash and final_recipient_hash:
return self.provider.fetchone( return self.provider.fetchone(
"SELECT * FROM lxmf_forwarding_mappings WHERE original_sender_hash = ? AND final_recipient_hash = ?", "SELECT * FROM lxmf_forwarding_mappings WHERE original_sender_hash = ? AND final_recipient_hash = ?",
@@ -131,8 +162,11 @@ class MessageDAO:
data = dict(data) data = dict(data)
fields = [ fields = [
"alias_identity_private_key", "alias_hash", "original_sender_hash", "alias_identity_private_key",
"final_recipient_hash", "original_destination_hash", "alias_hash",
"original_sender_hash",
"final_recipient_hash",
"original_destination_hash",
] ]
columns = ", ".join(fields) columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields)) placeholders = ", ".join(["?"] * len(fields))
@@ -143,4 +177,3 @@ class MessageDAO:
def get_all_forwarding_mappings(self): def get_all_forwarding_mappings(self):
return self.provider.fetchall("SELECT * FROM lxmf_forwarding_mappings") return self.provider.fetchall("SELECT * FROM lxmf_forwarding_mappings")

View File

@@ -15,13 +15,22 @@ class MiscDAO:
) )
def is_destination_blocked(self, destination_hash): def is_destination_blocked(self, destination_hash):
return self.provider.fetchone("SELECT 1 FROM blocked_destinations WHERE destination_hash = ?", (destination_hash,)) is not None return (
self.provider.fetchone(
"SELECT 1 FROM blocked_destinations WHERE destination_hash = ?",
(destination_hash,),
)
is not None
)
def get_blocked_destinations(self): def get_blocked_destinations(self):
return self.provider.fetchall("SELECT * FROM blocked_destinations") return self.provider.fetchall("SELECT * FROM blocked_destinations")
def delete_blocked_destination(self, destination_hash): def delete_blocked_destination(self, destination_hash):
self.provider.execute("DELETE FROM blocked_destinations WHERE destination_hash = ?", (destination_hash,)) self.provider.execute(
"DELETE FROM blocked_destinations WHERE destination_hash = ?",
(destination_hash,),
)
# Spam Keywords # Spam Keywords
def add_spam_keyword(self, keyword): def add_spam_keyword(self, keyword):
@@ -45,9 +54,12 @@ class MiscDAO:
return False return False
# User Icons # User Icons
def update_lxmf_user_icon(self, destination_hash, icon_name, foreground_colour, background_colour): def update_lxmf_user_icon(
self, destination_hash, icon_name, foreground_colour, background_colour
):
now = datetime.now(UTC) now = datetime.now(UTC)
self.provider.execute(""" self.provider.execute(
"""
INSERT INTO lxmf_user_icons (destination_hash, icon_name, foreground_colour, background_colour, updated_at) INSERT INTO lxmf_user_icons (destination_hash, icon_name, foreground_colour, background_colour, updated_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET ON CONFLICT(destination_hash) DO UPDATE SET
@@ -55,10 +67,15 @@ class MiscDAO:
foreground_colour = EXCLUDED.foreground_colour, foreground_colour = EXCLUDED.foreground_colour,
background_colour = EXCLUDED.background_colour, background_colour = EXCLUDED.background_colour,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
""", (destination_hash, icon_name, foreground_colour, background_colour, now)) """,
(destination_hash, icon_name, foreground_colour, background_colour, now),
)
def get_user_icon(self, destination_hash): def get_user_icon(self, destination_hash):
return self.provider.fetchone("SELECT * FROM lxmf_user_icons WHERE destination_hash = ?", (destination_hash,)) return self.provider.fetchone(
"SELECT * FROM lxmf_user_icons WHERE destination_hash = ?",
(destination_hash,),
)
# Forwarding Rules # Forwarding Rules
def get_forwarding_rules(self, identity_hash=None, active_only=False): def get_forwarding_rules(self, identity_hash=None, active_only=False):
@@ -71,18 +88,31 @@ class MiscDAO:
query += " AND is_active = 1" query += " AND is_active = 1"
return self.provider.fetchall(query, params) return self.provider.fetchall(query, params)
def create_forwarding_rule(self, identity_hash, forward_to_hash, source_filter_hash, is_active=True): def create_forwarding_rule(
self, identity_hash, forward_to_hash, source_filter_hash, is_active=True
):
now = datetime.now(UTC) now = datetime.now(UTC)
self.provider.execute( self.provider.execute(
"INSERT INTO lxmf_forwarding_rules (identity_hash, forward_to_hash, source_filter_hash, is_active, updated_at) VALUES (?, ?, ?, ?, ?)", "INSERT INTO lxmf_forwarding_rules (identity_hash, forward_to_hash, source_filter_hash, is_active, updated_at) VALUES (?, ?, ?, ?, ?)",
(identity_hash, forward_to_hash, source_filter_hash, 1 if is_active else 0, now), (
identity_hash,
forward_to_hash,
source_filter_hash,
1 if is_active else 0,
now,
),
) )
def delete_forwarding_rule(self, rule_id): def delete_forwarding_rule(self, rule_id):
self.provider.execute("DELETE FROM lxmf_forwarding_rules WHERE id = ?", (rule_id,)) self.provider.execute(
"DELETE FROM lxmf_forwarding_rules WHERE id = ?", (rule_id,)
)
def toggle_forwarding_rule(self, rule_id): def toggle_forwarding_rule(self, rule_id):
self.provider.execute("UPDATE lxmf_forwarding_rules SET is_active = NOT is_active WHERE id = ?", (rule_id,)) self.provider.execute(
"UPDATE lxmf_forwarding_rules SET is_active = NOT is_active WHERE id = ?",
(rule_id,),
)
# Archived Pages # Archived Pages
def archive_page(self, destination_hash, page_path, content, page_hash): def archive_page(self, destination_hash, page_path, content, page_hash):
@@ -105,7 +135,9 @@ class MiscDAO:
params.append(destination_hash) params.append(destination_hash)
if query: if query:
like_term = f"%{query}%" like_term = f"%{query}%"
sql += " AND (destination_hash LIKE ? OR page_path LIKE ? OR content LIKE ?)" sql += (
" AND (destination_hash LIKE ? OR page_path LIKE ? OR content LIKE ?)"
)
params.extend([like_term, like_term, like_term]) params.extend([like_term, like_term, like_term])
sql += " ORDER BY created_at DESC" sql += " ORDER BY created_at DESC"
@@ -113,25 +145,41 @@ class MiscDAO:
def delete_archived_pages(self, destination_hash=None, page_path=None): def delete_archived_pages(self, destination_hash=None, page_path=None):
if destination_hash and page_path: if destination_hash and page_path:
self.provider.execute("DELETE FROM archived_pages WHERE destination_hash = ? AND page_path = ?", (destination_hash, page_path)) self.provider.execute(
"DELETE FROM archived_pages WHERE destination_hash = ? AND page_path = ?",
(destination_hash, page_path),
)
else: else:
self.provider.execute("DELETE FROM archived_pages") self.provider.execute("DELETE FROM archived_pages")
# Crawl Tasks # Crawl Tasks
def upsert_crawl_task(self, destination_hash, page_path, status="pending", retry_count=0): def upsert_crawl_task(
self.provider.execute(""" self, destination_hash, page_path, status="pending", retry_count=0
):
self.provider.execute(
"""
INSERT INTO crawl_tasks (destination_hash, page_path, status, retry_count) INSERT INTO crawl_tasks (destination_hash, page_path, status, retry_count)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(destination_hash, page_path) DO UPDATE SET ON CONFLICT(destination_hash, page_path) DO UPDATE SET
status = EXCLUDED.status, status = EXCLUDED.status,
retry_count = EXCLUDED.retry_count retry_count = EXCLUDED.retry_count
""", (destination_hash, page_path, status, retry_count)) """,
(destination_hash, page_path, status, retry_count),
)
def get_pending_crawl_tasks(self): def get_pending_crawl_tasks(self):
return self.provider.fetchall("SELECT * FROM crawl_tasks WHERE status = 'pending'") return self.provider.fetchall(
"SELECT * FROM crawl_tasks WHERE status = 'pending'"
)
def update_crawl_task(self, task_id, **kwargs): def update_crawl_task(self, task_id, **kwargs):
allowed_keys = {"destination_hash", "page_path", "status", "retry_count", "updated_at"} allowed_keys = {
"destination_hash",
"page_path",
"status",
"retry_count",
"updated_at",
}
filtered_kwargs = {k: v for k, v in kwargs.items() if k in allowed_keys} filtered_kwargs = {k: v for k, v in kwargs.items() if k in allowed_keys}
if not filtered_kwargs: if not filtered_kwargs:
@@ -150,5 +198,6 @@ class MiscDAO:
) )
def get_archived_page_by_id(self, archive_id): def get_archived_page_by_id(self, archive_id):
return self.provider.fetchone("SELECT * FROM archived_pages WHERE id = ?", (archive_id,)) return self.provider.fetchone(
"SELECT * FROM archived_pages WHERE id = ?", (archive_id,)
)

View File

@@ -23,7 +23,9 @@ class DatabaseProvider:
@property @property
def connection(self): def connection(self):
if not hasattr(self._local, "connection"): if not hasattr(self._local, "connection"):
self._local.connection = sqlite3.connect(self.db_path, check_same_thread=False) self._local.connection = sqlite3.connect(
self.db_path, check_same_thread=False
)
self._local.connection.row_factory = sqlite3.Row self._local.connection.row_factory = sqlite3.Row
# Enable WAL mode for better concurrency # Enable WAL mode for better concurrency
self._local.connection.execute("PRAGMA journal_mode=WAL") self._local.connection.execute("PRAGMA journal_mode=WAL")
@@ -62,4 +64,3 @@ class DatabaseProvider:
def checkpoint(self): def checkpoint(self):
return self.fetchall("PRAGMA wal_checkpoint(TRUNCATE)") return self.fetchall("PRAGMA wal_checkpoint(TRUNCATE)")

View File

@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema: class DatabaseSchema:
LATEST_VERSION = 12 LATEST_VERSION = 13
def __init__(self, provider: DatabaseProvider): def __init__(self, provider: DatabaseProvider):
self.provider = provider self.provider = provider
@@ -16,7 +16,9 @@ class DatabaseSchema:
self.migrate(current_version) self.migrate(current_version)
def _get_current_version(self): def _get_current_version(self):
row = self.provider.fetchone("SELECT value FROM config WHERE key = ?", ("database_version",)) row = self.provider.fetchone(
"SELECT value FROM config WHERE key = ?", ("database_version",)
)
if row: if row:
return int(row["value"]) return int(row["value"])
return 0 return 0
@@ -189,21 +191,45 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
""", """,
"voicemails": """
CREATE TABLE IF NOT EXISTS voicemails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remote_identity_hash TEXT,
remote_identity_name TEXT,
filename TEXT,
duration_seconds INTEGER,
is_read INTEGER DEFAULT 0,
timestamp REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
} }
for table_name, create_sql in tables.items(): for table_name, create_sql in tables.items():
self.provider.execute(create_sql) self.provider.execute(create_sql)
# Create indexes that were present # Create indexes that were present
if table_name == "announces": if table_name == "announces":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_announces_aspect ON announces(aspect)") self.provider.execute(
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_announces_identity_hash ON announces(identity_hash)") "CREATE INDEX IF NOT EXISTS idx_announces_aspect ON announces(aspect)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_announces_identity_hash ON announces(identity_hash)"
)
elif table_name == "lxmf_messages": elif table_name == "lxmf_messages":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_messages_source_hash ON lxmf_messages(source_hash)") self.provider.execute(
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_messages_destination_hash ON lxmf_messages(destination_hash)") "CREATE INDEX IF NOT EXISTS idx_lxmf_messages_source_hash ON lxmf_messages(source_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_messages_destination_hash ON lxmf_messages(destination_hash)"
)
elif table_name == "blocked_destinations": elif table_name == "blocked_destinations":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_blocked_destinations_hash ON blocked_destinations(destination_hash)") self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_blocked_destinations_hash ON blocked_destinations(destination_hash)"
)
elif table_name == "spam_keywords": elif table_name == "spam_keywords":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_spam_keywords_keyword ON spam_keywords(keyword)") self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_spam_keywords_keyword ON spam_keywords(keyword)"
)
def migrate(self, current_version): def migrate(self, current_version):
if current_version < 7: if current_version < 7:
@@ -217,9 +243,15 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
""") """)
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_destination_hash ON archived_pages(destination_hash)") self.provider.execute(
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_page_path ON archived_pages(page_path)") "CREATE INDEX IF NOT EXISTS idx_archived_pages_destination_hash ON archived_pages(destination_hash)"
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_hash ON archived_pages(hash)") )
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_archived_pages_page_path ON archived_pages(page_path)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_archived_pages_hash ON archived_pages(hash)"
)
if current_version < 8: if current_version < 8:
self.provider.execute(""" self.provider.execute("""
@@ -234,8 +266,12 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
""") """)
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_crawl_tasks_destination_hash ON crawl_tasks(destination_hash)") self.provider.execute(
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_crawl_tasks_page_path ON crawl_tasks(page_path)") "CREATE INDEX IF NOT EXISTS idx_crawl_tasks_destination_hash ON crawl_tasks(destination_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_crawl_tasks_page_path ON crawl_tasks(page_path)"
)
if current_version < 9: if current_version < 9:
self.provider.execute(""" self.provider.execute("""
@@ -249,7 +285,9 @@ class DatabaseSchema:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
""") """)
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_rules_identity_hash ON lxmf_forwarding_rules(identity_hash)") self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_rules_identity_hash ON lxmf_forwarding_rules(identity_hash)"
)
self.provider.execute(""" self.provider.execute("""
CREATE TABLE IF NOT EXISTS lxmf_forwarding_mappings ( CREATE TABLE IF NOT EXISTS lxmf_forwarding_mappings (
@@ -262,9 +300,15 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
""") """)
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_alias_hash ON lxmf_forwarding_mappings(alias_hash)") self.provider.execute(
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_sender_hash ON lxmf_forwarding_mappings(original_sender_hash)") "CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_alias_hash ON lxmf_forwarding_mappings(alias_hash)"
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_recipient_hash ON lxmf_forwarding_mappings(final_recipient_hash)") )
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_sender_hash ON lxmf_forwarding_mappings(original_sender_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_recipient_hash ON lxmf_forwarding_mappings(final_recipient_hash)"
)
if current_version < 10: if current_version < 10:
# Ensure unique constraints exist for ON CONFLICT clauses # Ensure unique constraints exist for ON CONFLICT clauses
@@ -272,26 +316,56 @@ class DatabaseSchema:
# but a UNIQUE index works for ON CONFLICT. # but a UNIQUE index works for ON CONFLICT.
# Clean up duplicates before adding unique indexes # Clean up duplicates before adding unique indexes
self.provider.execute("DELETE FROM announces WHERE id NOT IN (SELECT MAX(id) FROM announces GROUP BY destination_hash)") self.provider.execute(
self.provider.execute("DELETE FROM crawl_tasks WHERE id NOT IN (SELECT MAX(id) FROM crawl_tasks GROUP BY destination_hash, page_path)") "DELETE FROM announces WHERE id NOT IN (SELECT MAX(id) FROM announces GROUP BY destination_hash)"
self.provider.execute("DELETE FROM custom_destination_display_names WHERE id NOT IN (SELECT MAX(id) FROM custom_destination_display_names GROUP BY destination_hash)") )
self.provider.execute("DELETE FROM favourite_destinations WHERE id NOT IN (SELECT MAX(id) FROM favourite_destinations GROUP BY destination_hash)") self.provider.execute(
self.provider.execute("DELETE FROM lxmf_user_icons WHERE id NOT IN (SELECT MAX(id) FROM lxmf_user_icons GROUP BY destination_hash)") "DELETE FROM crawl_tasks WHERE id NOT IN (SELECT MAX(id) FROM crawl_tasks GROUP BY destination_hash, page_path)"
self.provider.execute("DELETE FROM lxmf_conversation_read_state WHERE id NOT IN (SELECT MAX(id) FROM lxmf_conversation_read_state GROUP BY destination_hash)") )
self.provider.execute("DELETE FROM lxmf_messages WHERE id NOT IN (SELECT MAX(id) FROM lxmf_messages GROUP BY hash)") self.provider.execute(
"DELETE FROM custom_destination_display_names WHERE id NOT IN (SELECT MAX(id) FROM custom_destination_display_names GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM favourite_destinations WHERE id NOT IN (SELECT MAX(id) FROM favourite_destinations GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM lxmf_user_icons WHERE id NOT IN (SELECT MAX(id) FROM lxmf_user_icons GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM lxmf_conversation_read_state WHERE id NOT IN (SELECT MAX(id) FROM lxmf_conversation_read_state GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM lxmf_messages WHERE id NOT IN (SELECT MAX(id) FROM lxmf_messages GROUP BY hash)"
)
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_announces_destination_hash_unique ON announces(destination_hash)") self.provider.execute(
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_crawl_tasks_destination_path_unique ON crawl_tasks(destination_hash, page_path)") "CREATE UNIQUE INDEX IF NOT EXISTS idx_announces_destination_hash_unique ON announces(destination_hash)"
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_display_names_dest_hash_unique ON custom_destination_display_names(destination_hash)") )
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_favourite_destinations_dest_hash_unique ON favourite_destinations(destination_hash)") self.provider.execute(
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_messages_hash_unique ON lxmf_messages(hash)") "CREATE UNIQUE INDEX IF NOT EXISTS idx_crawl_tasks_destination_path_unique ON crawl_tasks(destination_hash, page_path)"
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_user_icons_dest_hash_unique ON lxmf_user_icons(destination_hash)") )
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_conversation_read_state_dest_hash_unique ON lxmf_conversation_read_state(destination_hash)") self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_display_names_dest_hash_unique ON custom_destination_display_names(destination_hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_favourite_destinations_dest_hash_unique ON favourite_destinations(destination_hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_messages_hash_unique ON lxmf_messages(hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_user_icons_dest_hash_unique ON lxmf_user_icons(destination_hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_conversation_read_state_dest_hash_unique ON lxmf_conversation_read_state(destination_hash)"
)
if current_version < 11: if current_version < 11:
# Add is_spam column to lxmf_messages if it doesn't exist # Add is_spam column to lxmf_messages if it doesn't exist
try: try:
self.provider.execute("ALTER TABLE lxmf_messages ADD COLUMN is_spam INTEGER DEFAULT 0") self.provider.execute(
"ALTER TABLE lxmf_messages ADD COLUMN is_spam INTEGER DEFAULT 0"
)
except Exception: except Exception:
# Column might already exist if table was created with newest schema # Column might already exist if table was created with newest schema
pass pass
@@ -309,9 +383,35 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
""") """)
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_call_history_remote_hash ON call_history(remote_identity_hash)") self.provider.execute(
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_call_history_timestamp ON call_history(timestamp)") "CREATE INDEX IF NOT EXISTS idx_call_history_remote_hash ON call_history(remote_identity_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_call_history_timestamp ON call_history(timestamp)"
)
if current_version < 13:
self.provider.execute("""
CREATE TABLE IF NOT EXISTS voicemails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remote_identity_hash TEXT,
remote_identity_name TEXT,
filename TEXT,
duration_seconds INTEGER,
is_read INTEGER DEFAULT 0,
timestamp REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_voicemails_remote_hash ON voicemails(remote_identity_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_voicemails_timestamp ON voicemails(timestamp)"
)
# Update version in config # Update version in config
self.provider.execute("INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", ("database_version", str(self.LATEST_VERSION))) self.provider.execute(
"INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
("database_version", str(self.LATEST_VERSION)),
)

View File

@@ -1,4 +1,3 @@
from .provider import DatabaseProvider from .provider import DatabaseProvider
@@ -41,4 +40,3 @@ class TelephoneDAO:
"SELECT * FROM call_history ORDER BY timestamp DESC LIMIT ?", "SELECT * FROM call_history ORDER BY timestamp DESC LIMIT ?",
(limit,), (limit,),
) )

View File

@@ -0,0 +1,63 @@
from .provider import DatabaseProvider
class VoicemailDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
def add_voicemail(
self,
remote_identity_hash,
remote_identity_name,
filename,
duration_seconds,
timestamp,
):
self.provider.execute(
"""
INSERT INTO voicemails (
remote_identity_hash,
remote_identity_name,
filename,
duration_seconds,
timestamp
) VALUES (?, ?, ?, ?, ?)
""",
(
remote_identity_hash,
remote_identity_name,
filename,
duration_seconds,
timestamp,
),
)
def get_voicemails(self, limit=50, offset=0):
return self.provider.fetchall(
"SELECT * FROM voicemails ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset),
)
def get_voicemail(self, voicemail_id):
return self.provider.fetchone(
"SELECT * FROM voicemails WHERE id = ?",
(voicemail_id,),
)
def mark_as_read(self, voicemail_id):
self.provider.execute(
"UPDATE voicemails SET is_read = 1 WHERE id = ?",
(voicemail_id,),
)
def delete_voicemail(self, voicemail_id):
self.provider.execute(
"DELETE FROM voicemails WHERE id = ?",
(voicemail_id,),
)
def get_unread_count(self):
row = self.provider.fetchone(
"SELECT COUNT(*) as count FROM voicemails WHERE is_read = 0"
)
return row["count"] if row else 0

View File

@@ -15,14 +15,20 @@ class ForwardingManager:
mappings = self.db.messages.get_all_forwarding_mappings() mappings = self.db.messages.get_all_forwarding_mappings()
for mapping in mappings: for mapping in mappings:
try: try:
private_key_bytes = base64.b64decode(mapping["alias_identity_private_key"]) private_key_bytes = base64.b64decode(
mapping["alias_identity_private_key"]
)
alias_identity = RNS.Identity.from_bytes(private_key_bytes) alias_identity = RNS.Identity.from_bytes(private_key_bytes)
alias_destination = self.message_router.register_delivery_identity(identity=alias_identity) alias_destination = self.message_router.register_delivery_identity(
identity=alias_identity
)
self.forwarding_destinations[mapping["alias_hash"]] = alias_destination self.forwarding_destinations[mapping["alias_hash"]] = alias_destination
except Exception as e: except Exception as e:
print(f"Failed to load forwarding alias {mapping['alias_hash']}: {e}") print(f"Failed to load forwarding alias {mapping['alias_hash']}: {e}")
def get_or_create_mapping(self, source_hash, final_recipient_hash, original_destination_hash): def get_or_create_mapping(
self, source_hash, final_recipient_hash, original_destination_hash
):
mapping = self.db.messages.get_forwarding_mapping( mapping = self.db.messages.get_forwarding_mapping(
original_sender_hash=source_hash, original_sender_hash=source_hash,
final_recipient_hash=final_recipient_hash, final_recipient_hash=final_recipient_hash,
@@ -32,11 +38,15 @@ class ForwardingManager:
alias_identity = RNS.Identity() alias_identity = RNS.Identity()
alias_hash = alias_identity.hash.hex() alias_hash = alias_identity.hash.hex()
alias_destination = self.message_router.register_delivery_identity(alias_identity) alias_destination = self.message_router.register_delivery_identity(
alias_identity
)
self.forwarding_destinations[alias_hash] = alias_destination self.forwarding_destinations[alias_hash] = alias_destination
data = { data = {
"alias_identity_private_key": base64.b64encode(alias_identity.get_private_key()).decode(), "alias_identity_private_key": base64.b64encode(
alias_identity.get_private_key()
).decode(),
"alias_hash": alias_hash, "alias_hash": alias_hash,
"original_sender_hash": source_hash, "original_sender_hash": source_hash,
"final_recipient_hash": final_recipient_hash, "final_recipient_hash": final_recipient_hash,
@@ -45,4 +55,3 @@ class ForwardingManager:
self.db.messages.create_forwarding_mapping(data) self.db.messages.create_forwarding_mapping(data)
return data return data
return mapping return mapping

View File

@@ -55,13 +55,15 @@ class MapManager:
if f.endswith(".mbtiles"): if f.endswith(".mbtiles"):
full_path = os.path.join(mbtiles_dir, f) full_path = os.path.join(mbtiles_dir, f)
stats = os.stat(full_path) stats = os.stat(full_path)
files.append({ files.append(
"name": f, {
"path": full_path, "name": f,
"size": stats.st_size, "path": full_path,
"mtime": stats.st_mtime, "size": stats.st_size,
"is_active": full_path == self.get_offline_path(), "mtime": stats.st_mtime,
}) "is_active": full_path == self.get_offline_path(),
}
)
return sorted(files, key=lambda x: x["mtime"], reverse=True) return sorted(files, key=lambda x: x["mtime"], reverse=True)
def delete_mbtiles(self, filename): def delete_mbtiles(self, filename):
@@ -97,7 +99,10 @@ class MapManager:
# Basic validation: ensure it's raster (format is not pbf) # Basic validation: ensure it's raster (format is not pbf)
if metadata.get("format") == "pbf": if metadata.get("format") == "pbf":
RNS.log("MBTiles file is in vector (PBF) format, which is not supported.", RNS.LOG_ERROR) RNS.log(
"MBTiles file is in vector (PBF) format, which is not supported.",
RNS.LOG_ERROR,
)
return None return None
self._metadata_cache = metadata self._metadata_cache = metadata
@@ -176,8 +181,12 @@ class MapManager:
# create schema # create schema
cursor.execute("CREATE TABLE metadata (name text, value text)") cursor.execute("CREATE TABLE metadata (name text, value text)")
cursor.execute("CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)") cursor.execute(
cursor.execute("CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row)") "CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)"
)
cursor.execute(
"CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row)"
)
# insert metadata # insert metadata
metadata = [ metadata = [
@@ -205,7 +214,11 @@ class MapManager:
# wait a bit to be nice to OSM # wait a bit to be nice to OSM
time.sleep(0.1) time.sleep(0.1)
response = requests.get(tile_url, headers={"User-Agent": "MeshChatX/1.0 MapExporter"}, timeout=10) response = requests.get(
tile_url,
headers={"User-Agent": "MeshChatX/1.0 MapExporter"},
timeout=10,
)
if response.status_code == 200: if response.status_code == 200:
# MBTiles uses TMS (y flipped) # MBTiles uses TMS (y flipped)
tms_y = (1 << z) - 1 - y tms_y = (1 << z) - 1 - y
@@ -214,11 +227,16 @@ class MapManager:
(z, x, tms_y, response.content), (z, x, tms_y, response.content),
) )
except Exception as e: except Exception as e:
RNS.log(f"Export failed to download tile {z}/{x}/{y}: {e}", RNS.LOG_ERROR) RNS.log(
f"Export failed to download tile {z}/{x}/{y}: {e}",
RNS.LOG_ERROR,
)
current_count += 1 current_count += 1
self._export_progress[export_id]["current"] = current_count self._export_progress[export_id]["current"] = current_count
self._export_progress[export_id]["progress"] = int((current_count / total_tiles) * 100) self._export_progress[export_id]["progress"] = int(
(current_count / total_tiles) * 100
)
# commit after each zoom level # commit after each zoom level
conn.commit() conn.commit()
@@ -236,9 +254,13 @@ class MapManager:
def _lonlat_to_tile(self, lon, lat, zoom): def _lonlat_to_tile(self, lon, lat, zoom):
lat_rad = math.radians(lat) lat_rad = math.radians(lat)
n = 2.0 ** zoom n = 2.0**zoom
x = int((lon + 180.0) / 360.0 * n) x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n) y = int(
(1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi)
/ 2.0
* n
)
return x, y return x, y
def close(self): def close(self):

View File

@@ -5,7 +5,15 @@ class MessageHandler:
def __init__(self, db: Database): def __init__(self, db: Database):
self.db = db self.db = db
def get_conversation_messages(self, local_hash, destination_hash, limit=100, offset=0, after_id=None, before_id=None): def get_conversation_messages(
self,
local_hash,
destination_hash,
limit=100,
offset=0,
after_id=None,
before_id=None,
):
query = """ query = """
SELECT * FROM lxmf_messages SELECT * FROM lxmf_messages
WHERE ((source_hash = ? AND destination_hash = ?) WHERE ((source_hash = ? AND destination_hash = ?)
@@ -31,7 +39,9 @@ class MessageHandler:
WHERE ((source_hash = ? AND destination_hash = ?) WHERE ((source_hash = ? AND destination_hash = ?)
OR (destination_hash = ? AND source_hash = ?)) OR (destination_hash = ? AND source_hash = ?))
""" """
self.db.provider.execute(query, [local_hash, destination_hash, local_hash, destination_hash]) self.db.provider.execute(
query, [local_hash, destination_hash, local_hash, destination_hash]
)
def search_messages(self, local_hash, search_term): def search_messages(self, local_hash, search_term):
like_term = f"%{search_term}%" like_term = f"%{search_term}%"
@@ -61,6 +71,12 @@ class MessageHandler:
WHERE m1.source_hash = ? OR m1.destination_hash = ? WHERE m1.source_hash = ? OR m1.destination_hash = ?
ORDER BY m1.timestamp DESC ORDER BY m1.timestamp DESC
""" """
params = [local_hash, local_hash, local_hash, local_hash, local_hash, local_hash] params = [
local_hash,
local_hash,
local_hash,
local_hash,
local_hash,
local_hash,
]
return self.db.provider.fetchall(query, params) return self.db.provider.fetchall(query, params)

View File

@@ -22,9 +22,17 @@ class RNCPHandler:
self.allow_overwrite_on_receive = False self.allow_overwrite_on_receive = False
self.allowed_identity_hashes = [] self.allowed_identity_hashes = []
def setup_receive_destination(self, allowed_hashes=None, fetch_allowed=False, fetch_jail=None, allow_overwrite=False): def setup_receive_destination(
self,
allowed_hashes=None,
fetch_allowed=False,
fetch_jail=None,
allow_overwrite=False,
):
if allowed_hashes: if allowed_hashes:
self.allowed_identity_hashes = [bytes.fromhex(h) if isinstance(h, str) else h for h in allowed_hashes] self.allowed_identity_hashes = [
bytes.fromhex(h) if isinstance(h, str) else h for h in allowed_hashes
]
self.fetch_jail = fetch_jail self.fetch_jail = fetch_jail
self.allow_overwrite_on_receive = allow_overwrite self.allow_overwrite_on_receive = allow_overwrite
@@ -44,7 +52,9 @@ class RNCPHandler:
"receive", "receive",
) )
self.receive_destination.set_link_established_callback(self._client_link_established) self.receive_destination.set_link_established_callback(
self._client_link_established
)
if fetch_allowed: if fetch_allowed:
self.receive_destination.register_request_handler( self.receive_destination.register_request_handler(
@@ -86,7 +96,9 @@ class RNCPHandler:
if resource.status == RNS.Resource.COMPLETE: if resource.status == RNS.Resource.COMPLETE:
if resource.metadata: if resource.metadata:
try: try:
filename = os.path.basename(resource.metadata["name"].decode("utf-8")) filename = os.path.basename(
resource.metadata["name"].decode("utf-8")
)
save_dir = os.path.join(self.storage_dir, "rncp_received") save_dir = os.path.join(self.storage_dir, "rncp_received")
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
@@ -105,13 +117,17 @@ class RNCPHandler:
while os.path.isfile(saved_filename): while os.path.isfile(saved_filename):
counter += 1 counter += 1
base, ext = os.path.splitext(filename) base, ext = os.path.splitext(filename)
saved_filename = os.path.join(save_dir, f"{base}.{counter}{ext}") saved_filename = os.path.join(
save_dir, f"{base}.{counter}{ext}"
)
shutil.move(resource.data.name, saved_filename) shutil.move(resource.data.name, saved_filename)
if transfer_id in self.active_transfers: if transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "completed" self.active_transfers[transfer_id]["status"] = "completed"
self.active_transfers[transfer_id]["saved_path"] = saved_filename self.active_transfers[transfer_id]["saved_path"] = (
saved_filename
)
self.active_transfers[transfer_id]["filename"] = filename self.active_transfers[transfer_id]["filename"] = filename
except Exception as e: except Exception as e:
if transfer_id in self.active_transfers: if transfer_id in self.active_transfers:
@@ -120,7 +136,9 @@ class RNCPHandler:
elif transfer_id in self.active_transfers: elif transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "failed" self.active_transfers[transfer_id]["status"] = "failed"
def _fetch_request(self, path, data, request_id, link_id, remote_identity, requested_at): def _fetch_request(
self, path, data, request_id, link_id, remote_identity, requested_at
):
if self.fetch_jail: if self.fetch_jail:
if data.startswith(self.fetch_jail + "/"): if data.startswith(self.fetch_jail + "/"):
data = data.replace(self.fetch_jail + "/", "") data = data.replace(self.fetch_jail + "/", "")
@@ -171,7 +189,9 @@ class RNCPHandler:
RNS.Transport.request_path(destination_hash) RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + timeout timeout_after = time.time() + timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after: while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash): if not RNS.Transport.has_path(destination_hash):
@@ -257,7 +277,9 @@ class RNCPHandler:
RNS.Transport.request_path(destination_hash) RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + timeout timeout_after = time.time() + timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after: while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash): if not RNS.Transport.has_path(destination_hash):
@@ -326,7 +348,9 @@ class RNCPHandler:
if resource.status == RNS.Resource.COMPLETE: if resource.status == RNS.Resource.COMPLETE:
if resource.metadata: if resource.metadata:
try: try:
filename = os.path.basename(resource.metadata["name"].decode("utf-8")) filename = os.path.basename(
resource.metadata["name"].decode("utf-8")
)
if save_path: if save_path:
save_dir = os.path.abspath(os.path.expanduser(save_path)) save_dir = os.path.abspath(os.path.expanduser(save_path))
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
@@ -367,7 +391,12 @@ class RNCPHandler:
link.set_resource_strategy(RNS.Link.ACCEPT_ALL) link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
link.set_resource_started_callback(fetch_resource_started) link.set_resource_started_callback(fetch_resource_started)
link.set_resource_concluded_callback(fetch_resource_concluded) link.set_resource_concluded_callback(fetch_resource_concluded)
link.request("fetch_file", data=file_path, response_callback=request_response, failed_callback=request_failed) link.request(
"fetch_file",
data=file_path,
response_callback=request_response,
failed_callback=request_failed,
)
while not request_resolved: while not request_resolved:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@@ -418,4 +447,3 @@ class RNCPHandler:
"error": transfer.get("error"), "error": transfer.get("error"),
} }
return None return None

View File

@@ -31,8 +31,14 @@ class RNProbeHandler:
if not RNS.Transport.has_path(destination_hash): if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash) RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + (timeout or self.DEFAULT_TIMEOUT + self.reticulum.get_first_hop_timeout(destination_hash)) timeout_after = time.time() + (
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after: timeout
or self.DEFAULT_TIMEOUT
+ self.reticulum.get_first_hop_timeout(destination_hash)
)
while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash): if not RNS.Transport.has_path(destination_hash):
@@ -70,8 +76,14 @@ class RNProbeHandler:
if_name = self.reticulum.get_next_hop_if_name(destination_hash) if_name = self.reticulum.get_next_hop_if_name(destination_hash)
if_str = f" on {if_name}" if if_name and if_name != "None" else "" if_str = f" on {if_name}" if if_name and if_name != "None" else ""
timeout_after = time.time() + (timeout or self.DEFAULT_TIMEOUT + self.reticulum.get_first_hop_timeout(destination_hash)) timeout_after = time.time() + (
while receipt.status == RNS.PacketReceipt.SENT and time.time() < timeout_after: timeout
or self.DEFAULT_TIMEOUT
+ self.reticulum.get_first_hop_timeout(destination_hash)
)
while (
receipt.status == RNS.PacketReceipt.SENT and time.time() < timeout_after
):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
result: dict = { result: dict = {
@@ -96,9 +108,15 @@ class RNProbeHandler:
reception_stats = {} reception_stats = {}
if self.reticulum.is_connected_to_shared_instance: if self.reticulum.is_connected_to_shared_instance:
reception_rssi = self.reticulum.get_packet_rssi(receipt.proof_packet.packet_hash) reception_rssi = self.reticulum.get_packet_rssi(
reception_snr = self.reticulum.get_packet_snr(receipt.proof_packet.packet_hash) receipt.proof_packet.packet_hash
reception_q = self.reticulum.get_packet_q(receipt.proof_packet.packet_hash) )
reception_snr = self.reticulum.get_packet_snr(
receipt.proof_packet.packet_hash
)
reception_q = self.reticulum.get_packet_q(
receipt.proof_packet.packet_hash
)
if reception_rssi is not None: if reception_rssi is not None:
reception_stats["rssi"] = reception_rssi reception_stats["rssi"] = reception_rssi
@@ -134,4 +152,3 @@ class RNProbeHandler:
"timeouts": sum(1 for r in results if r["status"] == "timeout"), "timeouts": sum(1 for r in results if r["status"] == "timeout"),
"failed": sum(1 for r in results if r["status"] == "failed"), "failed": sum(1 for r in results if r["status"] == "failed"),
} }

View File

@@ -25,7 +25,12 @@ class RNStatusHandler:
def __init__(self, reticulum_instance): def __init__(self, reticulum_instance):
self.reticulum = reticulum_instance self.reticulum = reticulum_instance
def get_status(self, include_link_stats: bool = False, sorting: str | None = None, sort_reverse: bool = False): def get_status(
self,
include_link_stats: bool = False,
sorting: str | None = None,
sort_reverse: bool = False,
):
stats = None stats = None
link_count = None link_count = None
@@ -53,15 +58,25 @@ class RNStatusHandler:
if sorting and isinstance(sorting, str): if sorting and isinstance(sorting, str):
sorting = sorting.lower() sorting = sorting.lower()
if sorting in ("rate", "bitrate"): if sorting in ("rate", "bitrate"):
interfaces.sort(key=lambda i: i.get("bitrate", 0) or 0, reverse=sort_reverse) interfaces.sort(
key=lambda i: i.get("bitrate", 0) or 0, reverse=sort_reverse
)
elif sorting == "rx": elif sorting == "rx":
interfaces.sort(key=lambda i: i.get("rxb", 0) or 0, reverse=sort_reverse) interfaces.sort(
key=lambda i: i.get("rxb", 0) or 0, reverse=sort_reverse
)
elif sorting == "tx": elif sorting == "tx":
interfaces.sort(key=lambda i: i.get("txb", 0) or 0, reverse=sort_reverse) interfaces.sort(
key=lambda i: i.get("txb", 0) or 0, reverse=sort_reverse
)
elif sorting == "rxs": elif sorting == "rxs":
interfaces.sort(key=lambda i: i.get("rxs", 0) or 0, reverse=sort_reverse) interfaces.sort(
key=lambda i: i.get("rxs", 0) or 0, reverse=sort_reverse
)
elif sorting == "txs": elif sorting == "txs":
interfaces.sort(key=lambda i: i.get("txs", 0) or 0, reverse=sort_reverse) interfaces.sort(
key=lambda i: i.get("txs", 0) or 0, reverse=sort_reverse
)
elif sorting == "traffic": elif sorting == "traffic":
interfaces.sort( interfaces.sort(
key=lambda i: (i.get("rxb", 0) or 0) + (i.get("txb", 0) or 0), key=lambda i: (i.get("rxb", 0) or 0) + (i.get("txb", 0) or 0),
@@ -84,13 +99,19 @@ class RNStatusHandler:
reverse=sort_reverse, reverse=sort_reverse,
) )
elif sorting == "held": elif sorting == "held":
interfaces.sort(key=lambda i: i.get("held_announces", 0) or 0, reverse=sort_reverse) interfaces.sort(
key=lambda i: i.get("held_announces", 0) or 0, reverse=sort_reverse
)
formatted_interfaces = [] formatted_interfaces = []
for ifstat in interfaces: for ifstat in interfaces:
name = ifstat.get("name", "") name = ifstat.get("name", "")
if name.startswith("LocalInterface[") or name.startswith("TCPInterface[Client") or name.startswith("BackboneInterface[Client on"): if (
name.startswith("LocalInterface[")
or name.startswith("TCPInterface[Client")
or name.startswith("BackboneInterface[Client on")
):
continue continue
formatted_if: dict[str, Any] = { formatted_if: dict[str, Any] = {
@@ -165,9 +186,13 @@ class RNStatusHandler:
formatted_if["peers"] = ifstat["peers"] formatted_if["peers"] = ifstat["peers"]
if "incoming_announce_frequency" in ifstat: if "incoming_announce_frequency" in ifstat:
formatted_if["incoming_announce_frequency"] = ifstat["incoming_announce_frequency"] formatted_if["incoming_announce_frequency"] = ifstat[
"incoming_announce_frequency"
]
if "outgoing_announce_frequency" in ifstat: if "outgoing_announce_frequency" in ifstat:
formatted_if["outgoing_announce_frequency"] = ifstat["outgoing_announce_frequency"] formatted_if["outgoing_announce_frequency"] = ifstat[
"outgoing_announce_frequency"
]
if "held_announces" in ifstat: if "held_announces" in ifstat:
formatted_if["held_announces"] = ifstat["held_announces"] formatted_if["held_announces"] = ifstat["held_announces"]
@@ -181,4 +206,3 @@ class RNStatusHandler:
"link_count": link_count, "link_count": link_count,
"timestamp": time.time(), "timestamp": time.time(),
} }

View File

@@ -76,7 +76,9 @@ class TelephoneManager:
destination_identity = RNS.Identity.recall(destination_hash) destination_identity = RNS.Identity.recall(destination_hash)
if destination_identity is None: if destination_identity is None:
# If not found by identity hash, try as destination hash # If not found by identity hash, try as destination hash
destination_identity = RNS.Identity.recall(destination_hash) # Identity.recall takes identity hash destination_identity = RNS.Identity.recall(
destination_hash
) # Identity.recall takes identity hash
if destination_identity is None: if destination_identity is None:
msg = "Destination identity not found" msg = "Destination identity not found"
@@ -92,4 +94,3 @@ class TelephoneManager:
self.call_is_incoming = False self.call_is_incoming = False
await asyncio.to_thread(self.telephone.call, destination_identity) await asyncio.to_thread(self.telephone.call, destination_identity)
return self.telephone.active_call return self.telephone.active_call

View File

@@ -6,12 +6,14 @@ from typing import Any
try: try:
import requests import requests
HAS_REQUESTS = True HAS_REQUESTS = True
except ImportError: except ImportError:
HAS_REQUESTS = False HAS_REQUESTS = False
try: try:
from argostranslate import package, translate from argostranslate import package, translate
HAS_ARGOS_LIB = True HAS_ARGOS_LIB = True
except ImportError: except ImportError:
HAS_ARGOS_LIB = False HAS_ARGOS_LIB = False
@@ -63,7 +65,9 @@ LANGUAGE_CODE_TO_NAME = {
class TranslatorHandler: class TranslatorHandler:
def __init__(self, libretranslate_url: str | None = None): def __init__(self, libretranslate_url: str | None = None):
self.libretranslate_url = libretranslate_url or os.getenv("LIBRETRANSLATE_URL", "http://localhost:5000") self.libretranslate_url = libretranslate_url or os.getenv(
"LIBRETRANSLATE_URL", "http://localhost:5000"
)
self.has_argos = HAS_ARGOS self.has_argos = HAS_ARGOS
self.has_argos_lib = HAS_ARGOS_LIB self.has_argos_lib = HAS_ARGOS_LIB
self.has_argos_cli = HAS_ARGOS_CLI self.has_argos_cli = HAS_ARGOS_CLI
@@ -136,7 +140,12 @@ class TranslatorHandler:
if self.has_requests: if self.has_requests:
try: try:
url = libretranslate_url or self.libretranslate_url url = libretranslate_url or self.libretranslate_url
return self._translate_libretranslate(text, source_lang=source_lang, target_lang=target_lang, libretranslate_url=url) return self._translate_libretranslate(
text,
source_lang=source_lang,
target_lang=target_lang,
libretranslate_url=url,
)
except Exception as e: except Exception as e:
if self.has_argos: if self.has_argos:
return self._translate_argos(text, source_lang, target_lang) return self._translate_argos(text, source_lang, target_lang)
@@ -148,7 +157,13 @@ class TranslatorHandler:
msg = "No translation backend available. Install requests for LibreTranslate or argostranslate for local translation." msg = "No translation backend available. Install requests for LibreTranslate or argostranslate for local translation."
raise RuntimeError(msg) raise RuntimeError(msg)
def _translate_libretranslate(self, text: str, source_lang: str, target_lang: str, libretranslate_url: str | None = None) -> dict[str, Any]: def _translate_libretranslate(
self,
text: str,
source_lang: str,
target_lang: str,
libretranslate_url: str | None = None,
) -> dict[str, Any]:
if not self.has_requests: if not self.has_requests:
msg = "requests library not available" msg = "requests library not available"
raise RuntimeError(msg) raise RuntimeError(msg)
@@ -172,12 +187,16 @@ class TranslatorHandler:
result = response.json() result = response.json()
return { return {
"translated_text": result.get("translatedText", ""), "translated_text": result.get("translatedText", ""),
"source_lang": result.get("detectedLanguage", {}).get("language", source_lang), "source_lang": result.get("detectedLanguage", {}).get(
"language", source_lang
),
"target_lang": target_lang, "target_lang": target_lang,
"source": "libretranslate", "source": "libretranslate",
} }
def _translate_argos(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]: def _translate_argos(
self, text: str, source_lang: str, target_lang: str
) -> dict[str, Any]:
if source_lang == "auto": if source_lang == "auto":
if self.has_argos_lib: if self.has_argos_lib:
detected_lang = self._detect_language(text) detected_lang = self._detect_language(text)
@@ -200,7 +219,9 @@ class TranslatorHandler:
msg = "Argos Translate not available (neither library nor CLI)" msg = "Argos Translate not available (neither library nor CLI)"
raise RuntimeError(msg) raise RuntimeError(msg)
def _translate_argos_lib(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]: def _translate_argos_lib(
self, text: str, source_lang: str, target_lang: str
) -> dict[str, Any]:
try: try:
installed_packages = package.get_installed_packages() installed_packages = package.get_installed_packages()
translation_package = None translation_package = None
@@ -228,7 +249,9 @@ class TranslatorHandler:
msg = f"Argos Translate error: {e}" msg = f"Argos Translate error: {e}"
raise RuntimeError(msg) raise RuntimeError(msg)
def _translate_argos_cli(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]: def _translate_argos_cli(
self, text: str, source_lang: str, target_lang: str
) -> dict[str, Any]:
if source_lang == "auto" or not source_lang: if source_lang == "auto" or not source_lang:
msg = "Auto-detection is not supported with CLI. Please select a source language manually." msg = "Auto-detection is not supported with CLI. Please select a source language manually."
raise ValueError(msg) raise ValueError(msg)
@@ -251,7 +274,14 @@ class TranslatorHandler:
raise RuntimeError(msg) raise RuntimeError(msg)
try: try:
args = [executable, "--from-lang", source_lang, "--to-lang", target_lang, text] args = [
executable,
"--from-lang",
source_lang,
"--to-lang",
target_lang,
text,
]
result = subprocess.run(args, capture_output=True, text=True, check=True) # noqa: S603 result = subprocess.run(args, capture_output=True, text=True, check=True) # noqa: S603
translated_text = result.stdout.strip() translated_text = result.stdout.strip()
if not translated_text: if not translated_text:
@@ -264,7 +294,11 @@ class TranslatorHandler:
"source": "argos", "source": "argos",
} }
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
error_msg = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or str(e)) error_msg = (
e.stderr.decode()
if isinstance(e.stderr, bytes)
else (e.stderr or str(e))
)
msg = f"Argos Translate CLI error: {error_msg}" msg = f"Argos Translate CLI error: {error_msg}"
raise RuntimeError(msg) raise RuntimeError(msg)
except Exception as e: except Exception as e:
@@ -333,7 +367,9 @@ class TranslatorHandler:
return languages return languages
def install_language_package(self, package_name: str = "translate") -> dict[str, Any]: def install_language_package(
self, package_name: str = "translate"
) -> dict[str, Any]:
argospm = shutil.which("argospm") argospm = shutil.which("argospm")
if not argospm: if not argospm:
msg = "argospm not found in PATH. Install argostranslate first." msg = "argospm not found in PATH. Install argostranslate first."

View File

@@ -0,0 +1,301 @@
import os
import platform
import shutil
import subprocess
import threading
import time
import LXST
import RNS
from LXST.Codecs import Null
from LXST.Pipeline import Pipeline
from LXST.Sinks import OpusFileSink
from LXST.Sources import OpusFileSource
class VoicemailManager:
def __init__(self, db, telephone_manager, storage_dir):
self.db = db
self.telephone_manager = telephone_manager
self.storage_dir = os.path.join(storage_dir, "voicemails")
self.greetings_dir = os.path.join(self.storage_dir, "greetings")
self.recordings_dir = os.path.join(self.storage_dir, "recordings")
# Ensure directories exist
os.makedirs(self.greetings_dir, exist_ok=True)
os.makedirs(self.recordings_dir, exist_ok=True)
self.is_recording = False
self.recording_pipeline = None
self.recording_sink = None
self.recording_start_time = None
self.recording_remote_identity = None
self.recording_filename = None
# Paths to executables
self.espeak_path = self._find_espeak()
self.ffmpeg_path = self._find_ffmpeg()
# Check for presence
self.has_espeak = self.espeak_path is not None
self.has_ffmpeg = self.ffmpeg_path is not None
if self.has_espeak:
RNS.log(f"Voicemail: Found eSpeak at {self.espeak_path}", RNS.LOG_DEBUG)
else:
RNS.log("Voicemail: eSpeak not found", RNS.LOG_ERROR)
if self.has_ffmpeg:
RNS.log(f"Voicemail: Found ffmpeg at {self.ffmpeg_path}", RNS.LOG_DEBUG)
else:
RNS.log("Voicemail: ffmpeg not found", RNS.LOG_ERROR)
def _find_espeak(self):
# Try standard name first
path = shutil.which("espeak-ng")
if path:
return path
# Try without -ng suffix
path = shutil.which("espeak")
if path:
return path
# Windows common install locations if not in PATH
if platform.system() == "Windows":
common_paths = [
os.path.expandvars(r"%ProgramFiles%\eSpeak NG\espeak-ng.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\eSpeak NG\espeak-ng.exe"),
os.path.expandvars(r"%ProgramFiles%\eSpeak\espeak.exe"),
]
for p in common_paths:
if os.path.exists(p):
return p
return None
def _find_ffmpeg(self):
path = shutil.which("ffmpeg")
if path:
return path
# Windows common install locations
if platform.system() == "Windows":
common_paths = [
os.path.expandvars(r"%ProgramFiles%\ffmpeg\bin\ffmpeg.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\ffmpeg\bin\ffmpeg.exe"),
]
for p in common_paths:
if os.path.exists(p):
return p
return None
def generate_greeting(self, text):
if not self.has_espeak or not self.has_ffmpeg:
msg = "espeak-ng and ffmpeg are required for greeting generation"
raise RuntimeError(msg)
wav_path = os.path.join(self.greetings_dir, "greeting.wav")
opus_path = os.path.join(self.greetings_dir, "greeting.opus")
try:
# espeak-ng to WAV
subprocess.run([self.espeak_path, "-w", wav_path, text], check=True)
# ffmpeg to Opus
if os.path.exists(opus_path):
os.remove(opus_path)
subprocess.run(
[
self.ffmpeg_path,
"-i",
wav_path,
"-c:a",
"libopus",
"-b:a",
"16k",
"-vbr",
"on",
opus_path,
],
check=True,
)
return opus_path
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
def handle_incoming_call(self, caller_identity):
if not self.db.config.voicemail_enabled.get():
return
delay = self.db.config.voicemail_auto_answer_delay_seconds.get()
def voicemail_job():
time.sleep(delay)
# Check if still ringing and no other active call
telephone = self.telephone_manager.telephone
if (
telephone
and telephone.active_call
and telephone.active_call.get_remote_identity() == caller_identity
and telephone.call_status == LXST.Signalling.STATUS_RINGING
):
RNS.log(
f"Auto-answering call from {RNS.prettyhexrep(caller_identity.hash)} for voicemail",
RNS.LOG_DEBUG,
)
self.start_voicemail_session(caller_identity)
threading.Thread(target=voicemail_job, daemon=True).start()
def start_voicemail_session(self, caller_identity):
telephone = self.telephone_manager.telephone
if not telephone:
return
# Answer the call
if not telephone.answer(caller_identity):
return
# Stop microphone if it's active to prevent local noise being sent or recorded
if telephone.audio_input:
telephone.audio_input.stop()
# Play greeting
greeting_path = os.path.join(self.greetings_dir, "greeting.opus")
if not os.path.exists(greeting_path):
# Fallback if no greeting generated yet
self.generate_greeting(self.db.config.voicemail_greeting.get())
def session_job():
try:
# 1. Play greeting
greeting_source = OpusFileSource(greeting_path, target_frame_ms=60)
# Attach to transmit mixer
greeting_pipeline = Pipeline(
source=greeting_source, codec=Null(), sink=telephone.transmit_mixer
)
greeting_pipeline.start()
# Wait for greeting to finish
while greeting_source.running:
time.sleep(0.1)
if not telephone.active_call:
return
greeting_pipeline.stop()
# 2. Play beep
beep_source = LXST.ToneSource(
frequency=800,
gain=0.1,
target_frame_ms=60,
codec=Null(),
sink=telephone.transmit_mixer,
)
beep_source.start()
time.sleep(0.5)
beep_source.stop()
# 3. Start recording
self.start_recording(caller_identity)
# 4. Wait for max recording time or hangup
max_time = self.db.config.voicemail_max_recording_seconds.get()
start_wait = time.time()
while self.is_recording and (time.time() - start_wait < max_time):
time.sleep(0.5)
if not telephone.active_call:
break
# 5. End session
if telephone.active_call:
telephone.hangup()
self.stop_recording()
except Exception as e:
RNS.log(f"Error during voicemail session: {e}", RNS.LOG_ERROR)
if self.is_recording:
self.stop_recording()
threading.Thread(target=session_job, daemon=True).start()
def start_recording(self, caller_identity):
telephone = self.telephone_manager.telephone
if not telephone or not telephone.active_call:
return
timestamp = time.time()
filename = f"voicemail_{caller_identity.hash.hex()}_{int(timestamp)}.opus"
filepath = os.path.join(self.recordings_dir, filename)
try:
self.recording_sink = OpusFileSink(filepath)
# Connect the caller's audio source to our sink
# active_call.audio_source is a LinkSource that feeds into receive_mixer
# We want to record what we receive.
self.recording_pipeline = Pipeline(
source=telephone.active_call.audio_source,
codec=Null(),
sink=self.recording_sink,
)
self.recording_pipeline.start()
self.is_recording = True
self.recording_start_time = timestamp
self.recording_remote_identity = caller_identity
self.recording_filename = filename
RNS.log(
f"Started recording voicemail from {RNS.prettyhexrep(caller_identity.hash)}",
RNS.LOG_DEBUG,
)
except Exception as e:
RNS.log(f"Failed to start recording: {e}", RNS.LOG_ERROR)
def stop_recording(self):
if not self.is_recording:
return
try:
duration = int(time.time() - self.recording_start_time)
self.recording_pipeline.stop()
self.recording_sink = None
self.recording_pipeline = None
# Save to database if long enough
if duration >= 1:
remote_name = self.telephone_manager.get_name_for_identity_hash(
self.recording_remote_identity.hash.hex()
)
self.db.voicemails.add_voicemail(
remote_identity_hash=self.recording_remote_identity.hash.hex(),
remote_identity_name=remote_name,
filename=self.recording_filename,
duration_seconds=duration,
timestamp=self.recording_start_time,
)
RNS.log(
f"Saved voicemail from {RNS.prettyhexrep(self.recording_remote_identity.hash)} ({duration}s)",
RNS.LOG_DEBUG,
)
else:
# Delete short/empty recording
filepath = os.path.join(self.recordings_dir, self.recording_filename)
if os.path.exists(filepath):
os.remove(filepath)
self.is_recording = False
self.recording_start_time = None
self.recording_remote_identity = None
self.recording_filename = None
except Exception as e:
RNS.log(f"Error stopping recording: {e}", RNS.LOG_ERROR)
self.is_recording = False

View File

@@ -26,7 +26,7 @@
<div <div
class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 shadow-inner" class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 shadow-inner"
> >
<img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo-chat-bubble.png" /> <img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo.png" />
</div> </div>
<div class="my-auto"> <div class="my-auto">
<div <div
@@ -387,11 +387,7 @@
</div> </div>
</template> </template>
</template> </template>
<CallOverlay <CallOverlay v-if="activeCall || isCallEnded" :active-call="activeCall || lastCall" :is-ended="isCallEnded" />
v-if="activeCall || isCallEnded"
:active-call="activeCall || lastCall"
:is-ended="isCallEnded"
/>
<Toast /> <Toast />
</div> </div>
</template> </template>

View File

@@ -5,7 +5,7 @@
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8" class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
> >
<div class="text-center mb-8"> <div class="text-center mb-8">
<img class="w-16 h-16 mx-auto mb-4" src="/assets/images/logo-chat-bubble.png" /> <img class="w-16 h-16 mx-auto mb-4" src="/assets/images/logo.png" />
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2"> <h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
{{ isSetup ? "Initial Setup" : "Authentication Required" }} {{ isSetup ? "Initial Setup" : "Authentication Required" }}
</h1> </h1>

View File

@@ -7,12 +7,17 @@
<!-- Header --> <!-- Header -->
<div class="p-3 flex items-center bg-gray-50 dark:bg-zinc-800/50 border-b border-gray-100 dark:border-zinc-800"> <div class="p-3 flex items-center bg-gray-50 dark:bg-zinc-800/50 border-b border-gray-100 dark:border-zinc-800">
<div class="flex-1 flex items-center space-x-2"> <div class="flex-1 flex items-center space-x-2">
<div <div class="size-2 rounded-full" :class="isEnded ? 'bg-red-500' : 'bg-green-500 animate-pulse'"></div>
class="size-2 rounded-full"
:class="isEnded ? 'bg-red-500' : 'bg-green-500 animate-pulse'"
></div>
<span class="text-[10px] font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider"> <span class="text-[10px] font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
{{ isEnded ? "Call Ended" : (activeCall.status === 6 ? "Active Call" : "Call Status") }} {{
isEnded
? "Call Ended"
: activeCall.is_voicemail
? "Recording Voicemail"
: activeCall.status === 6
? "Active Call"
: "Call Status"
}}
</span> </span>
</div> </div>
<button <button
@@ -31,12 +36,12 @@
<div v-show="!isMinimized" class="p-4"> <div v-show="!isMinimized" class="p-4">
<!-- icon and name --> <!-- icon and name -->
<div class="flex flex-col items-center mb-4"> <div class="flex flex-col items-center mb-4">
<div <div
class="p-4 rounded-full mb-3" class="p-4 rounded-full mb-3"
:class="isEnded ? 'bg-red-100 dark:bg-red-900/30' : 'bg-blue-100 dark:bg-blue-900/30'" :class="isEnded ? 'bg-red-100 dark:bg-red-900/30' : 'bg-blue-100 dark:bg-blue-900/30'"
> >
<MaterialDesignIcon <MaterialDesignIcon
icon-name="account" icon-name="account"
class="size-8" class="size-8"
:class="isEnded ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'" :class="isEnded ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'"
/> />
@@ -60,10 +65,11 @@
<div <div
class="text-sm font-medium" class="text-sm font-medium"
:class="[ :class="[
isEnded ? 'text-red-600 dark:text-red-400 animate-pulse' : isEnded
(activeCall.status === 6 ? 'text-red-600 dark:text-red-400 animate-pulse'
? 'text-green-600 dark:text-green-400' : activeCall.status === 6
: 'text-gray-600 dark:text-zinc-400') ? 'text-green-600 dark:text-green-400'
: 'text-gray-600 dark:text-zinc-400',
]" ]"
> >
<span v-if="isEnded">Call Ended</span> <span v-if="isEnded">Call Ended</span>
@@ -150,7 +156,10 @@
</div> </div>
<!-- Minimized State --> <!-- Minimized State -->
<div v-show="isMinimized && !isEnded" class="px-4 py-2 flex items-center justify-between bg-white dark:bg-zinc-900"> <div
v-show="isMinimized && !isEnded"
class="px-4 py-2 flex items-center justify-between bg-white dark:bg-zinc-900"
>
<div class="flex items-center space-x-2 overflow-hidden mr-2"> <div class="flex items-center space-x-2 overflow-hidden mr-2">
<MaterialDesignIcon icon-name="account" class="size-5 text-blue-500" /> <MaterialDesignIcon icon-name="account" class="size-5 text-blue-500" />
<span class="text-sm font-medium text-gray-700 dark:text-zinc-200 truncate"> <span class="text-sm font-medium text-gray-700 dark:text-zinc-200 truncate">

View File

@@ -1,183 +1,230 @@
<template> <template>
<div class="flex w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{ dark: config?.theme === 'dark' }"> <div class="flex flex-col w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{ dark: config?.theme === 'dark' }">
<div class="mx-auto my-auto w-full max-w-xl p-4"> <div class="mx-auto w-full max-w-xl p-4 flex-1 flex flex-col">
<div v-if="activeCall || isCallEnded" class="flex"> <!-- Tabs -->
<div class="mx-auto my-auto min-w-64"> <div class="flex border-b border-gray-200 dark:border-zinc-800 mb-6 shrink-0">
<div class="text-center"> <button
<div> :class="[
<!-- icon --> activeTab === 'phone'
<div class="flex mb-4"> ? 'border-blue-500 text-blue-600 dark:text-blue-400'
<div : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
class="mx-auto bg-gray-300 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-4 rounded-full" ]"
:class="{ 'animate-pulse': activeCall && activeCall.status === 4 }" class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
> @click="activeTab = 'phone'"
<MaterialDesignIcon icon-name="account" class="size-12" /> >
</div> Phone
</div> </button>
<button
:class="[
activeTab === 'voicemail'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
]"
class="py-2 px-4 border-b-2 font-medium text-sm flex items-center gap-2 transition-all"
@click="activeTab = 'voicemail'"
>
Voicemail
<span
v-if="unreadVoicemailsCount > 0"
class="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full animate-pulse"
>{{ unreadVoicemailsCount }}</span
>
</button>
<button
:class="[
activeTab === 'settings'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
]"
class="py-2 px-4 border-b-2 font-medium text-sm ml-auto transition-all"
@click="activeTab = 'settings'"
>
<MaterialDesignIcon icon-name="cog" class="size-4" />
</button>
</div>
<!-- name --> <!-- Phone Tab -->
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100"> <div v-if="activeTab === 'phone'" class="flex-1 flex flex-col">
<span v-if="(activeCall || lastCall)?.remote_identity_name != null">{{ <div v-if="activeCall || isCallEnded" class="flex my-auto">
(activeCall || lastCall).remote_identity_name <div class="mx-auto my-auto min-w-64">
}}</span> <div class="text-center">
<span v-else>Unknown</span> <div>
</div> <!-- icon -->
<div class="flex mb-4">
<!-- identity hash --> <div
<div class="mx-auto bg-gray-300 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-4 rounded-full"
v-if="(activeCall || lastCall)?.remote_identity_hash != null" :class="{ 'animate-pulse': activeCall && activeCall.status === 4 }"
class="text-gray-500 dark:text-zinc-100 opacity-60 text-sm"
>
{{
(activeCall || lastCall).remote_identity_hash
? formatDestinationHash((activeCall || lastCall).remote_identity_hash)
: ""
}}
</div>
</div>
<!-- call status -->
<div class="text-gray-500 dark:text-zinc-100 mb-4 mt-2">
<template v-if="isCallEnded">
<span class="text-red-500 font-bold animate-pulse">Call Ended</span>
</template>
<template v-else-if="activeCall">
<span v-if="activeCall.is_incoming && activeCall.status === 4" class="animate-bounce inline-block">Incoming Call...</span>
<span v-else>
<span v-if="activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall.status === 1">Rejected...</span>
<span v-else-if="activeCall.status === 2">Calling...</span>
<span v-else-if="activeCall.status === 3">Available...</span>
<span v-else-if="activeCall.status === 4">Ringing...</span>
<span v-else-if="activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall.status === 6" class="text-green-500 font-medium">Connected</span>
<span v-else>Status: {{ activeCall.status }}</span>
</span>
</template>
</div>
<!-- settings during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mb-4">
<div class="w-full">
<select
v-model="selectedAudioProfileId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white dark:focus:ring-blue-600 dark:focus:border-blue-600"
@change="switchAudioProfile(selectedAudioProfileId)"
>
<option
v-for="audioProfile in audioProfiles"
:key="audioProfile.id"
:value="audioProfile.id"
> >
{{ audioProfile.name }} <MaterialDesignIcon icon-name="account" class="size-12" />
</option> </div>
</select>
</div>
</div>
<!-- controls during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mx-auto space-x-4 mb-8">
<!-- mute/unmute mic -->
<button
type="button"
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
:class="[
isMicMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleMicrophone"
>
<MaterialDesignIcon
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
class="size-8"
/>
</button>
<!-- mute/unmute speaker -->
<button
type="button"
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
:class="[
isSpeakerMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleSpeaker"
>
<MaterialDesignIcon
:icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'"
class="size-8"
/>
</button>
<!-- toggle stats -->
<button
type="button"
:class="[
isShowingStats
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="isShowingStats = !isShowingStats"
>
<MaterialDesignIcon icon-name="chart-bar" class="size-8" />
</button>
</div>
<!-- actions -->
<div v-if="activeCall" class="mx-auto space-x-4">
<!-- answer call -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
title="Answer Call"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-6" />
<span>Accept</span>
</button>
<!-- hangup/decline call -->
<button
:title="
activeCall.is_incoming && activeCall.status === 4 ? 'Decline Call' : 'Hangup Call'
"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
<span>{{
activeCall.is_incoming && activeCall.status === 4 ? "Decline" : "Hangup"
}}</span>
</button>
</div>
<!-- stats -->
<div
v-if="isShowingStats"
class="mt-4 p-4 text-left bg-gray-200 dark:bg-zinc-800 rounded-lg text-sm text-gray-600 dark:text-zinc-300"
>
<div class="grid grid-cols-2 gap-2">
<div>
TX: {{ activeCall.tx_packets }} ({{ formatBytes(activeCall.tx_bytes) }})
</div> </div>
<div>
RX: {{ activeCall.rx_packets }} ({{ formatBytes(activeCall.rx_bytes) }}) <!-- name -->
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">
<span v-if="(activeCall || lastCall)?.remote_identity_name != null">{{
(activeCall || lastCall).remote_identity_name
}}</span>
<span v-else>Unknown</span>
</div>
<!-- identity hash -->
<div
v-if="(activeCall || lastCall)?.remote_identity_hash != null"
class="text-gray-500 dark:text-zinc-100 opacity-60 text-sm"
>
{{
(activeCall || lastCall).remote_identity_hash
? formatDestinationHash((activeCall || lastCall).remote_identity_hash)
: ""
}}
</div>
</div>
<!-- call status -->
<div class="text-gray-500 dark:text-zinc-100 mb-4 mt-2">
<template v-if="isCallEnded">
<span class="text-red-500 font-bold animate-pulse">Call Ended</span>
</template>
<template v-else-if="activeCall">
<span
v-if="activeCall.is_incoming && activeCall.status === 4"
class="animate-bounce inline-block"
>Incoming Call...</span
>
<span v-else>
<span v-if="activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall.status === 1">Rejected...</span>
<span v-else-if="activeCall.status === 2">Calling...</span>
<span v-else-if="activeCall.status === 3">Available...</span>
<span v-else-if="activeCall.status === 4">Ringing...</span>
<span v-else-if="activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall.status === 6" class="text-green-500 font-medium"
>Connected</span
>
<span v-else>Status: {{ activeCall.status }}</span>
</span>
</template>
</div>
<!-- settings during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mb-4">
<div class="w-full">
<select
v-model="selectedAudioProfileId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white dark:focus:ring-blue-600 dark:focus:border-blue-600"
@change="switchAudioProfile(selectedAudioProfileId)"
>
<option
v-for="audioProfile in audioProfiles"
:key="audioProfile.id"
:value="audioProfile.id"
>
{{ audioProfile.name }}
</option>
</select>
</div>
</div>
<!-- controls during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mx-auto space-x-4 mb-8">
<!-- mute/unmute mic -->
<button
type="button"
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
:class="[
isMicMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleMicrophone"
>
<MaterialDesignIcon
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
class="size-8"
/>
</button>
<!-- mute/unmute speaker -->
<button
type="button"
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
:class="[
isSpeakerMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleSpeaker"
>
<MaterialDesignIcon
:icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'"
class="size-8"
/>
</button>
<!-- toggle stats -->
<button
type="button"
:class="[
isShowingStats
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="isShowingStats = !isShowingStats"
>
<MaterialDesignIcon icon-name="chart-bar" class="size-8" />
</button>
</div>
<!-- actions -->
<div v-if="activeCall" class="mx-auto space-x-4">
<!-- answer call -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
title="Answer Call"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-6" />
<span>Accept</span>
</button>
<!-- hangup/decline call -->
<button
:title="
activeCall.is_incoming && activeCall.status === 4
? 'Decline Call'
: 'Hangup Call'
"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
<span>{{
activeCall.is_incoming && activeCall.status === 4 ? "Decline" : "Hangup"
}}</span>
</button>
</div>
<!-- stats -->
<div
v-if="isShowingStats"
class="mt-4 p-4 text-left bg-gray-200 dark:bg-zinc-800 rounded-lg text-sm text-gray-600 dark:text-zinc-300"
>
<div class="grid grid-cols-2 gap-2">
<div>TX: {{ activeCall.tx_packets }} ({{ formatBytes(activeCall.tx_bytes) }})</div>
<div>RX: {{ activeCall.rx_packets }} ({{ formatBytes(activeCall.rx_bytes) }})</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div v-else class="flex"> <div v-else class="my-auto">
<div class="mx-auto my-auto w-full">
<div class="text-center mb-4"> <div class="text-center mb-4">
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">Telephone</div> <div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">Telephone</div>
<div class="text-gray-500 dark:text-zinc-400">Enter an identity hash to call.</div> <div class="text-gray-500 dark:text-zinc-400">Enter an identity hash to call.</div>
@@ -200,72 +247,304 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Call History -->
<div v-if="callHistory.length > 0 && !activeCall && !isCallEnded" class="mt-8">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
>
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
Call History
</h3>
<MaterialDesignIcon icon-name="history" class="size-4 text-gray-400" />
</div>
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="entry in callHistory"
:key="entry.id"
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
>
<div class="flex items-center space-x-3">
<div :class="entry.is_incoming ? 'text-blue-500' : 'text-green-500'">
<MaterialDesignIcon
:icon-name="entry.is_incoming ? 'phone-incoming' : 'phone-outgoing'"
class="size-5"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{{ entry.remote_identity_name || "Unknown" }}
</p>
<span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2">
{{ entry.timestamp ? formatDateTime(entry.timestamp * 1000) : "" }}
</span>
</div>
<div class="flex items-center justify-between mt-0.5">
<div
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-2"
>
<span>{{ entry.status }}</span>
<span v-if="entry.duration_seconds > 0"
> {{ formatDuration(entry.duration_seconds) }}</span
>
</div>
<button
type="button"
class="text-[10px] text-blue-500 hover:text-blue-600 font-bold uppercase tracking-tighter"
@click="
destinationHash = entry.remote_identity_hash;
call(destinationHash);
"
>
Call Back
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</div> </div>
<div v-if="callHistory.length > 0 && !activeCall" class="mt-8"> <!-- Voicemail Tab -->
<div <div v-if="activeTab === 'voicemail'" class="flex-1 flex flex-col">
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden" <div v-if="voicemails.length === 0" class="my-auto text-center">
> <div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
<div <MaterialDesignIcon icon-name="voicemail" class="size-12 text-gray-400" />
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
>
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
Call History
</h3>
<MaterialDesignIcon icon-name="history" class="size-4 text-gray-400" />
</div> </div>
<ul class="divide-y divide-gray-100 dark:divide-zinc-800"> <h3 class="text-lg font-medium text-gray-900 dark:text-white">No Voicemails</h3>
<li <p class="text-gray-500 dark:text-zinc-400">
v-for="entry in callHistory" When people leave you messages, they'll show up here.
:key="entry.id" </p>
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors" </div>
<div v-else class="space-y-4">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
> >
<div class="flex items-center space-x-3"> <h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
<div :class="entry.is_incoming ? 'text-blue-500' : 'text-green-500'"> Voicemail Inbox
<MaterialDesignIcon </h3>
:icon-name="entry.is_incoming ? 'phone-incoming' : 'phone-outgoing'" <span
class="size-5" class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-2 py-0.5 rounded-full font-bold uppercase"
/> >
</div> {{ voicemails.length }} Messages
<div class="flex-1 min-w-0"> </span>
<div class="flex items-center justify-between"> </div>
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate"> <ul class="divide-y divide-gray-100 dark:divide-zinc-800">
{{ entry.remote_identity_name || "Unknown" }} <li
</p> v-for="voicemail in voicemails"
<span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2"> :key="voicemail.id"
{{ class="px-4 py-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
entry.timestamp :class="{ 'bg-blue-50/50 dark:bg-blue-900/10': !voicemail.is_read }"
? formatDateTime( >
entry.timestamp * 1000 <div class="flex items-start space-x-4">
) <!-- Play/Pause Button -->
: "" <button
}} class="shrink-0 size-10 rounded-full flex items-center justify-center transition-all"
</span> :class="
</div> playingVoicemailId === voicemail.id
<div class="flex items-center justify-between mt-0.5"> ? 'bg-red-500 text-white animate-pulse'
<div : 'bg-blue-500 text-white hover:bg-blue-600'
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-2" "
> @click="playVoicemail(voicemail)"
<span>{{ entry.status }}</span> >
<span v-if="entry.duration_seconds > 0" <MaterialDesignIcon
> {{ formatDuration(entry.duration_seconds) }}</span :icon-name="playingVoicemailId === voicemail.id ? 'stop' : 'play'"
> class="size-6"
/>
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-bold text-gray-900 dark:text-white truncate">
{{ voicemail.remote_identity_name || "Unknown" }}
<span
v-if="!voicemail.is_read"
class="ml-2 size-2 inline-block rounded-full bg-blue-500"
></span>
</p>
<span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono">
{{ formatDateTime(voicemail.timestamp * 1000) }}
</span>
</div> </div>
<button
type="button" <div
class="text-[10px] text-blue-500 hover:text-blue-600 font-bold uppercase tracking-tighter" class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-3 mb-3"
@click="
destinationHash = entry.remote_identity_hash;
call(destinationHash);
"
> >
Call Back <span class="flex items-center gap-1">
</button> <MaterialDesignIcon icon-name="clock-outline" class="size-3" />
{{ formatDuration(voicemail.duration_seconds) }}
</span>
<span class="opacity-60 font-mono text-[10px]">{{
formatDestinationHash(voicemail.remote_identity_hash)
}}</span>
</div>
<div class="flex items-center gap-4">
<button
type="button"
class="text-[10px] flex items-center gap-1 text-blue-500 hover:text-blue-600 font-bold uppercase tracking-wider transition-colors"
@click="
destinationHash = voicemail.remote_identity_hash;
activeTab = 'phone';
call(destinationHash);
"
>
<MaterialDesignIcon icon-name="phone" class="size-3" />
Call Back
</button>
<button
type="button"
class="text-[10px] flex items-center gap-1 text-red-500 hover:text-red-600 font-bold uppercase tracking-wider transition-colors"
@click="deleteVoicemail(voicemail.id)"
>
<MaterialDesignIcon icon-name="delete" class="size-3" />
Delete
</button>
</div>
</div> </div>
</div> </div>
</li>
</ul>
</div>
</div>
</div>
<!-- Settings Tab -->
<div v-if="activeTab === 'settings' && config" class="flex-1 space-y-6">
<div
class="bg-white dark:bg-zinc-900 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-zinc-800"
>
<h3
class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-6 flex items-center gap-2"
>
<MaterialDesignIcon icon-name="voicemail" class="size-5 text-blue-500" />
Voicemail Settings
</h3>
<!-- Status Banner -->
<div
v-if="!voicemailStatus.has_espeak || !voicemailStatus.has_ffmpeg"
class="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg flex gap-3 items-start"
>
<MaterialDesignIcon
icon-name="alert"
class="size-5 text-amber-600 dark:text-amber-400 shrink-0"
/>
<div class="text-xs text-amber-800 dark:text-amber-200">
<p class="font-bold mb-1">Dependencies Missing</p>
<p v-if="!voicemailStatus.has_espeak">
Voicemail requires `espeak-ng` to generate greetings. Please install it on your system.
</p>
<p v-if="!voicemailStatus.has_ffmpeg" :class="{ 'mt-1': !voicemailStatus.has_espeak }">
Voicemail requires `ffmpeg` to process audio files. Please install it on your system.
</p>
</div>
</div>
<div class="space-y-6">
<!-- Enabled Toggle -->
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">Enable Voicemail</div>
<div class="text-xs text-gray-500 dark:text-zinc-400">
Accept calls automatically and record messages
</div>
</div> </div>
</li> <button
</ul> :disabled="!voicemailStatus.has_espeak || !voicemailStatus.has_ffmpeg"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
:class="config.voicemail_enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-zinc-700'"
@click="
config.voicemail_enabled = !config.voicemail_enabled;
updateConfig({ voicemail_enabled: config.voicemail_enabled });
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="config.voicemail_enabled ? 'translate-x-5' : 'translate-x-0'"
></span>
</button>
</div>
<!-- Greeting Text -->
<div class="space-y-2">
<label class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
>Greeting Message</label
>
<textarea
v-model="config.voicemail_greeting"
rows="3"
class="block w-full rounded-lg border-0 py-2 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-zinc-900"
placeholder="Enter greeting text..."
></textarea>
<div class="flex justify-between items-center">
<p class="text-[10px] text-gray-500 dark:text-zinc-500">
This text will be converted to speech using eSpeak NG.
</p>
<button
:disabled="!voicemailStatus.has_espeak || isGeneratingGreeting"
class="text-[10px] bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-3 py-1 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50"
@click="
updateConfig({ voicemail_greeting: config.voicemail_greeting });
generateGreeting();
"
>
{{ isGeneratingGreeting ? "Generating..." : "Save & Generate" }}
</button>
</div>
</div>
<!-- Delays -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label
class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
>Answer Delay (s)</label
>
<input
v-model.number="config.voicemail_auto_answer_delay_seconds"
type="number"
min="1"
max="120"
class="block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
@change="
updateConfig({
voicemail_auto_answer_delay_seconds:
config.voicemail_auto_answer_delay_seconds,
})
"
/>
</div>
<div class="space-y-2">
<label
class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
>Max Recording (s)</label
>
<input
v-model.number="config.voicemail_max_recording_seconds"
type="number"
min="5"
max="600"
class="block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
@change="
updateConfig({
voicemail_max_recording_seconds: config.voicemail_max_recording_seconds,
})
"
/>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -292,6 +571,17 @@ export default {
isCallEnded: false, isCallEnded: false,
lastCall: null, lastCall: null,
endedTimeout: null, endedTimeout: null,
activeTab: "phone",
voicemails: [],
unreadVoicemailsCount: 0,
voicemailStatus: {
has_espeak: false,
has_ffmpeg: false,
is_recording: false,
},
isGeneratingGreeting: false,
playingVoicemailId: null,
audioPlayer: null,
}; };
}, },
computed: { computed: {
@@ -307,15 +597,19 @@ export default {
this.getAudioProfiles(); this.getAudioProfiles();
this.getStatus(); this.getStatus();
this.getHistory(); this.getHistory();
this.getVoicemails();
this.getVoicemailStatus();
// poll for status // poll for status
this.statusInterval = setInterval(() => { this.statusInterval = setInterval(() => {
this.getStatus(); this.getStatus();
this.getVoicemailStatus();
}, 1000); }, 1000);
// poll for history less frequently // poll for history/voicemails less frequently
this.historyInterval = setInterval(() => { this.historyInterval = setInterval(() => {
this.getHistory(); this.getHistory();
this.getVoicemails();
}, 10000); }, 10000);
// autofill destination hash from query string // autofill destination hash from query string
@@ -329,6 +623,10 @@ export default {
if (this.statusInterval) clearInterval(this.statusInterval); if (this.statusInterval) clearInterval(this.statusInterval);
if (this.historyInterval) clearInterval(this.historyInterval); if (this.historyInterval) clearInterval(this.historyInterval);
if (this.endedTimeout) clearTimeout(this.endedTimeout); if (this.endedTimeout) clearTimeout(this.endedTimeout);
if (this.audioPlayer) {
this.audioPlayer.pause();
this.audioPlayer = null;
}
}, },
methods: { methods: {
formatDestinationHash(hash) { formatDestinationHash(hash) {
@@ -351,6 +649,15 @@ export default {
console.log(e); console.log(e);
} }
}, },
async updateConfig(config) {
try {
await window.axios.patch("/api/v1/config", config);
await this.getConfig();
ToastUtils.success("Settings saved");
} catch {
ToastUtils.error("Failed to save settings");
}
},
async getAudioProfiles() { async getAudioProfiles() {
try { try {
const response = await window.axios.get("/api/v1/telephone/audio-profiles"); const response = await window.axios.get("/api/v1/telephone/audio-profiles");
@@ -366,12 +673,17 @@ export default {
const oldCall = this.activeCall; const oldCall = this.activeCall;
this.activeCall = response.data.active_call; this.activeCall = response.data.active_call;
if (response.data.voicemail) {
this.unreadVoicemailsCount = response.data.voicemail.unread_count;
}
// If call just ended, refresh history and show ended state // If call just ended, refresh history and show ended state
if (oldCall != null && this.activeCall == null) { if (oldCall != null && this.activeCall == null) {
this.getHistory(); this.getHistory();
this.getVoicemails();
this.lastCall = oldCall; this.lastCall = oldCall;
this.isCallEnded = true; this.isCallEnded = true;
if (this.endedTimeout) clearTimeout(this.endedTimeout); if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.endedTimeout = setTimeout(() => { this.endedTimeout = setTimeout(() => {
this.isCallEnded = false; this.isCallEnded = false;
@@ -395,6 +707,72 @@ export default {
console.log(e); console.log(e);
} }
}, },
async getVoicemailStatus() {
try {
const response = await window.axios.get("/api/v1/telephone/voicemail/status");
this.voicemailStatus = response.data;
} catch (e) {
console.log(e);
}
},
async getVoicemails() {
try {
const response = await window.axios.get("/api/v1/telephone/voicemails");
this.voicemails = response.data.voicemails;
this.unreadVoicemailsCount = response.data.unread_count;
} catch (e) {
console.log(e);
}
},
async generateGreeting() {
this.isGeneratingGreeting = true;
try {
await window.axios.post("/api/v1/telephone/voicemail/generate-greeting");
ToastUtils.success("Greeting generated successfully");
} catch (e) {
ToastUtils.error(e.response?.data?.message || "Failed to generate greeting");
} finally {
this.isGeneratingGreeting = false;
}
},
async playVoicemail(voicemail) {
if (this.playingVoicemailId === voicemail.id) {
this.audioPlayer.pause();
this.playingVoicemailId = null;
return;
}
if (this.audioPlayer) {
this.audioPlayer.pause();
}
this.playingVoicemailId = voicemail.id;
this.audioPlayer = new Audio(`/api/v1/telephone/voicemails/${voicemail.id}/audio`);
this.audioPlayer.play();
this.audioPlayer.onended = () => {
this.playingVoicemailId = null;
};
// Mark as read
if (!voicemail.is_read) {
try {
await window.axios.post(`/api/v1/telephone/voicemails/${voicemail.id}/read`);
voicemail.is_read = 1;
this.unreadVoicemailsCount = Math.max(0, this.unreadVoicemailsCount - 1);
} catch (e) {
console.error(e);
}
}
},
async deleteVoicemail(voicemailId) {
try {
await window.axios.delete(`/api/v1/telephone/voicemails/${voicemailId}`);
this.getVoicemails();
ToastUtils.success("Voicemail deleted");
} catch {
ToastUtils.error("Failed to delete voicemail");
}
},
async call(identityHash) { async call(identityHash) {
if (!identityHash) { if (!identityHash) {
ToastUtils.error("Enter an identity hash to call"); ToastUtils.error("Enter an identity hash to call");

View File

@@ -388,7 +388,9 @@
<div class="border-t border-gray-100 dark:border-zinc-800 pt-4 space-y-4"> <div class="border-t border-gray-100 dark:border-zinc-800 pt-4 space-y-4">
<div> <div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">MBTiles Storage Directory</label> <label class="block text-xs font-bold text-gray-500 uppercase mb-1"
>MBTiles Storage Directory</label
>
<input <input
v-model="mbtilesDir" v-model="mbtilesDir"
type="text" type="text"
@@ -407,8 +409,14 @@
class="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800" class="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800"
> >
<div class="flex flex-col min-w-0 flex-1 mr-2"> <div class="flex flex-col min-w-0 flex-1 mr-2">
<span class="text-xs font-medium text-gray-900 dark:text-zinc-100 truncate" :title="file.name">{{ file.name }}</span> <span
<span class="text-[10px] text-gray-500">{{ (file.size / 1024 / 1024).toFixed(1) }} MB</span> class="text-xs font-medium text-gray-900 dark:text-zinc-100 truncate"
:title="file.name"
>{{ file.name }}</span
>
<span class="text-[10px] text-gray-500"
>{{ (file.size / 1024 / 1024).toFixed(1) }} MB</span
>
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<button <button
@@ -648,7 +656,8 @@ export default {
const response = await window.axios.get("/api/v1/config"); const response = await window.axios.get("/api/v1/config");
this.config = response.data.config; this.config = response.data.config;
this.offlineEnabled = this.config.map_offline_enabled; this.offlineEnabled = this.config.map_offline_enabled;
this.cachingEnabled = this.config.map_tile_cache_enabled !== undefined ? this.config.map_tile_cache_enabled : true; this.cachingEnabled =
this.config.map_tile_cache_enabled !== undefined ? this.config.map_tile_cache_enabled : true;
this.mbtilesDir = this.config.map_mbtiles_dir || ""; this.mbtilesDir = this.config.map_mbtiles_dir || "";
if (this.config.map_tile_server_url) { if (this.config.map_tile_server_url) {
this.tileServerUrl = this.config.map_tile_server_url; this.tileServerUrl = this.config.map_tile_server_url;
@@ -674,7 +683,7 @@ export default {
await this.checkOfflineMap(); await this.checkOfflineMap();
await this.loadMBTilesList(); await this.loadMBTilesList();
ToastUtils.success("Map source updated"); ToastUtils.success("Map source updated");
} catch (e) { } catch {
ToastUtils.error("Failed to set active map"); ToastUtils.error("Failed to set active map");
} }
}, },
@@ -687,7 +696,7 @@ export default {
await this.checkOfflineMap(); await this.checkOfflineMap();
} }
ToastUtils.success("File deleted"); ToastUtils.success("File deleted");
} catch (e) { } catch {
ToastUtils.error("Failed to delete file"); ToastUtils.error("Failed to delete file");
} }
}, },
@@ -698,7 +707,7 @@ export default {
}); });
ToastUtils.success("Storage directory saved"); ToastUtils.success("Storage directory saved");
this.loadMBTilesList(); this.loadMBTilesList();
} catch (e) { } catch {
ToastUtils.error("Failed to save directory"); ToastUtils.error("Failed to save directory");
} }
}, },
@@ -800,7 +809,7 @@ export default {
const customTileUrl = this.tileServerUrl || defaultTileUrl; const customTileUrl = this.tileServerUrl || defaultTileUrl;
const isCustomLocal = this.isLocalUrl(customTileUrl); const isCustomLocal = this.isLocalUrl(customTileUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl, "tile"); const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl, "tile");
let tileUrl; let tileUrl;
if (isOffline) { if (isOffline) {
if (isCustomLocal || (!isDefaultOnline && customTileUrl !== defaultTileUrl)) { if (isCustomLocal || (!isDefaultOnline && customTileUrl !== defaultTileUrl)) {
@@ -811,14 +820,14 @@ export default {
} else { } else {
tileUrl = customTileUrl; tileUrl = customTileUrl;
} }
const source = new XYZ({ const source = new XYZ({
url: tileUrl, url: tileUrl,
crossOrigin: "anonymous", crossOrigin: "anonymous",
}); });
const originalTileLoadFunction = source.getTileLoadFunction(); const originalTileLoadFunction = source.getTileLoadFunction();
if (isOffline) { if (isOffline) {
source.setTileLoadFunction(async (tile, src) => { source.setTileLoadFunction(async (tile, src) => {
try { try {
@@ -832,7 +841,7 @@ export default {
} }
const blob = await response.blob(); const blob = await response.blob();
tile.getImage().src = URL.createObjectURL(blob); tile.getImage().src = URL.createObjectURL(blob);
} catch (error) { } catch {
tile.setState(3); tile.setState(3);
} }
}); });
@@ -902,15 +911,15 @@ export default {
if (enabled) { if (enabled) {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const defaultNominatimUrl = "https://nominatim.openstreetmap.org"; const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl); const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl);
const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl, "tile"); const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl, "tile");
const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl; const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl;
const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl); const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim"); const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
const hasCustomNominatim = this.nominatimApiUrl && this.nominatimApiUrl !== defaultNominatimUrl; const hasCustomNominatim = this.nominatimApiUrl && this.nominatimApiUrl !== defaultNominatimUrl;
if (hasCustomTile && !isCustomTileLocal && !isDefaultTileOnline) { if (hasCustomTile && !isCustomTileLocal && !isDefaultTileOnline) {
const isAccessible = await this.checkApiConnection(this.tileServerUrl); const isAccessible = await this.checkApiConnection(this.tileServerUrl);
if (!isAccessible) { if (!isAccessible) {
@@ -918,7 +927,7 @@ export default {
return; return;
} }
} }
if (hasCustomNominatim && !isCustomNominatimLocal && !isDefaultNominatimOnline) { if (hasCustomNominatim && !isCustomNominatimLocal && !isDefaultNominatimOnline) {
const isAccessible = await this.checkApiConnection(this.nominatimApiUrl); const isAccessible = await this.checkApiConnection(this.nominatimApiUrl);
if (!isAccessible) { if (!isAccessible) {
@@ -1199,7 +1208,7 @@ export default {
const defaultNominatimUrl = "https://nominatim.openstreetmap.org"; const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomLocal = this.isLocalUrl(this.nominatimApiUrl); const isCustomLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim"); const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
if (this.offlineEnabled) { if (this.offlineEnabled) {
if (isCustomLocal || (!isDefaultOnline && this.nominatimApiUrl !== defaultNominatimUrl)) { if (isCustomLocal || (!isDefaultOnline && this.nominatimApiUrl !== defaultNominatimUrl)) {
const isAccessible = await this.checkApiConnection(this.nominatimApiUrl); const isAccessible = await this.checkApiConnection(this.nominatimApiUrl);

View File

@@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: 'Roboto Mono Nerd Font'; font-family: "Roboto Mono Nerd Font";
src: url('./RobotoMonoNerdFont-Regular.ttf') format('truetype'); src: url("./RobotoMonoNerdFont-Regular.ttf") format("truetype");
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
} }

View File

@@ -1,33 +1,35 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" />
<meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="/manifest.json"> <link rel="icon" type="image/png" href="favicons/favicon-512x512.png" />
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/> <title>Reticulum MeshChat</title>
<title>Reticulum MeshChat</title> </head>
<body class="bg-gray-100">
</head> <div id="app"></div>
<body class="bg-gray-100"> <script type="module" src="main.js"></script>
<div id="app"></div> <script>
<script type="module" src="main.js"></script> // install service worker
<script> if ("serviceWorker" in navigator) {
// install service worker navigator.serviceWorker.register("/service-worker.js").catch((error) => {
if('serviceWorker' in navigator){ // Silently handle SSL certificate errors and other registration failures
navigator.serviceWorker.register('/service-worker.js').catch((error) => { // This is common in development with self-signed certificates
// Silently handle SSL certificate errors and other registration failures const errorMessage = error.message || "";
// This is common in development with self-signed certificates const errorName = error.name || "";
const errorMessage = error.message || ''; if (
const errorName = error.name || ''; errorName === "SecurityError" ||
if (errorName === 'SecurityError' || errorMessage.includes('SSL certificate') || errorMessage.includes('certificate')) { errorMessage.includes("SSL certificate") ||
return; errorMessage.includes("certificate")
) {
return;
}
// Log other errors for debugging but don't throw
console.debug("Service worker registration failed:", error);
});
} }
// Log other errors for debugging but don't throw </script>
console.debug('Service worker registration failed:', error); </body>
});
}
</script>
</body>
</html> </html>

View File

@@ -79,7 +79,7 @@ class Utils {
static formatTimeAgo(datetimeString) { static formatTimeAgo(datetimeString) {
if (!datetimeString) return "unknown"; if (!datetimeString) return "unknown";
// ensure UTC if no timezone is provided // ensure UTC if no timezone is provided
let dateString = datetimeString; let dateString = datetimeString;
if (typeof dateString === "string" && !dateString.includes("Z") && !dateString.includes("+")) { if (typeof dateString === "string" && !dateString.includes("Z") && !dateString.includes("+")) {
@@ -87,7 +87,7 @@ class Utils {
// Replace space with T and append Z for ISO format // Replace space with T and append Z for ISO format
dateString = dateString.replace(" ", "T") + "Z"; dateString = dateString.replace(" ", "T") + "Z";
} }
const millisecondsAgo = Date.now() - new Date(dateString).getTime(); const millisecondsAgo = Date.now() - new Date(dateString).getTime();
const secondsAgo = Math.round(millisecondsAgo / 1000); const secondsAgo = Math.round(millisecondsAgo / 1000);
return this.formatSeconds(secondsAgo); return this.formatSeconds(secondsAgo);

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
""" """Auto-generated helper so Python tooling and the Electron build
Auto-generated helper so Python tooling and the Electron build
share the same version string. share the same version string.
""" """
__version__ = '2.50.0' __version__ = "2.50.0"

View File

@@ -1,155 +1,155 @@
{ {
"name": "reticulum-meshchatx", "name": "reticulum-meshchatx",
"version": "2.50.0", "version": "2.50.0",
"description": "A simple mesh network communications app powered by the Reticulum Network Stack", "description": "A simple mesh network communications app powered by the Reticulum Network Stack",
"author": "Sudo-Ivan", "author": "Sudo-Ivan",
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"watch": "pnpm run build-frontend -- --watch", "watch": "pnpm run build-frontend -- --watch",
"build-frontend": "vite build", "build-frontend": "vite build",
"build-backend": "node scripts/build-backend.js", "build-backend": "node scripts/build-backend.js",
"build": "pnpm run build-frontend && pnpm run build-backend", "build": "pnpm run build-frontend && pnpm run build-backend",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"electron-postinstall": "electron-builder install-app-deps", "electron-postinstall": "electron-builder install-app-deps",
"electron": "pnpm run electron-postinstall && pnpm run build && electron .", "electron": "pnpm run electron-postinstall && pnpm run build && electron .",
"dist": "pnpm run electron-postinstall && pnpm run build && electron-builder --publish=never", "dist": "pnpm run electron-postinstall && pnpm run build && electron-builder --publish=never",
"dist-prebuilt": "pnpm run electron-postinstall && pnpm run build-backend && electron-builder --publish=never", "dist-prebuilt": "pnpm run electron-postinstall && pnpm run build-backend && electron-builder --publish=never",
"dist:mac-arm64": "pnpm run electron-postinstall && pnpm run build && electron-builder --mac --arm64 --publish=never", "dist:mac-arm64": "pnpm run electron-postinstall && pnpm run build && electron-builder --mac --arm64 --publish=never",
"dist:mac-universal": "pnpm run electron-postinstall && pnpm run build && electron-builder --mac --universal --publish=never" "dist:mac-universal": "pnpm run electron-postinstall && pnpm run build && electron-builder --mac --universal --publish=never"
},
"license": "MIT",
"engines": {
"node": ">=18"
},
"packageManager": "pnpm@10.0.0",
"devDependencies": {
"@eslint/js": "^9.39.2",
"@rushstack/eslint-patch": "^1.15.0",
"@vue/eslint-config-prettier": "^10.2.0",
"electron": "^39.2.7",
"electron-builder": "^24.13.3",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"terser": "^5.44.1"
},
"build": {
"appId": "com.sudoivan.reticulummeshchat",
"productName": "Reticulum MeshChatX",
"asar": true,
"asarUnpack": [
"build/exe/**/*"
],
"files": [
"electron/**/*"
],
"directories": {
"buildResources": "electron/build"
}, },
"mac": { "license": "MIT",
"target": { "engines": {
"target": "dmg", "node": ">=18"
"arch": [
"universal"
]
},
"identity": null,
"artifactName": "ReticulumMeshChat-v${version}-mac-${arch}.${ext}",
"x64ArchFiles": "Contents/Resources/app/electron/build/exe/**",
"extendInfo": {
"NSMicrophoneUsageDescription": "Microphone access is only needed for Audio Calls",
"com.apple.security.device.audio-input": true
},
"extraFiles": [
{
"from": "build/exe",
"to": "Resources/app/electron/build/exe",
"filter": [
"**/*"
]
}
]
}, },
"win": { "packageManager": "pnpm@10.0.0",
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}", "devDependencies": {
"target": [ "@eslint/js": "^9.39.2",
{ "@rushstack/eslint-patch": "^1.15.0",
"target": "portable" "@vue/eslint-config-prettier": "^10.2.0",
"electron": "^39.2.7",
"electron-builder": "^24.13.3",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"terser": "^5.44.1"
},
"build": {
"appId": "com.sudoivan.reticulummeshchat",
"productName": "Reticulum MeshChatX",
"asar": true,
"asarUnpack": [
"build/exe/**/*"
],
"files": [
"electron/**/*"
],
"directories": {
"buildResources": "electron/build"
}, },
{ "mac": {
"target": "nsis" "target": {
"target": "dmg",
"arch": [
"universal"
]
},
"identity": null,
"artifactName": "ReticulumMeshChat-v${version}-mac-${arch}.${ext}",
"x64ArchFiles": "Contents/Resources/app/electron/build/exe/**",
"extendInfo": {
"NSMicrophoneUsageDescription": "Microphone access is only needed for Audio Calls",
"com.apple.security.device.audio-input": true
},
"extraFiles": [
{
"from": "build/exe",
"to": "Resources/app/electron/build/exe",
"filter": [
"**/*"
]
}
]
},
"win": {
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
"target": [
{
"target": "portable"
},
{
"target": "nsis"
}
],
"extraFiles": [
{
"from": "build/exe",
"to": "Resources/app/electron/build/exe",
"filter": [
"**/*"
]
}
]
},
"linux": {
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
"target": [
"AppImage",
"deb"
],
"maintainer": "Sudo-Ivan",
"category": "Network",
"extraFiles": [
{
"from": "build/exe",
"to": "resources/app/electron/build/exe",
"filter": [
"**/*"
]
}
]
},
"dmg": {
"writeUpdateInfo": false
},
"portable": {
"artifactName": "ReticulumMeshChat-v${version}-${os}-portable.${ext}"
},
"nsis": {
"artifactName": "ReticulumMeshChat-v${version}-${os}-installer.${ext}",
"oneClick": false,
"allowToChangeInstallationDirectory": true
} }
],
"extraFiles": [
{
"from": "build/exe",
"to": "Resources/app/electron/build/exe",
"filter": [
"**/*"
]
}
]
}, },
"linux": { "dependencies": {
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}", "@mdi/js": "^7.4.47",
"target": [ "@tailwindcss/forms": "^0.5.11",
"AppImage", "@vitejs/plugin-vue": "^5.2.4",
"deb" "autoprefixer": "^10.4.23",
], "axios": "^1.13.2",
"maintainer": "Sudo-Ivan", "click-outside-vue3": "^4.0.1",
"category": "Network", "compressorjs": "^1.2.1",
"extraFiles": [ "dayjs": "^1.11.19",
{ "electron-prompt": "^1.7.0",
"from": "build/exe", "micron-parser": "^1.0.2",
"to": "resources/app/electron/build/exe", "mitt": "^3.0.1",
"filter": [ "ol": "^10.7.0",
"**/*" "postcss": "^8.5.6",
] "protobufjs": "^7.5.4",
} "tailwindcss": "^3.4.19",
] "vis-data": "^7.1.10",
}, "vis-network": "^9.1.13",
"dmg": { "vite": "^6.4.1",
"writeUpdateInfo": false "vite-plugin-vuetify": "^2.1.2",
}, "vue": "^3.5.26",
"portable": { "vue-i18n": "^11.2.8",
"artifactName": "ReticulumMeshChat-v${version}-${os}-portable.${ext}" "vue-router": "^4.6.4",
}, "vuetify": "^3.11.6"
"nsis": {
"artifactName": "ReticulumMeshChat-v${version}-${os}-installer.${ext}",
"oneClick": false,
"allowToChangeInstallationDirectory": true
} }
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.11",
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.23",
"axios": "^1.13.2",
"click-outside-vue3": "^4.0.1",
"compressorjs": "^1.2.1",
"dayjs": "^1.11.19",
"electron-prompt": "^1.7.0",
"micron-parser": "^1.0.2",
"mitt": "^3.0.1",
"ol": "^10.7.0",
"postcss": "^8.5.6",
"protobufjs": "^7.5.4",
"tailwindcss": "^3.4.19",
"vis-data": "^7.1.10",
"vis-network": "^9.1.13",
"vite": "^6.4.1",
"vite-plugin-vuetify": "^2.1.2",
"vue": "^3.5.26",
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4",
"vuetify": "^3.11.6"
}
} }

14
poetry.lock generated
View File

@@ -1375,6 +1375,18 @@ files = [
[package.extras] [package.extras]
test = ["importlib_metadata (>=2.0)", "pytest (>=6.0)"] test = ["importlib_metadata (>=2.0)", "pytest (>=6.0)"]
[[package]]
name = "ply"
version = "3.11"
description = "Python Lex & Yacc"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"},
{file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"},
]
[[package]] [[package]]
name = "propcache" name = "propcache"
version = "0.4.1" version = "0.4.1"
@@ -1975,4 +1987,4 @@ propcache = ">=0.2.1"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.11" python-versions = ">=3.11"
content-hash = "fc66bbe16d88af079264f801bc18fd10385c0e6af437fdf0e5ab960349971b21" content-hash = "6dae87a310bad0bec81b8eea974fd5cb4b0c40dd2873d09ff7f4659a13f63d7e"

View File

@@ -35,6 +35,7 @@ dependencies = [
"requests (>=2.32.5,<3.0.0)", "requests (>=2.32.5,<3.0.0)",
"lxst (>=0.4.5,<0.5.0)", "lxst (>=0.4.5,<0.5.0)",
"audioop-lts (>=0.2.2); python_version>='3.13'", "audioop-lts (>=0.2.2); python_version>='3.13'",
"ply (>=3.11,<4.0)",
] ]
[project.scripts] [project.scripts]