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