diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 7616ba0..3db5779 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -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 diff --git a/.github/workflows/build-linux-packages.yml b/.github/workflows/build-linux-packages.yml index 268adcc..095ec1e 100644 --- a/.github/workflows/build-linux-packages.yml +++ b/.github/workflows/build-linux-packages.yml @@ -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 diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 1a1c16c..722e57d 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 036ea05..af1f489 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 152dfea..ba59ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index eaf953c..aed356d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e2269e6..b064471 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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<- Name of the artifact uploaded to the calling workflow run. - Defaults to ``meshchatx-frontend--``. + Defaults to meshchatx-frontend--. required: false type: string default: "" diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 49c4be8..5cfb925 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -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 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 6564eb2..62ef7f0 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ac5fe..9e3d15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d1cb5d..feaa9c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 3357c4d..5689195 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.hardened b/Dockerfile.hardened index 40f0b26..95481bd 100644 --- a/Dockerfile.hardened +++ b/Dockerfile.hardened @@ -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 diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..fc035eb --- /dev/null +++ b/FAQ.md @@ -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. diff --git a/README.md b/README.md index 92a326f..05f4f63 100644 --- a/README.md +++ b/README.md @@ -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) Get it on Obtainium @@ -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 | diff --git a/Taskfile.yml b/Taskfile.yml index 49340c6..ccd6315 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 --- diff --git a/android/app/build.gradle b/android/app/build.gradle index 71c75f9..82006e3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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" diff --git a/docs/meshchatx.md b/docs/meshchatx.md index 161ef7b..95f8dfb 100644 --- a/docs/meshchatx.md +++ b/docs/meshchatx.md @@ -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. diff --git a/docs/meshchatx_linux_sandbox.md b/docs/meshchatx_linux_sandbox.md index 070316a..1a7b03d 100644 --- a/docs/meshchatx_linux_sandbox.md +++ b/docs/meshchatx_linux_sandbox.md @@ -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 \ diff --git a/docs/meshchatx_on_android_with_termux.md b/docs/meshchatx_on_android_with_termux.md index 3a9ec4b..e4e10c3 100644 --- a/docs/meshchatx_on_android_with_termux.md +++ b/docs/meshchatx_on_android_with_termux.md @@ -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 ``` diff --git a/docs/meshchatx_on_raspberry_pi.md b/docs/meshchatx_on_raspberry_pi.md index b9083ed..3ac5e4d 100644 --- a/docs/meshchatx_on_raspberry_pi.md +++ b/docs/meshchatx_on_raspberry_pi.md @@ -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 diff --git a/eslint.config.mjs b/eslint.config.mjs index e156891..492c73d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,7 +25,7 @@ export default [ "**/.venv/**", "**/*.min.js", "**/pnpm-lock.yaml", - "**/poetry.lock", + "**/uv.lock", "**/linux-unpacked/**", "**/win-unpacked/**", "**/mac-unpacked/**", diff --git a/lang/README.de.md b/lang/README.de.md index bf70b21..adccb1d 100644 --- a/lang/README.de.md +++ b/lang/README.de.md @@ -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) Get it on Obtainium +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) | diff --git a/lang/README.it.md b/lang/README.it.md index 78722e9..c356343 100644 --- a/lang/README.it.md +++ b/lang/README.it.md @@ -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) Get it on Obtainium +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 | diff --git a/lang/README.ja.md b/lang/README.ja.md index 154778b..19b7248 100644 --- a/lang/README.ja.md +++ b/lang/README.ja.md @@ -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) Get it on Obtainium +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` | フロントエンドとバックエンドのテスト | diff --git a/lang/README.ru.md b/lang/README.ru.md index b662757..9e28aab 100644 --- a/lang/README.ru.md +++ b/lang/README.ru.md @@ -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) Get it on Obtainium +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` | Тесты фронтенда и бэкенда | diff --git a/lang/README.zh.md b/lang/README.zh.md index 0468385..43b3996 100644 --- a/lang/README.zh.md +++ b/lang/README.zh.md @@ -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) Get it on Obtainium +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` | (无) | Reticulum(RNS)日志级别:`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` | 运行前端与后端测试 | diff --git a/meshchatx/android_push_bridge.py b/meshchatx/android_push_bridge.py index 0291f14..b3904f2 100644 --- a/meshchatx/android_push_bridge.py +++ b/meshchatx/android_push_bridge.py @@ -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//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": diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index a3abc92..ec1122f 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -143,7 +143,10 @@ from meshchatx.src.backend.sticker_utils import ( validate_export_document, ) from meshchatx.src.backend.telemetry_utils import Telemeter -from meshchatx.android_push_bridge import _is_chaquopy_android +from meshchatx.android_push_bridge import ( + _get_android_external_files_dir, + _is_chaquopy_android, +) from meshchatx.src.backend.web_audio_bridge import WebAudioBridge from meshchatx.src.env_utils import env_bool from meshchatx.src.path_utils import ( @@ -214,6 +217,29 @@ def _resolve_rns_loglevel(cli_override: str | None) -> int | None: return _parse_rns_loglevel_value(os.environ.get("MESHCHAT_RNS_LOG_LEVEL")) +def _restore_rns_console_logging_after_reticulum_init(app) -> None: + """Undo shutdown side effects from ``RNS.Reticulum.exit_handler``. + + That handler sets ``RNS.loglevel`` to ``LOG_NONE`` and points ``sys.stdout`` / + ``sys.stderr`` at ``os.devnull``. Without this, hot reload appears to stop all + announce traffic logging even though interfaces are up. + + When no CLI or ``MESHCHAT_RNS_LOG_LEVEL`` value applies and the level is still + ``LOG_NONE`` after reading config, fall back to ``LOG_WARNING`` so notices are + visible. Explicit ``none`` in the environment remains respected. + """ + try: + if hasattr(sys, "__stdout__"): + sys.stdout = sys.__stdout__ + if hasattr(sys, "__stderr__"): + sys.stderr = sys.__stderr__ + except Exception: + pass + resolved = _resolve_rns_loglevel(getattr(app, "_rns_loglevel_cli", None)) + if resolved is None and RNS.loglevel == RNS.LOG_NONE: + RNS.loglevel = RNS.LOG_WARNING + + def _python_jit_status_line() -> str: jit_runtime = getattr(sys, "_jit", None) if jit_runtime is None: @@ -313,6 +339,11 @@ class ReticulumMeshChat: # track announce timestamps for rate calculation self.announce_timestamps = [] + # track incoming lxmf message timestamps for flood protection + self._lxmf_incoming_timestamps = [] + self._flood_protection_current_cost = None + self._flood_protection_last_bump_time = 0 + # track download speeds for nomadnetwork files self.download_speeds = [] @@ -660,6 +691,54 @@ class ReticulumMeshChat: raise RuntimeError("Database not initialized") return self.database.restore_database(backup_path) + def reset_password(self): + """Clear the stored password hash so a new password can be set via the web UI.""" + if self.config.auth_password_hash.get() is not None: + self.config.auth_password_hash.set(None) + return True + return False + + @staticmethod + def _disable_rnode_interfaces_on_android(config_path: str) -> bool: + """If running on Android, disable RNode* interfaces in Reticulum config. + + Returns True if any interfaces were disabled. + """ + if not _is_chaquopy_android(): + return False + if not os.path.isfile(config_path): + return False + try: + from RNS.vendor.configobj import ConfigObj + + cfg = ConfigObj(config_path) + except Exception: + return False + + modified = False + interfaces = cfg.get("interfaces") + if not isinstance(interfaces, dict): + return False + for _iface_name, iface in interfaces.items(): + if not isinstance(iface, dict): + continue + iface_type = iface.get("type", "") + if isinstance(iface_type, str) and iface_type.startswith("RNode"): + if str(iface.get("interface_enabled", "")).lower() in ( + "true", + "yes", + "1", + "on", + ): + iface["interface_enabled"] = "false" + modified = True + if modified: + try: + cfg.write() + except Exception: + pass + return modified + def _ensure_reticulum_config(self, materialize: bool = True): """Normalize ``reticulum_config_dir`` and optionally ensure a ``config`` file exists. @@ -673,6 +752,9 @@ class ReticulumMeshChat: self.reticulum_config_dir = config_dir if not materialize: return + if not getattr(self, "_reticulum_instance_name_startup_repair_done", False): + self._repair_reticulum_instance_name_corruption() + self._reticulum_instance_name_startup_repair_done = True config_path = os.path.join(config_dir, "config") needs_default = True if os.path.isfile(config_path): @@ -687,6 +769,24 @@ class ReticulumMeshChat: if not os.path.isdir(config_dir): os.makedirs(config_dir, exist_ok=True) self._write_rns_reticulum_default_config_file(config_path) + # Scrub stale default_bootstrap_only from Reticulum config so it never + # affects discovered/auto-connected interfaces. + try: + from RNS.vendor.configobj import ConfigObj + + cfg = ConfigObj(config_path) + if "default_bootstrap_only" in cfg.get("reticulum", {}): + cfg["reticulum"].pop("default_bootstrap_only", None) + cfg.write() + except Exception: + pass + # Android: RNodeInterface crashes because serial port access isn't available + if _is_chaquopy_android(): + disabled = self._disable_rnode_interfaces_on_android(config_path) + if disabled: + logging.getLogger(__name__).warning( + "RNodeInterface is not supported on Android; disabled in config.", + ) def setup_identity(self, identity: RNS.Identity): identity_hash = identity.hash.hex() @@ -715,6 +815,7 @@ class ReticulumMeshChat: ) else: self.reticulum = RNS.Reticulum(self.reticulum_config_dir) + _restore_rns_console_logging_after_reticulum_init(self) self.page_node_manager.load_nodes() self.page_node_manager.start_all() @@ -816,6 +917,7 @@ class ReticulumMeshChat: if identity_hash in self.contexts: del self.contexts[identity_hash] self.current_context = None + gc.collect() def _teardown_all_contexts_for_reload(self): # Stop per-identity long-running services before tearing down contexts. @@ -845,6 +947,7 @@ class ReticulumMeshChat: self.contexts.clear() self.current_context = None self.running = False + gc.collect() async def _send_rns_reload_status( self, @@ -983,6 +1086,49 @@ class ReticulumMeshChat: return closed_any + _reload_instance_suffix_re = re.compile(r"-reload-(\d+)-(\d+)$") + _meshchat_reload_pid_max = 4_194_304 + _meshchat_reload_epoch_min = 1_577_836_800 + _meshchat_reload_epoch_max = 4_102_444_800 + + @staticmethod + def _looks_like_meshchat_hot_reload_tail(pid: int, epoch: int) -> bool: + """Limit repairs to suffixes :meth:`reload_reticulum` actually writes. + + Hot reload uses ``-reload-{os.getpid()}-{int(time.time())}``. Names like + ``my-net-reload-peer`` must not be truncated. + """ + if pid < 1 or pid > ReticulumMeshChat._meshchat_reload_pid_max: + return False + if ( + epoch < ReticulumMeshChat._meshchat_reload_epoch_min + or epoch > ReticulumMeshChat._meshchat_reload_epoch_max + ): + return False + return True + + @staticmethod + def _strip_reload_instance_suffix(name): + """Remove stacked MeshChat hot-reload tails only (validated pid + unix time).""" + if not isinstance(name, str): + return None + out = name.strip() + if not out: + return None + while True: + m = ReticulumMeshChat._reload_instance_suffix_re.search(out) + if not m or m.end() != len(out): + break + try: + pid = int(m.group(1)) + epoch = int(m.group(2)) + except ValueError: + break + if not ReticulumMeshChat._looks_like_meshchat_hot_reload_tail(pid, epoch): + break + out = out[: m.start()].strip() + return out if out else None + def _read_reticulum_instance_name(self): """Return current Reticulum instance_name from config or None.""" config_dir = self._normalize_reticulum_config_dir( @@ -993,11 +1139,24 @@ class ReticulumMeshChat: return None cp = configparser.ConfigParser() - cp.read(config_path) + try: + cp.read(config_path) + except configparser.Error: + return None if not cp.has_section("reticulum"): return None return cp.get("reticulum", "instance_name", fallback=None) + def _repair_reticulum_instance_name_corruption(self): + """Rewrite persisted ``instance_name`` if hot-reload suffixes were left on disk.""" + raw = self._read_reticulum_instance_name() + if not raw: + return + cleaned = ReticulumMeshChat._strip_reload_instance_suffix(raw) + if cleaned == raw or cleaned is None: + return + self._write_reticulum_instance_name(cleaned) + def _write_reticulum_instance_name(self, instance_name): """Persist a Reticulum instance_name value into the config.""" config_dir = self._normalize_reticulum_config_dir( @@ -1005,7 +1164,10 @@ class ReticulumMeshChat: ) config_path = os.path.join(config_dir, "config") cp = configparser.ConfigParser() - cp.read(config_path) + try: + cp.read(config_path) + except configparser.Error: + cp = configparser.ConfigParser() if not cp.has_section("reticulum"): cp.add_section("reticulum") cp.set("reticulum", "instance_name", instance_name) @@ -1208,30 +1370,41 @@ class ReticulumMeshChat: config_path = os.path.join(config_dir, "config") if os.path.isfile(config_path): cp = configparser.ConfigParser() - cp.read(config_path) - if cp.has_section("reticulum"): - rpc_port = cp.getint("reticulum", "rpc_port", fallback=37429) - rpc_bind = cp.get("reticulum", "rpc_bind", fallback="127.0.0.1") - shared_port = cp.getint( - "reticulum", - "shared_instance_port", - fallback=37428, - ) - shared_bind = cp.get( - "reticulum", - "shared_instance_bind", - fallback="127.0.0.1", - ) + try: + cp.read(config_path) + except configparser.Error: + pass + else: + if cp.has_section("reticulum"): + rpc_port = cp.getint( + "reticulum", "rpc_port", fallback=37429 + ) + rpc_bind = cp.get( + "reticulum", "rpc_bind", fallback="127.0.0.1" + ) + shared_port = cp.getint( + "reticulum", + "shared_instance_port", + fallback=37428, + ) + shared_bind = cp.get( + "reticulum", + "shared_instance_bind", + fallback="127.0.0.1", + ) - # Only add if not already there - if not any( - addr == (rpc_bind, rpc_port) for addr, _ in rpc_addrs - ): - rpc_addrs.append(((rpc_bind, rpc_port), "AF_INET")) - if not any( - addr == (shared_bind, shared_port) for addr, _ in rpc_addrs - ): - rpc_addrs.append(((shared_bind, shared_port), "AF_INET")) + # Only add if not already there + if not any( + addr == (rpc_bind, rpc_port) for addr, _ in rpc_addrs + ): + rpc_addrs.append(((rpc_bind, rpc_port), "AF_INET")) + if not any( + addr == (shared_bind, shared_port) + for addr, _ in rpc_addrs + ): + rpc_addrs.append( + ((shared_bind, shared_port), "AF_INET") + ) except Exception as e: print(f"Warning reading Reticulum config for ports: {e}") @@ -1469,13 +1642,18 @@ class ReticulumMeshChat: if hasattr(RNS.Reticulum, "_Reticulum__instance"): RNS.Reticulum._Reticulum__instance = None - original_instance_name = None switched_instance_name = None + instance_restore_name = None if abstract_unix_addr_in_use_after_wait: - original_instance_name = self._read_reticulum_instance_name() - base_name = original_instance_name or "default" + stored_instance_name = self._read_reticulum_instance_name() + stable_base = ReticulumMeshChat._strip_reload_instance_suffix( + stored_instance_name, + ) + instance_restore_name = ( + stable_base if stable_base is not None else "default" + ) switched_instance_name = ( - f"{base_name}-reload-{os.getpid()}-{int(time.time())}" + f"{instance_restore_name}-reload-{os.getpid()}-{int(time.time())}" ) self._write_reticulum_instance_name(switched_instance_name) print( @@ -1492,9 +1670,7 @@ class ReticulumMeshChat: self.setup_identity(identity_to_restore) finally: if switched_instance_name: - self._write_reticulum_instance_name( - original_instance_name or "default", - ) + self._write_reticulum_instance_name(instance_restore_name) await self._send_rns_reload_status( "done", "RNS reload complete.", @@ -1920,7 +2096,13 @@ class ReticulumMeshChat: return None @staticmethod - def apply_bootstrap_only_to_interface(interface_details, data, default_enabled): + def apply_bootstrap_only_to_interface( + interface_details, + data, + default_enabled, + *, + updating_existing=False, + ): if "bootstrap_only" in data: yn = ReticulumMeshChat._bootstrap_only_request_yes_no( data.get("bootstrap_only") @@ -1932,6 +2114,8 @@ class ReticulumMeshChat: else: interface_details.pop("bootstrap_only", None) return + if updating_existing: + return if default_enabled: interface_details["bootstrap_only"] = "yes" else: @@ -2075,7 +2259,7 @@ class ReticulumMeshChat: def _default_announce_fetch_limit(self, aspect): ctx = self.current_context if not ctx or not ctx.config: - return 500 + return 2500 keys = { "lxmf.delivery": ctx.config.announce_fetch_limit_lxmf_delivery, "nomadnetwork.node": ctx.config.announce_fetch_limit_nomadnetwork_node, @@ -2084,10 +2268,10 @@ class ReticulumMeshChat: } cfg = keys.get(aspect) if cfg is None: - return 500 + return 2500 v = cfg.get() if v is None or v < 1: - return 500 + return 2500 return min(int(v), 100_000) def get_lxst_version(self) -> str: @@ -2309,9 +2493,13 @@ class ReticulumMeshChat: # uses the provided destination hash as the active propagation node def set_active_propagation_node(self, destination_hash: str | None, context=None): ctx = context or self.current_context - if not ctx: + if not ctx or not ctx.message_router: return + # Always cancel an in-flight sync before switching nodes so we don't + # orphan a transfer or leave the router in a stuck state. + self.stop_propagation_node_sync(context=ctx) + # set outbound propagation node if destination_hash is not None and destination_hash != "": try: @@ -2332,7 +2520,7 @@ class ReticulumMeshChat: # stops the in progress propagation node sync def stop_propagation_node_sync(self, context=None): ctx = context or self.current_context - if not ctx: + if not ctx or not ctx.message_router: return ctx.message_router.cancel_propagation_node_requests() @@ -2395,6 +2583,13 @@ class ReticulumMeshChat: "messages_hidden": 0, } + if not ctx.message_router: + return { + "messages_stored": 0, + "delivery_confirmations": 0, + "messages_hidden": 0, + } + messages_received = ctx.message_router.propagation_transfer_last_result or 0 current_total_messages = ctx.database.messages.count_lxmf_messages() current_delivered_messages = ctx.database.messages.count_lxmf_messages_by_state( @@ -2429,16 +2624,20 @@ class ReticulumMeshChat: ctx = context or self.current_context if not ctx: return - # fixme: it's possible for internal transfer state to get stuck if we change propagation node during a sync - # this still happens even if we cancel the propagation node requests - # for now, the user can just manually cancel syncing in the ui if they think it's stuck... self.stop_propagation_node_sync(context=ctx) - ctx.message_router.outbound_propagation_node = None + if ctx.message_router: + ctx.message_router.outbound_propagation_node = None + # Force the transfer state back to idle so nothing remains stuck + # after the outbound node is removed. + with contextlib.suppress(Exception): + ctx.message_router.propagation_transfer_state = ( + ctx.message_router.PR_IDLE + ) # enables or disables the local lxmf propagation node def enable_local_propagation_node(self, enabled: bool = True, context=None): ctx = context or self.current_context - if not ctx: + if not ctx or not ctx.message_router: return try: if enabled: @@ -2469,6 +2668,9 @@ class ReticulumMeshChat: return None router = ctx.message_router + if not router: + return None + is_running = bool(getattr(router, "propagation_node", False)) stats = None if is_running: @@ -2479,13 +2681,13 @@ class ReticulumMeshChat: return value if isinstance(value, (int, float)) else default destination_hash_raw = getattr( - ctx.message_router.propagation_destination, + router.propagation_destination, "hexhash", None, ) if destination_hash_raw is None: destination_hash_raw = getattr( - ctx.message_router.propagation_destination, + router.propagation_destination, "hash", None, ) @@ -2823,8 +3025,12 @@ class ReticulumMeshChat: # Reject all calls if telephony is disabled if not ctx.config.telephone_enabled.get(): - if ctx.telephone_manager.telephone: - ctx.telephone_manager.telephone.hangup() + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: + threading.Timer( + 0.5, + lambda t=telephone: t.hangup(), + ).start() return if ctx.telephone_manager and ctx.telephone_manager.initiation_status: @@ -2838,18 +3044,24 @@ class ReticulumMeshChat: # Check if caller is blocked if self.is_destination_blocked(caller_hash, context=ctx): print(f"Rejecting incoming call from blocked source: {caller_hash}") - if ctx.telephone_manager.telephone: - ctx.telephone_manager.telephone.hangup() + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: + # Use a small delay to avoid deadlocking with LXST call_handler_lock + threading.Timer( + 0.5, + lambda t=telephone: t.hangup(), + ).start() return # Check for Do Not Disturb if ctx.config.do_not_disturb_enabled.get(): print(f"Rejecting incoming call due to Do Not Disturb: {caller_hash}") - if ctx.telephone_manager.telephone: + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: # Use a small delay to ensure LXST state is ready for hangup threading.Timer( 0.5, - lambda: ctx.telephone_manager.telephone.hangup(), + lambda t=telephone: t.hangup(), ).start() return @@ -2858,13 +3070,21 @@ class ReticulumMeshChat: ctx.config.telephone_allow_calls_from_contacts_only.get() or ctx.config.block_all_from_strangers.get() ): - contact = ctx.database.contacts.get_contact_by_identity_hash(caller_hash) + contact = None + try: + contact = ctx.database.contacts.get_contact_by_identity_hash( + caller_hash + ) + except Exception: + # Treat lookup failure as non-contact to avoid accidentally allowing spam + pass if not contact: print(f"Rejecting incoming call from non-contact: {caller_hash}") - if ctx.telephone_manager.telephone: + telephone = getattr(ctx.telephone_manager, "telephone", None) + if telephone: threading.Timer( 0.5, - lambda: ctx.telephone_manager.telephone.hangup(), + lambda t=telephone: t.hangup(), ).start() return @@ -2874,6 +3094,13 @@ class ReticulumMeshChat: print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}") ch = caller_identity.hash.hex() caller_name = (self.get_name_for_identity_hash(ch) or "").strip() or "Mesh" + is_contact = False + try: + is_contact = ( + ctx.database.contacts.get_contact_by_identity_hash(ch) is not None + ) + except Exception: + pass AsyncUtils.run_async( self.websocket_broadcast( json.dumps( @@ -2881,6 +3108,7 @@ class ReticulumMeshChat: "type": "telephone_ringing", "remote_identity_hash": ch, "remote_identity_name": caller_name, + "is_contact": is_contact, }, ), ), @@ -2970,10 +3198,14 @@ class ReticulumMeshChat: if ctx.config.do_not_disturb_enabled.get(): is_filtered = True elif ctx.config.telephone_allow_calls_from_contacts_only.get(): - contact = ctx.database.contacts.get_contact_by_identity_hash( - remote_identity_hash, - ) - if not contact: + try: + contact = ctx.database.contacts.get_contact_by_identity_hash( + remote_identity_hash, + ) + if not contact: + is_filtered = True + except Exception: + # Treat lookup failure as filtered to avoid leaking missed-call noise is_filtered = True if not is_filtered: @@ -4910,14 +5142,16 @@ class ReticulumMeshChat: interface_type == "BackboneInterface" and str(interface_details.get("remote") or "").strip() != "" ): - default_boot = ReticulumMeshChat._reticulum_yes_no_preference( - self._get_reticulum_section().get("default_bootstrap_only"), - default=True, + default_boot = bool( + self.current_context.config.default_bootstrap_only.get() + if self.current_context and self.current_context.config + else False, ) ReticulumMeshChat.apply_bootstrap_only_to_interface( interface_details, data, default_boot, + updating_existing=allow_overwriting_interface, ) # set common interface options @@ -5321,7 +5555,10 @@ class ReticulumMeshChat: config_path = os.path.join(config_dir, "config") if os.path.isfile(config_path): cp = configparser.ConfigParser() - cp.read(config_path) + try: + cp.read(config_path) + except configparser.Error: + pass if cp.has_section("reticulum"): shared_port = cp.getint( "reticulum", @@ -6338,6 +6575,20 @@ class ReticulumMeshChat: if len(page) < page_size: break offset += page_size + icon_hashes = set() + for m in messages_list: + h = m.get("peer_hash") or m.get("source_hash") + if h: + icon_hashes.add(h) + icons = {} + if icon_hashes: + icon_rows = self.database.misc.get_user_icons(list(icon_hashes)) + for ir in icon_rows: + icons[ir["destination_hash"]] = dict(ir) + for m in messages_list: + h = m.get("peer_hash") or m.get("source_hash") + if h and h in icons: + m["lxmf_icon"] = icons[h] return web.json_response({"messages": messages_list}) # maintenance - import messages @@ -6410,9 +6661,10 @@ class ReticulumMeshChat: "autoconnect_discovered_interfaces", ReticulumMeshChat.DEFAULT_AUTOCONNECT_DISCOVERED_INTERFACES, ), - "default_bootstrap_only": ReticulumMeshChat._reticulum_yes_no_preference( - reticulum_config.get("default_bootstrap_only"), - default=True, + "default_bootstrap_only": bool( + self.current_context.config.default_bootstrap_only.get() + if self.current_context and self.current_context.config + else False, ), "network_identity": reticulum_config.get("network_identity"), } @@ -6464,11 +6716,23 @@ class ReticulumMeshChat: "interface_discovery_blacklist", "required_discovery_value", "autoconnect_discovered_interfaces", - "default_bootstrap_only", "network_identity", ): update_config_value(key) + # default_bootstrap_only is a MeshChatX-only setting; do NOT write it + # to Reticulum config so discovered/auto-connected interfaces are + # never affected. Clean up any stale value in Reticulum config. + reticulum_config.pop("default_bootstrap_only", None) + if ( + self.current_context + and self.current_context.config + and "default_bootstrap_only" in data + ): + self.current_context.config.default_bootstrap_only.set( + bool(data.get("default_bootstrap_only")), + ) + if not self._write_reticulum_config(): return web.json_response( {"message": "Failed to write Reticulum config"}, @@ -6493,9 +6757,10 @@ class ReticulumMeshChat: "autoconnect_discovered_interfaces", ReticulumMeshChat.DEFAULT_AUTOCONNECT_DISCOVERED_INTERFACES, ), - "default_bootstrap_only": ReticulumMeshChat._reticulum_yes_no_preference( - reticulum_config.get("default_bootstrap_only"), - default=True, + "default_bootstrap_only": bool( + self.current_context.config.default_bootstrap_only.get() + if self.current_context and self.current_context.config + else False, ), "network_identity": reticulum_config.get("network_identity"), } @@ -6578,9 +6843,12 @@ class ReticulumMeshChat: "listen_ip": s.get("listen_ip"), "connected": s.get("connected"), "online": s.get("online"), + "status": s.get("status"), "transport_id": transport_id, "network_id": s.get("network_id"), "autoconnect_source": s.get("autoconnect_source"), + "txb": s.get("txb"), + "rxb": s.get("rxb"), }, ) except Exception as e: @@ -7864,11 +8132,23 @@ class ReticulumMeshChat: async def telephone_contacts_export(request): try: rows = self.database.contacts.get_contacts(limit=10000, offset=0) + hashes = [ + r["remote_identity_hash"] + for r in rows + if r.get("remote_identity_hash") + ] + icons = {} + if hashes: + icon_rows = self.database.misc.get_user_icons(hashes) + for ir in icon_rows: + icons[ir["destination_hash"]] = dict(ir) export_data = [] for row in rows: d = dict(row) - for k in ("id", "created_at", "updated_at"): - d.pop(k, None) + d.pop("id", None) + h = d.get("remote_identity_hash") + if h and h in icons: + d["lxmf_icon"] = icons[h] export_data.append(d) return web.json_response({"contacts": export_data}) except Exception as e: @@ -7887,9 +8167,18 @@ class ReticulumMeshChat: {"message": "Invalid import format: contacts must be an array"}, status=400, ) + seen = {} + no_hash = [] + for c in contacts: + h = c.get("remote_identity_hash") + if h: + seen[h] = c + else: + no_hash.append(c) + unique_contacts = list(seen.values()) + no_hash added = 0 skipped = 0 - for c in contacts: + for c in unique_contacts: name = c.get("name") remote_identity_hash = c.get("remote_identity_hash") if not name or not remote_identity_hash: @@ -8280,6 +8569,59 @@ class ReticulumMeshChat: }, ) + # bulk import favourites + @routes.post("/api/v1/favourites/import") + async def favourites_import(request): + try: + data = await request.json() + entries = data.get("favourites", []) + if not isinstance(entries, list): + return web.json_response( + { + "message": "Invalid import format: favourites must be an array" + }, + status=400, + ) + seen = {} + no_hash = [] + for entry in entries: + h = entry.get("destination_hash") + if h: + seen[h] = entry + else: + no_hash.append(entry) + unique_entries = list(seen.values()) + no_hash + imported = 0 + skipped = 0 + for entry in unique_entries: + dest_hash = entry.get("destination_hash") + display_name = entry.get("display_name", "") + aspect = entry.get("aspect") + if not dest_hash or not aspect: + skipped += 1 + continue + try: + self.database.announces.upsert_favourite( + dest_hash, + display_name, + aspect, + ) + imported += 1 + except Exception: + skipped += 1 + return web.json_response( + { + "message": "Favourites import complete", + "imported": imported, + "skipped": skipped, + } + ) + except Exception as e: + return web.json_response( + {"message": f"Failed to import favourites: {e!s}"}, + status=500, + ) + # serve archived pages @routes.get("/api/v1/nomadnet/archives") async def get_all_archived_pages(request): @@ -8411,7 +8753,7 @@ class ReticulumMeshChat: RNS.Transport.request_path(outbound_node) # request messages from propagation node - await self.sync_propagation_nodes() + await self.sync_propagation_nodes(force=True) return web.json_response( { @@ -11175,6 +11517,23 @@ class ReticulumMeshChat: try: self.database.misc.add_blocked_destination(destination_hash) + # Block all known destinations for the same identity + announce = self.database.announces.get_announce_by_hash( + destination_hash + ) + if announce and announce.get("identity_hash"): + identity_hash = announce["identity_hash"] + other_announces = ( + self.database.announces.get_announces_by_identity_hash( + identity_hash + ) + ) + for other in other_announces: + other_hash = other["destination_hash"] + if other_hash != destination_hash: + self.database.misc.add_blocked_destination(other_hash) + self._lxmf_reticulum_enforce_block(other_hash) + self._delete_contact_and_stamp_ticket(other_hash) except Exception: return web.json_response( {"error": "Destination already blocked"}, @@ -11182,6 +11541,12 @@ class ReticulumMeshChat: ) self._lxmf_reticulum_enforce_block(destination_hash) + self._delete_contact_and_stamp_ticket(destination_hash) + + local_hash = self.local_lxmf_destination.hash.hex() + self.message_handler.delete_conversation(local_hash, destination_hash) + + AsyncUtils.run_async(self._broadcast_blocked_destinations()) return web.json_response({"message": "ok"}) @@ -11198,26 +11563,41 @@ class ReticulumMeshChat: try: self.database.misc.delete_blocked_destination(destination_hash) - # remove from Reticulum blackhole if available and enabled - if self.config.blackhole_integration_enabled.get(): - try: - if hasattr(self, "reticulum") and self.reticulum: - # Try to resolve identity hash from destination hash - identity_hash = None - announce = self.database.announces.get_announce_by_hash( - destination_hash, - ) - if announce and announce.get("identity_hash"): - identity_hash = announce["identity_hash"] + # Unblock all known destinations for the same identity + announce = self.database.announces.get_announce_by_hash( + destination_hash + ) + if announce and announce.get("identity_hash"): + identity_hash = announce["identity_hash"] + other_announces = ( + self.database.announces.get_announces_by_identity_hash( + identity_hash + ) + ) + for other in other_announces: + other_hash = other["destination_hash"] + if other_hash != destination_hash: + self.database.misc.delete_blocked_destination(other_hash) - # Use resolved identity hash or fallback to destination hash - target_hash = identity_hash or destination_hash - dest_bytes = bytes.fromhex(target_hash) + # Always remove from Reticulum blackhole if available + try: + if hasattr(self, "reticulum") and self.reticulum: + identity_hash = None + announce = self.database.announces.get_announce_by_hash( + destination_hash, + ) + if announce and announce.get("identity_hash"): + identity_hash = announce["identity_hash"] - if hasattr(self.reticulum, "unblackhole_identity"): - self.reticulum.unblackhole_identity(dest_bytes) - except Exception as e: - print(f"Failed to unblackhole identity in Reticulum: {e}") + target_hash = identity_hash or destination_hash + dest_bytes = bytes.fromhex(target_hash) + + if hasattr(self.reticulum, "unblackhole_identity"): + self.reticulum.unblackhole_identity(dest_bytes) + except Exception as e: + print(f"Failed to unblackhole identity in Reticulum: {e}") + + AsyncUtils.run_async(self._broadcast_blocked_destinations()) return web.json_response({"message": "ok"}) except Exception as e: @@ -12695,19 +13075,40 @@ class ReticulumMeshChat: await self.send_announced_to_websocket_clients(context=ctx) # handle syncing propagation nodes - async def sync_propagation_nodes(self, context=None): + async def sync_propagation_nodes(self, context=None, force=False): ctx = context or self.current_context if not ctx: return + router = ctx.message_router + if not router: + return + + # Prevent overlapping auto-syncs from piling up requests. + # A manual/API call can force a restart by cancelling the old sync first. + if router.propagation_transfer_state != router.PR_IDLE: + if not force: + return + self.stop_propagation_node_sync(context=ctx) + # Give the router a moment to settle back to idle + settle_deadline = time.monotonic() + 5.0 + while time.monotonic() < settle_deadline: + if router.propagation_transfer_state == router.PR_IDLE: + break + await asyncio.sleep(0.2) + else: + # Force reset if it didn't settle + with contextlib.suppress(Exception): + router.propagation_transfer_state = router.PR_IDLE + self._begin_propagation_sync_metrics(context=ctx) # update last synced at timestamp ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(int(time.time())) - outbound_node = ctx.message_router.get_outbound_propagation_node() + outbound_node = router.get_outbound_propagation_node() local_propagation_destination = getattr( - ctx.message_router, + router, "propagation_destination", None, ) @@ -12720,16 +13121,14 @@ class ReticulumMeshChat: # Local node selected as preferred: no transport path lookup is needed. # Mark sync as complete immediately to avoid getting stuck in PR_PATH_REQUESTED. with contextlib.suppress(Exception): - ctx.message_router.propagation_transfer_state = ( - ctx.message_router.PR_COMPLETE - ) - ctx.message_router.propagation_transfer_progress = 1.0 - ctx.message_router.propagation_transfer_last_result = 0 + router.propagation_transfer_state = router.PR_COMPLETE + router.propagation_transfer_progress = 1.0 + router.propagation_transfer_last_result = 0 await self.send_config_to_websocket_clients(context=ctx) return # request messages from propagation node - ctx.message_router.request_messages_from_propagation_node(ctx.identity) + router.request_messages_from_propagation_node(ctx.identity) # send config to websocket clients (used to tell ui last synced at) await self.send_config_to_websocket_clients(context=ctx) @@ -12860,17 +13259,24 @@ class ReticulumMeshChat: value = 0 elif value >= 255: value = 254 - self.config.lxmf_inbound_stamp_cost.set(value) - # update the inbound stamp cost on the delivery destination - self.message_router.set_inbound_stamp_cost( - self.local_lxmf_destination.hash, - value, - ) - # re-announce to update the stamp cost in announces - self.local_lxmf_destination.display_name = self.config.display_name.get() - self.message_router.announce( - destination_hash=self.local_lxmf_destination.hash, - ) + # If block strangers is active, store the desired value for later restore + # but keep the enforced max cost active + if self.config.block_all_from_strangers.get(): + self.config.lxmf_inbound_stamp_cost_before_block.set(value) + else: + self.config.lxmf_inbound_stamp_cost.set(value) + # update the inbound stamp cost on the delivery destination + self.message_router.set_inbound_stamp_cost( + self.local_lxmf_destination.hash, + value, + ) + # re-announce to update the stamp cost in announces + self.local_lxmf_destination.display_name = ( + self.config.display_name.get() + ) + self.message_router.announce( + destination_hash=self.local_lxmf_destination.hash, + ) # update propagation node stamp cost (for messages propagated through your node) if "lxmf_propagation_node_stamp_cost" in data: @@ -13122,9 +13528,73 @@ class ReticulumMeshChat: ) if "block_all_from_strangers" in data: - self.config.block_all_from_strangers.set( - self._parse_bool(data["block_all_from_strangers"]), + new_value = self._parse_bool(data["block_all_from_strangers"]) + old_value = self.config.block_all_from_strangers.get() + self.config.block_all_from_strangers.set(new_value) + if new_value and not old_value: + # Enabling block strangers: save current stamp cost and set to max + current_cost = self.config.lxmf_inbound_stamp_cost.get() + if current_cost < 254: + self.config.lxmf_inbound_stamp_cost_before_block.set(current_cost) + self.config.lxmf_inbound_stamp_cost.set(254) + if self.message_router and self.local_lxmf_destination: + self.message_router.set_inbound_stamp_cost( + self.local_lxmf_destination.hash, + 254, + ) + self.local_lxmf_destination.display_name = ( + self.config.display_name.get() + ) + self.message_router.announce( + destination_hash=self.local_lxmf_destination.hash, + ) + elif not new_value and old_value: + # Disabling block strangers: restore previous stamp cost + saved = self.config.lxmf_inbound_stamp_cost_before_block.get() + if saved > 0 and saved < 255: + restore_cost = saved + else: + restore_cost = 8 + self.config.lxmf_inbound_stamp_cost.set(restore_cost) + self.config.lxmf_inbound_stamp_cost_before_block.set(0) + if self.message_router and self.local_lxmf_destination: + self.message_router.set_inbound_stamp_cost( + self.local_lxmf_destination.hash, + restore_cost, + ) + self.local_lxmf_destination.display_name = ( + self.config.display_name.get() + ) + self.message_router.announce( + destination_hash=self.local_lxmf_destination.hash, + ) + + # update flood protection settings + if "lxmf_flood_protection_enabled" in data: + self.config.lxmf_flood_protection_enabled.set( + self._parse_bool(data["lxmf_flood_protection_enabled"]), ) + if "lxmf_flood_threshold_per_minute" in data: + try: + value = int(data["lxmf_flood_threshold_per_minute"]) + value = max(1, min(value, 1000)) + self.config.lxmf_flood_threshold_per_minute.set(value) + except (TypeError, ValueError): + pass + if "lxmf_flood_max_stamp_cost" in data: + try: + value = int(data["lxmf_flood_max_stamp_cost"]) + value = max(1, min(value, 254)) + self.config.lxmf_flood_max_stamp_cost.set(value) + except (TypeError, ValueError): + pass + if "lxmf_flood_cooldown_seconds" in data: + try: + value = int(data["lxmf_flood_cooldown_seconds"]) + value = max(30, min(value, 3600)) + self.config.lxmf_flood_cooldown_seconds.set(value) + except (TypeError, ValueError): + pass if "show_unknown_contact_banner" in data: self.config.show_unknown_contact_banner.set( @@ -13609,6 +14079,11 @@ class ReticulumMeshChat: destination_hash_hex = download_data.get("destination_hash") file_path = download_data.get("file_path") + request_data = download_data.get("data") + if isinstance(request_data, str): + request_data = convert_nomadnet_string_data_to_map(request_data) + elif request_data is None: + request_data = {} if not destination_hash_hex or not file_path: return @@ -13754,6 +14229,7 @@ class ReticulumMeshChat: on_file_download_success, on_file_download_failure, on_file_download_progress, + data=request_data, on_phase=on_file_download_phase, reticulum=getattr(self, "reticulum", None), ) @@ -14426,6 +14902,27 @@ class ReticulumMeshChat: ), ) + async def _broadcast_blocked_destinations(self): + try: + blocked = self.database.misc.get_blocked_destinations() + blocked_list = [ + { + "destination_hash": b["destination_hash"], + "created_at": b["created_at"], + } + for b in blocked + ] + await self.websocket_broadcast( + json.dumps( + { + "type": "blocked_destinations", + "blocked_destinations": blocked_list, + }, + ), + ) + except Exception as e: + print(f"_broadcast_blocked_destinations: failed: {e}") + # returns a dictionary of config def get_config_dict(self, context=None): ctx = context or self.current_context @@ -14467,6 +14964,10 @@ class ReticulumMeshChat: "lxmf_user_icon_background_colour": ctx.config.lxmf_user_icon_background_colour.get(), "lxmf_inbound_stamp_cost": ctx.config.lxmf_inbound_stamp_cost.get(), "lxmf_propagation_node_stamp_cost": ctx.config.lxmf_propagation_node_stamp_cost.get(), + "lxmf_flood_protection_enabled": ctx.config.lxmf_flood_protection_enabled.get(), + "lxmf_flood_threshold_per_minute": ctx.config.lxmf_flood_threshold_per_minute.get(), + "lxmf_flood_max_stamp_cost": ctx.config.lxmf_flood_max_stamp_cost.get(), + "lxmf_flood_cooldown_seconds": ctx.config.lxmf_flood_cooldown_seconds.get(), "page_archiver_enabled": ctx.config.page_archiver_enabled.get(), "page_archiver_max_versions": ctx.config.page_archiver_max_versions.get(), "archives_max_storage_gb": ctx.config.archives_max_storage_gb.get(), @@ -14967,40 +15468,90 @@ class ReticulumMeshChat: if not ctx or not ctx.database: return False try: - return ctx.database.misc.is_destination_blocked(destination_hash) + if ctx.database.misc.is_destination_blocked(destination_hash): + return True + # Check if any destination for this identity is blocked + announce = ctx.database.announces.get_announce_by_hash(destination_hash) + if announce and announce.get("identity_hash"): + identity_hash = announce["identity_hash"] + other_announces = ctx.database.announces.get_announces_by_identity_hash( + identity_hash + ) + for other in other_announces: + if ctx.database.misc.is_destination_blocked( + other["destination_hash"] + ): + return True + return False except Exception: return False def _lxmf_reticulum_enforce_block(self, destination_hash: str) -> None: """Apply Reticulum blackhole or drop_path after a peer was added to the block list.""" - if self.config.blackhole_integration_enabled.get(): - try: - if hasattr(self, "reticulum") and self.reticulum: - identity_hash = None - announce = self.database.announces.get_announce_by_hash( - destination_hash, + try: + if hasattr(self, "reticulum") and self.reticulum: + identity_hash = None + announce = self.database.announces.get_announce_by_hash( + destination_hash, + ) + if announce and announce.get("identity_hash"): + identity_hash = announce["identity_hash"] + target_hash = identity_hash or destination_hash + dest_bytes = bytes.fromhex(target_hash) + if hasattr(self.reticulum, "blackhole_identity"): + reason = ( + f"Blocked in MeshChatX (from {destination_hash})" + if identity_hash + else "Blocked in MeshChatX" ) - if announce and announce.get("identity_hash"): - identity_hash = announce["identity_hash"] - target_hash = identity_hash or destination_hash - dest_bytes = bytes.fromhex(target_hash) - if hasattr(self.reticulum, "blackhole_identity"): - reason = ( - f"Blocked in MeshChatX (from {destination_hash})" - if identity_hash - else "Blocked in MeshChatX" - ) - self.reticulum.blackhole_identity(dest_bytes, reason=reason) - else: - self.reticulum.drop_path(dest_bytes) - except Exception as e: - print(f"_lxmf_reticulum_enforce_block: blackhole failed: {e}") - else: - try: - if hasattr(self, "reticulum") and self.reticulum: - self.reticulum.drop_path(bytes.fromhex(destination_hash)) - except Exception as e: - print(f"_lxmf_reticulum_enforce_block: drop_path failed: {e}") + self.reticulum.blackhole_identity(dest_bytes, reason=reason) + else: + self.reticulum.drop_path(dest_bytes) + except Exception as e: + print(f"_lxmf_reticulum_enforce_block: failed: {e}") + + def _delete_contact_and_stamp_ticket( + self, destination_hash: str, context=None + ) -> None: + """Remove contact and stamp/ticket state for a blocked destination.""" + ctx = context or self.current_context + if not ctx or not ctx.database: + return + try: + # Delete contact if present + contact = ctx.database.contacts.get_contact_by_identity_hash( + destination_hash + ) + if contact and contact.get("id"): + ctx.database.contacts.delete_contact(contact["id"]) + except Exception as e: + print(f"_delete_contact_and_stamp_ticket: contact delete failed: {e}") + + try: + # Remove stamp costs and tickets from LXMRouter + if ctx.message_router: + dest_bytes = bytes.fromhex(destination_hash) + # Remove outbound stamp cost + if hasattr(ctx.message_router, "outbound_stamp_costs"): + ctx.message_router.outbound_stamp_costs.pop(dest_bytes, None) + # Remove tickets + if hasattr(ctx.message_router, "available_tickets"): + ctx.message_router.available_tickets["outbound"].pop( + dest_bytes, None + ) + ctx.message_router.available_tickets["inbound"].pop( + dest_bytes, None + ) + ctx.message_router.available_tickets["last_deliveries"].pop( + dest_bytes, None + ) + # Persist changes + if hasattr(ctx.message_router, "save_outbound_stamp_costs"): + ctx.message_router.save_outbound_stamp_costs() + if hasattr(ctx.message_router, "save_available_tickets"): + ctx.message_router.save_available_tickets() + except Exception as e: + print(f"_delete_contact_and_stamp_ticket: stamp/ticket cleanup failed: {e}") def banish_lxmf_peer(self, destination_hash: str, context=None) -> None: """Banish (block) an LXMF peer: persist block and apply Reticulum blackhole/drop when configured.""" @@ -15011,10 +15562,25 @@ class ReticulumMeshChat: return try: ctx.database.misc.add_blocked_destination(destination_hash) + # Block all known destinations for the same identity + announce = ctx.database.announces.get_announce_by_hash(destination_hash) + if announce and announce.get("identity_hash"): + identity_hash = announce["identity_hash"] + other_announces = ctx.database.announces.get_announces_by_identity_hash( + identity_hash + ) + for other in other_announces: + other_hash = other["destination_hash"] + if other_hash != destination_hash: + ctx.database.misc.add_blocked_destination(other_hash) + self._lxmf_reticulum_enforce_block(other_hash) + self._delete_contact_and_stamp_ticket(other_hash, context=ctx) except Exception as e: - print(f"banish_lxmf_peer: add_blocked_destination failed: {e}") + print(f"banish_lxmf_peer: failed: {e}") return self._lxmf_reticulum_enforce_block(destination_hash) + self._delete_contact_and_stamp_ticket(destination_hash, context=ctx) + AsyncUtils.run_async(self._broadcast_blocked_destinations()) def check_spam_keywords(self, title: str, content: str, context=None) -> bool: """Return whether title/content match configured spam keywords.""" @@ -15026,6 +15592,100 @@ class ReticulumMeshChat: except Exception: return False + def _apply_lxmf_flood_stamp_cost(self, cost: int, context=None) -> None: + """Apply the given inbound stamp cost for flood protection and re-announce.""" + ctx = context or self.current_context + if not ctx or not ctx.message_router or not ctx.local_lxmf_destination: + return + cost = max(0, min(254, cost)) + if cost < 1: + cost = 0 + ctx.config.lxmf_inbound_stamp_cost.set(cost) + ctx.message_router.set_inbound_stamp_cost( + ctx.local_lxmf_destination.hash, + cost, + ) + try: + ctx.local_lxmf_destination.display_name = ctx.config.display_name.get() + ctx.message_router.announce( + destination_hash=ctx.local_lxmf_destination.hash, + ) + except Exception as e: + print(f"_apply_lxmf_flood_stamp_cost: re-announce failed: {e}") + + def _check_lxmf_flood_protection(self, context=None) -> None: + """Check incoming LXMF message rate and auto-adjust stamp cost if flooding.""" + ctx = context or self.current_context + if not ctx or not ctx.config: + return + if not ctx.config.lxmf_flood_protection_enabled.get(): + return + # Do not interfere when block strangers is active (it uses max stamp) + if ctx.config.block_all_from_strangers.get(): + return + + now = time.time() + # Clean old timestamps (> 1 hour) + self._lxmf_incoming_timestamps = [ + t for t in self._lxmf_incoming_timestamps if now - t <= 3600.0 + ] + msgs_per_minute = len( + [t for t in self._lxmf_incoming_timestamps if now - t <= 60.0], + ) + + threshold = ctx.config.lxmf_flood_threshold_per_minute.get() + max_cost = ctx.config.lxmf_flood_max_stamp_cost.get() + current_cost = ctx.config.lxmf_inbound_stamp_cost.get() + if current_cost < 0: + current_cost = 0 + + # Determine base cost (the normal non-flood cost) + if self._flood_protection_current_cost is not None: + base_cost = self._flood_protection_current_cost + else: + base_cost = current_cost + + if msgs_per_minute > threshold: + # Flood detected: bump stamp cost + new_cost = min(current_cost + 2, max_cost) + if new_cost != current_cost: + print( + f"LXMF flood detected: {msgs_per_minute} msg/min " + f"(threshold {threshold}). Raising stamp cost from " + f"{current_cost} to {new_cost}.", + ) + if self._flood_protection_current_cost is None: + self._flood_protection_current_cost = base_cost + self._flood_protection_last_bump_time = now + self._apply_lxmf_flood_stamp_cost(new_cost, context=ctx) + elif current_cost > base_cost: + cooldown = ctx.config.lxmf_flood_cooldown_seconds.get() + if now - self._flood_protection_last_bump_time > cooldown: + # Step down by 1 toward base cost + new_cost = max(current_cost - 1, base_cost) + if new_cost != current_cost: + print( + f"LXMF flood subsided: {msgs_per_minute} msg/min. " + f"Lowering stamp cost from {current_cost} to {new_cost}.", + ) + self._apply_lxmf_flood_stamp_cost(new_cost, context=ctx) + if new_cost == base_cost: + self._flood_protection_current_cost = None + self._flood_protection_last_bump_time = 0 + + async def lxmf_flood_protection_cooldown_loop(self, session_id, context=None): + """Background loop to step down flood protection stamp cost during quiet periods.""" + ctx = context or self.current_context + if not ctx: + return + await asyncio.sleep(60) + while self.running and ctx.running and ctx.session_id == session_id: + try: + self._check_lxmf_flood_protection(context=ctx) + except Exception as e: + print(f"lxmf_flood_protection_cooldown_loop error: {e}") + await asyncio.sleep(30) + def _collect_lxmf_sieve_peer_haystack( self, peer_hash: str, @@ -15197,6 +15857,10 @@ class ReticulumMeshChat: print(f"Rejecting LXMF message from blocked source: {source_hash}") return + # track incoming message timestamps for flood protection + self._lxmf_incoming_timestamps.append(time.time()) + self._check_lxmf_flood_protection(context=ctx) + is_sideband_telemetry_request = False lxmf_fields = lxmf_message.get_fields() @@ -17110,6 +17774,13 @@ def main(): default=os.environ.get("MESHCHAT_RESTORE_SNAPSHOT"), ) + parser.add_argument( + "--reset-password", + action="store_true", + default=env_bool("MESHCHAT_RESET_PASSWORD", False), + help="Clear the stored password hash on startup so a new password can be set via the web UI. Can also be set via MESHCHAT_RESET_PASSWORD environment variable.", + ) + args = parser.parse_args() ssl_cert = (args.ssl_cert or "").strip() or None @@ -17123,7 +17794,14 @@ def main(): if args.no_crash_recovery: recovery.disable() - planned_storage_dir = args.storage_dir or os.path.join("storage") + planned_storage_dir = args.storage_dir + if not planned_storage_dir: + # On Android, prefer user-accessible external storage + android_external = _get_android_external_files_dir() + if android_external: + planned_storage_dir = android_external + else: + planned_storage_dir = os.path.join("storage") effective_storage_dir, migration_context = resolve_startup_storage( planned_storage_dir, ) @@ -17249,6 +17927,12 @@ def main(): reticulum_config_dir=reticulum_meshchat.reticulum_config_dir, ) + if args.reset_password: + if reticulum_meshchat.reset_password(): + print("Password has been reset. Set a new password via the web UI.") + else: + print("No password was set; nothing to reset.") + if args.backup_db: result = reticulum_meshchat.backup_database(args.backup_db) print(f"Backup written to {result['path']} ({result['size']} bytes)") diff --git a/meshchatx/src/backend/announce_manager.py b/meshchatx/src/backend/announce_manager.py index ff12522..ffb8ab8 100644 --- a/meshchatx/src/backend/announce_manager.py +++ b/meshchatx/src/backend/announce_manager.py @@ -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: diff --git a/meshchatx/src/backend/auto_propagation_manager.py b/meshchatx/src/backend/auto_propagation_manager.py index 58da785..5745d4d 100644 --- a/meshchatx/src/backend/auto_propagation_manager.py +++ b/meshchatx/src/backend/auto_propagation_manager.py @@ -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") diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index 8ddf4cb..0b017e7 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -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", diff --git a/meshchatx/src/backend/database/announces.py b/meshchatx/src/backend/database/announces.py index 5949e28..289cc86 100644 --- a/meshchatx/src/backend/database/announces.py +++ b/meshchatx/src/backend/database/announces.py @@ -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" diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py index 3a0e150..5296329 100644 --- a/meshchatx/src/backend/docs_manager.py +++ b/meshchatx/src/backend/docs_manager.py @@ -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'
  • {html_file}
  • ' + ) 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""" + + + + + MeshChatX Documentation + + + + +

    MeshChatX Documentation

    +
      + {"".join(index_links)} +
    + +""" + 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: diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index 2407fe4..f2f5cce 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -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, diff --git a/meshchatx/src/backend/nomadnet_downloader.py b/meshchatx/src/backend/nomadnet_downloader.py index 732933b..8b9ff6d 100644 --- a/meshchatx/src/backend/nomadnet_downloader.py +++ b/meshchatx/src/backend/nomadnet_downloader.py @@ -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, diff --git a/meshchatx/src/backend/recovery/health_monitor.py b/meshchatx/src/backend/recovery/health_monitor.py index 5a63100..93d2ca1 100644 --- a/meshchatx/src/backend/recovery/health_monitor.py +++ b/meshchatx/src/backend/recovery/health_monitor.py @@ -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): diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 71c51e7..dabca05 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -225,16 +225,16 @@ - +
  • - + - +
  • @@ -251,6 +251,19 @@ + +
  • + + + + +
  • +
  • @@ -277,16 +290,16 @@
  • - +
  • - + - +
  • @@ -319,16 +332,16 @@ - +
  • - + - +
  • @@ -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; diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue index 509d3de..8526e4a 100644 --- a/meshchatx/src/frontend/components/CommandPalette.vue +++ b/meshchatx/src/frontend/components/CommandPalette.vue @@ -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", diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue index 916a566..e78f2e1 100644 --- a/meshchatx/src/frontend/components/TutorialModal.vue +++ b/meshchatx/src/frontend/components/TutorialModal.vue @@ -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) { diff --git a/meshchatx/src/frontend/components/blocked/BlockedPage.vue b/meshchatx/src/frontend/components/blocked/BlockedPage.vue index 30eb53d..f8d9ac9 100644 --- a/meshchatx/src/frontend/components/blocked/BlockedPage.vue +++ b/meshchatx/src/frontend/components/blocked/BlockedPage.vue @@ -43,13 +43,16 @@
    -
    +

    {{ $t("banishment.loading_items") }}

    @@ -63,8 +66,8 @@
    @@ -78,15 +81,15 @@ />
    -
    +

    - {{ item.display_name || $t("call.unknown") }} + {{ identity.display_name || $t("call.unknown") }}

    {{ $t("banishment.node") }} @@ -98,7 +101,7 @@ {{ $t("banishment.user") }} @@ -107,32 +110,52 @@

    - {{ item.destination_hash }} + {{ identity.identity_hash }}

    -
    - {{ $t("banishment.banished_at") }} {{ formatTimeAgo(item.created_at) }} + + +
    +

    + {{ $t("banishment.blocked_destinations") }} +

    +
    +
    + {{ dest.destination_hash }} + + {{ formatTimeAgo(dest.created_at) }} + +
    +
    +
    - Source: {{ item.rns_source }} -
    -
    - "{{ item.rns_reason }}" + "{{ identity.rns_reason }}" +
    +
    + Source: {{ identity.rns_source }}
    -
    -
    - - -
    -
    - - -
    - - - - - -
    -
    -
    - - -
    -
    - - - - - -
    - - - +
    +

    LXST is disabled

    +

    + Telephony is currently disabled. Enable it to make and receive calls. +

    +
    -
    -
    -
    -
    - +
    diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue index 6125218..103e41f 100644 --- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue +++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue @@ -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; diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue index 27d29f7..22678f6 100644 --- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue +++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue @@ -347,6 +347,16 @@ > Heard: {{ formatLastHeard(iface.last_heard) }} +
    @@ -456,20 +466,6 @@ >Loc: {{ iface.latitude }}, {{ iface.longitude }}
    - -
    - - TX {{ discoveredBytes(iface).tx }} · RX - {{ discoveredBytes(iface).rx }} -
    @@ -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); diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue index 5c76aca..1577759 100644 --- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue +++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue @@ -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); diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index f55ce80..f4f1394 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -2,10 +2,7 @@