chore(release): merge dev for v4.6.2-rc.2

This commit is contained in:
Ivan
2026-05-10 04:42:29 -05:00
117 changed files with 8320 additions and 4937 deletions
+9 -9
View File
@@ -32,7 +32,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
PYTHON_VERSION: "3.14"
NODE_VERSION: "24"
POETRY_VERSION: "2.3.4"
UV_VERSION: "0.11.12"
PNPM_VERSION: "10.33.0"
jobs:
@@ -48,18 +48,18 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache Poetry downloads
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-pypoetry-${{ hashFiles('poetry.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-pypoetry-
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
@@ -88,7 +88,7 @@ jobs:
- name: Run benchmarks
run: |
set -euo pipefail
poetry run python tests/backend/run_comprehensive_benchmarks.py \
uv run python tests/backend/run_comprehensive_benchmarks.py \
--json-output bench_results.json 2>&1 | tee bench_results.txt
- name: Run integrity tests
+162 -10
View File
@@ -1,5 +1,5 @@
# Linux packaging build test: Flatpak (branches and PRs). Tagged Flatpak releases
# run in `.github/workflows/build-release.yml` with Linux + desktop + draft.
# Linux packaging build test: AppImage, deb, rpm, Flatpak (branches and PRs).
# Tagged release assets run in .github/workflows/build-release.yml with draft.
#
# Pinned first-party actions (bump tag and SHA together when upgrading):
# actions/checkout@v6.0.1 8e8c483db84b4bee98b60c0593521ed34d9990e8
@@ -41,7 +41,7 @@ env:
NODE_OPTIONS: --max-old-space-size=8192
PYTHON_VERSION: "3.14"
NODE_VERSION: "24"
POETRY_VERSION: "2.3.4"
UV_VERSION: "0.11.12"
PNPM_VERSION: "10.32.1"
jobs:
@@ -54,6 +54,158 @@ jobs:
artifact_name: meshchatx-frontend-linux-pkg-${{ github.run_id }}-${{ github.run_attempt }}
retention_days: 1
linux-test-x64:
name: Linux build test (x64)
needs: frontend
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
actions: write
env:
FRONTEND_ARTIFACT_NAME: ${{ needs.frontend.outputs.artifact_name }}
MESHCHATX_FRONTEND_PREBUILT: "1"
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install UV (PyPI pin)
env:
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Linux packaging APT dependencies
run: bash scripts/ci/github-apt-linux-packaging.sh
- name: Install project dependencies
run: bash scripts/ci/github-install-deps.sh
- name: Download frontend artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: ${{ env.FRONTEND_ARTIFACT_NAME }}
path: meshchatx/public
- name: Verify frontend artifact contents
run: |
set -euo pipefail
test -f meshchatx/public/index.html
test -d meshchatx/public/assets
test -d meshchatx/public/reticulum-docs-bundled/current
- name: Setup Task
run: sh scripts/ci/setup-task.sh
- name: Build release-assets
run: bash scripts/ci/github-build-linux-release-assets.sh
- name: Upload Linux build-test artifact (x64)
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: meshchatx-linux-build-test-x64-${{ github.ref_name }}-${{ github.run_id }}
path: release-assets/
if-no-files-found: warn
retention-days: 1
linux-test-arm64:
name: Linux build test (arm64)
needs: frontend
runs-on: ubuntu-24.04-arm
timeout-minutes: 120
permissions:
contents: read
actions: write
env:
FRONTEND_ARTIFACT_NAME: ${{ needs.frontend.outputs.artifact_name }}
MESHCHATX_FRONTEND_PREBUILT: "1"
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install UV (PyPI pin)
env:
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Linux packaging APT dependencies
run: bash scripts/ci/github-apt-linux-packaging.sh
- name: Install project dependencies
run: bash scripts/ci/github-install-deps.sh
- name: Download frontend artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: ${{ env.FRONTEND_ARTIFACT_NAME }}
path: meshchatx/public
- name: Verify frontend artifact contents
run: |
set -euo pipefail
test -f meshchatx/public/index.html
test -d meshchatx/public/assets
test -d meshchatx/public/reticulum-docs-bundled/current
- name: Setup Task
run: sh scripts/ci/setup-task.sh
- name: Build release-assets
run: bash scripts/ci/github-build-linux-release-assets.sh
- name: Upload Linux build-test artifact (arm64)
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: meshchatx-linux-build-test-arm64-${{ github.ref_name }}-${{ github.run_id }}
path: release-assets/
if-no-files-found: warn
retention-days: 1
flatpak:
name: Flatpak (electron-forge)
needs: frontend
@@ -94,18 +246,18 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache Poetry downloads
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-pypoetry-${{ hashFiles('poetry.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-pypoetry-
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
+145 -38
View File
@@ -1,9 +1,9 @@
# Single tagged-release pipeline: Linux release assets, Windows + macOS Electron
# builds, Flatpak, Android APKs (dev/master track tags via android-apk-tag.yml), SLSA
# provenance (generic generator: Linux, desktop, optional Android+Flatpak), optional cosign bundles, and one draft GitHub release.
# Optional: same draft ``upload/`` tree is mirrored to bunny.net Edge Storage for master/dev
# tags when ``BUNNY_STORAGE_ACCESS_KEY`` is set (see draft job). ``-rc`` tags always use the
# ``dev/`` prefix; previous release folders under ``master/`` and ``dev/`` are pruned after each upload.
# Optional: same draft upload/ tree is mirrored to bunny.net Edge Storage for master/dev
# tags when BUNNY_STORAGE_ACCESS_KEY is set (see draft job). -rc tags always use the
# dev/ prefix; previous release folders under master/ and dev/ are pruned after each upload.
# One workflow run per tag keeps the release graph immutable.
#
# Pinned first-party actions (bump tag and SHA together when upgrading):
@@ -40,7 +40,7 @@ env:
NODE_OPTIONS: --max-old-space-size=8192
PYTHON_VERSION: "3.14"
NODE_VERSION: "24"
POETRY_VERSION: "2.3.4"
UV_VERSION: "0.11.12"
PNPM_VERSION: "10.33.0"
COSIGN_VERSION: "3.0.6"
# Official .deb from aquasecurity/trivy releases; scripts/ci/setup-trivy.sh verifies
@@ -81,12 +81,10 @@ jobs:
run_unit_tests: true
linux-release:
name: Linux release assets
name: Linux release assets (x64)
needs: frontend
runs-on: ubuntu-latest
timeout-minutes: 120
outputs:
hashes: ${{ steps.slsa-hashes.outputs.hashes }}
permissions:
contents: read
actions: write
@@ -102,18 +100,18 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache Poetry downloads
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-pypoetry-${{ hashFiles('poetry.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-pypoetry-
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
@@ -156,9 +154,116 @@ jobs:
- name: Build release-assets
run: bash scripts/ci/github-build-linux-release-assets.sh
- name: Upload Linux release artifact (x64)
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: meshchatx-linux-release-x64-${{ github.ref_name }}-${{ github.run_id }}
path: release-assets/
if-no-files-found: error
retention-days: 30
linux-release-arm64:
name: Linux release assets (arm64)
needs: frontend
runs-on: ubuntu-24.04-arm
timeout-minutes: 120
permissions:
contents: read
actions: write
env:
FRONTEND_ARTIFACT_NAME: ${{ needs.frontend.outputs.artifact_name }}
MESHCHATX_FRONTEND_PREBUILT: "1"
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install UV (PyPI pin)
env:
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Linux packaging APT dependencies
run: bash scripts/ci/github-apt-linux-packaging.sh
- name: Install project dependencies
run: bash scripts/ci/github-install-deps.sh
- name: Download frontend artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: ${{ env.FRONTEND_ARTIFACT_NAME }}
path: meshchatx/public
- name: Verify frontend artifact contents
run: |
set -euo pipefail
test -f meshchatx/public/index.html
test -d meshchatx/public/assets
test -d meshchatx/public/reticulum-docs-bundled/current
- name: Build release-assets
run: bash scripts/ci/github-build-linux-release-assets.sh
- name: Upload Linux release artifact (arm64)
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: meshchatx-linux-release-arm64-${{ github.ref_name }}-${{ github.run_id }}
path: release-assets/
if-no-files-found: error
retention-days: 30
collect-linux-slsa-subjects:
name: SLSA subjects + cosign (Linux)
needs: [linux-release, linux-release-arm64]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
outputs:
hashes: ${{ steps.hash.outputs.hashes }}
permissions:
contents: read
actions: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Download x64 artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: meshchatx-linux-release-x64-${{ github.ref_name }}-${{ github.run_id }}
path: release-assets
- name: Download arm64 artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: meshchatx-linux-release-arm64-${{ github.ref_name }}-${{ github.run_id }}
path: release-assets
- name: SLSA subject hashes
id: slsa-hashes
if: startsWith(github.ref, 'refs/tags/')
id: hash
run: bash scripts/ci/github-slsa-hashes-release-assets.sh
- name: SLSA attestations (cosign)
@@ -187,14 +292,6 @@ jobs:
sh scripts/ci/attest-release-assets.sh ./release-assets
rm -f /tmp/cosign.key
- name: Upload Linux release artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: meshchatx-linux-release-${{ github.ref_name }}-${{ github.run_id }}
path: release-assets/
if-no-files-found: error
retention-days: 30
flatpak:
name: Flatpak (electron-forge)
needs: frontend
@@ -237,18 +334,18 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache Poetry downloads
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-pypoetry-${{ hashFiles('poetry.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-pypoetry-
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
@@ -332,10 +429,10 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
@@ -467,7 +564,7 @@ jobs:
slsa-provenance-linux:
name: SLSA provenance (Linux)
needs: [linux-release]
needs: [collect-linux-slsa-subjects]
if: startsWith(github.ref, 'refs/tags/')
permissions:
id-token: write
@@ -475,7 +572,7 @@ jobs:
actions: read
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: ${{ needs.linux-release.outputs.hashes }}
base64-subjects: ${{ needs.collect-linux-slsa-subjects.outputs.hashes }}
upload-assets: false
provenance-name: meshchatx-linux-${{ github.ref_name }}.intoto.jsonl
@@ -541,6 +638,8 @@ jobs:
name: Draft GitHub release (all assets + SLSA)
needs:
- linux-release
- linux-release-arm64
- collect-linux-slsa-subjects
- slsa-provenance-linux
- build-release
- slsa-provenance-desktop
@@ -553,6 +652,8 @@ jobs:
!cancelled() &&
startsWith(github.ref, 'refs/tags/') &&
needs.linux-release.result == 'success' &&
needs.linux-release-arm64.result == 'success' &&
needs.collect-linux-slsa-subjects.result == 'success' &&
needs.slsa-provenance-linux.result == 'success' &&
needs.build-release.result == 'success' &&
needs.collect-desktop-slsa-subjects.result == 'success' &&
@@ -597,10 +698,16 @@ jobs:
echo "track=${track}" >> "${GITHUB_OUTPUT}"
echo "Resolved tag ${GITHUB_REF_NAME} -> track=${track}"
- name: Download Linux release assets
- name: Download Linux release assets (x64)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: meshchatx-linux-release-${{ github.ref_name }}-${{ github.run_id }}
name: meshchatx-linux-release-x64-${{ github.ref_name }}-${{ github.run_id }}
path: upload
- name: Download Linux release assets (arm64)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: meshchatx-linux-release-arm64-${{ github.ref_name }}-${{ github.run_id }}
path: upload
- name: Download Windows dist
+4 -4
View File
@@ -33,7 +33,7 @@ env:
NODE_OPTIONS: --max-old-space-size=8192
PYTHON_VERSION: "3.14"
NODE_VERSION: "24"
POETRY_VERSION: "2.3.4"
UV_VERSION: "0.11.12"
PNPM_VERSION: "10.32.1"
jobs:
@@ -80,10 +80,10 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
+20 -20
View File
@@ -35,7 +35,7 @@ env:
NODE_OPTIONS: --max-old-space-size=8192
PYTHON_VERSION: "3.14"
NODE_VERSION: "24"
POETRY_VERSION: "2.3.4"
UV_VERSION: "0.11.12"
PNPM_VERSION: "10.32.1"
jobs:
@@ -80,18 +80,18 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache Poetry downloads
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-pypoetry-${{ hashFiles('poetry.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-pypoetry-
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
@@ -112,20 +112,20 @@ jobs:
case "${{ matrix.task.id }}" in
lint)
pnpm run lint
poetry run ruff check .
poetry run ruff format --check .
uv run ruff check .
uv run ruff format --check .
;;
frontend-tests)
pnpm exec vitest run --exclude tests/frontend/LoadTimePerformance.test.js --exclude tests/frontend/i18n.test.js
pnpm exec vitest run --config vitest.electron.config.js
;;
backend-tests)
poetry run python -m pytest tests/backend -n auto \
uv run python -m pytest tests/backend -n auto \
--cov=meshchatx/src/backend
;;
lang-tests)
pnpm exec vitest run tests/frontend/i18n.test.js
poetry run python -m pytest tests/backend/test_translator_handler.py
uv run python -m pytest tests/backend/test_translator_handler.py
;;
*)
echo "Unknown matrix task: ${{ matrix.task.id }}" >&2
@@ -152,18 +152,18 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache Poetry downloads
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-pypoetry-${{ hashFiles('poetry.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-pypoetry-
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
@@ -192,7 +192,7 @@ jobs:
test -d meshchatx/public/reticulum-docs-bundled/current
- name: Compile backend sources
run: poetry run python -m compileall meshchatx/
run: uv run python -m compileall meshchatx/
- name: Build backend (cx_Freeze)
run: pnpm run build-backend
+8 -8
View File
@@ -30,7 +30,7 @@ env:
NODE_OPTIONS: --max-old-space-size=8192
PYTHON_VERSION: "3.14"
NODE_VERSION: "24"
POETRY_VERSION: "2.3.4"
UV_VERSION: "0.11.12"
PNPM_VERSION: "10.32.1"
jobs:
@@ -103,18 +103,18 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache Poetry downloads
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-pypoetry-${{ hashFiles('poetry.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-pypoetry-
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
+16 -5
View File
@@ -54,6 +54,16 @@ jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- variant: standard
dockerfile: ./Dockerfile
tag_suffix: ""
- variant: hardened
dockerfile: ./Dockerfile.hardened
tag_suffix: "-hardened"
steps:
- name: Verify action pins (GitHub API)
env:
@@ -149,9 +159,10 @@ jobs:
env:
GITHUB_REF: ${{ github.ref }}
GITHUB_REF_NAME: ${{ github.ref_name }}
TAG_SUFFIX: ${{ matrix.tag_suffix }}
run: |
set -euo pipefail
sh scripts/ci/docker-tags.sh "${{ steps.image.outputs.name }}" /tmp/docker-tags.txt
TAG_SUFFIX="${{ matrix.tag_suffix }}" sh scripts/ci/docker-tags.sh "${{ steps.image.outputs.name }}" /tmp/docker-tags.txt
{
echo 'tags<<EOF'
sed 's/^-t //' /tmp/docker-tags.txt
@@ -170,7 +181,7 @@ jobs:
set -euo pipefail
repo="${DH_REPO_NAME:-meshchatx}"
base="docker.io/$(printf '%s' "$DH_USER" | tr '[:upper:]' '[:lower:]')/$(printf '%s' "$repo" | tr '[:upper:]' '[:lower:]')"
sh scripts/ci/docker-tags.sh "${base}" /tmp/docker-hub-tags.txt
TAG_SUFFIX="${{ matrix.tag_suffix }}" sh scripts/ci/docker-tags.sh "${base}" /tmp/docker-hub-tags.txt
{
echo 'tags<<EOF'
sed 's/^-t //' /tmp/docker-hub-tags.txt
@@ -198,12 +209,12 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
with:
context: .
file: ./Dockerfile
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.all_tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.variant }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
build-args: |
OCI_REVISION=${{ github.sha }}
OCI_VERSION=${{ steps.oci.outputs.version }}
+3 -3
View File
@@ -1,6 +1,6 @@
# Reusable workflow that builds the Vite frontend, bundles the offline Reticulum
# manual and repository wheels into ``meshchatx/public/``. Other workflows invoke this once
# via ``workflow_call`` and download the resulting artifact instead of
# manual and repository wheels into meshchatx/public/. Other workflows invoke this once
# via workflow_call and download the resulting artifact instead of
# re-running the same node/pnpm pipeline on every job.
#
# Pinned first-party actions (bump tag and SHA together when upgrading):
@@ -17,7 +17,7 @@ on:
artifact_name:
description: >-
Name of the artifact uploaded to the calling workflow run.
Defaults to ``meshchatx-frontend-<run_id>-<run_attempt>``.
Defaults to meshchatx-frontend-<run_id>-<run_attempt>.
required: false
type: string
default: ""
+8 -8
View File
@@ -1,14 +1,14 @@
# Publish ``reticulum-meshchatx`` sdist and wheel to PyPI (Trusted Publishing).
# Publish reticulum-meshchatx sdist and wheel to PyPI (Trusted Publishing).
# Builds the Vite frontend and offline bundles via the reusable frontend workflow,
# places them under ``meshchatx/public/``, then runs ``python -m build``.
# places them under meshchatx/public/, then runs python -m build.
#
# Tag runs also emit SLSA generic provenance (``generator_generic_slsa3``) for the
# exact ``dist/`` digests and optional Cosign ``*.cosign.bundle`` files next to each
# distribution (same scripts as ``build-release.yml``; skips if ``COSIGN_PRIVATE_KEY`` unset).
# Tag runs also emit SLSA generic provenance (generator_generic_slsa3) for the
# exact dist/ digests and optional Cosign *.cosign.bundle files next to each
# distribution (same scripts as build-release.yml; skips if COSIGN_PRIVATE_KEY unset).
# PyPI upload uses a staging directory so bundles are not sent to the index.
#
# PyPI Trusted Publisher must reference this file as ``pypi.yml`` and use the
# ``pypi`` GitHub Environment (with required reviewers if you enabled protection).
# PyPI Trusted Publisher must reference this file as pypi.yml and use the
# pypi GitHub Environment (with required reviewers if you enabled protection).
#
# Pinned first-party actions (bump tag and SHA together when upgrading):
# actions/checkout@v6.0.1 8e8c483db84b4bee98b60c0593521ed34d9990e8
@@ -19,7 +19,7 @@
# SLSA generator (must stay @vX.Y.Z semver per upstream):
# slsa-framework/slsa-github-generator/generator_generic_slsa3.yml@v2.1.0
#
# Third-party pin (resolve before bumping ``release/v1``):
# Third-party pin (resolve before bumping release/v1):
# curl -sS "https://api.github.com/repos/pypa/gh-action-pypi-publish/commits/release/v1" | jq -r '.sha'
# pypa/gh-action-pypi-publish@release/v1 -> cef221092ed1bacb1cc03d23a2d87d1d172e277b
+10 -10
View File
@@ -30,7 +30,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
PYTHON_VERSION: "3.14"
NODE_VERSION: "24"
POETRY_VERSION: "2.3.4"
UV_VERSION: "0.11.12"
PNPM_VERSION: "10.32.1"
COSIGN_VERSION: "3.0.6"
# Official .deb; setup-trivy.sh verifies sigstore + SHA256 (see build-release.yml).
@@ -51,18 +51,18 @@ jobs:
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install Poetry (PyPI pin)
- name: Install UV (PyPI pin)
env:
POETRY_VERSION: ${{ env.POETRY_VERSION }}
run: bash scripts/ci/github-install-poetry.sh
UV_VERSION: ${{ env.UV_VERSION }}
run: bash scripts/ci/github-install-uv.sh
- name: Cache Poetry downloads
- name: Cache UV downloads
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-pypoetry-${{ hashFiles('poetry.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-pypoetry-
${{ runner.os }}-uv-
- name: Enable pnpm (corepack)
run: corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
@@ -79,8 +79,8 @@ jobs:
- name: pip-audit
run: |
poetry run pip install --upgrade "pip>=26.0" pip-audit
poetry run pip-audit --ignore-vuln CVE-2026-3219
uv run pip install --upgrade "pip>=26.0" pip-audit
uv run pip-audit --ignore-vuln CVE-2026-3219
- name: Apt update (for Trivy .deb)
run: sh scripts/ci/exec-priv.sh apt-get update -qq
+33 -4
View File
@@ -2,15 +2,31 @@
All notable changes to this project will be documented in this file.
## [4.6.2] - 2026-05-06
## [4.6.2] - 2026-05-10
### Fixed
- **Build backend**: Bytecode cleanup now only removes .pyc and .pyo files when a matching .py source file is present, so standalone compiled artifacts are not deleted accidentally.
- **Propagation sync**: Auto-select no longer lets a sync get stuck on an unreachable node. When `auto_propagation` is enabled and the current propagation node loses its path while syncing, the manager stops the stuck sync and evaluates candidates to find one with a working path. If no candidate works, the broken active node is removed rather than restored.
- **Propagation sync**: Auto-select no longer lets a sync get stuck on an unreachable node. When `auto_propagation` is enabled and the current propagation node loses its path while syncing, the manager stops the stuck sync and evaluates candidates to find one with a working path. If no candidate works, the broken active node is removed rather than restored. Stuck-sync detection also monitors path unresponsiveness and stale paths.
- **LXMF / chat UI**: Conversation sidebar and list APIs show one-line previews for **image-only**, **voice-note (audio)**, and **file-attachment** messages (plus notification previews) instead of an empty subtitle. Server (`lxmf_sidebar_preview_for_conversation_latest_row`) and client (`lxmfConversationListPreview`) stay aligned, with locale strings and tests.
- **Outbound images (pending row)**: Optimistic `pending-*` messages no longer trigger a `GET /api/v1/lxmf-messages/attachment/pending-*/image` **404** when the FileReader preview URL is not ready yet. The client falls back to an inline **data URL** from the outbound job payload and avoids using the attachment endpoint for pending hashes without a preview.
- **Conversation loads**: Stale responses from an older `lxmf-messages/conversation` fetch (e.g. after switching peers quickly) are still discarded safely, without noisy console logging.
- **Identity context**: Guarded `inbound_stamp_cost` comparison against non-integer values to prevent `TypeError`.
- **Config parsing**: `configparser` errors when reading configuration files are now handled gracefully to prevent crashes.
- **Conversation cleanup**: Blocking a destination now deletes the associated conversation to ensure proper cleanup.
- **Call handling**: Delayed hangup for rejected calls with improved contact lookup handling.
- **Docs manager**: `/meshchatx-docs/index.html` now resolves correctly after generating an `index.html` during docs population.
- **Health monitor**: Added garbage collection calls during context teardown and health checks to clean up resources.
- **Ping error logging**: Failed destination pings now log a clean `console.warn` message instead of dumping the full `HttpError` stack trace in browser dev tools.
- **Trivy CI setup**: Added curl retries (`--retry 5 --retry-delay 2`) to handle transient 502 errors during Trivy downloads.
- **Tests**: Fixed multiple failing backend tests (`test_http_api_contract`, `test_interface_discovery`, `test_websocket_interfaces`, `test_security_fuzzing`, `test_telemetry_integration`) for pytest compatibility, BoolConfig mocking, and RNS `get_instance` changes. Added missing dev dependencies (`pytest-asyncio`, `pytest-xdist`, `pytest-cov`, `jsonschema`).
- **Banishment**: Blocking now targets the **identity**, not just a single destination hash. All known destinations for the same identity are blocked, contacts are deleted, and LXMF stamp/ticket state is cleaned up from `LXMRouter`.
- **Banishment (UI)**: Blocked destinations page groups entries by identity and shows all blocked destination hashes per identity. Unblocking one unblocks the entire identity.
- **Banishment (Reticulum)**: `blackhole_identity()` is always applied when available to drop packets before LXMF delivery callbacks reach the sender, preventing "phantom deliveries" to blocked peers.
- **NomadNet file downloads**: Backtick-separated request data (e.g. `/file/artifact`g=reticulum|r=lxmf|t=0.9.7`) is now parsed and forwarded as `var_*` request data dicts, matching upstream NomadNet behavior. Previously the raw string was passed and remote nodes could not resolve the artifact.
- **NomadNet file downloads (cancel)**: Fixed `AttributeError` when cancelling a download — `RequestReceipt` has no `.cancel()`; we now cancel the underlying `Resource` if present, or mark the receipt `FAILED` and remove it from the link queue.
- **NomadNet browser (links)**: Relative `/page/` and `/file/` URLs from the Micron parser (which include backtick parameters) are now parsed correctly so they no longer show "Unsupported URL".
- **NomadNet browser (hover)**: Links with `data-destination` now show the full URL including backtick parameters in the browser hover title.
### Added
@@ -22,16 +38,29 @@ All notable changes to this project will be documented in this file.
- **Micron (Nomad)** (thanks to @RFnexus): MicronParser text inputs can upgrade to **multiline** textareas. Pressing **Enter twice** shows a hint. **`multiline_hint`** was added for NomadNet strings across supported locales.
- **Chat header (LXMF stamps)**: When the peer has an **outbound stamp ticket** (`outbound_ticket_expiry` from stamp info), a **ticket** icon appears beside stamp cost. The icon is **green** while the ticket is still valid, **amber** after expiry, with localized tooltips.
- **Connectivity (Android tooling)**: **`usbserial4a`** dependency for USB serial support in the stack where used.
- **FAQ**: Added `FAQ.md` covering common questions about LXMF reachability, project goals, AI usage, legacy support, and contribution policies.
- **Password reset**: Added `--reset-password` CLI flag and `MESHCHAT_RESET_PASSWORD` environment variable to clear the stored password hash on startup so a new password can be set via the web UI.
- **Favourites import/export**: Settings page now supports importing and exporting NomadNet favourites, with deduplication and icon handling.
- **Bulk favourites import**: Server-side bulk import endpoint for favourites with proper validation and merge logic.
- **Call page flood protection**: Added flood protection settings UI to the call page.
- **NomadNet file downloads**: Support for query-parameter data in file downloads. URLs like `hash:/file/report.pdf?version=2` parse the query string and forward it as request data through the WebSocket to `NomadnetFileDownloader`, matching upstream NomadNet behavior.
- **NomadNet query tests**: Frontend and backend tests for `parseNomadnetworkUrl` with query strings and `downloadNomadNetFile` data payload handling.
- **Android RNode protection**: On Android, `RNodeInterface`, `RNodeIPInterface`, and `RNodeMultiInterface` entries in the Reticulum config are automatically disabled before startup to prevent crashes from missing serial/BLE support in Chaquopy.
- **Android external storage**: On Android, MeshChatX now defaults to `getExternalFilesDir()` (user-accessible via file managers) instead of private internal storage.
### Changed
- **Dependencies**: **RNS** updated to **1.2.3**, **aiohttp** to **3.13.5** in Python, **requirements.txt**, **Chaquopy** metadata, and Android **build.gradle**, **micron-parser** lockfile refresh, and general **pnpm** / **package** bumps for 4.6.2.
- **Dependencies**: **RNS** updated to **1.2.5**, **LXMF** to **0.9.7**, **aiohttp** to **3.13.5** in Python, **requirements.txt**, **Chaquopy** metadata, and Android **build.gradle**, **micron-parser** lockfile refresh, and general **pnpm** / **package** bumps for 4.6.2.
- **Vendored LXMFy**: Refreshed `vendor/lxmfy` from upstream [LXMFy/LXMFy](https://git.quad4.io/LXMFy/LXMFy) at `0a6ba8c9fd0f306be614d0edce44e4e805c025b0` (LXMF field helpers for structured bot commands, expanded docs, and new tests). Bundled package version remains **1.6.2**. `vendor/README.txt` lists the revision pointer.
- **CI**: Docker images publish the **:latest** tag on version tag pushes, **main** and **master** branch builds are enabled, and the Bunny Storage release folder is pruned before uploads.
- **CI**: Docker images publish the **:latest** tag on version tag pushes, **main** and **master** branch builds are enabled, and the Bunny Storage release folder is pruned before uploads. Release descriptions now include a SHA256 checksum table for all assets.
- **async_utils**: Tighter coroutine scheduling limits with **logging when work is dropped**. Removed the **Python 3.13 asyncio** compatibility patch in favor of cleaner scheduling assumptions. Adds regression tests for **HTTPS file responses** (including sendfile-style paths).
- **reticulum_config**: Default Reticulum configuration is applied via **file-backed writes** instead of embedding large default text only through the previous helper path (tests updated).
- **Lint tooling**: **`vue-eslint-parser`** added/updated (**10.4.0**) for frontend ESLint alignment.
- **Contributors**: **zenith** added to **CONTRIBUTORS**.
- **Announce limits**: Default `announce_max_stored_*` raised from **1000** to **2500** and `announce_fetch_limit_*` from **500** to **2500** so the API lists everything stored in the database by default, matching public network usage.
- **Sidebar order**: Reordered sidebar so **Telephone** appears directly below **Messages** for faster access.
- **Telephone announce**: Disabled by default in `config_manager`.
- **CONTRIBUTING.md**: Updated generative AI policy to emphasize local/offline models and reference the Reticulum Zen and License.
## [4.6.1] - 2026-05-04
+2 -2
View File
@@ -59,10 +59,10 @@ You also confirm that you have the right to submit the contribution under these
## Generative AI policy
You may use generative AI tools when contributing, on the condition that your setup actually supplies the model with enough context to produce sound work: relevant files, constraints, failing tests, and project conventions. Vague prompts and thin context lead to wrong or generic patches; that burden is on the contributor, not the reviewers.
You may use generative AI tools when contributing, on the condition that your setup actually supplies the model with enough context to produce sound work and your provider does not train on the code, read [Reticulum Zen](https://reticulum.network/manual/zen.html) and the [Reticulum License](https://reticulum.network/manual/license.html). Vague prompts and thin context lead to wrong or generic patches; that burden is on the contributor, not the reviewers.
You must disclose AI usage in the patch message body (or commit message, if you prefer): state which tools or services you used in a material way for that change (for example, model or product name, and whether it was local or cloud). If a change was written without meaningful AI assistance, say so briefly. This is so reviewers can judge scope and provenance; it is not a substitute for your own review and testing.
We prefer models that run locally or offline when that is practical for you.
We strongly prefer models that run locally or offline when that is practical for you.
Contributions must still be yours to justify and maintain. Do not submit bulk-generated changes you have not read, understood, and tested. We are not looking for unreviewed AI output or style-only churn from tools used without engineering/architectural judgment.
+4 -6
View File
@@ -36,7 +36,7 @@ RUN apk upgrade --no-cache && \
apk add --no-cache gcc g++ musl-dev linux-headers python3-dev libffi-dev openssl-dev git
# Install build tools in the system python
RUN pip install --no-cache-dir --upgrade "pip>=26.0" poetry setuptools wheel "jaraco.context>=6.1.0"
RUN pip install --no-cache-dir --upgrade "pip>=26.0" uv setuptools wheel "jaraco.context>=6.1.0"
# Create the clean venv for our application dependencies
RUN python -m venv /opt/venv
@@ -45,12 +45,10 @@ ENV PATH="/opt/venv/bin:$PATH"
# Install essential runtime tools in the venv (cffi verify needs setuptools on Python 3.12+)
RUN pip install --no-cache-dir --upgrade "pip>=26.0" "setuptools" "jaraco.context>=6.1.0"
COPY pyproject.toml poetry.lock README.md ./
COPY pyproject.toml uv.lock README.md ./
COPY vendor ./vendor
RUN poetry config virtualenvs.create false && \
poetry check --lock && \
poetry install --no-root --only main --no-interaction --no-ansi && \
rm -rf /root/.cache/pip /root/.cache/pypoetry
RUN uv sync --no-group dev --no-install-project && \
rm -rf /root/.cache/pip /root/.cache/uv
COPY meshchatx ./meshchatx
COPY scripts/docker-bake-lxst-filterlib-musl.py ./scripts/docker-bake-lxst-filterlib-musl.py
+4 -6
View File
@@ -30,19 +30,17 @@ USER root
WORKDIR /build
RUN apk add --no-cache build-base git pkgconf openssl-dev libffi-dev linux-headers
RUN pip install --no-cache-dir --upgrade "pip>=26.0" poetry setuptools wheel "jaraco.context>=6.1.0"
RUN pip install --no-cache-dir --upgrade "pip>=26.0" uv setuptools wheel "jaraco.context>=6.1.0"
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir --upgrade "pip>=26.0" "setuptools" "jaraco.context>=6.1.0"
COPY pyproject.toml poetry.lock README.md ./
COPY pyproject.toml uv.lock README.md ./
COPY vendor ./vendor
RUN poetry config virtualenvs.create false && \
poetry check --lock && \
poetry install --no-root --only main --no-interaction --no-ansi && \
rm -rf /root/.cache/pip /root/.cache/pypoetry
RUN uv sync --no-group dev --no-install-project && \
rm -rf /root/.cache/pip /root/.cache/uv
COPY meshchatx ./meshchatx
COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py
+35
View File
@@ -0,0 +1,35 @@
# FAQ - Frequently Asked Questions
...and questions that will likely be asked at some point.
**Why can't I reach you over LXMF all the time?**
Grow some patience, send it to a propagation node, and chill. Sometimes I go offline or travel for a few hours, days, or maybe a week. Your message will make it to me; just be patient.
**Why did you create MeshChatX?**
I noticed Liam Cottle became busy with MeshCore stuff, but I also wanted something modern and easier to use for myself and some friends. Eventually more people discovered my fork and started using it. I have since reworked the architecture and modernized most of the frontend and backend. I am not a fan of Electron, but I do what I can to make it a worthy Electron application by taking advantage of everything it has to offer, from packaging to security and integrity.
**Will MeshChatX move to a different implementation?**
Not for the foreseeable future. MeshChatX will continue to use the official Reticulum Network Stack by Mark Qvist. That goes for LXMF and LXST as well.
**Can you move your repository under a community organization?**
No. Until the day I stop maintaining MeshChatX, it will remain under Quad4 control. The official source code is both on `git.quad4.io` and on Reticulum via rngit, while GitHub and other places are mirrors only. You are always welcome to fork if you do not like the way I do things.
**Why are PRs disabled on GitHub?**
GitHub is a mirror to use CI and push out releases only. Submitting a patch over LXMF is also a filter for the low-effort and purely vibe-coded crap that people submit these days. It has actually been working quite well, especially for low-effort social engineering.
**Do you use AI?**
In some places of the codebase, yes, but I apply my own judgment. I mostly use local models with some custom tooling. If I use external providers for more complex tasks, I choose open-weight models and zero-retention, zero-training providers that are in my jurisdiction and can be held accountable in court if necessary. I also use linting, SAST, DAST, and tests to ensure LLM code is properly validated, implemented, and follows best practices.
**Will MeshChatX support legacy systems?**
Electron has to be kept up-to-date with a stable release cycle in order to get any CVE fixes for the bundled Chromium or other Electron-related security and performance fixes. You can use the Python wheels if your system supports Python 3.11. I would like to support all systems, but that is just not possible with Electron and the values of this project (security).
**Can you make it so MeshChatX uses system RNS/LXMF packages?**
The Python wheels can use system RNS/LXMF, and you can update them easily. With Docker, you can grab the Dockerfile, update it, and build manually. You can also build from source. As for Electron builds, there is not much that can be done right now, but I will keep exploring options.
+13 -17
View File
@@ -12,6 +12,7 @@ This project is independent from the original Reticulum MeshChat project and is
- Releases: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- Changelog: [`CHANGELOG.md`](CHANGELOG.md)
- Donate: [`donate.md`](donate.md) ([Donation](#donation))
- Umbrel App Store: [apps.umbrel.com/app/meshchatx](https://apps.umbrel.com/app/meshchatx)
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Quad4-Software/MeshChatX"><img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="60" alt="Get it on Obtainium"></a>
@@ -29,20 +30,14 @@ MeshChatX NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
- Uses Electron 41.x (bundled Node 24 runtime).
- .whls ships with webserver and built-in frontend assets for more deployment options.
- i18n
- PNPM and Poetry for dependency management.
> [!WARNING]
> MeshChatX is not guaranteed to be wire/data compatible with older Reticulum MeshChat releases. Back up data before migration/testing.
> [!WARNING]
> Legacy systems are not supported yet. Current baseline is Python `>=3.11` and Node `>=24` (Electron 41 aligns with Node 24; `package.json` `engines` and CI use the same line).
- PNPM and UV for dependency management.
## Requirements
- Python `>=3.11` (from `pyproject.toml`)
- Node.js `>=24` (from `package.json` `engines`)
- pnpm `10.33.0` (from `package.json` `packageManager`)
- Poetry (used by `Taskfile.yml` and CI workflows)
- UV (used by `Taskfile.yml` and CI workflows)
**Browser Versions Required:**
@@ -166,11 +161,11 @@ cd MeshChatX
corepack enable
pnpm config set verify-store-integrity true
pnpm install --frozen-lockfile
pip install "poetry==2.3.4"
poetry check --lock
poetry install
pip install "uv==0.11.12"
uv lock --check
uv sync --group dev
pnpm run build-frontend
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1
```
Notes on the install commands above:
@@ -178,10 +173,10 @@ Notes on the install commands above:
- `pnpm install --frozen-lockfile` refuses to update `pnpm-lock.yaml` and fails if the lockfile does not match `package.json`. This is what blocks an unexpected upstream version from being silently pulled in.
- `verify-store-integrity=true` is also set in the project `.npmrc`; the explicit `pnpm config set` line above just hardens the user-level config too.
- Lifecycle scripts (`preinstall`/`postinstall`) are blocked by default in pnpm v10+. Only the packages listed under `pnpm.onlyBuiltDependencies` in `package.json` are allowed to run install scripts (currently `electron`, `electron-winstaller`, `esbuild`).
- `poetry check --lock` fails fast if `poetry.lock` is out of sync with `pyproject.toml`; `poetry install` then resolves only from the lockfile.
- For a strict lockfile-only Poetry install (no implicit lockfile refresh), pin Poetry with `pip install "poetry==2.3.4"` to match what CI uses.
- `uv lock --check` fails fast if `uv.lock` is out of sync with `pyproject.toml`; `uv sync` then resolves only from the lockfile.
- For a strict lockfile-only UV install (no implicit lockfile refresh), pin UV with `pip install "uv==0.11.12"` to match what CI uses.
If you intentionally want to update dependencies, run `pnpm update` / `poetry update` in a dedicated commit and review the resulting lockfile diff before pushing.
If you intentionally want to update dependencies, run `pnpm update` / `uv lock` in a dedicated commit and review the resulting lockfile diff before pushing.
## Run sandboxed (Linux)
@@ -323,6 +318,7 @@ MeshChatX supports both CLI args and env vars.
| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | (none) | Reticulum (RNS) stack log level: `none`, `critical`, `error`, `warning`, `notice`, `verbose`, `debug`, `extreme`, or a numeric level. CLI overrides env when both are set. |
| `--headless` | `MESHCHAT_HEADLESS` | `false` | Do not auto-launch browser |
| `--auth` | `MESHCHAT_AUTH` | `false` | Enable basic auth |
| `--reset-password` | `MESHCHAT_RESET_PASSWORD` | `false` | Clear the stored password hash so a new password can be set via the web UI |
| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | Data directory |
| `--public-dir` | `MESHCHAT_PUBLIC_DIR` | auto/bundled | Frontend files directory (needed for source installs without bundled assets) |
@@ -348,8 +344,8 @@ task build:all
| Command | Description |
| -------------- | --------------------------------------- |
| `make install` | Install pnpm and poetry dependencies |
| `make run` | Run MeshChatX via poetry |
| `make install` | Install pnpm and UV dependencies |
| `make run` | Run MeshChatX via UV |
| `make build` | Build frontend |
| `make lint` | Run eslint and ruff |
| `make test` | Run frontend and backend tests |
+26 -26
View File
@@ -73,17 +73,17 @@ tasks:
- "{{.NPM}} install"
deps:be:
desc: Install Python dependencies using Poetry
desc: Install Python dependencies using UV
cmds:
- poetry install
- poetry run python scripts/patch_lxst_pyogg_ogg_ctypes.py
- uv sync --group dev
- uv run python scripts/patch_lxst_pyogg_ogg_ctypes.py
setup:be:
desc: Full backend environment setup
cmds:
- poetry install
- poetry run python scripts/patch_lxst_pyogg_ogg_ctypes.py
- poetry run pip install ruff
- uv sync --group dev
- uv run python scripts/patch_lxst_pyogg_ogg_ctypes.py
- uv run pip install ruff
# --- Execution ---
@@ -91,7 +91,7 @@ tasks:
desc: Run the application
deps: [install]
cmds:
- poetry run python -m meshchatx.meshchat
- uv run python -m meshchatx.meshchat
dev:
desc: Run in development mode (builds frontend first)
@@ -127,8 +127,8 @@ tasks:
lint:be:
desc: Lint Python code (ruff)
cmds:
- poetry run ruff check .
- poetry run ruff format --check .
- uv run ruff check .
- uv run ruff format --check .
lint:fe:
desc: Lint frontend code
@@ -142,8 +142,8 @@ tasks:
fmt:be:
desc: Format Python code (ruff)
cmds:
- poetry run ruff format ./ --exclude tests
- poetry run ruff check --fix ./ --exclude tests
- uv run ruff format ./ --exclude tests
- uv run ruff check --fix ./ --exclude tests
fmt:fe:
desc: Format frontend code (Prettier/ESLint)
@@ -158,34 +158,34 @@ tasks:
deps: [test:be, test:fe, test:lang]
test:e2e:
desc: Playwright E2E (starts MeshChat backend + Vite; requires poetry, curl, pnpm exec playwright install chromium)
desc: Playwright E2E (starts MeshChat backend + Vite; requires uv, curl, pnpm exec playwright install chromium)
cmds:
- "{{.NPM}} run test:e2e"
test:be:
desc: Run Python tests (pytest; includes perf/memory profiling — same as GitHub CI)
cmds:
- poetry run pytest tests/backend -n auto --cov=meshchatx/src/backend
- uv run pytest tests/backend -n auto --cov=meshchatx/src/backend
test:be:cov:
desc: Run Python tests with detailed coverage (includes performance and memory profiling tests)
cmds:
- poetry run pytest tests/backend -n auto --cov=meshchatx/src/backend --cov-report=term-missing
- uv run pytest tests/backend -n auto --cov=meshchatx/src/backend --cov-report=term-missing
test:be:perf:
desc: Backend performance regression tests only (hot paths + bottlenecks)
cmds:
- poetry run pytest tests/backend/test_performance_hotpaths.py tests/backend/test_performance_bottlenecks.py
- uv run pytest tests/backend/test_performance_hotpaths.py tests/backend/test_performance_bottlenecks.py
test:be:full:
desc: Same as test:be (full backend suite)
cmds:
- poetry run pytest tests/backend -n auto --cov=meshchatx/src/backend
- uv run pytest tests/backend -n auto --cov=meshchatx/src/backend
test:mutation:
desc: Mutation testing (mutmut; slow; optional; default targets meshchat_utils)
cmds:
- poetry run mutmut run "meshchatx.src.backend.meshchat_utils*"
- uv run mutmut run "meshchatx.src.backend.meshchat_utils*"
test:fe:
desc: Run frontend + Electron shell unit tests (vitest; excludes i18n — see test:lang; excludes LoadTimePerformance — use test:fe:loadtime locally)
@@ -201,12 +201,12 @@ tasks:
test:be:media-fuzz:
desc: Hypothesis fuzz tests for stickers, TGS/Lottie JSON, WebM, GIFs, pack exports
cmds:
- poetry run pytest tests/backend/test_media_fuzzing.py -q
- uv run pytest tests/backend/test_media_fuzzing.py -q
test:be:media-http:
desc: aiohttp integration tests for /api/v1/stickers, sticker-packs, and /api/v1/gifs
cmds:
- poetry run pytest tests/backend/test_media_http_api.py -q
- uv run pytest tests/backend/test_media_http_api.py -q
test:fuzz:all:
desc: Vitest fuzzing tag plus backend media Hypothesis fuzz
@@ -223,18 +223,18 @@ tasks:
desc: Run localization tests
cmds:
- "{{.NPM}} exec vitest run tests/frontend/i18n.test.js"
- "poetry run pytest tests/backend/test_translator_handler.py"
- "uv run pytest tests/backend/test_translator_handler.py"
test:integrity:
desc: Run data integrity tests
cmds:
- poetry run pytest tests/backend/test_integrity.py tests/backend/test_backend_integrity.py
- uv run pytest tests/backend/test_integrity.py tests/backend/test_backend_integrity.py
test:cov:
desc: Run all tests with coverage (Python + frontend JS/Vue + translator pytest)
deps: [test:be:cov, test:fe:cov]
cmds:
- "poetry run pytest tests/backend/test_translator_handler.py"
- "uv run pytest tests/backend/test_translator_handler.py"
bench:
desc: Default benchmark run (backend comprehensive suite; same as bench:be)
@@ -244,17 +244,17 @@ tasks:
bench:be:
desc: Run backend benchmarks
cmds:
- poetry run python tests/backend/run_comprehensive_benchmarks.py
- uv run python tests/backend/run_comprehensive_benchmarks.py
bench:be:extreme:
desc: Run extreme stress benchmarks
cmds:
- poetry run python tests/backend/run_comprehensive_benchmarks.py --extreme
- uv run python tests/backend/run_comprehensive_benchmarks.py --extreme
profile:mem:
desc: Run memory profiling
cmds:
- poetry run pytest tests/backend/test_memory_profiling.py
- uv run pytest tests/backend/test_memory_profiling.py
check:
desc: Run formatting, linting, and testing sequentially
@@ -286,7 +286,7 @@ tasks:
desc: Build Python wheel package
deps: [install]
cmds:
- poetry build -f wheel
- uv build --wheel
- "{{.PYTHON}} scripts/move_wheels.py"
# --- Electron Distribution ---
+1 -1
View File
@@ -163,7 +163,7 @@ chaquopy {
options "--find-links", vendorWheelDir.absolutePath
install "packaging>=23"
install "aiohttp==3.13.5"
install "rns>=1.2.1"
install "rns>=1.2.4"
install "bleak==3.0.1"
install "lxmf>=0.9.4"
install "numpy==1.26.2"
+1
View File
@@ -104,6 +104,7 @@ The project favors predictable SQL behavior and explicit migration control, whic
- Cookie sessions via encrypted storage
- Auth and access-attempt tracking integrated with IP/User-Agent aware controls
- Debug endpoints provide visibility into logs and access-attempt records
- Password reset via `--reset-password` (or `MESHCHAT_RESET_PASSWORD=true`) clears the stored bcrypt hash so a new password can be set through the web UI
This is also very well tested, but I still would not recommend exposing MeshChatX to the internet.
+9 -9
View File
@@ -55,26 +55,26 @@ firejail --noprofile --whitelist="$DATA" \
`--noprofile` disables many Firejail restrictions; treat it as a stepping stone, not the final hardening.
### From source with Poetry
### From source with UV
Poetry needs the project tree and the virtualenv. Example:
```bash
cd /path/to/reticulum-meshchatX
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/meshchatx-sandbox"
VENV="$(poetry env info -p)"
VENV="$(pwd)/.venv"
mkdir -p "$DATA/storage" "$DATA/.reticulum"
firejail --quiet \
--whitelist="$(pwd)" \
--whitelist="$VENV" \
--whitelist="$DATA" \
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1 \
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1 \
--storage-dir="$DATA/storage" \
--reticulum-config-dir="$DATA/.reticulum"
```
You may need extra `--whitelist=` entries if Poetry or dependencies read config elsewhere (for example under `$HOME/.config`).
You may need extra `--whitelist=` entries if UV or dependencies read config elsewhere (for example under `$HOME/.config`).
### USB serial (RNode or similar)
@@ -116,14 +116,14 @@ Notes:
- If `meshchatx` lives only inside a venv that is **not** under `$DATA`, the read-only root still allows **reading** that path; you do not have to bind-mount the venv separately unless you also need writes there.
- Distributions that merge `/` and `/usr` (merged-usr) still work with `--ro-bind / /` on typical glibc setups. If `bwrap` fails with missing library paths, add the extra `--ro-bind` lines your distro documents (for example `/lib64`).
### From source with Poetry
### From source with UV
Bind the repository and the Poetry venv read-only, and keep `DATA` writable:
Bind the repository and the UV venv read-only, and keep `DATA` writable:
```bash
cd /path/to/reticulum-meshchatX
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/meshchatx-sandbox"
VENV="$(poetry env info -p)"
VENV="$(pwd)/.venv"
mkdir -p "$DATA/storage" "$DATA/.reticulum"
PROJ="$(pwd)"
@@ -140,12 +140,12 @@ exec bwrap \
--uid "$(id -u)" --gid "$(id -g)" \
--setenv PATH "$VENV/bin:$PATH" \
--chdir "$PROJ" \
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1 \
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1 \
--storage-dir="$DATA/storage" \
--reticulum-config-dir="$DATA/.reticulum"
```
`poetry` itself must be reachable on `PATH` inside the sandbox (often under `/usr` or `$HOME/.local/bin`, both visible with `--ro-bind / /`). If `poetry run` fails because it cannot read `~/.config/poetry`, add a read-only bind for that directory or invoke the venv interpreter directly instead of `poetry run`:
`uv` itself must be reachable on `PATH` inside the sandbox (often under `/usr` or `$HOME/.local/bin`, both visible with `--ro-bind / /`). If `uv run` fails because it cannot read `~/.cache/uv`, add a read-only bind for that directory or invoke the venv interpreter directly instead of `uv run`:
```bash
exec bwrap \
+3 -3
View File
@@ -66,11 +66,11 @@ corepack prepare pnpm@latest --activate
```
git clone https://git.quad4.io/RNS-Things/MeshChatX.git
cd MeshChatX
pip install poetry
poetry install
pip install uv
uv sync --group dev
pnpm install
pnpm run build-frontend
poetry build -f wheel
uv build --wheel
pip install dist/*.whl
```
+16
View File
@@ -225,6 +225,22 @@ journalctl -u meshchatx.service -n 200 --no-pager
systemctl show meshchatx.service -p ExecStart -p User -p Group
```
## Reset Password
If you forget the web UI password and have SSH access to the Pi, reset it with the `--reset-password` flag:
```bash
meshchatx --reset-password --headless --host 0.0.0.0 --port 8000
```
Or set the environment variable:
```bash
MESHCHAT_RESET_PASSWORD=true meshchatx --headless --host 0.0.0.0 --port 8000
```
This clears the stored password hash on startup. Open the web UI and you will see the Initial Setup screen where you can set a new password. After resetting, you can stop the app and restart without the flag.
## Notes
- Reticulum configuration and identity data are stored in the service user's home
+1 -1
View File
@@ -25,7 +25,7 @@ export default [
"**/.venv/**",
"**/*.min.js",
"**/pnpm-lock.yaml",
"**/poetry.lock",
"**/uv.lock",
"**/linux-unpacked/**",
"**/win-unpacked/**",
"**/mac-unpacked/**",
+14 -9
View File
@@ -11,12 +11,16 @@ Dieses Projekt ist unabhaengig vom originalen Reticulum MeshChat und steht in ke
- Offizielles GitHub-Mirror: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- Releases: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- Aenderungsprotokoll: [`CHANGELOG.md`](../CHANGELOG.md)
- Spenden: [`donate.md`](../donate.md)
- Spenden: [`donate.md`](../donate.md) ([Spenden](#spenden))
- Umbrel App Store: [apps.umbrel.com/app/meshchatx](https://apps.umbrel.com/app/meshchatx)
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Quad4-Software/MeshChatX"><img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="60" alt="Get it on Obtainium"></a>
rngit NomadNet Node: `5399f5a0212477618821e91e88ce053b:/page/index.mu`
rngit: `git clone rns://926baefe13daf5178c174f158dae1b45/quad4/MeshChatX`
NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
MeshChatX NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
## Wichtige Aenderungen gegenueber Reticulum MeshChat
@@ -162,11 +166,11 @@ cd MeshChatX
corepack enable
pnpm config set verify-store-integrity true
pnpm install --frozen-lockfile
pip install "poetry==2.3.4"
poetry check --lock
poetry install
pip install "uv==0.11.12"
uv lock --check
uv sync --group dev
pnpm run build-frontend
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1
```
Hinweise zu den Installationsbefehlen:
@@ -174,10 +178,10 @@ Hinweise zu den Installationsbefehlen:
- `pnpm install --frozen-lockfile` verweigert Aenderungen an `pnpm-lock.yaml` und schlaegt fehl, wenn die Lockdatei nicht zu `package.json` passt. Damit wird verhindert, dass eine unerwartete Upstream-Version still eingespielt wird.
- `verify-store-integrity=true` ist auch in der projektweiten `.npmrc` gesetzt; die explizite `pnpm config set`-Zeile haertet zusaetzlich die Benutzerkonfiguration.
- Lifecycle-Skripte (`preinstall`/`postinstall`) sind in pnpm v10+ standardmaessig blockiert. Nur die unter `pnpm.onlyBuiltDependencies` in `package.json` aufgefuehrten Pakete duerfen Installationsskripte ausfuehren (aktuell `electron`, `electron-winstaller`, `esbuild`).
- `poetry check --lock` schlaegt frueh fehl, wenn `poetry.lock` nicht mit `pyproject.toml` synchron ist; `poetry install` aufloest danach nur aus der Lockdatei.
- Fuer eine strikte Lockfile-Installation (ohne implizite Lock-Aktualisierung) Poetry mit `pip install "poetry==2.3.4"` pinnen, passend zur CI-Version.
- `uv lock --check` schlaegt frueh fehl, wenn `uv.lock` nicht mit `pyproject.toml` synchron ist; `uv sync --group dev` aufloest danach nur aus der Lockdatei.
- Fuer eine strikte Lockfile-Installation (ohne implizite Lock-Aktualisierung) Poetry mit `pip install "uv==0.11.12"` pinnen, passend zur CI-Version.
Wenn Sie absichtlich Abhaengigkeiten aktualisieren wollen, fuehren Sie `pnpm update` / `poetry update` in einem dedizierten Commit aus und pruefen Sie das resultierende Lockdatei-Diff vor dem Push.
Wenn Sie absichtlich Abhaengigkeiten aktualisieren wollen, fuehren Sie `pnpm update` / `uv lock` in einem dedizierten Commit aus und pruefen Sie das resultierende Lockdatei-Diff vor dem Push.
## Sandboxing (Linux)
@@ -306,6 +310,7 @@ Weitere Dokumentation:
| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | (keine) | Reticulum (RNS) Log-Level: `none`, `critical`, `error`, `warning`, `notice`, `verbose`, `debug`, `extreme` oder numerisch. CLI ueberschreibt die Umgebungsvariable, wenn beide gesetzt sind. |
| `--headless` | `MESHCHAT_HEADLESS` | `false` | Browser nicht automatisch oeffnen |
| `--auth` | `MESHCHAT_AUTH` | `false` | Basis-Authentifizierung aktivieren |
| `--reset-password` | `MESHCHAT_RESET_PASSWORD` | `false` | Gespeicherten Passwort-Hash loeschen, damit ein neues Passwort ueber die Web-Oberflaeche gesetzt werden kann |
| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | Datenverzeichnis |
| `--public-dir` | `MESHCHAT_PUBLIC_DIR` | auto/bundled | Frontend-Verzeichnis (fuer Quell-Installationen ohne gebundelte Assets) |
+16 -11
View File
@@ -11,12 +11,16 @@ Questo progetto e indipendente dal progetto originale Reticulum MeshChat e non e
- Mirror ufficiale su GitHub: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- Release: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- Changelog: [`CHANGELOG.md`](../CHANGELOG.md)
- Donazioni: [`donate.md`](../donate.md)
- Donazioni: [`donate.md`](../donate.md) ([Donazioni](#donazioni))
- Umbrel App Store: [apps.umbrel.com/app/meshchatx](https://apps.umbrel.com/app/meshchatx)
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Quad4-Software/MeshChatX"><img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="60" alt="Get it on Obtainium"></a>
rngit NomadNet Node: `5399f5a0212477618821e91e88ce053b:/page/index.mu`
rngit: `git clone rns://926baefe13daf5178c174f158dae1b45/quad4/MeshChatX`
NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
MeshChatX NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
## Modifiche importanti rispetto a Reticulum MeshChat
@@ -162,11 +166,11 @@ cd MeshChatX
corepack enable
pnpm config set verify-store-integrity true
pnpm install --frozen-lockfile
pip install "poetry==2.3.4"
poetry check --lock
poetry install
pip install "uv==0.11.12"
uv lock --check
uv sync --group dev
pnpm run build-frontend
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1
```
Note sui comandi di installazione:
@@ -174,10 +178,10 @@ Note sui comandi di installazione:
- `pnpm install --frozen-lockfile` rifiuta di aggiornare `pnpm-lock.yaml` e fallisce se il lockfile non corrisponde a `package.json`. Cosi' si evita che una versione upstream inattesa venga installata silenziosamente.
- `verify-store-integrity=true` e' impostato anche nel `.npmrc` del progetto; la riga esplicita `pnpm config set` rafforza inoltre la configurazione utente.
- Gli script di lifecycle (`preinstall`/`postinstall`) sono bloccati di default in pnpm v10+. Solo i pacchetti elencati in `pnpm.onlyBuiltDependencies` di `package.json` possono eseguire script di installazione (attualmente `electron`, `electron-winstaller`, `esbuild`).
- `poetry check --lock` fallisce subito se `poetry.lock` non e' allineato con `pyproject.toml`; `poetry install` risolve poi solo dal lockfile.
- Per un'installazione Poetry strettamente basata sul lockfile (senza refresh implicito), fissa Poetry con `pip install "poetry==2.3.4"`, in linea con la CI.
- `uv lock --check` fallisce subito se `uv.lock` non e' allineato con `pyproject.toml`; `uv sync --group dev` risolve poi solo dal lockfile.
- Per un'installazione Poetry strettamente basata sul lockfile (senza refresh implicito), fissa Poetry con `pip install "uv==0.11.12"`, in linea con la CI.
Se vuoi aggiornare intenzionalmente le dipendenze, esegui `pnpm update` / `poetry update` in un commit dedicato e rivedi il diff del lockfile prima del push.
Se vuoi aggiornare intenzionalmente le dipendenze, esegui `pnpm update` / `uv lock` in un commit dedicato e rivedi il diff del lockfile prima del push.
## Esecuzione in sandbox (Linux)
@@ -306,6 +310,7 @@ Documentazione aggiuntiva:
| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | (nessuno) | Livello di log Reticulum (RNS): `none`, `critical`, `error`, `warning`, `notice`, `verbose`, `debug`, `extreme` o numerico. La CLI ha priorita sulla variabile d'ambiente se entrambe sono impostate. |
| `--headless` | `MESHCHAT_HEADLESS` | `false` | Non aprire il browser automaticamente |
| `--auth` | `MESHCHAT_AUTH` | `false` | Attiva autenticazione base |
| `--reset-password` | `MESHCHAT_RESET_PASSWORD` | `false` | Cancella l'hash della password memorizzata per impostarne una nuova tramite l'interfaccia web |
| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | Directory dei dati |
| `--public-dir` | `MESHCHAT_PUBLIC_DIR` | auto/bundled | Directory dei file frontend (necessaria per installazioni da sorgente senza asset in bundle) |
@@ -331,8 +336,8 @@ Scorciatoie `Makefile`:
| Comando | Descrizione |
| -------------- | ----------------------------------------- |
| `make install` | Installa dipendenze pnpm e poetry |
| `make run` | Esegue MeshChatX tramite poetry |
| `make install` | Installa dipendenze pnpm e UV |
| `make run` | Esegue MeshChatX tramite UV |
| `make build` | Compila il frontend |
| `make lint` | Esegue eslint e ruff |
| `make test` | Test frontend e backend |
+27 -22
View File
@@ -11,12 +11,16 @@ Liam Cottle 氏による Reticulum MeshChat を大幅に改修・機能拡張し
- 公式 GitHub ミラー: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- リリース: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- 変更履歴: [`CHANGELOG.md`](../CHANGELOG.md)
- 寄付: [`donate.md`](../donate.md)
- 寄付: [`donate.md`](../donate.md) ([寄付](#寄付))
- Umbrel App Store: [apps.umbrel.com/app/meshchatx](https://apps.umbrel.com/app/meshchatx)
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Quad4-Software/MeshChatX"><img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="60" alt="Get it on Obtainium"></a>
rngit NomadNet Node: `5399f5a0212477618821e91e88ce053b:/page/index.mu`
rngit: `git clone rns://926baefe13daf5178c174f158dae1b45/quad4/MeshChatX`
NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
MeshChatX NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
## Reticulum MeshChat からの主な変更
@@ -162,11 +166,11 @@ cd MeshChatX
corepack enable
pnpm config set verify-store-integrity true
pnpm install --frozen-lockfile
pip install "poetry==2.3.4"
poetry check --lock
poetry install
pip install "uv==0.11.12"
uv lock --check
uv sync --group dev
pnpm run build-frontend
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1
```
上記インストールコマンドに関する補足:
@@ -174,10 +178,10 @@ poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
- `pnpm install --frozen-lockfile``pnpm-lock.yaml` の更新を拒否し、ロックファイルが `package.json` と一致しない場合は失敗します。これにより、想定外の上流バージョンが暗黙的にインストールされるのを防げます。
- `verify-store-integrity=true` はプロジェクトの `.npmrc` にも設定されています。上記の `pnpm config set` の行はユーザー設定側も明示的に固めるためのものです。
- pnpm v10 以降、ライフサイクルスクリプト (`preinstall`/`postinstall`) はデフォルトでブロックされます。インストールスクリプトを実行できるのは `package.json``pnpm.onlyBuiltDependencies` に列挙されたパッケージ(現在 `electron``electron-winstaller``esbuild`)だけです。
- `poetry check --lock``poetry.lock``pyproject.toml` が同期していない場合に即時失敗します。その後の `poetry install` はロックファイルからのみ解決します。
- 厳密にロックファイルだけで Poetry をインストールしたい場合は、CI と揃えるために `pip install "poetry==2.3.4"` で Poetry バージョンを固定してください。
- `uv lock --check``uv.lock``pyproject.toml` が同期していない場合に即時失敗します。その後の `uv sync --group dev` はロックファイルからのみ解決します。
- 厳密にロックファイルだけで Poetry をインストールしたい場合は、CI と揃えるために `pip install "uv==0.11.12"` で Poetry バージョンを固定してください。
意図的に依存を更新する場合は、`pnpm update` / `poetry update` を専用コミットで実行し、push 前にロックファイルの diff を必ず確認してください。
意図的に依存を更新する場合は、`pnpm update` / `uv lock` を専用コミットで実行し、push 前にロックファイルの diff を必ず確認してください。
## サンドボックスで実行(Linux)
@@ -297,17 +301,18 @@ cd android
## 設定
| 引数 | 環境変数 | デフォルト | 説明 |
| -------------------------- | ---------------------------------------- | ----------- | -------------------------------------------------------------------------------------- |
| `--host` | `MESHCHAT_HOST` | `127.0.0.1` | Web サーバーのバインドアドレス |
| `--port` | `MESHCHAT_PORT` | `8000` | Web サーバーポート |
| `--no-https` | `MESHCHAT_NO_HTTPS` | `false` | HTTPS を無効化 |
| `--ssl-cert` / `--ssl-key` | `MESHCHAT_SSL_CERT` / `MESHCHAT_SSL_KEY` | (なし) | PEM 証明書と鍵のパス。両方指定。アイデンティティの `ssl/` 下の自動生成証明書を上書き。 |
| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | (なし) | Reticulum(RNS)のログレベル(上記の名前または数値)。CLI は環境変数より優先。 |
| `--headless` | `MESHCHAT_HEADLESS` | `false` | ブラウザを自動で開かない |
| `--auth` | `MESHCHAT_AUTH` | `false` | 基本認証を有効化 |
| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | データディレクトリ |
| `--public-dir` | `MESHCHAT_PUBLIC_DIR` | 自動/同梱 | フロントエンドのディレクトリ(同梱資産なしのソースインストールで必要) |
| 引数 | 環境変数 | デフォルト | 説明 |
| -------------------------- | ---------------------------------------- | ----------- | --------------------------------------------------------------------------------------- |
| `--host` | `MESHCHAT_HOST` | `127.0.0.1` | Web サーバーのバインドアドレス |
| `--port` | `MESHCHAT_PORT` | `8000` | Web サーバーポート |
| `--no-https` | `MESHCHAT_NO_HTTPS` | `false` | HTTPS を無効化 |
| `--ssl-cert` / `--ssl-key` | `MESHCHAT_SSL_CERT` / `MESHCHAT_SSL_KEY` | (なし) | PEM 証明書と鍵のパス。両方指定。アイデンティティの `ssl/` 下の自動生成証明書を上書き。 |
| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | (なし) | Reticulum(RNS)のログレベル(上記の名前または数値)。CLI は環境変数より優先。 |
| `--headless` | `MESHCHAT_HEADLESS` | `false` | ブラウザを自動で開かない |
| `--auth` | `MESHCHAT_AUTH` | `false` | 基本認証を有効化 |
| `--reset-password` | `MESHCHAT_RESET_PASSWORD` | `false` | 保存されたパスワードハッシュを消去し、Web UI から新しいパスワードを設定できるようにする |
| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | データディレクトリ |
| `--public-dir` | `MESHCHAT_PUBLIC_DIR` | 自動/同梱 | フロントエンドのディレクトリ(同梱資産なしのソースインストールで必要) |
## ブランチ
@@ -331,8 +336,8 @@ task build:all
| コマンド | 説明 |
| -------------- | --------------------------------------- |
| `make install` | pnpm と poetry の依存関係をインストール |
| `make run` | poetry 経由で MeshChatX を実行 |
| `make install` | pnpm と UV の依存関係をインストール |
| `make run` | UV 経由で MeshChatX を実行 |
| `make build` | フロントエンドをビルド |
| `make lint` | eslint と ruff を実行 |
| `make test` | フロントエンドとバックエンドのテスト |
+16 -11
View File
@@ -11,12 +11,16 @@
- Официальное зеркало на GitHub: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- Релизы: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- Журнал изменений: [`CHANGELOG.md`](../CHANGELOG.md)
- Поддержка проекта: [`donate.md`](../donate.md)
- Поддержка проекта: [`donate.md`](../donate.md) ([Поддержка проекта](#поддержка-проекта))
- Umbrel App Store: [apps.umbrel.com/app/meshchatx](https://apps.umbrel.com/app/meshchatx)
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Quad4-Software/MeshChatX"><img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="60" alt="Get it on Obtainium"></a>
rngit NomadNet Node: `5399f5a0212477618821e91e88ce053b:/page/index.mu`
rngit: `git clone rns://926baefe13daf5178c174f158dae1b45/quad4/MeshChatX`
NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
MeshChatX NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
## Важные отличия от Reticulum MeshChat
@@ -162,11 +166,11 @@ cd MeshChatX
corepack enable
pnpm config set verify-store-integrity true
pnpm install --frozen-lockfile
pip install "poetry==2.3.4"
poetry check --lock
poetry install
pip install "uv==0.11.12"
uv lock --check
uv sync --group dev
pnpm run build-frontend
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1
```
Пояснения к командам установки:
@@ -174,10 +178,10 @@ poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
- `pnpm install --frozen-lockfile` запрещает обновление `pnpm-lock.yaml` и завершится с ошибкой, если lock-файл не соответствует `package.json`. Это исключает скрытую установку неожиданной upstream-версии.
- `verify-store-integrity=true` уже задан в `.npmrc` проекта; явный `pnpm config set` дополнительно ужесточает пользовательскую конфигурацию.
- Lifecycle-скрипты (`preinstall`/`postinstall`) по умолчанию заблокированы в pnpm v10+. Скрипты установки могут запускать только пакеты из `pnpm.onlyBuiltDependencies` в `package.json` (сейчас `electron`, `electron-winstaller`, `esbuild`).
- `poetry check --lock` сразу падает, если `poetry.lock` не синхронизирован с `pyproject.toml`; затем `poetry install` ставит зависимости только из lock-файла.
- Для строгой установки Poetry только из lock-файла зафиксируйте версию Poetry через `pip install "poetry==2.3.4"`, как это делает CI.
- `uv lock --check` сразу падает, если `uv.lock` не синхронизирован с `pyproject.toml`; затем `uv sync --group dev` ставит зависимости только из lock-файла.
- Для строгой установки Poetry только из lock-файла зафиксируйте версию Poetry через `pip install "uv==0.11.12"`, как это делает CI.
Если вы намеренно хотите обновить зависимости, выполните `pnpm update` / `poetry update` отдельным коммитом и проверьте diff lock-файлов до пуша.
Если вы намеренно хотите обновить зависимости, выполните `pnpm update` / `uv lock` отдельным коммитом и проверьте diff lock-файлов до пуша.
## Запуск в песочнице (Linux)
@@ -306,6 +310,7 @@ cd android
| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | (нет) | Уровень лога стека Reticulum (RNS): `none`, `critical`, `error`, `warning`, `notice`, `verbose`, `debug`, `extreme` или число. CLI перекрывает переменную окружения, если заданы оба. |
| `--headless` | `MESHCHAT_HEADLESS` | `false` | Не открывать браузер автоматически |
| `--auth` | `MESHCHAT_AUTH` | `false` | Базовая аутентификация |
| `--reset-password` | `MESHCHAT_RESET_PASSWORD` | `false` | Сбросить сохраненный хэш пароля, чтобы задать новый через веб-интерфейс |
| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | Каталог данных |
| `--public-dir` | `MESHCHAT_PUBLIC_DIR` | авто/bundled | Каталог фронтенда (для установок без встроенных ресурсов) |
@@ -331,8 +336,8 @@ task build:all
| Команда | Описание |
| -------------- | --------------------------------------- |
| `make install` | Установить зависимости pnpm и poetry |
| `make run` | Запуск MeshChatX через poetry |
| `make install` | Установить зависимости pnpm и UV |
| `make run` | Запуск MeshChatX через UV |
| `make build` | Сборка фронтенда |
| `make lint` | eslint и ruff |
| `make test` | Тесты фронтенда и бэкенда |
+16 -11
View File
@@ -11,12 +11,16 @@ Liam Cottle 开发的 Reticulum MeshChat 的一个功能丰富的深度修改分
- 官方 GitHub 镜像: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- 发行版: [github.com/Quad4-Software/MeshChatX](https://github.com/Quad4-Software/MeshChatX)
- 变更日志: [`CHANGELOG.md`](../CHANGELOG.md)
- 捐赠: [`donate.md`](../donate.md)
- 捐赠: [`donate.md`](../donate.md) ([捐赠](#捐赠))
- Umbrel App Store: [apps.umbrel.com/app/meshchatx](https://apps.umbrel.com/app/meshchatx)
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Quad4-Software/MeshChatX"><img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" height="60" alt="Get it on Obtainium"></a>
rngit NomadNet Node: `5399f5a0212477618821e91e88ce053b:/page/index.mu`
rngit: `git clone rns://926baefe13daf5178c174f158dae1b45/quad4/MeshChatX`
NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
MeshChatX NomadNet Node: `c10d80b1a42fa958c37a6cc30dc04f53:/page/index.mu`
## 与 Reticulum MeshChat 的重要差异
@@ -162,11 +166,11 @@ cd MeshChatX
corepack enable
pnpm config set verify-store-integrity true
pnpm install --frozen-lockfile
pip install "poetry==2.3.4"
poetry check --lock
poetry install
pip install "uv==0.11.12"
uv lock --check
uv sync --group dev
pnpm run build-frontend
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1
```
关于上述安装命令的说明:
@@ -174,10 +178,10 @@ poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1
- `pnpm install --frozen-lockfile` 禁止更新 `pnpm-lock.yaml`,若 lockfile 与 `package.json` 不一致则直接失败。这能阻止意外的上游版本被静默安装。
- `verify-store-integrity=true` 已在项目的 `.npmrc` 中设置;显式的 `pnpm config set` 行同时加固用户级配置。
- pnpm v10+ 默认禁用所有生命周期脚本(`preinstall`/`postinstall`)。仅 `package.json``pnpm.onlyBuiltDependencies` 列出的包允许执行安装脚本(当前为 `electron``electron-winstaller``esbuild`)。
- `poetry check --lock` 会在 `poetry.lock``pyproject.toml` 不同步时立即失败;随后的 `poetry install` 只会从 lock 文件解析依赖。
- 若需严格按 lock 文件安装 Poetry 依赖(不进行隐式刷新),用 `pip install "poetry==2.3.4"` 固定 Poetry 版本,与 CI 保持一致。
- `uv lock --check` 会在 `uv.lock``pyproject.toml` 不同步时立即失败;随后的 `uv sync --group dev` 只会从 lock 文件解析依赖。
- 若需严格按 lock 文件安装 Poetry 依赖(不进行隐式刷新),用 `pip install "uv==0.11.12"` 固定 Poetry 版本,与 CI 保持一致。
如果确有意愿更新依赖,请在独立提交中运行 `pnpm update` / `poetry update`,并在推送前审查生成的 lock 文件 diff。
如果确有意愿更新依赖,请在独立提交中运行 `pnpm update` / `uv lock`,并在推送前审查生成的 lock 文件 diff。
## 在沙盒中运行(Linux
@@ -306,6 +310,7 @@ cd android
| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | (无) | ReticulumRNS)日志级别:`none``critical``error` 等或数值。同时设置时 CLI 优先于环境变量。 |
| `--headless` | `MESHCHAT_HEADLESS` | `false` | 不自动打开浏览器 |
| `--auth` | `MESHCHAT_AUTH` | `false` | 启用基本认证 |
| `--reset-password` | `MESHCHAT_RESET_PASSWORD` | `false` | 清除已保存的密码哈希,以便通过 Web UI 设置新密码 |
| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | 数据目录 |
| `--public-dir` | `MESHCHAT_PUBLIC_DIR` | 自动/捆绑 | 前端文件目录(源码安装且未捆绑资源时需要) |
@@ -331,8 +336,8 @@ task build:all
| 命令 | 说明 |
| -------------- | --------------------------- |
| `make install` | 安装 pnpm 与 poetry 依赖 |
| `make run` | 通过 poetry 运行 MeshChatX |
| `make install` | 安装 pnpm 与 UV 依赖 |
| `make run` | 通过 UV 运行 MeshChatX |
| `make build` | 构建前端 |
| `make lint` | 运行 eslint 与 ruff |
| `make test` | 运行前端与后端测试 |
+19
View File
@@ -21,6 +21,25 @@ def _is_chaquopy_android() -> bool:
return True
def _get_android_external_files_dir() -> str | None:
"""Return the Android app-specific external files directory, or None.
This path is user-accessible via file managers (Android/data/<pkg>/files).
"""
if not _is_chaquopy_android():
return None
try:
from com.chaquo.python import Python
context = Python.getPlatform().getApplication()
external = context.getExternalFilesDir(None)
if external is not None:
return str(external.getAbsolutePath())
except Exception:
pass
return None
def lxmf_delivery_notification_text(payload: dict[str, Any]) -> tuple[str, str] | None:
"""Return (title, body) for a system notification, or None to skip."""
if payload.get("type") != "lxmf.delivery":
+828 -144
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -46,16 +46,16 @@ class AnnounceManager:
def _get_fetch_limit_for_aspect(self, aspect):
if not self.config:
return 500
return 2500
key = _ASPECT_FETCH_LIMIT_KEYS.get(aspect)
if not key:
return 500
return 2500
attr = getattr(self.config, key, None)
if attr is None:
return 500
return 2500
v = attr.get()
if v is None or v < 1:
return 500
return 2500
return min(v, 100_000)
def is_storing_announce_for_aspect(self, aspect, force_store: bool = False) -> bool:
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: 0BSD
import asyncio
import contextlib
import time
import RNS
@@ -69,6 +70,8 @@ class AutoPropagationManager:
async def _probe_propagation_sync(self, node_hex: str) -> bool:
ctx = self.context
router = ctx.message_router
if not router:
return False
try:
dest = bytes.fromhex(node_hex)
if len(dest) != RNS.Identity.TRUNCATED_HASHLENGTH // 8:
@@ -76,7 +79,17 @@ class AutoPropagationManager:
except Exception:
return False
# Ensure any previous sync is fully cancelled and state is idle
self.app.stop_propagation_node_sync(context=ctx)
settle_deadline = time.monotonic() + 5.0
while time.monotonic() < settle_deadline:
if router.propagation_transfer_state == LXMRouter.PR_IDLE:
break
await asyncio.sleep(POLL_INTERVAL_SECONDS)
else:
with contextlib.suppress(Exception):
router.propagation_transfer_state = LXMRouter.PR_IDLE
try:
router.set_outbound_propagation_node(dest)
except Exception:
@@ -85,16 +98,29 @@ class AutoPropagationManager:
router.request_messages_from_propagation_node(ctx.identity)
deadline = time.monotonic() + SYNC_PROBE_TIMEOUT_SECONDS
seen_progress = False
# Wait for the sync to actually start (leave idle)
while time.monotonic() < deadline:
state = router.propagation_transfer_state
if state in _PROP_FAILURE_STATES:
self.app.stop_propagation_node_sync(context=ctx)
return False
if state != LXMRouter.PR_IDLE:
seen_progress = True
elif seen_progress:
break
await asyncio.sleep(POLL_INTERVAL_SECONDS)
else:
# Never left idle -> failed to start
self.app.stop_propagation_node_sync(context=ctx)
return False
# Wait for the sync to finish (return to idle)
while time.monotonic() < deadline:
state = router.propagation_transfer_state
if state in _PROP_FAILURE_STATES:
self.app.stop_propagation_node_sync(context=ctx)
return False
if state == LXMRouter.PR_IDLE:
# Success: we left idle and now we're back
self.app.stop_propagation_node_sync(context=ctx)
return True
await asyncio.sleep(POLL_INTERVAL_SECONDS)
@@ -105,28 +131,47 @@ class AutoPropagationManager:
async def check_and_update_propagation_node(self):
ctx = self.context
router = ctx.message_router
if not router:
return
previous_hex = (
self.config.lxmf_preferred_propagation_node_destination_hash.get()
)
# If a sync is in progress, only interrupt it when the current node
# appears unreachable. This prevents getting stuck on a node we
# cannot get a path to.
# appears unreachable or the path is stale/unresponsive. This prevents
# getting stuck on a node we cannot actually reach.
if router.propagation_transfer_state != LXMRouter.PR_IDLE:
current_has_path = False
current_path_ok = False
if previous_hex:
try:
current_dest = bytes.fromhex(previous_hex)
current_has_path = RNS.Transport.has_path(current_dest)
current_path_ok = (
current_has_path
and not RNS.Transport.path_is_unresponsive(current_dest)
and not reticulum_pathfinding.transport_path_table_entry_is_expired(
current_dest,
)
)
except Exception:
pass
if current_has_path:
if current_path_ok:
# Sync is likely making progress let it finish.
return
# Current node is unreachable stop the stuck sync so we can
# look for a working alternative.
self.app.stop_propagation_node_sync(context=ctx)
# Wait briefly for the router to settle back to idle before we
# start probing other nodes.
settle_deadline = time.monotonic() + 5.0
while time.monotonic() < settle_deadline:
if router.propagation_transfer_state == LXMRouter.PR_IDLE:
break
await asyncio.sleep(POLL_INTERVAL_SECONDS)
else:
with contextlib.suppress(Exception):
router.propagation_transfer_state = LXMRouter.PR_IDLE
announces = self.database.announces.get_announces(aspect="lxmf.propagation")
+39 -9
View File
@@ -106,6 +106,31 @@ class ConfigManager:
"lxmf_propagation_node_stamp_cost",
16,
) # for propagation node messages
self.lxmf_inbound_stamp_cost_before_block = self.IntConfig(
self,
"lxmf_inbound_stamp_cost_before_block",
0,
) # saved stamp cost before block strangers was enabled
self.lxmf_flood_protection_enabled = self.BoolConfig(
self,
"lxmf_flood_protection_enabled",
False,
)
self.lxmf_flood_threshold_per_minute = self.IntConfig(
self,
"lxmf_flood_threshold_per_minute",
30,
)
self.lxmf_flood_max_stamp_cost = self.IntConfig(
self,
"lxmf_flood_max_stamp_cost",
24,
)
self.lxmf_flood_cooldown_seconds = self.IntConfig(
self,
"lxmf_flood_cooldown_seconds",
300,
)
self.page_archiver_enabled = self.BoolConfig(
self,
"page_archiver_enabled",
@@ -206,7 +231,7 @@ class ConfigManager:
self.telephone_announce_enabled = self.BoolConfig(
self,
"telephone_announce_enabled",
True,
False,
)
self.telephone_audio_profile_id = self.IntConfig(
self,
@@ -388,37 +413,37 @@ class ConfigManager:
True,
)
# announce caps: max rows stored per aspect (oldest dropped). Default 1000.
# announce caps: max rows stored per aspect (oldest dropped). Default 2500.
self.announce_max_stored_lxmf_delivery = self.IntConfig(
self,
"announce_max_stored_lxmf_delivery",
1000,
2500,
)
self.announce_max_stored_nomadnetwork_node = self.IntConfig(
self,
"announce_max_stored_nomadnetwork_node",
1000,
2500,
)
self.announce_max_stored_lxmf_propagation = self.IntConfig(
self,
"announce_max_stored_lxmf_propagation",
1000,
2500,
)
# default API page size per aspect when limit query param omitted. Default 500.
# default API page size per aspect when limit query param omitted. Default 2500.
self.announce_fetch_limit_lxmf_delivery = self.IntConfig(
self,
"announce_fetch_limit_lxmf_delivery",
500,
2500,
)
self.announce_fetch_limit_nomadnetwork_node = self.IntConfig(
self,
"announce_fetch_limit_nomadnetwork_node",
500,
2500,
)
self.announce_fetch_limit_lxmf_propagation = self.IntConfig(
self,
"announce_fetch_limit_lxmf_propagation",
500,
2500,
)
# lxst.telephony shares LXMF caps in announce_manager aspect mapping
self.announce_search_max_fetch = self.IntConfig(
@@ -480,6 +505,11 @@ class ConfigManager:
"nomad_default_page_path",
"/page/index.mu",
)
self.default_bootstrap_only = self.BoolConfig(
self,
"default_bootstrap_only",
False,
)
self.lxmf_sieve_filters_json = self.StringConfig(
self,
"lxmf_sieve_filters_json",
+7 -1
View File
@@ -105,6 +105,12 @@ class AnnounceDAO:
(destination_hash,),
)
def get_announces_by_identity_hash(self, identity_hash):
return self.provider.fetchall(
"SELECT * FROM announces WHERE identity_hash = ?",
(identity_hash,),
)
def get_announce_count_by_aspect(self, aspect):
row = self.provider.fetchone(
"SELECT COUNT(*) as count FROM announces WHERE aspect = ?",
@@ -127,7 +133,7 @@ class AnnounceDAO:
search_term=None,
identity_hash=None,
destination_hash=None,
limit=500,
limit=2500,
offset=0,
):
query = "SELECT * FROM announces WHERE 1=1"
+32 -1
View File
@@ -221,6 +221,7 @@ class DocsManager:
return
try:
index_links: list[str] = []
for file, src_docs in sourced:
src_path = os.path.join(src_docs, file)
dest_path = os.path.join(self.meshchatx_docs_dir, file)
@@ -259,8 +260,38 @@ class DocsManager:
encoding="utf-8",
) as f:
f.write(full_html)
index_links.append(
f'<li class="mb-2"><a href="{html_file}" class="text-blue-400 hover:text-blue-300">{html_file}</a></li>'
)
except Exception as e:
logging.exception(f"Failed to render {file} to HTML: {e}")
# Generate an index.html so /meshchatx-docs/index.html resolves
if index_links and os.access(self.meshchatx_docs_dir, os.W_OK):
index_html = f"""<!DOCTYPE html>
<html class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MeshChatX Documentation</title>
<script src="../assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
<style>
body {{ background-color: #111827; color: #f3f4f6; }}
</style>
</head>
<body class="p-4 md:p-8 max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-4">MeshChatX Documentation</h1>
<ul class="list-disc pl-5">
{"".join(index_links)}
</ul>
</body>
</html>"""
with open(
os.path.join(self.meshchatx_docs_dir, "index.html"),
"w",
encoding="utf-8",
) as f:
f.write(index_html)
except Exception as e:
logging.exception(f"Failed to populate MeshChatX docs: {e}")
@@ -310,7 +341,7 @@ class DocsManager:
return None
if not full_path.startswith(base + os.sep) and full_path != base:
return None
if not os.path.exists(full_path):
if not os.path.isfile(full_path):
return None
with open(full_path, encoding="utf-8", errors="ignore") as f:
+20
View File
@@ -217,6 +217,14 @@ class IdentityContext:
# Register LXMF delivery identity
inbound_stamp_cost = self.config.lxmf_inbound_stamp_cost.get()
# Enforce max stamp cost when block strangers is enabled on startup
if (
self.config.block_all_from_strangers.get()
and isinstance(inbound_stamp_cost, int)
and inbound_stamp_cost < 254
):
inbound_stamp_cost = 254
self.config.lxmf_inbound_stamp_cost.set(254)
self.local_lxmf_destination = self.message_router.register_delivery_identity(
identity=self.identity,
display_name=self.config.display_name.get(),
@@ -413,6 +421,18 @@ class IdentityContext:
thread.daemon = True
thread.start()
# start background thread for LXMF flood protection cooldown
thread = threading.Thread(
target=asyncio.run,
args=(
self.app.lxmf_flood_protection_cooldown_loop(
self.session_id, context=self
),
),
)
thread.daemon = True
thread.start()
# start background thread for auto propagation node selection
thread = threading.Thread(
target=asyncio.run,
+18 -2
View File
@@ -107,7 +107,22 @@ class NomadnetDownloader:
if self.request_receipt is not None:
try:
self.request_receipt.cancel()
if (
hasattr(self.request_receipt, "resource")
and self.request_receipt.resource is not None
):
self.request_receipt.resource.cancel()
else:
self.request_receipt.status = RNS.RequestReceipt.FAILED
if (
hasattr(self.request_receipt, "link")
and self.request_receipt.link is not None
and self.request_receipt
in self.request_receipt.link.pending_requests
):
self.request_receipt.link.pending_requests.remove(
self.request_receipt
)
except Exception as e:
print(f"Failed to cancel request: {e}")
@@ -282,6 +297,7 @@ class NomadnetFileDownloader(NomadnetDownloader):
on_file_download_success: Callable[[str, bytes], None],
on_file_download_failure: Callable[[str], None],
on_progress_update: Callable[[float], None],
data: str | None = None,
timeout: int | None = None,
*,
on_phase: Callable[[str], None] | None = None,
@@ -292,7 +308,7 @@ class NomadnetFileDownloader(NomadnetDownloader):
super().__init__(
destination_hash,
page_path,
None,
data,
self.on_download_success,
self.on_download_failure,
on_progress_update,
@@ -12,6 +12,7 @@ from in-memory deques kept by PersistentLogHandler and psutil.
import asyncio
import collections
import gc
import json
import logging
import threading
@@ -59,6 +60,7 @@ class HealthMonitor:
self._check()
except Exception as exc:
_log.debug("HealthMonitor check error: %s", exc)
gc.collect()
await asyncio.sleep(self.CHECK_INTERVAL)
def _check(self):
+34 -15
View File
@@ -225,16 +225,16 @@
</SidebarLink>
</li>
<!-- nomad network -->
<!-- telephone -->
<li>
<SidebarLink :to="{ name: 'nomadnetwork' }" :is-collapsed="isSidebarCollapsed">
<SidebarLink :to="{ name: 'call' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="earth"
icon-name="phone"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.nomad_network") }}</template>
<template #text>{{ $t("app.audio_calls") }}</template>
</SidebarLink>
</li>
@@ -251,6 +251,19 @@
</SidebarLink>
</li>
<!-- nomad network -->
<li>
<SidebarLink :to="{ name: 'nomadnetwork' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="earth"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.nomad_network") }}</template>
</SidebarLink>
</li>
<!-- map -->
<li>
<SidebarLink :to="{ name: 'map' }" :is-collapsed="isSidebarCollapsed">
@@ -277,16 +290,16 @@
</SidebarLink>
</li>
<!-- telephone -->
<!-- tools -->
<li>
<SidebarLink :to="{ name: 'call' }" :is-collapsed="isSidebarCollapsed">
<SidebarLink :to="{ name: 'tools' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="phone"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
icon-name="wrench"
class="size-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.audio_calls") }}</template>
<template #text>{{ $t("app.tools") }}</template>
</SidebarLink>
</li>
@@ -319,16 +332,16 @@
</SidebarLink>
</li>
<!-- tools -->
<!-- banished -->
<li>
<SidebarLink :to="{ name: 'tools' }" :is-collapsed="isSidebarCollapsed">
<SidebarLink :to="{ name: 'blocked' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="wrench"
class="size-6 text-gray-700 dark:text-gray-200"
icon-name="gavel"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.tools") }}</template>
<template #text>{{ $t("banishment.title") }}</template>
</SidebarLink>
</li>
@@ -1174,7 +1187,9 @@ export default {
if (this.config?.do_not_disturb_enabled) {
break;
}
// If we are the caller (outgoing initiation), skip playing the incoming ringtone
if (this.config?.telephone_allow_calls_from_contacts_only && !json.is_contact) {
break;
}
if (this.initiationStatus) {
break;
}
@@ -1232,6 +1247,10 @@ export default {
this.updateTelephoneStatus();
break;
}
case "blocked_destinations": {
GlobalState.blockedDestinations = json.blocked_destinations || [];
break;
}
case "lxmf.delivery": {
if (this.config?.do_not_disturb_enabled) {
break;
@@ -134,6 +134,14 @@ export default {
type: "navigation",
route: { name: "messages" },
},
{
id: "nav-call",
title: "nav_call",
description: "nav_call_desc",
icon: "phone",
type: "navigation",
route: { name: "call" },
},
{
id: "nav-nomad",
title: "nav_nomad",
@@ -158,14 +166,6 @@ export default {
type: "navigation",
route: { name: "paper-message" },
},
{
id: "nav-call",
title: "nav_call",
description: "nav_call_desc",
icon: "phone",
type: "navigation",
route: { name: "call" },
},
{
id: "nav-settings",
title: "nav_settings",
@@ -2177,7 +2177,7 @@ export default {
discoveryInterval: null,
markingSeen: false,
windowWidth: typeof window !== "undefined" ? window.innerWidth : 1024,
defaultBootstrapOnly: true,
defaultBootstrapOnly: false,
refreshingCommunityPresets: false,
bootstrapListSearch: "",
bootstrapDiscoveredSectionOpen: true,
@@ -2802,7 +2802,7 @@ export default {
bootstrap_only: this.defaultBootstrapOnly === true,
};
},
parseDiscoveryBool(value, defaultValue = true) {
parseDiscoveryBool(value, defaultValue = false) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
@@ -2815,10 +2815,10 @@ export default {
try {
const response = await window.api.get("/api/v1/reticulum/discovery");
const d = response.data?.discovery ?? {};
this.defaultBootstrapOnly = this.parseDiscoveryBool(d.default_bootstrap_only, true);
this.defaultBootstrapOnly = this.parseDiscoveryBool(d.default_bootstrap_only, false);
} catch (e) {
console.error(e);
this.defaultBootstrapOnly = true;
this.defaultBootstrapOnly = false;
}
},
async persistDefaultBootstrapOnly(value) {
@@ -43,13 +43,16 @@
</div>
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div v-if="isLoading && blockedItems.length === 0" class="flex flex-col items-center justify-center h-64">
<div
v-if="isLoading && filteredBlockedIdentities.length === 0"
class="flex flex-col items-center justify-center h-64"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p class="text-gray-500 dark:text-gray-400">{{ $t("banishment.loading_items") }}</p>
</div>
<div
v-else-if="filteredBlockedItems.length === 0"
v-else-if="filteredBlockedIdentities.length === 0"
class="flex flex-col items-center justify-center h-64 text-center"
>
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
@@ -63,8 +66,8 @@
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="item in filteredBlockedItems"
:key="item.destination_hash"
v-for="identity in filteredBlockedIdentities"
:key="identity.identity_hash"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
>
<div class="p-5">
@@ -78,15 +81,15 @@
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<h4
class="text-base font-semibold text-gray-900 dark:text-white wrap-break-word"
:title="item.display_name"
:title="identity.display_name"
>
{{ item.display_name || $t("call.unknown") }}
{{ identity.display_name || $t("call.unknown") }}
</h4>
<span
v-if="item.is_node"
v-if="identity.is_node"
class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-sm"
>
{{ $t("banishment.node") }}
@@ -98,7 +101,7 @@
{{ $t("banishment.user") }}
</span>
<span
v-if="item.is_rns_blackholed"
v-if="identity.is_rns_blackholed"
class="px-2 py-0.5 text-xs font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 rounded-sm border border-zinc-200 dark:border-zinc-700"
title="Blackholed at Reticulum transport layer"
>
@@ -107,32 +110,52 @@
</div>
<p
class="text-xs text-gray-500 dark:text-gray-400 font-mono break-all mt-1"
:title="item.destination_hash"
:title="identity.identity_hash"
>
{{ item.destination_hash }}
{{ identity.identity_hash }}
</p>
</div>
</div>
<div v-if="item.created_at" class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{{ $t("banishment.banished_at") }} {{ formatTimeAgo(item.created_at) }}
<!-- Blocked destination hashes -->
<div v-if="identity.blocked_destinations.length > 0" class="mb-2">
<p class="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{{ $t("banishment.blocked_destinations") }}
</p>
<div class="space-y-1">
<div
v-for="dest in identity.blocked_destinations"
:key="dest.destination_hash"
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-50 dark:bg-zinc-800 px-2 py-1 rounded"
>
<span class="break-all">{{ dest.destination_hash }}</span>
<span
v-if="dest.created_at"
class="shrink-0 ml-2 text-gray-400 dark:text-zinc-500"
>
{{ formatTimeAgo(dest.created_at) }}
</span>
</div>
</div>
</div>
<div
v-if="item.rns_source"
class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate mb-1"
>
Source: {{ item.rns_source }}
</div>
<div
v-if="item.rns_reason"
v-if="identity.rns_reason"
class="text-xs italic text-zinc-500 dark:text-zinc-400 mb-2"
>
"{{ item.rns_reason }}"
"{{ identity.rns_reason }}"
</div>
<div
v-if="identity.rns_source"
class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate mb-1"
>
Source: {{ identity.rns_source }}
</div>
</div>
</div>
<button
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors font-medium"
@click="onUnblock(item)"
@click="onUnblock(identity)"
>
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
<span>{{ $t("banishment.lift_banishment") }}</span>
@@ -157,35 +180,28 @@ export default {
},
data() {
return {
blockedItems: [],
reticulumBlackholedItems: [],
blockedIdentities: {},
isLoading: false,
searchQuery: "",
};
},
computed: {
allBlockedItems() {
// Combine local blocked items and reticulum blackholed items
// Prioritize local items if they overlap
const localHashes = new Set(this.blockedItems.map((i) => i.destination_hash));
const combined = [...this.blockedItems];
for (const item of this.reticulumBlackholedItems) {
if (!localHashes.has(item.destination_hash)) {
combined.push(item);
}
}
return combined;
allBlockedIdentities() {
return Object.values(this.blockedIdentities).sort((a, b) => {
const nameA = (a.display_name || "").toLowerCase();
const nameB = (b.display_name || "").toLowerCase();
return nameA.localeCompare(nameB);
});
},
filteredBlockedItems() {
filteredBlockedIdentities() {
if (!this.searchQuery.trim()) {
return this.allBlockedItems;
return this.allBlockedIdentities;
}
const query = this.searchQuery.toLowerCase();
return this.allBlockedItems.filter((item) => {
const matchesHash = item.destination_hash.toLowerCase().includes(query);
const matchesDisplayName = (item.display_name || "").toLowerCase().includes(query);
return matchesHash || matchesDisplayName;
return this.allBlockedIdentities.filter((identity) => {
if (identity.identity_hash.toLowerCase().includes(query)) return true;
if ((identity.display_name || "").toLowerCase().includes(query)) return true;
return identity.blocked_destinations.some((d) => d.destination_hash.toLowerCase().includes(query));
});
},
},
@@ -209,14 +225,35 @@ export default {
console.error("Failed to load Reticulum blackhole", e);
}
const processItem = async (hash, data = {}) => {
let displayName = this.$t("call.unknown");
const identityMap = {};
const ensureIdentity = (identityHash) => {
if (!identityMap[identityHash]) {
identityMap[identityHash] = {
identity_hash: identityHash,
display_name: null,
is_node: false,
blocked_destinations: [],
is_rns_blackholed: false,
rns_source: null,
rns_reason: null,
rns_until: null,
};
}
return identityMap[identityHash];
};
// Process local blocked destinations
const processBlockedHash = async (blocked) => {
const hash = blocked.destination_hash;
let identityHash = hash;
let displayName = null;
let isNode = false;
try {
const announceResponse = await window.api.get("/api/v1/announces", {
params: {
identity_hash: hash,
destination_hash: hash,
include_blocked: true,
limit: 1,
},
@@ -224,39 +261,55 @@ export default {
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
const announce = announceResponse.data.announces[0];
displayName = announce.display_name || this.$t("call.unknown");
identityHash = announce.identity_hash || hash;
displayName = announce.display_name || null;
isNode = announce.aspect === "nomadnetwork.node";
}
} catch {
// ignore error
}
return {
const identity = ensureIdentity(identityHash);
identity.display_name = identity.display_name || displayName;
identity.is_node = identity.is_node || isNode;
identity.blocked_destinations.push({
destination_hash: hash,
display_name: displayName,
created_at: data.created_at || null,
is_node: isNode,
is_rns_blackholed: !!data.is_rns,
rns_source: data.source || null,
rns_reason: data.reason || null,
rns_until: data.until || null,
};
created_at: blocked.created_at || null,
});
};
const items = await Promise.all(
blockedHashes.map((blocked) =>
processItem(blocked.destination_hash, { created_at: blocked.created_at })
)
);
await Promise.all(blockedHashes.map((blocked) => processBlockedHash(blocked)));
const rnsItems = await Promise.all(
Object.entries(reticulumBlackholed).map(([hash, info]) =>
processItem(hash, { ...info, is_rns: true })
)
);
// Process Reticulum blackholed identities
for (const [hash, info] of Object.entries(reticulumBlackholed)) {
const identity = ensureIdentity(hash);
identity.is_rns_blackholed = true;
identity.rns_source = info.source || null;
identity.rns_reason = info.reason || null;
identity.rns_until = info.until || null;
this.blockedItems = items;
this.reticulumBlackholedItems = rnsItems;
// Try to look up display name from announces
if (!identity.display_name) {
try {
const announceResponse = await window.api.get("/api/v1/announces", {
params: {
identity_hash: hash,
include_blocked: true,
limit: 1,
},
});
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
const announce = announceResponse.data.announces[0];
identity.display_name = announce.display_name || null;
identity.is_node = announce.aspect === "nomadnetwork.node";
}
} catch {
// ignore
}
}
}
this.blockedIdentities = identityMap;
} catch (e) {
console.log(e);
ToastUtils.error(this.$t("banishment.failed_load_banished"));
@@ -264,17 +317,25 @@ export default {
this.isLoading = false;
}
},
async onUnblock(item) {
async onUnblock(identity) {
if (
!(await DialogUtils.confirm(
this.$t("banishment.lift_banishment_confirm", { name: item.display_name || item.destination_hash })
this.$t("banishment.lift_banishment_confirm", {
name: identity.display_name || identity.identity_hash,
})
))
) {
return;
}
try {
await window.api.delete(`/api/v1/blocked-destinations/${item.destination_hash}`);
// Use the first blocked destination hash, or fall back to identity hash
const targetHash =
identity.blocked_destinations.length > 0
? identity.blocked_destinations[0].destination_hash
: identity.identity_hash;
await window.api.delete(`/api/v1/blocked-destinations/${targetHash}`);
await this.loadBlockedDestinations();
ToastUtils.success(this.$t("banishment.banishment_lifted"));
} catch (e) {
@@ -283,10 +344,7 @@ export default {
}
},
onSearchInput() {},
formatDestinationHash: function (destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
formatTimeAgo: function (datetimeString) {
formatTimeAgo(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
},
File diff suppressed because it is too large Load Diff
@@ -1938,7 +1938,7 @@ export default {
interface_discovery_blacklist: "",
required_discovery_value: null,
autoconnect_discovered_interfaces: null,
default_bootstrap_only: true,
default_bootstrap_only: false,
network_identity: "",
},
@@ -2128,7 +2128,7 @@ export default {
this.reticulumDiscovery.interface_discovery_whitelist = discovery.interface_discovery_whitelist ?? "";
this.reticulumDiscovery.interface_discovery_blacklist = discovery.interface_discovery_blacklist ?? "";
this.reticulumDiscovery.default_bootstrap_only = this.parseBool(
discovery.default_bootstrap_only ?? true
discovery.default_bootstrap_only ?? false
);
if (!this.isEditingInterface) {
this.newInterfaceBootstrapOnly = this.reticulumDiscovery.default_bootstrap_only;
@@ -347,6 +347,16 @@
>
Heard: {{ formatLastHeard(iface.last_heard) }}
</span>
<template v-if="discoveredBytes(iface)">
<span class="stat-chip bg-gray-50 dark:bg-zinc-800/50">
{{ $t("interface.tx") }}
{{ discoveredBytes(iface).tx }}
</span>
<span class="stat-chip bg-gray-50 dark:bg-zinc-800/50">
{{ $t("interface.rx") }}
{{ discoveredBytes(iface).rx }}
</span>
</template>
</div>
<div class="grid gap-1.5 text-[10px] sm:text-[11px] pt-1 min-w-0">
@@ -456,20 +466,6 @@
>Loc: {{ iface.latitude }}, {{ iface.longitude }}</span
>
</div>
<div
v-if="discoveredBytes(iface)"
class="flex items-center gap-2 text-gray-500 dark:text-gray-500 min-w-0"
>
<MaterialDesignIcon
icon-name="swap-vertical"
class="w-3.5 h-3.5 shrink-0"
/>
<span class="truncate"
>TX {{ discoveredBytes(iface).tx }} · RX
{{ discoveredBytes(iface).rx }}</span
>
</div>
</div>
</div>
@@ -753,7 +749,7 @@ export default {
interface_discovery_blacklist: "",
required_discovery_value: null,
autoconnect_discovered_interfaces: null,
default_bootstrap_only: true,
default_bootstrap_only: false,
network_identity: "",
},
savingDiscovery: false,
@@ -887,7 +883,7 @@ export default {
const set = new Set();
this.discoveredActive.forEach((a) => {
if (a.transport_id) {
set.add(a.transport_id);
set.add(String(a.transport_id).toLowerCase());
}
});
return set;
@@ -1141,7 +1137,7 @@ export default {
const reach = iface.reachable_on;
const port = iface.port;
const nid = iface.network_id ? String(iface.network_id).toLowerCase() : null;
if (iface.transport_id && this.discoveredActiveTransportIds.has(iface.transport_id)) {
if (iface.transport_id && this.discoveredActiveTransportIds.has(String(iface.transport_id).toLowerCase())) {
return true;
}
const hasMeta = this.discoveryAutoconnectMetadataPresent;
@@ -1163,7 +1159,7 @@ export default {
const portMatch =
(s.target_port && port && Number(s.target_port) === Number(port)) ||
(s.listen_port && port && Number(s.listen_port) === Number(port));
if (!hostMatch || !portMatch || !(s.connected || s.online)) return false;
if (!hostMatch || !portMatch || !this.interfaceStatLinkUp(s)) return false;
const asrc = s.autoconnect_source;
if (asrc != null && asrc !== undefined) {
if (nid !== null) return String(asrc).toLowerCase() === nid;
@@ -1189,18 +1185,62 @@ export default {
query: { view: "discovered" },
});
},
interfaceStatLinkUp(s) {
if (!s || typeof s !== "object") return false;
if (s.status === false || s.connected === false || s.online === false) return false;
if (s.status === true || s.connected === true || s.online === true) return true;
return true;
},
discoveredBytes(iface) {
if (!this.isDiscoveredConnected(iface)) return null;
const tid = iface.transport_id ? String(iface.transport_id).toLowerCase() : null;
if (tid) {
const byTid = (this.discoveredActive || []).find((a) => {
if (!a.transport_id) return false;
if (String(a.transport_id).toLowerCase() !== tid) return false;
return this.interfaceStatLinkUp(a);
});
if (byTid && (byTid.txb !== undefined || byTid.rxb !== undefined)) {
return {
tx: this.formatBytes(byTid.txb ?? 0),
rx: this.formatBytes(byTid.rxb ?? 0),
};
}
}
const reach = iface.reachable_on;
const port = iface.port;
const nid = iface.network_id ? String(iface.network_id).toLowerCase() : null;
const stats = this.activeInterfaceStats || [];
const hasMeta = this.discoveryAutoconnectMetadataPresent;
const byActive = (this.discoveredActive || []).find((a) => {
const host = a.target_host || a.remote || a.listen_ip;
const p = a.target_port || a.listen_port;
if (!host || p == null || !reach || port == null) return false;
if (String(host) !== String(reach) || Number(p) !== Number(port)) return false;
if (!this.interfaceStatLinkUp(a)) return false;
const asrc = a.autoconnect_source;
if (asrc != null && asrc !== undefined) {
if (nid !== null) return String(asrc).toLowerCase() === nid;
return true;
}
return !hasMeta;
});
if (byActive && (byActive.txb !== undefined || byActive.rxb !== undefined)) {
return {
tx: this.formatBytes(byActive.txb ?? 0),
rx: this.formatBytes(byActive.rxb ?? 0),
};
}
const stats = this.activeInterfaceStats || [];
const match = stats.find((s) => {
const host = s.target_host || s.remote || s.interface_name;
const host = s.target_host || s.remote || s.listen_ip;
const p = s.target_port || s.listen_port;
const hostMatch = host && reach && host === reach;
const portMatch = p && port && Number(p) === Number(port);
if (!hostMatch || !portMatch || !(s.connected || s.online)) return false;
if (!host || !reach || port == null || p == null) return false;
if (String(host) !== String(reach) || Number(p) !== Number(port)) return false;
if (!this.interfaceStatLinkUp(s)) return false;
const asrc = s.autoconnect_source;
if (asrc != null && asrc !== undefined) {
if (nid !== null) return String(asrc).toLowerCase() === nid;
@@ -1208,10 +1248,10 @@ export default {
}
return !hasMeta;
});
if (!match) return null;
if (!match || (match.txb === undefined && match.rxb === undefined)) return null;
return {
tx: this.formatBytes(match.txb || 0),
rx: this.formatBytes(match.rxb || 0),
tx: this.formatBytes(match.txb ?? 0),
rx: this.formatBytes(match.rxb ?? 0),
};
},
formatBytes(bytes) {
@@ -1243,7 +1283,7 @@ export default {
discovery.autoconnect_discovered_interfaces !== ""
? Number(discovery.autoconnect_discovered_interfaces)
: null;
this.discoveryConfig.default_bootstrap_only = this.parseBool(discovery.default_bootstrap_only ?? true);
this.discoveryConfig.default_bootstrap_only = this.parseBool(discovery.default_bootstrap_only ?? false);
this.discoveryConfig.network_identity = discovery.network_identity ?? "";
} catch (e) {
console.log(e);
@@ -355,8 +355,8 @@ export default {
// show result
DialogUtils.alert(info.join("\n"));
} catch (e) {
console.log(e);
const message = e.response?.data?.message ?? this.$t("messages.ping_failed");
console.warn("Ping failed:", message);
DialogUtils.alert(message);
} finally {
ToastUtils.dismiss(pingToastKey);
@@ -2,10 +2,7 @@
<template>
<!-- peer selected -->
<div
v-if="selectedPeer"
class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden transition-all relative"
>
<div v-if="selectedPeer" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden relative">
<!-- banished overlay -->
<div
v-if="GlobalState?.config?.banished_effect_enabled && isSelectedPeerBlocked"
@@ -179,35 +176,62 @@
</div>
<!-- chat items -->
<div
id="messages"
ref="messagesScroll"
class="flex-1 min-h-0 overflow-y-scroll bg-white dark:bg-zinc-950 transition-none"
style="overflow-anchor: none; overscroll-behavior-y: contain"
:class="!messagesViewportReady ? 'invisible opacity-0 pointer-events-none select-none' : ''"
:data-message-list-mode="useVirtualMessageList ? 'virtual' : 'reverse'"
:aria-busy="!messagesViewportReady ? 'true' : undefined"
@scroll="onMessagesScroll"
>
<div class="flex-1 min-h-0 min-w-0 relative flex flex-col">
<div
v-if="selectedPeerChatItems.length > 0"
class="min-w-0 px-4 py-6"
:class="useVirtualMessageList ? 'relative flex flex-col' : ''"
id="messages"
ref="messagesScroll"
class="flex-1 min-h-0 overflow-y-scroll bg-white dark:bg-zinc-950"
style="overflow-anchor: none; overscroll-behavior-y: contain"
:data-message-list-mode="useVirtualMessageList ? 'virtual' : 'reverse'"
:aria-busy="!messagesViewportReady ? 'true' : undefined"
@scroll="onMessagesScroll"
>
<template v-if="!useVirtualMessageList">
<div class="flex flex-col flex-col-reverse min-w-0">
<template
v-for="entry in selectedPeerChatDisplayGroupsNewestFirstAugmented"
:key="entry.key"
>
<ConversationMessageEntry :entry="entry" :cv="conversationViewerSelf" />
</template>
<!-- load previous -->
<div
v-if="selectedPeerChatItems.length > 0"
:key="selectedPeer ? selectedPeer.destination_hash : ''"
class="min-w-0 px-4 py-6"
:class="useVirtualMessageList ? 'relative flex flex-col' : ''"
>
<template v-if="!useVirtualMessageList">
<div class="flex flex-col flex-col-reverse min-w-0">
<template
v-for="entry in selectedPeerChatDisplayGroupsNewestFirstAugmented"
:key="entry.key"
>
<ConversationMessageEntry :entry="entry" :cv="conversationViewerSelf" />
</template>
<!-- load previous -->
<button
v-show="!isLoadingPrevious && hasMorePrevious"
id="load-previous"
type="button"
class="flex items-center gap-2 mx-auto mt-4 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 px-4 py-2 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-full shadow-xs text-sm font-medium text-gray-700 dark:text-zinc-300 transition-colors"
@click="loadPrevious"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span>Load Previous</span>
</button>
</div>
</template>
<template v-else>
<button
v-show="!isLoadingPrevious && hasMorePrevious"
id="load-previous"
type="button"
class="flex items-center gap-2 mx-auto mt-4 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 px-4 py-2 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-full shadow-xs text-sm font-medium text-gray-700 dark:text-zinc-300 transition-colors"
class="absolute top-2 left-1/2 z-20 -translate-x-1/2 flex items-center gap-2 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm border border-gray-200 dark:border-zinc-800 px-4 py-2 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-full shadow-xs text-sm font-medium text-gray-700 dark:text-zinc-300 transition-colors"
@click="loadPrevious"
>
<svg
@@ -226,40 +250,20 @@
</svg>
<span>Load Previous</span>
</button>
</div>
</template>
<template v-else>
<button
v-show="!isLoadingPrevious && hasMorePrevious"
id="load-previous"
type="button"
class="absolute top-2 left-1/2 z-20 -translate-x-1/2 flex items-center gap-2 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm border border-gray-200 dark:border-zinc-800 px-4 py-2 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-full shadow-xs text-sm font-medium text-gray-700 dark:text-zinc-300 transition-colors"
@click="loadPrevious"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span>Load Previous</span>
</button>
<ConversationMessageListVirtual
ref="messageListVirtual"
:groups="selectedPeerChatDisplayGroupsOldestFirstAugmented"
:get-scroll-element="getMessagesScrollElement"
:cv="conversationViewerSelf"
/>
</template>
<ConversationMessageListVirtual
ref="messageListVirtual"
:groups="selectedPeerChatDisplayGroupsOldestFirstAugmented"
:get-scroll-element="getMessagesScrollElement"
:cv="conversationViewerSelf"
/>
</template>
</div>
</div>
<div
v-if="!messagesViewportReady"
class="absolute inset-0 z-20 bg-white dark:bg-zinc-950 pointer-events-none select-none"
aria-hidden="true"
/>
</div>
<Transition name="scroll-fab">
@@ -271,7 +275,7 @@
type="button"
class="flex items-center justify-center size-8 rounded-full bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm border border-gray-200 dark:border-zinc-700 shadow-sm text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-700 hover:text-gray-700 dark:hover:text-zinc-200 transition-colors"
title="Scroll to bottom"
@click="scrollMessagesToBottom"
@click="scrollMessagesToBottom()"
>
<MaterialDesignIcon icon-name="chevron-down" class="size-5" />
</button>
@@ -1712,7 +1716,13 @@ import { MESSAGE_BODY_MAX_DISPLAY_CHARS, isStringTooLargeForInlineDisplay } from
import { buildTimestampGroupedOldestFirst } from "../../js/messageTimestampGrouping.js";
import DownloadUtils from "../../js/DownloadUtils";
import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
import { isNearBottom, scrollContainerToBottom, shouldLoadPreviousMessages } from "./conversationScroll.js";
import {
canTrustScrollNearBottomHeuristic,
isNearBottom,
resetMessagesScrollSurface,
scrollContainerToBottom,
shouldLoadPreviousMessages,
} from "./conversationScroll.js";
import {
isTelemetryOnly as isTelemetryOnlyMessage,
hasRenderableContent as messageHasRenderableContent,
@@ -1733,6 +1743,10 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
const SCROLL_SETTLE_MAX_PASSES = 24;
const OPEN_CONVERSATION_SCROLL_PIN_MS = 900;
import SendMessageButton from "./SendMessageButton.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
@@ -1915,6 +1929,8 @@ export default {
prevScrollWantedLoadPrevious: false,
initialLoadActive: false,
messagesViewportReady: true,
openConversationScrollObserver: null,
conversationOpenPinUntil: 0,
};
},
computed: {
@@ -2335,7 +2351,9 @@ export default {
this.saveDraft(oldPeer.destination_hash);
}
this.teardownPeerHeaderResizeObserver();
this.disconnectOpenConversationScrollObserver();
this.scrollBottomGen += 1;
this.autoScrollOnNewMessage = true;
this.messagesViewportReady = false;
if (!newPeer) {
this.peerHeaderCompact = false;
@@ -2345,6 +2363,9 @@ export default {
this.checkIfStrangerPeer();
this.prevScrollWantedLoadPrevious = false;
this.initialLoad();
this.$nextTick(() => {
this.resetStaleConversationScrollSurface();
});
if (newPeer) {
this.loadDraft(newPeer.destination_hash);
this.$nextTick(() => this.setupPeerHeaderResizeObserver());
@@ -2456,6 +2477,7 @@ export default {
if (this.propagationStatusInterval) {
clearInterval(this.propagationStatusInterval);
}
this.disconnectOpenConversationScrollObserver();
},
methods: {
isMeshChatXAndroid() {
@@ -3067,7 +3089,12 @@ export default {
},
onMessagesScroll(event) {
const element = event.target;
this.autoScrollOnNewMessage = isNearBottom(element);
const nearBottom = isNearBottom(element);
this.autoScrollOnNewMessage = nearBottom;
if (!nearBottom) {
this.conversationOpenPinUntil = 0;
this.disconnectOpenConversationScrollObserver();
}
const wantLoad = shouldLoadPreviousMessages(element);
if (wantLoad && !this.prevScrollWantedLoadPrevious) {
@@ -3089,6 +3116,9 @@ export default {
return;
}
await this.$nextTick();
this.resetStaleConversationScrollSurface();
this.getPeerPath();
this.getPeerLxmfStampInfo();
this.getPeerSignalMetrics();
@@ -3098,8 +3128,14 @@ export default {
await this.loadPrevious();
await this.$nextTick();
await this.$nextTick();
await new Promise((resolve) => {
requestAnimationFrame(() => resolve());
});
this.initialLoadActive = false;
this.scrollMessagesToBottom();
this.scrollMessagesToBottom({ pinAfter: true });
this.autoLoadAudioAttachments();
},
@@ -3602,10 +3638,84 @@ export default {
].join("\n")
);
},
scrollMessagesToBottom: function () {
resetStaleConversationScrollSurface() {
resetMessagesScrollSurface(this.$refs.messagesScroll ?? null);
},
disconnectOpenConversationScrollObserver() {
if (this.openConversationScrollObserver) {
try {
this.openConversationScrollObserver.disconnect();
} catch {
/* ignore */
}
this.openConversationScrollObserver = null;
}
if (this._openConversationScrollPinTimer) {
clearTimeout(this._openConversationScrollPinTimer);
this._openConversationScrollPinTimer = null;
}
},
_startOpenConversationScrollPin(gen, stale) {
this.disconnectOpenConversationScrollObserver();
const container = this.$refs.messagesScroll;
const observed = container?.firstElementChild;
if (!observed || typeof ResizeObserver === "undefined") {
return;
}
this.conversationOpenPinUntil = Date.now() + OPEN_CONVERSATION_SCROLL_PIN_MS;
const ro = new ResizeObserver(() => {
if (stale() || Date.now() > this.conversationOpenPinUntil || !this.autoScrollOnNewMessage) {
this.disconnectOpenConversationScrollObserver();
return;
}
requestAnimationFrame(() => {
if (stale() || Date.now() > this.conversationOpenPinUntil || !this.autoScrollOnNewMessage) {
return;
}
const c = this.$refs.messagesScroll;
if (!c) {
return;
}
try {
if (this.useVirtualMessageList && this.$refs.messageListVirtual) {
this.$refs.messageListVirtual.scrollToBottom();
}
scrollContainerToBottom(c);
} catch (e) {
console.error(e);
}
});
});
this.openConversationScrollObserver = ro;
ro.observe(observed);
this._openConversationScrollPinTimer = setTimeout(() => {
this.disconnectOpenConversationScrollObserver();
}, OPEN_CONVERSATION_SCROLL_PIN_MS + 80);
},
scrollMessagesToBottom: function (options) {
const pinAfter = options && options.pinAfter === true;
this.scrollBottomGen += 1;
const gen = this.scrollBottomGen;
const stale = () => gen !== this.scrollBottomGen;
const pump = () => {
const container = this.$refs.messagesScroll;
if (!container) {
return;
}
try {
if (this.useVirtualMessageList && this.$refs.messageListVirtual) {
this.$refs.messageListVirtual.scrollToBottom();
}
scrollContainerToBottom(container);
} catch (e) {
console.error(e);
}
};
this.$nextTick(() => {
if (stale()) return;
this.$nextTick(() => {
@@ -3615,40 +3725,36 @@ export default {
this.messagesViewportReady = true;
return;
}
const pump = () => {
if (this.useVirtualMessageList && this.$refs.messageListVirtual) {
this.$refs.messageListVirtual.scrollToBottom();
}
scrollContainerToBottom(container);
};
const safePump = () => {
try {
pump();
} catch (e) {
console.error(e);
}
};
safePump();
if (!this.useVirtualMessageList) {
const hasItems = this.selectedPeerChatItems.length > 0;
if (!hasItems) {
pump();
requestAnimationFrame(() => {
if (stale()) return;
safePump();
this.messagesViewportReady = true;
});
} else {
let passes = 0;
const settle = () => {
if (stale()) return;
safePump();
passes++;
if (isNearBottom(container) || passes >= 6) {
this.messagesViewportReady = true;
} else {
requestAnimationFrame(settle);
if (pinAfter) {
this._startOpenConversationScrollPin(gen, stale);
}
};
requestAnimationFrame(settle);
});
return;
}
let passes = 0;
const settle = () => {
if (stale()) return;
pump();
passes++;
const trustNear = canTrustScrollNearBottomHeuristic(container);
if ((trustNear && isNearBottom(container)) || passes >= SCROLL_SETTLE_MAX_PASSES) {
this.messagesViewportReady = true;
if (pinAfter) {
this._startOpenConversationScrollPin(gen, stale);
}
} else {
requestAnimationFrame(settle);
}
};
pump();
requestAnimationFrame(settle);
});
});
},
@@ -64,6 +64,28 @@ export function scrollContainerToBottom(container) {
}
}
/**
* Clears stale scroll position when reusing the same scroll element for another thread.
* `scrollMessagesToBottom` / `scrollContainerToBottom` run after content is mounted.
* @param {Element | null | undefined} container
*/
export function resetMessagesScrollSurface(container) {
if (!container) {
return;
}
container.scrollTop = 0;
}
/**
* When the scroll area has no inner content yet, `isScrollColumnReverse` is false and
* `isNearBottom` is misleading (empty scroller looks "at bottom"). Do not use it to settle.
* @param {Element | null | undefined} container
* @returns {boolean}
*/
export function canTrustScrollNearBottomHeuristic(container) {
return Boolean(container?.firstElementChild);
}
/**
* Whether the user has scrolled into the region where older messages should be loaded.
* @param {Element} container
@@ -1937,9 +1937,11 @@ export default {
path = this.defaultNodePagePath;
}
const queryIndex = path.indexOf("?");
return {
destination_hash: null, // node hash was not in provided url
path: path,
path: queryIndex >= 0 ? path.substring(0, queryIndex) : path,
query: queryIndex >= 0 ? path.substring(queryIndex + 1) : null,
};
}
@@ -1950,18 +1952,32 @@ export default {
// ensure destination is expected length
if (destinationHash.length === 32) {
const joined = relativeUrl.join(":");
const queryIndex = joined.indexOf("?");
return {
destination_hash: destinationHash,
path: relativeUrl.join(":"),
path: queryIndex >= 0 ? joined.substring(0, queryIndex) : joined,
query: queryIndex >= 0 ? joined.substring(queryIndex + 1) : null,
};
}
}
// parse relative page/file urls (e.g. /file/artifact`g=reticulum|r=lxmf)
if (url.startsWith("/page/") || url.startsWith("/file/")) {
const queryIndex = url.indexOf("?");
return {
destination_hash: null,
path: queryIndex >= 0 ? url.substring(0, queryIndex) : url,
query: queryIndex >= 0 ? url.substring(queryIndex + 1) : null,
};
}
// parse node id only
if (url.length === 32) {
return {
destination_hash: url,
path: this.defaultNodePagePath,
query: null,
};
}
@@ -2061,9 +2077,18 @@ export default {
return;
}
// NomadNet file URLs may use backticks to separate path from parameters
let filePath = parsedUrl.path;
let fileData = parsedUrl.query;
const pathBacktickIndex = filePath.indexOf("`");
if (pathBacktickIndex >= 0) {
fileData = filePath.substring(pathBacktickIndex + 1);
filePath = filePath.substring(0, pathBacktickIndex);
}
// update ui
this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFilePath = filePath.split("/").pop();
this.nodeFileProgress = 0;
this.nodeFileDownloadStartTime = Date.now();
this.nodeFileLastProgressTime = Date.now();
@@ -2073,7 +2098,8 @@ export default {
// start file download
this.downloadNomadNetFile(
destinationHash,
parsedUrl.path,
filePath,
fileData,
(fileName, fileBytesBase64) => {
// Calculate final download speed based on actual file size
if (this.nodeFileDownloadStartTime) {
@@ -2351,7 +2377,14 @@ export default {
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
downloadNomadNetFile(
destinationHash,
filePath,
data,
onSuccessCallback,
onFailureCallback,
onProgressCallback
) {
try {
// set callbacks for nomadnet filePath download
this.nomadnetFileDownloadCallbacks[this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath)] =
@@ -2362,15 +2395,17 @@ export default {
};
// ask reticulum to download file from nomadnet
WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.file.download",
nomadnet_file_download: {
destination_hash: destinationHash,
file_path: filePath,
},
})
);
const payload = {
type: "nomadnet.file.download",
nomadnet_file_download: {
destination_hash: destinationHash,
file_path: filePath,
},
};
if (data != null) {
payload.nomadnet_file_download.data = data;
}
WebSocketConnection.send(JSON.stringify(payload));
} catch (e) {
console.error(e);
}
@@ -284,10 +284,9 @@ export default {
return;
}
console.log(e);
// add ping error to results
const message = e.response?.data?.message ?? e;
const message = e.response?.data?.message ?? e.message ?? String(e);
console.warn("Ping failed:", message);
this.addPingResult(`seq=${this.seq} error=${message}`);
this.lastPingSummary = {
error: typeof message === "string" ? message : JSON.stringify(message),
@@ -2257,6 +2257,71 @@
{{ $t("app.inbound_stamp_description") }}
</div>
</div>
<hr class="border-gray-200 dark:border-gray-700" />
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{{ $t("app.flood_protection") }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mb-3">
{{ $t("app.flood_protection_description") }}
</div>
<label class="setting-toggle">
<Toggle
id="lxmf-flood-protection"
v-model="config.lxmf_flood_protection_enabled"
@update:model-value="onLxmfFloodProtectionEnabledChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("app.flood_protection_enabled")
}}</span>
</span>
</label>
<div v-show="config.lxmf_flood_protection_enabled" class="space-y-3 mt-2">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.flood_threshold") }}
</div>
<input
v-model.number="config.lxmf_flood_threshold_per_minute"
type="number"
min="1"
max="1000"
placeholder="30"
class="input-field"
@input="onLxmfFloodThresholdChange"
/>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.flood_max_stamp_cost") }}
</div>
<input
v-model.number="config.lxmf_flood_max_stamp_cost"
type="number"
min="1"
max="254"
placeholder="24"
class="input-field"
@input="onLxmfFloodMaxStampCostChange"
/>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.flood_cooldown") }}
</div>
<input
v-model.number="config.lxmf_flood_cooldown_seconds"
type="number"
min="30"
max="3600"
placeholder="300"
class="input-field"
@input="onLxmfFloodCooldownChange"
/>
</div>
</div>
</div>
</div>
</section>
@@ -2936,6 +3001,12 @@ export default {
"app.inbound_stamp_description",
"app.inbound_stamps_required_title",
"app.inbound_stamps_required_description",
"app.flood_protection",
"app.flood_protection_description",
"app.flood_protection_enabled",
"app.flood_threshold",
"app.flood_max_stamp_cost",
"app.flood_cooldown",
],
propagation: [
"LXMF",
@@ -3609,6 +3680,47 @@ export default {
);
}, 1000);
},
async onLxmfFloodProtectionEnabledChange(value) {
await this.updateConfig({
lxmf_flood_protection_enabled: value,
});
},
async onLxmfFloodThresholdChange() {
if (this.saveTimeouts.flood_threshold) clearTimeout(this.saveTimeouts.flood_threshold);
this.saveTimeouts.flood_threshold = setTimeout(async () => {
let v = Number(this.config.lxmf_flood_threshold_per_minute);
if (!v || v < 1) v = 30;
else if (v > 1000) v = 1000;
this.config.lxmf_flood_threshold_per_minute = v;
await this.updateConfig({
lxmf_flood_threshold_per_minute: v,
});
}, 1000);
},
async onLxmfFloodMaxStampCostChange() {
if (this.saveTimeouts.flood_max_cost) clearTimeout(this.saveTimeouts.flood_max_cost);
this.saveTimeouts.flood_max_cost = setTimeout(async () => {
let v = Number(this.config.lxmf_flood_max_stamp_cost);
if (!v || v < 1) v = 24;
else if (v > 254) v = 254;
this.config.lxmf_flood_max_stamp_cost = v;
await this.updateConfig({
lxmf_flood_max_stamp_cost: v,
});
}, 1000);
},
async onLxmfFloodCooldownChange() {
if (this.saveTimeouts.flood_cooldown) clearTimeout(this.saveTimeouts.flood_cooldown);
this.saveTimeouts.flood_cooldown = setTimeout(async () => {
let v = Number(this.config.lxmf_flood_cooldown_seconds);
if (!v || v < 30) v = 30;
else if (v > 3600) v = 3600;
this.config.lxmf_flood_cooldown_seconds = v;
await this.updateConfig({
lxmf_flood_cooldown_seconds: v,
});
}, 1000);
},
async onPageArchiverEnabledChangeWrapper(value) {
this.config.page_archiver_enabled = value;
await this.updateConfig(
@@ -4262,9 +4374,17 @@ export default {
} catch {
// keep empty layout
}
let favourites = [];
try {
const response = await window.api.get("/api/v1/favourites");
favourites = response.data.favourites || [];
} catch {
// continue without favourite records
}
const body = {
format: "meshchatx/nomadnet_favourites/v1",
exported_at: new Date().toISOString(),
favourites,
layout,
};
const blob = new Blob([JSON.stringify(body, null, 2)], { type: "application/json" });
@@ -4287,13 +4407,18 @@ export default {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
reader.onload = async (e) => {
try {
const data = JSON.parse(e.target.result);
const parsed = this.parseNomadnetFavouritesImportData(data);
if (!parsed) {
throw new Error("invalid file");
}
if (Array.isArray(data.favourites) && data.favourites.length > 0) {
await window.api.post("/api/v1/favourites/import", {
favourites: data.favourites,
});
}
if (parsed.kind === "full") {
localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(parsed.layout));
} else if (parsed.kind === "section") {
@@ -160,6 +160,15 @@ export function isolateNomadLinksInHtml(html, destinationHash) {
a.classList.add("nomadnet-link", "text-blue-600", "dark:text-blue-400", "hover:underline");
} else {
a.setAttribute("href", "#");
// For micron parser links with data-destination, update title so hover shows the full URL
const dataDest = a.getAttribute("data-destination");
if (dataDest) {
let titleUrl = dataDest.trim();
if (!/^[a-f0-9]{32}:/i.test(titleUrl)) {
titleUrl = `${dh}:${titleUrl.startsWith(":") ? titleUrl.slice(1) : titleUrl}`;
}
a.setAttribute("title", titleUrl);
}
}
a.removeAttribute("target");
a.removeAttribute("rel");
+15 -8
View File
@@ -357,7 +357,13 @@
"telemetry_trust_revoke": "Telemetrie-Vertrauen entziehen",
"telemetry_trust_grant": "Für Telemetrie vertrauen",
"location_manage_desc": "Verwalten Sie, wie Ihr Standort geteilt wird.",
"restart_rns": "RNS neu starten"
"restart_rns": "RNS neu starten",
"flood_protection": "Flood Protection",
"flood_protection_description": "Automatically raise the inbound stamp cost when receiving too many messages per minute from many sources. This makes coordinated spam attacks computationally expensive while keeping normal conversations affordable.",
"flood_protection_enabled": "Enable auto-adjusting stamp cost",
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)"
},
"common": {
"open": "Öffnen",
@@ -921,17 +927,17 @@
"backbone_transport_identity_hint": "Entspricht Reticulum-Backbone-Fernbeispielen nur mit remote und target_port. Hash verwenden, wenn Betreiber oder Verzeichnis ihn angeben.",
"loopback_local_title": "Lokal / Loopback",
"loopback_local_body": "Reticulum nutzt intern eine geteilte Instanz. Um einen anderen RNS-Prozess auf diesem Host zu erreichen, verwenden Sie einen TCP-Client auf 127.0.0.1 mit dem Instanz-Port oder ein externes Interface-Modul.",
"loopback_local_docs_hint": "Siehe Kapitel Interfaces f\u00fcr unterst\u00fctzte Typen.",
"loopback_local_docs_link": "Geb\u00fcndeltes Reticulum-Handbuch \u00f6ffnen",
"custom_external_intro": "Typen wie WeaveInterface oder eigene Klassen werden geladen, wenn Reticulum eine passende Moduldatei (TypeName.py) unter interfacepath mit interface_class findet (externer Loader in RNS). JSON-Optionen werden in den Interface-Abschnitt Ihrer Konfiguration \u00fcbernommen.",
"loopback_local_docs_hint": "Siehe Kapitel Interfaces für unterstützte Typen.",
"loopback_local_docs_link": "Gebündeltes Reticulum-Handbuch öffnen",
"custom_external_intro": "Typen wie WeaveInterface oder eigene Klassen werden geladen, wenn Reticulum eine passende Moduldatei (TypeName.py) unter interfacepath mit interface_class findet (externer Loader in RNS). JSON-Optionen werden in den Interface-Abschnitt Ihrer Konfiguration übernommen.",
"custom_external_type_label": "Interface-Typname",
"custom_external_type_placeholder": "WeaveInterface",
"custom_external_json_label": "Zus\u00e4tzliche Optionen (JSON-Objekt)",
"custom_external_json_label": "Zusätzliche Optionen (JSON-Objekt)",
"custom_external_json_placeholder": "\"listen_ip\": \"0.0.0.0\", \"listen_port\": 4242",
"custom_external_docs_hint": "Siehe Interfaces-Handbuch f\u00fcr native Typen und externe Module.",
"custom_external_docs_link": "Geb\u00fcndeltes Reticulum-Handbuch \u00f6ffnen",
"custom_external_docs_hint": "Siehe Interfaces-Handbuch für native Typen und externe Module.",
"custom_external_docs_link": "Gebündeltes Reticulum-Handbuch öffnen",
"custom_external_type_required": "Reticulum-Interface-Typnamen eingeben.",
"custom_external_json_invalid": "Ung\u00fcltiges JSON f\u00fcr Zusatzoptionen.",
"custom_external_json_invalid": "Ungültiges JSON für Zusatzoptionen.",
"failed_save_discovery": "Speichern der Erkennungseinstellungen fehlgeschlagen",
"no_interfaces_found_config": "Keine Schnittstellen in der ausgewählten Konfigurationsdatei gefunden",
"failed_parse_config": "Parsen der Konfigurationsdatei fehlgeschlagen",
@@ -2699,6 +2705,7 @@
"type": "Type",
"user": "User",
"node": "Node",
"blocked_destinations": "Blockierte Ziele",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment"
}
+7
View File
@@ -146,6 +146,12 @@
"inbound_stamps_required_description": "When off, direct messages to you do not require proof-of-work stamps. When on, set the stamp cost below. Higher values mean more work for senders.",
"inbound_stamp_cost": "Inbound Message Stamp Cost",
"inbound_stamp_description": "Proof-of-work difficulty for direct messages. Range: 1-254. Default: 8.",
"flood_protection": "Flood Protection",
"flood_protection_description": "Automatically raise the inbound stamp cost when receiving too many messages per minute from many sources. This makes coordinated spam attacks computationally expensive while keeping normal conversations affordable.",
"flood_protection_enabled": "Enable auto-adjusting stamp cost",
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
"local_message_auto_delete_title": "Delete old messages on this device",
"local_message_auto_delete_description": "Removes messages from the local database after the age you set. This only affects this device and no data is deleted from the network.",
"local_message_auto_delete_age": "Delete messages older than",
@@ -1476,6 +1482,7 @@
"type": "Type",
"user": "User",
"node": "Node",
"blocked_destinations": "Blocked destinations",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment"
},
+12 -5
View File
@@ -357,7 +357,13 @@
"telemetry_trust_revoke": "Revoke Telemetry Trust",
"telemetry_trust_grant": "Trust for Telemetry",
"location_manage_desc": "Administrar cómo se comparte su ubicación.",
"restart_rns": "Reiniciar RNS"
"restart_rns": "Reiniciar RNS",
"flood_protection": "Flood Protection",
"flood_protection_description": "Automatically raise the inbound stamp cost when receiving too many messages per minute from many sources. This makes coordinated spam attacks computationally expensive while keeping normal conversations affordable.",
"flood_protection_enabled": "Enable auto-adjusting stamp cost",
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)"
},
"common": {
"open": "Abierto",
@@ -869,17 +875,17 @@
"backbone_transport_identity_hint": "Como los ejemplos remotos backbone de Reticulum solo con remote y target_port. Use el hash si el operador o el directorio lo indican.",
"loopback_local_title": "Local / loopback",
"loopback_local_body": "Reticulum utiliza una instancia compartida internamente. Para comunicarse con otro proceso RNS en este host, use un cliente TCP a 127.0.0.1 con el puerto de la instancia, o use un módulo de interfaz externo.",
"loopback_local_docs_hint": "Vea el cap\u00edtulo Interfaces para tipos admitidos.",
"loopback_local_docs_hint": "Vea el capítulo Interfaces para tipos admitidos.",
"loopback_local_docs_link": "Abrir manual Reticulum incluido",
"custom_external_intro": "Tipos como WeaveInterface se cargan si Reticulum encuentra un archivo de m\u00f3dulo coincidente (TypeName.py) en interfacepath con interface_class (cargador externo RNS). Las opciones JSON se fusionan en la secci\u00f3n de interfaz de su archivo de configuraci\u00f3n.",
"custom_external_intro": "Tipos como WeaveInterface se cargan si Reticulum encuentra un archivo de módulo coincidente (TypeName.py) en interfacepath con interface_class (cargador externo RNS). Las opciones JSON se fusionan en la sección de interfaz de su archivo de configuración.",
"custom_external_type_label": "Nombre del tipo de interfaz",
"custom_external_type_placeholder": "WeaveInterface",
"custom_external_json_label": "Opciones adicionales (objeto JSON)",
"custom_external_json_placeholder": "\"listen_ip\": \"0.0.0.0\", \"listen_port\": 4242",
"custom_external_docs_hint": "Vea el manual Interfaces sobre tipos nativos y m\u00f3dulos externos.",
"custom_external_docs_hint": "Vea el manual Interfaces sobre tipos nativos y módulos externos.",
"custom_external_docs_link": "Abrir manual Reticulum incluido",
"custom_external_type_required": "Introduzca el nombre del tipo de interfaz Reticulum.",
"custom_external_json_invalid": "JSON no v\u00e1lido para opciones adicionales.",
"custom_external_json_invalid": "JSON no válido para opciones adicionales.",
"failed_save_discovery": "Failed to save discovery settings",
"no_interfaces_found_config": "No se encontraron interfaces en el archivo de configuración seleccionado",
"failed_parse_config": "Failed to parse configuración file",
@@ -1476,6 +1482,7 @@
"type": "Tipo",
"user": "Usuario",
"node": "Node",
"blocked_destinations": "Destinos bloqueados",
"banishment_lifted": "El destierro se levantó con éxito",
"failed_lift_banishment": "Failed to lift banishment"
},
+9 -2
View File
@@ -153,7 +153,7 @@
"local_message_auto_delete_unit_days": "jours",
"local_message_auto_delete_unit_months": "mois (30 jours chacun)",
"local_message_auto_delete_month_note": "Un mois compte comme 30 jours. Désactivé par défaut.",
"browse_nodes": "Parcourir les n\u0153uds",
"browse_nodes": "Parcourir les nœuds",
"propagation_nodes_description": "Gardez les conversations qui circulent même lorsque les pairs sont hors ligne.",
"nodes_info_1": "Les nœuds de propagation maintiennent les messages en toute sécurité jusqu'à ce que les destinataires se synchronisent à nouveau.",
"nodes_info_2": "Des nœuds s'unissent pour distribuer des charges utiles chiffrées.",
@@ -357,7 +357,13 @@
"telemetry_trust_revoke": "Revoquer la confiance en télémétrie",
"telemetry_trust_grant": "Confiance pour la télémétrie",
"location_manage_desc": "Gérez comment votre emplacement est partagé.",
"restart_rns": "Redémarrer RNS"
"restart_rns": "Redémarrer RNS",
"flood_protection": "Flood Protection",
"flood_protection_description": "Automatically raise the inbound stamp cost when receiving too many messages per minute from many sources. This makes coordinated spam attacks computationally expensive while keeping normal conversations affordable.",
"flood_protection_enabled": "Enable auto-adjusting stamp cost",
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)"
},
"common": {
"open": "Ouvrir",
@@ -1476,6 +1482,7 @@
"type": "Type",
"user": "Utilisateur",
"node": "Noeud",
"blocked_destinations": "Destinations bloquées",
"banishment_lifted": "Interdiction levée avec succès",
"failed_lift_banishment": "Échec à la levée du bannissement"
},
+8 -1
View File
@@ -357,7 +357,13 @@
"telemetry_trust_revoke": "Revoca Fiducia Telemetrica",
"telemetry_trust_grant": "Fidati per la Telemetria",
"location_manage_desc": "Gestisci come viene condivisa la tua posizione.",
"restart_rns": "Riavvia RNS"
"restart_rns": "Riavvia RNS",
"flood_protection": "Flood Protection",
"flood_protection_description": "Automatically raise the inbound stamp cost when receiving too many messages per minute from many sources. This makes coordinated spam attacks computationally expensive while keeping normal conversations affordable.",
"flood_protection_enabled": "Enable auto-adjusting stamp cost",
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)"
},
"common": {
"open": "Apri",
@@ -1528,6 +1534,7 @@
"type": "Tipo",
"user": "Utente",
"node": "Nodo",
"blocked_destinations": "Destinazioni bloccate",
"banishment_lifted": "Esilio revocato con successo",
"failed_lift_banishment": "Impossibile revocare l'esilio"
},
+8 -1
View File
@@ -357,7 +357,13 @@
"telemetry_trust_revoke": "Revoce Telemetrie Trust",
"telemetry_trust_grant": "Vertrouwen voor telemetrie",
"location_manage_desc": "Beheer hoe uw locatie wordt gedeeld.",
"restart_rns": "RNS herstarten"
"restart_rns": "RNS herstarten",
"flood_protection": "Flood Protection",
"flood_protection_description": "Automatically raise the inbound stamp cost when receiving too many messages per minute from many sources. This makes coordinated spam attacks computationally expensive while keeping normal conversations affordable.",
"flood_protection_enabled": "Enable auto-adjusting stamp cost",
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)"
},
"common": {
"open": "Open",
@@ -1476,6 +1482,7 @@
"type": "Type",
"user": "Gebruiker",
"node": "Knooppunt",
"blocked_destinations": "Geblokkeerde bestemmingen",
"banishment_lifted": "Verbanning succesvol opgeheven",
"failed_lift_banishment": "Kon verbanning niet opheffen"
},
+8 -1
View File
@@ -357,7 +357,13 @@
"telemetry_trust_revoke": "Отозвать доверие телеметрии",
"telemetry_trust_grant": "Доверять телеметрии",
"location_manage_desc": "Управление тем, как передается ваше местоположение.",
"restart_rns": "Перезапуск RNS"
"restart_rns": "Перезапуск RNS",
"flood_protection": "Flood Protection",
"flood_protection_description": "Automatically raise the inbound stamp cost when receiving too many messages per minute from many sources. This makes coordinated spam attacks computationally expensive while keeping normal conversations affordable.",
"flood_protection_enabled": "Enable auto-adjusting stamp cost",
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)"
},
"common": {
"open": "Открыть",
@@ -2699,6 +2705,7 @@
"type": "Type",
"user": "User",
"node": "Node",
"blocked_destinations": "Заблокированные направления",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment"
}
+8 -1
View File
@@ -357,7 +357,13 @@
"telemetry_trust_revoke": "撤销遥测信任",
"telemetry_trust_grant": "授予遥测信任",
"location_manage_desc": "管理您的位置共享方式。",
"restart_rns": "重启 RNS"
"restart_rns": "重启 RNS",
"flood_protection": "Flood Protection",
"flood_protection_description": "Automatically raise the inbound stamp cost when receiving too many messages per minute from many sources. This makes coordinated spam attacks computationally expensive while keeping normal conversations affordable.",
"flood_protection_enabled": "Enable auto-adjusting stamp cost",
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)"
},
"common": {
"open": "打开",
@@ -1476,6 +1482,7 @@
"type": "类型",
"user": "用户",
"node": "节点",
"blocked_destinations": "被阻止的目的地",
"banishment_lifted": "放逐已解除",
"failed_lift_banishment": "解除放逐失败"
},
@@ -55,26 +55,26 @@ firejail --noprofile --whitelist="$DATA" \
`--noprofile` disables many Firejail restrictions; treat it as a stepping stone, not the final hardening.
### From source with Poetry
### From source with UV
Poetry needs the project tree and the virtualenv. Example:
```bash
cd /path/to/reticulum-meshchatX
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/meshchatx-sandbox"
VENV="$(poetry env info -p)"
VENV="$(pwd)/.venv"
mkdir -p "$DATA/storage" "$DATA/.reticulum"
firejail --quiet \
--whitelist="$(pwd)" \
--whitelist="$VENV" \
--whitelist="$DATA" \
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1 \
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1 \
--storage-dir="$DATA/storage" \
--reticulum-config-dir="$DATA/.reticulum"
```
You may need extra `--whitelist=` entries if Poetry or dependencies read config elsewhere (for example under `$HOME/.config`).
You may need extra `--whitelist=` entries if UV or dependencies read config elsewhere (for example under `$HOME/.config`).
### USB serial (RNode or similar)
@@ -116,14 +116,14 @@ Notes:
- If `meshchatx` lives only inside a venv that is **not** under `$DATA`, the read-only root still allows **reading** that path; you do not have to bind-mount the venv separately unless you also need writes there.
- Distributions that merge `/` and `/usr` (merged-usr) still work with `--ro-bind / /` on typical glibc setups. If `bwrap` fails with missing library paths, add the extra `--ro-bind` lines your distro documents (for example `/lib64`).
### From source with Poetry
### From source with UV
Bind the repository and the Poetry venv read-only, and keep `DATA` writable:
Bind the repository and the UV venv read-only, and keep `DATA` writable:
```bash
cd /path/to/reticulum-meshchatX
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/meshchatx-sandbox"
VENV="$(poetry env info -p)"
VENV="$(pwd)/.venv"
mkdir -p "$DATA/storage" "$DATA/.reticulum"
PROJ="$(pwd)"
@@ -140,12 +140,12 @@ exec bwrap \
--uid "$(id -u)" --gid "$(id -g)" \
--setenv PATH "$VENV/bin:$PATH" \
--chdir "$PROJ" \
poetry run python -m meshchatx.meshchat --headless --host 127.0.0.1 \
uv run python -m meshchatx.meshchat --headless --host 127.0.0.1 \
--storage-dir="$DATA/storage" \
--reticulum-config-dir="$DATA/.reticulum"
```
`poetry` itself must be reachable on `PATH` inside the sandbox (often under `/usr` or `$HOME/.local/bin`, both visible with `--ro-bind / /`). If `poetry run` fails because it cannot read `~/.config/poetry`, add a read-only bind for that directory or invoke the venv interpreter directly instead of `poetry run`:
`uv` itself must be reachable on `PATH` inside the sandbox (often under `/usr` or `$HOME/.local/bin`, both visible with `--ro-bind / /`). If `uv run` fails because it cannot read `~/.cache/uv`, add a read-only bind for that directory or invoke the venv interpreter directly instead of `uv run`:
```bash
exec bwrap \
@@ -66,11 +66,11 @@ corepack prepare pnpm@latest --activate
```
git clone https://git.quad4.io/RNS-Things/MeshChatX.git
cd MeshChatX
pip install poetry
poetry install
pip install uv
uv sync --group dev
pnpm install
pnpm run build-frontend
poetry build -f wheel
uv build --wheel
pip install dist/*.whl
```
Generated
-3379
View File
File diff suppressed because it is too large Load Diff
+16 -27
View File
@@ -21,10 +21,10 @@ classifiers = [
]
dependencies = [
"aiohttp>=3.13.5",
"lxmf>=0.9.6",
"lxmf>=0.9.7",
"psutil>=7.2.2",
"bleak==3.0.1",
"rns>=1.2.3",
"rns>=1.2.5",
"websockets>=16.0",
"bcrypt>=5.0.0,<6.0.0",
"aiohttp-session>=2.12.1,<3.0.0",
@@ -46,31 +46,6 @@ lxmfy = "lxmfy.cli:main"
[project.urls]
Homepage = "https://git.quad4.io/RNS-Things/MeshChatX"
[tool.poetry]
packages = [
{include = "meshchatx"},
{include = "lxmfy", from = "vendor/lxmfy"},
]
[tool.poetry.dependencies]
python = ">=3.11"
lxst = ">=0.4.6"
[tool.poetry.group.dev.dependencies]
cx-freeze = ">=7.0.0"
pygments = ">=2.20.0"
pytest = ">=9.0.2,<10.0.0"
pytest-asyncio = ">=1.3.0,<2.0.0"
pytest-cov = ">=7.0.0,<8.0.0"
hypothesis = ">=6.151.9"
ruff = ">=0.14.0"
jsonschema = "^4.26.0"
mutmut = {version = "^3.5.0", python = "<4.0"}
pytest-xdist = "^3.8.0"
[[tool.poetry.include]]
path = "logo"
[tool.setuptools.packages.find]
where = [".", "vendor/lxmfy"]
include = ["meshchatx*", "lxmfy*"]
@@ -87,6 +62,20 @@ meshchatx = ["public/repository-server-bundled/**"]
requires = ["setuptools>=65.0", "wheel"]
build-backend = "setuptools.build_meta"
[dependency-groups]
dev = [
"cx-freeze>=7.0.0",
"hypothesis>=6.152.4",
"jsonschema>=4.26.0",
"pygments>=2.20.0",
"pytest>=9.0.3",
"pytest-asyncio>=0.25.0",
"pytest-cov>=7.1.0",
"pytest-xdist>=3.8.0",
"ruff>=0.14.0",
"mutmut>=3.5.0; python_version < '4.0'",
]
[tool.ruff]
exclude = [
".git",
+1
View File
@@ -4,6 +4,7 @@ python_files = test_*.py
python_functions = test_*
markers =
integration: optional tests (live network, subprocess Reticulum, etc.)
long_running: multi-minute soak tests (set MESHCHAT_LONG_TEST_SECONDS; see test_long_running_stress.py)
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
filterwarnings =
+2 -2
View File
@@ -11,7 +11,7 @@ cryptography==46.0.7 ; python_version >= "3.11"
frozenlist==1.8.0 ; python_version >= "3.11"
idna==3.11 ; python_version >= "3.11"
jaraco-context==6.1.2 ; python_version >= "3.11"
lxmf==0.9.5 ; python_version >= "3.11"
lxmf==0.9.7 ; python_version >= "3.11"
lxst==0.4.6 ; python_version >= "3.11"
miniaudio==1.70 ; python_version >= "3.11"
multidict==6.7.1 ; python_version >= "3.11"
@@ -23,7 +23,7 @@ pycodec2==4.1.1 ; python_version >= "3.11"
pycparser==3.0 ; python_version >= "3.11"
pyserial==3.5 ; python_version >= "3.11"
bleak==3.0.1 ; python_version >= "3.11"
rns==1.2.3 ; python_version >= "3.11"
rns==1.2.5 ; python_version >= "3.11"
typing-extensions==4.15.0 ; python_version >= "3.11" and python_version < "3.13"
websockets==16.0 ; python_version >= "3.11"
yarl==1.23.0 ; python_version >= "3.11"
+55 -3
View File
@@ -182,7 +182,7 @@ function shouldRefreshLicenseArtifacts(repoRoot) {
const inputFiles = [
path.join(repoRoot, "pyproject.toml"),
path.join(repoRoot, "poetry.lock"),
path.join(repoRoot, "uv.lock"),
path.join(repoRoot, "package.json"),
path.join(repoRoot, "pnpm-lock.yaml"),
path.join(repoRoot, "meshchatx", "src", "backend", "licenses_collector.py"),
@@ -199,12 +199,50 @@ function shouldRefreshLicenseArtifacts(repoRoot) {
return newestInput >= oldestOutput;
}
function verifyBinaryArchitecture(buildDir, expectedArch, targetName) {
const binaryPath = path.join(buildDir, targetName);
if (!fs.existsSync(binaryPath)) {
console.warn(`Binary not found at ${binaryPath}, skipping architecture verification.`);
return true;
}
let output = "";
try {
const result = spawnSync("file", ["--brief", "--no-pad", binaryPath], {
encoding: "utf-8",
shell: false,
});
if (result.error || result.status !== 0) {
console.warn(`Could not verify binary architecture: ${result.error?.message || `exit ${result.status}`}`);
return true;
}
output = result.stdout.toLowerCase();
} catch (e) {
console.warn(`Architecture verification failed: ${e.message}`);
return true;
}
const isArm64 = output.includes("aarch64") || output.includes("arm64");
const isX64 = output.includes("x86-64") || output.includes("x86_64") || output.includes("amd64");
if (expectedArch === "arm64" && !isArm64) {
console.error(`Architecture mismatch: expected arm64 but binary is not arm64 (${output.trim()}).`);
return false;
}
if (expectedArch === "x64" && !isX64) {
console.error(`Architecture mismatch: expected x64 but binary is not x64 (${output.trim()}).`);
return false;
}
return true;
}
try {
const platform = process.env.PLATFORM || process.platform;
const arch = process.env.ARCH || process.arch;
const isWin = platform === "win32" || platform === "win";
const isDarwin = platform === "darwin";
const targetName = isWin ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
const rosettaX64 = isDarwin && arch === "x64" && process.arch === "arm64";
let platformFolder = "linux";
if (isWin) {
@@ -215,8 +253,20 @@ try {
const buildDirRelative = `build/exe/${platformFolder}-${arch}`;
const buildDir = path.join(__dirname, "..", buildDirRelative);
if (arch !== process.arch && !rosettaX64 && !process.env.PYTHON_CMD) {
console.error(
`Cross-compilation detected (host: ${process.arch}, target: ${arch}).\n` +
`cx_Freeze produces binaries for the architecture of the Python interpreter it runs under.\n` +
`To build the backend for ${arch}, you must either:\n` +
` - Build natively on ${arch} hardware (or in a VM/container of that architecture).\n` +
` - Set PYTHON_CMD to a ${arch} Python interpreter (with Poetry dependencies installed).\n` +
` - Use Docker with QEMU/binfmt support (e.g., docker run --platform ${platformFolder}/${arch}).`
);
process.exit(1);
}
// Allow overriding the python command
const pythonCmd = process.env.PYTHON_CMD || "poetry run python";
const pythonCmd = process.env.PYTHON_CMD || "uv run python";
console.log(
`Building backend for ${platform} (target: ${targetName}, output: ${buildDirRelative}) using: ${pythonCmd}`
@@ -237,7 +287,6 @@ try {
let spawnCmd = cmd;
let spawnArgs = licensesArgs;
const rosettaX64 = isDarwin && arch === "x64" && process.arch === "arm64";
if (rosettaX64) {
spawnCmd = "arch";
spawnArgs = ["-x86_64", cmd, ...licensesArgs];
@@ -278,6 +327,9 @@ try {
if (isDarwin) {
stripPythonBytecodeArtifacts(buildDir);
}
if (!verifyBinaryArchitecture(buildDir, arch, targetName)) {
process.exit(1);
}
const manifestPath = path.join(buildDir, "backend-manifest.json");
const skipManifest =
process.env.MESHCHATX_SKIP_BACKEND_MANIFEST === "1" ||
+8 -3
View File
@@ -5,7 +5,7 @@ set -euo pipefail
cd /src
export POETRY_VERSION="${POETRY_VERSION:-2.3.4}"
export UV_VERSION="${UV_VERSION:-0.11.12}"
export PNPM_VERSION="${PNPM_VERSION:-10.33.0}"
apt-get update -y
@@ -26,13 +26,18 @@ if ! command -v node >/dev/null 2>&1; then
fi
TASK_VER="${TASK_VERSION:-3.46.4}"
curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VER}/task_linux_amd64.tar.gz" \
_TASK_ARCH="$(uname -m)"
case "$_TASK_ARCH" in
x86_64) _TASK_ARCH="amd64" ;;
aarch64) _TASK_ARCH="arm64" ;;
esac
curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VER}/task_linux_${_TASK_ARCH}.tar.gz" \
| tar xz -C /usr/local/bin task
corepack enable
corepack prepare "pnpm@${PNPM_VERSION}" --activate
bash scripts/ci/github-install-poetry.sh
bash scripts/ci/github-install-uv.sh
export TRIVY_SBOM=0
+21 -11
View File
@@ -2,7 +2,7 @@
# Generate Docker image tags from git context.
#
# Usage: docker-tags.sh <image_name> [output_file]
# Environment: GITEA_REF / GITHUB_REF, GITEA_REF_NAME / GITHUB_REF_NAME
# Environment: GITEA_REF / GITHUB_REF, GITEA_REF_NAME / GITHUB_REF_NAME, TAG_SUFFIX
#
# The output file contains one `-t registry/image:tag` per line,
# suitable for passing directly to `docker buildx build`.
@@ -10,39 +10,49 @@ set -eu
IMAGE="$1"
OUTPUT="${2:-/tmp/docker-tags.txt}"
SUFFIX="${TAG_SUFFIX:-}"
: > "$OUTPUT"
_suffix_tag() {
local tag="$1"
if [ -n "$SUFFIX" ]; then
printf '%s' "${tag}${SUFFIX}"
else
printf '%s' "$tag"
fi
}
SHA="$(git rev-parse --short HEAD)"
REF="${GITEA_REF:-${GITHUB_REF:-}}"
BRANCH="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}}"
echo "-t ${IMAGE}:sha-${SHA}" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "sha-${SHA}")" >> "$OUTPUT"
case "$BRANCH" in
master|main)
echo "-t ${IMAGE}:latest" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "latest")" >> "$OUTPUT"
;;
dev)
echo "-t ${IMAGE}:dev" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "dev")" >> "$OUTPUT"
;;
esac
case "$REF" in
refs/tags/v*)
VERSION="${REF#refs/tags/v}"
echo "-t ${IMAGE}:latest" >> "$OUTPUT"
echo "-t ${IMAGE}:${VERSION}" >> "$OUTPUT"
echo "-t ${IMAGE}:v${VERSION}" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "latest")" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "${VERSION}")" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "v${VERSION}")" >> "$OUTPUT"
MAJOR_MINOR="$(echo "$VERSION" | cut -d. -f1-2)"
if [ "$MAJOR_MINOR" != "$VERSION" ]; then
echo "-t ${IMAGE}:${MAJOR_MINOR}" >> "$OUTPUT"
echo "-t ${IMAGE}:v${MAJOR_MINOR}" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "${MAJOR_MINOR}")" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "v${MAJOR_MINOR}")" >> "$OUTPUT"
fi
;;
refs/tags/*)
TAG="${REF#refs/tags/}"
echo "-t ${IMAGE}:latest" >> "$OUTPUT"
echo "-t ${IMAGE}:${TAG}" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "latest")" >> "$OUTPUT"
echo "-t ${IMAGE}:$(_suffix_tag "${TAG}")" >> "$OUTPUT"
;;
esac
+10 -12
View File
@@ -5,16 +5,14 @@ set -euo pipefail
# shellcheck source=scripts/ci/priv.sh
. "$(dirname "$0")/priv.sh"
run_priv dpkg --add-architecture i386 || true
_HOST_ARCH="$(uname -m)"
if [ "$_HOST_ARCH" = "x86_64" ]; then
run_priv dpkg --add-architecture i386 || true
fi
run_priv apt-get update -y
run_priv apt-get install -y --no-install-recommends \
patchelf \
libopusfile0 \
espeak-ng \
zip \
rpm \
elfutils \
fakeroot \
file \
libc6:i386 \
libstdc++6:i386
_PKGS="patchelf libopusfile0 espeak-ng zip rpm elfutils fakeroot file"
if [ "$_HOST_ARCH" = "x86_64" ]; then
_PKGS="$_PKGS libc6:i386 libstdc++6:i386"
fi
run_priv apt-get install -y --no-install-recommends $_PKGS
+25 -10
View File
@@ -12,23 +12,38 @@ cd "$ROOT"
mkdir -p release-assets
HOST_ARCH="$(uname -m)"
case "$HOST_ARCH" in
x86_64) NATIVE_ARCH="x64" ;;
aarch64|arm64) NATIVE_ARCH="arm64" ;;
*) NATIVE_ARCH="$HOST_ARCH" ;;
esac
if [ "${SKIP_WHEEL:-0}" != 1 ]; then
echo "Building Python wheel..."
task build:wheel
if [ "$NATIVE_ARCH" = "x64" ]; then
echo "Building Python wheel..."
task build:wheel
else
echo "Skipping wheel on $NATIVE_ARCH runner (pure-Python wheel built on x64)."
fi
else
echo "Skipping wheel (SKIP_WHEEL=1)."
fi
if [ "${SKIP_ELECTRON:-0}" != 1 ]; then
echo "Electron linux x64..."
pnpm run dist:linux-x64
if [ "$NATIVE_ARCH" = "x64" ]; then
echo "Electron linux x64..."
pnpm run dist:linux-x64
elif [ "$NATIVE_ARCH" = "arm64" ]; then
echo "Electron linux arm64..."
pnpm run dist:linux-arm64
fi
echo "Electron linux arm64..."
pnpm run dist:linux-arm64
echo "RPM (best-effort)..."
if ! task dist:fe:rpm; then
echo "RPM build failed or skipped; continuing." >&2
if [ "$NATIVE_ARCH" = "x64" ]; then
echo "RPM (best-effort)..."
if ! task dist:fe:rpm; then
echo "RPM build failed or skipped; continuing." >&2
fi
fi
else
echo "Skipping Electron packages (SKIP_ELECTRON=1)."
@@ -23,10 +23,6 @@ if [ -z "${GH_REPO:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then
export GH_REPO="$GITHUB_REPOSITORY"
fi
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --draft --title "$TAG" --notes "Automated draft release. Review assets and provenance before publishing."
fi
mapfile -d '' -t all < <(find "$DIR" -type f -print0)
skip_noise() {
@@ -74,4 +70,36 @@ for f in "${all[@]}"; do
done
mapfile -t files < <(find "$STAGE" -type f)
# Build SHA256 section for release notes
sha256_table=""
for f in "${files[@]}"; do
b=$(basename "$f")
hash=$(sha256sum "$f" | awk '{print $1}')
sha256_table="${sha256_table}\n| ${b} | \`${hash}\` |"
done
notes=$(cat <<EOF
Automated draft release. Review assets and provenance before publishing.
## SHA256 Checksums
| Asset | SHA256 |
|-------|--------|
${sha256_table}
## Verification
- **Cosign bundles** (\`.cosign.bundle\`) are attached for keyless sigstore verification.
- **SLSA provenance** (\`.intoto.jsonl\`) is available for supply-chain attestation.
- Or verify manually using the SHA256 table above.
EOF
)
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --draft --title "$TAG" --notes "$notes"
else
gh release edit "$TAG" --notes "$notes"
fi
gh release upload "$TAG" "${files[@]}" --clobber
+8 -8
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Install Python (Poetry) and Node (pnpm) dependencies for native Electron builds.
# Install Python (UV) and Node (pnpm) dependencies for native Electron builds.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
@@ -28,13 +28,13 @@ if [[ "$(uname -s)" == "Linux" ]] && command -v apt-get >/dev/null 2>&1; then
run_priv apt-get install -y libopus0 libogg0
fi
python -m poetry check --lock
python -m poetry install --no-interaction --no-ansi
python -m poetry run python scripts/patch_lxst_pyogg_ogg_ctypes.py
uv lock --check
uv sync --group dev
uv run python scripts/patch_lxst_pyogg_ogg_ctypes.py
if [[ "$(uname -s)" == "Darwin" ]]; then
if poetry run python -c "import platform, sys; sys.exit(0 if platform.machine() == 'arm64' else 1)"; then
_miniaudio_state="$(poetry run python -c "
if uv run python -c "import platform, sys; sys.exit(0 if platform.machine() == 'arm64' else 1)"; then
_miniaudio_state="$(uv run python -c "
import importlib.util
import pathlib
import subprocess
@@ -78,10 +78,10 @@ else:
export ARCHFLAGS="-arch arm64"
export CFLAGS="-arch arm64"
export CXXFLAGS="-arch arm64"
poetry run python -m pip install --force-reinstall --no-cache-dir --no-binary miniaudio "miniaudio>=1.70,<2"
uv run python -m pip install --force-reinstall --no-cache-dir --no-binary miniaudio "miniaudio>=1.70,<2"
)
fi
if ! poetry run python -c "
if ! uv run python -c "
import importlib.util
import pathlib
import subprocess
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# Install Poetry from PyPI with an explicit version (no third-party installers).
# Set POETRY_VERSION to override the default.
set -euo pipefail
POETRY_VERSION="${POETRY_VERSION:-2.3.4}"
python -m pip install --disable-pip-version-check --upgrade pip
python -m pip install --disable-pip-version-check "poetry==${POETRY_VERSION}"
python -m poetry --version
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Install UV (Python package manager) from PyPI with an explicit version.
# Set UV_VERSION to override the default.
set -euo pipefail
UV_VERSION="${UV_VERSION:-0.11.12}"
python -m pip install --disable-pip-version-check --upgrade pip
python -m pip install --disable-pip-version-check "uv==${UV_VERSION}"
uv --version
+5 -5
View File
@@ -31,8 +31,8 @@ verify_upstream_deb() {
ensure_cosign
export COSIGN_YES="${COSIGN_YES:-true}"
curl -fsSL -o /tmp/trivy_checksums.txt "${TRIVY_RELEASE_BASE}/trivy_${TRIVY_VERSION}_checksums.txt"
curl -fsSL -o /tmp/trivy_checksums.sigstore.json "${TRIVY_RELEASE_BASE}/trivy_${TRIVY_VERSION}_checksums.txt.sigstore.json"
curl -fsSL --retry 5 --retry-delay 2 -o /tmp/trivy_checksums.txt "${TRIVY_RELEASE_BASE}/trivy_${TRIVY_VERSION}_checksums.txt"
curl -fsSL --retry 5 --retry-delay 2 -o /tmp/trivy_checksums.sigstore.json "${TRIVY_RELEASE_BASE}/trivy_${TRIVY_VERSION}_checksums.txt.sigstore.json"
cosign verify-blob /tmp/trivy_checksums.txt --bundle /tmp/trivy_checksums.sigstore.json \
--certificate-identity-regexp="${TRIVY_CERT_IDENTITY_RE}" \
--certificate-oidc-issuer-regexp="${TRIVY_CERT_ISSUER_RE}"
@@ -43,10 +43,10 @@ verify_upstream_deb() {
exit 1
fi
curl -fsSL -o /tmp/trivy.deb "${TRIVY_RELEASE_BASE}/${DEB_BASE}"
curl -fsSL --retry 5 --retry-delay 2 -o /tmp/trivy.deb "${TRIVY_RELEASE_BASE}/${DEB_BASE}"
echo "${EXPECTED_SHA} /tmp/trivy.deb" | sha256sum -c
curl -fsSL -o /tmp/trivy.deb.sigstore.json "${TRIVY_RELEASE_BASE}/${DEB_BASE}.sigstore.json"
curl -fsSL --retry 5 --retry-delay 2 -o /tmp/trivy.deb.sigstore.json "${TRIVY_RELEASE_BASE}/${DEB_BASE}.sigstore.json"
cosign verify-blob /tmp/trivy.deb --bundle /tmp/trivy.deb.sigstore.json \
--certificate-identity-regexp="${TRIVY_CERT_IDENTITY_RE}" \
--certificate-oidc-issuer-regexp="${TRIVY_CERT_ISSUER_RE}"
@@ -59,7 +59,7 @@ if [ -n "${TRIVY_DEB_URL:-}" ]; then
echo "setup-trivy.sh: TRIVY_DEB_URL requires TRIVY_DEB_SHA256" >&2
exit 1
fi
curl -fsSL -o /tmp/trivy.deb "${TRIVY_DEB_URL}"
curl -fsSL --retry 5 --retry-delay 2 -o /tmp/trivy.deb "${TRIVY_DEB_URL}"
echo "${TRIVY_DEB_SHA256} /tmp/trivy.deb" | sha256sum -c
else
arch="$(uname -m)"
+1 -1
View File
@@ -17,7 +17,7 @@ cleanup() {
}
trap cleanup EXIT INT TERM
poetry run python -m meshchatx.meshchat &
uv run python -m meshchatx.meshchat &
BE_PID=$!
sleep "${DEV_BACKEND_WAIT:-1}"
+1 -1
View File
@@ -25,7 +25,7 @@ trap cleanup EXIT INT TERM
echo "E2E: starting MeshChat backend on 127.0.0.1:${BACKEND_PORT} (isolated storage under ${TMPDIR})"
poetry run python -m meshchatx.meshchat \
uv run python -m meshchatx.meshchat \
--headless \
--no-https \
--host 127.0.0.1 \
+1 -1
View File
@@ -42,7 +42,7 @@ wine_wrap wine "./$GIT_EXE" /VERYSILENT /NORESTART
echo "Installing build dependencies in Wine Python..."
wine_wrap wine C:/Python314/python.exe -m pip install --upgrade pip
wine_wrap wine C:/Python314/python.exe -m pip install cx_Freeze poetry
wine_wrap wine C:/Python314/python.exe -m pip install cx_Freeze
if [ -f "requirements.txt" ]; then
wine_wrap wine C:/Python314/python.exe -m pip install -r requirements.txt
fi
+1
View File
@@ -221,6 +221,7 @@ def mock_app(db, tmp_path, temp_db):
# a live provider for the same path as the db fixture.
app.database = Database(temp_db)
app.current_context.config = ConfigManager(app.database)
app.config = app.current_context.config
app.websocket_broadcast = MagicMock(side_effect=lambda data: None)
yield app
@@ -248,6 +248,10 @@
"method": "POST",
"path": "/api/v1/favourites/add"
},
{
"method": "POST",
"path": "/api/v1/favourites/import"
},
{
"method": "DELETE",
"path": "/api/v1/favourites/{destination_hash}"
+91
View File
@@ -0,0 +1,91 @@
# SPDX-License-Identifier: 0BSD
import pytest
from meshchatx.meshchat import ReticulumMeshChat
def test_disable_rnode_interfaces_on_android(tmp_path):
config_path = tmp_path / "config"
config_path.write_text(
"""[reticulum]
enable_transport = False
[interfaces]
[[RNode Serial]]
type = RNodeInterface
interface_enabled = True
port = /dev/ttyUSB0
frequency = 867200000
bandwidth = 125000
txpower = 7
spreadingfactor = 8
codingrate = 5
[[TCP Client]]
type = TCPClientInterface
interface_enabled = True
target_host = localhost
target_port = 4242
""",
encoding="utf-8",
)
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"meshchatx.meshchat._is_chaquopy_android",
lambda: True,
)
modified = ReticulumMeshChat._disable_rnode_interfaces_on_android(
str(config_path),
)
assert modified is True
content = config_path.read_text(encoding="utf-8")
assert "interface_enabled = false" in content
assert "type = RNodeInterface" in content
assert "type = TCPClientInterface" in content
def test_disable_rnode_interfaces_skips_when_not_android(tmp_path):
config_path = tmp_path / "config"
config_path.write_text(
"""[interfaces]
[[RNode Serial]]
type = RNodeInterface
interface_enabled = True
""",
encoding="utf-8",
)
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"meshchatx.meshchat._is_chaquopy_android",
lambda: False,
)
modified = ReticulumMeshChat._disable_rnode_interfaces_on_android(
str(config_path),
)
assert modified is False
content = config_path.read_text(encoding="utf-8")
assert "interface_enabled = True" in content
def test_disable_rnode_interfaces_handles_missing_config():
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"meshchatx.meshchat._is_chaquopy_android",
lambda: True,
)
modified = ReticulumMeshChat._disable_rnode_interfaces_on_android(
"/nonexistent/config",
)
assert modified is False
def test_get_android_external_files_dir_returns_none_on_desktop():
from meshchatx.android_push_bridge import _get_android_external_files_dir
assert _get_android_external_files_dir() is None
+13
View File
@@ -53,6 +53,19 @@ def _cleanup(db, path):
pass
def test_trim_announces_for_aspect_noop_when_max_rows_below_one():
db = path = None
try:
db, path = _new_db()
aspect = "lxmf.delivery"
_insert(db, "01" * 16, aspect, "2000-01-01T00:00:00Z")
_insert(db, "02" * 16, aspect, "2000-01-02T00:00:00Z")
db.announces.trim_announces_for_aspect(aspect, 0)
assert db.announces.get_announce_count_by_aspect(aspect) == 2
finally:
_cleanup(db, path)
def test_trim_announces_for_aspect_drops_oldest():
db = path = None
try:
+85
View File
@@ -166,6 +166,91 @@ def test_get_filtered_announces_resolves_default_limit(mock_db, mock_config):
assert 33 in params
def test_max_stored_clamps_to_one_million(mock_db, mock_config):
mock_config.announce_max_stored_lxmf_delivery.get.return_value = 9_999_999
manager = _make_manager(mock_db, mock_config)
assert manager._get_max_stored_for_aspect("lxmf.delivery") == 1_000_000
def test_trim_called_with_clamped_max_stored(mock_db, mock_config):
mock_config.announce_max_stored_lxmf_delivery.get.return_value = 5_000_000
manager = _make_manager(mock_db, mock_config)
reticulum = MagicMock()
identity = MagicMock()
identity.hash.hex.return_value = "ab" * 16
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
reticulum,
identity,
b"\x01" * 16,
"lxmf.delivery",
b"app_data",
b"packet_hash",
)
mock_db.announces.trim_announces_for_aspect.assert_called_once_with(
"lxmf.delivery",
1_000_000,
)
def test_max_stored_zero_skips_cap(mock_db, mock_config):
mock_config.announce_max_stored_lxmf_delivery.get.return_value = 0
manager = _make_manager(mock_db, mock_config)
assert manager._get_max_stored_for_aspect("lxmf.delivery") is None
def test_fetch_limit_clamps_to_hundred_thousand(mock_db, mock_config):
mock_config.announce_fetch_limit_lxmf_delivery = MagicMock()
mock_config.announce_fetch_limit_lxmf_delivery.get.return_value = 800_000
manager = _make_manager(mock_db, mock_config)
assert manager._get_fetch_limit_for_aspect("lxmf.delivery") == 100_000
def test_fetch_limit_invalid_falls_back_to_default(mock_db, mock_config):
mock_config.announce_fetch_limit_lxmf_delivery = MagicMock()
mock_config.announce_fetch_limit_lxmf_delivery.get.return_value = 0
manager = _make_manager(mock_db, mock_config)
assert manager._get_fetch_limit_for_aspect("lxmf.delivery") == 2500
def test_fetch_limit_unknown_aspect_returns_default(mock_db, mock_config):
manager = _make_manager(mock_db, mock_config)
assert manager._get_fetch_limit_for_aspect("unknown.aspect") == 2500
def test_fetch_limit_none_falls_back_to_default(mock_db, mock_config):
mock_config.announce_fetch_limit_lxmf_delivery = MagicMock()
mock_config.announce_fetch_limit_lxmf_delivery.get.return_value = None
manager = _make_manager(mock_db, mock_config)
assert manager._get_fetch_limit_for_aspect("lxmf.delivery") == 2500
def test_get_filtered_announces_uses_clamped_fetch_limit(mock_db, mock_config):
mock_config.announce_fetch_limit_lxmf_delivery = MagicMock()
mock_config.announce_fetch_limit_lxmf_delivery.get.return_value = 400_000
manager = _make_manager(mock_db, mock_config)
manager.get_filtered_announces(aspect="lxmf.delivery", limit=None)
args, _ = mock_db.provider.fetchall.call_args
_sql, params = args
assert 100_000 in params
def test_announce_handles_none_packet_hash(mock_db):
manager = AnnounceManager(mock_db)
reticulum = MagicMock()
@@ -0,0 +1,338 @@
# SPDX-License-Identifier: 0BSD
"""Integration tests: announce row caps via AnnounceManager + real SQLite."""
import os
import tempfile
from unittest.mock import MagicMock
import pytest
from meshchatx.src.backend.announce_manager import AnnounceManager
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.database.provider import DatabaseProvider
class _FakeIdentity:
__slots__ = ("_h",)
def __init__(self, identity_hex32: str):
self._h = bytes.fromhex(identity_hex32)
@property
def hash(self):
return self._h
def get_public_key(self):
return b"\xaa\xbb"
def _cleanup(db, path):
if db is not None:
try:
db.close()
except Exception:
pass
DatabaseProvider._instance = None
if path:
try:
os.unlink(path)
except OSError:
pass
for suffix in ("-wal", "-shm"):
try:
os.unlink(path + suffix)
except OSError:
pass
def _new_db():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
db = Database(path)
db.initialize()
return db, path
def _store_enabled_config(**max_stored):
"""Config mock with storage toggles on and configurable announce_max_stored_* .get() values."""
config = MagicMock()
for _k in (
"announce_store_lxmf_delivery",
"announce_store_lxst_telephony",
"announce_store_nomadnetwork_node",
"announce_store_lxmf_propagation",
"announce_store_git_repositories",
):
m = MagicMock()
m.get.return_value = True
setattr(config, _k, m)
for key, default in (
("announce_max_stored_lxmf_delivery", None),
("announce_max_stored_nomadnetwork_node", None),
("announce_max_stored_lxmf_propagation", None),
):
attr = MagicMock()
attr.get.return_value = max_stored.get(key, default)
setattr(config, key, attr)
return config
@pytest.fixture
def sqlite_db():
db, path = _new_db()
yield db, path
_cleanup(db, path)
def test_many_sequential_upserts_trims_to_max(sqlite_db):
db, _path = sqlite_db
max_keep = 12
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=max_keep)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
n_insert = 55
for i in range(n_insert):
dh = f"{i:032x}"
ident = _FakeIdentity(f"{i:032x}")
mgr.upsert_announce(
ret,
ident,
bytes.fromhex(dh),
"lxmf.delivery",
b"payload",
None,
)
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == max_keep
rows = db.announces.get_announces(aspect="lxmf.delivery")
kept = {r["destination_hash"] for r in rows}
expect = {f"{i:032x}" for i in range(n_insert - max_keep, n_insert)}
assert kept == expect
def test_aspect_max_limits_are_independent(sqlite_db):
db, _path = sqlite_db
cfg = _store_enabled_config(
announce_max_stored_lxmf_delivery=7,
announce_max_stored_nomadnetwork_node=4,
)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
for i in range(20):
dh = f"{i:032x}"
ident = _FakeIdentity(f"{i:032x}")
mgr.upsert_announce(
ret,
ident,
bytes.fromhex(dh),
"lxmf.delivery",
b"x",
None,
)
for i in range(15):
dh = f"{0x70000000 + i:032x}"
ident = _FakeIdentity(f"{0x71000000 + i:032x}")
mgr.upsert_announce(
ret,
ident,
bytes.fromhex(dh),
"nomadnetwork.node",
b"y",
None,
)
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == 7
assert db.announces.get_announce_count_by_aspect("nomadnetwork.node") == 4
def test_repeated_upsert_same_destination_does_not_expand_table(sqlite_db):
db, _path = sqlite_db
max_keep = 10
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=max_keep)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
primary_dest = "f" * 32
ident = _FakeIdentity("e" * 32)
for _ in range(80):
mgr.upsert_announce(
ret,
ident,
bytes.fromhex(primary_dest),
"lxmf.delivery",
b"v1",
None,
)
for i in range(25):
dh = f"{i:032x}"
oid = _FakeIdentity(f"1{i:031x}")
mgr.upsert_announce(
ret,
oid,
bytes.fromhex(dh),
"lxmf.delivery",
b"x",
None,
)
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == max_keep
dup_rows = db.provider.fetchall(
"""
SELECT destination_hash FROM announces WHERE aspect = ?
GROUP BY destination_hash HAVING COUNT(*) > 1
""",
("lxmf.delivery",),
)
assert dup_rows == []
def test_manager_trim_skips_contact_linked_identity(sqlite_db):
db, _path = sqlite_db
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=500)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
protected_idx = 3
contact_ih = f"{protected_idx:032x}"
for i in range(8):
dh = f"{i:032x}"
ident = _FakeIdentity(f"{i:032x}")
mgr.upsert_announce(
ret,
ident,
bytes.fromhex(dh),
"lxmf.delivery",
b"p",
None,
)
db.contacts.add_contact("peer", contact_ih)
cfg.announce_max_stored_lxmf_delivery.get.return_value = 3
mgr.upsert_announce(
ret,
_FakeIdentity("ffffffffffffffffffffffffffffffff"),
bytes.fromhex("ffffffffffffffffffffffffffffffff"),
"lxmf.delivery",
b"tick",
None,
)
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == 3
rows = db.announces.get_announces(aspect="lxmf.delivery")
hashes = {r["destination_hash"] for r in rows}
assert f"{protected_idx:032x}" in hashes
def test_trim_after_prefilled_table_overflow(sqlite_db):
"""Simulates a large announce backlog (direct DAO inserts), then one managed upsert."""
db, _path = sqlite_db
aspect = "lxmf.delivery"
for i in range(220):
dh = f"{i:032x}"
db.announces.upsert_announce(
{
"destination_hash": dh,
"aspect": aspect,
"identity_hash": f"{i:032x}",
"identity_public_key": "cHVibmtleQ==",
"app_data": None,
"rssi": None,
"snr": None,
"quality": None,
},
)
assert db.announces.get_announce_count_by_aspect(aspect) == 220
max_keep = 15
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=max_keep)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
mgr.upsert_announce(
ret,
_FakeIdentity("aa" * 16),
bytes.fromhex(f"{220:032x}"),
aspect,
b"flush",
None,
)
assert db.announces.get_announce_count_by_aspect(aspect) == max_keep
kept = {r["destination_hash"] for r in db.announces.get_announces(aspect=aspect)}
assert kept == {f"{i:032x}" for i in range(206, 221)}
def test_integration_respects_favourite_under_tight_cap(sqlite_db):
db, _path = sqlite_db
aspect = "lxmf.delivery"
favourite_dest = f"{5:032x}"
for i in range(24):
dh = f"{i:032x}"
db.announces.upsert_announce(
{
"destination_hash": dh,
"aspect": aspect,
"identity_hash": f"{i:032x}",
"identity_public_key": "cHVibmtleQ==",
"app_data": None,
"rssi": None,
"snr": None,
"quality": None,
},
)
db.announces.upsert_favourite(favourite_dest, "Pinned", aspect)
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=4)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
mgr.upsert_announce(
ret,
_FakeIdentity("bb" * 16),
bytes.fromhex(f"{100:032x}"),
aspect,
b"tight",
None,
)
rows = db.announces.get_announces(aspect=aspect)
hashes = {r["destination_hash"] for r in rows}
assert favourite_dest in hashes
assert f"{100:032x}" in hashes
def test_lxst_telephony_shares_lxmf_delivery_cap(sqlite_db):
db, _path = sqlite_db
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=6)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
for i in range(10):
dh = f"{0x60000000 + i:032x}"
mgr.upsert_announce(
ret,
_FakeIdentity(f"{0x61000000 + i:032x}"),
bytes.fromhex(dh),
"lxst.telephony",
b"t",
None,
)
assert db.announces.get_announce_count_by_aspect("lxst.telephony") == 6
kept = {
r["destination_hash"]
for r in db.announces.get_announces(aspect="lxst.telephony")
}
assert kept == {f"{0x60000000 + i:032x}" for i in range(4, 10)}
+218
View File
@@ -0,0 +1,218 @@
# SPDX-License-Identifier: 0BSD
"""SQLite integration: announce spam and bounded storage (anti-exhaustion).
Multi-minute soak scenarios live in ``test_long_running_stress.py`` (opt-in via
``MESHCHAT_LONG_TEST_SECONDS``).
"""
from __future__ import annotations
import os
import tempfile
from unittest.mock import MagicMock
import pytest
from meshchatx.src.backend.announce_manager import AnnounceManager
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.database.provider import DatabaseProvider
class _FakeIdentity:
__slots__ = ("_h",)
def __init__(self, identity_hex32: str):
self._h = bytes.fromhex(identity_hex32)
@property
def hash(self):
return self._h
def get_public_key(self):
return b"\xaa\xbb"
def _cleanup(db, path):
if db is not None:
try:
db.close()
except Exception:
pass
DatabaseProvider._instance = None
if path:
try:
os.unlink(path)
except OSError:
pass
for suffix in ("-wal", "-shm"):
try:
os.unlink(path + suffix)
except OSError:
pass
def _new_db():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
db = Database(path)
db.initialize()
return db, path
def _store_enabled_config(**max_stored):
config = MagicMock()
for _k in (
"announce_store_lxmf_delivery",
"announce_store_lxst_telephony",
"announce_store_nomadnetwork_node",
"announce_store_lxmf_propagation",
"announce_store_git_repositories",
):
m = MagicMock()
m.get.return_value = True
setattr(config, _k, m)
for key, default in (
("announce_max_stored_lxmf_delivery", None),
("announce_max_stored_nomadnetwork_node", None),
("announce_max_stored_lxmf_propagation", None),
):
attr = MagicMock()
attr.get.return_value = max_stored.get(key, default)
setattr(config, key, attr)
return config
@pytest.fixture
def sqlite_db():
db, path = _new_db()
yield db, path
_cleanup(db, path)
def test_spam_unique_destinations_stays_within_cap(sqlite_db):
db, _path = sqlite_db
cap = 48
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=cap)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
total = 650
for i in range(total):
dh = f"{i:032x}"
mgr.upsert_announce(
ret,
_FakeIdentity(f"{i:032x}"),
bytes.fromhex(dh),
"lxmf.delivery",
b"x",
None,
)
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == cap
def test_spam_same_destination_does_not_duplicate_rows(sqlite_db):
db, _path = sqlite_db
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=12)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
dest = bytes.fromhex("ab" * 16)
ident = _FakeIdentity("cd" * 16)
for _ in range(400):
mgr.upsert_announce(ret, ident, dest, "lxmf.delivery", b"y", None)
rows = db.provider.fetchall(
"SELECT COUNT(*) AS n FROM announces WHERE aspect = ? AND destination_hash = ?",
("lxmf.delivery", "ab" * 16),
)
assert rows[0]["n"] == 1
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == 1
def test_spam_interleaved_aspects_each_bounded(sqlite_db):
db, _path = sqlite_db
cfg = _store_enabled_config(
announce_max_stored_lxmf_delivery=15,
announce_max_stored_nomadnetwork_node=9,
announce_max_stored_lxmf_propagation=11,
)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
for round_i in range(120):
i = round_i * 3
mgr.upsert_announce(
ret,
_FakeIdentity(f"{i + 1:032x}"),
bytes.fromhex(f"{i + 1:032x}"),
"lxmf.delivery",
b"a",
None,
)
mgr.upsert_announce(
ret,
_FakeIdentity(f"{i + 2:032x}"),
bytes.fromhex(f"{i + 2:032x}"),
"nomadnetwork.node",
b"b",
None,
)
mgr.upsert_announce(
ret,
_FakeIdentity(f"{i + 3:032x}"),
bytes.fromhex(f"{i + 3:032x}"),
"lxmf.propagation",
b"c",
None,
)
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == 15
assert db.announces.get_announce_count_by_aspect("nomadnetwork.node") == 9
assert db.announces.get_announce_count_by_aspect("lxmf.propagation") == 11
def test_quick_check_ok_after_heavy_announce_spam(sqlite_db):
db, _path = sqlite_db
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=30)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
for i in range(900):
mgr.upsert_announce(
ret,
_FakeIdentity(f"{i % 100:032x}"),
bytes.fromhex(f"{i:032x}"),
"lxmf.delivery",
os.urandom(64),
None,
)
qc = db.provider.quick_check()
assert qc
first = qc[0]
val = next(iter(first.values()))
assert val == "ok"
def test_large_app_data_spam_remains_bounded(sqlite_db):
db, _path = sqlite_db
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=20)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
blob = b"z" * 12000
for i in range(180):
mgr.upsert_announce(
ret,
_FakeIdentity(f"{i:032x}"),
bytes.fromhex(f"{i:032x}"),
"lxmf.delivery",
blob,
None,
)
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == 20
+108
View File
@@ -0,0 +1,108 @@
# SPDX-License-Identifier: 0BSD
"""Critical-path tests for ``AsyncUtils``: cross-thread scheduling and memory caps."""
from __future__ import annotations
import asyncio
import threading
import warnings
import pytest
from meshchatx.src.backend.async_utils import AsyncUtils
@pytest.fixture(autouse=True)
def _reset_async_utils():
AsyncUtils.main_loop = None
AsyncUtils._pending_futures.clear()
AsyncUtils._pending_coroutines.clear()
yield
AsyncUtils.main_loop = None
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
AsyncUtils._pending_futures.clear()
AsyncUtils._pending_coroutines.clear()
async def _noop():
return None
def test_buffered_coroutines_capped_when_event_loop_not_running():
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
for _ in range(AsyncUtils._COROUTINES_MAX + 12):
AsyncUtils.run_async(_noop())
assert len(AsyncUtils._pending_coroutines) == AsyncUtils._COROUTINES_MAX
@pytest.mark.asyncio
async def test_set_main_loop_drains_buffered_coroutines():
seen: list[bool] = []
async def record():
seen.append(True)
AsyncUtils.main_loop = None
queued = threading.Event()
def schedule_from_worker():
AsyncUtils.run_async(record())
queued.set()
threading.Thread(target=schedule_from_worker).start()
assert queued.wait(timeout=2.0)
assert len(AsyncUtils._pending_coroutines) == 1
AsyncUtils.set_main_loop(asyncio.get_running_loop())
assert AsyncUtils._pending_coroutines == []
await asyncio.sleep(0.15)
assert seen == [True]
@pytest.mark.asyncio
async def test_run_async_with_running_loop_executes_coroutine():
outcomes: list[int] = []
async def work():
outcomes.append(7)
AsyncUtils.set_main_loop(asyncio.get_running_loop())
done = threading.Event()
def schedule_from_worker():
AsyncUtils.run_async(work())
done.set()
threading.Thread(target=schedule_from_worker).start()
assert done.wait(timeout=2.0)
await asyncio.sleep(0.15)
assert outcomes == [7]
@pytest.mark.asyncio
async def test_pending_futures_list_sheds_completed_entries():
AsyncUtils.set_main_loop(asyncio.get_running_loop())
with AsyncUtils._futures_lock:
AsyncUtils._pending_futures.clear()
finished = threading.Event()
def blast():
for _ in range(AsyncUtils._FUTURES_SWEEP_THRESHOLD + 8):
AsyncUtils.run_async(asyncio.sleep(0))
finished.set()
threading.Thread(target=blast).start()
assert finished.wait(timeout=5.0)
await asyncio.sleep(0.15)
with AsyncUtils._futures_lock:
still_pending = [f for f in AsyncUtils._pending_futures if not f.done()]
assert len(still_pending) == 0
+137 -1
View File
@@ -137,7 +137,19 @@ async def test_auto_propagation_skips_when_sync_active_and_path_exists():
)
context.message_router.propagation_transfer_state = LXMRouter.PR_RECEIVING
with patch.object(RNS.Transport, "has_path", return_value=True):
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(
manager,
"_wait_for_path",
return_value=True,
),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
await manager.check_and_update_propagation_node()
app.stop_propagation_node_sync.assert_not_called()
@@ -170,6 +182,7 @@ async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
patch.object(RNS.Transport, "hops_to", return_value=1),
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch("meshchatx.src.backend.auto_propagation_manager.asyncio.sleep"),
):
# Current node A has no path, candidate B has a path.
mock_has_path.side_effect = lambda dh: dh == bytes.fromhex(_VALID_HASH_B)
@@ -214,3 +227,126 @@ async def test_auto_propagation_removes_broken_node_when_all_candidates_fail():
app.set_active_propagation_node.assert_not_called()
app.remove_active_propagation_node.assert_called_once_with(context=context)
@pytest.mark.asyncio
async def test_check_and_update_propagation_node_noops_without_message_router():
manager, app, context, config, _database = _make_manager()
context.message_router = None
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_not_called()
app.remove_active_propagation_node.assert_not_called()
def test_stop_propagation_node_sync_noops_when_message_router_none():
from meshchatx.meshchat import ReticulumMeshChat
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
ctx = MagicMock()
ctx.message_router = None
ReticulumMeshChat.stop_propagation_node_sync(app, context=ctx)
@pytest.mark.asyncio
async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
"""Stop a stuck sync when the current path is unresponsive.
Even if RNS reports has_path=True, a stale or unresponsive path should be
treated as broken so the manager can look for a working alternative.
"""
manager, app, context, config, database = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
_VALID_HASH_A
)
context.message_router.propagation_transfer_state = LXMRouter.PR_RECEIVING
announce1 = {
"destination_hash": _VALID_HASH_B,
"app_data": _APP_DATA_ENABLED,
}
database.announces.get_announces.return_value = [announce1]
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=True),
patch.object(RNS.Transport, "hops_to", return_value=1),
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch("meshchatx.src.backend.auto_propagation_manager.asyncio.sleep"),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
await manager.check_and_update_propagation_node()
app.stop_propagation_node_sync.assert_called_once_with(context=context)
app.set_active_propagation_node.assert_called_once_with(
_VALID_HASH_B,
context=context,
)
@pytest.mark.asyncio
async def test_probe_propagation_sync_ignores_stale_state():
"""A stale non-idle state from a previous sync must not cause a false success.
The probe should wait for PR_IDLE before starting, then only count state
changes that happen after the new request is issued.
"""
import time
manager, app, context, config, database = _make_manager()
router = context.message_router
router.propagation_transfer_state = LXMRouter.PR_RECEIVING
call_count = [0]
async def fake_sleep(_):
call_count[0] += 1
if call_count[0] == 3:
router.propagation_transfer_state = LXMRouter.PR_IDLE
fake_time = [0.0]
def fake_monotonic():
fake_time[0] += 0.5
return fake_time[0]
with (
patch(
"meshchatx.src.backend.auto_propagation_manager.asyncio.sleep", fake_sleep
),
patch.object(time, "monotonic", fake_monotonic),
):
result = await manager._probe_propagation_sync(_VALID_HASH_A)
# The stale state goes idle after a few sleeps, but the new request never
# leaves idle, so the probe must return False rather than True.
assert result is False
app.stop_propagation_node_sync.assert_called()
@pytest.mark.asyncio
async def test_sync_propagation_nodes_skips_when_active_and_not_forced():
"""Auto-sync must not overlap an already-active propagation transfer."""
from meshchatx.meshchat import ReticulumMeshChat
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
ctx = MagicMock()
router = MagicMock()
router.propagation_transfer_state = LXMRouter.PR_RECEIVING
router.PR_IDLE = LXMRouter.PR_IDLE
ctx.message_router = router
ctx.config = MagicMock()
with patch.object(app, "stop_propagation_node_sync") as mock_stop:
await app.sync_propagation_nodes(context=ctx, force=False)
router.request_messages_from_propagation_node.assert_not_called()
mock_stop.assert_not_called()
+48 -2
View File
@@ -73,6 +73,9 @@ async def test_contacts_export_with_data(mock_rns_minimal, temp_dir):
)
app.database.contacts.add_contact("Alice", "a" * 32, lxmf_address="b" * 32)
app.database.contacts.add_contact("Bob", "c" * 32)
app.database.misc.update_lxmf_user_icon(
"a" * 32, "account", "#FFFFFF", "#000000"
)
handler = None
for route in app.get_routes():
@@ -93,8 +96,13 @@ async def test_contacts_export_with_data(mock_rns_minimal, temp_dir):
assert names == {"Alice", "Bob"}
for c in data["contacts"]:
assert "id" not in c
assert "created_at" not in c
assert "updated_at" not in c
assert "created_at" in c
assert "updated_at" in c
alice = next(c for c in data["contacts"] if c["name"] == "Alice")
assert "lxmf_icon" in alice
assert alice["lxmf_icon"]["icon_name"] == "account"
bob = next(c for c in data["contacts"] if c["name"] == "Bob")
assert "lxmf_icon" not in bob
@pytest.mark.asyncio
@@ -193,3 +201,41 @@ async def test_contacts_import_rejects_non_array(mock_rns_minimal, temp_dir):
request.json = AsyncMock(return_value={"contacts": "not an array"})
response = await handler(request)
assert response.status == 400
@pytest.mark.asyncio
async def test_contacts_import_deduplicates(mock_rns_minimal, temp_dir):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = None
for route in app.get_routes():
if (
route.path == "/api/v1/telephone/contacts/import"
and route.method == "POST"
):
handler = route.handler
break
assert handler is not None
request = MagicMock()
request.json = AsyncMock(
return_value={
"contacts": [
{"name": "First", "remote_identity_hash": "a" * 32},
{"name": "Second", "remote_identity_hash": "a" * 32},
{"name": "Third", "remote_identity_hash": "b" * 32},
],
},
)
response = await handler(request)
data = json.loads(response.body)
assert data["added"] == 2
assert data["skipped"] == 0
rows = app.database.contacts.get_contacts(limit=10)
assert len(rows) == 2
names = {r["name"] for r in rows}
assert names == {"Second", "Third"}
+35
View File
@@ -331,3 +331,38 @@ def test_extract_docs_malformed_zip(docs_manager, temp_dirs):
finally:
if os.path.exists(zip_path):
os.remove(zip_path)
def test_populate_meshchatx_docs_generates_index_html(tmp_path):
public_dir = tmp_path / "public"
public_dir.mkdir()
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "README.md").write_text("# Hello\nWorld")
(docs_dir / "FAQ.md").write_text("# FAQ\nQ&A")
config = MagicMock()
dm = DocsManager(config, str(public_dir), project_root=str(tmp_path))
dm.populate_meshchatx_docs()
index_path = os.path.join(dm.meshchatx_docs_dir, "index.html")
assert os.path.exists(index_path)
content = open(index_path, encoding="utf-8").read()
assert "MeshChatX Documentation" in content
assert "README.html" in content
assert "FAQ.html" in content
def test_get_doc_content_rejects_directory_path(tmp_path):
public_dir = tmp_path / "public"
public_dir.mkdir()
config = MagicMock()
dm = DocsManager(config, str(public_dir))
# Ensure meshchatx_docs_dir exists as a directory
os.makedirs(dm.meshchatx_docs_dir, exist_ok=True)
# Passing "." should resolve to the directory itself, not a file
assert dm.get_doc_content(".") is None
assert dm.get_doc_content("..") is None
assert dm.get_doc_content("") is None
+183
View File
@@ -0,0 +1,183 @@
# SPDX-License-Identifier: 0BSD
import json
import shutil
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir():
dir_path = tempfile.mkdtemp()
yield dir_path
shutil.rmtree(dir_path)
@pytest.fixture
def mock_rns_minimal():
with (
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
):
mock_rns_instance = mock_rns.return_value
mock_rns_instance.configpath = "/tmp/mock_config"
mock_rns_instance.is_connected_to_shared_instance = False
mock_rns_instance.transport_enabled.return_value = True
mock_id = MagicMock(spec=RNS.Identity)
mock_id.hash = b"test_hash_32_bytes_long_01234567"
mock_id.hexhash = mock_id.hash.hex()
mock_id.get_private_key.return_value = b"test_private_key"
yield mock_id
@pytest.mark.asyncio
async def test_favourites_import(mock_rns_minimal, temp_dir):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = None
for route in app.get_routes():
if route.path == "/api/v1/favourites/import" and route.method == "POST":
handler = route.handler
break
assert handler is not None
request = MagicMock()
request.json = AsyncMock(
return_value={
"favourites": [
{
"destination_hash": "a" * 32,
"display_name": "Node A",
"aspect": "nomadnetwork.node",
},
{
"destination_hash": "b" * 32,
"display_name": "Node B",
"aspect": "nomadnetwork.node",
},
],
},
)
response = await handler(request)
data = json.loads(response.body)
assert data["imported"] == 2
assert data["skipped"] == 0
rows = app.database.announces.get_favourites(aspect="nomadnetwork.node")
assert len(rows) == 2
@pytest.mark.asyncio
async def test_favourites_import_skips_invalid(mock_rns_minimal, temp_dir):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = None
for route in app.get_routes():
if route.path == "/api/v1/favourites/import" and route.method == "POST":
handler = route.handler
break
assert handler is not None
request = MagicMock()
request.json = AsyncMock(
return_value={
"favourites": [
{
"destination_hash": "a" * 32,
"display_name": "Node A",
"aspect": "nomadnetwork.node",
},
{"display_name": "Missing hash"},
{"destination_hash": "b" * 32, "aspect": None},
],
},
)
response = await handler(request)
data = json.loads(response.body)
assert data["imported"] == 1
assert data["skipped"] == 2
@pytest.mark.asyncio
async def test_favourites_import_deduplicates(mock_rns_minimal, temp_dir):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = None
for route in app.get_routes():
if route.path == "/api/v1/favourites/import" and route.method == "POST":
handler = route.handler
break
assert handler is not None
request = MagicMock()
request.json = AsyncMock(
return_value={
"favourites": [
{
"destination_hash": "a" * 32,
"display_name": "First",
"aspect": "nomadnetwork.node",
},
{
"destination_hash": "a" * 32,
"display_name": "Second",
"aspect": "nomadnetwork.node",
},
{
"destination_hash": "b" * 32,
"display_name": "Node B",
"aspect": "nomadnetwork.node",
},
],
},
)
response = await handler(request)
data = json.loads(response.body)
assert data["imported"] == 2
assert data["skipped"] == 0
rows = app.database.announces.get_favourites(aspect="nomadnetwork.node")
assert len(rows) == 2
names = {r["display_name"] for r in rows}
assert names == {"Second", "Node B"}
@pytest.mark.asyncio
async def test_favourites_import_rejects_non_array(mock_rns_minimal, temp_dir):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = None
for route in app.get_routes():
if route.path == "/api/v1/favourites/import" and route.method == "POST":
handler = route.handler
break
assert handler is not None
request = MagicMock()
request.json = AsyncMock(return_value={"favourites": "not an array"})
response = await handler(request)
assert response.status == 400
+30
View File
@@ -157,3 +157,33 @@ def test_auth_login_fuzz_never_500(mock_app, body):
assert r.status != 500
asyncio.run(run())
def test_reset_password_clears_hash_when_set(mock_app):
mock_app.config.auth_enabled.set(True)
h = bcrypt.hashpw(b"old-password", bcrypt.gensalt()).decode("utf-8")
mock_app.config.auth_password_hash.set(h)
assert mock_app.reset_password() is True
assert mock_app.config.auth_password_hash.get() is None
def test_reset_password_no_op_when_no_hash(mock_app):
mock_app.config.auth_password_hash.set(None)
assert mock_app.reset_password() is False
assert mock_app.config.auth_password_hash.get() is None
@pytest.mark.asyncio
@pytest.mark.usefixtures("require_loopback_tcp")
async def test_reset_password_exposes_setup_screen(mock_app):
mock_app.config.auth_enabled.set(True)
h = bcrypt.hashpw(b"old-password", bcrypt.gensalt()).decode("utf-8")
mock_app.config.auth_password_hash.set(h)
assert mock_app.reset_password() is True
aio_app = _make_aio_app(mock_app, use_https=False)
async with TestClient(TestServer(aio_app)) as client:
status = await client.get("/api/v1/auth/status")
assert status.status == 200
body = await status.json()
assert body["password_set"] is False
+27 -5
View File
@@ -39,10 +39,22 @@ def _run_incoming(app, caller, ctx=None):
bound(caller, context=ctx)
def test_incoming_rejects_when_blocked_immediate_hangup(policy_app):
def test_incoming_rejects_when_blocked_uses_delayed_hangup(policy_app):
policy_app.is_destination_blocked.return_value = True
caller = _caller_identity()
_run_incoming(policy_app, caller)
with patch("meshchatx.meshchat.threading.Timer") as mock_timer:
def run_timer(delay, fn):
assert delay == 0.5
fn()
t = MagicMock()
t.start = MagicMock()
return t
mock_timer.side_effect = run_timer
_run_incoming(policy_app, caller)
policy_app.telephone_manager.telephone.hangup.assert_called_once()
policy_app.voicemail_manager.handle_incoming_call.assert_not_called()
@@ -127,9 +139,14 @@ def test_contacts_only_accepts_matching_contact(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
# Called twice: once for policy check, once for websocket broadcast is_contact flag
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_with(
CALLER_HASH_HEX,
)
assert (
policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_count
== 2
)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
)
@@ -178,7 +195,10 @@ def test_when_policy_off_stranger_rings(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
# Called once for websocket broadcast is_contact flag even when policy is off
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
CALLER_HASH_HEX,
)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
)
@@ -224,8 +244,10 @@ def test_uses_passed_context_not_app_current_context(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller, ctx=other_ctx)
other_ctx.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
# Called twice: once for policy check, once for websocket broadcast is_contact flag
other_ctx.database.contacts.get_contact_by_identity_hash.assert_called_with(
CALLER_HASH_HEX,
)
assert other_ctx.database.contacts.get_contact_by_identity_hash.call_count == 2
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
other_ctx.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
+127 -2
View File
@@ -82,6 +82,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app_instance.current_context.config.default_bootstrap_only.set(True)
get_handler = await find_route_handler(
app_instance,
@@ -145,11 +146,46 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
assert "interface_discovery_blacklist" not in config["reticulum"]
assert config["reticulum"]["required_discovery_value"] == 18
assert config["reticulum"]["autoconnect_discovered_interfaces"] == 5
assert config["reticulum"]["default_bootstrap_only"] is False
assert "default_bootstrap_only" not in config["reticulum"]
assert app_instance.current_context.config.default_bootstrap_only.get() is False
assert config["reticulum"]["network_identity"] == "/tmp/other_id"
assert config.write_called
@pytest.mark.asyncio
async def test_reticulum_discovery_get_default_bootstrap_false_when_unset(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.transport_enabled.return_value = True
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
get_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/discovery",
"GET",
)
assert get_handler
get_response = await get_handler(MagicMock())
get_data = json.loads(get_response.body)
assert get_data["discovery"]["default_bootstrap_only"] is False
@pytest.mark.asyncio
async def test_discovery_patch_rejects_zero_autoconnect_as_unset(temp_dir):
config = ConfigDict(
@@ -409,7 +445,7 @@ async def test_interface_add_includes_discovery_fields(temp_dir):
assert saved["discovery_frequency"] == 915000000
assert saved["discovery_bandwidth"] == 125000
assert saved["discovery_modulation"] == "LoRa"
assert saved.get("bootstrap_only") == "yes"
assert "bootstrap_only" not in saved
assert config.write_called
@@ -516,6 +552,66 @@ async def test_interface_add_tcp_explicit_bootstrap_only_no(temp_dir):
assert config["interfaces"]["ExplicitNo"]["bootstrap_only"] == "no"
@pytest.mark.asyncio
async def test_interface_edit_tcp_preserves_bootstrap_when_key_omitted(temp_dir):
config = ConfigDict(
{
"reticulum": {"default_bootstrap_only": True},
"interfaces": {
"KeepBoot": {
"type": "TCPClientInterface",
"target_host": "example.com",
"target_port": "4242",
"bootstrap_only": "yes",
},
},
},
)
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.transport_enabled.return_value = True
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
add_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/interfaces/add",
"POST",
)
assert add_handler
payload = {
"allow_overwriting_interface": True,
"name": "KeepBoot",
"type": "TCPClientInterface",
"target_host": "example.com",
"target_port": "4242",
}
class AddRequest:
@staticmethod
async def json():
return payload
response = await add_handler(AddRequest())
data = json.loads(response.body)
assert "message" in data
assert config["interfaces"]["KeepBoot"]["bootstrap_only"] == "yes"
def test_apply_bootstrap_only_to_interface():
details = {}
ReticulumMeshChat.apply_bootstrap_only_to_interface(details, {}, True)
@@ -531,6 +627,35 @@ def test_apply_bootstrap_only_to_interface():
ReticulumMeshChat.apply_bootstrap_only_to_interface(details, {}, False)
assert "bootstrap_only" not in details
details = {"bootstrap_only": "yes"}
ReticulumMeshChat.apply_bootstrap_only_to_interface(
details, {}, True, updating_existing=True
)
assert details["bootstrap_only"] == "yes"
def test_strip_reload_instance_suffix():
assert ReticulumMeshChat._strip_reload_instance_suffix(None) is None
assert ReticulumMeshChat._strip_reload_instance_suffix("") is None
assert ReticulumMeshChat._strip_reload_instance_suffix("mesh") == "mesh"
assert ReticulumMeshChat._strip_reload_instance_suffix(
"production-reload-backend"
) == ("production-reload-backend")
assert (
ReticulumMeshChat._strip_reload_instance_suffix("my-net-reload-peer")
== "my-net-reload-peer"
)
assert (
ReticulumMeshChat._strip_reload_instance_suffix("node-reload-1-500")
== "node-reload-1-500"
)
assert (
ReticulumMeshChat._strip_reload_instance_suffix(
"default-reload-2246687-1777566181-reload-3009314-1777566481",
)
== "default"
)
@pytest.mark.asyncio
async def test_interface_add_discoverable_without_optional_coordinates(temp_dir):
+247
View File
@@ -0,0 +1,247 @@
# SPDX-License-Identifier: 0BSD
"""Multi-minute soak tests (announce DB + websocket fan-out).
These are **opt-in**: unset ``MESHCHAT_LONG_TEST_SECONDS`` skips them immediately.
Examples::
MESHCHAT_LONG_TEST_SECONDS=300 uv run pytest tests/backend/test_long_running_stress.py -m long_running -v
MESHCHAT_LONG_TEST_SECONDS=600 uv run pytest tests/backend/test_long_running_stress.py -m long_running -v
Quick smoke (seconds)::
MESHCHAT_LONG_TEST_SECONDS=5 uv run pytest tests/backend/test_long_running_stress.py -m long_running -v
"""
from __future__ import annotations
import os
import tempfile
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend.announce_manager import AnnounceManager
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.database.provider import DatabaseProvider
class _FakeIdentity:
__slots__ = ("_h",)
def __init__(self, identity_hex32: str):
self._h = bytes.fromhex(identity_hex32)
@property
def hash(self):
return self._h
def get_public_key(self):
return b"\xaa\xbb"
def _cleanup(db, path):
if db is not None:
try:
db.close()
except Exception:
pass
DatabaseProvider._instance = None
if path:
try:
os.unlink(path)
except OSError:
pass
for suffix in ("-wal", "-shm"):
try:
os.unlink(path + suffix)
except OSError:
pass
def _new_db():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
db = Database(path)
db.initialize()
return db, path
def _store_enabled_config(**max_stored):
config = MagicMock()
for _k in (
"announce_store_lxmf_delivery",
"announce_store_lxst_telephony",
"announce_store_nomadnetwork_node",
"announce_store_lxmf_propagation",
"announce_store_git_repositories",
):
m = MagicMock()
m.get.return_value = True
setattr(config, _k, m)
for key, default in (
("announce_max_stored_lxmf_delivery", None),
("announce_max_stored_nomadnetwork_node", None),
("announce_max_stored_lxmf_propagation", None),
):
attr = MagicMock()
attr.get.return_value = max_stored.get(key, default)
setattr(config, key, attr)
return config
def _long_test_seconds() -> float:
raw = os.environ.get("MESHCHAT_LONG_TEST_SECONDS", "").strip()
if not raw:
return 0.0
try:
return float(raw)
except ValueError:
return 0.0
def _require_long_duration():
sec = _long_test_seconds()
if sec <= 0:
pytest.skip(
"Set MESHCHAT_LONG_TEST_SECONDS to a positive value "
"(e.g. 300 for 5 minutes, 600 for 10 minutes).",
)
return sec
def _bind_real_websocket_broadcast(app):
return ReticulumMeshChat.websocket_broadcast.__get__(app, ReticulumMeshChat)
class _MagicWs:
__slots__ = ("send_str",)
def __init__(self):
self.send_str = AsyncMock(return_value=None)
@pytest.mark.long_running
def test_soak_sqlite_announces_stay_bounded_and_quick_check():
duration_s = _require_long_duration()
cap = 96
batch_size = 120
qc_interval_batches = 15
db, path = _new_db()
try:
cfg = _store_enabled_config(announce_max_stored_lxmf_delivery=cap)
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
deadline = time.monotonic() + duration_s
seq = 0
batches = 0
while time.monotonic() < deadline:
for _ in range(batch_size):
mgr.upsert_announce(
ret,
_FakeIdentity(f"{seq % 2048:032x}"),
bytes.fromhex(f"{seq:032x}"),
"lxmf.delivery",
os.urandom(48),
None,
)
seq += 1
batches += 1
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == cap
if batches % qc_interval_batches == 0:
qc = db.provider.quick_check()
assert qc
assert next(iter(qc[0].values())) == "ok"
assert seq > 0
finally:
_cleanup(db, path)
@pytest.mark.long_running
def test_soak_interleaved_aspects_under_cap():
duration_s = _require_long_duration()
cfg = _store_enabled_config(
announce_max_stored_lxmf_delivery=40,
announce_max_stored_nomadnetwork_node=25,
announce_max_stored_lxmf_propagation=30,
)
db, path = _new_db()
try:
mgr = AnnounceManager(db, cfg)
ret = MagicMock()
deadline = time.monotonic() + duration_s
n = 0
while time.monotonic() < deadline:
for aspect, payload in (
("lxmf.delivery", b"a"),
("nomadnetwork.node", b"b"),
("lxmf.propagation", b"c"),
):
mgr.upsert_announce(
ret,
_FakeIdentity(f"{n % 900:032x}"),
bytes.fromhex(f"{n:032x}"),
aspect,
payload,
None,
)
n += 1
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") <= 40
assert db.announces.get_announce_count_by_aspect("nomadnetwork.node") <= 25
assert db.announces.get_announce_count_by_aspect("lxmf.propagation") <= 30
assert n > 0
assert db.announces.get_announce_count_by_aspect("lxmf.delivery") == 40
assert db.announces.get_announce_count_by_aspect("nomadnetwork.node") == 25
assert db.announces.get_announce_count_by_aspect("lxmf.propagation") == 30
finally:
_cleanup(db, path)
@pytest.mark.long_running
@pytest.mark.asyncio
async def test_soak_websocket_broadcast_under_load(mock_app):
duration_s = _require_long_duration()
mock_app.websocket_clients.clear()
n_clients = min(int(os.environ.get("MESHCHAT_LONG_TEST_WS_CLIENTS", "400")), 2000)
clients = [_MagicWs() for _ in range(n_clients)]
mock_app.websocket_clients.extend(clients)
real = _bind_real_websocket_broadcast(mock_app)
deadline = time.monotonic() + duration_s
rounds = 0
while time.monotonic() < deadline:
payload = f'{{"type":"soak","round":{rounds}}}'
await real(payload)
for c in clients:
assert c.send_str.await_args[0][0] == payload
rounds += 1
assert rounds > 0
@pytest.mark.long_running
@pytest.mark.asyncio
async def test_soak_websocket_broadcast_with_churn(mock_app):
duration_s = _require_long_duration()
real = _bind_real_websocket_broadcast(mock_app)
deadline = time.monotonic() + duration_s
wave = 0
while time.monotonic() < deadline:
mock_app.websocket_clients.clear()
batch = [_MagicWs() for _ in range(80)]
if wave % 3 == 0:
for c in batch[:20]:
c.send_str = AsyncMock(side_effect=ConnectionError("closed"))
mock_app.websocket_clients.extend(batch)
payload = f'{{"wave":{wave}}}'
await real(payload)
for c in mock_app.websocket_clients:
assert c.send_str.await_args[0][0] == payload
wave += 1
assert wave > 0
@@ -61,6 +61,10 @@ class FakePropagationRouter:
self.propagation_transfer_state = self.PR_PATH_REQUESTED
self.propagation_transfer_progress = 0.0
def cancel_propagation_node_requests(self):
self.propagation_transfer_state = self.PR_IDLE
self.propagation_transfer_progress = 0.0
@pytest.fixture
def temp_dir():
+186
View File
@@ -0,0 +1,186 @@
# SPDX-License-Identifier: 0BSD
import json
import shutil
import tempfile
from unittest.mock import MagicMock, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir():
dir_path = tempfile.mkdtemp()
yield dir_path
shutil.rmtree(dir_path)
@pytest.fixture
def mock_rns_minimal():
with (
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
):
mock_rns_instance = mock_rns.return_value
mock_rns_instance.configpath = "/tmp/mock_config"
mock_rns_instance.is_connected_to_shared_instance = False
mock_rns_instance.transport_enabled.return_value = True
mock_id = MagicMock(spec=RNS.Identity)
mock_id.hash = b"test_hash_32_bytes_long_01234567"
mock_id.hexhash = mock_id.hash.hex()
mock_id.get_private_key.return_value = b"test_private_key"
yield mock_id
@pytest.mark.asyncio
async def test_messages_export_with_icons(mock_rns_minimal, temp_dir):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.database.messages.upsert_lxmf_message(
{
"hash": "msg1",
"source_hash": "peer1",
"destination_hash": "local",
"peer_hash": "peer1",
"state": "delivered",
"progress": 1.0,
"is_incoming": 1,
"method": "delivery",
"delivery_attempts": 0,
"next_delivery_attempt_at": None,
"title": None,
"content": "Hello",
"fields": None,
"timestamp": 1000.0,
"rssi": None,
"snr": None,
"quality": None,
"is_spam": 0,
"reply_to_hash": None,
"attachments_stripped": None,
"path_hops_at_send": None,
"path_interface_at_send": None,
"path_finding_measure": None,
"path_row_hash_hex": None,
}
)
app.database.messages.upsert_lxmf_message(
{
"hash": "msg2",
"source_hash": "local",
"destination_hash": "peer2",
"peer_hash": "peer2",
"state": "delivered",
"progress": 1.0,
"is_incoming": 0,
"method": "delivery",
"delivery_attempts": 0,
"next_delivery_attempt_at": None,
"title": None,
"content": "World",
"fields": None,
"timestamp": 2000.0,
"rssi": None,
"snr": None,
"quality": None,
"is_spam": 0,
"reply_to_hash": None,
"attachments_stripped": None,
"path_hops_at_send": None,
"path_interface_at_send": None,
"path_finding_measure": None,
"path_row_hash_hex": None,
}
)
app.database.misc.update_lxmf_user_icon(
"peer1", "account", "#FFFFFF", "#000000"
)
app.database.misc.update_lxmf_user_icon("peer2", "robot", "#000000", "#FFFFFF")
handler = None
for route in app.get_routes():
if (
route.path == "/api/v1/maintenance/messages/export"
and route.method == "GET"
):
handler = route.handler
break
assert handler is not None
request = MagicMock()
response = await handler(request)
data = json.loads(response.body)
assert "messages" in data
assert len(data["messages"]) == 2
msg1 = next(m for m in data["messages"] if m["hash"] == "msg1")
assert "lxmf_icon" in msg1
assert msg1["lxmf_icon"]["icon_name"] == "account"
msg2 = next(m for m in data["messages"] if m["hash"] == "msg2")
assert "lxmf_icon" in msg2
assert msg2["lxmf_icon"]["icon_name"] == "robot"
@pytest.mark.asyncio
async def test_messages_export_without_icons(mock_rns_minimal, temp_dir):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.database.messages.upsert_lxmf_message(
{
"hash": "msg1",
"source_hash": "peer1",
"destination_hash": "local",
"peer_hash": "peer1",
"state": "delivered",
"progress": 1.0,
"is_incoming": 1,
"method": "delivery",
"delivery_attempts": 0,
"next_delivery_attempt_at": None,
"title": None,
"content": "Hello",
"fields": None,
"timestamp": 1000.0,
"rssi": None,
"snr": None,
"quality": None,
"is_spam": 0,
"reply_to_hash": None,
"attachments_stripped": None,
"path_hops_at_send": None,
"path_interface_at_send": None,
"path_finding_measure": None,
"path_row_hash_hex": None,
}
)
handler = None
for route in app.get_routes():
if (
route.path == "/api/v1/maintenance/messages/export"
and route.method == "GET"
):
handler = route.handler
break
assert handler is not None
request = MagicMock()
response = await handler(request)
data = json.loads(response.body)
assert len(data["messages"]) == 1
assert "lxmf_icon" not in data["messages"][0]
@@ -89,3 +89,53 @@ async def test_nomadnet_file_download_started_before_download_async_scheduled(
assert events[0][1]["type"] == "nomadnet.file.download"
assert events[0][1]["nomadnet_file_download"]["status"] == "started"
assert events[1][0] == "run_async"
@pytest.mark.asyncio
async def test_nomadnet_file_download_with_data_passed_to_downloader(
mock_app, monkeypatch
):
"""Query-param data from the WS payload must reach NomadnetFileDownloader."""
mock_app._try_serve_local_page_node_file = MagicMock(return_value=None)
from meshchatx.src.backend import nomadnet_downloader
captured = {}
orig_init = nomadnet_downloader.NomadnetFileDownloader.__init__
def capturing_init(self, *args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
# Don't call real init to avoid RNS side-effects
self.is_cancelled = False
self.destination_hash = args[0]
self.path = args[1]
self.data = kwargs.get("data")
monkeypatch.setattr(
nomadnet_downloader.NomadnetFileDownloader, "__init__", capturing_init
)
mock_ws = MagicMock()
mock_ws.send_str = AsyncMock()
dh = "c" * 32
await mock_app.on_websocket_data_received(
mock_ws,
{
"type": "nomadnet.file.download",
"nomadnet_file_download": {
"destination_hash": dh,
"file_path": "/files/report.pdf",
"data": "version=2&format=raw",
},
},
)
assert captured["kwargs"].get("data") == {"var_version": "2&format=raw"}
assert captured["args"][0] == bytes.fromhex(dh)
assert captured["args"][1] == "/files/report.pdf"
monkeypatch.setattr(
nomadnet_downloader.NomadnetFileDownloader, "__init__", orig_init
)
+2 -1
View File
@@ -24,9 +24,10 @@ class TestNomadnetDownloader(unittest.TestCase):
def test_cancel(self):
self.downloader.request_receipt = MagicMock()
self.downloader.request_receipt.resource = MagicMock()
self.downloader.cancel()
self.assertTrue(self.downloader.is_cancelled)
self.downloader.request_receipt.cancel.assert_called_once()
self.downloader.request_receipt.resource.cancel.assert_called_once()
if __name__ == "__main__":
@@ -188,6 +188,37 @@ def test_file_downloader_list_response_short_list_no_crash():
on_fail.assert_called_once_with("unsupported_response")
def test_file_downloader_stores_data_parameter():
on_ok = MagicMock()
on_fail = MagicMock()
on_progress = MagicMock()
fd = NomadnetFileDownloader(
b"ab" * 8,
"/file/data.bin",
on_ok,
on_fail,
on_progress,
data="query=value&other=123",
)
assert fd.data == "query=value&other=123"
def test_file_downloader_passes_data_to_parent():
on_ok = MagicMock()
on_fail = MagicMock()
on_progress = MagicMock()
fd = NomadnetFileDownloader(
b"ab" * 8,
"/file/data.bin",
on_ok,
on_fail,
on_progress,
data="foo=bar",
)
# NomadnetDownloader stores data as the 3rd positional arg
assert fd.data == "foo=bar"
def test_cache_lock_serializes_mutations():
mock_link = MagicMock()
mock_link.status = RNS.Link.ACTIVE

Some files were not shown because too many files have changed in this diff Show More