diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c20fce8c25..a3400f3ffa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,10 +27,6 @@ on: - "scripts/desktop/**" - ".github/**" -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} - # This workflow uses custom actions (prepare-build and prepare-release) defined in: # # .github/actions/ @@ -70,14 +66,36 @@ jobs: # ============================= # Create release, but only if it's triggered by tag push. -# On pull requests/commits push, this job will always complete. +# Create simplex-chat-libs release core-, but only if the head commit of a push to master or stable changes the version in simplex-chat.cabal. +# On pull requests and other pushes, this job will always complete. + +# Secrets: +# ------- +# NODEJS_REPO_TOKEN +# Only select repositories: simplex-chat-libs +# Permissions: +# * Contents (Read and Write) maybe-release: runs-on: ubuntu-latest + outputs: + libs_tag: ${{ steps.libs_commit.outputs.tag }} + core_changed: ${{ steps.core.outputs.core }} steps: - name: Clone project - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' || github.event_name == 'pull_request' uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Check chat core change + id: core + if: github.event_name == 'pull_request' + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + git fetch -q --depth=1 origin "$BASE" + python3 scripts/ci/packages_release.py changes "$BASE" | grep '^core=' >> "$GITHUB_OUTPUT" - name: Build changelog id: build_changelog @@ -103,6 +121,39 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check version change + id: check_version + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable' + shell: bash + run: | + version=$(sed -n 's/^version: *//p' simplex-chat.cabal) + prev_version=$(git show HEAD^1:simplex-chat.cabal | sed -n 's/^version: *//p') + if [ "$version" != "$prev_version" ]; then + echo "version=$version" >> $GITHUB_OUTPUT + fi + + - name: Record libs release + id: libs_commit + if: steps.check_version.outputs.version + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + VERSION: ${{ steps.check_version.outputs.version }} + run: | + commit=$(python3 scripts/ci/packages_release.py record --core "$VERSION" --commit "$GITHUB_SHA") + echo "tag=core-$VERSION" >> "$GITHUB_OUTPUT" + echo "commit=$commit" >> "$GITHUB_OUTPUT" + + # creates the tag on the commit that recorded the release + - name: Create libs release + if: steps.check_version.outputs.version + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ steps.libs_commit.outputs.tag }} + target_commitish: ${{ steps.libs_commit.outputs.commit }} + token: ${{ secrets.NODEJS_REPO_TOKEN }} + # ========================= # Linux Build # ========================= @@ -111,6 +162,9 @@ jobs: name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.runner }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-linux-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.ghc }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} strategy: fail-fast: false matrix: @@ -129,6 +183,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' + libs: true - os: 24.04 os_underscore: 24_04 arch: x86_64 @@ -143,6 +198,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' + libs: true - os: 24.04 os_underscore: 24_04 arch: aarch64 @@ -245,6 +301,34 @@ jobs: done strip /out/simplex-chat + # Also for pull requests that change the chat core, for the package tests (packages-pr). + - name: Build library + if: matrix.libs && (needs.maybe-release.outputs.libs_tag || (needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64')) + shell: docker exec -t builder sh -eu {0} + run: | + SKIP_VLC=1 scripts/desktop/build-lib-linux.sh + name=simplex-chat-libs-linux-${{ matrix.arch }} + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Upload library artifact + if: matrix.libs && needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64' + uses: actions/upload-artifact@v7 + with: + name: simplex-chat-libs-linux-${{ matrix.arch }} + path: release-libs/simplex-chat-libs-linux-${{ matrix.arch }}.zip + if-no-files-found: error + + - name: Upload library + if: needs.maybe-release.outputs.libs_tag && matrix.libs + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.maybe-release.outputs.libs_tag }} + files: release-libs/*.zip + token: ${{ secrets.NODEJS_REPO_TOKEN }} + - name: Build CLI deb if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: docker exec -t builder sh -eu {0} @@ -398,7 +482,7 @@ jobs: name: "ubuntu-22.04-x86_64 (Postgres lib), GHC: ${{ needs.variables.outputs.GHC_VER }}" needs: [maybe-release, variables] runs-on: ubuntu-22.04 - if: startsWith(github.ref, 'refs/tags/v') + if: needs.maybe-release.outputs.libs_tag steps: - name: Checkout Code uses: actions/checkout@v3 @@ -468,15 +552,18 @@ jobs: ARCH=x86_64 GHC_VER=${{ needs.variables.outputs.GHC_VER }} BUILD_DIR=$(echo dist-newstyle/build/${ARCH}-linux/ghc-${GHC_VER}/simplex-chat-*) - mkdir -p postgres-libs - cp ${BUILD_DIR}/build/libsimplex.so postgres-libs/ - cp ${BUILD_DIR}/build/deps/* postgres-libs/ + mkdir -p postgres-libs/libs + cp ${BUILD_DIR}/build/libsimplex.so postgres-libs/libs/ + cp ${BUILD_DIR}/build/deps/* postgres-libs/libs/ + (cd postgres-libs && zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs) - - name: Upload postgres libs artifact - uses: actions/upload-artifact@v4 + - name: Upload postgres library + uses: softprops/action-gh-release@v2 with: - name: simplex-libs-linux-postgres-x86_64 - path: postgres-libs/ + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.maybe-release.outputs.libs_tag }} + files: postgres-libs/*.zip + token: ${{ secrets.NODEJS_REPO_TOKEN }} - name: Fix permissions for cache shell: bash @@ -492,16 +579,21 @@ jobs: name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.os }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-macos-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} strategy: fail-fast: false matrix: include: - os: macos-latest + arch: aarch64 ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-macos-aarch64 desktop_asset_name: simplex-desktop-macos-aarch64.dmg openssl_dir: "/opt/homebrew/opt" - os: macos-15-intel + arch: x86_64 ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-macos-x86-64 desktop_asset_name: simplex-desktop-macos-x86_64.dmg @@ -528,7 +620,7 @@ jobs: run: | echo "ignore-project: False" >> cabal.project.local echo "package simplexmq" >> cabal.project.local - echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local + echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local echo "" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local @@ -601,6 +693,26 @@ jobs: exit 1 fi + - name: Build library + id: mac_lib_build + if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag && steps.mac_cli_build.outcome == 'success' }} + shell: bash + run: | + SKIP_VLC=1 scripts/desktop/build-lib-mac.sh + name=simplex-chat-libs-macos-${{ matrix.arch }} + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Upload library + if: ${{ !cancelled() && steps.mac_lib_build.outcome == 'success' }} + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.maybe-release.outputs.libs_tag }} + files: release-libs/*.zip + token: ${{ secrets.NODEJS_REPO_TOKEN }} + # ========================= # Windows Build # ========================= @@ -609,6 +721,9 @@ jobs: name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.os }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-windows-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} strategy: fail-fast: false matrix: @@ -648,6 +763,7 @@ jobs: git perl make + zip pacboy: >- toolchain:p cmake:p @@ -676,6 +792,28 @@ jobs: echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + - name: Build library + if: needs.maybe-release.outputs.libs_tag + shell: msys2 {0} + run: | + export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) + SKIP_VLC=1 scripts/desktop/build-lib-windows.sh + dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll + name=simplex-chat-libs-windows-x86_64 + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64 -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + cp libsimplex.lib release-libs/$name/libs/ + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Upload library + if: needs.maybe-release.outputs.libs_tag + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.maybe-release.outputs.libs_tag }} + files: release-libs/*.zip + token: ${{ secrets.NODEJS_REPO_TOKEN }} + - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') uses: ./.github/actions/prepare-release @@ -711,108 +849,32 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} # ========================= -# NodeJS libs release +# Packages release # ========================= -# Downloads Desktop builds, extracts and archives libraries for NodeJS addon. -# Depends on Linux/MacOS, executes only on release. +# A new library release is tested with the Node.js and Python packages and updates them in a release PR. -# Secrets: -# ------- -# NODEJS_REPO_TOKEN -# Only select repositories: simplex-chat-libs -# Permissions: -# * Contents (Read and Write) + packages: + needs: [maybe-release, build-linux, build-linux-postgres, build-macos, build-windows] + if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag }} + # the called workflow can't be granted more than its caller, and its publish job needs these + permissions: + contents: read + id-token: write + uses: ./.github/workflows/packages.yml + with: + libs_release: ${{ needs.maybe-release.outputs.libs_tag }} + secrets: inherit - release-nodejs-libs: - runs-on: ubuntu-latest - needs: [build-linux, build-linux-postgres, build-macos] - if: startsWith(github.ref, 'refs/tags/v') && (!cancelled()) - steps: - - name: Checkout current repository - uses: actions/checkout@v6 +# A pull request that changes the chat core is tested with the Node.js and Python packages. - - name: Install packages for archiving - run: sudo apt install -y msitools gcc-mingw-w64 - - - name: Download postgres libs artifact - if: needs.build-linux-postgres.result == 'success' - uses: actions/download-artifact@v4 - with: - name: simplex-libs-linux-postgres-x86_64 - path: ${{ runner.temp }}/postgres-libs - - - name: Build archives - run: | - INIT_DIR='${{ runner.temp }}/artifacts' - RELEASE_DIR='${{ runner.temp }}/release-assets' - TAG='${{ github.ref_name }}' - URL='https://github.com/${{ github.repository }}/releases/download' - PREFIX='${{ github.event.repository.name }}-libs' - # Windows-specific - FILE_URL='https://raw.githubusercontent.com/${{ github.repository }}/refs/tags/${{ github.ref_name }}' - - # Setup directories - mkdir "$INIT_DIR" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Downlaod desktop release - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-ubuntu-22_04-x86_64.deb" -o linux.deb - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-macos-aarch64.dmg" -o macos-aarch64.dmg - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-macos-x86_64.dmg" -o macos-x86_64.dmg - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-windows-x86_64.msi" -o windows-x86_64.msi - - # Linux - # ----- - # Extract libraries - dpkg-deb -R linux.deb linux-out/ && cd linux-out/opt/simplex/lib/app/resources - # Preprare directory - mkdir libs && cp *.so libs/ - # Archive - zip -r "${PREFIX}-linux-x86_64.zip" libs - # Back to original dir - mv "${PREFIX}-linux-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # MacOS: aarch64 - # -------------- - 7z x macos-aarch64.dmg -omacos1-out/ && cd macos1-out/SimpleX/SimpleX.app/Contents/app/resources/ - mkdir libs && cp *.dylib libs/ - zip -r "${PREFIX}-macos-aarch64.zip" libs - mv "${PREFIX}-macos-aarch64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Macos: x86_64 - # ------------- - 7z x macos-x86_64.dmg -omacos2-out/ && cd macos2-out/SimpleX/SimpleX.app/Contents/app/resources/ - mkdir libs && cp *.dylib libs/ - zip -r "${PREFIX}-macos-x86_64.zip" libs - mv "${PREFIX}-macos-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Windows: x86_64 - # --------------- - msiextract windows-x86_64.msi -C windows-out && cd windows-out/SimpleX/app/resources - - # We need to generate library that exports symbols from Windows dll - curl --proto '=https' --tlsv1.2 -sSf -LO "${FILE_URL}/libsimplex.dll.def" - x86_64-w64-mingw32-dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll - - mkdir libs && cp *.dll *.lib libs/ - zip -r "${PREFIX}-windows-x86_64.zip" libs - mv "${PREFIX}-windows-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Linux PostgreSQL (only if postgres build succeeded) - # ------------------------------------------------- - POSTGRES_LIBS='${{ runner.temp }}/postgres-libs' - if [ -d "$POSTGRES_LIBS" ]; then - mkdir -p linux-postgres/libs - cp "${POSTGRES_LIBS}"/*.so linux-postgres/libs/ - cd linux-postgres - zip -r "${PREFIX}-linux-x86_64-postgres.zip" libs - mv "${PREFIX}-linux-x86_64-postgres.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - fi - - - name: Create release in libs repo and upload artifacts - uses: softprops/action-gh-release@v2 - with: - repository: ${{ github.repository }}-libs - tag_name: ${{ github.ref_name }} - files: ${{ runner.temp }}/release-assets/* - token: ${{ secrets.NODEJS_REPO_TOKEN }} + packages-pr: + needs: [maybe-release, build-linux] + # build-linux uploads the library before its chat tests run + if: ${{ !cancelled() && needs.maybe-release.outputs.core_changed }} + permissions: + contents: read + id-token: write + uses: ./.github/workflows/packages.yml + with: + libs_artifact: true diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000000..01c2c1be33 --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,382 @@ +name: packages + +# Releases simplex-chat (npm, PyPI) and @simplex-chat/types (npm) from master and stable: +# 1. A missing Node.js add-on release (nodejs-addon-) is built and uploaded to simplex-chat-libs. +# 2. Versions that are not published yet are published, and recorded in releases.toml of simplex-chat-libs. +# npm prerelease versions get dist-tag beta, others latest. +# 3. The release PR packages-release/ pins new library and add-on releases with their SHA-256 +# and bumps the versions of changed packages. Merging it publishes them (step 2). +# Package tests run on every platform with the pinned libsimplex. build.yml runs them with the libsimplex it builds: +# for pull requests that change the chat core (linux-x86_64), and for a new library release before its release PR. + +# Publishing uses trusted publishing (OIDC) for this workflow on npm and PyPI, +# or the NPM_TOKEN and PYPI_API_TOKEN secrets when they are set. +# Test repositories: variables NPM_SCOPE, PYPI_PROJECT and PYPI_URL / PYPI_UPLOAD_URL (TestPyPI) +# publish under other names. + +# Secrets: +# ------- +# NODEJS_REPO_TOKEN +# Only select repositories: simplex-chat-libs +# Permissions: +# * Contents (Read and Write) +# RELEASE_PR_TOKEN +# Only select repositories: simplex-chat +# Permissions: +# * Contents (Read and Write) +# * Pull requests (Read and Write) +# A PR opened with GITHUB_TOKEN does not run the checks of other workflows. + +on: + workflow_dispatch: + inputs: + nodejs_version: + description: "simplex-chat npm version, bumped when needed by default" + required: false + types_version: + description: "@simplex-chat/types version, bumped when needed by default" + required: false + python_version: + description: "simplex-chat PyPI version, bumped when needed by default" + required: false + # build.yml: a pull request that changes the chat core, or a new library release + workflow_call: + inputs: + libs_artifact: + description: "test with simplex-chat-libs-linux-x86_64 built by the calling run" + type: boolean + default: false + libs_release: + description: "test with this simplex-chat-libs release" + type: string + default: "" + push: + branches: + - master + - stable + paths: + - "packages/simplex-chat-nodejs/**" + - "packages/simplex-chat-client/types/typescript/**" + - "packages/simplex-chat-python/**" + - ".github/workflows/packages.yml" + pull_request: + paths: + - "packages/simplex-chat-nodejs/**" + - "packages/simplex-chat-client/types/typescript/**" + - "packages/simplex-chat-python/**" + - ".github/workflows/packages.yml" + +concurrency: + # build.yml and this workflow both run for a pull request that changes the core and the packages + group: ${{ github.workflow }}-packages-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + +jobs: + addon: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + release_branch: ${{ steps.branch.outputs.release_branch }} + missing: ${{ steps.release.outputs.missing }} + platforms: ${{ steps.plan.outputs.platforms }} + test: ${{ steps.plan.outputs.test }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Add-on release tag + id: tag + run: echo "tag=$(python3 scripts/ci/packages_release.py addon-tag)" >> "$GITHUB_OUTPUT" + + - name: Release branch + id: branch + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') + run: echo "release_branch=true" >> "$GITHUB_OUTPUT" + + - name: Check the add-on release + id: release + if: steps.branch.outputs.release_branch + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + TAG: ${{ steps.tag.outputs.tag }} + run: | + if ! gh release view "$TAG" --repo "$LIBS_REPOSITORY" > /dev/null 2>&1; then + echo "missing=true" >> "$GITHUB_OUTPUT" + fi + + - name: Build plan + id: plan + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + RELEASE_MISSING: ${{ steps.release.outputs.missing }} + LIBS_ARTIFACT: ${{ inputs.libs_artifact }} + LIBS_RELEASE: ${{ inputs.libs_release }} + run: | + if [ -n "$BASE" ]; then git fetch -q --depth=1 origin "$BASE"; fi + python3 scripts/ci/packages_release.py build-plan --event "$EVENT" --base "$BASE" \ + --release-missing "$RELEASE_MISSING" --libs-artifact "$LIBS_ARTIFACT" --libs-release "$LIBS_RELEASE" >> "$GITHUB_OUTPUT" + + build: + name: "build ${{ matrix.platform }}" + needs: addon + if: needs.addon.outputs.platforms != '[]' + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.addon.outputs.platforms) }} + defaults: + run: + shell: bash + working-directory: packages/simplex-chat-nodejs + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + # The local types: the dependency can be bumped to a version that is not published yet. + - name: Build simplex-chat (npm) and add-on + run: | + (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript + npx --yes node-gyp rebuild + npx tsc && cp src/simplex.* dist + cp build/Release/simplex.node build/simplex-chat-nodejs-${{ matrix.platform }}.node + + - name: Download libsimplex of this run + if: needs.addon.outputs.test && inputs.libs_artifact + uses: actions/download-artifact@v8 + with: + name: simplex-chat-libs-${{ matrix.platform }} + path: ${{ runner.temp }}/libsimplex + + - name: Download libsimplex release + if: needs.addon.outputs.test && inputs.libs_release + env: + URL: https://github.com/${{ github.repository }}-libs/releases/download/${{ inputs.libs_release }}/simplex-chat-libs-${{ matrix.platform }}.zip + run: curl -fsSL --create-dirs -o "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$URL" + + - name: Unpack libsimplex + if: needs.addon.outputs.test && (inputs.libs_artifact || inputs.libs_release) + run: python -m zipfile -e "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$RUNNER_TEMP/libsimplex" + + - name: Test simplex-chat (npm) + if: needs.addon.outputs.test + env: + SIMPLEX_LIBS_DIR: ${{ (inputs.libs_artifact || inputs.libs_release) && format('{0}/libsimplex/libs', runner.temp) || '' }} + # A failed test can leave chat controllers running, and jest then waits for them. + # Tests on the public preset servers fail in CI when a server is slow to connect. + run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)' + + - name: Test simplex-chat (PyPI) + if: needs.addon.outputs.test + working-directory: packages/simplex-chat-python + run: | + python -m pip install -e ".[test]" + python -m pytest -q + + - name: Upload add-on artifact + uses: actions/upload-artifact@v7 + with: + name: simplex-nodejs-addon-${{ matrix.platform }} + path: packages/simplex-chat-nodejs/build/simplex-chat-nodejs-${{ matrix.platform }}.node + if-no-files-found: error + + publish: + needs: addon + # Trusted publishing accepts this workflow only when it is not called by another one (build.yml). + if: needs.addon.outputs.release_branch && contains(github.workflow_ref, '/.github/workflows/packages.yml@') + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + # set by a publish step after it succeeds, so a failed publish still reports the earlier ones + outputs: + types: ${{ steps.types.outputs.version }} + nodejs: ${{ steps.nodejs.outputs.version }} + python: ${{ steps.python.outputs.version }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Versions to publish + id: plan + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + if: steps.plan.outputs.types_version || steps.plan.outputs.nodejs_version + uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Setup Python + if: steps.plan.outputs.python_version + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + # Before the test names: the build installs @simplex-chat/types from this repository. + - name: Build and test simplex-chat (npm) + if: steps.plan.outputs.nodejs_version + working-directory: packages/simplex-chat-nodejs + run: | + (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript + npx tsc && cp src/simplex.* dist + npx jest tests/libs.test.ts tests/api.unit.test.ts + + - name: Use test package names + if: vars.NPM_SCOPE || vars.PYPI_PROJECT + run: python3 scripts/ci/packages_release.py test-names + + - name: Publish @simplex-chat/types + id: types + if: steps.plan.outputs.types_version + working-directory: packages/simplex-chat-client/types/typescript + env: + VERSION: ${{ steps.plan.outputs.types_version }} + DIST_TAG: ${{ steps.plan.outputs.types_dist_tag }} + run: | + npm install --no-audit --no-fund + npm publish --provenance --access public --tag "$DIST_TAG" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Publish simplex-chat (npm) + id: nodejs + if: steps.plan.outputs.nodejs_version + working-directory: packages/simplex-chat-nodejs + env: + VERSION: ${{ steps.plan.outputs.nodejs_version }} + DIST_TAG: ${{ steps.plan.outputs.nodejs_dist_tag }} + run: | + npm publish --provenance --access public --tag "$DIST_TAG" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build and test simplex-chat (PyPI) + if: steps.plan.outputs.python_version + working-directory: packages/simplex-chat-python + run: | + python -m pip install build -e ".[test]" + python -m pytest -q + python -m build + + - name: Publish simplex-chat (PyPI) + if: steps.plan.outputs.python_version + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/simplex-chat-python/dist + repository-url: ${{ vars.PYPI_UPLOAD_URL || 'https://upload.pypi.org/legacy/' }} + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Report the published PyPI version + id: python + if: steps.plan.outputs.python_version + env: + VERSION: ${{ steps.plan.outputs.python_version }} + run: echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # The only job here that writes to simplex-chat-libs; publish runs npm and pip installs without its token. + record: + needs: publish + if: ${{ !cancelled() && (needs.publish.outputs.types || needs.publish.outputs.nodejs || needs.publish.outputs.python) }} + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Record published versions + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + TYPES: ${{ needs.publish.outputs.types }} + NODEJS: ${{ needs.publish.outputs.nodejs }} + PYTHON: ${{ needs.publish.outputs.python }} + run: | + python3 scripts/ci/packages_release.py record --commit "$GITHUB_SHA" \ + --types "$TYPES" --nodejs "$NODEJS" --python "$PYTHON" + + release: + needs: [addon, build] + # A merged release PR changes only versions and pins. + if: >- + !cancelled() && needs.addon.outputs.release_branch && needs.build.result != 'failure' + && !startsWith(github.event.head_commit.message, 'packages: release') + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # Outside the checkout: the release PR commits every change in it. + - name: Download add-on artifacts + if: needs.addon.outputs.missing + uses: actions/download-artifact@v8 + with: + pattern: simplex-nodejs-addon-* + merge-multiple: true + path: ${{ runner.temp }}/addons + + - name: Create add-on release + if: needs.addon.outputs.missing + uses: softprops/action-gh-release@v3 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.addon.outputs.tag }} + files: ${{ runner.temp }}/addons/*.node + token: ${{ secrets.NODEJS_REPO_TOKEN }} + + - name: Update pins and versions + id: prepare + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + NODEJS_VERSION: ${{ inputs.nodejs_version }} + TYPES_VERSION: ${{ inputs.types_version }} + PYTHON_VERSION: ${{ inputs.python_version }} + run: | + python3 scripts/ci/packages_release.py prepare \ + --nodejs-version "$NODEJS_VERSION" \ + --types-version "$TYPES_VERSION" \ + --python-version "$PYTHON_VERSION" \ + --summary "$RUNNER_TEMP/summary.md" + echo "title=$(head -n 1 "$RUNNER_TEMP/summary.md")" >> "$GITHUB_OUTPUT" + + - name: Create or update release PR + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.RELEASE_PR_TOKEN }} + branch: packages-release/${{ github.ref_name }} + base: ${{ github.ref_name }} + title: ${{ steps.prepare.outputs.title }} + commit-message: ${{ steps.prepare.outputs.title }} + body-path: ${{ runner.temp }}/summary.md + author: SimpleX Chat + committer: SimpleX Chat + delete-branch: true diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 6fd0efc128..6af17e3fb8 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1226,6 +1226,7 @@ enum ChatEvent: Decodable, ChatAPIResult { // group events case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) @@ -1309,6 +1310,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .chatItemsDeleted: "chatItemsDeleted" case .groupChatItemsDeleted: "groupChatItemsDeleted" case .receivedGroupInvitation: "receivedGroupInvitation" + case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup" case .userAcceptedGroupSent: "userAcceptedGroupSent" case .groupLinkConnecting: "groupLinkConnecting" case .businessLinkConnecting: "businessLinkConnecting" @@ -1395,6 +1397,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 7c1e8e65ab..1ee79e1c4f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -474,10 +474,27 @@ final class ChatModel: ObservableObject { // another in its place; only the badge alert shows regardless. Set while rendering, so not published. var chatListBanner: ChatListBanner? + var fileToOpen: (fileId: Int64, chatId: String?, open: (CIFile) -> Void)? + func bannerSlotFree(for banner: ChatListBanner) -> Bool { chatListBanner == nil || chatListBanner == banner } + func openWhenReceived(_ file: CIFile, open: @escaping (CIFile) -> Void) { + fileToOpen = (fileId: file.fileId, chatId: chatId, open: open) + } + + func receivedFileToOpen(_ file: CIFile) -> Bool { + if let f = fileToOpen { f.fileId == file.fileId && f.chatId == chatId } else { false } + } + + func openReceivedFile(_ file: CIFile) { + if let f = fileToOpen, receivedFileToOpen(file) { + fileToOpen = nil + f.open(file) + } + } + static let shared = ChatModel() let im = ItemsModel.shared diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 5359769545..60ecfea09c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2747,7 +2747,8 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_) - case let .receivedGroupInvitation(user, groupInfo, _, _): + case let .receivedGroupInvitation(user, groupInfo, _, _), + let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _): if active(user) { await MainActor.run { m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated @@ -2914,6 +2915,9 @@ func processReceivedMsg(_ res: ChatEvent) async { await chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): await chatItemSimpleUpdate(user, aChatItem) + if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) { + await MainActor.run { m.openReceivedFile(file) } + } case let .rcvFileSndCancelled(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 8bb76ca155..5c65a310b5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -26,7 +26,7 @@ struct CIFileView: View { var body: some View { if smallViewSize != nil { fileIndicator() - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) } else { // reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil } @@ -56,7 +56,7 @@ struct CIFileView: View { .padding(.bottom, 6) .padding(.leading, 10) .padding(.trailing, 12) - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) .disabled(!itemInteractive) } } @@ -85,7 +85,7 @@ struct CIFileView: View { return false } - private func fileAction() { + private func fileAction(_ file: CIFile?) { logger.debug("CIFileView fileAction") if let file = file { switch (file.fileStatus) { @@ -93,6 +93,7 @@ struct CIFileView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { fileAction($0) } Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { @@ -117,7 +118,7 @@ struct CIFileView: View { case .rcvComplete: logger.debug("CIFileView fileAction - in .rcvComplete") if let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") @@ -128,12 +129,12 @@ struct CIFileView: View { case .sndStored: logger.debug("CIFileView fileAction - in .sndStored") if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case .sndComplete: logger.debug("CIFileView fileAction - in .sndComplete") if let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case let .sndError(sndFileError): logger.debug("CIFileView fileAction - in .sndError") @@ -146,6 +147,14 @@ struct CIFileView: View { } } + private func openFile(_ file: CIFile, _ fileSource: CryptoFile) { + if isMarkdownFile(file) { + showMarkdownFile(file) + } else { + saveCryptoFile(fileSource) + } + } + @ViewBuilder private func fileIndicator() -> some View { if let file = file { switch file.fileStatus { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index ddb58fdfd1..fecd1ba891 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -39,6 +39,13 @@ struct CIGroupInvitationView: View { Divider().frame(width: frameWidth) + if chat.chatInfo.groupInfo != nil { + Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: "")) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .overlay(DetermineWidth()) + } + if action { VStack(alignment: .leading, spacing: 2) { groupInvitationText() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index fb6ccb1ea3..469fc5a3fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -54,6 +54,7 @@ struct CIImageView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { _ in showFullScreenImage = true } Task { if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 4eb187bcac..42259361b7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -52,7 +52,7 @@ struct CILinkView: View { func openBrowserAlert(uri: String) { let (url, err) = sanitizeUri(uri) if let url { - let uriStr = url.uri.absoluteString + let uriStr = displayedUri(url.uri.absoluteString) showAlert( NSLocalizedString("Open link?", comment: "alert title"), message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr, @@ -90,9 +90,9 @@ func openBrowserAlert(uri: String) { func showInvalidLinkAlert(_ uri: String, error: String? = nil) { let message = if let error, !error.isEmpty { - error + "\n" + uri + error + "\n" + displayedUri(uri) } else { - uri + displayedUri(uri) } showAlert( NSLocalizedString("Invalid link", comment: "alert title"), @@ -101,6 +101,25 @@ func showInvalidLinkAlert(_ uri: String, error: String? = nil) { ) } +func unicodeMarker(_ s: Unicode.Scalar) -> String { + String(format: "[U+%04X]", s.value) +} + +func displayedUri(_ uri: String) -> String { + let authorityStart = uri.range(of: "://")?.upperBound ?? uri.startIndex + let authority = authorityStart ..< (uri[authorityStart...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex) + var shown = "" + for i in uri.unicodeScalars.indices { + let s = uri.unicodeScalars[i] + if s.properties.generalCategory == .format || (!s.isASCII && authority.contains(i)) { + shown += unicodeMarker(s) + } else { + shown.unicodeScalars.append(s) + } + } + return shown +} + func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) { let parsed = parseSanitizeUri(s, safe: false) return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 0df17dfe3d..339d119362 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -120,6 +120,25 @@ struct CIVideoView: View { } } + private func playReceived(_ file: CIFile) { + setupPlayer(file) + if urlDecrypted == nil { + decrypt(file: file, completed: playVideo) + } else { + playVideo() + } + } + + private func playVideo() { + if smallView { + showFullScreenPlayer = urlDecrypted != nil + } else if let urlDecrypted { + m.stopPreviousRecPlay = urlDecrypted + videoPlaying = true + player?.play() + } + } + private func setupPlayer(_ file: CIFile?) { let newUrl = getLoadedVideo(file) if newUrl == url { @@ -424,6 +443,7 @@ struct CIVideoView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file, open: playReceived) Task { if let user = m.currentUser { await receiveFile(user, file.fileId, false, false) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 64fa2a4dd3..6c89a5f788 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -303,6 +303,11 @@ struct VoiceMessagePlayer: View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { playPauseIcon(icon, theme.colors.primary) .simultaneousGesture(TapGesture().onEnded { + chatModel.openWhenReceived(recordingFile) { file in + if let recordingSource = getLoadedFileSource(file) { + startPlayback(recordingSource) + } + } Task { if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift new file mode 100644 index 0000000000..aa7e827d6b --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift @@ -0,0 +1,215 @@ +import SwiftUI +import SimpleXChat +import Ink + +private let MAX_MARKDOWN_VIEW_SIZE: Int64 = 1_048_576 +private let MAX_MARKDOWN_LINE_LENGTH = 15_000 +private let MAX_MARKDOWN_NESTING = 16 +private let MAX_MARKDOWN_LINK_NESTING = 4 +private let MAX_MARKDOWN_TABLE_ROWS = 200 +private let MAX_MARKDOWN_IMAGE_DIMENSION: CGFloat = 4320 +private let MAX_MARKDOWN_IMAGE_PIXELS: CGFloat = 2160 * 4320 + +func isMarkdownFile(_ file: CIFile) -> Bool { + let ext = (file.fileName as NSString).pathExtension.lowercased() + return file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && (ext == "md" || ext == "markdown") +} + +func showMarkdownFile(_ file: CIFile) { + guard let fileSource = getLoadedFileSource(file) else { return } + Task { + do { + let data = try getFileData(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs) + let text = decodedText(data) + let lines = text.components(separatedBy: "\n") + let html = withinMarkdownLimits(lines) + ? MarkdownParser(modifiers: [Modifier(target: .html) { escapedHTML($0.markdown) }, imagesWithinBudget()]).html(from: text) + : nil + let shownLines = lines.flatMap(chunkedLine) + let hasBidiControls = text.unicodeScalars.contains(where: isBidiControl) + await MainActor.run { + showAppSheet { + MarkdownFileView(file: file, fileSource: fileSource, lines: shownLines, html: html, hasBidiControls: hasBidiControls) + } + } + } catch { + await MainActor.run { + showAlert(NSLocalizedString("Error reading file", comment: "alert title"), message: error.localizedDescription) + } + } + } +} + +private func decodedText(_ data: Data) -> String { + var text = String.UnicodeScalarView() + var parser = Unicode.UTF8.ForwardParser() + var bytes = data.makeIterator() + while true { + switch parser.parseScalar(from: &bytes) { + case let .valid(encoded): text.append(replacingControlChar(Unicode.UTF8.decode(encoded))) + case let .error(length): text.append(contentsOf: repeatElement("?" as Unicode.Scalar, count: length)) + case .emptyInput: return String(text).replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + } + } +} + +private func replacingControlChar(_ s: Unicode.Scalar) -> Unicode.Scalar { + s.value < 0x20 && s != "\t" && s != "\n" && s != "\r" ? "?" : s +} + +private func withinMarkdownLimits(_ lines: [String]) -> Bool { + var tableRows = 0 + var linkNesting = 0 + for line in lines { + tableRows = line.contains("|") ? tableRows + 1 : 0 + if line.count > MAX_MARKDOWN_LINE_LENGTH || nestingLevel(line) > MAX_MARKDOWN_NESTING || tableRows > MAX_MARKDOWN_TABLE_ROWS || !linkNestingWithinLimit(line, &linkNesting) { + return false + } + } + return true +} + +private func linkNestingWithinLimit(_ line: String, _ nesting: inout Int) -> Bool { + if line.isEmpty { nesting = 0 } + for c in line { + if c == "[" { + nesting += 1 + if nesting > MAX_MARKDOWN_LINK_NESTING { return false } + } else if c == "]" && nesting > 0 { + nesting -= 1 + } + } + return true +} + +private func nestingLevel(_ line: String) -> Int { + let content = line.drop(while: { $0 == " " || $0 == "\t" || $0 == ">" }) + let prefix = line[.." }.count + let listItem = content.first.map { "-*+".contains($0) || $0.isNumber } ?? false + return quotes + (listItem ? (prefix.count - quotes) / 2 : 0) +} + +private func chunkedLine(_ line: String) -> [String] { + let chars = Array(line) + return stride(from: 0, to: max(chars.count, 1), by: MAX_MARKDOWN_LINE_LENGTH).map { + String(chars[$0 ..< min($0 + MAX_MARKDOWN_LINE_LENGTH, chars.count)]) + } +} + +private func escapedHTML(_ s: Substring) -> String { + s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") +} + +private func imagesWithinBudget() -> Modifier { + var pixels = MAX_MARKDOWN_IMAGE_PIXELS + return Modifier(target: .images) { image in + let src = image.html.dropFirst("" + } +} + +private func isBidiControl(_ s: Unicode.Scalar) -> Bool { + [0x061C, 0x200E, 0x200F].contains(s.value) || (0x202A...0x202E).contains(s.value) || (0x2066...0x2069).contains(s.value) +} + +private func withInvisibleCharMarkers(_ line: String) -> Text { + var text = Text(verbatim: "") + var plain = "" + for s in line.unicodeScalars { + if s.properties.generalCategory == .format { + text = text + Text(verbatim: plain) + Text(verbatim: unicodeMarker(s)).foregroundColor(.orange) + plain = "" + } else { + plain.unicodeScalars.append(s) + } + } + return text + Text(verbatim: plain) +} + +private func openMarkdownFileLink(_ url: URL) { + let uri = url.absoluteString + let parsed = parseSimpleXMarkdown(uri) + let format = parsed?.count == 1 ? parsed?.first?.format : nil + switch format { + case let .simplexLink(_, _, simplexUri, _)?: + if let simplexUrl = URL(string: simplexUri) { + ChatModel.shared.appOpenUrl = simplexUrl + } else { + showInvalidLinkAlert(uri) + } + case .uri? where uri.hasPrefix("https://") || uri.hasPrefix("http://"): + openBrowserAlert(uri: uri) + default: + showInvalidLinkAlert(uri) + } +} + +struct MarkdownFileView: View { + @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW) private var textView = false + let file: CIFile + let fileSource: CryptoFile + let lines: [String] + let html: String? + let hasBidiControls: Bool + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if hasBidiControls { + Text("This file has text direction characters. The text can be shown in a different order than it is written.") + .foregroundColor(.orange) + .padding() + } + if let html, !textView { + ConditionsWebView(html: html, openLink: openMarkdownFileLink) + .padding(.horizontal) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines.indices, id: \.self) { i in + withInvisibleCharMarkers(lines[i]) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() + } + } + } + .modifier(ThemedBackground()) + .navigationTitle(file.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if html != nil { + Button { + textView.toggle() + } label: { + Image(systemName: textView ? "doc.richtext" : "chevron.left.forwardslash.chevron.right") + } + } + Button { + saveCryptoFile(fileSource) + } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + } + .navigationViewStyle(.stack) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index c9d8858fa1..f95e31369e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -490,6 +490,7 @@ func messageText( attrs[webLinkAttrKey] = true handleTaps = true } + t = displayedUri(t) case let .hyperLink(text, uri): attrs = linkAttrs() if let text { t = text } diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 56e437e5c8..ebe74b2a50 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -98,7 +98,7 @@ func openExternalLink(_ url: URL) { } else { showAlert( title: NSLocalizedString("Open external link?", comment: "alert title"), - message: s, + message: displayedUri(s), buttonTitle: NSLocalizedString("Open", comment: "alert button"), buttonAction: { UIApplication.shared.open(url) }, cancelButton: true diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift index 5abbbf8d2e..6a05851c63 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift @@ -12,11 +12,15 @@ import WebKit struct ConditionsWebView: UIViewRepresentable { @State var html: String + var openLink: (URL) -> Void = openExternalLink @EnvironmentObject var theme: AppTheme @State var pageLoaded = false func makeUIView(context: Context) -> WKWebView { - let view = WKWebView() + let config = WKWebViewConfiguration() + config.defaultWebpagePreferences.allowsContentJavaScript = false + config.websiteDataStore = .nonPersistent() + let view = WKWebView(frame: .zero, configuration: config) view.backgroundColor = .clear view.isOpaque = false view.navigationDelegate = context.coordinator @@ -35,8 +39,9 @@ struct ConditionsWebView: UIViewRepresentable { } private func loadPage(_ webView: WKWebView) { + let nonce = UUID().uuidString let styles = """ - """ - let head = "\(styles)" + let csp = "" + let head = "\(csp)\(styles)" webView.loadHTMLString(head + html, baseURL: nil) DispatchQueue.main.async { pageLoaded = true @@ -58,10 +77,16 @@ struct ConditionsWebView: UIViewRepresentable { } func makeCoordinator() -> Cordinator { - Cordinator() + Cordinator(openLink: openLink) } class Cordinator: NSObject, WKNavigationDelegate { + let openLink: (URL) -> Void + + init(openLink: @escaping (URL) -> Void) { + self.openLink = openLink + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { @@ -71,9 +96,9 @@ struct ConditionsWebView: UIViewRepresentable { switch navigationAction.navigationType { case .linkActivated: decisionHandler(.cancel) - openExternalLink(url) + openLink(url) default: - decisionHandler(.allow) + decisionHandler(url.scheme == "about" ? .allow : .cancel) } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 6cf9b5abe6..f7042b0c39 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -84,6 +84,7 @@ let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage" +let DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView" let DEFAULT_CURRENT_THEME = "currentTheme" let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme" @@ -145,6 +146,7 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true, DEFAULT_SHOW_SENT_VIA_RPOXY: false, DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false, + DEFAULT_MARKDOWN_FILE_TEXT_VIEW: false, ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue, DEFAULT_THEME_OVERRIDES: "{}", diff --git a/apps/ios/Shared/Views/UserSettings/StorageView.swift b/apps/ios/Shared/Views/UserSettings/StorageView.swift index 094c1cb3d6..9d88e02be2 100644 --- a/apps/ios/Shared/Views/UserSettings/StorageView.swift +++ b/apps/ios/Shared/Views/UserSettings/StorageView.swift @@ -10,33 +10,66 @@ import SwiftUI import SimpleXChat struct StorageView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme @State var appGroupFiles: [String: Int64] = [:] @State var documentsFiles: [String: Int64] = [:] var body: some View { ScrollView { VStack(alignment: .leading) { - directoryView("App group:", appGroupFiles) + directoryView("App group:", getGroupContainerDirectory(), appGroupFiles) if !documentsFiles.isEmpty { - directoryView("Documents:", documentsFiles) + directoryView("Documents:", getDocumentsDirectory(), documentsFiles) } } } .padding() - .onAppear { - appGroupFiles = traverseFiles(in: getGroupContainerDirectory()) - documentsFiles = traverseFiles(in: getDocumentsDirectory()) - } + .onAppear(perform: loadFiles) + } + + private func loadFiles() { + appGroupFiles = traverseFiles(in: getGroupContainerDirectory()) + documentsFiles = traverseFiles(in: getDocumentsDirectory()) } @ViewBuilder - private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View { + private func directoryView(_ name: LocalizedStringKey, _ dir: URL, _ contents: [String: Int64]) -> some View { Text(name).font(.headline) ForEach(Array(contents), id: \.key) { (key, value) in - Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))) + let sizeText = Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))) + if dir.appendingPathComponent(key).path == getTempFilesDirectory().path { + let stopped = m.chatRunning == false + HStack { + sizeText + Button("Delete temp data", role: .destructive, action: confirmDeleteTempFiles) + .disabled(!stopped) + } + if !stopped { + Text("Stop chat in Settings → Chat data → Database passphrase & export to delete temp data.") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + } + } else { + sizeText + } } } + private func confirmDeleteTempFiles() { + showAlert( + NSLocalizedString("Delete temp data?", comment: "alert title"), + message: NSLocalizedString("Files being sent or received will never complete. Videos in unsent drafts will be sent without the file.", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Delete", comment: "alert action"), style: .destructive) { _ in + deleteTempFiles() + loadFiles() + }, + cancelAlertAction + ]} + ) + } + private func traverseFiles(in dir: URL) -> [String: Int64] { var res: [String: Int64] = [:] let fm = FileManager.default diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift index 35a838fff9..28073ef342 100644 --- a/apps/ios/SimpleX NSE/NSEAPITypes.swift +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) case newChatItems(user: UserRef, chatItems: [AChatItem]) + case duplicateGroupMessage(user: UserRef, member: GroupMember) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) @@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case .contactConnected: "contactConnected" case .receivedContactRequest: "receivedContactRequest" case .newChatItems: "newChatItems" + case .duplicateGroupMessage: "duplicateGroupMessage" case .rcvFileSndCancelled: "rcvFileSndCancelled" case .sndFileComplete: "sndFileComplete" case .sndFileRcvCancelled: "sndFileRcvCancelled" @@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 25df063f82..38e1b604be 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -73,6 +73,16 @@ public enum NSENotificationData { default: nil } } + + func replaces(_ bestAttempt: NSENotificationData) -> Bool { + switch (self, bestAttempt) { + case (.noNtf, .connectionEvent): true + case (.noNtf, _): false + case (.callInvitation, _): true + case (_, .callInvitation): false + default: true + } + } } // Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid @@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension { // A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above. // If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once, // instead of requesting further messages. - if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil { + if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) { notificationEntities[id]?.msgBestAttemptNtf = ntf - } // otherwise keep call as best attempt + } return false } else { // We should not get to this branch, as notifications are not delivered for muted users. @@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf - let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir { - groupMember.id + let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId { + groupMemberEntityId(groupInfo.groupId, memberId) } else { chatItem.chatInfo.id } @@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? } else { return nil } + case let .duplicateGroupMessage(_, member): + return (member.id, .noNtf) case let .rcvFileSndCancelled(_, aChatItem, _): cleanupFile(aChatItem) return nil diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 37b24162e4..fb29f753b0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -172,6 +172,7 @@ 64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; + 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; @@ -195,8 +196,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -566,6 +567,7 @@ 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPurchaseStateView.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; + 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; @@ -590,8 +592,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -764,8 +766,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -852,8 +854,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */, ); path = Libraries; sourceTree = ""; @@ -1127,6 +1129,7 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, 5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */, 648010AA281ADD15009009B9 /* CIFileView.swift */, + 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */, 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */, 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, @@ -1606,6 +1609,7 @@ 5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, + 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */, 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index bf4440a810..a27f396a54 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3273,7 +3273,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var relayLink: String? public var memberVerifiedCode: SecurityCode? - public var id: String { "#\(groupId) @\(groupMemberId)" } + public var id: String { groupMemberEntityId(groupId, groupMemberId) } public var ready: Bool { get { activeConn?.connStatus == .ready } } public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var sendMsgEnabled: Bool { get { @@ -3462,6 +3462,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ) } +public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String { + "#\(groupId) @\(groupMemberId)" +} + public struct GroupSupportChat: Codable, Hashable { public var chatTs: Date public var unread: Int @@ -4235,6 +4239,10 @@ public enum CIDirection: Decodable, Hashable { } } + public var groupMemberId: Int64? { + if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil } + } + public func sameDirection(_ dir: CIDirection) -> Bool { switch (self, dir) { case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId @@ -4260,6 +4268,7 @@ public struct CIMeta: Decodable, Hashable { public var userMention: Bool public var deletable: Bool public var editable: Bool + public var forwardedByMember: Int64? public var showGroupAsSender: Bool public var msgVerified: MsgVerified? diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 1641541ef7..ba40a6817d 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -140,6 +140,16 @@ public func deleteAppFiles() { } } +public func deleteTempFiles() { + let fm = FileManager.default + do { + try fm.removeItem(at: getTempFilesDirectory()) + try fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true) + } catch { + logger.error("FileUtils deleteTempFiles error: \(error.localizedDescription)") + } +} + public func fileSize(_ url: URL) -> Int? { // in bytes do { let val = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index c509662c9e..b63d336c34 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -238,6 +238,12 @@ Renders voice messages with waveform visualization, play/pause control, and dura Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download. +### MarkdownFileView + +**File**: [`Shared/Views/Chat/ChatItem/MarkdownFileView.swift`](../../Shared/Views/Chat/ChatItem/MarkdownFileView.swift) + +Sheet with a viewer of Markdown files up to `MAX_MARKDOWN_VIEW_SIZE` (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it. + ### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) **File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 0ff63fa8de..e9503bc6ce 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -77,8 +77,8 @@ kotlin { api("dev.icerock.moko:resources-compose:0.27.1") // Markdown - implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0") - implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0") + implementation("com.mikepenz:multiplatform-markdown-renderer:0.34.0") + implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.34.0") } } val commonTest by getting { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index 1a3703822d..df2e6ccadc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.scale import boofcv.android.ConvertBitmap import boofcv.struct.image.GrayU8 import chat.simplex.common.R +import chat.simplex.common.views.helpers.decodeSampledBitmapFromByteArray import chat.simplex.common.views.helpers.errorBitmap import chat.simplex.common.views.helpers.getFileName import java.io.ByteArrayOutputStream @@ -41,6 +42,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } } +actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = + try { + decodeSampledBitmapFromByteArray(Base64.decode(base64, Base64.DEFAULT), width, width, MAX_IMAGE_DIMENSION).asImageBitmap() + } catch (e: Exception) { + Log.e(TAG, "decodeBase64Image error: $e") + null + } + actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 5d07aae088..a4f931058c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -2,6 +2,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.graphics.painter.Painter import java.io.File @@ -18,3 +19,5 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = Modifier + +actual fun newPasswordContentType(): ContentType = ContentType.NewPassword + ContentType.Password diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 371bd7676b..7b2f53d365 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -194,11 +194,12 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? } // https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap -private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap { +internal fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int, maxDimension: Int = Int.MAX_VALUE): Bitmap { // First decode with inJustDecodeBounds=true to check dimensions return BitmapFactory.Options().run { inJustDecodeBounds = true BitmapFactory.decodeByteArray(data, 0, data.size, this) + if (outWidth > maxDimension || outHeight > maxDimension) throw IOException("Image dimensions exceed limit") // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index c0497daacb..f66fd82473 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -4,6 +4,9 @@ //#include //#include +// from libc (stdlib.h is not included because of the reallocarray stub below) +void free(void *ptr); + // from the RTS void hs_init_with_rtsopts(int * argc, char **argv[]); @@ -49,6 +52,13 @@ Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused j // from simplex-chat typedef long* chat_ctrl; +// Strings returned by chat_* functions are allocated with malloc and owned by the caller. +static jstring decode_and_free(JNIEnv *env, char *string) { + jstring res = (*env)->NewStringUTF(env, string); + free(string); + return res; +} + /* When you start using any new function from Haskell libraries, you have to add the function name to the file libsimplex.dll.def in the root directory. @@ -80,7 +90,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE); const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE); jlong _ctrl = (jlong) 0; - jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); + jstring res = decode_and_free(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); (*env)->ReleaseStringUTFChars(env, confirm, _confirm); @@ -99,7 +109,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatCloseStore(JNIEnv *env, __unused jclass clazz, jlong controller) { - jstring res = (*env)->NewStringUTF(env, chat_close_store((void*)controller)); + jstring res = decode_and_free(env, chat_close_store((void*)controller)); return res; } @@ -109,7 +119,7 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmdRetry(JNIEnv *env, __unused //jint length = (jint) (*env)->GetStringUTFLength(env, msg); //for (int i = 0; i < length; ++i) // __android_log_print(ANDROID_LOG_ERROR, "simplex", "%d: %02x\n", i, _msg[i]); - jstring res = (*env)->NewStringUTF(env, chat_send_cmd_retry((void*)controller, _msg, retryNum)); + jstring res = decode_and_free(env, chat_send_cmd_retry((void*)controller, _msg, retryNum)); (*env)->ReleaseStringUTFChars(env, msg, _msg); return res; } @@ -117,25 +127,25 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmdRetry(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmdRetry(JNIEnv *env, __unused jclass clazz, jlong controller, jint rhId, jstring msg, jint retryNum) { const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_send_remote_cmd_retry((void*)controller, rhId, _msg, retryNum)); + jstring res = decode_and_free(env, chat_send_remote_cmd_retry((void*)controller, rhId, _msg, retryNum)); (*env)->ReleaseStringUTFChars(env, msg, _msg); return res; } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { - return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); + return decode_and_free(env, chat_recv_msg((void*)controller)); } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) { - return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait)); + return decode_and_free(env, chat_recv_msg_wait((void*)controller, wait)); } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str)); + jstring res = decode_and_free(env, chat_parse_markdown(_str)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -143,7 +153,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str)); + jstring res = decode_and_free(env, chat_parse_server(_str)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -151,7 +161,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, __unused jclass clazz, jstring str, jint safe) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_uri(_str, safe)); + jstring res = decode_and_free(env, chat_parse_uri(_str, safe)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -160,7 +170,7 @@ JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) { const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE); const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt)); + jstring res = decode_and_free(env, chat_password_hash(_pwd, _salt)); (*env)->ReleaseStringUTFChars(env, pwd, _pwd); (*env)->ReleaseStringUTFChars(env, salt, _salt); return res; @@ -169,7 +179,7 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) { const char *_name = (*env)->GetStringUTFChars(env, name, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_valid_name(_name)); + jstring res = decode_and_free(env, chat_valid_name(_name)); (*env)->ReleaseStringUTFChars(env, name, _name); return res; } @@ -177,7 +187,7 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseBadgeCode(JNIEnv *env, jclass clazz, jstring code) { const char *_code = (*env)->GetStringUTFChars(env, code, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_badge_code(_code)); + jstring res = decode_and_free(env, chat_parse_badge_code(_code)); (*env)->ReleaseStringUTFChars(env, code, _code); return res; } @@ -195,7 +205,7 @@ Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); - jstring res = (*env)->NewStringUTF(env, chat_write_file((void*)controller, _path, buff, capacity)); + jstring res = decode_and_free(env, chat_write_file((void*)controller, _path, buff, capacity)); (*env)->ReleaseStringUTFChars(env, path, _path); return res; } @@ -229,6 +239,7 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, arr = (*env)->NewByteArray(env, len); (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); } + free(res); jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), @@ -243,7 +254,7 @@ JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) { const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); + jstring res = decode_and_free(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; @@ -255,7 +266,7 @@ Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass cla const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE); const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); + jstring res = decode_and_free(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, key, _key); (*env)->ReleaseStringUTFChars(env, nonce, _nonce); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 6bb6ffbe97..fddeb16204 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -64,6 +64,7 @@ jstring decode_to_utf8_string(JNIEnv *env, char *string) { (*env)->DeleteLocalRef(env, bb); (*env)->DeleteLocalRef(env, charset); (*env)->DeleteLocalRef(env, cb); + free(string); return res; } @@ -239,6 +240,7 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, arr = (*env)->NewByteArray(env, len); (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); } + free(res); jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 656911443a..ce1152de6a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -415,7 +415,10 @@ fun CenterPartOfScreen() { } } } - else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} + else -> { + ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} + ModalManager.center.showInView() + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 4511214cc6..3db735ecea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -132,6 +132,8 @@ object BadgeModel { enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake } +data class FileToOpen(val fileId: Long, val inChatList: Boolean) + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -205,8 +207,21 @@ object ChatModel { // another in its place; only the badge alert shows regardless. Set while rendering, so not a state. var chatListBanner: ChatListBanner? = null + var fileToOpen: FileToOpen? = null + fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner + fun openWhenReceived(file: CIFile, inChatList: Boolean) { + fileToOpen = FileToOpen(file.fileId, inChatList) + } + + fun receivedFileToOpen(file: CIFile, inChatList: Boolean): Boolean { + val f = fileToOpen + if (f == null || f.fileId != file.fileId || f.inChatList != inChatList || !file.loaded) return false + fileToOpen = null + return !inChatList || appPlatform.isDesktop || chatId.value == null + } + // preferences val notificationPreviewMode by lazy { mutableStateOf( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7c4acafe0b..785965eff4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -144,6 +144,7 @@ class AppPreferences { val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false) + val markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false) val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) @@ -431,6 +432,7 @@ class AppPreferences { const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" + private const val SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW = "MarkdownFileTextView" private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" @@ -3160,6 +3162,13 @@ object ChatController { // TODO NtfManager.shared.notifyGroupInvitation } } + is CR.ReceivedGroupInvitationViaGroup -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + } + } + } is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return @@ -6877,6 +6886,7 @@ sealed class CR { @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @@ -7080,6 +7090,7 @@ sealed class CR { is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" is ReceivedGroupInvitation -> "receivedGroupInvitation" + is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" @@ -7274,6 +7285,7 @@ sealed class CR { is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") + is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole") is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index d7a241d5ee..47cc034bfb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -15,6 +15,7 @@ import java.io.InputStream import java.net.URI expect fun base64ToBitmap(base64ImageString: String): ImageBitmap +expect fun decodeBase64Image(base64: String, width: Int): ImageBitmap? expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream expect fun cropToSquare(image: ImageBitmap): ImageBitmap diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index caaf66811c..88d4709dfa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -3,6 +3,7 @@ package chat.simplex.common.platform import androidx.compose.foundation.combinedClickable import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.ImageBitmap @@ -127,3 +128,5 @@ private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_ while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2) return image } + +expect fun newPasswordContentType(): ContentType diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index c1b3c20b71..e9dbb3b157 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -157,7 +157,7 @@ fun ChatView( ) } val attachmentOption = rememberSaveable { mutableStateOf(null) } - val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = true) val scope = rememberCoroutineScope() val selectedChatItems = rememberSaveable { mutableStateOf(null as Set?) } val showCommandsMenu = rememberSaveable { mutableStateOf(false) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index a3c2b63f8f..42cbff3a2f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -80,6 +80,7 @@ fun CIFileView( if (prohibited != null) { showProhibitedFileAlert(file, prohibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } @@ -105,6 +106,8 @@ fun CIFileView( showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) + isMarkdownFile(file) && file.loaded -> + showMarkdownFile(file) file.forwardingAllowed() -> { withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) @@ -183,21 +186,23 @@ fun CIFileView( val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) } val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() } - val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null + val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null && !isMarkdownFile(file)) getLoadedFilePath(file) else null if (loadedFilePath != null && file?.fileSource != null) { val encrypted = file.fileSource.cryptoArgs != null SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) } + val onClick: () -> Unit = { + if (appPlatform.isAndroid && loadedFilePath != null) { + showOpenSaveMenu.value = true + } else { + fileAction() + } + } + if (file != null) OpenWhenReceivedEffect(file, smallView, onClick) Row( Modifier .combinedClickable( - onClick = { - if (appPlatform.isAndroid && loadedFilePath != null) { - showOpenSaveMenu.value = true - } else { - fileAction() - } - }, + onClick = onClick, onLongClick = { showMenu.value = true } ) .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), @@ -239,6 +244,13 @@ fun CIFileView( // the core decides whether a received file is above the size the sender's badge allows fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null +@Composable +fun OpenWhenReceivedEffect(file: CIFile, inChatList: Boolean, open: () -> Unit) { + KeyChangeEffect(file.loaded) { + if (chatModel.receivedFileToOpen(file, inChatList)) open() + } +} + fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) { val badgeIssue = when (prohibited.badgeStatus) { null, BadgeStatus.Active -> "" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 649188502d..14ecd723b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -29,6 +29,7 @@ fun CIGroupInvitationView( memberRole: GroupMemberRole, showTimestamp: Boolean, chatIncognito: Boolean = false, + groupChat: Boolean = false, joinGroup: (Long, () -> Unit) -> Unit, timedMessagesTTL: Int? ) { @@ -111,6 +112,9 @@ fun CIGroupInvitationView( val secondaryColor = MaterialTheme.colors.secondary Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (groupChat) { + Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor) + } if (action) { Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index d5ea44126b..a49884beeb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -173,6 +173,15 @@ fun CIImageView( return null } + val view = LocalMultiplatformView() + if (file != null) { + OpenWhenReceivedEffect(file, smallView) { + hideKeyboard(view) + ModalManager.fullscreen.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + } + } Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) .then( @@ -222,6 +231,7 @@ fun CIImageView( if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 21f406db18..62e1b9f737 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -70,6 +70,8 @@ fun CIVideoView( } } val f = filePath.value + val autoPlay = remember { mutableStateOf(false) } + if (file != null) OpenWhenReceivedEffect(file, smallView) { autoPlay.value = true } if (file != null && f != null) { val view = LocalMultiplatformView() val openFullscreen = { @@ -80,7 +82,6 @@ fun CIVideoView( } val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } - val autoPlay = remember { mutableStateOf(false) } val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) } val decrypted = uriDecrypted.value if (decrypted != null && smallView) { @@ -98,7 +99,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, smallView, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -128,7 +129,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, smallView, receiveFile) } } } } @@ -156,22 +157,26 @@ private fun VideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = false, blurred = blurred, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(1f, onLongClick = onLongClick) } else if (!blurred.value) { - PlayButton(false, 1f, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, 1f, onLongClick = onLongClick, onClick = play) } DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(defaultDuration) }, remember { mutableStateOf(0L) }) } @@ -190,22 +195,26 @@ private fun SmallVideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) } else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) { - PlayButton(false, sizeMultiplier, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, sizeMultiplier, onLongClick = onLongClick, onClick = play) } } } @@ -240,7 +249,7 @@ private fun SmallVideoView( PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) openFullscreen() } } @@ -272,7 +281,7 @@ private fun VideoView( player.stop() } val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) play() } // Drop autoPlay only when show preview changes to prevent blinking of the view @@ -566,10 +575,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { +private fun receiveFileIfValidSize(file: CIFile, smallView: Boolean, receiveFile: (Long) -> Unit) { if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 3b3c835e0a..5aeefd9668 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -87,7 +87,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, receiveFile) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } if (smallView) { @@ -95,6 +95,7 @@ fun CIVoiceView( AudioPlayer.stop() } } + OpenWhenReceivedEffect(file, smallView, play) } else if (smallView) { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, sizeMultiplier, {}, {}, longClick, receiveFile) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index cb3a07b7f9..822aef9aa2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -720,11 +720,11 @@ fun ChatItemView( DeleteItemMenu() } is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, groupChat = cInfo is ChatInfo.Group, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) DeleteItemMenu() } is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, groupChat = cInfo is ChatInfo.Group, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) DeleteItemMenu() } is CIContent.RcvDirectEventContent -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt new file mode 100644 index 0000000000..189e69a925 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt @@ -0,0 +1,284 @@ +package chat.simplex.common.views.chat.item + +import SectionBottomSpacer +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.documentMarkdownStyle +import chat.simplex.res.* +import com.mikepenz.markdown.compose.MarkdownElement +import com.mikepenz.markdown.m2.Markdown +import com.mikepenz.markdown.model.ImageData +import com.mikepenz.markdown.model.ImageTransformer +import com.mikepenz.markdown.model.State +import com.mikepenz.markdown.model.markdownAnnotator +import com.mikepenz.markdown.model.parseMarkdownFlow +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import java.io.ByteArrayInputStream +import java.io.File +import java.net.URI +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction + +private const val DATA_IMAGE = "data:image/" +private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576 +private const val MAX_MARKDOWN_LINE_LENGTH = 15_000 +private const val MAX_MARKDOWN_DEPTH = 32 +private const val MAX_MARKDOWN_TABLE_ROWS = 200 +private const val MAX_MARKDOWN_IMAGE_PIXELS = 2160L * 4320 + +private val controlChar = Regex("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]") + +private val bidiControlChar = Regex("[\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]") + +private val invisibleCharMarkerStyle = SpanStyle(color = WarningOrange) + +private class MarkdownDocument(val lines: List, val formatted: State.Success?, val hasBidiControls: Boolean) + +fun isMarkdownFile(file: CIFile): Boolean = + file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && file.fileName.substringAfterLast('.', "").lowercase() in listOf("md", "markdown") + +fun showMarkdownFile(file: CIFile) { + withLongRunningApi(slow = 600_000) { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file.loadRemoteFile(true) + filePath = getLoadedFilePath(file) + } + if (filePath == null) { + AlertManager.shared.showAlertMsg(MR.strings.file_not_found) + return@withLongRunningApi + } + val bytes = try { + val cryptoArgs = file.fileSource?.cryptoArgs + if (cryptoArgs != null) readCryptoFile(filePath, cryptoArgs) else File(filePath).readBytes() + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message) + return@withLongRunningApi + } + val text = decodedText(bytes) + val lines = text.lines() + val formatted = if (lines.all { it.length <= MAX_MARKDOWN_LINE_LENGTH }) formattedWithinLimits(text) else null + val document = MarkdownDocument( + lines.flatMap { line -> line.chunked(MAX_MARKDOWN_LINE_LENGTH).ifEmpty { listOf("") } }, + formatted, + bidiControlChar.containsMatchIn(text) + ) + ModalManager.center.showModal(endButtons = { + if (formatted != null) MarkdownViewSwitch() + MarkdownFileSaveButton(file, bytes) + }) { + MarkdownFileView(file.fileName, document) + } + } +} + +private fun decodedText(bytes: ByteArray): String = + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + .replaceWith("?") + .decode(ByteBuffer.wrap(bytes)) + .toString() + .replace(controlChar, "?") + +private suspend fun formattedWithinLimits(text: String): State.Success? = + (parseMarkdownFlow(text).first { it !is State.Loading } as? State.Success)?.takeIf { treeWithinLimits(it.node) } + +private fun treeWithinLimits(root: ASTNode): Boolean { + val nodes = ArrayDeque(listOf(root to 1)) + while (nodes.isNotEmpty()) { + val (node, depth) = nodes.removeLast() + if (depth > MAX_MARKDOWN_DEPTH) return false + if (node.type == GFMElementTypes.TABLE && node.children.count { it.type == GFMElementTypes.ROW } > MAX_MARKDOWN_TABLE_ROWS) return false + node.children.forEach { nodes.addLast(it to depth + 1) } + } + return true +} + +@Composable +private fun MarkdownViewSwitch() { + val textView = remember { appPrefs.markdownFileTextView.state } + IconButton({ appPrefs.markdownFileTextView.set(!textView.value) }) { + Icon( + painterResource(if (textView.value) MR.images.ic_article else MR.images.ic_code), + stringResource(if (textView.value) MR.strings.markdown_formatted_view else MR.strings.markdown_text_view), + tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +private fun MarkdownFileSaveButton(file: CIFile, bytes: ByteArray) { + val saveFileLauncher = rememberFileChooserLauncher(false, bytes) { to: URI? -> + if (to != null) copyBytesToFile(ByteArrayInputStream(bytes), to) {} + } + IconButton({ withLongRunningApi { saveFileLauncher.launch(file.fileName) } }) { + Icon(painterResource(MR.images.ic_download), stringResource(MR.strings.save_verb), tint = MaterialTheme.colors.primary) + } +} + +@Composable +private fun MarkdownFileView(fileName: String, document: MarkdownDocument) { + val textView = remember { appPrefs.markdownFileTextView.state } + val formatted = document.formatted + if (textView.value || formatted == null) { + SelectionContainer { + MarkdownFileColumn(fileName, document.hasBidiControls) { + items(document.lines) { line -> + Text(remember(line) { withInvisibleCharMarkers(line) }, Modifier.padding(horizontal = DEFAULT_PADDING), fontFamily = FontFamily.Monospace) + } + } + } + } else { + FormattedMarkdown(fileName, formatted, document.hasBidiControls) + } +} + +private fun withInvisibleCharMarkers(line: String): AnnotatedString = buildAnnotatedString { + var start = 0 + invisibleChar.findAll(line).forEach { + append(line.substring(start, it.range.first)) + withStyle(invisibleCharMarkerStyle) { append(unicodeMarker(it.value)) } + start = it.range.last + 1 + } + append(line.substring(start)) +} + +@Composable +private fun FormattedMarkdown(fileName: String, formatted: State.Success, hasBidiControls: Boolean) { + val style = documentMarkdownStyle(h1 = MaterialTheme.typography.h2.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold)) + val codeSpanStyle = style.typography.inlineCode.toSpanStyle().copy(background = style.colors.inlineCodeBackground) + val annotator = remember(codeSpanStyle) { + markdownAnnotator { content, child -> + when (child.type) { + MarkdownElementTypes.CODE_SPAN -> { + withStyle(codeSpanStyle) { append(" ${child.getTextInNode(content).trim('`')} ") } + true + } + MarkdownElementTypes.IMAGE -> + if (imageLink(content, child)?.startsWith(DATA_IMAGE) == true) { + false + } else { + append(child.getTextInNode(content).toString()) + true + } + else -> false + } + } + } + val imageTransformer = remember { DataImageTransformer() } + val parentUriHandler = LocalUriHandler.current + CompositionLocalProvider(LocalUriHandler provides remember { markdownFileUriHandler(parentUriHandler) }) { + Markdown( + formatted, + colors = style.colors, + typography = style.typography, + padding = style.padding, + imageTransformer = imageTransformer, + annotator = annotator, + components = style.components, + success = { state, components, _ -> + MarkdownFileColumn(fileName, hasBidiControls) { + items(state.node.children) { node -> + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + MarkdownElement(node, components, state.content, skipLinkDefinition = state.linksLookedUp) + } + } + } + } + ) + } +} + +@Composable +private fun MarkdownFileColumn(fileName: String, hasBidiControls: Boolean, content: LazyListScope.() -> Unit) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + LazyColumnWithScrollBar( + contentPadding = PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false) + ) + ) { + item { AppBarTitle(fileName) } + if (hasBidiControls) { + item { + Text(stringResource(MR.strings.markdown_file_bidi_warning), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING), color = WarningOrange) + } + } + content() + item { SectionBottomSpacer() } + } +} + +private fun imageLink(content: String, image: ASTNode): String? = + image.children.firstOrNull { it.type == MarkdownElementTypes.INLINE_LINK } + ?.children?.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION } + ?.getTextInNode(content)?.toString() + +private fun markdownFileUriHandler(parentUriHandler: UriHandler): UriHandler = object: UriHandler { + override fun openUri(uri: String) { + val format = parseToMarkdown(uri)?.singleOrNull()?.format + when { + format is Format.SimplexLink -> parentUriHandler.openVerifiedSimplexUri(format.simplexUri) + format is Format.Uri && (uri.startsWith("https://") || uri.startsWith("http://")) -> openBrowserAlert(uri, parentUriHandler) + else -> showInvalidLinkAlert(uri) + } + } +} + +private class DataImageTransformer : ImageTransformer { + private var pixels = MAX_MARKDOWN_IMAGE_PIXELS + private val images = mutableMapOf() + + @Composable + override fun transform(link: String): ImageData? { + val width = with(LocalDensity.current) { LocalWindowWidth().roundToPx() } + val bitmap by produceState(null, link) { + value = withContext(Dispatchers.Default) { decodedImage(link, width) } + } + return remember(bitmap) { bitmap?.let { ImageData(BitmapPainter(it)) } } + } + + private fun decodedImage(link: String, width: Int): ImageBitmap? = synchronized(this) { + if (link !in images) images[link] = imageWithinBudget(link, width) + images[link] + } + + private fun imageWithinBudget(link: String, width: Int): ImageBitmap? = + link.takeIf { it.startsWith(DATA_IMAGE) } + ?.let { decodeBase64Image(it.substringAfter("base64,"), width) } + ?.takeIf { it.width.toLong() * it.height <= pixels } + ?.also { pixels -= it.width.toLong() * it.height } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index da64862c03..c198451464 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -96,6 +96,7 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin } else if (mention != null) mentionText(ft.format.memberName) else ft.text } + is Format.Uri -> displayedUri(ft.text) is Format.HyperLink -> ft.format.showText ?: ft.text is Format.SimplexLink -> { val t = ft.format.showText @@ -283,7 +284,7 @@ fun MarkdownText ( val s = ft.text val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s" withAnnotation(tag = "WEB_URL", annotation = link) { - withStyle(ftStyle) { append(ft.text) } + withStyle(ftStyle) { append(displayedUri(ft.text)) } } } is Format.HyperLink -> { @@ -535,19 +536,22 @@ fun openBrowserAlert(uri: String, uriHandler: UriHandler) { if (res == null) { showInvalidLinkAlert(uri, err) } else { - val message = if (uri.count() > 160) uri.substring(0, 159) + "…" else uri + val shownUri = displayedUri(uri) + val message = if (shownUri.count() > 160) shownUri.substring(0, 159) + "…" else shownUri val sanitizedUri = res.second if (sanitizedUri == null) { AlertManager.shared.showAlertDialog( generalGetString(MR.strings.privacy_chat_list_open_web_link_question), message, confirmText = generalGetString(MR.strings.open_verb), - onConfirm = { safeOpenUri(uri, uriHandler) } + onConfirm = { safeOpenUri(uri, uriHandler) }, + parseHtml = false ) } else { AlertManager.shared.showAlertDialogButtonsColumn( generalGetString(MR.strings.privacy_chat_list_open_web_link_question), message, + parseHtml = false, buttons = { Column { SectionItemView({ @@ -585,7 +589,7 @@ fun safeOpenUri(uri: String, uriHandler: UriHandler) { } fun showInvalidLinkAlert(uri: String, error: String? = null) { - val message = if (error.isNullOrEmpty()) { uri } else { error + "\n" + uri } + val message = if (error.isNullOrEmpty()) { displayedUri(uri) } else { error + "\n" + displayedUri(uri) } AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), message) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 489ebb8c39..32e0befa3f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -586,7 +586,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection onClick = { ModalManager.center.closeModals() ModalManager.end.closeModals() - ModalManager.center.showModalCloseable(settings = true, showClose = appPlatform.isAndroid, cardScreen = true) { close -> + ModalManager.center.showModalCloseable(settings = true, cardScreen = true) { close -> ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close) } showMenu.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 652d072c9c..ea567d7da6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -217,23 +217,43 @@ fun UserPicker( @Composable fun SecondSection() { - UserPickerOptionRow( - painterResource(MR.images.ic_qr_code), - if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), - showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped - ) - UserPickerOptionRow( - painterResource(MR.images.ic_toggle_on), - stringResource(MR.strings.chat_preferences), - click = if (stopped) null else ({ - showCustomModal { m, close -> - PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close) - }() - }), - disabled = stopped - ) + if (!chatModel.desktopNoUserNoRemote) { + UserPickerOptionRow( + painterResource(MR.images.ic_qr_code), + if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), + showCustomModal { it, close -> + val user = it.currentUser.value + if (user != null) { + UserAddressView(it, shareViaProfile = user.addressShared, close = close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } + }, disabled = stopped + ) + UserPickerOptionRow( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.chat_preferences), + click = if (stopped) null else ({ + showCustomModal { m, close -> + val user = m.currentUser.value + if (user != null) { + PreferencesView(m, user, close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } + }() + }), + disabled = stopped + ) + } if (appPlatform.isDesktop) { - Divider(Modifier.padding(DEFAULT_PADDING)) + if (!chatModel.desktopNoUserNoRemote) Divider(Modifier.padding(DEFAULT_PADDING)) val inactiveUsers = users.filter { !it.user.activeUser } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index ac64c93c73..8f22515370 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -25,6 +25,9 @@ import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs @@ -141,7 +144,10 @@ fun DatabaseEncryptionLayout( } } - if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) { + val hasCurrentField = !initialRandomDBPassphrase.value && chatDbEncrypted == true + val newKeyContentType = + if (hasCurrentField) ContentType.NewPassword else newPasswordContentType() + if (hasCurrentField) { PassphraseField( currentKey, generalGetString(MR.strings.current_passphrase), @@ -158,6 +164,7 @@ fun DatabaseEncryptionLayout( showStrength = true, isValid = ::validKey, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + contentType = newKeyContentType, ) val onClickUpdate = { // Don't do things concurrently. Shouldn't be here concurrently, just in case @@ -187,6 +194,7 @@ fun DatabaseEncryptionLayout( generalGetString(MR.strings.confirm_new_passphrase), modifier = Modifier.padding(horizontal = DEFAULT_PADDING), isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, + contentType = newKeyContentType, keyboardActions = KeyboardActions(onDone = { if (!disabled) onClickUpdate() defaultKeyboardAction(ImeAction.Done) @@ -323,6 +331,7 @@ fun PassphraseField( keyboardActions: KeyboardActions = KeyboardActions(), dependsOn: State? = null, requestFocus: Boolean = false, + contentType: ContentType? = ContentType.Password, ) { var valid by remember { mutableStateOf(validKey(key.value)) } var showKey by remember { mutableStateOf(false) } @@ -362,6 +371,7 @@ fun PassphraseField( minWidth = TextFieldDefaults.MinWidth, minHeight = TextFieldDefaults.MinHeight ) + .semantics { if (contentType != null) this.contentType = contentType } .focusRequester(focusRequester), onValueChange = { state.value = it @@ -372,7 +382,7 @@ fun PassphraseField( visualTransformation = if (showKey) VisualTransformation.None else - VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) }, + PasswordVisualTransformation('*'), keyboardOptions = keyboardOptions, keyboardActions = KeyboardActions(onDone = { keyboard?.hide() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 7422d673d5..3655c4053d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import LocalCardScreen import chat.simplex.common.ui.theme.* @@ -119,6 +118,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + fun isLastModal(data: ModalData): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.data === data + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { showCustomModal(id = id, forceAnimated = forceAnimated) { close -> ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() }) @@ -145,9 +146,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { modalViews.add(ModalViewHolder(id, anim, data, modal)) _modalCount.value = modalViews.size - toRemove.size - if (placement == ModalPlacement.CENTER) { - ChatModel.chatId.value = null - } else if (placement == ModalPlacement.END) { + if (placement == ModalPlacement.END) { desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()) + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH * sqrt(appPrefs.fontScale.get())) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index dfe33e3595..81a64cb527 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -617,13 +617,27 @@ fun UriHandler.openExternalLink(uri: String) { } else { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.open_external_link_title), - text = uri, + text = displayedUri(uri), confirmText = generalGetString(MR.strings.open_verb), - onConfirm = { uriHandler.openUriCatching(uri) } + onConfirm = { uriHandler.openUriCatching(uri) }, + parseHtml = false ) } } +val invisibleChar = Regex("\\p{Cf}") + +private val nonAsciiChar = Regex("[^\\x00-\\x7F]") + +fun unicodeMarker(c: String): String = "[U+" + Integer.toHexString(c.codePointAt(0)).uppercase().padStart(4, '0') + "]" + +fun displayedUri(uri: String): String { + val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: 0 + val authorityEnd = uri.indexOfAny(charArrayOf('/', '?', '#'), authorityStart).takeIf { it >= 0 } ?: uri.length + val authority = uri.substring(authorityStart, authorityEnd).replace(nonAsciiChar) { unicodeMarker(it.value) } + return uri.replaceRange(authorityStart, authorityEnd, authority).replace(invisibleChar) { unicodeMarker(it.value) } +} + fun IntSize.Companion.Saver(): Saver = Saver( save = { it.width to it.height }, restore = { IntSize(it.first, it.second) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 0d00c20591..f198fe1f7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.focus.* import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalFocusManager @@ -152,6 +153,7 @@ private fun SetupDatabasePassphraseLayout( showStrength = true, isValid = ::validKey, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + contentType = newPasswordContentType(), ) PassphraseField( @@ -169,6 +171,7 @@ private fun SetupDatabasePassphraseLayout( }, isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }), + contentType = newPasswordContentType(), ) } Spacer(Modifier.weight(1f)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index cfdf003bc6..fce6c69033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -60,6 +60,7 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> } SettingsPreferenceItem(painterResource(MR.images.ic_report), stringResource(MR.strings.show_internal_errors), appPreferences.showInternalErrors) SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls) + SettingsActionItem(painterResource(MR.images.ic_folder_open), stringResource(MR.strings.storage), { ModalManager.start.showModal(cardScreen = true) { StorageView() } }) } } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index a14dab3f1a..8e96bec184 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -70,10 +70,10 @@ private fun HiddenProfileLayout( val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } } SectionView(stringResource(MR.strings.hidden_profile_password)) { SectionItemViewWithoutMinPadding { - PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true) + PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true, contentType = null) } SectionItemViewWithoutMinPadding { - PassphraseField(confirmHidePassword, stringResource(MR.strings.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword) + PassphraseField(confirmHidePassword, stringResource(MR.strings.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword, contentType = null) } SectionItemViewSpaceBetween({ saveProfilePassword(hidePassword.value) }, disabled = saveDisabled, minHeight = TextFieldDefaults.MinHeight) { Text(generalGetString(MR.strings.save_profile_password), color = if (saveDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt new file mode 100644 index 0000000000..a3e0c3206b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt @@ -0,0 +1,100 @@ +package chat.simplex.common.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemViewSpaceBetween +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.nio.file.* +import java.nio.file.attribute.BasicFileAttributes + +private class EntryUsage(val name: String, val bytes: Long) + +private class RootUsage(val dir: File, val entries: List) { + val bytes: Long = entries.sumOf { it.bytes } +} + +@Composable +fun StorageView() { + val usage by produceState?>(initialValue = null) { + value = withContext(Dispatchers.IO) { storageRoots().map(::rootUsage) } + } + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.storage)) + val roots = usage + if (roots == null) { + DefaultProgressView(null) + } else { + roots.forEachIndexed { i, root -> + if (i > 0) SectionDividerSpaced() + SectionView(root.dir.path) { + UsageRow(stringResource(MR.strings.storage_total), root.bytes) + root.entries.forEach { UsageRow(it.name, it.bytes) } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun UsageRow(name: String, bytes: Long) { + SectionItemViewSpaceBetween { + Text(name, Modifier.weight(1f)) + Text(formatBytes(bytes), Modifier.padding(start = DEFAULT_PADDING), color = MaterialTheme.colors.secondary) + } +} + +private fun storageRoots(): List { + val dirs = listOf(dataDir, preferencesDir, tmpDir).map { it.normalize() }.distinct() + return dirs.filter { dir -> dirs.none { it != dir && dir.startsWith(it) } } +} + +private fun rootUsage(root: File): RootUsage = + RootUsage( + root, + rootEntries(root) + .map { EntryUsage(it.fileName.toString(), treeSize(it)) } + .sortedByDescending { it.bytes } + ) + +private fun rootEntries(root: File): List = + try { + Files.newDirectoryStream(root.toPath()).use { it.toList() } + } catch (e: Exception) { + Log.e(TAG, "StorageView rootEntries: ${e.javaClass.simpleName}") + emptyList() + } + +private fun treeSize(path: Path): Long { + var bytes = 0L + Files.walkFileTree(path, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + bytes += attrs.size() + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + Log.e(TAG, "StorageView visitFileFailed: ${exc.javaClass.simpleName}") + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) Log.e(TAG, "StorageView postVisitDirectory: ${exc.javaClass.simpleName}") + return FileVisitResult.CONTINUE + } + }) + return bytes +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index dd1e208349..e91d6c2ce0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -277,7 +277,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: ( @Composable fun PasswordAndAction(label: StringResource, color: Color = MaterialTheme.colors.primary) { SectionView() { SectionItemViewWithoutMinPadding { - PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true) + PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true, contentType = null) } SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) { Text(generalGetString(label), color = if (actionEnabled) color else MaterialTheme.colors.secondary) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 1e46091ea3..20a182551c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -33,10 +33,14 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.res.* import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.compose.components.MarkdownComponents import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.elements.MarkdownHeader import com.mikepenz.markdown.m2.markdownColor import com.mikepenz.markdown.m2.markdownTypography +import com.mikepenz.markdown.model.MarkdownColors +import com.mikepenz.markdown.model.MarkdownPadding +import com.mikepenz.markdown.model.MarkdownTypography import com.mikepenz.markdown.model.markdownPadding import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -771,22 +775,36 @@ expect fun ConditionsBox(modifier: Modifier, scrollState: ScrollState, content: @Composable private fun ConditionsMarkdown(text: String) { + val style = documentMarkdownStyle() Markdown(text, - markdownColor(linkText = MaterialTheme.colors.primary), + style.colors, + style.typography, + Modifier.padding(8.dp), + // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly + flavour = CommonMarkFlavourDescriptor(), + components = style.components, + padding = style.padding + ) +} + +class DocumentMarkdownStyle(val colors: MarkdownColors, val typography: MarkdownTypography, val components: MarkdownComponents, val padding: MarkdownPadding) + +@Composable +fun documentMarkdownStyle(h1: TextStyle = MaterialTheme.typography.body1): DocumentMarkdownStyle = + DocumentMarkdownStyle( + markdownColor(), markdownTypography( - h1 = MaterialTheme.typography.body1, + h1 = h1, h2 = MaterialTheme.typography.h3.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold), h3 = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), h4 = MaterialTheme.typography.h5.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold), h5 = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold), link = MaterialTheme.typography.body1.copy( + color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline ) ), - Modifier.padding(8.dp), - // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly - flavour = CommonMarkFlavourDescriptor(), - components = markdownComponents( + markdownComponents( heading2 = { Spacer(Modifier.height(10.dp)) MarkdownHeader(it.content, it.node, it.typography.h2) @@ -803,9 +821,8 @@ private fun ConditionsMarkdown(text: String) { Spacer(Modifier.height(4.dp)) }, ), - padding = markdownPadding(block = 4.dp) + markdownPadding(block = 4.dp) ) -} @Composable private fun ConditionsLinkView(conditionsLink: String) { diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 5b0227b3a4..962eac1ac5 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -688,6 +688,9 @@ File saved File not found Error saving file + Formatted view + Text view + This file has text direction characters. The text can be shown in a different order than it is written. Loading the file Please, wait while the file is being loaded from the linked mobile File error @@ -1161,6 +1164,8 @@ Deprecated options Show internal errors Show slow API calls + Storage + Total Shutdown? Notifications will stop working until you re-launch the app Error saving settings @@ -1768,6 +1773,8 @@ You joined this group You rejected group invitation Group invitation expired + For you only + For %1$s only deleted contact diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 9c6139b493..925698154e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import boofcv.io.image.ConvertBufferedImage import boofcv.struct.image.GrayU8 +import chat.simplex.common.views.helpers.decodeBoundedBufferedImage import chat.simplex.res.* import org.jetbrains.skia.Image import java.awt.RenderingHints @@ -54,6 +55,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } } +actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = + try { + decodeBoundedBufferedImage(Base64.getMimeDecoder().decode(base64), width).toComposeImageBitmap() + } catch (e: Throwable) { + Log.e(TAG, "decodeBase64Image error: $e") + null + } + actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index b090e301d5..894f31a395 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.* +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.draganddrop.* import androidx.compose.ui.draganddrop.DragData import androidx.compose.ui.input.pointer.* @@ -85,3 +86,5 @@ actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = this then Modifier .onPointerEvent(PointerEventType.Enter) { action(true) } .onPointerEvent(PointerEventType.Exit) { action(false) } + +actual fun newPasswordContentType(): ContentType = ContentType.NewPassword diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 98ffa7c8a4..dd76e86c81 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -23,7 +23,7 @@ actual fun SimpleAndAnimatedImageView( // still image. A full screen modal is shown beside the chat rather than in place of it, so this item keeps // composing under one and would otherwise decode where nobody can see it. val frame = if (smallView) imageBitmap - else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() } + else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() || ModalManager.center.hasModalsOpen() } ImageView(BitmapPainter(frame)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md index 173def8ae7..3dff974366 100644 --- a/apps/multiplatform/product/README.md +++ b/apps/multiplatform/product/README.md @@ -277,6 +277,7 @@ SettingsView -> CallSettings .............. (call preferences) -> VersionInfoView ........... (about/version) -> DeveloperView ............. (developer options) + -> StorageView ............. (storage breakdown) -> HelpView .................. (help & support) UserPicker @@ -306,7 +307,7 @@ UserPicker | | OR (when no chat selected): | GroupProfileView | | UserPicker (overlay) | "No selected chat" | AddGroupMembersView | | - profile switcher | | MemberAdmission | -| - quick settings | OR (when modal open): | VerifyCodeView | +| - quick settings | Over them (when modal open): | VerifyCodeView | | | ModalManager.center content | SettingsView subtabs | | ModalManager.start | (settings, new chat, etc.) | | | - secondary modals | | ModalManager.end | @@ -315,7 +316,7 @@ UserPicker ModalManager Placement (Desktop): - ModalManager.start -> left panel overlay (settings subviews) - - ModalManager.center -> center panel (replaces chat, used when chatId is null) + - ModalManager.center -> center panel (drawn over the chat or the placeholder) - ModalManager.end -> right panel (detail/info views) - ModalManager.fullscreen -> full window overlay (onboarding, auth, call) diff --git a/apps/multiplatform/product/views/settings.md b/apps/multiplatform/product/views/settings.md index e668bf2d04..563fa09d5b 100644 --- a/apps/multiplatform/product/views/settings.md +++ b/apps/multiplatform/product/views/settings.md @@ -139,6 +139,7 @@ Platform-specific section (expect/actual composable): |---|---| | App updates (Desktop) | App update checker and installer | | Developer tools | Toggle developer mode | +| Storage (developer options) | Opens `StorageView`: size of each top-level entry in the app's data, config and temp folders | | Chat console | Opens `ChatConsoleView` terminal | | Terminal always visible (Desktop) | Keep terminal window open | | Install terminal app | Link to CLI app on GitHub | diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md index dfc37a5e8d..38a0e13874 100644 --- a/apps/multiplatform/product/views/user-profiles.md +++ b/apps/multiplatform/product/views/user-profiles.md @@ -28,8 +28,8 @@ Overlay panel triggered from `ChatListView` toolbar: | Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | | Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | | User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | -| SimpleX address | Row to open `UserAddressView` (create or view address) | -| Chat preferences | Row to open `PreferencesView` | +| SimpleX address | Row to open `UserAddressView` (create or view address); hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) | +| Chat preferences | Row to open `PreferencesView`; hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) | | Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | | Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | | Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index e34da9613d..5a63ad92bc 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -279,6 +279,9 @@ fun UserPicker( - **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. - **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. - Closes automatically when any `ModalManager.start` modal opens. +- On Desktop, when there is no active user and no mobile is in use (`desktopNoUserNoRemote`), the SimpleX address and Chat preferences rows and the divider below them are hidden. +- The SimpleX address row opens `UserAddressView` in `ModalManager.start`. With no active user (`currentUser == null`) the modal draws nothing and closes itself when it is the top modal (`ModalManager.start.isLastModal`); if another screen covers it, it closes when the user goes back to it. +- The Chat preferences row closes its modal the same way when there is no active user. ### Content diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index adf8ad0224..3bad2e09c4 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -313,6 +313,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `EmojiItemView.kt` | Large emoji-only message | | `FramedItemView.kt` | Message bubble frame (quoted item, text, media) | | `ImageFullScreenView.kt` | Fullscreen image gallery | +| `MarkdownFileView.kt` | Markdown file viewer, formatted or as text | | `IntegrityErrorItemView.kt` | Message integrity error | | `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | | `TextItemView.kt` | Plain text message with markdown | diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index ee0eb60f77..4900cfaba7 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -186,7 +186,7 @@ fun DesktopScreen(userPickerState: MutableStateFlow) |---|---|---| | **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay | | **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` | -| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) | +| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView with `ModalManager.center` over it; without a chat: `ModalManager.center` or "no selected chat" placeholder) | | **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) | ### Column Separators @@ -204,7 +204,7 @@ When the UserPicker is visible or a start modal is open (but no center modal), a - When `chatId` is null and no center modals: shows "No selected chat" placeholder. - When `chatId` is null and center modals open: shows `ModalManager.center`. -- When `chatId` is set: shows `ChatView`. +- When `chatId` is set: shows `ChatView`, and `ModalManager.center` over it. - Automatically closes center modals when a chat is selected. ### StartPartOfScreen @@ -231,7 +231,7 @@ class ModalManager(private val placement: ModalPlacement?) | Zone | Android Behavior | Desktop Behavior | |---|---|---| | `start` | Shared (same as all others) | Left column overlay, slides from start | -| `center` | Shared | Center column overlay, replaces ChatView | +| `center` | Shared | Center column overlay, drawn over ChatView | | `end` | Shared | Right column, slides from end | | `fullscreen` | Shared | Fullscreen overlay | @@ -264,6 +264,7 @@ Each `ModalManager` maintains a stack of `ModalViewHolder` objects with: | `closeModals` | Pop all modals from the stack | | `closeModalsExceptFirst` | Pop all but the bottom modal | | `hasModalsOpen()` | Check if any modals are on the stack | +| `isLastModal(data)` | Check if the modal with this `ModalData` is the top modal not being removed | | `showInView` | Render the current modal stack into the composable tree | ### Usage Pattern diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index 1400dc25ae..7fb6ff437e 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -220,6 +220,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `views/usersettings/SetDeliveryReceiptsView.kt` | PC2 | Low | Delivery receipts toggle | | `views/usersettings/RTCServers.kt` | PC17, PC25 | Medium | WebRTC ICE server configuration | | `views/usersettings/DeveloperView.kt` | — | Low | Developer/debug settings | +| `views/usersettings/StorageView.kt` | — | Low | Storage breakdown (developer options) | | `views/usersettings/HelpView.kt` | — | Low | Help and support links | | `views/usersettings/MarkdownHelpView.kt` | PC4 | Low | Markdown formatting guide | | `views/usersettings/VersionInfoView.kt` | — | Low | Version display | diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md index 028e470964..d8985700cd 100644 --- a/apps/simplex-calculator-bot/README.md +++ b/apps/simplex-calculator-bot/README.md @@ -25,7 +25,7 @@ The bot prints its address and keeps its data in `./data`. To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`. -To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`. +To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`, then start the bot with `SIMPLEX_ADDON_PATH=../../packages/simplex-chat-nodejs/build/Release/simplex.node`. ## Test diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 98226805bc..02a2d2f5be 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -74,7 +74,7 @@ import Simplex.Chat.Names (claimDomain) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Store import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Shared (GroupKeysRow, groupInfoQueryFields, groupInfoQueryFrom, mkGroupKeys, toGroupInfo_) +import Simplex.Chat.Store.Shared (GroupKeysRow, groupInfoQueryFields, groupInfoQueryFrom, mkGroupInfoKeys, toGroupInfo_) import Simplex.Chat.Types import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), SimplexDomain) @@ -316,8 +316,8 @@ getGroupAndRegLink cc user@User {userId, userContactId} gId = (g, gksData, gr, gLink_) <- ExceptT $ firstRow (toGroupInfoKeysRegLink currentTs cxt user) ("group " ++ show gId ++ " not found") $ DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) - gks <- withExceptT groupDBError $ mkGroupKeys db cxt g gksData - pure (GIK g gks, gr, gLink_) + gks <- withExceptT groupDBError $ mkGroupInfoKeys db cxt g gksData + pure (gks, gr, gLink_) where cxt = storeCxt cc diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md index 19b9ab8bd6..d95aa5b59b 100644 --- a/apps/simplex-support-bot/README.md +++ b/apps/simplex-support-bot/README.md @@ -12,30 +12,18 @@ A business-address bot that triages incoming support chats, optionally runs them ```bash cd apps/simplex-support-bot -npm install # downloads native libs + transitive deps +npm install npm run build # tsc ``` -By default this installs the **SQLite** backend. +The native library is downloaded on first start into the user cache. To download it ahead of time (for example in a Dockerfile), run as the user that runs the bot: -To use **PostgreSQL** instead, drop a `.npmrc` next to `package.json` *before* `npm install`: - -```bash -echo 'simplex_backend=postgres' > .npmrc -npm install # now pulls postgres-flavored native libs -npm run build +```sh +npx simplex-chat install --backend sqlite +npx simplex-chat install --backend postgres # Linux x86_64 only ``` -`.npmrc` lives next to the package — npm reads it natively, no extra setup. - -### Switching backends - -`npm install` is a no-op for already-installed deps, so editing `.npmrc` and re-running `npm install` will *not* re-trigger `simplex-chat`'s preinstall. To switch backends, force a clean install: - -```bash -rm -rf node_modules -npm install # download-libs.js re-runs and pulls the right native lib -``` +Select the database with `--db sqlite` (default) or `--db postgres`. ## Run @@ -47,11 +35,9 @@ npm start -- --team-group "Support Team" # PostgreSQL npm start -- --team-group "Support Team" \ - --pg-conn "postgres://user:pass@host/db" + --db postgres --pg-conn "postgres://user:pass@host/db" ``` -The bot runs via `npm start` so npm can expose `.npmrc` settings to the process — `detectBackend()` reads `npm_config_simplex_backend` to know which backend was installed. - ## Flags Run `npm start -- --help` for the auto-generated reference. Summary: @@ -60,6 +46,7 @@ Run `npm start -- --help` for the auto-generated reference. Summary: |---|---|---|---|---| | `--team-group` | both | yes | — | team group display name | | `--state-file` | both | no | `./data/state.json` | path to bot state JSON | +| `--db` | both | no | `sqlite` | `sqlite` or `postgres` | | `--sqlite-file-prefix` | sqlite | no | `./data/simplex` | DB file prefix (creates `_chat.db`, `_agent.db`) | | `--sqlite-key` | sqlite | no | (unencrypted) | SQLCipher encryption key | | `--pg-conn` | postgres | yes | — | PostgreSQL connection string | @@ -76,7 +63,8 @@ Run `npm start -- --help` for the auto-generated reference. Summary: | Var | Purpose | |---|---| | `GROK_API_KEY` | xAI API key; enables Grok replies | -| `SIMPLEX_BACKEND` | alternative to `.npmrc` for selecting the install backend (`sqlite` or `postgres`) | +| `SIMPLEX_LIBS_DIR` | directory with a local libsimplex build for the `--db` backend, instead of the downloaded one | +| `SIMPLEX_ADDON_PATH` | locally built Node.js add-on (`simplex.node`), instead of the downloaded one | ## Local development against unreleased lib changes @@ -84,18 +72,18 @@ This package depends on `simplex-chat` from npm. To test against an in-tree vers ```bash # In packages/simplex-chat-nodejs +npm install && npm run build npm link # In apps/simplex-support-bot npm link simplex-chat +export SIMPLEX_ADDON_PATH=../../packages/simplex-chat-nodejs/build/Release/simplex.node ``` `npm unlink simplex-chat && npm install` reverts to the registry version. ## Troubleshooting -- **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string. +- **`--pg-conn is required with --db postgres`** — pass a connection string, or use `--db sqlite`. - **`libpq5` errors at startup** — install `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu). - **`ENOENT: no such file or directory, open './data/state.json'`** — the parent directory of `--state-file` must exist; `mkdir -p data` before starting. -- **Wrong backend installed** — check `node_modules/simplex-chat/libs/installed.txt`. Edit `.npmrc`, then `rm -rf node_modules && npm install` to switch (`npm install` alone won't re-run the dep's preinstall). -- **`libpq` connection error** at startup with sqlite-flavored config (or vice versa) — `.npmrc` was changed but libs weren't reinstalled. See "Switching backends" above. diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index 37521e49dc..a9785de1e3 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -2403,54 +2403,48 @@ describe("parseConfig Validation", () => { .toThrow(/--complete-hours must be a non-negative integer, got "abc"/) }) - test("postgres backend without --pg-conn → throws", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - expect(() => parseConfig(baseArgs)) - .toThrow(/--pg-conn is required when backend is postgres/) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres without --pg-conn → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres"])) + .toThrow(/--pg-conn is required with --db postgres/) }) - test("postgres backend with --pg-conn → db is postgres DbConfig", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://user:pass@localhost/db"]) - expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres with --pg-conn → db is postgres DbConfig", () => { + const cfg = parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "postgres://user:pass@localhost/db"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) }) - test("postgres backend with --pg-schema → DbConfig carries schemaPrefix", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) - expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres with --pg-schema → DbConfig carries schemaPrefix", () => { + const cfg = parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) }) - test("sqlite backend (default) → db is sqlite DbConfig with default filePrefix", () => { - const prevBackend = process.env.SIMPLEX_BACKEND - const prevNpm = process.env.npm_config_simplex_backend - delete process.env.SIMPLEX_BACKEND - delete process.env.npm_config_simplex_backend - try { - const cfg = parseConfig(baseArgs) - expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) - } finally { - if (prevBackend !== undefined) process.env.SIMPLEX_BACKEND = prevBackend - if (prevNpm !== undefined) process.env.npm_config_simplex_backend = prevNpm - } + test("default --db → sqlite DbConfig with default filePrefix", () => { + const cfg = parseConfig(baseArgs) + expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) + }) + + test("--pg-conn with sqlite → throws", () => { + expect(() => parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db"])) + .toThrow(/--pg-conn and --pg-schema require --db postgres/) + }) + + test("--pg-schema with sqlite → throws", () => { + expect(() => parseConfig([...baseArgs, "--pg-schema", "bot"])) + .toThrow(/--pg-conn and --pg-schema require --db postgres/) + }) + + test("--sqlite-key with postgres → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "c", "--sqlite-key", "k"])) + .toThrow(/--sqlite-file-prefix and --sqlite-key require --db sqlite/) + }) + + test("--sqlite-file-prefix with postgres → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "c", "--sqlite-file-prefix", "./x"])) + .toThrow(/--sqlite-file-prefix and --sqlite-key require --db sqlite/) + }) + + test("invalid --db → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "mysql"])).toThrow(/Allowed choices are sqlite, postgres/) }) test("sqlite backend with --sqlite-key → DbConfig carries encryptionKey", () => { @@ -2468,18 +2462,6 @@ describe("parseConfig Validation", () => { .toThrow(/required option '--team-group/) }) - test("invalid SIMPLEX_BACKEND → throws", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "mysql" - try { - expect(() => parseConfig(baseArgs)) - .toThrow(/Invalid SIMPLEX_BACKEND: "mysql"/) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } - }) - test("--complete-hours negative → throws", () => { // parseArgs refuses "-1" as a bare arg (ambiguous with a short flag), so use `=` form expect(() => parseConfig([...baseArgs, "--complete-hours", "-1"])) diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index 8fbd006aef..be33db0c38 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -1,4 +1,4 @@ -import {Command} from "commander" +import {Command, Option} from "commander" import {api} from "simplex-chat" export interface IdName { @@ -6,8 +6,6 @@ export interface IdName { name: string } -export type Backend = "sqlite" | "postgres" - export interface Config { stateFile: string // local path to the bot's state JSON db: api.DbConfig // passed to ChatApi.init / bot.run @@ -21,18 +19,6 @@ export interface Config { grokApiKey: string | null } -// Mirrors packages/simplex-chat-nodejs/src/download-libs.js so runtime detection -// matches what was used at install time. Works whether the user installed via -// SIMPLEX_BACKEND env var, .npmrc (→ npm_config_simplex_backend), or the -// --simplex_backend=postgres CLI flag (also surfaced as npm_config_*). -export function detectBackend(): Backend { - const raw = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || "sqlite").toLowerCase() - if (raw !== "sqlite" && raw !== "postgres") { - throw new Error(`Invalid SIMPLEX_BACKEND: "${raw}". Must be "sqlite" or "postgres".`) - } - return raw -} - export function parseIdName(s: string): IdName { const i = s.indexOf(":") if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`) @@ -57,21 +43,23 @@ function buildCommand(): Command { .description("business-address triage bot") .requiredOption("--team-group ", "team group display name") .option("--state-file ", "state JSON path", "./data/state.json") + .addOption(new Option("--db ", "database backend").choices(["sqlite", "postgres"]).default("sqlite")) .option("--sqlite-file-prefix ", "SQLite DB file prefix", "./data/simplex") .option("--sqlite-key ", "SQLCipher encryption key (default: unencrypted)") - .option("--pg-conn ", "PostgreSQL connection string (required for postgres)") + .option("--pg-conn ", "PostgreSQL connection string (required with --db postgres)") .option("--pg-schema ", "PostgreSQL schema prefix (default: simplex_v1)") .option("-a, --auto-add-team-members ", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)") .option("--timezone ", "IANA timezone for weekend detection", "UTC") .option("--complete-hours ", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3) .option("--card-flush-seconds ", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300) .option("--context-file ", "text file with Grok system context (required if GROK_API_KEY set)") - .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_BACKEND sqlite | postgres — alternative to .npmrc for backend selection\n") + .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_LIBS_DIR local libsimplex build for the --db backend\n SIMPLEX_ADDON_PATH locally built Node.js add-on\n") } interface RawOpts { teamGroup: string stateFile: string + db: api.DbConfig["type"] sqliteFilePrefix: string sqliteKey?: string pgConn?: string @@ -93,19 +81,19 @@ export function parseConfig(args: string[]): Config { throw err } const opts = cmd.opts() + const given = (option: keyof RawOpts) => cmd.getOptionValueSource(option) === "cli" const grokApiKey = process.env.GROK_API_KEY || null - const backend = detectBackend() let db: api.DbConfig - if (backend === "sqlite") { + if (opts.db === "sqlite") { + if (given("pgConn") || given("pgSchema")) throw new Error("--pg-conn and --pg-schema require --db postgres") db = opts.sqliteKey ? {type: "sqlite", filePrefix: opts.sqliteFilePrefix, encryptionKey: opts.sqliteKey} : {type: "sqlite", filePrefix: opts.sqliteFilePrefix} } else { - if (!opts.pgConn) { - throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)") - } + if (given("sqliteFilePrefix") || given("sqliteKey")) throw new Error("--sqlite-file-prefix and --sqlite-key require --db sqlite") + if (!opts.pgConn) throw new Error("--pg-conn is required with --db postgres") db = opts.pgSchema ? {type: "postgres", connectionString: opts.pgConn, schemaPrefix: opts.pgSchema} : {type: "postgres", connectionString: opts.pgConn} diff --git a/apps/simplex-support-bot/vitest.config.ts b/apps/simplex-support-bot/vitest.config.ts index c143572a87..3a70f6c5e7 100644 --- a/apps/simplex-support-bot/vitest.config.ts +++ b/apps/simplex-support-bot/vitest.config.ts @@ -5,13 +5,6 @@ export default defineConfig({ test: { globals: true, testTimeout: 10000, - // Clear backend signals — .npmrc next to package.json otherwise injects - // npm_config_simplex_backend into every test's env, breaking sqlite-default - // assumptions in parseConfig tests. - env: { - SIMPLEX_BACKEND: "", - npm_config_simplex_backend: "", - }, }, resolve: { alias: { diff --git a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md index 3c3fb7b515..b0e84ada28 100644 --- a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md +++ b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md @@ -50,7 +50,7 @@ Other limitations of the desktop app: - you cannot send voice messages. - there is no support for calls yet. -You can download the desktop app for Linux and Mac via [downloads page](../docs/DOWNLOADS.md). Windows version will be available soon. +You can download the desktop app for Linux and Mac via [downloads page](https://simplex.chat/downloads/). Windows version will be available soon. ## Group directory service and other group improvements diff --git a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md index 539d719af4..1a0019216a 100644 --- a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md +++ b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md @@ -40,7 +40,7 @@ This is only possible when both devices are connected to the same local network. **On desktop** -If you don't have desktop app installed yet, [download it](../docs/DOWNLOADS.md) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer. +If you don't have desktop app installed yet, [download it](https://simplex.chat/downloads/) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer. Then in desktop app settings choose *Link a mobile* - it will show a QR code. diff --git a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md index e06f7c2084..9bd342a146 100644 --- a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md +++ b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md @@ -110,7 +110,7 @@ We [wrote previously](https://github.com/simplex-chat/simplexmq/blob/master/rfcs While we cannot require the operators of self-hosted and private servers to disclose any information about them (apart from which server code they use - this is the requirement of the AGPLv3 license to share this information with users connecting to the server), as we add other server operators to the app, it is important for the users to have all important information about these operators and servers location. -This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app. +This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app. The updated server guide also includes [the instruction](../docs/SERVER.md#) about how to host this page for your server. It is generated as a static page when the server starts. We recommend using Caddy webserver to serve it. diff --git a/blog/20260919-simplex-supporter-badges.md b/blog/20260919-simplex-supporter-badges.md index 7aa2b2e4ac..50e2ebcb4b 100644 --- a/blog/20260919-simplex-supporter-badges.md +++ b/blog/20260919-simplex-supporter-badges.md @@ -11,7 +11,7 @@ permalink: "/blog/20260919-simplex-supporter-badges.html" **Published:** Sep 19, 2026 -You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. +You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. ## A paid feature that cannot identify you @@ -28,7 +28,7 @@ Read more about badges in the [whitepaper](https://github.com/simplex-chat/simpl ## How to get a badge - + Buy a code on [simplex.chat/badges](https://simplex.chat/badges/), paying by card, Bitcoin or Monero, and redeem it in the app: open Settings, tap **Supporter perks**, and enter the code. The badge appears on your profile. The badge does not renew by itself, and no account is created. diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index af7b0146f7..96fe5422cd 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -25,6 +25,7 @@ This file is generated automatically. [Group commands](#group-commands) - [APIAddMember](#apiaddmember) +- [APIAddMemberViaGroup](#apiaddmemberviagroup) - [APIJoinGroup](#apijoingroup) - [APIAcceptMember](#apiacceptmember) - [APIMembersRole](#apimembersrole) @@ -761,6 +762,52 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIAddMemberViaGroup + +Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. + +*Network usage*: interactive. + +**Parameters**: +- groupId: int64 +- viaGroupId: int64 +- groupMemberId: int64 +- memberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) + +**Syntax**: + +``` +/_add # # relay|observer|author|member|moderator|admin|owner +``` + +```javascript +'/_add #' + groupId + ' #' + viaGroupId + ' ' + groupMemberId + ' ' + memberRole // JavaScript +``` + +```python +'/_add #' + str(groupId) + ' #' + str(viaGroupId) + ' ' + str(groupMemberId) + ' ' + str(memberRole) # Python +``` + +**Responses**: + +SentGroupInvitationViaGroup: Group invitation sent to member of another group. +- type: "sentGroupInvitationViaGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- member: [GroupMember](./TYPES.md#groupmember) +- viaGroupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaMember: [GroupMember](./TYPES.md#groupmember) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +**Errors**: +- PeerChatVRangeIncompatible: Member's client version is older than required. + +--- + + ### APIJoinGroup Join group. diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index 5416fe4c0e..7c43ce3bd4 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -25,6 +25,7 @@ This file is generated automatically. [Group events](#group-events) - Main events - [ReceivedGroupInvitation](#receivedgroupinvitation) + - [ReceivedGroupInvitationViaGroup](#receivedgroupinvitationviagroup) - [UserJoinedGroup](#userjoinedgroup) - [GroupUpdated](#groupupdated) - [JoinedGroupMember](#joinedgroupmember) @@ -286,6 +287,22 @@ Received group invitation. --- +### ReceivedGroupInvitationViaGroup + +Received group invitation from member of another group. + +**Record type**: +- type: "receivedGroupInvitationViaGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaGroupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaMember: [GroupMember](./TYPES.md#groupmember) +- fromMemberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- memberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) + +--- + + ### UserJoinedGroup Bot user joined group. Received when connection via group link completes. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index ca95a37f4e..d13817c439 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -119,6 +119,7 @@ chatCommandsDocsData = ( "Group commands", "Commands to manage and moderate groups. These commands can be used with business chats as well - they are groups. E.g., a common scenario would be to add human agents to business chat with the customer who connected via business address.", [ ("APIAddMember", [], "Add contact to group. Requires bot to have Admin role.", ["CRSentGroupInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_add #" <> Param "groupId" <> " " <> Param "contactId" <> " " <> Param "memberRole"), + ("APIAddMemberViaGroup", [], "Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.", ["CRSentGroupInvitationViaGroup", "CRChatCmdError"], [TD "CEPeerChatVRangeIncompatible" "Member's client version is older than required"], Just UNInteractive, "/_add #" <> Param "groupId" <> " #" <> Param "viaGroupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), ("APIJoinGroup", ["enableNtfs"], "Join group.", ["CRUserAcceptedGroupSent", "CRChatCmdError"], [], Just UNInteractive, "/_join #" <> Param "groupId"), ("APIAcceptMember", [], "Accept group member. Requires Admin role.", ["CRMemberAccepted", "CRChatCmdError"], [TD "CEGroupMemberNotActive" "Member is not connected yet"], Just UNBackground, "/_accept member #" <> Param "groupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), ("APIMembersRole", [], "Set members role. Requires Admin role.", ["CRMembersRoleUser", "CRChatCmdError"], [], Just UNBackground, "/_member role #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> " " <> Param "memberRole"), diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index c43b0fe3e4..a566010823 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -85,6 +85,7 @@ chatEventsDocsData = "Bots may use these events to manage users' groups and business address groups.\n\n\ \*Please note*: programming groups is more complex than programming direct connections", [ ("CEvtReceivedGroupInvitation", ""), + ("CEvtReceivedGroupInvitationViaGroup", "Received group invitation from member of another group."), ("CEvtUserJoinedGroup", "Bot user joined group. Received when connection via group link completes."), ("CEvtGroupUpdated", "Group profile or preferences updated."), ("CEvtJoinedGroupMember", "Another member joined group."), @@ -202,6 +203,7 @@ undocumentedEvents = "CEvtContactRequestRejected", "CEvtContactSwitch", "CEvtCustomChatEvent", + "CEvtDuplicateGroupMessage", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", "CEvtServiceSubStatus", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 674620487f..132e7adf38 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -92,6 +92,7 @@ chatResponsesDocsData = ("CRRemoteCtrlConnecting", "Remote controller is connecting."), ("CRSentConfirmation", "Confirmation sent to one-time invitation"), ("CRSentGroupInvitation", "Group invitation sent"), + ("CRSentGroupInvitationViaGroup", "Group invitation sent to member of another group"), ("CRSentInvitation", "Invitation sent to contact address"), ("CRSentInvitationToContact", "Invitation sent to contact (when connecting via SimpleX name to a known contact address)."), ("CRServiceReplyAccepted", "Service reply accepted for delivery. `connectionId` correlates the reply delivery event."), diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index ac5992770e..3adcaef783 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -298,7 +298,7 @@ chatTypesDocsData = (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkOwner, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), - (sti @GroupMember, STRecord, "", [], "", ""), + (removeField "memberBadgeProof" $ sti @GroupMember, STRecord, "", [], "", ""), (sti @GroupMemberAdmission, STRecord, "", [], "", ""), (sti @GroupMemberCategory, STEnum' (dropPfxSfx "GC" "Member"), "", [], "", ""), (sti @GroupMemberRef, STRecord, "", [], "", ""), diff --git a/cabal.project b/cabal.project index e5f2c65eda..fea595d685 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e24d537eaaa32230eec1fefcd957d474d7b8d4d5 + tag: cba13823567d7cd564d8a1f354046174fba375d8 source-repository-package type: git diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 95cf972c0d..92c0dac545 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -1,11 +1,12 @@ --- title: Download SimpleX apps -permalink: /downloads/index.html revision: 09.09.2024 --- # Download SimpleX apps +Download the app for your platform: https://simplex.chat/downloads/ + You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). If you cannot access GitHub, you can download SimpleX Chat apps from our mirror at [git.simplex.chat](https://git.simplex.chat/simplex-chat/simplex-chat/releases) diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index edf3ba199b..44e99a42a9 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -49,6 +49,6 @@ This is a part-time remote contract. You must be in UTC +/- 8 hours timezone. ## How to join the team -To apply, please [install SimpleX Chat app](./DOWNLOADS.md) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA). +To apply, please [install SimpleX Chat app](https://simplex.chat/downloads/) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA). **We do NOT review CVs at the initial stage, please only send the links to your achievements**. diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md index 6a232ea2ff..fe34bf3576 100644 --- a/docs/protocol/channels-protocol.md +++ b/docs/protocol/channels-protocol.md @@ -44,11 +44,11 @@ Creating a channel involves generating cryptographic material, creating the chan When a relay receives an invitation to serve a channel, it validates the channel and creates its own relay link. This flow is currently part of channel creation; adding relays to an existing channel is planned but not yet implemented. -1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, and the channel's short link. +1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, the channel's short link, the channel's public group ID, and the owner's member key. The message is signed with the owner's member key over the public group ID and the owner's member ID. -2. Relay receives the invitation and creates a relay request record. A relay request worker processes it asynchronously. +2. Relay receives the invitation and creates a relay request record. From chat protocol version 22 of the owner, an invitation without a valid signature is ignored. The owner's badge is verified under the public group ID, the owner's member ID and the owner's member key from a signed invitation. A relay request worker processes it asynchronously. -3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. +3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. The request fails when the link's entity ID differs from the public group ID in the invitation, or when the owner's key in the link data differs from the owner's member key in the invitation. 4. The relay creates its own contact address link (the relay link) with the channel's entity ID in the immutable fixed data. diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 066b465d0d..8cd655d0c0 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -232,7 +232,9 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.inv` message is sent to invite contact to the group via contact's direct connection and includes group member connection address. This message MUST only be sent by members with `admin` or `owner` role. Optional `groupLinkId` is included when this message is sent to contacts connected via the user's group link. This identifier is a random byte sequence, with no global or even local uniqueness - it is only used for the user's invitations to a given group to provide confirmation to the contact that the group invitation is for the same group the contact was connecting to via the group link, so that the invitation can be automatically accepted by the contact - the contact compares it with the group link id contained in the group link uri's data field. -`x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user. +`x.grp.inv` message can also be sent to a group member via the member connection, to invite the member to another group. In this case, the message MUST only be sent when direct messages are allowed for the sender in the group of the member connection, and receiving clients MUST ignore it otherwise. + +`x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user. It optionally includes the invited member's group profile. The inviting user replies with `x.grp.mem.info` that includes the inviting member's group profile. `x.grp.link.inv` message is sent as part of connection handshake to member joining via group link, and contains group profile and initial information about inviting and joining member. diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index a2400e9947..4e9c3c73a0 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -568,6 +568,9 @@ "params": { "properties": { "memberId": {"ref": "base64url"} + }, + "optionalProperties": { + "profile": {"ref": "profile"} } } } diff --git a/docs/rfcs/2026-10-10-invite-member-via-group.md b/docs/rfcs/2026-10-10-invite-member-via-group.md new file mode 100644 index 0000000000..865c8b08bc --- /dev/null +++ b/docs/rfcs/2026-10-10-invite-member-via-group.md @@ -0,0 +1,204 @@ +# Inviting group members to other groups + +## Problem + +A group admin can invite only a contact to a group. +To invite a member of group A to group B, the admin first creates a contact with this member: + +1. Create a member contact (`APICreateMemberContact`). +2. Send the contact invitation in group A (`x.grp.direct.inv`). +3. Wait until the contact connection is established. +4. Invite the contact to group B (`APIAddMember`, `x.grp.inv`). + +Steps 1-3 are possible only when direct messages are allowed in group A. +A direct chat is created with each invited member. + +## Design + +An admin of groups A and B invites a member of group A to group B. +The invitation is sent as `x.grp.inv` over the connection with this member in group A. +Only the invited member receives the invitation. +The invited member sees it in group A and joins group B in the same way as after an invitation from a contact. + +### Protocol + +`x.grp.inv` is accepted on group member connections, in addition to contact connections. + +``` +event x.grp.inv +params groupInvitation, unchanged +connection connection with the invited member in group A +forwarding excluded +group history excluded +``` + +Sender requirements: + +``` +group A preferences direct messages allowed for the user's role +role in group A author or above +role in group B admin or owner, and not lower than the invited role +group A group, not channel, different from group B +group B group, not channel +membership in A main profile +membership in B main profile +invited member current member with an active connection +invited member version 22 or later +``` + +The receiving client ignores the invitation when: + +``` +group A channel +group B channel +group A preferences direct messages not allowed for the sender's role +sender blocked +fromMember role below admin, or below the invited role +fromMember id equal to the invited member id +``` + +### Sending + +New command, next to `APIAddMember`: + +```haskell +APIAddMemberViaGroup + { groupId :: GroupId, + viaGroupId :: GroupId, + groupMemberId :: GroupMemberId, + memberRole :: GroupMemberRole + } +``` + +``` +groupId group B +viaGroupId group A +groupMemberId member of group A +memberRole role in group B +``` + +Syntax: `/_add # # ` + +For a member outside group A, `SEGroupMemberNotFound` is returned. + +New response, next to `CRSentGroupInvitation`: + +```haskell +CRSentGroupInvitationViaGroup + { user :: User, + groupInfo :: GroupInfo, + member :: GroupMember, + viaGroupInfo :: GroupInfo, + viaMember :: GroupMember + } +``` + +``` +groupInfo group B +member record of the invited member in group B +viaGroupInfo group A +viaMember member of group A +``` + +In group B, the record of the invited member is linked to the member of group A. +Until the member joins, the same invitation is re-sent on a repeated command. +After the member joins, `CEGroupDuplicateMember` is returned. +After the member leaves, is removed or is rejected, a new invitation is sent. + +The sent invitation is saved in group A as `CISndGroupInvitation`, visible to the sender only. +The name of the member in group A is stored in the item (`CIGroupInvitation.localDisplayName`), as the contact name in direct chats. + +### Receiving + +New event, next to `CEvtReceivedGroupInvitation`: + +```haskell +CEvtReceivedGroupInvitationViaGroup + { user :: User, + groupInfo :: GroupInfo, + viaGroupInfo :: GroupInfo, + viaMember :: GroupMember, + fromMemberRole :: GroupMemberRole, + memberRole :: GroupMemberRole + } +``` + +``` +groupInfo group B +viaGroupInfo group A +viaMember sender, member of group A +fromMemberRole sender role in group B +memberRole invited role in group B +``` + +``` +group B created with status "invited", or found by the invitation link +chat item CIRcvGroupInvitation in group A, main scope, from the sender +``` + +With automatic acceptance of group invitations enabled, the client joins group B and saves the item with status "accepted". + +With an incognito membership in group A, the same incognito profile is used in group B. + +In group B, copies of the group A profiles are stored for the sender and for the invited member. +Badge proofs bound to group A are dropped from the copies. +The copies are replaced by the profiles sent in the connection handshake (`x.grp.acpt`, `x.grp.mem.info`). + +### Joining + +The invited member joins group B with `APIJoinGroup`, or by tapping the invitation in group A. + +Status of the invitation item: + +``` +pending received +accepted joined +rejected group B deleted before joining +``` + +With disappearing messages on in group A, the item disappears after the status changes, as in direct chats. + +The connection with the host is created from the invitation link, independently of the inviter's connection. +So an invitation from a contact deleted before joining is accepted in the same way. +Before this change, joining failed with `SEContactNotFoundByMemberId`. + +### Clients + +``` +group A existing group invitation card ("Tap to join"), with a label +chat list group B, added on CEvtReceivedGroupInvitationViaGroup +sending API only +``` + +Labels, on group invitation cards in group chats only: + +``` +received card For you only +sent card For only name of the member in group A +``` + +### Bots API + +``` +APIAddMemberViaGroup new command +CRSentGroupInvitationViaGroup new response +CEvtReceivedGroupInvitationViaGroup new event +``` + +`APIAddMember`, `CRSentGroupInvitation` and `CEvtReceivedGroupInvitation`: unchanged. + +### Version + +``` +chat version 22 +``` + +Version 22 is shared with owner-signed relay invitations, in one constant `groupInvVersion`. +Version 22 is not in any release: v7.1.0-beta.6 uses version 21. + +## Out of scope + +- Role changes of members invited via a group before they join. +- "Open" action on accepted invitations. +- Invitations to members pending approval in group A. +- User interface for sending invitations. diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 24ea58b66b..7bf43e0840 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.12.0", + "version": "0.12.1", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index cc01ae9472..fc1530f674 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -266,6 +266,23 @@ export namespace APIAddMember { } } +// Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +// Network usage: interactive. +export interface APIAddMemberViaGroup { + groupId: number // int64 + viaGroupId: number // int64 + groupMemberId: number // int64 + memberRole: T.GroupMemberRole +} + +export namespace APIAddMemberViaGroup { + export type Response = CR.SentGroupInvitationViaGroup | CR.ChatCmdError + + export function cmdString(self: APIAddMemberViaGroup): string { + return '/_add #' + self.groupId + ' #' + self.viaGroupId + ' ' + self.groupMemberId + ' ' + self.memberRole + } +} + // Join group. // Network usage: interactive. export interface APIJoinGroup { diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index 5b644ee41e..b1e8fc6057 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -17,6 +17,7 @@ export type ChatEvent = | CEvt.GroupChatItemsDeleted | CEvt.ChatItemsStatusesUpdated | CEvt.ReceivedGroupInvitation + | CEvt.ReceivedGroupInvitationViaGroup | CEvt.UserJoinedGroup | CEvt.GroupUpdated | CEvt.JoinedGroupMember @@ -73,6 +74,7 @@ export namespace CEvt { | "groupChatItemsDeleted" | "chatItemsStatusesUpdated" | "receivedGroupInvitation" + | "receivedGroupInvitationViaGroup" | "userJoinedGroup" | "groupUpdated" | "joinedGroupMember" @@ -210,6 +212,16 @@ export namespace CEvt { memberRole: T.GroupMemberRole } + export interface ReceivedGroupInvitationViaGroup extends Interface { + type: "receivedGroupInvitationViaGroup" + user: T.User + groupInfo: T.GroupInfo + viaGroupInfo: T.GroupInfo + viaMember: T.GroupMember + fromMemberRole: T.GroupMemberRole + memberRole: T.GroupMemberRole + } + export interface UserJoinedGroup extends Interface { type: "userJoinedGroup" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 647427f0dc..ef83e89e9d 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -51,6 +51,7 @@ export type ChatResponse = | CR.RemoteCtrlConnecting | CR.SentConfirmation | CR.SentGroupInvitation + | CR.SentGroupInvitationViaGroup | CR.SentInvitation | CR.SentInvitationToContact | CR.ServiceReplyAccepted @@ -117,6 +118,7 @@ export namespace CR { | "remoteCtrlConnecting" | "sentConfirmation" | "sentGroupInvitation" + | "sentGroupInvitationViaGroup" | "sentInvitation" | "sentInvitationToContact" | "serviceReplyAccepted" @@ -445,6 +447,15 @@ export namespace CR { member: T.GroupMember } + export interface SentGroupInvitationViaGroup extends Interface { + type: "sentGroupInvitationViaGroup" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + viaGroupInfo: T.GroupInfo + viaMember: T.GroupMember + } + export interface SentInvitation extends Interface { type: "sentInvitation" user: T.User diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 10c4ee0a81..67e470e52d 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -261,6 +261,12 @@ export class ChatClient { throw new ChatCommandError("error adding member", r) } + async apiAddMemberViaGroup(groupId: number, viaGroupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})) + if (r.type === "sentGroupInvitationViaGroup") return r.member + throw new ChatCommandError("error adding member", r) + } + async apiJoinGroup(groupId: number): Promise { const r = await this.sendChatCmd(CC.APIJoinGroup.cmdString({groupId})) if (r.type === "userAcceptedGroupSent") return r.groupInfo diff --git a/packages/simplex-chat-nodejs/.gitignore b/packages/simplex-chat-nodejs/.gitignore index 322e38bfda..4fa6477e38 100644 --- a/packages/simplex-chat-nodejs/.gitignore +++ b/packages/simplex-chat-nodejs/.gitignore @@ -2,7 +2,7 @@ node_modules/ package-lock.json .vscode build/ -libs/ dist/ coverage/ tmp/ +libs/ diff --git a/packages/simplex-chat-nodejs/.npmignore b/packages/simplex-chat-nodejs/.npmignore index 26fdd85dff..3e2e84b087 100644 --- a/packages/simplex-chat-nodejs/.npmignore +++ b/packages/simplex-chat-nodejs/.npmignore @@ -1,3 +1,2 @@ -libs/ build/ node_modules/ diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md index 05c99eca5c..6ccd26fc05 100644 --- a/packages/simplex-chat-nodejs/README.md +++ b/packages/simplex-chat-nodejs/README.md @@ -55,40 +55,38 @@ If you cloned this repository, you can: cd ./packages/simplex-chat-nodejs npm install npm run build -node ./examples/squaring-bot-readme.js +SIMPLEX_ADDON_PATH=build/Release/simplex.node node ./examples/squaring-bot-readme.js ``` +Building the Node.js add-on from source needs [node-gyp](https://github.com/nodejs/node-gyp) build tools. + There is an example with more options in [./examples/squaring-bot.ts](./examples/squaring-bot.ts). You can run it with: `npx ts-node ./examples/squaring-bot.ts` A larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/). -## PostgreSQL backend +## Native library -By default, the package uses SQLite. To use PostgreSQL instead: +`libsimplex` and the prebuilt Node.js add-on are downloaded on first use into the user cache (`$XDG_CACHE_HOME/simplex-chat` or `~/.cache/simplex-chat` on Linux, `~/Library/Caches/simplex-chat` on macOS, `%LOCALAPPDATA%\simplex-chat` on Windows), shared with the Python library. To download them ahead of time, for example in a Dockerfile, run as the user that runs the app: ```bash -npm install simplex-chat --simplex_backend=postgres +npx simplex-chat install # sqlite (default) +npx simplex-chat install --backend postgres # Linux x86_64 only ``` -Or persist the setting in `.npmrc`: +Set `SIMPLEX_LIBS_DIR` to a directory with a local build of `libsimplex` and its dependencies to use it instead; it must be built for the backend in use. Set `SIMPLEX_ADDON_PATH` to a locally built add-on (`build/Release/simplex.node`) to use it instead of the downloaded one. -```ini -simplex_backend=postgres -``` +`ChatApi.init` loads the library for `DbConfig.type`; the low-level `core` functions require `core.loadLibrary(backend)` first. -### Prerequisites (PostgreSQL) +## PostgreSQL backend + +`DbConfig.type` selects the backend; one backend per process. - `libpq5` must be installed on the host system (`apt install libpq5` on Debian/Ubuntu) - PostgreSQL backend is only available for Linux x86_64 - A PostgreSQL server accessible via connection string -### Passing PostgreSQL connection - -The `DbConfig` type is a discriminated union — pick the variant that matches -the backend you installed: - ```ts // SQLite (default) dbOpts: {type: "sqlite", filePrefix: "./data/bot"} diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp index cfa6d61039..de6dbfbfd4 100644 --- a/packages/simplex-chat-nodejs/binding.gyp +++ b/packages/simplex-chat-nodejs/binding.gyp @@ -15,36 +15,8 @@ "msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1 } }, "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ], "conditions": [ - ["OS=='mac'", { - "libraries": [ - "-L<(module_root_dir)/libs", - "-lsimplex" - ], - "xcode_settings": { - "OTHER_LDFLAGS": [ - "-Wl,-rpath,@loader_path/../../libs" - ] - } - }], ["OS=='linux'", { - "libraries": [ - "-L<(module_root_dir)/libs", - "-lsimplex" - ], - "ldflags": [ - "-Wl,-rpath,'$$ORIGIN'/../../libs" - ] - }], - ["OS=='win'", { - "libraries": [ - "<(module_root_dir)/libs/libsimplex.lib" - ], - "copies": [{ - "destination": "<(PRODUCT_DIR)", - "files": [ - "<(module_root_dir)/libs/*" - ] - }] + "libraries": [ "-ldl" ] }] ] } diff --git a/packages/simplex-chat-nodejs/cpp/simplex.cc b/packages/simplex-chat-nodejs/cpp/simplex.cc index bcca2b994a..6cc2172ee1 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.cc +++ b/packages/simplex-chat-nodejs/cpp/simplex.cc @@ -8,15 +8,102 @@ #include #include #include +#include #include #include #include +#ifdef _WIN32 +#include +#else +#include +#endif #include "simplex.h" namespace simplex { using namespace Napi; +struct Library { + std::string path; + hs_init_with_rtsopts_fn hs_init_with_rtsopts = nullptr; + hs_thread_done_fn hs_thread_done = nullptr; + chat_migrate_init_fn chat_migrate_init = nullptr; + chat_migrate_init_queue_fn chat_migrate_init_queue = nullptr; + chat_close_store_fn chat_close_store = nullptr; + chat_send_cmd_fn chat_send_cmd = nullptr; + chat_recv_msg_wait_fn chat_recv_msg_wait = nullptr; + chat_write_file_fn chat_write_file = nullptr; + chat_read_file_fn chat_read_file = nullptr; + chat_encrypt_file_fn chat_encrypt_file = nullptr; + chat_decrypt_file_fn chat_decrypt_file = nullptr; +}; + +static Library lib; +static std::atomic loaded{false}; +static std::mutex load_mutex; + +#ifdef _WIN32 +typedef HMODULE LibHandle; + +static LibHandle OpenLibrary(const std::string& path) { + int n = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0); + std::wstring wpath(n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, &wpath[0], n); + // dependencies resolve from the libsimplex.dll, application and system dirs, not the cwd or PATH + return LoadLibraryExW(wpath.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); +} + +static void CloseLibrary(LibHandle h) { + FreeLibrary(h); +} + +static void* FindSymbol(LibHandle h, const char* name) { + return reinterpret_cast(GetProcAddress(h, name)); +} + +static std::string LastLoadError() { + DWORD code = GetLastError(); + char* msg = nullptr; + FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, code, 0, reinterpret_cast(&msg), 0, nullptr); + std::string err = msg ? msg : "error " + std::to_string(code); + LocalFree(msg); + // system messages end with CRLF + err.erase(err.find_last_not_of("\r\n") + 1); + return err; +} +#else +typedef void* LibHandle; + +static LibHandle OpenLibrary(const std::string& path) { + return dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); +} + +static void CloseLibrary(LibHandle h) { + dlclose(h); +} + +static void* FindSymbol(LibHandle h, const char* name) { + return dlsym(h, name); +} + +static std::string LastLoadError() { + const char* err = dlerror(); + return err ? err : "unknown error"; +} +#endif + +template +static void Resolve(LibHandle h, const char* name, F& fn) { + fn = reinterpret_cast(FindSymbol(h, name)); +} + +template +static void ResolveRequired(LibHandle h, const char* name, F& fn, std::string& missing) { + Resolve(h, name, fn); + if (fn == nullptr && missing.empty()) missing = name; +} + void haskell_init() { #ifdef _WIN32 // non-moving GC is broken on windows with GHC 9.4-9.6.3 @@ -40,7 +127,57 @@ void haskell_init() { nullptr}; #endif char **pargv = const_cast(argv); - hs_init_with_rtsopts(&argc, &pargv); + lib.hs_init_with_rtsopts(&argc, &pargv); +} + +static bool RequireLoaded(Env env) { + if (loaded) return true; + Error::New(env, "libsimplex is not loaded, call core.loadLibrary(backend) first").ThrowAsJavaScriptException(); + return false; +} + +Value Load(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 1 || !args[0].IsString()) { + TypeError::New(env, "Expected string (libPath)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + std::string path = args[0].As().Utf8Value(); + // worker_threads may load concurrently; the Haskell runtime must be initialized once per process + std::lock_guard lock(load_mutex); + if (loaded) { + if (path != lib.path) Error::New(env, "libsimplex already loaded from " + lib.path).ThrowAsJavaScriptException(); + return env.Undefined(); + } + LibHandle h = OpenLibrary(path); + if (h == nullptr) { + std::string err = LastLoadError(); + Error::New(env, "cannot load " + path + ": " + err).ThrowAsJavaScriptException(); + return env.Undefined(); + } + Library l; + l.path = path; + std::string missing; + ResolveRequired(h, "hs_init_with_rtsopts", l.hs_init_with_rtsopts, missing); + ResolveRequired(h, "chat_migrate_init", l.chat_migrate_init, missing); + ResolveRequired(h, "chat_close_store", l.chat_close_store, missing); + ResolveRequired(h, "chat_send_cmd", l.chat_send_cmd, missing); + ResolveRequired(h, "chat_recv_msg_wait", l.chat_recv_msg_wait, missing); + ResolveRequired(h, "chat_write_file", l.chat_write_file, missing); + ResolveRequired(h, "chat_read_file", l.chat_read_file, missing); + ResolveRequired(h, "chat_encrypt_file", l.chat_encrypt_file, missing); + ResolveRequired(h, "chat_decrypt_file", l.chat_decrypt_file, missing); + if (!missing.empty()) { + CloseLibrary(h); + Error::New(env, path + " does not export " + missing).ThrowAsJavaScriptException(); + return env.Undefined(); + } + Resolve(h, "chat_migrate_init_queue", l.chat_migrate_init_queue); + Resolve(h, "hs_thread_done", l.hs_thread_done); + lib = l; + haskell_init(); + loaded = true; + return env.Undefined(); } class ResultAsyncWorker : public AsyncWorker { @@ -287,7 +424,7 @@ class Receiver { request = std::move(queue_.front()); queue_.pop_front(); } - char* c_res = chat_recv_msg_wait(ctrl_, request.wait); + char* c_res = lib.chat_recv_msg_wait(ctrl_, request.wait); napi_status status = Settle(request.deferred, [c_res](Napi::Env env, Promise::Deferred& deferred) { if (c_res == nullptr) { deferred.Reject(Error::New(env, "chat_recv_msg_wait failed").Value()); @@ -312,7 +449,7 @@ class Receiver { } pending_->Tsfn().Release(); // Each OS thread that enters Haskell keeps an RTS task record until it calls hs_thread_done. - hs_thread_done(); + if (lib.hs_thread_done) lib.hs_thread_done(); } template @@ -361,6 +498,7 @@ ResultAsyncWorker::ResultProcessor MigrateResultProcessor() { Value ChatMigrateInit(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected three string arguments").ThrowAsJavaScriptException(); return env.Undefined(); @@ -375,7 +513,7 @@ Value ChatMigrateInit(const CallbackInfo& args) { auto execute_fn = [path, key, confirm](ResultAsyncWorker* worker) { chat_ctrl ctrl = nullptr; - char* c_res = chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); + char* c_res = lib.chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); worker->SetCtrl(reinterpret_cast(ctrl)); HandleCResult(worker, c_res, "chat_migrate_init"); }; @@ -388,6 +526,11 @@ Value ChatMigrateInit(const CallbackInfo& args) { Value ChatMigrateInitQueue(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); + if (lib.chat_migrate_init_queue == nullptr) { + Error::New(env, "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex").ThrowAsJavaScriptException(); + return env.Undefined(); + } if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) { TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException(); return env.Undefined(); @@ -408,7 +551,7 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) { auto execute_fn = [path, key, confirm, queue_size](ResultAsyncWorker* worker) { chat_ctrl ctrl = nullptr; - char* c_res = chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); + char* c_res = lib.chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); worker->SetCtrl(reinterpret_cast(ctrl)); HandleCResult(worker, c_res, "chat_migrate_init_queue"); }; @@ -421,6 +564,7 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) { Value ChatCloseStore(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 1 || !args[0].IsBigInt()) { TypeError::New(env, "Expected bigint (ctrl)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -436,7 +580,7 @@ Value ChatCloseStore(const CallbackInfo& args) { if (receiver) { receiver->Stop(); } - char* c_res = chat_close_store(ctrl); + char* c_res = lib.chat_close_store(ctrl); HandleCResult(worker, c_res, "chat_close_store"); }; @@ -448,6 +592,7 @@ Value ChatCloseStore(const CallbackInfo& args) { Value ChatSendCmd(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsString()) { TypeError::New(env, "Expected bigint (ctrl) and string (cmd)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -460,7 +605,7 @@ Value ChatSendCmd(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, cmd](ResultAsyncWorker* worker) { - char* c_res = chat_send_cmd(ctrl, cmd.c_str()); + char* c_res = lib.chat_send_cmd(ctrl, cmd.c_str()); HandleCResult(worker, c_res, "chat_send_cmd"); }; @@ -472,6 +617,7 @@ Value ChatSendCmd(const CallbackInfo& args) { Value ChatRecvMsgWait(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsNumber()) { TypeError::New(env, "Expected bigint (ctrl), number (wait)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -503,6 +649,7 @@ Value ChatRecvMsgWait(const CallbackInfo& args) { Value ChatWriteFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !(args[2].IsArrayBuffer() || args[2].IsTypedArray())) { TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer or Uint8Array").ThrowAsJavaScriptException(); return env.Undefined(); @@ -530,7 +677,7 @@ Value ChatWriteFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) { - char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast(len)); + char* c_res = lib.chat_write_file(ctrl, path.c_str(), data, static_cast(len)); HandleCResult(worker, c_res, "chat_write_file"); }; @@ -543,6 +690,7 @@ Value ChatWriteFile(const CallbackInfo& args) { Value ChatReadFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected three strings (path, key, nonce)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -556,7 +704,7 @@ Value ChatReadFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [path, key, nonce](BinaryAsyncWorker* worker) { - char* buf = chat_read_file(path.c_str(), key.c_str(), nonce.c_str()); + char* buf = lib.chat_read_file(path.c_str(), key.c_str(), nonce.c_str()); if (buf == nullptr) { worker->SetWorkerError("chat_read_file failed"); return; @@ -586,6 +734,7 @@ Value ChatReadFile(const CallbackInfo& args) { Value ChatEncryptFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected bigint (ctrl), two strings (fromPath, toPath)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -599,7 +748,7 @@ Value ChatEncryptFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, fromPath, toPath](ResultAsyncWorker* worker) { - char* c_res = chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()); + char* c_res = lib.chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()); HandleCResult(worker, c_res, "chat_encrypt_file"); }; @@ -611,6 +760,7 @@ Value ChatEncryptFile(const CallbackInfo& args) { Value ChatDecryptFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsString()) { TypeError::New(env, "Expected four strings (fromPath, key, nonce, toPath)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -625,7 +775,7 @@ Value ChatDecryptFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [fromPath, key, nonce, toPath](ResultAsyncWorker* worker) { - char* c_res = chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()); + char* c_res = lib.chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()); HandleCResult(worker, c_res, "chat_decrypt_file"); }; @@ -636,7 +786,7 @@ Value ChatDecryptFile(const CallbackInfo& args) { } Object Init(Env env, Object exports) { - haskell_init(); + exports.Set("load", Function::New(env, Load)); auto* receivers = new Receivers(); // Stopping all receivers before joining any bounds teardown by the longest in-flight receive. env.AddCleanupHook([receivers]() { diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h index ddc052946a..7c44313295 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.h +++ b/packages/simplex-chat-nodejs/cpp/simplex.h @@ -9,40 +9,30 @@ #ifndef SimpleX_h #define SimpleX_h -extern "C" void hs_init(int argc, char **argv[]); -extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]); -extern "C" void hs_thread_done(void); - typedef long* chat_ctrl; +typedef void (*hs_init_with_rtsopts_fn)(int *argc, char **argv[]); +typedef void (*hs_thread_done_fn)(void); // the last parameter is used to return the pointer to chat controller -extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); -extern "C" char *chat_migrate_init_queue(const char *path, const char *key, const char *confirm, const int queueSize, chat_ctrl *ctrl); -extern "C" char *chat_close_store(chat_ctrl ctrl); -extern "C" char *chat_reopen_store(chat_ctrl ctrl); -extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); -extern "C" char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); -extern "C" char *chat_parse_markdown(const char *str); -extern "C" char *chat_parse_server(const char *str); -extern "C" char *chat_password_hash(const char *pwd, const char *salt); -extern "C" char *chat_valid_name(const char *name); -extern "C" int chat_json_length(const char *str); -extern "C" char *chat_encrypt_media(chat_ctrl ctrl, const char *key, const char *frame, const int len); -extern "C" char *chat_decrypt_media(const char *key, const char *frame, const int len); +typedef char *(*chat_migrate_init_fn)(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); +typedef char *(*chat_migrate_init_queue_fn)(const char *path, const char *key, const char *confirm, int queueSize, chat_ctrl *ctrl); +typedef char *(*chat_close_store_fn)(chat_ctrl ctrl); +typedef char *(*chat_send_cmd_fn)(chat_ctrl ctrl, const char *cmd); +typedef char *(*chat_recv_msg_wait_fn)(chat_ctrl ctrl, int wait); // chat_write_file returns null-terminated string with JSON of WriteFileResult -extern "C" char *chat_write_file(chat_ctrl ctrl, const char *path, const char *data, const int len); +typedef char *(*chat_write_file_fn)(chat_ctrl ctrl, const char *path, const char *data, int len); // chat_read_file returns a buffer with: // result status (1 byte), then if // status == 0 (success): buffer length (uint32, 4 bytes), buffer of specified length. // status == 1 (error): null-terminated error message string. -extern "C" char *chat_read_file(const char *path, const char *key, const char *nonce); +typedef char *(*chat_read_file_fn)(const char *path, const char *key, const char *nonce); // chat_encrypt_file returns null-terminated string with JSON of WriteFileResult -extern "C" char *chat_encrypt_file(chat_ctrl ctrl, const char *fromPath, const char *toPath); +typedef char *(*chat_encrypt_file_fn)(chat_ctrl ctrl, const char *fromPath, const char *toPath); // chat_decrypt_file returns null-terminated string with the error message -extern "C" char *chat_decrypt_file(const char *fromPath, const char *key, const char *nonce, const char *toPath); +typedef char *(*chat_decrypt_file_fn)(const char *fromPath, const char *key, const char *nonce, const char *toPath); #endif /* simplex_h */ \ No newline at end of file diff --git a/packages/simplex-chat-nodejs/docs/Namespace.core.md b/packages/simplex-chat-nodejs/docs/Namespace.core.md index 82b0d9ffba..fdf9f8b4dd 100644 --- a/packages/simplex-chat-nodejs/docs/Namespace.core.md +++ b/packages/simplex-chat-nodejs/docs/Namespace.core.md @@ -32,6 +32,7 @@ You are unlikely to ever need to use this module directly. ## Type Aliases +- [Backend](core.TypeAlias.Backend.md) - [DBMigrationError](core.TypeAlias.DBMigrationError.md) - [MigrationError](core.TypeAlias.MigrationError.md) - [MTRError](core.TypeAlias.MTRError.md) @@ -46,3 +47,4 @@ You are unlikely to ever need to use this module directly. - [chatRecvMsgWait](core.Function.chatRecvMsgWait.md) - [chatSendCmd](core.Function.chatSendCmd.md) - [chatWriteFile](core.Function.chatWriteFile.md) +- [loadLibrary](core.Function.loadLibrary.md) diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md index 287099906b..cf293f55d9 100644 --- a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md @@ -6,7 +6,7 @@ # Class: ChatApi -Defined in: [src/api.ts:97](../src/api.ts#L97) +Defined in: [src/api.ts:95](../src/api.ts#L95) Main API class for interacting with the chat core library. @@ -16,7 +16,7 @@ Main API class for interacting with the chat core library. > `protected` **ctrl\_**: `bigint` \| `undefined` -Defined in: [src/api.ts:103](../src/api.ts#L103) +Defined in: [src/api.ts:101](../src/api.ts#L101) ## Accessors @@ -26,7 +26,7 @@ Defined in: [src/api.ts:103](../src/api.ts#L103) > **get** **ctrl**(): `bigint` -Defined in: [src/api.ts:344](../src/api.ts#L344) +Defined in: [src/api.ts:343](../src/api.ts#L343) Chat controller reference @@ -42,7 +42,7 @@ Chat controller reference > **get** **initialized**(): `boolean` -Defined in: [src/api.ts:330](../src/api.ts#L330) +Defined in: [src/api.ts:329](../src/api.ts#L329) Chat controller is initialized @@ -58,7 +58,7 @@ Chat controller is initialized > **get** **started**(): `boolean` -Defined in: [src/api.ts:337](../src/api.ts#L337) +Defined in: [src/api.ts:336](../src/api.ts#L336) Chat controller is started @@ -72,7 +72,7 @@ Chat controller is started > **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:750](../src/api.ts#L750) +Defined in: [src/api.ts:770](../src/api.ts#L770) Accept contact request. Network usage: interactive. @@ -93,7 +93,7 @@ Network usage: interactive. > **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:570](../src/api.ts#L570) +Defined in: [src/api.ts:590](../src/api.ts#L590) Accept group member. Requires Admin role. Network usage: background. @@ -122,7 +122,7 @@ Network usage: background. > **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:550](../src/api.ts#L550) +Defined in: [src/api.ts:560](../src/api.ts#L560) Add contact to group. Requires bot to have Admin role. Network usage: interactive. @@ -147,11 +147,44 @@ Network usage: interactive. *** +### apiAddMemberViaGroup() + +> **apiAddMemberViaGroup**(`groupId`, `viaGroupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> + +Defined in: [src/api.ts:570](../src/api.ts#L570) + +Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +Network usage: interactive. + +#### Parameters + +##### groupId + +`number` + +##### viaGroupId + +`number` + +##### groupMemberId + +`number` + +##### memberRole + +`GroupMemberRole` + +#### Returns + +`Promise`\<`GroupMember`\> + +*** + ### apiBlockMembersForAll() > **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\> -Defined in: [src/api.ts:590](../src/api.ts#L590) +Defined in: [src/api.ts:610](../src/api.ts#L610) Block members. Requires Moderator role. Network usage: background. @@ -180,7 +213,7 @@ Network usage: background. > **apiCancelFile**(`fileId`): `Promise`\<`void`\> -Defined in: [src/api.ts:540](../src/api.ts#L540) +Defined in: [src/api.ts:550](../src/api.ts#L550) Cancel file. Network usage: background. @@ -201,7 +234,7 @@ Network usage: background. > **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\> -Defined in: [src/api.ts:513](../src/api.ts#L513) +Defined in: [src/api.ts:523](../src/api.ts#L523) Add/remove message reaction. Network usage: background. @@ -238,7 +271,7 @@ Network usage: background. > **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:719](../src/api.ts#L719) +Defined in: [src/api.ts:739](../src/api.ts#L739) Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link Network usage: interactive. @@ -267,7 +300,7 @@ Network usage: interactive. > **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:728](../src/api.ts#L728) +Defined in: [src/api.ts:748](../src/api.ts#L748) Connect via SimpleX link as string in the active user profile. Network usage: interactive. @@ -288,7 +321,7 @@ Network usage: interactive. > **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> -Defined in: [src/api.ts:709](../src/api.ts#L709) +Defined in: [src/api.ts:729](../src/api.ts#L729) Determine SimpleX link type and if the bot is already connected via this link. Network usage: interactive. @@ -313,7 +346,7 @@ Network usage: interactive. > **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\> -Defined in: [src/api.ts:887](../src/api.ts#L887) +Defined in: [src/api.ts:907](../src/api.ts#L907) Create new user profile Network usage: no. @@ -334,7 +367,7 @@ Network usage: no. > **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\> -Defined in: [src/api.ts:650](../src/api.ts#L650) +Defined in: [src/api.ts:670](../src/api.ts#L670) Create group link. Network usage: interactive. @@ -359,7 +392,7 @@ Network usage: interactive. > **apiCreateLink**(`userId`): `Promise`\<`string`\> -Defined in: [src/api.ts:696](../src/api.ts#L696) +Defined in: [src/api.ts:716](../src/api.ts#L716) Create 1-time invitation link. Network usage: interactive. @@ -380,7 +413,7 @@ Network usage: interactive. > **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:953](../src/api.ts#L953) +Defined in: [src/api.ts:973](../src/api.ts#L973) Create a direct message contact with a group member. Returns the created contact. @@ -406,7 +439,7 @@ Network usage: interactive. > **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\> -Defined in: [src/api.ts:361](../src/api.ts#L361) +Defined in: [src/api.ts:360](../src/api.ts#L360) Create bot address. Network usage: interactive. @@ -427,7 +460,7 @@ Network usage: interactive. > **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\> -Defined in: [src/api.ts:809](../src/api.ts#L809) +Defined in: [src/api.ts:829](../src/api.ts#L829) Delete chat. Network usage: background. @@ -456,7 +489,7 @@ Network usage: background. > **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:488](../src/api.ts#L488) +Defined in: [src/api.ts:498](../src/api.ts#L498) Delete message. Network usage: background. @@ -489,7 +522,7 @@ Network usage: background. > **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\> -Defined in: [src/api.ts:672](../src/api.ts#L672) +Defined in: [src/api.ts:692](../src/api.ts#L692) Delete group link. Network usage: background. @@ -510,7 +543,7 @@ Network usage: background. > **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:503](../src/api.ts#L503) +Defined in: [src/api.ts:513](../src/api.ts#L513) Moderate message. Requires Moderator role (and higher than message author's). Network usage: background. @@ -535,7 +568,7 @@ Network usage: background. > **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\> -Defined in: [src/api.ts:917](../src/api.ts#L917) +Defined in: [src/api.ts:937](../src/api.ts#L937) Delete user profile. Network usage: background. @@ -564,7 +597,7 @@ Network usage: background. > **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\> -Defined in: [src/api.ts:371](../src/api.ts#L371) +Defined in: [src/api.ts:370](../src/api.ts#L370) Deletes a user address. Network usage: background. @@ -585,7 +618,7 @@ Network usage: background. > **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\> -Defined in: [src/api.ts:867](../src/api.ts#L867) +Defined in: [src/api.ts:887](../src/api.ts#L887) Get active user profile Network usage: no. @@ -600,7 +633,7 @@ Network usage: no. > **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\> -Defined in: [src/api.ts:857](../src/api.ts#L857) +Defined in: [src/api.ts:877](../src/api.ts#L877) Get chat items. Network usage: no. @@ -629,7 +662,7 @@ Network usage: no. > **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\> -Defined in: [src/api.ts:794](../src/api.ts#L794) +Defined in: [src/api.ts:814](../src/api.ts#L814) Get chat previews (paginated). Network usage: no. @@ -666,7 +699,7 @@ on large databases. > **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\> -Defined in: [src/api.ts:681](../src/api.ts#L681) +Defined in: [src/api.ts:701](../src/api.ts#L701) Get group link. Network usage: no. @@ -687,7 +720,7 @@ Network usage: no. > **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\> -Defined in: [src/api.ts:687](../src/api.ts#L687) +Defined in: [src/api.ts:707](../src/api.ts#L707) #### Parameters @@ -705,7 +738,7 @@ Defined in: [src/api.ts:687](../src/api.ts#L687) > **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\> -Defined in: [src/api.ts:381](../src/api.ts#L381) +Defined in: [src/api.ts:380](../src/api.ts#L380) Get bot address and settings. Network usage: no. @@ -726,7 +759,7 @@ Network usage: no. > **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:560](../src/api.ts#L560) +Defined in: [src/api.ts:580](../src/api.ts#L580) Join group. Network usage: interactive. @@ -747,7 +780,7 @@ Network usage: interactive. > **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:610](../src/api.ts#L610) +Defined in: [src/api.ts:630](../src/api.ts#L630) Leave group. Network usage: background. @@ -768,7 +801,7 @@ Network usage: background. > **apiListContacts**(`userId`): `Promise`\<`Contact`[]\> -Defined in: [src/api.ts:770](../src/api.ts#L770) +Defined in: [src/api.ts:790](../src/api.ts#L790) Get contacts. Network usage: no. @@ -789,7 +822,7 @@ Network usage: no. > **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\> -Defined in: [src/api.ts:780](../src/api.ts#L780) +Defined in: [src/api.ts:800](../src/api.ts#L800) Get groups. Network usage: no. @@ -818,7 +851,7 @@ Network usage: no. > **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:620](../src/api.ts#L620) +Defined in: [src/api.ts:640](../src/api.ts#L640) Get group members. Network usage: no. @@ -839,7 +872,7 @@ Network usage: no. > **apiListUsers**(): `Promise`\<`UserInfo`[]\> -Defined in: [src/api.ts:897](../src/api.ts#L897) +Defined in: [src/api.ts:917](../src/api.ts#L917) Get all user profiles Network usage: no. @@ -854,7 +887,7 @@ Network usage: no. > **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:630](../src/api.ts#L630) +Defined in: [src/api.ts:650](../src/api.ts#L650) Create group. Network usage: no. @@ -879,7 +912,7 @@ Network usage: no. > **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\> -Defined in: [src/api.ts:529](../src/api.ts#L529) +Defined in: [src/api.ts:539](../src/api.ts#L539) Receive file. Network usage: no. @@ -900,7 +933,7 @@ Network usage: no. > **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\> -Defined in: [src/api.ts:760](../src/api.ts#L760) +Defined in: [src/api.ts:780](../src/api.ts#L780) Reject contact request. The user who sent the request is **not notified**. Network usage: no. @@ -921,7 +954,7 @@ Network usage: no. > **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:600](../src/api.ts#L600) +Defined in: [src/api.ts:620](../src/api.ts#L620) Remove members. Requires Admin role. Network usage: background. @@ -950,7 +983,7 @@ Network usage: background. > **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:964](../src/api.ts#L964) +Defined in: [src/api.ts:984](../src/api.ts#L984) Send a direct message invitation to a group member contact. The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact). @@ -976,7 +1009,7 @@ Network usage: interactive. > **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:432](../src/api.ts#L432) +Defined in: [src/api.ts:442](../src/api.ts#L442) Send messages. Network usage: background. @@ -1005,7 +1038,7 @@ Network usage: background. > **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:455](../src/api.ts#L455) +Defined in: [src/api.ts:465](../src/api.ts#L465) Send text message. Network usage: background. @@ -1034,7 +1067,7 @@ Network usage: background. > **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:463](../src/api.ts#L463) +Defined in: [src/api.ts:473](../src/api.ts#L473) Send text message in reply to received message. Network usage: background. @@ -1059,7 +1092,7 @@ Network usage: background. > **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\> -Defined in: [src/api.ts:907](../src/api.ts#L907) +Defined in: [src/api.ts:927](../src/api.ts#L927) Set active user profile Network usage: no. @@ -1084,7 +1117,7 @@ Network usage: no. > **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\> -Defined in: [src/api.ts:415](../src/api.ts#L415) +Defined in: [src/api.ts:414](../src/api.ts#L414) Set bot address settings. Network usage: interactive. @@ -1109,7 +1142,7 @@ Network usage: interactive. > **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\> -Defined in: [src/api.ts:846](../src/api.ts#L846) +Defined in: [src/api.ts:866](../src/api.ts#L866) Set auto-accept member contacts. Network usage: no. @@ -1134,7 +1167,7 @@ Network usage: no. > **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:836](../src/api.ts#L836) +Defined in: [src/api.ts:856](../src/api.ts#L856) Set contact custom data. Network usage: no. @@ -1159,7 +1192,7 @@ Network usage: no. > **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\> -Defined in: [src/api.ts:943](../src/api.ts#L943) +Defined in: [src/api.ts:963](../src/api.ts#L963) Configure chat preference overrides for the contact. Network usage: background. @@ -1184,7 +1217,7 @@ Network usage: background. > **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:826](../src/api.ts#L826) +Defined in: [src/api.ts:846](../src/api.ts#L846) Set group custom data. Network usage: no. @@ -1209,7 +1242,7 @@ Network usage: no. > **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:663](../src/api.ts#L663) +Defined in: [src/api.ts:683](../src/api.ts#L683) Set member role for group link. Network usage: no. @@ -1234,7 +1267,7 @@ Network usage: no. > **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:580](../src/api.ts#L580) +Defined in: [src/api.ts:600](../src/api.ts#L600) Set members role. Requires Admin role. Network usage: background. @@ -1263,7 +1296,7 @@ Network usage: background. > **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\> -Defined in: [src/api.ts:399](../src/api.ts#L399) +Defined in: [src/api.ts:398](../src/api.ts#L398) Add address to bot profile. Network usage: interactive. @@ -1284,11 +1317,33 @@ Network usage: interactive. *** +### apiSetUserDomain() + +> **apiSetUserDomain**(`userId`, `simplexDomain?`): `Promise`\<`User`\> + +Defined in: [src/api.ts:427](../src/api.ts#L427) + +#### Parameters + +##### userId + +`number` + +##### simplexDomain? + +`string` + +#### Returns + +`Promise`\<`User`\> + +*** + ### apiUpdateChatItem() > **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\> -Defined in: [src/api.ts:471](../src/api.ts#L471) +Defined in: [src/api.ts:481](../src/api.ts#L481) Update message. Network usage: background. @@ -1325,7 +1380,7 @@ Network usage: background. > **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:640](../src/api.ts#L640) +Defined in: [src/api.ts:660](../src/api.ts#L660) Update group profile. Network usage: background. @@ -1350,7 +1405,7 @@ Network usage: background. > **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\> -Defined in: [src/api.ts:927](../src/api.ts#L927) +Defined in: [src/api.ts:947](../src/api.ts#L947) Update user profile. Network usage: background. @@ -1375,7 +1430,7 @@ Network usage: background. > **close**(): `Promise`\<`void`\> -Defined in: [src/api.ts:158](../src/api.ts#L158) +Defined in: [src/api.ts:157](../src/api.ts#L157) Stop chat controller and close chat database. The database is not closed if stopping fails. @@ -1391,7 +1446,7 @@ Usually doesn't need to be called in chat bots. > **off**\<`K`\>(`event`, `subscriber?`): `void` -Defined in: [src/api.ts:302](../src/api.ts#L302) +Defined in: [src/api.ts:301](../src/api.ts#L301) Unsubscribe all or a specific handler from a specific event. @@ -1425,7 +1480,7 @@ An optional subscriber function for the event. > **offAny**(`receiver?`): `void` -Defined in: [src/api.ts:318](../src/api.ts#L318) +Defined in: [src/api.ts:317](../src/api.ts#L317) Unsubscribe all or a specific handler from any events. @@ -1449,7 +1504,7 @@ An optional subscriber function for the event. > **on**\<`K`\>(`subscribers`): `void` -Defined in: [src/api.ts:212](../src/api.ts#L212) +Defined in: [src/api.ts:211](../src/api.ts#L211) Subscribe multiple event handlers at once. @@ -1479,7 +1534,7 @@ If the same function is subscribed to event. > **on**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:220](../src/api.ts#L220) +Defined in: [src/api.ts:219](../src/api.ts#L219) Subscribe a handler to a specific event. @@ -1517,7 +1572,7 @@ If the same function is subscribed to event. > **onAny**(`receiver`): `void` -Defined in: [src/api.ts:243](../src/api.ts#L243) +Defined in: [src/api.ts:242](../src/api.ts#L242) Subscribe a handler to any event. @@ -1543,7 +1598,7 @@ If the same function is subscribed to event. > **once**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:254](../src/api.ts#L254) +Defined in: [src/api.ts:253](../src/api.ts#L253) Subscribe a handler to a specific event to be delivered one time. @@ -1581,7 +1636,7 @@ If the same function is subscribed to event. > **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/api.ts:353](../src/api.ts#L353) +Defined in: [src/api.ts:352](../src/api.ts#L352) #### Parameters @@ -1599,7 +1654,7 @@ Defined in: [src/api.ts:353](../src/api.ts#L353) > **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/api.ts:349](../src/api.ts#L349) +Defined in: [src/api.ts:348](../src/api.ts#L348) #### Parameters @@ -1617,7 +1672,7 @@ Defined in: [src/api.ts:349](../src/api.ts#L349) > **startChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:124](../src/api.ts#L124) +Defined in: [src/api.ts:123](../src/api.ts#L123) Start chat controller. Must be called with the existing user profile. @@ -1631,7 +1686,7 @@ Start chat controller. Must be called with the existing user profile. > **stopChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:147](../src/api.ts#L147) +Defined in: [src/api.ts:146](../src/api.ts#L146) Stop chat controller. `close` calls it before closing the database. @@ -1649,7 +1704,7 @@ Usually doesn't need to be called in chat bots. > **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:262](../src/api.ts#L262) +Defined in: [src/api.ts:261](../src/api.ts#L261) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1674,7 +1729,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:263](../src/api.ts#L263) +Defined in: [src/api.ts:262](../src/api.ts#L262) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1703,7 +1758,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `timeout`): `Promise`\ -Defined in: [src/api.ts:264](../src/api.ts#L264) +Defined in: [src/api.ts:263](../src/api.ts#L263) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1732,7 +1787,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\ -Defined in: [src/api.ts:265](../src/api.ts#L265) +Defined in: [src/api.ts:264](../src/api.ts#L264) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1767,9 +1822,9 @@ Returns `undefined` on timeout if specified. > `static` **init**(`db`, `confirm?`, `queueSize?`): `Promise`\<`ChatApi`\> -Defined in: [src/api.ts:111](../src/api.ts#L111) +Defined in: [src/api.ts:109](../src/api.ts#L109) -Initializes the ChatApi. +Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use). #### Parameters diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md index 7fe255327c..26934a9c6b 100644 --- a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md +++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md @@ -8,12 +8,10 @@ > **DbConfig** = \{ `encryptionKey?`: `string`; `filePrefix`: `string`; `type`: `"sqlite"`; \} \| \{ `connectionString`: `string`; `schemaPrefix?`: `string`; `type`: `"postgres"`; \} -Defined in: [src/api.ts:65](../src/api.ts#L65) +Defined in: [src/api.ts:63](../src/api.ts#L63) -Database configuration. The native library is built against exactly one -backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this -type makes the caller state which one they are targeting so field names -can't lie about their meaning. +Database configuration. `type` selects the libsimplex backend loaded by +`ChatApi.init`; one backend per process. ## Union Members diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md index 0af2644bc2..bc31ad01a8 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.run.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md @@ -8,7 +8,7 @@ > **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> -Defined in: [src/bot.ts:48](../src/bot.ts#L48) +Defined in: [src/bot.ts:49](../src/bot.ts#L49) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md index 8fbb699f80..0bcc49cbc6 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -8,7 +8,7 @@ > **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void` -Defined in: [src/bot.ts:108](../src/bot.ts#L108) +Defined in: [src/bot.ts:110](../src/bot.ts#L110) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md index 3f7757041b..036afe64d4 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:36](../src/bot.ts#L36) > **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md) -Defined in: [src/bot.ts:38](../src/bot.ts#L38) +Defined in: [src/bot.ts:39](../src/bot.ts#L39) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38) > `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md) -Defined in: [src/bot.ts:45](../src/bot.ts#L45) +Defined in: [src/bot.ts:46](../src/bot.ts#L46) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:45](../src/bot.ts#L45) > `optional` **onCommands?**: `object` -Defined in: [src/bot.ts:42](../src/bot.ts#L42) +Defined in: [src/bot.ts:43](../src/bot.ts#L43) #### Index Signature @@ -42,7 +42,7 @@ Defined in: [src/bot.ts:42](../src/bot.ts#L42) > `optional` **onMessage?**: (`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\> -Defined in: [src/bot.ts:40](../src/bot.ts#L40) +Defined in: [src/bot.ts:41](../src/bot.ts#L41) #### Parameters @@ -68,7 +68,7 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40) > **options**: [`BotOptions`](bot.Interface.BotOptions.md) -Defined in: [src/bot.ts:39](../src/bot.ts#L39) +Defined in: [src/bot.ts:40](../src/bot.ts#L40) *** @@ -77,3 +77,11 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39) > **profile**: `Profile` Defined in: [src/bot.ts:37](../src/bot.ts#L37) + +*** + +### simplexDomain? + +> `optional` **simplexDomain?**: `string` \| `null` + +Defined in: [src/bot.ts:38](../src/bot.ts#L38) diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md index 3953d777da..64e3e764df 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md @@ -6,7 +6,7 @@ # Class: ChatAPIError -Defined in: [src/core.ts:95](../src/core.ts#L95) +Defined in: [src/core.ts:117](../src/core.ts#L117) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:95](../src/core.ts#L95) > **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:96](../src/core.ts#L96) > **chatError**: `ChatError` \| `undefined` = `undefined` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:96](../src/core.ts#L96) > **message**: `string` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md index 649090e3ac..aefc4b9073 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md @@ -6,7 +6,7 @@ # Class: ChatInitError -Defined in: [src/core.ts:119](../src/core.ts#L119) +Defined in: [src/core.ts:141](../src/core.ts#L141) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:119](../src/core.ts#L119) > **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError` -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:120](../src/core.ts#L120) > **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md) -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:120](../src/core.ts#L120) > **message**: `string` -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md index fea7bfb531..9b6ad3c099 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md @@ -6,7 +6,7 @@ # Interface: ErrorMigration -Defined in: [src/core.ts:152](../src/core.ts#L152) +Defined in: [src/core.ts:174](../src/core.ts#L174) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:152](../src/core.ts#L152) > **dbFile**: `string` -Defined in: [src/core.ts:154](../src/core.ts#L154) +Defined in: [src/core.ts:176](../src/core.ts#L176) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:154](../src/core.ts#L154) > **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md) -Defined in: [src/core.ts:155](../src/core.ts#L155) +Defined in: [src/core.ts:177](../src/core.ts#L177) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:155](../src/core.ts#L155) > **type**: `"errorMigration"` -Defined in: [src/core.ts:153](../src/core.ts#L153) +Defined in: [src/core.ts:175](../src/core.ts#L175) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md index eb0bfb11cb..d54f59003f 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md @@ -6,7 +6,7 @@ # Interface: ErrorNotADatabase -Defined in: [src/core.ts:147](../src/core.ts#L147) +Defined in: [src/core.ts:169](../src/core.ts#L169) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:147](../src/core.ts#L147) > **dbFile**: `string` -Defined in: [src/core.ts:149](../src/core.ts#L149) +Defined in: [src/core.ts:171](../src/core.ts#L171) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:149](../src/core.ts#L149) > **type**: `"errorNotADatabase"` -Defined in: [src/core.ts:148](../src/core.ts#L148) +Defined in: [src/core.ts:170](../src/core.ts#L170) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md index ffaa364f9b..e76de932f6 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md @@ -6,7 +6,7 @@ # Interface: ErrorSQL -Defined in: [src/core.ts:158](../src/core.ts#L158) +Defined in: [src/core.ts:180](../src/core.ts#L180) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:158](../src/core.ts#L158) > **dbFile**: `string` -Defined in: [src/core.ts:160](../src/core.ts#L160) +Defined in: [src/core.ts:182](../src/core.ts#L182) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:160](../src/core.ts#L160) > **migrationSQLError**: `string` -Defined in: [src/core.ts:161](../src/core.ts#L161) +Defined in: [src/core.ts:183](../src/core.ts#L183) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:161](../src/core.ts#L161) > **type**: `"errorSQL"` -Defined in: [src/core.ts:159](../src/core.ts#L159) +Defined in: [src/core.ts:181](../src/core.ts#L181) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md index 67711030da..30157c808d 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md @@ -6,7 +6,7 @@ # Interface: InvalidConfirmation -Defined in: [src/core.ts:139](../src/core.ts#L139) +Defined in: [src/core.ts:161](../src/core.ts#L161) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:139](../src/core.ts#L139) > **type**: `"invalidConfirmation"` -Defined in: [src/core.ts:140](../src/core.ts#L140) +Defined in: [src/core.ts:162](../src/core.ts#L162) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md index 9093b5e9b1..5264e4977f 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md @@ -6,7 +6,7 @@ # Interface: InvalidQueueSize -Defined in: [src/core.ts:143](../src/core.ts#L143) +Defined in: [src/core.ts:165](../src/core.ts#L165) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:143](../src/core.ts#L143) > **type**: `"invalidQueueSize"` -Defined in: [src/core.ts:144](../src/core.ts#L144) +Defined in: [src/core.ts:166](../src/core.ts#L166) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md index b04f2f5af1..d94d5893ad 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` -Defined in: [src/core.ts:133](../src/core.ts#L133) +Defined in: [src/core.ts:155](../src/core.ts#L155) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md index 48e250c7d4..cc2dea3901 100644 --- a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md @@ -6,7 +6,7 @@ # Enumeration: MigrationConfirmation -Defined in: [src/core.ts:104](../src/core.ts#L104) +Defined in: [src/core.ts:126](../src/core.ts#L126) Migration confirmation mode @@ -16,7 +16,7 @@ Migration confirmation mode > **Console**: `"console"` -Defined in: [src/core.ts:107](../src/core.ts#L107) +Defined in: [src/core.ts:129](../src/core.ts#L129) *** @@ -24,7 +24,7 @@ Defined in: [src/core.ts:107](../src/core.ts#L107) > **Error**: `"error"` -Defined in: [src/core.ts:108](../src/core.ts#L108) +Defined in: [src/core.ts:130](../src/core.ts#L130) *** @@ -32,7 +32,7 @@ Defined in: [src/core.ts:108](../src/core.ts#L108) > **YesUp**: `"yesUp"` -Defined in: [src/core.ts:105](../src/core.ts#L105) +Defined in: [src/core.ts:127](../src/core.ts#L127) *** @@ -40,4 +40,4 @@ Defined in: [src/core.ts:105](../src/core.ts#L105) > **YesUpDown**: `"yesUpDown"` -Defined in: [src/core.ts:106](../src/core.ts#L106) +Defined in: [src/core.ts:128](../src/core.ts#L128) diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md index 4b0324b92c..69be845ed3 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md @@ -8,7 +8,7 @@ > **chatCloseStore**(`ctrl`): `Promise`\<`void`\> -Defined in: [src/core.ts:20](../src/core.ts#L20) +Defined in: [src/core.ts:42](../src/core.ts#L42) Close chat store diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md index b6786fe1cb..a2a901ddc5 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md @@ -8,7 +8,7 @@ > **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\> -Defined in: [src/core.ts:76](../src/core.ts#L76) +Defined in: [src/core.ts:98](../src/core.ts#L98) Decrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md index 9e30e55a23..8f4c7815c4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md @@ -8,7 +8,7 @@ > **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:68](../src/core.ts#L68) +Defined in: [src/core.ts:90](../src/core.ts#L90) Encrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md index dbe4912520..c6ad146aa1 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md @@ -8,7 +8,7 @@ > **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`, `queueSize?`): `Promise`\<`bigint`\> -Defined in: [src/core.ts:8](../src/core.ts#L8) +Defined in: [src/core.ts:30](../src/core.ts#L30) Initialize chat controller diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md index f6713f22d8..f9413c4d74 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md @@ -8,7 +8,7 @@ > **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> -Defined in: [src/core.ts:61](../src/core.ts#L61) +Defined in: [src/core.ts:83](../src/core.ts#L83) Read buffer from encrypted file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md index 719cf610a1..8aaa2e1590 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md @@ -8,7 +8,7 @@ > **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/core.ts:40](../src/core.ts#L40) +Defined in: [src/core.ts:62](../src/core.ts#L62) Receive chat event diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md index 4296a714cf..9f8c34be0f 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md @@ -8,7 +8,7 @@ > **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/core.ts:28](../src/core.ts#L28) +Defined in: [src/core.ts:50](../src/core.ts#L50) Send chat command as string diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md index 4ca640d8c4..bd22e9d2c6 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md @@ -8,7 +8,7 @@ > **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:53](../src/core.ts#L53) +Defined in: [src/core.ts:75](../src/core.ts#L75) Write buffer to encrypted file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md b/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md new file mode 100644 index 0000000000..a2034b953d --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md @@ -0,0 +1,24 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / loadLibrary + +# Function: loadLibrary() + +> **loadLibrary**(`backend`): `Promise`\<`void`\> + +Defined in: [src/core.ts:13](../src/core.ts#L13) + +Resolve (downloading on first use) and load libsimplex for the backend. +One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected. + +## Parameters + +### backend + +[`Backend`](core.TypeAlias.Backend.md) + +## Returns + +`Promise`\<`void`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md index 2ede6d9ffb..63b0caba85 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md @@ -6,7 +6,7 @@ # Interface: APIResult\ -Defined in: [src/core.ts:90](../src/core.ts#L90) +Defined in: [src/core.ts:112](../src/core.ts#L112) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/core.ts:90](../src/core.ts#L90) > `optional` **error?**: `ChatError` -Defined in: [src/core.ts:92](../src/core.ts#L92) +Defined in: [src/core.ts:114](../src/core.ts#L114) *** @@ -28,4 +28,4 @@ Defined in: [src/core.ts:92](../src/core.ts#L92) > `optional` **result?**: `R` -Defined in: [src/core.ts:91](../src/core.ts#L91) +Defined in: [src/core.ts:113](../src/core.ts#L113) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md index 51a55b86b6..939b3f7430 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md @@ -6,7 +6,7 @@ # Interface: CryptoArgs -Defined in: [src/core.ts:114](../src/core.ts#L114) +Defined in: [src/core.ts:136](../src/core.ts#L136) File encryption key and nonce @@ -16,7 +16,7 @@ File encryption key and nonce > **fileKey**: `string` -Defined in: [src/core.ts:115](../src/core.ts#L115) +Defined in: [src/core.ts:137](../src/core.ts#L137) *** @@ -24,4 +24,4 @@ Defined in: [src/core.ts:115](../src/core.ts#L115) > **fileNonce**: `string` -Defined in: [src/core.ts:116](../src/core.ts#L116) +Defined in: [src/core.ts:138](../src/core.ts#L138) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md index 9e1fca49b4..30a7f0b060 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md @@ -6,7 +6,7 @@ # Interface: UpMigration -Defined in: [src/core.ts:193](../src/core.ts#L193) +Defined in: [src/core.ts:215](../src/core.ts#L215) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/core.ts:193](../src/core.ts#L193) > **upName**: `string` -Defined in: [src/core.ts:194](../src/core.ts#L194) +Defined in: [src/core.ts:216](../src/core.ts#L216) *** @@ -22,4 +22,4 @@ Defined in: [src/core.ts:194](../src/core.ts#L194) > **withDown**: `boolean` -Defined in: [src/core.ts:195](../src/core.ts#L195) +Defined in: [src/core.ts:217](../src/core.ts#L217) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md index f85fb492be..aaaa506f1e 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md @@ -6,7 +6,7 @@ # Interface: MTREDifferent -Defined in: [src/core.ts:214](../src/core.ts#L214) +Defined in: [src/core.ts:236](../src/core.ts#L236) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:214](../src/core.ts#L214) > **downMigrations**: `string`[] -Defined in: [src/core.ts:216](../src/core.ts#L216) +Defined in: [src/core.ts:238](../src/core.ts#L238) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:216](../src/core.ts#L216) > **type**: `"different"` -Defined in: [src/core.ts:215](../src/core.ts#L215) +Defined in: [src/core.ts:237](../src/core.ts#L237) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md index f031ac8982..e088536be9 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md @@ -6,7 +6,7 @@ # Interface: MTRENoDown -Defined in: [src/core.ts:209](../src/core.ts#L209) +Defined in: [src/core.ts:231](../src/core.ts#L231) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:209](../src/core.ts#L209) > **dbMigrations**: `string`[] -Defined in: [src/core.ts:211](../src/core.ts#L211) +Defined in: [src/core.ts:233](../src/core.ts#L233) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:211](../src/core.ts#L211) > **type**: `"noDown"` -Defined in: [src/core.ts:210](../src/core.ts#L210) +Defined in: [src/core.ts:232](../src/core.ts#L232) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md index 59501b539a..058c64d396 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"noDown"` \| `"different"` -Defined in: [src/core.ts:203](../src/core.ts#L203) +Defined in: [src/core.ts:225](../src/core.ts#L225) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md index f6202b9456..523fe8fe1e 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md @@ -6,7 +6,7 @@ # Interface: MEDowngrade -Defined in: [src/core.ts:182](../src/core.ts#L182) +Defined in: [src/core.ts:204](../src/core.ts#L204) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:182](../src/core.ts#L182) > **downMigrations**: `string`[] -Defined in: [src/core.ts:184](../src/core.ts#L184) +Defined in: [src/core.ts:206](../src/core.ts#L206) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:184](../src/core.ts#L184) > **type**: `"downgrade"` -Defined in: [src/core.ts:183](../src/core.ts#L183) +Defined in: [src/core.ts:205](../src/core.ts#L205) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md index 7467895b74..c5856f1943 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md @@ -6,7 +6,7 @@ # Interface: MEUpgrade -Defined in: [src/core.ts:177](../src/core.ts#L177) +Defined in: [src/core.ts:199](../src/core.ts#L199) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:177](../src/core.ts#L177) > **type**: `"upgrade"` -Defined in: [src/core.ts:178](../src/core.ts#L178) +Defined in: [src/core.ts:200](../src/core.ts#L200) #### Overrides @@ -30,4 +30,4 @@ Defined in: [src/core.ts:178](../src/core.ts#L178) > **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[] -Defined in: [src/core.ts:179](../src/core.ts#L179) +Defined in: [src/core.ts:201](../src/core.ts#L201) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md index 7b2546fcd5..f3f7d9bc1f 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md @@ -6,7 +6,7 @@ # Interface: MigrationError -Defined in: [src/core.ts:187](../src/core.ts#L187) +Defined in: [src/core.ts:209](../src/core.ts#L209) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:187](../src/core.ts#L187) > **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md) -Defined in: [src/core.ts:189](../src/core.ts#L189) +Defined in: [src/core.ts:211](../src/core.ts#L211) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:189](../src/core.ts#L189) > **type**: `"migrationError"` -Defined in: [src/core.ts:188](../src/core.ts#L188) +Defined in: [src/core.ts:210](../src/core.ts#L210) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md index e2e1dcb33f..92f649e615 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"` -Defined in: [src/core.ts:171](../src/core.ts#L171) +Defined in: [src/core.ts:193](../src/core.ts#L193) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md new file mode 100644 index 0000000000..9b32c20d5b --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / Backend + +# Type Alias: Backend + +> **Backend** = `"sqlite"` \| `"postgres"` + +Defined in: [src/core.ts:5](../src/core.ts#L5) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md index 3f4797fb8e..159ce87391 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md @@ -8,4 +8,4 @@ > **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) -Defined in: [src/core.ts:125](../src/core.ts#L125) +Defined in: [src/core.ts:147](../src/core.ts#L147) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md index 66d6a092dc..1c4ceb16e9 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md @@ -8,4 +8,4 @@ > **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md) -Defined in: [src/core.ts:198](../src/core.ts#L198) +Defined in: [src/core.ts:220](../src/core.ts#L220) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md index 6c0ba77541..485311f0de 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md @@ -8,4 +8,4 @@ > **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md) -Defined in: [src/core.ts:165](../src/core.ts#L165) +Defined in: [src/core.ts:187](../src/core.ts#L187) diff --git a/packages/simplex-chat-nodejs/jest.config.js b/packages/simplex-chat-nodejs/jest.config.js index 18c5ddf0e5..ac94fe9001 100644 --- a/packages/simplex-chat-nodejs/jest.config.js +++ b/packages/simplex-chat-nodejs/jest.config.js @@ -1,6 +1,13 @@ +const path = require("path") + +// Tests load the locally built add-on instead of downloading the released one. +process.env.SIMPLEX_ADDON_PATH ??= path.join(__dirname, "build", "Release", "simplex.node") + module.exports = { preset: "ts-jest", maxWorkers: 1, + // a test with a new database runs every migration, each committed with a disk flush: seconds on slow CI runners + testTimeout: 30000, testEnvironment: "node", transform: { '^.+\\.ts$': ['ts-jest', { diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index f80289d33b..67eefdee4a 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,22 +1,22 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.6", + "version": "7.1.0-beta.6.3", "main": "dist/index.js", "types": "dist/index.d.ts", + "gypfile": false, + "bin": { + "simplex-chat": "dist/cli.js" + }, "files": [ "src", - "cpp", "dist", - "binding.gyp", "tsconfig.json", "docs", "examples" ], "scripts": { - "preinstall": "node src/download-libs.js", - "install": "node-gyp configure; node-gyp rebuild --release", "install-tools": "npm install -g node-gyp", - "configure": "node-gyp configure; mkdir libs 2> /dev/null | true", + "configure": "node-gyp configure", "build": "node-gyp rebuild && tsc && cp ./src/simplex.* ./dist", "run": "node src/index.js", "build-run": "node-gyp build && node src/index.js", @@ -24,15 +24,15 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.11.4", + "@simplex-chat/types": "^0.12.1", "extract-zip": "^2.0.1", - "fast-deep-equal": "^3.1.3", - "node-addon-api": "^8.5.0" + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@types/jest": "^30.0.0", "@types/node": "^25.0.5", "jest": "^30.2.0", + "node-addon-api": "^8.5.0", "ts-jest": "^29.4.6", "typedoc": "^0.28.15", "typedoc-plugin-markdown": "^4.9.0", diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 8d4e75b181..4ee4e4b3b9 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -57,10 +57,8 @@ interface EventSubscriber { } /** - * Database configuration. The native library is built against exactly one - * backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this - * type makes the caller state which one they are targeting so field names - * can't lie about their meaning. + * Database configuration. `type` selects the libsimplex backend loaded by + * `ChatApi.init`; one backend per process. */ export type DbConfig = | { @@ -80,6 +78,11 @@ export type DbConfig = connectionString: string } +function chatErrorEvent(err: unknown): ChatEvent { + if (err instanceof core.ChatAPIError && err.chatError) return {type: "chatError", chatError: err.chatError} + throw err +} + function dbConfigToMigrateArgs(db: DbConfig): [string, string] { switch (db.type) { case "sqlite": @@ -103,7 +106,7 @@ export class ChatApi { private constructor(protected ctrl_: bigint | undefined) {} /** - * Initializes the ChatApi. + * Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use). * @param {DbConfig} db - Database configuration (sqlite or postgres). * @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode. * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. @@ -114,6 +117,7 @@ export class ChatApi { queueSize?: number ): Promise { const [path, key] = dbConfigToMigrateArgs(db) + await core.loadLibrary(db.type) const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize) return new ChatApi(ctrl) } @@ -171,7 +175,7 @@ export class ChatApi { private async runEventsLoop(): Promise { while (this.receiveEvents) { try { - const event = await this.recvChatEvent() + const event = await this.recvChatEvent().catch(chatErrorEvent) if (!event) continue const subs = this.subscribers[event.type] if (subs) { @@ -194,12 +198,7 @@ export class ChatApi { } } } catch(err) { - const e = err as core.ChatAPIError - if ("chatError" in e) { - console.log("Chat error", e.chatError) - } else { - console.log("Invalid event", e) - } + console.log("Invalid event", err) } } } @@ -564,6 +563,16 @@ export class ChatApi { throw new ChatCommandError("error adding member", r) } + /** + * Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. + * Network usage: interactive. + */ + async apiAddMemberViaGroup(groupId: number, viaGroupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})) + if (r.type === "sentGroupInvitationViaGroup") return r.member + throw new ChatCommandError("error adding member", r) + } + /** * Join group. * Network usage: interactive. diff --git a/packages/simplex-chat-nodejs/src/cli.ts b/packages/simplex-chat-nodejs/src/cli.ts new file mode 100644 index 0000000000..8f24e5a89d --- /dev/null +++ b/packages/simplex-chat-nodejs/src/cli.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import {parseArgs} from "util" +import type {Backend} from "./core" +import {resolveAddonPath, resolveLibsDir} from "./libs" + +const USAGE = "usage: simplex-chat install [--backend sqlite|postgres]" +const EXIT_INSTALL_FAILED = 1 +const EXIT_USAGE = 2 + +export function parseInstallArgs(argv: string[]): Backend { + const {positionals, values: {backend = "sqlite"}} = parseArgs({args: argv, allowPositionals: true, options: {backend: {type: "string"}}}) + if (positionals.length !== 1 || positionals[0] !== "install") throw new Error("expected command: install") + if (backend !== "sqlite" && backend !== "postgres") throw new Error(`invalid backend: ${backend}`) + return backend +} + +export async function main(argv: string[]): Promise { + if (argv.includes("-h") || argv.includes("--help")) { + console.log(USAGE) + return 0 + } + let backend: Backend + try { + backend = parseInstallArgs(argv) + } catch (e) { + console.error(`${(e as Error).message}\n${USAGE}`) + return EXIT_USAGE + } + try { + const [dir, addon] = await Promise.all([resolveLibsDir(backend), resolveAddonPath()]) + console.log(`libsimplex installed at: ${dir}`) + console.log(`Node.js add-on installed at: ${addon}`) + return 0 + } catch (e) { + console.error(`install failed: ${(e as Error).message}`) + return EXIT_INSTALL_FAILED + } +} + +if (require.main === module) { + main(process.argv.slice(2)).then(code => { process.exitCode = code }) +} diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 49664fed71..5c246cf5c1 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -1,6 +1,28 @@ import {ChatEvent, ChatResponse, T} from "@simplex-chat/types" +import * as libs from "./libs" import * as simplex from "./simplex" +export type Backend = "sqlite" | "postgres" + +let loading: {backend: Backend, promise: Promise} | undefined + +/** + * Resolve (downloading on first use) and load libsimplex for the backend. + * One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected. + */ +export function loadLibrary(backend: Backend): Promise { + if (loading) { + if (loading.backend === backend) return loading.promise + return Promise.reject(new Error(`libsimplex already loaded with backend=${loading.backend}; cannot switch to ${backend} in the same process`)) + } + const promise = Promise.all([libs.resolveLibsDir(backend), libs.resolveAddonPath()]) + .then(([dir, addon]) => simplex.load(addon, libs.libPath(dir))) + loading = {backend, promise} + // a failed download or load can be retried + promise.catch(() => { loading = undefined }) + return promise +} + /** * Initialize chat controller * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js deleted file mode 100644 index a278ee27dc..0000000000 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ /dev/null @@ -1,266 +0,0 @@ -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const extract = require('extract-zip'); - -const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.1.0-beta.6'; -const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); -// A locally built libsimplex, copied into libs/ rather than loaded in place: -// the addon's RUNPATH is $ORIGIN/../../libs. -const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir; -const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll']; - -if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { - console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`); - process.exit(1); -} - -if (BACKEND === 'postgres' && (process.platform !== 'linux' || process.arch !== 'x64')) { - console.error(`✗ SIMPLEX_BACKEND=postgres is only supported on Linux x86_64.`); - process.exit(1); -} - -const ROOT_DIR = process.cwd(); // Root of the package being installed -const LIBS_DIR = path.join(ROOT_DIR, 'libs') -const INSTALLED_FILE = path.join(LIBS_DIR, 'installed.txt'); - -// Detect platform and architecture -function getPlatformInfo() { - const platform = process.platform; - const arch = process.arch; - - let platformName; - let archName; - - if (platform === 'linux') { - platformName = 'linux'; - } else if (platform === 'darwin') { - platformName = 'macos'; - } else if (platform === 'win32') { - platformName = 'windows'; - } else { - throw new Error(`Unsupported platform: ${platform}`); - } - - if (arch === 'x64') { - archName = 'x86_64'; - } else if (arch === 'arm64') { - archName = 'aarch64'; - } else { - throw new Error(`Unsupported architecture: ${arch}`); - } - - return { platformName, archName }; -} - -// Cleanup on libs version mismatch -function cleanLibsDirectory() { - if (fs.existsSync(LIBS_DIR)) { - console.log('Cleaning old libraries...'); - fs.rmSync(LIBS_DIR, { recursive: true, force: true }); - fs.mkdirSync(LIBS_DIR, { recursive: true }); - console.log('✓ Old libraries removed'); - } -} - -// Check if libraries are already installed with the correct version -function isAlreadyInstalled() { - if (!fs.existsSync(INSTALLED_FILE)) { - return false; - } - - try { - const installedVersion = fs.readFileSync(INSTALLED_FILE, 'utf-8').trim(); - const expectedVersion = `${RELEASE_TAG}:${BACKEND}`; - if (installedVersion === expectedVersion) { - console.log(`✓ Libraries version ${RELEASE_TAG}:${BACKEND} already installed`); - return true; - } else { - console.log(`Version mismatch: installed ${installedVersion}, need ${expectedVersion}`); - cleanLibsDirectory(); - return false; - } - } catch (err) { - console.warn(`Could not read installed.txt: ${err.message}`); - return false; - } -} - -// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild. -function installFromOverride() { - if (!fs.existsSync(LIBS_DIR_OVERRIDE)) { - throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`); - } - const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name))); - if (!lib) { - throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); - } - console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); - cleanLibsDirectory(); - copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR); - // Not a release tag: a later install without the variable sees a mismatch - // and replaces these files with the released ones. - fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8'); - console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`); -} - -async function install() { - try { - if (LIBS_DIR_OVERRIDE) { - installFromOverride(); - return; - } - - // Check if already installed - if (isAlreadyInstalled()) { - return; - } - - const { platformName, archName } = getPlatformInfo(); - const repoName = GITHUB_REPO.split('/')[1]; - const backendSuffix = BACKEND === 'postgres' ? '-postgres' : ''; - const zipFilename = `${repoName}-${platformName}-${archName}${backendSuffix}.zip`; - const ZIP_URL = `https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${zipFilename}`; - const ZIP_PATH = path.join(ROOT_DIR, zipFilename); - const TEMP_EXTRACT_DIR = path.join(ROOT_DIR, '.temp-extract'); - - console.log(`Detected: ${platformName} ${archName}`); - console.log(`Backend: ${BACKEND}`); - console.log(`Downloading: ${zipFilename}`); - - // Create libs directory - if (!fs.existsSync(LIBS_DIR)) { - fs.mkdirSync(LIBS_DIR, { recursive: true }); - } - - // Download zip with error handling - await downloadFile(ZIP_URL, ZIP_PATH); - - // Extract to temporary directory - console.log('Extracting to temporary directory...'); - if (!fs.existsSync(TEMP_EXTRACT_DIR)) { - fs.mkdirSync(TEMP_EXTRACT_DIR, { recursive: true }); - } - await extract(ZIP_PATH, { dir: TEMP_EXTRACT_DIR }); - - // Move libs folder contents to final location - console.log('Moving libraries to libs/...'); - const libsSourcePath = path.join(TEMP_EXTRACT_DIR, 'libs'); - - if (fs.existsSync(libsSourcePath)) { - // Copy all files from libs folder to LIBS_DIR - const files = fs.readdirSync(libsSourcePath); - files.forEach(file => { - const src = path.join(libsSourcePath, file); - const dest = path.join(LIBS_DIR, file); - - if (fs.statSync(src).isDirectory()) { - copyDirSync(src, dest); - } else { - fs.copyFileSync(src, dest); - } - }); - } else { - throw new Error('libs folder not found in zip archive'); - } - - // Write installed.txt with version - fs.writeFileSync(INSTALLED_FILE, `${RELEASE_TAG}:${BACKEND}`, 'utf-8'); - console.log(`✓ Wrote version ${RELEASE_TAG}:${BACKEND} to installed.txt`); - - // Cleanup - fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true }); - fs.unlinkSync(ZIP_PATH); - console.log('✓ Installation complete'); - } catch (err) { - console.error('✗ Failed:', err.message); - process.exit(1); - } -} - -// Helper function to recursively copy directories -function copyDirSync(src, dest) { - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true }); - } - const files = fs.readdirSync(src); - files.forEach(file => { - const srcFile = path.join(src, file); - const destFile = path.join(dest, file); - if (fs.statSync(srcFile).isDirectory()) { - copyDirSync(srcFile, destFile); - } else { - fs.copyFileSync(srcFile, destFile); - } - }); -} - -function downloadFile(url, dest) { - return new Promise((resolve, reject) => { - const file = fs.createWriteStream(dest); - - https.get(url, { headers: { 'User-Agent': 'Node.js' } }, (response) => { - // Handle redirects - if (response.statusCode === 302 || response.statusCode === 301) { - file.destroy(); - fs.unlink(dest, () => {}); - return downloadFile(response.headers.location, dest) - .then(resolve) - .catch(reject); - } - - // Handle 404 - if (response.statusCode === 404) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `Release artifact not found (404). Check:\n` + - ` - Repository exists: ${url.split('/releases')[0]}\n` + - ` - Release tag exists: ${RELEASE_TAG}\n` + - ` - Artifact filename is correct` - )); - return; - } - - // Handle 403 - if (response.statusCode === 403) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `Access denied (403). The repository may be private.\n` + - `Set GITHUB_TOKEN environment variable for private repos.` - )); - return; - } - - // Handle other HTTP errors - if (response.statusCode < 200 || response.statusCode >= 300) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `HTTP ${response.statusCode}: Failed to download from ${url}` - )); - return; - } - - response.pipe(file); - - file.on('finish', () => { - file.close(); - resolve(); - }); - - file.on('error', (err) => { - fs.unlink(dest, () => {}); - reject(new Error(`File write error: ${err.message}`)); - }); - }).on('error', (err) => { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error(`Download error: ${err.message}`)); - }); - }); -} - -install(); diff --git a/packages/simplex-chat-nodejs/src/libs.ts b/packages/simplex-chat-nodejs/src/libs.ts new file mode 100644 index 0000000000..013e49f5d0 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/libs.ts @@ -0,0 +1,202 @@ +import * as crypto from "crypto" +import * as fs from "fs" +import * as http from "http" +import * as https from "https" +import * as os from "os" +import * as path from "path" +import {pipeline} from "stream/promises" +import extract = require("extract-zip") +import type {Backend} from "./core" +import {ADDON_RELEASE, LIBS_RELEASE, SHA256} from "./release" + +const GITHUB_REPO = "simplex-chat/simplex-chat-libs" +const REQUEST_TIMEOUT_MS = 60_000 +export const MAX_REDIRECTS = 5 +const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = { + linux: {name: "linux", lib: "libsimplex.so"}, + darwin: {name: "macos", lib: "libsimplex.dylib"}, + win32: {name: "windows", lib: "libsimplex.dll"}, +} +const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"} +const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"] + +function unsupported(platform: string, arch: string): Error { + return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`) +} + +export function platformTag(platform: string, arch: string): string { + const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}` + if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch) + return tag +} + +function libName(platform: string = process.platform, arch: string = process.arch): string { + const lib = PLATFORMS[platform]?.lib + if (!lib) throw unsupported(platform, arch) + return lib +} + +export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string { + if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat") + if (platform === "win32") { + if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set") + return path.join(env.LOCALAPPDATA, "simplex-chat") + } + return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat") +} + +export function libsAsset(backend: Backend, tag: string): string { + const suffix = backend === "postgres" ? "-postgres" : "" + return `simplex-chat-libs-${tag}${suffix}.zip` +} + +export function addonAsset(tag: string): string { + return `simplex-chat-nodejs-${tag}.node` +} + +export function releaseUrl(release: string, asset: string): string { + return `https://github.com/${GITHUB_REPO}/releases/download/${release}/${asset}` +} + +function pinnedSha256(asset: string): string { + const sha256 = SHA256[asset] + if (!sha256) throw new Error(`no SHA-256 is pinned for ${asset}`) + return sha256 +} + +export function libPath(dir: string): string { + return path.join(dir, libName()) +} + +export async function resolveLibsDir( + backend: Backend, + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, + arch: string = process.arch +): Promise { + const lib = libName(platform, arch) + if (env.SIMPLEX_LIBS_DIR) { + const dir = path.resolve(env.SIMPLEX_LIBS_DIR) + if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`) + return dir + } + const tag = platformTag(platform, arch) + if (backend === "postgres" && tag !== "linux-x86_64") { + throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`) + } + const target = path.resolve(cacheRoot(platform, env), LIBS_RELEASE, backend) + if (!fs.existsSync(path.join(target, lib))) { + const asset = libsAsset(backend, tag) + await installLibs(releaseUrl(LIBS_RELEASE, asset), pinnedSha256(asset), target, lib) + } + return target +} + +export async function resolveAddonPath( + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, + arch: string = process.arch +): Promise { + if (env.SIMPLEX_ADDON_PATH) { + const file = path.resolve(env.SIMPLEX_ADDON_PATH) + if (!fs.existsSync(file)) throw new Error(`SIMPLEX_ADDON_PATH does not exist: ${file}`) + return file + } + if (!ADDON_RELEASE) throw new Error("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH") + const asset = addonAsset(platformTag(platform, arch)) + const target = path.resolve(cacheRoot(platform, env), ADDON_RELEASE, asset) + if (!fs.existsSync(target)) await installAddon(releaseUrl(ADDON_RELEASE, asset), pinnedSha256(asset), target) + return target +} + +export function installLibs(url: string, sha256: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { + return inTempDir(target, async tmp => { + process.stderr.write(`Downloading libsimplex from ${url} ...\n`) + const zipPath = path.join(tmp, "libs.zip") + await download(url, zipPath, timeoutMs) + await verifySha256(zipPath, sha256, url) + await extract(zipPath, {dir: tmp}) + const extracted = path.join(tmp, "libs") + if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`) + await renameInto(extracted, target, path.join(target, lib)) + }) +} + +export function installAddon(url: string, sha256: string, target: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { + return inTempDir(target, async tmp => { + process.stderr.write(`Downloading the Node.js add-on from ${url} ...\n`) + const file = path.join(tmp, path.basename(target)) + await download(url, file, timeoutMs) + await verifySha256(file, sha256, url) + await renameInto(file, target, target) + }) +} + +async function verifySha256(file: string, expected: string, url: string): Promise { + const hash = crypto.createHash("sha256") + for await (const chunk of fs.createReadStream(file)) hash.update(chunk) + const actual = hash.digest("hex") + if (actual !== expected) throw new Error(`SHA-256 of ${url} is ${actual}, expected ${expected}`) +} + +// The temp dir is next to the target, so the final rename stays on one filesystem. +async function inTempDir(target: string, install: (tmp: string) => Promise): Promise { + const parent = path.dirname(target) + await fs.promises.mkdir(parent, {recursive: true}) + const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-")) + try { + await install(tmp) + } finally { + await fs.promises.rm(tmp, {recursive: true, force: true}) + } +} + +async function renameInto(from: string, target: string, installed: string): Promise { + try { + await fs.promises.rename(from, target) + } catch (e) { + // Another process installed the same version first; its files are identical. + // Windows reports renaming onto an existing directory or a loaded file as EPERM. + const code = (e as NodeJS.ErrnoException).code + const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target) + if (!lost) throw e + if (!fs.existsSync(installed)) { + throw new Error(`another process partially populated ${target} but ${path.basename(installed)} is missing; remove it manually and retry`) + } + } +} + +function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise { + return new Promise((resolve, reject) => { + const get = url.startsWith("https:") ? https.get : http.get + // Once a response arrives, request errors are ignored: a download settles via pipeline, which + // waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows. + let res: http.IncomingMessage | undefined + const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => { + res = response + const status = response.statusCode ?? 0 + const location = response.headers.location + if (status >= 300 && status < 400 && location) { + response.resume() + if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`)) + let next: URL + try { + next = new URL(location, url) + } catch (e) { + return reject(e) + } + if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`)) + download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject) + return + } + if (status !== 200) { + response.resume() + return reject(new Error(`HTTP ${status} downloading ${url}`)) + } + pipeline(response, fs.createWriteStream(dest)).then(resolve, reject) + }) + const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err)) + req.on("timeout", () => abort(new Error(`timeout downloading ${url}`))) + req.on("error", e => { if (!res) reject(e) }) + }) +} diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts new file mode 100644 index 0000000000..b56435e210 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/release.ts @@ -0,0 +1,19 @@ +// Written by scripts/ci/packages_release.py from the simplex-chat-libs releases. + +export const LIBS_RELEASE = "core-7.1.0.11" + +export const ADDON_RELEASE: string | undefined = "nodejs-addon-308c0960c707" + +export const SHA256: {readonly [asset: string]: string} = { + "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", + "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803", + "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a", + "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", + "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", + "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", + "simplex-chat-nodejs-linux-aarch64.node": "3636a82c56c48229e0326fa03d6ceb5c16dd3afbcc1e86337783dc53a7328d67", + "simplex-chat-nodejs-linux-x86_64.node": "55f3479b03fdb57591179ed730da744f1aa362e7529df55221b60028415746e9", + "simplex-chat-nodejs-macos-aarch64.node": "a3472872fb0816f435f52ee5471088e6545ebffb95e89fbf8e1ab82275f2d56e", + "simplex-chat-nodejs-macos-x86_64.node": "290901f7f227019a8cee3acc832541c930c4926fc08110df25169810a9002f5a", + "simplex-chat-nodejs-windows-x86_64.node": "ec689373b4f0fef73253a8328f60b8e902a45ea67e756d42a1f759b44dfd7339", +} diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts index 1e0ca825a6..0187556afd 100644 --- a/packages/simplex-chat-nodejs/src/simplex.d.ts +++ b/packages/simplex-chat-nodejs/src/simplex.d.ts @@ -1,5 +1,6 @@ // These functions are defined in CPP add-on ../cpp/simplex.cc +export function load(addonPath: string, libPath: string): void export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]> export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]> export function chat_close_store(ctrl: bigint): Promise diff --git a/packages/simplex-chat-nodejs/src/simplex.js b/packages/simplex-chat-nodejs/src/simplex.js index fd26c67438..3bdfb528df 100644 --- a/packages/simplex-chat-nodejs/src/simplex.js +++ b/packages/simplex-chat-nodejs/src/simplex.js @@ -1 +1,22 @@ -module.exports = require("../build/Release/simplex") +// The add-on is downloaded from the libsimplex release, so it is loaded at runtime by core.loadLibrary. +const NOT_LOADED = "libsimplex is not loaded, call core.loadLibrary(backend) first" +const BINDINGS = ["chat_migrate_init", "chat_migrate_init_queue", "chat_close_store", "chat_send_cmd", "chat_recv_msg_wait", + "chat_write_file", "chat_read_file", "chat_encrypt_file", "chat_decrypt_file"] + +let addon + +exports.load = (addonPath, libPath) => { + if (!addon) { + const addonModule = {exports: {}} + process.dlopen(addonModule, addonPath) + addon = addonModule.exports + } + addon.load(libPath) +} + +for (const name of BINDINGS) { + exports[name] = (...args) => { + if (!addon) throw new Error(NOT_LOADED) + return addon[name](...args) + } +} diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts index b6b92bf93b..b4ae456af9 100644 --- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -5,6 +5,7 @@ import * as core from "../src/core" const user = {userId: 1} as T.User async function chatWithResponse(response: object): Promise { + jest.spyOn(core, "loadLibrary").mockResolvedValue() jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) jest.spyOn(core, "chatSendCmd").mockResolvedValue(response as ChatResponse) return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) @@ -45,6 +46,7 @@ describe("documented success responses", () => { describe("startChat lifecycle", () => { function chatWithResponses(...responses: object[]): Promise { + jest.spyOn(core, "loadLibrary").mockResolvedValue() jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) jest.spyOn(core, "chatRecvMsgWait").mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 10))) const send = jest.spyOn(core, "chatSendCmd") @@ -110,9 +112,40 @@ describe("startChat lifecycle", () => { await chat.stopChat() }) + it("passes event errors to chatError subscribers", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"}) + const chatError = {type: "errorStore", storeError: {type: "connectionNotFound", agentConnId: "Y29ubg=="}} as T.ChatError + jest.mocked(core.chatRecvMsgWait).mockRejectedValueOnce(new core.ChatAPIError("Chat event error (see chatError property)", chatError)) + const received = new Promise(resolve => chat.on("chatError", event => resolve(event))) + await chat.startChat() + await expect(received).resolves.toEqual({type: "chatError", chatError}) + await chat.stopChat() + }) + it("receives with a 500 ms wait", async () => { const chat = await chatWithResponses() await chat.recvChatEvent() expect(core.chatRecvMsgWait).toHaveBeenCalledWith(BigInt(1), 500_000) }) }) + +describe("ChatApi.init", () => { + it("loads the library for the configured backend before opening the database", async () => { + let loaded!: () => void + const load = jest.spyOn(core, "loadLibrary").mockReturnValue(new Promise(resolve => { loaded = resolve })) + const migrate = jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + const init = api.ChatApi.init({type: "postgres", connectionString: "postgres://unused"}) + await new Promise(setImmediate) + expect(load).toHaveBeenCalledWith("postgres") + expect(migrate).not.toHaveBeenCalled() + loaded() + await init + expect(migrate).toHaveBeenCalled() + }) + + it("rejects an invalid config before loading the library", async () => { + const load = jest.spyOn(core, "loadLibrary").mockResolvedValue() + await expect(api.ChatApi.init({type: "mysql"} as unknown as api.DbConfig)).rejects.toThrow('Invalid DbConfig: {"type":"mysql"}') + expect(load).not.toHaveBeenCalled() + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts index 8eb106a5b5..608c3939cd 100644 --- a/packages/simplex-chat-nodejs/tests/core.test.ts +++ b/packages/simplex-chat-nodejs/tests/core.test.ts @@ -2,8 +2,19 @@ import {execFile, spawnSync} from "child_process"; import * as fs from "fs"; import * as path from "path"; import {core} from "../src/index"; +import * as libs from "../src/libs"; +import * as simplex from "../src/simplex"; describe("Core tests", () => { + let libPath: string; + let addonPath: string; + // the first run downloads libsimplex + beforeAll(async () => { + const libsDir = await libs.resolveLibsDir("sqlite"); + libPath = libs.libPath(libsDir); + addonPath = await libs.resolveAddonPath(); + await core.loadLibrary("sqlite"); + }, 300000); const tmpDir = "./tests/tmp"; const dbPath = path.join(tmpDir, "simplex_v1"); @@ -125,9 +136,10 @@ describe("Core tests", () => { const start = Date.now(); await fs.promises.stat(tmpDir); expect(Date.now() - start).toBeLessThan(200); + // one receiver thread per controller runs the 4 receives in turn await Promise.all(receives); await stopAndClose(ctrl); - }, 10000); + }, 30000); const itOnLinux = process.platform === "linux" ? it : it.skip; @@ -157,7 +169,7 @@ describe("Core tests", () => { expect(elapsed).toBeLessThan(1800); await stopAndClose(ctrlA); await stopAndClose(ctrlB); - }, 10000); + }); it("should receive events of one controller in order", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); @@ -171,7 +183,7 @@ describe("Core tests", () => { {type: "timedAction", action: "second"} ]); await stopAndClose(ctrl); - }, 10000); + }); it("should close the store while a receive is in flight", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); @@ -189,36 +201,70 @@ describe("Core tests", () => { expect({closed, timerDelayBelow100ms: timerDelay < 100}).toEqual({closed: false, timerDelayBelow100ms: true}); await close; expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]); - }, 10000); + }); + + it("should accept loading libsimplex again from the same path", () => { + expect(() => simplex.load(addonPath, libPath)).not.toThrow(); + }); + + it("should refuse to load libsimplex from another path", () => { + expect(() => simplex.load(addonPath, path.resolve("other", "libsimplex.so"))).toThrow(`libsimplex already loaded from ${libPath}`); + }); + + it("should load libsimplex after a failed load and in several workers", () => { + const addon = path.resolve(__dirname, "..", "build", "Release", "simplex.node"); + const childDbPath = path.resolve(tmpDir, "simplex_workers"); + const script = ` + const {Worker} = require("worker_threads"); + const simplex = require(${JSON.stringify(addon)}); + try { simplex.load(${JSON.stringify(addon)}) } catch (e) { console.log(JSON.stringify(e.message.endsWith("does not export hs_init_with_rtsopts"))) } + const load = ${JSON.stringify(`require(${JSON.stringify(addon)}).load(${JSON.stringify(libPath)})`)}; + const workers = Array.from({length: 4}, () => new Worker(load, {eval: true})); + Promise.all(workers.map((w) => new Promise((resolve, reject) => w.on("exit", resolve).on("error", reject)))) + .then(async (codes) => { + simplex.load(${JSON.stringify(libPath)}); + const [ctrl] = await simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp"); + await simplex.chat_send_cmd(ctrl, "/_stop"); + console.log(JSON.stringify({codes, closed: await simplex.chat_close_store(ctrl) === ""})); + }); + `; + const child = spawnSync(process.execPath, ["-e", script], {timeout: 30000, encoding: "utf8"}); + if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr); + expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()}) + .toEqual({status: 0, signal: null, stdout: 'true\n{"codes":[0,0,0,0],"closed":true}'}); + }, 35000); it("should let the process exit while a receiver is idle", () => { const childDbPath = path.resolve(tmpDir, "simplex_child"); const script = ` const simplex = require("./build/Release/simplex.node"); + simplex.load(${JSON.stringify(libPath)}); simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp") .then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1)) .then((res) => console.log("received " + JSON.stringify(res))); `; - const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 10000, encoding: "utf8"}); + const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 25000, encoding: "utf8"}); if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr); expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()}) .toEqual({status: 0, signal: null, stdout: 'received ""'}); - }, 15000); + }); it("should not crash when closing stopped controllers repeatedly", async () => { const script = ` const fs = require("fs"), path = require("path"); const simplex = require("./build/Release/simplex.node"); + simplex.load(${JSON.stringify(libPath)}); (async () => { + // a new database runs every migration, each committed with a disk flush that takes up to 30 ms on CI runners + const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); for (let i = 0; i < 40; i++) { - const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp"); await simplex.chat_send_cmd(ctrl, "/v"); await simplex.chat_send_cmd(ctrl, "/_stop"); const res = await simplex.chat_close_store(ctrl); - fs.rmSync(dir, {recursive: true, force: true}); if (res !== "") throw new Error("close failed: " + res); } + fs.rmSync(dir, {recursive: true, force: true}); })(); `; const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => { diff --git a/packages/simplex-chat-nodejs/tests/libs.test.ts b/packages/simplex-chat-nodejs/tests/libs.test.ts new file mode 100644 index 0000000000..c8a65f6bdd --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/libs.test.ts @@ -0,0 +1,466 @@ +import * as fs from "fs" +import * as http from "http" +import * as https from "https" +import * as os from "os" +import * as path from "path" +import {AddressInfo} from "net" +import * as crypto from "crypto" +import {addonAsset, cacheRoot, installAddon, installLibs, libPath, libsAsset, MAX_REDIRECTS, platformTag, releaseUrl, resolveAddonPath, resolveLibsDir} from "../src/libs" +import * as release from "../src/release" +import {main, parseInstallArgs} from "../src/cli" +import * as libs from "../src/libs" +import {storedZip} from "./zip" + +jest.mock("../src/release", () => ({LIBS_RELEASE: "core-test", ADDON_RELEASE: "nodejs-addon-test", SHA256: {}})) + +const LIB = "libtest.so" +const ADDON = Buffer.from("addon") +const PINNED = release.SHA256 as {[asset: string]: string} + +function sha256(data: Buffer): string { + return crypto.createHash("sha256").update(data).digest("hex") +} +const LINUX_LIB = "libsimplex.so" +const {XDG_CACHE_HOME, LOCALAPPDATA, SIMPLEX_LIBS_DIR, SIMPLEX_ADDON_PATH} = process.env + +function setEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + +// A path resolution regression must neither download nor write into the real user cache. +let home: string +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "libs-home-")) + jest.spyOn(os, "homedir").mockReturnValue(home) + jest.spyOn(https, "get").mockImplementation(() => { throw new Error("unexpected download") }) + setEnv("XDG_CACHE_HOME", home) + setEnv("LOCALAPPDATA", home) + setEnv("SIMPLEX_LIBS_DIR", undefined) + setEnv("SIMPLEX_ADDON_PATH", undefined) +}) +afterEach(() => { + jest.restoreAllMocks() + setEnv("XDG_CACHE_HOME", XDG_CACHE_HOME) + setEnv("LOCALAPPDATA", LOCALAPPDATA) + setEnv("SIMPLEX_LIBS_DIR", SIMPLEX_LIBS_DIR) + setEnv("SIMPLEX_ADDON_PATH", SIMPLEX_ADDON_PATH) + fs.rmSync(home, {recursive: true, force: true}) +}) + +describe("paths", () => { + let libsDir: string + + beforeEach(() => { + libsDir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-dir-")) + fs.writeFileSync(libPath(libsDir), "lib") + }) + afterEach(() => fs.rmSync(libsDir, {recursive: true, force: true})) + + it("uses the Python cache layout", () => { + expect(cacheRoot("linux", {XDG_CACHE_HOME: "/x"}, "/h")).toBe(path.join("/x", "simplex-chat")) + expect(cacheRoot("linux", {}, "/h")).toBe(path.join("/h", ".cache", "simplex-chat")) + expect(cacheRoot("darwin", {}, "/h")).toBe(path.join("/h", "Library", "Caches", "simplex-chat")) + expect(cacheRoot("win32", {LOCALAPPDATA: "C:\\L"}, "/h")).toBe(path.join("C:\\L", "simplex-chat")) + expect(() => cacheRoot("win32", {}, "/h")).toThrow("LOCALAPPDATA is not set") + }) + + it.each([ + ["linux", "x64", "linux-x86_64"], + ["linux", "arm64", "linux-aarch64"], + ["darwin", "x64", "macos-x86_64"], + ["darwin", "arm64", "macos-aarch64"], + ["win32", "x64", "windows-x86_64"], + ])("tags %s/%s as %s", (platform, arch, tag) => { + expect(platformTag(platform, arch)).toBe(tag) + }) + + it("rejects unsupported platforms", () => { + expect(() => platformTag("win32", "arm64")).toThrow("Unsupported platform") + }) + + it("rejects an unsupported OS with its arch", async () => { + await expect(resolveLibsDir("sqlite", {}, "freebsd", "riscv64")).rejects.toThrow("Unsupported platform: freebsd/riscv64; supported: ") + }) + + it("uses the release URLs", () => { + const releases = "https://github.com/simplex-chat/simplex-chat-libs/releases/download" + expect(releaseUrl("core-7.1.0.11", libsAsset("sqlite", "macos-aarch64"))).toBe(`${releases}/core-7.1.0.11/simplex-chat-libs-macos-aarch64.zip`) + expect(releaseUrl("core-7.1.0.11", libsAsset("postgres", "linux-x86_64"))).toBe(`${releases}/core-7.1.0.11/simplex-chat-libs-linux-x86_64-postgres.zip`) + expect(releaseUrl("nodejs-addon-1", addonAsset("windows-x86_64"))).toBe(`${releases}/nodejs-addon-1/simplex-chat-nodejs-windows-x86_64.node`) + }) + + it("resolves an absolute SIMPLEX_LIBS_DIR", async () => { + await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir})).resolves.toBe(libsDir) + }) + + it("uses SIMPLEX_LIBS_DIR on an unsupported CPU", async () => { + fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib") + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm")).resolves.toBe(libsDir) + }) + + it("uses SIMPLEX_LIBS_DIR for postgres outside linux-x86_64", async () => { + fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib") + await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm64")).resolves.toBe(libsDir) + }) + + it("returns a relative SIMPLEX_LIBS_DIR as absolute", async () => { + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: path.relative(process.cwd(), libsDir)})).resolves.toBe(libsDir) + }) + + it.each([ + ["linux", "x64", "libsimplex.so"], + ["darwin", "arm64", "libsimplex.dylib"], + ["win32", "x64", "libsimplex.dll"], + ])("looks for the %s library name", async (platform, arch, lib) => { + fs.rmSync(libPath(libsDir)) + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, platform, arch)).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${lib}: `) + }) + + it("returns a relative SIMPLEX_ADDON_PATH as absolute", async () => { + const addon = path.join(libsDir, "simplex.node") + fs.writeFileSync(addon, "addon") + await expect(resolveAddonPath({SIMPLEX_ADDON_PATH: path.relative(process.cwd(), addon)})).resolves.toBe(addon) + }) + + it("rejects a missing SIMPLEX_ADDON_PATH", async () => { + const addon = path.join(libsDir, "simplex.node") + await expect(resolveAddonPath({SIMPLEX_ADDON_PATH: addon})).rejects.toThrow(`SIMPLEX_ADDON_PATH does not exist: ${addon}`) + }) + + it("rejects a SIMPLEX_LIBS_DIR without libsimplex", async () => { + fs.rmSync(libPath(libsDir)) + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir})).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${path.basename(libPath(libsDir))}: ${libsDir}`) + }) +}) + +describe("installLibs", () => { + let server: http.Server + let base: string + let tmp: string + const routes: {[p: string]: (res: http.ServerResponse) => void} = {} + + beforeAll(async () => { + server = http.createServer((req, res) => { + const route = routes[req.url ?? ""] + if (route) route(res) + else res.writeHead(404).end() + }) + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(() => new Promise(resolve => server.close(() => resolve()))) + + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "libs-test-")) }) + afterEach(() => fs.rmSync(tmp, {recursive: true, force: true})) + + const good = storedZip({[`libs/${LIB}`]: "lib", "libs/libHSdep.so": "dep"}) + const GOOD_SHA = sha256(good) + routes["/good.zip"] = res => res.writeHead(200).end(good) + routes["/redirect.zip"] = res => res.writeHead(302, {location: "/good.zip"}).end() + const nolib = storedZip({"other/file": "x"}) + routes["/nolib.zip"] = res => res.writeHead(200).end(nolib) + routes["/stall.zip"] = res => { res.writeHead(200); res.write(good.subarray(0, 10)) } + let loopRequests = 0 + routes["/loop.zip"] = res => { loopRequests++; res.writeHead(302, {location: "/loop.zip"}).end() } + routes["/to-http.zip"] = res => res.writeHead(302, {location: `${base}/good.zip`}).end() + routes["/bad-location.zip"] = res => res.writeHead(302, {location: "http://[bad"}).end() + routes["/no-location.zip"] = res => res.writeHead(302).end() + routes["/hang.zip"] = () => {} + routes["/cross-host.zip"] = res => res.writeHead(302, {location: "https://objects.libs.test/good.zip"}).end() + let downloadDirs: string[] = [] + routes["/observed.zip"] = res => { downloadDirs = leftovers(); res.writeHead(200).end(good) } + + function leftovers(): string[] { + return fs.readdirSync(tmp).filter(f => f.startsWith(".download-")) + } + + // Serves https requests from a local http route (the requested path by default), recording the requested URLs. + function mockHttps(route?: string): string[] { + const requested: string[] = [] + jest.spyOn(https, "get").mockImplementation(((url: string, options: http.RequestOptions, cb: (res: http.IncomingMessage) => void) => { + requested.push(url) + return http.get(`${base}${route ?? new URL(url).pathname}`, options, cb) + }) as unknown as typeof https.get) + return requested + } + + function renameFails(code: string): void { + jest.spyOn(fs.promises, "rename").mockRejectedValue(Object.assign(new Error(code), {code})) + } + + beforeEach(() => { jest.spyOn(console, "error").mockImplementation(() => {}) }) + + it("follows a redirect and installs libs/", async () => { + const target = path.join(tmp, "v1", "sqlite") + await installLibs(`${base}/redirect.zip`, GOOD_SHA, target, LIB) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + expect(fs.existsSync(path.join(target, "libHSdep.so"))).toBe(true) + }) + + it("downloads into a temp dir next to the target", async () => { + await installLibs(`${base}/observed.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB) + expect(downloadDirs).toHaveLength(1) + expect(leftovers()).toEqual([]) + }) + + it("stops following a redirect loop", async () => { + loopRequests = 0 + await expect(installLibs(`${base}/loop.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("too many redirects") + expect(loopRequests).toBe(MAX_REDIRECTS + 1) + expect(leftovers()).toEqual([]) + }) + + it("follows an https redirect to another host", async () => { + const requested = mockHttps() + const target = path.join(tmp, "sqlite") + await installLibs("https://libs.test/cross-host.zip", GOOD_SHA, target, LIB) + expect(requested).toEqual(["https://libs.test/cross-host.zip", "https://objects.libs.test/good.zip"]) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + }) + + it("rejects a redirect from https to http", async () => { + mockHttps("/to-http.zip") + await expect(installLibs("https://libs.test/libs.zip", GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("changes protocol") + expect(leftovers()).toEqual([]) + }) + + it("rejects an invalid redirect location", async () => { + await expect(installLibs(`${base}/bad-location.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("Invalid URL") + expect(leftovers()).toEqual([]) + }) + + it("rejects a redirect without a location", async () => { + await expect(installLibs(`${base}/no-location.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("HTTP 302") + expect(leftovers()).toEqual([]) + }) + + it("rejects 404 and cleans up", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/missing.zip`, GOOD_SHA, target, LIB)).rejects.toThrow("HTTP 404") + expect(leftovers()).toEqual([]) + expect(fs.existsSync(target)).toBe(false) + }) + + it("rejects a zip without the lib", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/nolib.zip`, sha256(nolib), target, LIB)).rejects.toThrow(`libs/${LIB} missing`) + expect(leftovers()).toEqual([]) + }) + + it("times out a request without a response", async () => { + await expect(installLibs(`${base}/hang.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB, 200)).rejects.toThrow("timeout") + expect(leftovers()).toEqual([]) + }) + + it("times out a stalled download", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/stall.zip`, GOOD_SHA, target, LIB, 200)).rejects.toThrow("timeout") + expect(leftovers()).toEqual([]) + }) + + it.each(["sqlite", "postgres"] as const)("installs %s into a relative XDG_CACHE_HOME and returns an absolute path", async backend => { + const zip = storedZip({[`libs/${LINUX_LIB}`]: "lib"}) + const asset = libsAsset(backend, "linux-x86_64") + PINNED[asset] = sha256(zip) + routes["/libsimplex.zip"] = res => res.writeHead(200).end(zip) + const requested = mockHttps("/libsimplex.zip") + const cache = path.relative(process.cwd(), path.join(tmp, "cache")) + const dir = await resolveLibsDir(backend, {XDG_CACHE_HOME: cache}, "linux", "x64") + expect(requested).toEqual([releaseUrl("core-test", asset)]) + expect(path.isAbsolute(dir)).toBe(true) + expect(dir).toBe(path.join(tmp, "cache", "simplex-chat", "core-test", backend)) + expect(fs.readFileSync(path.join(dir, LINUX_LIB), "utf8")).toBe("lib") + }) + + it("downloads the add-on into the cache", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + PINNED[addonAsset("linux-x86_64")] = sha256(ADDON) + const requested = mockHttps("/addon.node") + const addon = await resolveAddonPath({XDG_CACHE_HOME: tmp}, "linux", "x64") + const version = path.join(tmp, "simplex-chat", "nodejs-addon-test") + expect(requested).toEqual([releaseUrl("nodejs-addon-test", addonAsset("linux-x86_64"))]) + expect(addon).toBe(path.join(version, "simplex-chat-nodejs-linux-x86_64.node")) + expect(fs.readFileSync(addon, "utf8")).toBe("addon") + expect(fs.readdirSync(version)).toEqual(["simplex-chat-nodejs-linux-x86_64.node"]) + }) + + it("does not download a cached add-on", async () => { + const requested = mockHttps("/addon.node") + const cached = path.join(cacheRoot("darwin", {}), "nodejs-addon-test", "simplex-chat-nodejs-macos-aarch64.node") + fs.mkdirSync(path.dirname(cached), {recursive: true}) + fs.writeFileSync(cached, "addon") + await expect(resolveAddonPath({}, "darwin", "arm64")).resolves.toBe(cached) + expect(requested).toEqual([]) + }) + + it("rejects a download with another SHA-256 and cleans up", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/good.zip`, sha256(ADDON), target, LIB)).rejects.toThrow(`SHA-256 of ${base}/good.zip is ${GOOD_SHA}, expected ${sha256(ADDON)}`) + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("rejects an add-on with another SHA-256", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + const target = path.join(tmp, "simplex.node") + await expect(installAddon(`${base}/addon.node`, GOOD_SHA, target)).rejects.toThrow("SHA-256 of") + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("rejects an asset without a pinned SHA-256", async () => { + mockHttps("/good.zip") + await expect(resolveLibsDir("sqlite", {XDG_CACHE_HOME: tmp}, "darwin", "x64")) + .rejects.toThrow("no SHA-256 is pinned for simplex-chat-libs-macos-x86_64.zip") + }) + + it("requires SIMPLEX_ADDON_PATH without a pinned add-on release", async () => { + jest.replaceProperty(release, "ADDON_RELEASE", undefined) + await expect(resolveAddonPath({XDG_CACHE_HOME: tmp}, "linux", "x64")) + .rejects.toThrow("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH") + }) + + it("rejects a missing add-on and cleans up", async () => { + const target = path.join(tmp, "simplex.node") + await expect(installAddon(`${base}/missing.node`, sha256(ADDON), target)).rejects.toThrow("HTTP 404") + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("accepts EPERM from rename onto an installed add-on", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + const target = path.join(tmp, "simplex.node") + fs.writeFileSync(target, "addon") + renameFails("EPERM") + await expect(installAddon(`${base}/addon.node`, sha256(ADDON), target)).resolves.toBeUndefined() + expect(leftovers()).toEqual([]) + }) + + it("does not download a cached lib", async () => { + const requested = mockHttps("/good.zip") + const cached = path.join(tmp, "simplex-chat", "core-test", "postgres") + fs.mkdirSync(cached, {recursive: true}) + fs.writeFileSync(path.join(cached, LINUX_LIB), "lib") + await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, "linux", "x64")).resolves.toBe(cached) + expect(requested).toEqual([]) + }) + + it("rejects a target populated without the lib", async () => { + const target = path.join(tmp, "sqlite") + fs.mkdirSync(target) + fs.writeFileSync(path.join(target, "other"), "x") + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB)).rejects.toThrow(`another process partially populated ${target}`) + expect(leftovers()).toEqual([]) + }) + + it.each([["linux", "arm64"], ["darwin", "arm64"]])("rejects postgres on %s/%s without downloading", async (platform, arch) => { + const requested = mockHttps("/good.zip") + await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, platform, arch)).rejects.toThrow("postgres backend is only supported on linux-x86_64") + expect(requested).toEqual([]) + }) + + it.each(["EPERM", "EEXIST"])("accepts %s from rename onto an installed target", async code => { + const target = path.join(tmp, "sqlite") + fs.mkdirSync(target) + fs.writeFileSync(path.join(target, LIB), "lib") + renameFails(code) + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB)).resolves.toBeUndefined() + expect(leftovers()).toEqual([]) + }) + + it("rethrows EPERM from rename when the target does not exist", async () => { + renameFails("EPERM") + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("EPERM") + expect(leftovers()).toEqual([]) + }) + + it("lets concurrent installs share one target", async () => { + const target = path.join(tmp, "sqlite") + await Promise.all([ + installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB), + installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB), + ]) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + expect(leftovers()).toEqual([]) + }) +}) + +describe("cli", () => { + function installWith(libsDir: string, addon: string): Promise { + setEnv("SIMPLEX_LIBS_DIR", libsDir) + setEnv("SIMPLEX_ADDON_PATH", addon) + return main(["install"]) + } + + it("parses install arguments", () => { + expect(parseInstallArgs(["install"])).toBe("sqlite") + expect(parseInstallArgs(["install", "--backend", "postgres"])).toBe("postgres") + expect(parseInstallArgs(["install", "--backend=postgres"])).toBe("postgres") + expect(() => parseInstallArgs(["install", "--backend", "mysql"])).toThrow("invalid backend: mysql") + expect(() => parseInstallArgs(["install", "--backend"])).toThrow("argument missing") + expect(() => parseInstallArgs(["install", "--force"])).toThrow("Unknown option '--force'") + expect(() => parseInstallArgs(["run"])).toThrow("expected command: install") + expect(() => parseInstallArgs(["install", "extra"])).toThrow("expected command: install") + expect(() => parseInstallArgs([])).toThrow("expected command: install") + }) + + it("prints the libs directory and the add-on", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + const addon = path.join(dir, "simplex.node") + fs.writeFileSync(libPath(dir), "lib") + fs.writeFileSync(addon, "addon") + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + try { + await expect(installWith(dir, addon)).resolves.toBe(0) + expect(log.mock.calls).toEqual([[`libsimplex installed at: ${dir}`], [`Node.js add-on installed at: ${addon}`]]) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("returns 1 without the add-on", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + fs.writeFileSync(libPath(dir), "lib") + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(installWith(dir, path.join(dir, "simplex.node"))).resolves.toBe(1) + expect(log).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_ADDON_PATH does not exist")) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("returns 1 when install fails", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + const addon = path.join(dir, "simplex.node") + fs.writeFileSync(addon, "addon") + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(installWith(dir, addon)).resolves.toBe(1) + expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_LIBS_DIR has no")) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("installs the requested backend", async () => { + const resolve = jest.spyOn(libs, "resolveLibsDir").mockResolvedValue("/libs") + jest.spyOn(libs, "resolveAddonPath").mockResolvedValue("/addon.node") + jest.spyOn(console, "log").mockImplementation(() => {}) + await expect(main(["install", "--backend", "postgres"])).resolves.toBe(0) + expect(resolve).toHaveBeenCalledWith("postgres") + }) + + it.each(["--help", "-h"])("prints usage on %s", async flag => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + await expect(main(["install", flag])).resolves.toBe(0) + expect(log).toHaveBeenCalledWith("usage: simplex-chat install [--backend sqlite|postgres]") + }) + + it("returns 2 with usage on invalid arguments", async () => { + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + await expect(main(["install", "--backend", "mysql"])).resolves.toBe(2) + expect(error).toHaveBeenCalledWith("invalid backend: mysql\nusage: simplex-chat install [--backend sqlite|postgres]") + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/loader.test.ts b/packages/simplex-chat-nodejs/tests/loader.test.ts new file mode 100644 index 0000000000..6a03fe8feb --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/loader.test.ts @@ -0,0 +1,107 @@ +import {spawnSync} from "child_process" +import * as path from "path" + +jest.mock("../src/simplex", () => ({load: jest.fn()})) +jest.mock("../src/libs", () => ({libPath: jest.requireActual("../src/libs").libPath, resolveLibsDir: jest.fn(), resolveAddonPath: jest.fn()})) + +const PACKAGE_DIR = path.join(__dirname, "..") +const ADDON = path.join(PACKAGE_DIR, "build", "Release", "simplex.node") +const CHILD_TIMEOUT_MS = 10000 +const LIBS_DIR = path.resolve("libs") +const SWITCH_ERROR = "libsimplex already loaded with backend=sqlite; cannot switch to postgres in the same process" +const ADDON_PATH = path.resolve("simplex.node") +const {libPath} = jest.requireActual("../src/libs") + +function runAddon(script: string): string { + const r = spawnSync(process.execPath, ["-e", `const simplex = require(${JSON.stringify(ADDON)});\n${script}`], {encoding: "utf8", timeout: CHILD_TIMEOUT_MS}) + return `${r.stdout}${r.stderr}status=${r.status} signal=${r.signal}` +} + +function freshCore(): {core: typeof import("../src/core"), resolveLibsDir: jest.Mock, load: jest.Mock} { + let modules!: ReturnType + jest.isolateModules(() => { + const libs = require("../src/libs") + libs.resolveAddonPath.mockResolvedValue(ADDON_PATH) + modules = { + core: require("../src/core"), + resolveLibsDir: libs.resolveLibsDir, + load: require("../src/simplex").load, + } + }) + return modules +} + +describe("native load", () => { + it("requires load before every FFI call", () => { + const out = runAddon(` + for (const name of Object.keys(simplex).filter(n => n !== "load").sort()) { + try { simplex[name](); console.log(name + ": no error") } catch (e) { console.log(name + ": " + e.message) } + }`) + const bindings = ["chat_close_store", "chat_decrypt_file", "chat_encrypt_file", "chat_migrate_init", "chat_migrate_init_queue", + "chat_read_file", "chat_recv_msg_wait", "chat_send_cmd", "chat_write_file"] + const expected = bindings.map(name => `${name}: libsimplex is not loaded, call core.loadLibrary(backend) first\n`).join("") + expect(out).toBe(`${expected}status=0 signal=null`) + }) + + it("reports the path of a library that cannot be opened", () => { + const out = runAddon(`try { simplex.load("/nonexistent/libsimplex.so") } catch (e) { console.log(e.message) }`) + expect(out).toContain("cannot load /nonexistent/libsimplex.so") + }) + + it("rejects a non-string library path", () => { + const out = runAddon(`try { simplex.load(1) } catch (e) { console.log(e.name + ": " + e.message) }`) + expect(out).toContain("TypeError: Expected string (libPath)") + }) + + it("rejects a library without the chat exports", () => { + const out = runAddon(`try { simplex.load(${JSON.stringify(ADDON)}) } catch (e) { console.log(e.message) }`) + expect(out).toContain(`${ADDON} does not export hs_init_with_rtsopts`) + }) +}) + +describe("add-on module", () => { + it("rejects FFI calls before the add-on is loaded", () => { + const simplex = jest.requireActual("../src/simplex") + expect(() => simplex.chat_send_cmd(BigInt(1), "/v")).toThrow("libsimplex is not loaded, call core.loadLibrary(backend) first") + }) +}) + +describe("loadLibrary", () => { + it("shares one load between concurrent calls", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + const first = core.loadLibrary("sqlite") + const second = core.loadLibrary("sqlite") + expect(second).toBe(first) + await Promise.all([first, second]) + expect(resolveLibsDir).toHaveBeenCalledTimes(1) + expect(load.mock.calls).toEqual([[ADDON_PATH, libPath(LIBS_DIR)]]) + }) + + it("refuses to switch backend while the first load is in progress", async () => { + const {core, resolveLibsDir} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + const first = core.loadLibrary("sqlite") + await expect(core.loadLibrary("postgres")).rejects.toThrow(SWITCH_ERROR) + await first + }) + + it("keeps a completed load for the process", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + await core.loadLibrary("sqlite") + await core.loadLibrary("sqlite") + await expect(core.loadLibrary("postgres")).rejects.toThrow(SWITCH_ERROR) + expect(resolveLibsDir).toHaveBeenCalledTimes(1) + expect(load).toHaveBeenCalledTimes(1) + }) + + it("retries after a failed load", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockRejectedValueOnce(new Error("HTTP 503")).mockResolvedValue(LIBS_DIR) + await expect(core.loadLibrary("sqlite")).rejects.toThrow("HTTP 503") + await expect(core.loadLibrary("postgres")).resolves.toBeUndefined() + expect(resolveLibsDir.mock.calls).toEqual([["sqlite"], ["postgres"]]) + expect(load).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/zip.ts b/packages/simplex-chat-nodejs/tests/zip.ts new file mode 100644 index 0000000000..547c8c211c --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/zip.ts @@ -0,0 +1,47 @@ +import * as zlib from "zlib" + +const LOCAL_FILE_HEADER = 0x04034b50 +const CENTRAL_DIRECTORY_HEADER = 0x02014b50 +const END_OF_CENTRAL_DIRECTORY = 0x06054b50 +const ZIP_VERSION = 20 +const LOCAL_HEADER_SIZE = 30 +const CENTRAL_ENTRY_SIZE = 46 +const END_RECORD_SIZE = 22 + +export function storedZip(files: {[name: string]: string}): Buffer { + const local: Buffer[] = [] + const central: Buffer[] = [] + let offset = 0 + for (const [name, content] of Object.entries(files)) { + const nameBuf = Buffer.from(name) + const data = Buffer.from(content) + const crc = zlib.crc32(data) + const header = Buffer.alloc(LOCAL_HEADER_SIZE) + header.writeUInt32LE(LOCAL_FILE_HEADER, 0) + header.writeUInt16LE(ZIP_VERSION, 4) + header.writeUInt32LE(crc, 14) + header.writeUInt32LE(data.length, 18) + header.writeUInt32LE(data.length, 22) + header.writeUInt16LE(nameBuf.length, 26) + local.push(header, nameBuf, data) + const entry = Buffer.alloc(CENTRAL_ENTRY_SIZE) + entry.writeUInt32LE(CENTRAL_DIRECTORY_HEADER, 0) + entry.writeUInt16LE(ZIP_VERSION, 4) + entry.writeUInt16LE(ZIP_VERSION, 6) + entry.writeUInt32LE(crc, 16) + entry.writeUInt32LE(data.length, 20) + entry.writeUInt32LE(data.length, 24) + entry.writeUInt16LE(nameBuf.length, 28) + entry.writeUInt32LE(offset, 42) + central.push(entry, nameBuf) + offset += header.length + nameBuf.length + data.length + } + const centralBuf = Buffer.concat(central) + const end = Buffer.alloc(END_RECORD_SIZE) + end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY, 0) + end.writeUInt16LE(Object.keys(files).length, 8) + end.writeUInt16LE(Object.keys(files).length, 10) + end.writeUInt32LE(centralBuf.length, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...local, centralBuf, end]) +} diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 009ff9a1e0..009ec685f4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -7,6 +7,7 @@ from __future__ import annotations import ctypes import errno +import hashlib import os import platform import sys @@ -18,7 +19,7 @@ from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p from pathlib import Path from typing import Any, Literal -from ._version import LIBS_VERSION +from ._version import LIBS_RELEASE, LIBS_SHA256 Backend = Literal["sqlite", "postgres"] @@ -59,11 +60,14 @@ def _libname() -> str: return _LIBNAME[sys.platform] -def _libs_url(backend: Backend) -> str: +def _libs_asset(backend: Backend) -> str: suffix = "-postgres" if backend == "postgres" else "" + return f"simplex-chat-libs-{_platform_tag()}{suffix}.zip" + + +def _libs_url(backend: Backend) -> str: return ( - f"https://github.com/{_GITHUB_REPO}/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip" + f"https://github.com/{_GITHUB_REPO}/releases/download/{LIBS_RELEASE}/{_libs_asset(backend)}" ) @@ -84,7 +88,7 @@ def _resolve_libs_dir(backend: Backend) -> Path: "postgres backend is only supported on linux-x86_64; " f"current platform is {_platform_tag()}" ) - target = _cache_root() / f"v{LIBS_VERSION}" / backend + target = _cache_root() / LIBS_RELEASE / backend if not (target / _libname()).exists(): _download(target, backend) return target @@ -125,16 +129,21 @@ def _download(target: Path, backend: Backend) -> None: what we need and proceed. NEVER rmtree the target: that creates a TOCTOU window where another process is reading/loading the file we're deleting. """ + asset = _libs_asset(backend) + sha256 = LIBS_SHA256.get(asset) + if sha256 is None: + raise RuntimeError(f"no SHA-256 is pinned for {asset}") target.parent.mkdir(parents=True, exist_ok=True) url = _libs_url(backend) print( - f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...", + f"Downloading libsimplex ({_platform_tag()}, {backend}) {LIBS_RELEASE} from {url} ...", file=sys.stderr, flush=True, ) with tempfile.TemporaryDirectory(dir=target.parent) as tmp: zip_path = Path(tmp) / "libs.zip" _stream_to_file(url, zip_path, timeout=60.0) + _verify_sha256(zip_path, sha256, url) with zipfile.ZipFile(zip_path) as zf: zf.extractall(tmp) # zip layout: /libs/libsimplex.* + libHS*.* @@ -158,6 +167,16 @@ def _download(target: Path, backend: Backend) -> None: ) from e +def _verify_sha256(file: Path, expected: str, url: str) -> None: + digest = hashlib.sha256() + with file.open("rb") as f: + while chunk := f.read(_DOWNLOAD_CHUNK): + digest.update(chunk) + actual = digest.hexdigest() + if actual != expected: + raise RuntimeError(f"SHA-256 of {url} is {actual}, expected {expected}") + + _lock = threading.Lock() _lib: ctypes.CDLL | None = None _libc: ctypes.CDLL | None = None @@ -230,8 +249,8 @@ def lib_for(backend: Backend) -> ctypes.CDLL: if _lib is not None: if _backend != backend: raise RuntimeError( - f"libsimplex already loaded with backend={_backend!r}; " - f"cannot switch to {backend!r} in the same process" + f"libsimplex already loaded with backend={_backend}; " + f"cannot switch to {backend} in the same process" ) return _lib libs_dir = _resolve_libs_dir(backend) diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index c6c24a1e59..0786a81795 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -1,9 +1,16 @@ -"""Single source of truth for both the Python package version and the -simplex-chat-libs release tag we depend on. +"""Package version and the simplex-chat-libs release it loads. -Bump both together for normal releases. For wrapper-only fixes use a PEP 440 -post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. +Written by scripts/ci/packages_release.py: the release PR bumps the version +and pins new library releases with the SHA-256 of their assets. """ __version__ = "7.1.0b6" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.6" # simplex-chat-libs release tag (no 'v' prefix) +LIBS_RELEASE = "core-7.1.0.11" # simplex-chat-libs release tag +LIBS_SHA256 = { + "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", + "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803", + "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a", + "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", + "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", + "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", +} diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index 876230ef39..62dec42292 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -351,6 +351,23 @@ class ChatApi: return r["member"] raise ChatCommandError("error adding member", r) + async def api_add_member_via_group( + self, group_id: int, via_group_id: int, group_member_id: int, member_role: T.GroupMemberRole + ) -> T.GroupMember: + r = await self.send_chat_cmd( + CC.APIAddMemberViaGroup_cmd_string( + { + "groupId": group_id, + "viaGroupId": via_group_id, + "groupMemberId": group_member_id, + "memberRole": member_role, + } + ) + ) + if r["type"] == "sentGroupInvitationViaGroup": + return r["member"] + raise ChatCommandError("error adding member", r) + async def api_join_group(self, group_id: int) -> T.GroupInfo: r = await self.send_chat_cmd(CC.APIJoinGroup_cmd_string({"groupId": group_id})) if r["type"] == "userAcceptedGroupSent": diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 365eff7e10..9dc3a05e8d 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -235,6 +235,21 @@ def APIAddMember_cmd_string(self: APIAddMember) -> str: APIAddMember_Response = CR.SentGroupInvitation | CR.ChatCmdError +# Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +# Network usage: interactive. +class APIAddMemberViaGroup(TypedDict): + groupId: int # int64 + viaGroupId: int # int64 + groupMemberId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIAddMemberViaGroup_cmd_string(self: APIAddMemberViaGroup) -> str: + return '/_add #' + str(self['groupId']) + ' #' + str(self['viaGroupId']) + ' ' + str(self['groupMemberId']) + ' ' + str(self['memberRole']) + +APIAddMemberViaGroup_Response = CR.SentGroupInvitationViaGroup | CR.ChatCmdError + + # Join group. # Network usage: interactive. class APIJoinGroup(TypedDict): diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_events.py b/packages/simplex-chat-python/src/simplex_chat/types/_events.py index bd73637ef8..13f6d5cb6e 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_events.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_events.py @@ -84,6 +84,15 @@ class ReceivedGroupInvitation(TypedDict): fromMemberRole: "T.GroupMemberRole" memberRole: "T.GroupMemberRole" +class ReceivedGroupInvitationViaGroup(TypedDict): + type: Literal["receivedGroupInvitationViaGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + viaGroupInfo: "T.GroupInfo" + viaMember: "T.GroupMember" + fromMemberRole: "T.GroupMemberRole" + memberRole: "T.GroupMemberRole" + class UserJoinedGroup(TypedDict): type: Literal["userJoinedGroup"] user: "T.User" @@ -352,6 +361,7 @@ ChatEvent = ( | GroupChatItemsDeleted | ChatItemsStatusesUpdated | ReceivedGroupInvitation + | ReceivedGroupInvitationViaGroup | UserJoinedGroup | GroupUpdated | JoinedGroupMember @@ -394,7 +404,7 @@ ChatEvent = ( | ChatErrors ) -ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] +ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "receivedGroupInvitationViaGroup", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] class OnEventDecorator(Protocol): @@ -483,6 +493,12 @@ class OnEventDecorator(Protocol): Callable[["ReceivedGroupInvitation"], Awaitable[None]], ]: ... + @overload + def __call__(self, event: Literal["receivedGroupInvitationViaGroup"], /) -> Callable[ + [Callable[["ReceivedGroupInvitationViaGroup"], Awaitable[None]]], + Callable[["ReceivedGroupInvitationViaGroup"], Awaitable[None]], + ]: ... + @overload def __call__(self, event: Literal["userJoinedGroup"], /) -> Callable[ [Callable[["UserJoinedGroup"], Awaitable[None]]], diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py index e3885fc16a..07c7c04f49 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -264,6 +264,14 @@ class SentGroupInvitation(TypedDict): contact: "T.Contact" member: "T.GroupMember" +class SentGroupInvitationViaGroup(TypedDict): + type: Literal["sentGroupInvitationViaGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + viaGroupInfo: "T.GroupInfo" + viaMember: "T.GroupMember" + class SentInvitation(TypedDict): type: Literal["sentInvitation"] user: "T.User" @@ -402,6 +410,7 @@ ChatResponse = ( | RemoteCtrlConnecting | SentConfirmation | SentGroupInvitation + | SentGroupInvitationViaGroup | SentInvitation | SentInvitationToContact | ServiceReplyAccepted @@ -420,4 +429,4 @@ ChatResponse = ( | ApiChats ) -ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentGroupInvitationViaGroup", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index 6c4f2a923d..ec10879077 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -1,10 +1,12 @@ +import hashlib import zipfile from pathlib import Path import pytest -from simplex_chat._native import _cache_root, _download, _resolve_libs_dir -from simplex_chat._version import LIBS_VERSION +from simplex_chat import _native +from simplex_chat._native import _cache_root, _download, _resolve_libs_dir, lib_for +from simplex_chat._version import LIBS_RELEASE def test_cache_root_linux(tmp_path, monkeypatch): @@ -42,7 +44,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): monkeypatch.setattr("simplex_chat._native._download", fake_download) libs_dir = _resolve_libs_dir("sqlite") - assert libs_dir == tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite" + assert libs_dir == tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite" assert called["backend"] == "sqlite" assert (libs_dir / "libsimplex.so").exists() @@ -50,7 +52,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) monkeypatch.setattr("sys.platform", "linux") - cached = tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite" + cached = tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite" cached.mkdir(parents=True) (cached / "libsimplex.so").touch() # Should NOT call _download — use the cached file. @@ -67,9 +69,8 @@ def test_postgres_on_macos_rejected(monkeypatch): _resolve_libs_dir("postgres") -def test_atomic_install(tmp_path, monkeypatch): - """Build a fake libs zip, mock _stream_to_file, verify extraction + atomic rename.""" - # Build zip: libs/libsimplex.so + libs/libHS-stub.so +def fake_libs_zip(tmp_path, monkeypatch, sha256=None) -> None: + """Serve a zip with libs/libsimplex.so + libs/libHS-stub.so, pinned with `sha256` (its own by default).""" src = tmp_path / "src" / "libs" src.mkdir(parents=True) (src / "libsimplex.so").write_text("fake-so") @@ -84,20 +85,54 @@ def test_atomic_install(tmp_path, monkeypatch): shutil.copy(zip_path, dest) + pinned = sha256 or hashlib.sha256(zip_path.read_bytes()).hexdigest() monkeypatch.setattr("simplex_chat._native._stream_to_file", fake_stream) monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + monkeypatch.setattr( + "simplex_chat._native.LIBS_SHA256", {"simplex-chat-libs-linux-x86_64.zip": pinned} + ) + +def test_atomic_install(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch) target = tmp_path / "out" _download(target, "sqlite") assert (target / "libsimplex.so").read_text() == "fake-so" assert (target / "libHS-stub.so").read_text() == "fake-hs" +def test_download_rejects_another_sha256(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch, sha256="0" * 64) + target = tmp_path / "out" + with pytest.raises(RuntimeError, match=f"expected {'0' * 64}"): + _download(target, "sqlite") + assert sorted(p.name for p in tmp_path.iterdir()) == ["fake-libs.zip", "src"] + + +def test_download_requires_pinned_sha256(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch) + with pytest.raises( + RuntimeError, match="no SHA-256 is pinned for simplex-chat-libs-linux-x86_64-postgres.zip" + ): + _download(tmp_path / "out", "postgres") + + def test_libc_on_windows_is_ucrt(monkeypatch): loaded: list[str | None] = [] monkeypatch.setattr("sys.platform", "win32") monkeypatch.setattr("ctypes.CDLL", lambda name: loaded.append(name)) - from simplex_chat import _native - _native._load_libc() assert loaded == ["ucrtbase"] + + +def test_lib_for_rejects_backend_switch(monkeypatch): + monkeypatch.setattr(_native, "_lib", object()) + monkeypatch.setattr(_native, "_backend", "sqlite") + monkeypatch.setattr( + _native, "_resolve_libs_dir", lambda _: pytest.fail("must not resolve libs") + ) + with pytest.raises(RuntimeError) as exc: + lib_for("postgres") + assert str(exc.value) == ( + "libsimplex already loaded with backend=sqlite; cannot switch to postgres in the same process" + ) diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py index 12270c9db1..f1d60d5170 100644 --- a/packages/simplex-chat-python/tests/test_native_url.py +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest from simplex_chat._native import _libname, _libs_url, _platform_tag -from simplex_chat._version import LIBS_VERSION +from simplex_chat._version import LIBS_RELEASE @patch("sys.platform", "linux") @@ -45,7 +45,7 @@ def test_url_sqlite(_): assert ( _libs_url("sqlite") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64.zip" + f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64.zip" ) @@ -54,5 +54,5 @@ def test_url_postgres(_): assert ( _libs_url("postgres") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64-postgres.zip" + f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64-postgres.zip" ) diff --git a/plans/2026-09-04-badge-binding-and-file-limits.md b/plans/2026-09-04-badge-binding-and-file-limits.md index 72890d8ac7..29d6905b85 100644 --- a/plans/2026-09-04-badge-binding-and-file-limits.md +++ b/plans/2026-09-04-badge-binding-and-file-limits.md @@ -11,14 +11,14 @@ The same badge raises the size limit for files the user sends. Today each app de The changes: -1. Three new presentation headers: one for a profile shown in a chat, one for a file invitation, one for a file description. The random header stays valid where there is no chat yet, and for the profile in direct chats until the direct binding reaches the handshake. -2. In p2p groups a badge is accepted only from a message signed by the member. The member connection handshake is signed in both directions, and the profile sent in it is stored, so the badge appears when two members connect. -3. In channels a badge is accepted from any profile message, because member keys there come from the roster, the member list signed by the channel owner. +1. Five new presentation headers: a profile shown in a chat, a file invitation, a file description, a contact request, and a link. The random header of released clients stays accepted until v7.2. +2. Every profile badge is bound to the chat it is shown in: a direct chat, a contact request, a link, or a group membership (section 14). A proof bound to another chat is ignored. +3. In p2p groups a badge is accepted from a message signed by the member, from a join request under the request header, and from an introduction under the key in the introduction. In channels it is accepted from a message signed by the member and from the relay's introduction under the key stored for the member: the key from the owner-signed roster for roles from member up, the key in the introduction for lower roles. 4. Files above the default limit include a proof in the invitation and a proof in the description, in every chat type. The core library verifies both. The decision is stored on the file and shown by the apps from one field. 5. Forwarding a file above the forwarder's limit is refused with an alert before the forwarding sheet opens, and again, for the chosen destination, before anything is uploaded. 6. A received file keeps its two proofs, so a file re-sent to a new member as part of history keeps them; the sender's own files get fresh proofs from the credential. -Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. One new function in simplexmq, the hash of the fields shared by all descriptions of one upload. +Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. A new table `group_member_badge_proofs` holding the proof a member presented in a group, forwarded in introductions. A new column on `connections`, the request header of a prepared connection. The relay invitation includes the channel's public group id and the owner's member key. In simplexmq: the hash of the fields shared by all descriptions of one upload, the verification codes of a connection, links and invitations before link data is signed, and the minimum SMP version raised to 15. ## Terms @@ -46,7 +46,7 @@ Two new columns on `files`, and a new table `file_badge_proofs` holding the invi **Presentation header.** A byte string that is an input to proof generation and to proof verification. A proof verifies only with the header it was generated with. Type `ProofPresHeader` in `Badges.hs`. `PHTest` is a random nonce. -**Chat binding.** The byte string that identifies the sender in one conversation, produced by `encodeChatBinding` (`Protocol.hs:444`). Message signatures and shared contact cards are computed over it. For a direct chat it is `encodeChatBinding CBDirect adHash`, where `adHash` is the hash of the connection's ratchet data, which both sides obtain with `getConnectionRatchetAdHash`. For a p2p group it is `encodeChatBinding CBGroup (smpEncode (memberId, memberKey))`. For a channel it is `encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))`. `groupBindingData` (`Internal.hs:2261`) computes the inner part for groups. +**Chat binding.** The byte string that identifies the sender in one conversation, produced by `encodeChatBinding` (`Protocol.hs:444`). Message signatures and shared contact cards are computed over it. For a direct chat it is `encodeChatBinding CBDirect codeAD`, where `codeAD` is the hash of the connection's ratchet data, which both sides obtain with `getConnectionVerifyCodes`. For a p2p group it is `encodeChatBinding CBGroup (smpEncode (memberId, memberKey))`. For a channel it is `encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))`. `groupBindingData` (`Internal.hs:2322`) computes the inner part for groups. **Member key.** The Ed25519 key a member holds for one group. It is created when first needed — at group creation on this branch, or by `createUserMemberKey` before the first signed message — and the public key is sent in introductions and in `XInfo`. @@ -64,10 +64,14 @@ data ProofPresHeader | PHChat ByteString | PHFileInv ByteString String Integer | PHFileDescr ByteString String Integer ByteString (Maybe UTCTime) + | PHRequest ByteString + | PHLink ByteString | PHUnknown Char ByteString ``` - `PHChat` holds the chat binding. +- `PHRequest` holds the request code (section 14.1). +- `PHLink` holds the link key. - `PHFileInv` holds the chat binding and the file size from the invitation. - `PHFileDescr` holds the same two values, then the shared description hash (section 8) and the file expiration. @@ -75,57 +79,63 @@ The file name is not part of either header: `validateFileInvitation` replaces it One constructor serves every chat type, because the chat binding already encodes the type of chat in its first byte. Each constructor gets a tag character in `ProofPresHeaderTag` and an encoding in the `StrEncoding` instance, in the same style as `PHTest`. The file expiration is optional, because a server may grant none; it is encoded as `strEncode` of the time, or one fixed byte when absent. The badge's own expiry is a time and is encoded with `strEncode` in the disclosed messages (`badgeInfoMessages`, `Badges.hs:296`). -`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed. What the receiver knows is already held by existing types, so no new type is added: +`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed; only BBS verification is run by `verifyBadge`, and the header rule is applied by its callers: -- A contact request, link data, and the profile in a direct chat: the header must be `PHTest`. -- A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect adHash`. -- A profile or a file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and a key that passes the key check. +- A contact request, link data, and the profile in a direct chat: the header of section 14. +- A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect codeAD`. +- A file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and the member key. +- A profile in a group: for a channel the group's public id, the member id and the member key; for a p2p group the member id and the member key. -**The key check.** A p2p binding contains the sender's member key. The receiver may know that member's key from the introduction or from a signed message, or may not know it yet. If the receiver knows a key and it differs from the key in the header, the proof fails. Otherwise the key in the header is used for this verification and never stored; keys are stored only by the introduction and by `storeMemberKey`. +**The key in a p2p binding.** For a file the receiver may not know the sender's key yet; if it knows one and it differs from the key in the header, the proof fails, otherwise the key in the header is used for this verification and never stored (`proofMemberKey`). For a profile the key is the one the message signature was verified under, section 3; a header key is never used. The file headers are checked the same way and then further: `PHFileInv` must also name the file size as received; `PHFileDescr` must also hold the hash of the received description and the expiration received with it. -`PHUnknown` fails every check. A proof from a released client, which presents `PHTest` in groups, fails in groups; no badge has been issued yet, so nothing in use is affected. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. +`PHUnknown` fails every check. `PHTest`, presented by released clients, is accepted everywhere; its acceptance is dropped in v7.2. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. -`groupBindingData` moves from `Internal.hs` to `Protocol.hs`, beside `encodeChatBinding`, because the store modules import `Protocol` and not `Internal`. Module order fixes where the check is computed: `Badges.hs` is imported by `Types.hs`, which `Protocol.hs` imports, so the header check in `Badges.hs` takes plain values — the expected binding for a channel or a direct chat, or the member id and the stored key for a p2p group — and the callers in the store compute them from `GroupInfo` and `GroupMember` with `groupBindingData`. `profileBadgeVerified` is in `Types.hs` today and cannot call `groupBindingData`; it moves to `Store/Shared.hs`, beside the other badge-verifying store code. +The expected header is computed in the chat layer and passed to the store; `groupBindingData` and `profileBadgeVerified` remain in `Internal.hs` and `Types.hs`. One predicate is defined in `Badges.hs`: `acceptedProof`, true for `PHTest` and for a header equal to the expected one (section 14.3). `SimplexDomainProof` (`Names.hs:37`) also uses `ProofPresHeader`, as an opaque value. Its verification is unchanged. ## 2. Presenting the profile badge -File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2178`). +File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2238`). -The function generates the proof for an outgoing profile. It takes a new argument, `Maybe GroupInfo`. With `Nothing` it generates `PHTest` as today. With `Just gInfo` it generates `PHChat` from the group's chat binding and the user's own member key in that group, calling `createUserMemberKey` first when the group has no key yet. +The proof for an outgoing profile is generated with `sndBadgeProof`, as file proofs are. The header, `Maybe ProofPresHeader`, is a parameter; with `Nothing`, no badge is presented. For a send into a group the header is `groupPresHeader`, `memberPresHeader` of the user's own member id and key in that group. The membership key is generated and stored by `mkGroupInfoKeys` when the group is read with its keys, and it is set on the membership of the returned group; for a membership without a public key, no badge is presented. The header of every call site is listed in section 14.3. -Call sites that send a profile into a group pass the group: `Commands.hs:3953` (join via group link, the group case), `:4291` (the owner's profile to a relay); `Subscriber.hs:480` (the group case), `:611`, `:799`, `:813`, `:941`, `:1220`, `:3271`; `Internal.hs:2539` (`sendGroupProfileUpdate`). All other call sites send a direct profile and pass `Nothing`. - -The profile in a direct chat keeps `PHTest`; moving it to `PHChat` is a later change. +In the handshake sends (section 4) and in `XGrpAcpt` (section 14.3, item 9), the badge is presented at every peer version; the message is signed only when the peer version is at least `relayWebCapVersion`. A peer below `relayWebCapVersion` does not store the proof; the proof is generated for it anyway, and no version check is added for it. ## 3. Accepting the profile badge -A received badge is verified today at seven places in the store layer, each verifying the proof with no knowledge of the sender: `profileBadgeVerified` (`Types.hs:834`), `createContact_` (`Store/Shared.hs:420`), `createJoiningMember` (`Store/Groups.hs:2089`), `createNewMemberProfile_` (`Store/Groups.hs:2459`), two contact request sites (`Store/ContactRequest.hs:169, 236`), and `linkDataBadge` (`Internal.hs:2194`). +A received badge is verified today at seven places in the store layer, each verifying the proof with no knowledge of the sender: `profileBadgeVerified` (`Types.hs:851`), `createContact_` (`Store/Shared.hs:421`), `createJoiningMember` (`Store/Groups.hs:2085`), `createNewMemberProfile_` (`Store/Groups.hs:2440`), two contact request sites (`Store/ContactRequest.hs:170, 237`), and `linkDataBadge` (`Internal.hs:2253`). -The direct sites keep verifying with `PHTest`. The group sites gain the `GroupInfo` and the sender's `GroupMember` where they do not have them already: `updateMemberProfile` and `updateContactMemberProfile` (`Store/Groups.hs:3430, 3453`) have the member and gain the group, and pass both to `profileBadgeVerified`; `createNewMemberProfile_` gains both from `createNewGroupMember`. A badge from a message that was not verified with the member's key is not verified at all: the caller removes it from the profile before storing, so the store function sees no badge. +The expected header, `Maybe ProofPresHeader`, is a parameter of the store functions and is checked with `acceptedProof` (section 14.3); `Nothing` is passed where a profile is built from a name alone. A proof rejected by `acceptedProof` is ignored: at creation no badge is stored, at update the stored badge is kept. A proof that fails BBS verification is stored as failed. -Where a badge is accepted in a p2p group: +The header is computed in the chat layer by `memberPresHeader gInfo memberId key_` (`Internal.hs`): for a channel `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, key)))`; for a p2p group `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, key)))`; `Nothing` without a key. File proofs keep the binding of message signatures (`rcvGroupChatBinding`). The key passed at each site: -- `xInfoMember` (`Subscriber.hs:2738`): only when the `XInfo` was verified with the member's key. `RcvMessage.msgSigned` is `MSSVerified` when a stored key verified it. When the same message delivers the key, `storeMemberKey` has verified the signature with that key, and the badge is kept on the same basis. Otherwise the badge is removed from the profile before `processMemberProfileUpdate`. -- `xGrpLinkMem` (`:2744`): the host's profile to the joiner, signed on this branch. -- The member connection handshake, section 4. +- `xInfoMember` (`Subscriber.hs:2798`) and `xGrpLinkMem` (`:2804`): the stored key when the message signature is verified with it, otherwise no key. A key delivered by the message is first stored by `storeMemberKey` when the signature is verified with it; the member with the stored key is returned by `storeMemberKey`. +- The member connection handshake, section 4: the stored key when the signature is verified with it. +- Introductions — `createNewGroupMember`, `createIntroReMember`, `updateUnknownMemberAnnounced`, `updateRosterMemberAnnounced`, `updatePreparedChannelMember`: in a channel the key stored for the member after the introduction — the roster key for roles from member up, the key in the `MemberInfo` for lower roles; in a p2p group the key in the `MemberInfo`. +- A join via a relay (`createJoiningMember`, `updateMemberProfile`): the key in `XMember` when the message signature is verified with it. +- The channel owner at a relay (`createRelayRequestGroup`, `relayInvPresHeader`): the owner's key claimed in the invitation, when the invitation is signed with it. +- `XGrpAcpt` at the inviting host: the key delivered by the message, stored before the profile; the binding includes it when the message signature is verified with it. +- A join by `XContact` to a p2p group: the request header (section 14.3). +- A relay's profile, from its link data: `Nothing`. -Where a badge is dropped in a p2p group: `createJoiningMember` and `createNewMemberProfile_`. The profile is stored without the badge, and the member's badge arrives at the handshake. - -In a channel a member's profile arrives in three ways: in `XMember` when the member joins, which the member signs and the owner verifies with the roster key (`verifyKey`, `Subscriber.hs:1656`) before `createJoiningMember`; in the introduction from a relay, stored by `createNewMemberProfile_`; and in `XInfo`. A badge in any of them is kept and verified, because member keys in a channel are established by the roster, which the owner signs, and `xGrpMemNew` rejects a relay that asserts a different key (`Subscriber.hs:3127-3134`). +In a channel a badge is accepted from `XMember` and `XInfo` whose signature was verified under the member's key, and from the introduction by a relay under the key stored for the member (section 14.3, item 9). In a p2p group a badge is accepted from a message whose signature was verified under the member's key, from a join request under the request header, and from an introduction under the key in the introduction. ## 4. The member connection handshake -When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:816`) and the join of the member connection and of the direct connection to the same member (`:3272`, both joined with the same message at `:3282-3283`). The four receiving sites — `:590, 620` on the direct connection, `:810, 823` on the member connection — each have a "TODO update member profile" comment. +When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:843`) and the join of the member connection and of the direct connection to the same member (`:3336`, both joined with the same message at `:3345-3347`). The four receiving sites — `:615, 647` on the direct connection, `:837, 850` on the member connection — each have a "TODO update member profile" comment. -- **Sign the join side.** `xGrpMemFwd` sends `encodeConnInfo $ XGrpMemInfo ...` (`:3272`), plain JSON. Change it to `encodeSignedConnInfo` with `groupMsgSigning` when the agreed version is at least `relayWebCapVersion`. The agreed version is computed three lines below, as `chatV`; move that computation above the send. Call `createUserMemberKey` before signing, here and at the reply site, as every other signing site does. -- **Parse the signature on CONF.** The member CONF site parses with `parseChatMessage` (`:745`), which discards the signature. Change it to `parseChatMessage'`, as INFO already does (`:823`). -- **Verify the signature.** At `:810` and `:823` verify the signed `XGrpMemInfo` with the member's stored key. `XGrpMemInfo` names no key, and the handshake follows the introduction, which stored the key. A member with no stored key is not verified. -- **Store the profile.** At `:810` and `:823` call `processMemberProfileUpdate` with the profile, with the badge removed when the signature did not verify. -- `:590` and `:620` stay as they are. The profile there is the same group profile, received over the direct connection to the member. The contact for a member shares the member's profile row (`createIntroToMemberContact`, `Store/Groups.hs:2684-2685`), so storing it once, on the member connection, updates both. +The badge is presented in `XGrpMemInfo` at every peer version, by `membershipHandshakeProfile`. `XGrpMemInfo` is signed by `groupMsgSigning` when its profile includes a badge and the peer version is at least `relayWebCapVersion`. A `PHChat` proof is kept only from a verified signature. + +- **Sign the join side.** In `xGrpMemFwd` (`:3336`), `XGrpMemInfo` is encoded with `encodeSignedConnInfo` when a signing is returned by `groupMsgSigning` and the agreed version, `chatV`, is at least `relayWebCapVersion`; `GroupInfoKeys` is passed from the dispatch. +- **The reply side** (`:843`): the message is signed by `allowAgentConnectionAsync` when the maximum of the connection's `peerChatVRange` is at least `relayWebCapVersion`. +- **Parse the signature on CONF.** The member CONF site (`:781`) is parsed with `parseChatMessage'`, as INFO is (`:847`). +- **Verify and store.** At `:837` and `:850` the signed message is passed to `processMemberProfileUpdate`, and the signature is verified by `signedMemberPresHeader` with the stored member key: the key from the introduction, or, at `:850` for the inviting host, the key from `XGrpInv`. The profile is stored with the binding of the verified key, or with no binding. +- `:615` and `:647` are unchanged: the profile there is the same group profile, received over the direct connection to the member, and the member's profile row is used by the contact for the member (`createIntroToMemberContact`). + +For a member whose key was never introduced, a `PHChat` proof is accepted only from a message that delivers the key and is signed with it. ## 5. The file size limit at send @@ -254,11 +264,374 @@ The six proof columns are the fields of `BadgeProof` — the proof, the presenta ## 13. Tests - `BadgeTests.hs`: each header encodes and decodes; a proof generated with one header fails with another; the key check accepts an unknown key, accepts an equal key, rejects a different one. -- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group appears at the other member after the connection handshake and not before; a proof presented under another member's binding is rejected; a badge in a channel appears on presentation. Existing tests that assert a badge at introduction time are updated. +- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group is shown at the other member from the introduction and from the connection handshake; a proof presented under another member's binding is ignored; a badge in a channel is shown on presentation. The tests of section 14.3 follow. - `ChatTests/Files.hs`, beside `testXFTPGroupFileTransfer`: a file above the default limit from a badge holder is received in a group and in a direct chat; an invitation whose proof was made for another member is refused; a description with a changed hash fails before download; a file above the limit received as history is received by the new member; a forward into an incognito membership above the default limit fails the command before any upload. - `ProtocolTests.hs`: the new fields in `FileInvitation` and `XMsgFileDescr`. +## 14. Bindings in every chat + +Every profile badge is bound to the chat it is shown in. The sender presents the proof under the header of that chat; the receiver computes the expected header and accepts the proof under it, and under `PHTest`. Implementation starts in simplexmq, in `/code/simplexmq-4`; the chat then builds against it. Line references in this section are to the branch; in sections 1-13, to master. + +Rules: + +1. A ratchet held by both sides: the ratchet code. +2. A connection request before the ratchet: the request code — the proposing party's X448 keys, its PQ key when present, and the id of the queue the joining party sends to. +3. A link shown to anyone who holds it: the link key. +4. A group: the member identity. + +| Context | Received by | Header | +|---|---|---| +| Direct chat: `XInfo`, `CONF` and `INFO` replies, accept, one-time link join, member contacts | contact | `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` | +| Request to an address with ratchet keys | address owner | `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` | +| Request to an address without ratchet keys | address owner, group host | `PHRequest code` — the joining party's keys, the address queue | +| One-time invitation link data | joining party | `PHLink linkKey` | +| Address link data | anyone with the link | `PHLink linkKey` | +| P2p group | members | `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, memberKey)))` | +| Channel | subscribers | `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, memberKey)))` | + +An address is a contact address, a business address, or a group link. The link key is part of the link: `sha3_256` of the fixed link data — the agent version range, the root key, the connection request with its server and queue id, and the entity id of an address (`encodeSignFixedData`, `ShortLink.hs`). + +### 14.1 Agent: verification codes + +Merged in simplexmq `5294b7d8`, except the ratchet stored by `newConnToAcceptDR`, its use in `startJoinInvitationDR`, and their tests. + +**Second verification code.** `RatchetInitParams` gains `rcVerifyCodePQ` and `Ratchet` gains `rcVCPQ :: Maybe Str`. The code is derived in `pqX3dh` with a separate HKDF over the same inputs, info `SimpleXVerifyCode`, 32 bytes; the ratchet keys and `rcAD` are unchanged. The code is exported keying material over every handshake input, the KEM included — the fourth of the paper's mitigations. A ratchet created before this change is decoded with `rcVCPQ = Nothing`, and its code is set at the next ratchet resync. The chat uses the AD code, `codeAD`, for the security code and for badge bindings; `codePQ` is stored. + +**Columns.** Migration `M20260919_ratchet_verify_codes`, SQLite and Postgres, both schema dumps: + +```sql +ALTER TABLE ratchets ADD COLUMN rc_verify_code_ad BLOB; +ALTER TABLE ratchets ADD COLUMN rc_verify_code_pq BLOB; +``` + +`BYTEA` on Postgres. Both are written by `createRatchet` and `createSndRatchet`, also through their `ON CONFLICT` update, by which a ratchet is recreated at a resync. + +One store function, `getRatchetVerifyCodes :: DB.Connection -> [ConnId] -> IO (Map ConnId ConnVerifyCodes)`, is used for one id or many: + +```sql +SELECT conn_id, rc_verify_code_ad, rc_verify_code_pq, CASE WHEN rc_verify_code_ad IS NULL THEN ratchet_state END +FROM ratchets +``` + +- A row with `rc_verify_code_ad` is used as is. +- A row with the ratchet state is decoded, and its codes are computed and saved. +- A row with x3dh keys only is skipped. +- SQLite: one SELECT per id; the computed codes are saved with one `executeMany` UPDATE. +- Postgres: one SELECT with `In connIds`; the computed codes are saved with one `UPDATE ... FROM (VALUES ...) RETURNING`; codes saved by a concurrent read are kept. + +It is used by `getConnectionVerifyCodes` with one id, with `CONN NOT_FOUND` for a connection without codes, and by `getConnectionsVerifyCodes` with many. The result is `ConnVerifyCodes {codeAD, codePQ}`, defined in `Agent/Protocol.hs`, where `codeAD = sha256 rcAD` and `codePQ` is the PQ code as derived. + +**Prepare step.** The result of `prepareConnectionToJoin` is `(ConnId, ContactRequestBinding)`, the binding for the message the chat composes next: + +```haskell +data ContactRequestBinding = CRBRatchet ConnVerifyCodes | CRBRequest ByteString +``` + +- `CRInvitationUri`: the sender ratchet is created (`createRatchet_`, local — the link's keys and a fresh keypair); the binding is `CRBRatchet` of its codes. +- `CRContactUri` with ratchet keys: the same, from the address keys. +- `CRContactUri` without keys: the x3dh keys are generated and stored (`generateRcvE2EParams`, `createRatchetX3dhKeys`); the binding is `CRBRequest (sha256 (smpEncode (k1, k2, kem, senderId)))` — the request's public keys and the queue id from the link's `SMPQueueUri`. + +The same pair is returned by `prepareConnectionToAccept`: for `CRInvitation` the ratchet is created from the invitation's keys, for `CRInvitationDR` the ratchet in the invitation is stored with the connection by `newConnToAcceptDR`. In `startJoinInvitation` the stored ratchet is used, and a ratchet is created only for a connection without one, as in the contact path and its retry branch. In `startJoinInvitationDR` the ratchet in the invitation is written by `createRatchet` after `updateNewConnSnd`, in one transaction: it is written only while the connection has no send queue, and a ratchet used in a send is never replaced. In `createConnReq` the x3dh keys are read before they are generated, as in `mkJoinInvitation`. The stored ratchet is used by async joins and accepts. The prepare step is local. + +**Events.** A `ContactRequestBinding` field in `REQ`: `CRBRequest` in `smpInvitation`, computed from the received `CRInvitationUri` and the queue of the request; `CRBRatchet` in `smpContactRequest`, from the ratchet initialised there. `CONF` and `INFO` are unchanged: the receiver's ratchet is stored before the notification, so its codes are available to `getConnectionVerifyCodes`. + +**Tests.** `DoubleRatchetTests`: the parties agree on `rcVerifyCodePQ`, a substituted KEM key makes it differ while `assocData` matches, and a ratchet stored before the change decodes with `rcVCPQ = Nothing`. `FunctionalAPITests`: both peers get the same codes, and codes cleared from a row are recomputed and saved on the next read. For an address with ratchet keys, the codes of the requester's prepare step, of `REQ`, of the acceptor's prepare step and of the stored ratchet are equal before `acceptContact`; an accept of a prepared connection without a ratchet is completed by `acceptContact`. + +### 14.2 Agent: links before link data + +Files: `Agent.hs`, `Agent/Client.hs`, `Agent/Protocol.hs`, `Crypto/ShortLink.hs`, `Protocol.hs`, `Transport.hs`, `tests/AgentTests/FunctionalAPITests.hs`. + +The chat pins the simplexmq commit in `cabal.project` and `scripts/nix/sha256map.nix`. + +A link is available to the chat before its link data is signed: a contact link with its short link from `prepareConnectionLink`, an invitation as its full link with its link key from `prepareInvitationLink`. One network request is made per creation. + +**New links.** The short link of an invitation, with the link id assigned by the server, is returned by `createInvitationForLink`: + +```haskell +prepareConnectionLink :: AgentClient -> UserId -> C.KeyPairEd25519 -> ByteString -> Bool -> Maybe CRClientData -> CR.InitialKeys -> UseRatchetKeys -> Maybe SMPServerWithAuth -> AE (CreatedConnLink 'CMContact, PreparedLinkParams (Maybe (RatchetKeyId, CR.RcvE2EPrivRatchetParams 'C.X448))) + +createConnectionForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> CreatedConnLink 'CMContact -> PreparedLinkParams (Maybe (RatchetKeyId, CR.RcvE2EPrivRatchetParams 'C.X448)) -> UserConnLinkData 'CMContact -> SubscriptionMode -> AE ConnId + +prepareInvitationLink :: AgentClient -> UserId -> CR.InitialKeys -> Maybe SMPServerWithAuth -> AE (ConnectionRequestUri 'CMInvitation, PreparedLinkParams (CR.RcvE2EPrivRatchetParams 'C.X448)) + +createInvitationForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> ConnectionRequestUri 'CMInvitation -> PreparedLinkParams (CR.RcvE2EPrivRatchetParams 'C.X448) -> UserConnLinkData 'CMInvitation -> SubscriptionMode -> AE (ConnId, CreatedConnLink 'CMInvitation) +``` + +- The contact functions keep the signatures of master, with the type parameter of `PreparedLinkParams`. +- `PreparedLinkParams` is parameterised by the keys stored at creation; `plpAddressKeys` is renamed `plpRatchetKeys`: + + ```haskell + data PreparedLinkParams k = PreparedLinkParams + { plpNonce :: C.CbNonce, + plpQueueE2EKeys :: C.KeyPairX25519, + plpLinkKey :: LinkKey, + plpRootPrivKey :: C.PrivateKeyEd25519, + plpSignedFixedData :: ByteString, + plpSrvWithAuth :: SMPServerWithAuth, + plpInitKeys :: InitialKeys, + plpRatchetKeys :: k + } + ``` + + - contact link: `k = Maybe (RatchetKeyId, RcvE2EPrivRatchetParams 'C.X448)`, the address ratchet keys + - invitation: `k = RcvE2EPrivRatchetParams 'C.X448`, the X3DH keys +- Prepare, `prepareLinkParams :: ConnectionModeI c => C.KeyPairEd25519 -> Maybe ByteString -> Maybe CRClientData -> CR.InitialKeys -> SMPServerWithAuth -> QueueMode -> (ConnReqUriData -> ConnectionRequestUri c) -> k -> AM (ConnectionRequestUri c, PreparedLinkParams k)`: + - the nonce and the queue keys are generated; + - the connection request is built by the given function from the queue of the given mode; + - its fixed data is signed with the root key and the entity id. +- `prepareConnectionLink'`: the server is chosen; client notices are checked when `checkNotices` is set; the address ratchet keys are generated when `useDR` is set; `QMContact`. +- `prepareInvitationLink'`: + - the server is chosen and the root key pair is generated; + - X3DH keys from `CR.generateRcvE2EParams`, PQ support from `CR.initialPQEncryption True pqInitKeys`; + - `CRInvitationUri` with the X3DH parameters, `QMMessaging`; + - the entity id and the client data are `Nothing`. +- Create, `createLinkQueue :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> SConnectionMode c -> PreparedLinkParams k -> SubscriptionMode -> ClntQueueReqData -> (ConnId -> AM ()) -> AM (ConnId, RcvQueue, SMPQueueUri)`: + - the connection is created; + - the keys are stored by the given action; + - the queue is created by `createRcvQueue`; + - the connection is deleted when either step fails. +- `createConnectionForLink'`: + - the link data is encrypted by `encryptContactLinkData` before the connection is created; + - the address ratchet keys are stored by `storeAddressRatchetKeys`; + - the sender id is compared after `NEW`, as on master. +- `createInvitationForLink'`: + - link data: `SL.encodeSignUserData SCMInvitation`, encrypted with `SL.invShortLinkKdf plpLinkKey`; + - queue request: `CQRMessaging (Just CQRData {linkKey, privSigKey, srvReq = (sndId, srvData)})`; + - the X3DH keys are stored by `createRatchetX3dhKeys`; + - the returned link from `connReqWithShortLink`: `CSLInvitation` with the link id from the server, PQ keys removed from the full link for `IKPQOn`. +- Tests: + - a connection via an invitation made by prepare and create: its link data is read by the joining party and holds the prepared invitation; `plpLinkKey` equals the key in the returned `CSLInvitation`; for `IKPQOn` the returned full link differs from the prepared invitation + - for link data above the size limit, `CMD LARGE` is returned for a contact link and for an invitation, and no connection is stored + +**Existing connections.** The link of a connection is returned without a network call: + +```haskell +prepareConnShortLink :: AgentClient -> ConnId -> Maybe CRClientData -> AE (ConnShortLink 'CMContact) +``` + +- A stored link is returned as is. +- Otherwise the credentials are created and stored by `newContactLinkCreds :: AgentClient -> RcvQueue -> Maybe CRClientData -> AM (ShortLinkCreds, C.SbKey)`: the signing key pair is generated, the fixed data is built from the connection request without ratchet keys, signed and encrypted (`SL.encryptFixedData`), and `ShortLinkCreds` are stored; the link data key is returned with them. +- In `setConnShortLink`, `newContactLinkCreds` is used for a connection without stored credentials, and the user data is then encrypted with the returned key and uploaded with one `LSET`. For stored credentials, the key is derived once, by the link id check. +- Tests: for an address without a short link, the link is the same from two `prepareConnShortLink` calls and from `setConnShortLink`; a requester connects via it. + +**SMP versions below 15.** The minimum SMP version of clients and servers is 15, and the code for older versions is removed: + +- `NEW` and `IDS` (`Protocol.hs`): the encodings and parsers for versions below 15, with `qReq` and `qm`. +- `mkShortLinkCreds` (`Agent/Client.hs`): link data without a link id in the response is an error; the `THandleParams` parameter is removed. +- `connReqWithShortLink`: absent short link credentials are an `INTERNAL` error. +- `Transport.hs`: `shortLinksSMPVersion` is renamed `_shortLinksSMPVersion` and is not exported; `_proxyServerHandshakeSMPVersion` is removed. +- Tests: `testInvitationShortLinkPrev` and `testProxyMatrixWithPrev` are removed. + +### 14.3 Chat + +In implementation order. A badge is presented only with a header: no badge is presented at a direct send for a connection without codes or for a retry without a stored request header, and at a send into a group for a `Nothing` from `groupPresHeader`. The agent is asked for codes or a link only when a badge is presented, or when a received proof has a header other than `PHTest`. An agent error is returned as a chat error. A failed read of verification codes fails the whole operation — a profile update, a badge broadcast, or a reply to a confirmation; the profile is not sent to the contacts whose codes were read, and no partial delivery is reported. + +**1. Headers** — `Badges.hs` + +- `PHRequest ByteString` in `ProofPresHeader`, tag `'R'`. +- `PHLink ByteString` in `ProofPresHeader`, tag `'L'`; payload: the link key bytes. +- `acceptedProof :: Maybe ProofPresHeader -> BadgeProof -> Bool`: true for `PHTest`, and for a header equal to `strEncode` of the expected one. `proofPresHeaderAccepted` and `verifyBadge_` are removed. +- `acceptedBadge :: Maybe ProofPresHeader -> Maybe BadgeProof -> Maybe BadgeProof -> Maybe BadgeProof`: the received proof when it is accepted or absent, otherwise the stored one. Used by `profileBadgeVerified`, `processContactProfileUpdate`, `processMemberProfileUpdate` and `setMemberBadgeProof`. +- `sameBadgeProof :: Maybe BadgeProof -> Maybe BadgeProof -> Bool`: equal disclosed information and equal headers, where the random header of `PHTest` is not compared. Used by `processMemberProfileUpdate` and `setMemberBadgeProof`. +- `unboundProof :: BadgeProof -> Bool`: true for `PHTest`. +- `ToField` and `FromField` for `ProofPresHeader`: the `strEncode` bytes as a blob. + +**2. Ignored proofs** — `Types.hs`, store modules + +- A proof rejected by `acceptedProof` is ignored. +- At creation no badge is stored: `createContact_`, `createContactRequest` (`ContactRequest.hs:170`), `createJoiningMember`, `createNewMemberProfile_`. +- At update the stored badge is kept: the badge to store and its verification are returned by `profileBadgeVerified` — for a rejected proof, the stored proof, verified again when its stored status is `BSFailed` or `BSUnknownKey`. Used in `updateContactProfile`, `updateMemberProfile`, `updateContactMemberProfile`, and the request update (`ContactRequest.hs:239`). +- `proofPresHeaderAccepted` is removed from `verifyBadgeWith`, and `verifyBadge_` is removed; at the creation sites `profileBadgeVerified` is called with no stored profile. `BSFailed` is stored only for a failed BBS verification. + +**3. Expected header in the store** + +The expected header, `Maybe ProofPresHeader`, is a parameter of: + +- `updateContactProfile` +- `createDirectContact` +- `createPreparedContact` +- `createOrUpdateContactRequest` +- `updateMemberProfile` +- `updateContactMemberProfile` +- `createJoiningMember` +- `createNewGroupMember` +- `createIntroReMember` +- `updateUnknownMemberAnnounced` +- `updateRosterMemberAnnounced` +- `updatePreparedChannelMember` +- `createRelayRequestGroup` + +`Nothing` is passed by `createContact` for the preset contact card, by `setRelayLinkAccepted` and `updateRelayMemberData` for a relay profile, by `updatePreparedUserAndHostMembers'` for a host profile built from a name, and by `acceptGroupJoinSendRejectAsync`. In `processMemberProfileUpdate` the header is computed from the signed message by `signedMemberPresHeader`. Other group headers are computed by `memberPresHeader` or `memberInfoPresHeader`; the request header is passed for a join by `XContact`, and `relayInvPresHeader` for a relay invitation. + +**4. Presenting** — `Internal.hs` + +```haskell +presentUserBadge :: User -> Maybe i -> Maybe ProofPresHeader -> Profile -> CM Profile + +presentUserBadgeWith :: User -> Maybe i -> CM (Maybe ProofPresHeader) -> Profile -> CM Profile +``` + +With `Nothing`, no badge is presented. A badge is presented only when `presentsUserBadge :: User -> Bool` holds, for a profile that is not incognito: the user's own badge is active or expired. `presentUserBadgeWith` runs the header action only in that case; `presentUserBadge` is `presentUserBadgeWith` of a known header. Header helpers: + +- `groupPresHeader :: GroupInfo -> Maybe ProofPresHeader` — `memberPresHeader` of the membership's id and key +- `directPresHeader :: ContactRequestBinding -> ProofPresHeader` — `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` for `CRBRatchet`, `PHRequest code` for `CRBRequest`. The profile header holds the labelled hash, so a profile proof does not include the input of the security code; file proofs keep `encodeChatBinding CBDirect codeAD`. +- `linkPresHeader :: ConnShortLink c -> ProofPresHeader` — `PHLink` of the link key +- `memberKeyPresHeader :: Maybe B64UrlByteString -> MemberId -> C.PublicKeyEd25519 -> ProofPresHeader` — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, key)))` with a channel id, `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, key)))` without one +- `memberPresHeader :: GroupInfo -> MemberId -> Maybe C.PublicKeyEd25519 -> Maybe ProofPresHeader` — `memberKeyPresHeader` of the channel id in a channel and of no id in a p2p group; `Nothing` without a key +- `memberInfoPresHeader :: GroupInfo -> MemberInfo -> Maybe ProofPresHeader` — `memberPresHeader` of the id and key in the `MemberInfo` +- `relayInvPresHeader :: Maybe SignedMsg -> GroupRelayInvitation -> Maybe ProofPresHeader` — `memberKeyPresHeader` of the channel id, the owner's id and the key in the invitation, for an invitation signed with that key (item 12) +- `connPresHeader :: Connection -> CM (Maybe ProofPresHeader)` — `connsPresHeaders` of one connection +- `connsPresHeaders :: [Connection] -> CM (Map ConnId ProofPresHeader)` — `directPresHeader . CRBRatchet` of `getConnectionsVerifyCodes`; the map includes the connections with codes + +**5. The binding from prepare steps** + +The result of each includes `directPresHeader` of the agent binding: + +- `prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat, ProofPresHeader)` +- `prepareAgentJoin :: User -> Bool -> ConnectionRequestUri c -> CM ((CommandId, ConnId), ProofPresHeader)` +- `prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM ((CommandId, ConnId), ProofPresHeader)` + +**6. Stored request header** + +Migration `M20260925_badge_bindings`, SQLite and Postgres, first statement; the rest is in item 11: + +```sql +ALTER TABLE connections ADD COLUMN pres_header BLOB; +``` + +`BYTEA` on Postgres. Both schema dumps and `chat_query_plans.txt` are updated. + +- `createConnReqConnection`: the request header is a parameter, written to `pres_header`. +- `getConnReqContact`: the result is `(ConnReqContact, Maybe ProofPresHeader)`, read from `via_contact_uri` and `pres_header`. + +**7. Direct sends** + +- `joinContact` (`Commands.hs:3982`): the request header, `Maybe ProofPresHeader`, is a parameter, set in `connect'`, `joinPreparedConn'` and `connectContactViaAddress` — the `prepareContact` header for a new connection, the stored header for a retry. The badge is presented with `groupPresHeader` in a relay group, and with the request header otherwise. +- `connectViaInvitation` (`Commands.hs:3808-3845`) and `connectMemberContact` (`:3395-3422`): the prepare binding for a new connection; `connPresHeader` for a prepared one, by `presentUserBadgeWith`. +- `joinMemberContactAsync` (`Subscriber.hs:3952`): the header is a parameter, set in `xGrpDirectInv` to the `prepareAgentJoin` header. +- `acceptContactRequest` (`Internal.hs:984-1014`): the prepare binding for a new connection; `connPresHeader` for an existing one, by `presentUserBadgeWith`. +- `acceptContactRequestAsync` (`Internal.hs:1016-1035`): the profile is built after `prepareAgentAccept`, from its header. +- `CONF` replies (`Subscriber.hs:506` direct case, `:623`) and `updateContactPrefs` (`Commands.hs:4099`): `connPresHeader`, by `presentUserBadgeWith`. +- `sendUpdateToContacts` (`Commands.hs:4043-4082`) and `presentUserBadgeToContacts` (`:5215-5230`): one `connsPresHeaders` call per command, when `presentsUserBadge` holds. + +**8. Direct receipts** + +- `REQ` (`Subscriber.hs:1399`): `directPresHeader` of the `REQ` binding is a parameter of `profileContactRequest`, passed to `createOrUpdateContactRequest` and to `acceptGroupJoinRequestAsync`. +- `processContactProfileUpdate` (`Subscriber.hs:2761`) and `saveConnInfo` (`:3185`, for `createDirectContact`): `connPresHeader` of the connection the profile was received on, read only for a received proof with a header other than `PHTest`. +- In `processContactProfileUpdate` the profile with the stored proof in place of a rejected one is used for the comparison and the chat item, as for members (item 11). + +**9. Groups** + +- `groupPresHeader` at every send into a group: `Commands.hs:4002` (`joinContact`, relay group), `:4331`; `Subscriber.hs:505` group case, `:636`, `:829`, `:971`, `:1253`, and `membershipHandshakeProfile :: User -> GroupInfo -> CM Profile` (`Internal.hs`) for `:791`, `:843`, `:3371` and `addRelays`; `Internal.hs:2546` (`encodeXGrpAcpt`) and `:2730`. +- `acceptGroupJoinRequestAsync` (`Internal.hs:1037`): the expected header is a new parameter, passed to `createJoiningMember` and `updateMemberProfile` — in `memberJoinRequestViaRelay`, `memberPresHeader` of the joining member's id and the key in `XMember` when the message signature is verified with it. The signature is verified once, for the roster key check and for the header. `Nothing` is passed to `createJoiningMember` in `acceptGroupJoinSendRejectAsync`. +- Host `INFO` with `XInfo` (`Subscriber.hs:858-865`): after `storeMemberKey`, the profile is stored by `processMemberProfileUpdate` with `signedMemberPresHeader`, under the member's key. +- The profile is also stored by `processMemberProfileUpdate` when the received proof is accepted and differs from the stored member proof in its header or its disclosed information, and when a profile without a proof is received for a member with a stored proof; proofs are compared by `sameBadgeProof`. +- The member event is sent when the profile content or the badge status of the member changes; a change of the proof alone sends no event. +- Introductions: + - In `memberInfo` (`Internal.hs:1344`) the stored proof (item 11) is included when `acceptedProof` holds under `memberPresHeader` of the member's id and stored key, in channels and in p2p groups. + - In `xGrpMemNew`, `xGrpMemIntro` and `xGrpMemFwd` (`Subscriber.hs:3213, 3296, 3347`) the header is built from the key stored for the member after the introduction: + - a roster member in `xGrpMemNew` and an existing member in `xGrpMemIntro`, in a channel: `memberPresHeader` of the stored key; + - a new member in `xGrpMemIntro` in a channel: `memberInfoPresHeader` of the introduction with the key removed for roles from member up; + - other introductions, in channels and in p2p groups: `memberInfoPresHeader` of the introduction, whose key is stored. +- Invitation via contact: + - `profile :: Maybe Profile` in `XGrpAcpt`, the optional JSON field `profile`. + - The invitee (`Commands.hs:2851`, `Subscriber.hs:2700`, both by `encodeXGrpAcpt`, `Internal.hs:2541-2549`) includes its group profile, with `groupPresHeader`; the message is encoded by `encodeConnInfoSigning`, signed when a signing is returned by `groupMsgSigning` and the maximum of the contact connection's `peerChatVRange` is at least `relayWebCapVersion`. + - `XGrpAcpt` with a badge in its profile is signed by `groupMsgSigning` (`Internal.hs:2360-2363`). + - `encodeConnInfoSigning :: PQSupport -> Maybe MsgSigning -> ChatMsgEvent e -> CM ByteString` — `encodeSignedConnInfo` with a signing, `encodeConnInfoPQ` without one; used by `encodeXGrpAcpt`, `xGrpMemFwd` and `allowAgentConnectionAsync`. + - The host (`Subscriber.hs:785-795`) stores the key, allows the connection, then stores the profile by `processMemberProfileUpdate` with the signed message. For an invitee whose contact is active, the profile row is kept by `contactUpdatableFromMember` and the proof is stored on the membership (item 11). + - The host replies with `XGrpMemInfo` and its group profile in place of `XOk` (`Subscriber.hs:791-793`); the badge is presented at every version, and the reply is signed by `allowAgentConnectionAsync` from `relayWebCapVersion`, as at `:843-845`. + +**10. Link data** + +- Invitation links (`APIAddContact`, `recreateConn`): the chat calls `prepareInvitationLink`; the badge is presented with `PHLink` of `plpLinkKey`; then `createInvitationForLink` is called with the invitation and the params. +- Invitation updates (`updatePCCShortLinkData`, `APISetConnectionIncognito`): `linkPresHeader` of the stored invitation short link. +- Address (`APICreateMyAddress`): `linkPresHeader` of the key in the prepared `CSLContact`. +- Address updates (`setMyAddressData`): the key of the stored short link. For an address without a short link, the key is taken from `prepareConnShortLink` when a badge is presented, then `setConnShortLink` is called once. +- Receipts: + - `linkDataBadge` (`Internal.hs:2285`): the expected header is a parameter — `linkPresHeader` of the short link, for an invitation (`Commands.hs:4415`) and for an address (`:4469-4470`). For a rejected proof the display badge, `localBadge`, is cleared. + - `createPreparedContact` (`Commands.hs:2206`): `linkPresHeader` of the short link in `accLink`; `Nothing` without a short link. + - `updateContactFromLinkData` (`Internal.hs:1644`): the header of the address link is a parameter. + +**11. Member proofs** — `Badges.hs`, `Types.hs`, `Types/Preferences.hs`, `Store/Shared.hs`, `Store/Groups.hs`, `Store/Connections.hs`, `Internal.hs`, `Subscriber.hs` + +A member's accepted proof is stored on the membership, in `group_member_badge_proofs`, and forwarded in introductions. The badge in the profile row is kept for display: the profile row of a member linked to a contact is the contact's. + +Migration `M20260925_badge_bindings`, after the statement of item 6, SQLite: + +```sql +CREATE TABLE group_member_badge_proofs( + group_member_id INTEGER PRIMARY KEY REFERENCES group_members ON DELETE CASCADE, + badge_proof BLOB NOT NULL, + badge_pres_header BLOB NOT NULL, + badge_key_idx INTEGER NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TEXT NOT NULL, + badge_extra TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +``` + +Postgres: `BIGINT`, `BYTEA` and `TIMESTAMPTZ`. Both schema dumps and `chat_query_plans.txt` are updated. + +- One row per member, keyed by the member id, as `rcv_files` is keyed by the file id. +- `MaybeBadgeProofRow` and `maybeRowToBadgeProof` in `Badges.hs`: the six proof columns of a `LEFT JOIN`. +- `PrefsJSON` is generalised: + + ```haskell + newtype NoJSON a = NoJSON {unNoJSON :: Maybe a} + + type PrefsJSON = NoJSON Object + ``` + + The field is omitted by `ToJSON`; `NoJSON Nothing` is returned by `FromJSON`. `NoJSON` is used at the construction sites. +- `GroupMember` gains `memberBadgeProof :: NoJSON BadgeProof`. It is removed from the bot API docs: `removeField "memberBadgeProof" $ sti @GroupMember`. +- Reads: + - The proof columns are selected by `groupMemberQuery` after the connection columns, with `LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id`; the field is set by `toContactMember`. + - They are selected the same way by the member read of `getConnectionEntity` (`Connections.hs:145-194`); the field is set by `toGroupAndMember`. + - `NoJSON Nothing` is set by `toGroupMember`, for the membership, chat item members and quoted members. +- Writes, for a received profile: + - a badge accepted by `acceptedProof` under the expected header, different from the stored proof, and verified by `verifyBadge` is upserted by `createMemberBadgeProof` (`INSERT ... ON CONFLICT (group_member_id) DO UPDATE`); a badge that does not verify keeps the row; + - for a profile without a badge and a member with a stored proof, the row is deleted; + - for a badge not accepted, or equal to the stored proof by `sameBadgeProof`, the row is kept. +- These rules are applied by `setMemberBadgeProof :: DB.Connection -> StoreCxt -> GroupMember -> Maybe ProofPresHeader -> Profile -> IO GroupMember`, and the member with the field set is returned. Used by `updateMemberProfile` and `updateContactMemberProfile`, and by `processMemberProfileUpdate` when `contactUpdatableFromMember` is false. +- The received profile is passed to `updateMemberProfile` after `redactedMemberProfile`, and unredacted to `updateContactMemberProfile` and `setMemberBadgeProof`. The profile with the stored proof in place of a rejected one is used for the comparisons, the business chat profile and the chat item. +- At creation the accepted proof is inserted when its verification succeeded (`verifiedProof`): `createNewMember_` (for `createNewGroupMember` and `createIntroReMember`), `createJoiningMember`, and the owner in `createRelayRequestGroup`. +- `memberBadgeProof`, or the profile's proof when it is absent, is included by `memberInfo`, filtered by `acceptedProof` under `memberPresHeader g memberId memberPubKey`. + +**12. The channel owner at the relay** — `Types.hs`, `Commands.hs`, `Subscriber.hs`, `Internal.hs`, `Store/Groups.hs`, `Store/RelayRequests.hs` + +- `GroupRelayInvitation` gains `publicGroupId :: Maybe B64UrlByteString` and `fromMemberKey :: Maybe MemberKey`, the optional JSON fields `publicGroupId` and `fromMemberKey`. The owner sets them in `addRelays` from `publicGroup' gInfo` and from `groupMemberKey` of the group keys; `addRelays` takes `GroupInfoKeys`. +- The invitation is always signed: `XGrpRelayInv_` is in `requiresSignature`, and the invitation is encoded by `encodeConnInfoSigning` with `groupMsgSigning`. Released relays parse the signed message and ignore the signature. +- `relayInvPresHeader :: Maybe SignedMsg -> GroupRelayInvitation -> Maybe ProofPresHeader` (`Subscriber.hs`) — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, memberKey)))` with the owner's member id and key, when the signature is verified with that key under `smpEncode (publicGroupId, memberId)`; `Nothing` without both claims or without the signature. The signature is verified by `verifyMemberSig`, which `verifyGroupSig` uses with `groupBindingData`. +- It is computed in `xGrpRelayInv` and passed to `createRelayRequestGroup` as the expected header, there and through `rejectRelayInvitationAsync`. The owner's profile and membership proof are stored under it. +- Chat version 22, `signedRelayInvVersion`; `currentChatVersion` is 22. From this version of the owner's range, an invitation without both claims and a verified signature is ignored by `xGrpRelayInv` with a message error, and no request is created. An invitation from an owner below this version is accepted unsigned. +- The group id claim is written to `groups.relay_request_public_group_id` with the other relay request fields, and read by `getRelayRequestData` into `RelayRequestData`, which gains `reqPublicGroupId :: Maybe B64UrlByteString`. `group_profiles.public_group_id` holds only ids taken from link data. +- Migration `M20260925_badge_bindings`, SQLite and Postgres: `ALTER TABLE groups ADD COLUMN relay_request_public_group_id BLOB;`, `BYTEA` on Postgres. +- The key claim is written to the owner's `group_members.member_pub_key`. +- The owner member is read by `processRelayRequest` in the transaction that reads the group, and passed to `getLinkDataCreateRelayLink` and `acceptOwnerConnection`. +- The request is failed by `getLinkDataCreateRelayLink`: + - when the group id claim differs from the link's entity id; + - when the key claim differs from the key of the link data owner with the owner's member id. +- Each comparison is skipped for a request without that claim. +- The public group id and the owner keys are written from the link data by `updateRelayGroupKeys`. + +**13. Tests** — `ChatTests/Profiles.hs`, `ChatTests/ChatRelays.hs`, `ChatTests/Groups.hs` + +- Direct: the badge is shown after a request to an address with and without ratchet keys, after accepting, after joining a one-time link, after a retried join, and after a profile update. +- A proof bound to another chat is ignored, and the stored badge is kept. +- P2p group: the joiner's badge is shown at the host at the request and after `INFO`; an introduced member's badge is shown from the introduction; the badge is shown on both sides of an invitation via contact. +- Invitation via contact: the invitee's badge is stored on the membership at the host, and a later member receives it in the introduction. +- Channel: the owner's badge is shown at a subscriber and the subscriber's badge at the owner, forwarded by the relay (`testChannelMemberBadges`). +- Channel: a relay invitation with an owner key pair that differs from the link data is failed by the relay, and the relay stays invited (`testChannelAddRelayOwnerKeyMismatch`). +- Link data: the badge is shown from an invitation link under `PHLink`, an address, and an address that gets its first short link. +- Member proofs: the stored proof is deleted when a profile without a badge is received, for a member joined by a group link and for a member with a contact. +- Peers below `relayWebCapVersion`: `XGrpAcpt` to an inviting host and `XGrpMemInfo` to a joining member include the badge unsigned, the handshakes complete, and neither receiver stores the proof. + +The profile update and app changes are not part of this plan; they were merged in #7663. + +`PHTest` proofs are generated only by released clients. For a connection prepared by a released client and joined after the update, the badge is presented only when its ratchet was stored by an earlier attempt; a retried request to an address prepared by a released client is sent without a badge. + ## Out of scope -- Moving the direct chat profile proof to `PHChat`. - Requiring an expiration in the description proof, once servers grant one. diff --git a/plans/2026-09-20-passphrase-autofill.md b/plans/2026-09-20-passphrase-autofill.md new file mode 100644 index 0000000000..a763a444e6 --- /dev/null +++ b/plans/2026-09-20-passphrase-autofill.md @@ -0,0 +1,56 @@ +# Passphrase fields not recognised by password managers (Android) + +PR #7612, branch `nd/passphrase-autofill`. + +## Problem + +> The passphase isn't detected as a password box for any password manager so it refuses to autofill. + +`PassphraseField` (`DatabaseEncryptionView.kt`) was never classified as a password, so no manager offered to fill or save the database passphrase. While verifying this, a second defect turned up: the raw passphrase could be copied to the clipboard from the selection toolbar and through accessibility `ACTION_COPY`. + +## Root cause (Compose 1.8.2, from bytecode) + +- Semantic autofill is enabled (`ComposeUiFlags.isSemanticAutofillEnabled = true`). `PopulateViewStructure.populate` builds each node from semantics. It sets autofill hints only from `SemanticsProperties.ContentType`, and `inputType = 0x81` plus `dataIsSensitive` only from `password()` or a `"password"` hint. +- The legacy `BasicTextField` never sets `ContentType`. It sets `password()` only when `visualTransformation is PasswordVisualTransformation`. The hand-rolled `VisualTransformation { … }` lambda fails that check, so the field reached managers with no hints and no password `inputType`. +- The same `is PasswordVisualTransformation` check gates Copy/Cut in the selection toolbar and the `CopyText`/`CutText` accessibility actions. Both copy `value.getSelectedText()`, which is the raw passphrase. + +## Fix + +`PassphraseField`: +- uses `PasswordVisualTransformation('*')`. The mask looks the same, and it restores `password()` semantics and the copy/cut gates. +- takes `contentType: ContentType? = ContentType.Password` and applies it with `.semantics { if (contentType != null) this.contentType = contentType }`. + +`newPasswordContentType()` (expect/actual in `platform/Modifier*.kt`) returns `NewPassword + Password` on Android and `NewPassword` on desktop, where `ContentType` has no `plus`. Desktop has no OS autofill, so it is inert there. + +| Screen | Current | New | Confirm | +| --- | --- | --- | --- | +| Change passphrase (`DatabaseEncryptionView`, current field shown) | `password` | `newPassword` | `newPassword` | +| First passphrase (`DatabaseEncryptionView` with no current field, `SetupDatabasePassphrase`) | — | `newPassword`+`password` | `newPassword`+`password` | +| Unlock / migrate (`DatabaseErrorView`, `MigrateToDevice`, `MigrateFromDevice`) | `password` | — | — | +| Hidden profile password (`HiddenProfileView`), unhide/delete (`UserProfilesView`) | none | none | none | + +Why the hints vary: +- **First passphrase:** KeePassDX does not recognise a form whose every field is `newPassword`, so these fields also carry `password`. +- **Change form:** adding `password` to the new fields here gives three identical `password` fields, and the manager stops offering to save. +- **Hidden profile:** these fields carry no `ContentType`, so no password manager offers to save them. A stored entry would reveal that the hidden profile exists. `AndroidAutofillManager.onEndApplyChanges` commits the session, which raises the save prompt, only after the last node carrying `ContentType` leaves the screen (`isRelatedToAutoCommit` = has `ContentType`), so leaving these screens never commits. Compose 1.8.2 cannot mark a node not important for autofill (`populate` never calls `setImportantForAutofill`). The fields still carry `password()`, so a manager may still offer to fill them. + +The app never calls `AutofillManager.commit()`. Committing on submit was tried and reverted: it ended the session while the fields were still shown, and autofill stayed dead until the app restarted. + +## Verification + +- **Semantics** (desktop Compose test `~/passphrase-review/semtest`, a two-field A/B): with the fix the node has `ContentType`, `Password = true`, and no `CopyText`. Without the fix it has no `ContentType`, `Password = false`, and `CopyText` present. `EditableText` is masked either way; autofill never received the raw value. +- **Device, structure** (stub `AutofillService` dumping `AssistStructure`, `~/passphrase-review/harness`, on an earlier `newPassword`-only build): the passphrase fields arrived as `EditText` with `inputType=0x81` and their hints. +- **Device, KeePassDX:** the hint combinations in the table were settled by testing with KeePassDX. It fills and offers to save on the change and first-passphrase forms, and the save prompt appears when the screen is closed. +- **Not yet checked on a device:** the hidden-profile screens (no `ContentType`) showing no save prompt. + +## Limitations + +- KeePassDX offers to fill only the last password field in the form (Confirm rather than New). New and Confirm emit identical hints and `inputType`, so this has to be fixed in KeePassDX. +- The framework still commits when the activity finishes (`AutofillManager.onActivityFinishing`) unless the service set `FLAG_DONT_SAVE_ON_FINISH`. A session opened by focusing a hidden-profile field could therefore still prompt to save if the activity finishes before that session ends. +- With a hardware keyboard or mouse, Ctrl+C and the Android right-click menu still copy, because those paths don't check the transformation (an upstream Compose gap). Only `BasicSecureTextField` blocks copying outright. +- The Compose text-toolbar "Autofill" item does nothing for the legacy `BasicTextField`, because `requestAutofillAction` is never assigned. Also upstream. + +## Out of scope + +- `DefaultConfigurableTextField` (`DefaultBasicTextField.kt:163–166`), which backs the SOCKS proxy password (`NetworkAndServers.kt:525`), has the same lambda and no `ContentType`. It has the same clipboard exposure, and the same two-line fix applies. +- iOS `PassphraseField` (`DatabaseEncryptionView.swift:304`) is a `SecureField` without `.textContentType`, which is the same gap. diff --git a/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md b/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md new file mode 100644 index 0000000000..672e59aed5 --- /dev/null +++ b/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md @@ -0,0 +1,60 @@ +# Fix attachment options hidden under the keyboard + +## Problem + +On Android, when the keyboard is already open and the user taps the attachment +(paperclip) button in a chat, the sheet with the Camera / Image / Video / File +options opens partly or fully under the keyboard. With the keyboard closed it opens +correctly. + +## Cause + +The sheet is a Material `ModalBottomSheetLayout` (`ChatView.kt`, `ChatLayout`) whose +state is created in `ChatView` as +`rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)`, leaving +`skipHalfExpanded` at its default `false`. + +The sheet content, `ChooseAttachmentView`, pads itself with +`.navigationBarsPadding().imePadding()`, so while the keyboard is open the sheet's +height is the buttons row plus the keyboard height, and the buttons are at the top of +the sheet. + +In Material 1.8.2 (`androidx.compose.material:material`, `ModalBottomSheet.kt`): + +- `modalBottomSheetAnchors` adds a `HalfExpanded` anchor at `fullHeight / 2` whenever + `!isSkipHalfExpanded && sheetSize.height > fullHeight / 2`; +- `show()` animates a hidden sheet to `HalfExpanded` when that anchor exists, and to + `Expanded` otherwise. + +With the keyboard closed the sheet is short, there is no `HalfExpanded` anchor, and +it opens `Expanded`. With the keyboard open the sheet is taller than half the +layout, so it stops at `HalfExpanded`: its top is at the middle of the screen, and +the buttons row below it lands under the keyboard's top edge. + +## Fix + +Pass `skipHalfExpanded = true` to the attachment sheet's +`rememberModalBottomSheetState`. `show()` then always goes to `Expanded`, so the +sheet's bottom sits at the bottom of the layout and the `imePadding` places the +buttons directly above the keyboard. + +The half-expanded state has no use for this sheet: it holds a single row of buttons +and no scrollable content. + +## Scope / non-goals + +- Only the chat attachment sheet is changed. The other `ModalBottomSheetLayout`s + (`GroupProfileView`, `UserProfileView`, `AddGroupView`, `AddChannelView`, + `WelcomeView`) use the same default state but are not part of this report. +- The two preview usages of `rememberModalBottomSheetState` in `ChatView.kt` are + left as they are. +- Desktop: the attachment sheet is shared code, but desktop has no software + keyboard inset, so the sheet is never taller than half the window and behaviour + is unchanged. +- iOS was not checked; it does not use this code. + +## Verification + +Debug arm64 APK built from this branch; on a device, open the keyboard in a chat +and tap the attachment button: the options are shown directly above the keyboard. +With the keyboard closed the sheet opens as before. diff --git a/plans/2026-09-26-free-core-strings-in-jni.md b/plans/2026-09-26-free-core-strings-in-jni.md new file mode 100644 index 0000000000..effd2374a7 --- /dev/null +++ b/plans/2026-09-26-free-core-strings-in-jni.md @@ -0,0 +1,103 @@ +# Free strings returned by the core in the JNI bindings + +## Problem + +On desktop and Android, resident memory grows for as long as the app runs and is +only released by restarting it. On a profile with ~200k connections it reaches +several GB within a day; the growth continues while the app is idle. + +None of it is visible to either heap tool. The JVM heap stays small, and the +Haskell heap accounts for its own region only — the memory is in the C allocator, +which neither profiler walks. + +## Cause + +Every `chat_*` entry point returns a string that the core allocates with `malloc` +and hands to the caller. `Simplex.Chat.Mobile.Shared` is explicit about it: + +```haskell +newCStringFromLazyBS :: LB.ByteString -> IO CString +newCStringFromLazyBS s = do + ... + buf <- mallocBytes (len + 1) +``` + +The caller owns that buffer. iOS honours the contract — `SimpleXChat/API.swift` +calls `free(c)` after copying each response into a Swift string. The two JNI +bindings never do. + +Both of them copy the bytes into a Java string and then drop the pointer: + +- desktop `decode_to_utf8_string` wraps the buffer in a `DirectByteBuffer`, + decodes it through `Charset.decode`, returns the resulting `String`, and + deletes only the JNI local references; +- Android calls `NewStringUTF(env, chat_*())` inline at every entry point, so the + pointer is never even bound to a variable. + +`chatReadFile` leaks the same way on both platforms, after `SetByteArrayRegion` +has already copied the payload. + +So every command, every received event, and every file read leaks its whole +response. Nothing bounds it: the buffers are unreachable from both runtimes, the +Java string is a copy, and no code path retains the original. + +### Measurements + +A direct-FFI harness calling `chat_send_cmd_retry` in a loop, outside the JVM, +separates the two cases cleanly: + +| harness | behaviour | +|---|---| +| response pointer dropped (current bindings) | +5 KB per call, linear, no plateau | +| response pointer freed | rises to 138 MB and stays flat | + +Per interface event the mean response is ~3.9 KB, so the rate follows activity +rather than connection count. + +On a desktop client running the affected profile, `/proc//smaps` attributes +roughly 1.0 GB to glibc's non-main arenas — sixteen mappings of 63-64 MB, which is +`HEAP_MAX_SIZE` on x86-64, aligned to 64 MB boundaries. Those pages are counted as +touched, not merely reserved. Over the ~10 hours of uptime that is on the order of +100 MB/hour, against a Haskell heap of 1.70 GB and a JVM heap of 328 MB in the +same process. + +## Fix + +Free each buffer once its contents have been copied. + +Desktop already has a single funnel, `decode_to_utf8_string`, which every entry +point goes through, so one `free` covers all of them. Android has no such funnel +because `NewStringUTF` is called inline, so the fix adds `decode_and_free` and +routes the sixteen call sites through it. `chatReadFile` frees its buffer directly +on both platforms. + +Android cannot include `stdlib.h` — the file defines a `reallocarray` stub that +conflicts with it — so `free` is declared on its own. + +The change is confined to the two binding files and does not alter what either +function returns. + +### Why this is safe + +In every case the copy is complete before the buffer is freed: + +- `Charset.decode` produces a `CharBuffer` whose backing array is freshly + allocated, and `toString` copies again into the `String`; +- `NewStringUTF` copies into a new Java string; +- `SetByteArrayRegion` copies into the `byte[]` before either `free` runs. + +Nothing retains the original pointer. On desktop the `DirectByteBuffer` is a view +over it, but it is a JNI local reference deleted in the same function and never +escapes to Java. + +Note that the *input* direction was already correct by accident: the bindings pass +the `malloc`ed buffers from `encode_to_utf8_chars` to `ReleaseStringUTFChars`, +which is not the matching deallocator but reaches `free` through HotSpot's +`FreeHeap`. That mismatch is left alone here. + +## Verification + +- Desktop AppImage with the fix, on a fresh profile: flat at 806-808 MB RSS + through 35 minutes of traffic followed by 10 minutes idle. +- Both `free` calls confirmed in the shipped binary by disassembling + `libapp-lib.so` out of the AppImage. diff --git a/plans/2026-09-27-android-desktop-storage-view.md b/plans/2026-09-27-android-desktop-storage-view.md new file mode 100644 index 0000000000..fbeb1be6f8 --- /dev/null +++ b/plans/2026-09-27-android-desktop-storage-view.md @@ -0,0 +1,53 @@ +# Android, desktop: storage breakdown in developer options + +## Problem + +When the Android or desktop app takes a lot of space, there is no way to see from inside the app what is using it. The Database screen shows only the count and size of received files (`directoryFileCountAndSize(appFilesDir.absolutePath)`, `Utils.kt`), and that helper is not recursive: it adds up `File.length()` of the direct children only. + +The databases, their `-wal`/`.bak` copies, wallpapers, core temp files, migration leftovers, remote-host data and preferences are all invisible. On one desktop profile, for example, the `.bak` copies left by database migrations were 5.6 MB of the 13 MB of database files. + +## Cause + +iOS has had this screen since #5529 (`apps/ios/Shared/Views/UserSettings/StorageView.swift`, under Developer options), but it was never ported to Kotlin. + +## Fix + +A Storage item in Developer options (shown only when "Show developer options" is on) opens `StorageView`. The screen lists each top-level entry of the app's storage folders with its recursive size, largest first, and a total per folder. + +Folders measured: `dataDir`, `preferencesDir` and `tmpDir`. Any folder that is the same as, or inside, another one is dropped, so nothing is counted twice: + +| Platform | Folders shown | +|---|---| +| Android | `dataDir` only. `shared_prefs` and `app_temp` are inside it, and so are the databases, `files/`, `cache/` (exports) and `app_temp/remote_hosts`. | +| Linux, macOS | `$XDG_DATA_HOME/simplex`, `$XDG_CONFIG_HOME/simplex`, `java.io.tmpdir/simplex` | +| Windows | `%AppData%\SimpleX` (config and data are the same folder), `java.io.tmpdir\simplex` (usually `%TEMP%`) | + +Like iOS, it shows whatever entries actually exist rather than named categories. Named categories would need to be kept in sync with the path helpers and would hide anything unexpected, and unexpected entries (such as `.bak` files) are what this screen is for. + +It improves on iOS in four ways: + +- It measures on `Dispatchers.IO`; iOS measures on the main thread in `.onAppear`. +- It sorts rows by size; iOS iterates a dictionary, so the order is random. +- It shows a total per folder. +- A file or folder inside the walk that cannot be read is logged and skipped, and a storage folder that cannot be listed shows as empty. On iOS, a failure to read one entry's attributes ends the whole walk. + +The walk uses `Files.walkFileTree` (API 26 = minSdk) without `FOLLOW_LINKS`, as iOS's `FileManager.enumerator` does not follow links either: a symlink is counted as the link itself, so it cannot loop, escape to `/` (Windows directory junctions are not treated as links by the JDK, but the app never creates them), or (on Android) count the APK's native libraries through a `lib` link that the system may create in the data folder. + +Sizes are file lengths (`BasicFileAttributes.size()`). iOS uses allocated size, which has no portable equivalent on the JVM, and length is what the existing Database screen reports. + +## Verification + +- A standalone harness copying the walk logic, run against: + - a symlink loop and a link to `/`: not followed + - an unreadable folder: logged, the walk continues + - a nested folder and a duplicate folder: dropped + - a missing folder: shown as empty +- On a real 187 MB desktop profile, the walk took 0.8 s and matched `du -sb` minus the 4 KB that `du` also counts for each directory. +- A desktop AppImage run on a separate test profile showed all three folders, with sizes matching `ls`. + +Top-level entries are listed with `Files.newDirectoryStream` rather than `File.listFiles`, so each name stays a `Path` built from the raw bytes. Going through `String` and back with `File.toPath()` loses names that are not valid in the JVM's file-name encoding (any non-ASCII name under a C locale on Linux, e.g. a received file decrypted into `tmpDir`), which then either throws `InvalidPathException` or points to a file that does not exist. A root that cannot be listed (missing or unreadable) is logged and shown as empty. + +## Not included + +- The desktop Postgres build keeps its database on the Postgres server, so it is not in the total. +- Leaving the screen does not cancel a walk in progress. The result is simply discarded. diff --git a/plans/2026-10-10-desktop-address-no-profile-crash.md b/plans/2026-10-10-desktop-address-no-profile-crash.md new file mode 100644 index 0000000000..c51eeaff04 --- /dev/null +++ b/plans/2026-10-10-desktop-address-no-profile-crash.md @@ -0,0 +1,99 @@ +# Crash in the SimpleX address screen when there is no profile + +## Problem + +A Windows user who uses the desktop app only through a linked mobile reported this crash: + +``` +java.lang.NullPointerException + at chat.simplex.common.views.chatlist.ComposableSingletons$UserPickerKt$lambda$-1608374373$1.invoke(UserPicker.kt:223) + at chat.simplex.common.views.chatlist.UserPickerKt$UserPicker$6$showCustomModal$1$1$1.invoke(UserPicker.kt:157) + at chat.simplex.common.views.helpers.ModalManager$showInView$4$1$1.invoke(ModalView.kt:218) +``` + +The "SimpleX address" screen crashes whenever it is shown while there is no active user. + +## Solution summary + +When there is no current user, the address modal closes itself instead of composing `UserAddressView`. It closes only while it is the top modal, so it does not close a different screen. The new `ModalManager.isLastModal` checks that. + +The "Chat preferences" row, which in paths 1 and 2 below left an invisible modal instead of crashing, closes itself the same way. + +## Technical design + +### Cause + +The "SimpleX address" row of the user picker opens `UserAddressView` with `shareViaProfile = it.currentUser.value!!.addressShared`. The modal content reads `currentUser`, so it recomposes whenever `currentUser` changes. It throws whenever it is composed while `currentUser` is null, that is, when there is no active user. A desktop that was only used with a linked mobile has no active user of its own. + +The screen can be shown in that state in three ways: + +1. **The mobile disconnects.** The screen was opened for the mobile's profile, and then the mobile disconnects. `switchUIRemoteHost(null)` sets `currentUser` to the local user, which is null, and the left-panel modals stay open. + - If the address screen is on top, it crashes when it recomposes. + - If another screen is on top of it, it crashes when the user goes back to it. + + `UserAddressView` closes itself when the user changes (`KeyChangeEffect` on the user), but with a null user it is never reached. +2. **The row is clicked with no active user.** With no active profile, the user picker still shows the "Create SimpleX address" row. With no profile and no mobile in use, the picker even opens by itself, at startup or when the state begins while no left-panel screen is open (`App.kt`, `desktopNoUserNoRemote`). Clicking the row crashes immediately. + + At startup, no profile and no connected mobile needs a mobile that was linked before; with no linked mobile, startup shows onboarding instead (`Core.kt`). + + No active user also follows from deleting the active profile when no other visible profile remains, including the only profile, and including the last visible profile of a connected mobile. `doRemoveUser` (`UserProfilesView.kt`) then calls `changeActiveUser_` with no user, which sets `currentUser` from `apiGetActiveUser`. After the deletion there is no active user, so it is null. + +3. **The self-destruct passcode is entered while the screen is open.** `deleteStorageAndRestart` (`LocalAuthView.kt`) calls `reinitChatController`. That calls `initChatController`, which sets `currentUser` from the new empty database to null (`Core.kt`). Only later does `deleteStorageAndRestart` create the new profile and call `closeAllModalsEverywhere`. + - On Android, the shared modal stack stays composed under the lock screen, so the address screen can recompose with a null user and throw. It throws only if a frame falls after `currentUser` becomes null and before onboarding replaces the main screen, so it does not happen on every run. Android's main loop catches exceptions (`SimplexApp.kt`). `GlobalExceptionsHandler` (`UI.android.kt`) then closes the top modal, recreates the activity and posts a crash alert, and the self-destruct flow hides all alerts at its end, so no crash is visible. + - On desktop, `initChatController` replaces the main screen with the splash screen shortly afterwards (`localUserCreated = null`), so the crash needs a frame to fall in that short window. This was not tested. + +The "Chat preferences" row has the same cause without the crash. It opens `PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)`, so with no active user the modal content returns early. `PreferencesView` closes itself when the user changes (`KeyChangeEffect` on the user, `Preferences.kt`), but with a null user it is never reached either. The modal stays on the left-panel stack with nothing drawn and no back button, in paths 1 and 2 above. + +### Self-closing modal + +Both rows use the same effect. `ModalManager.closeModal` closes the top modal that is not already being removed, whichever modal calls it. So the effect closes only when `ModalManager.isLastModal(data)` reports that this modal is that one. + +The effect is keyed on the modal and on the modal count of `ModalManager.start` (the left panel on desktop, the shared stack on Android). So it checks again when the count changes, or when its composition is reused for another modal, while this content is still composed. Other than the top modal, a modal stays composed only while it animates out (250 ms, longer if the animation is interrupted). For example, a quick second click on the row reuses the composition of the first modal, which is still animating out. + +A modal that is covered after an animation ends is disposed. It is composed afresh when it becomes the top again, for example when the user goes back to it, and the effect runs then. + +With the `isLastModal` check, the effect does not close a different screen, such as one opened just before or just after the address screen during an animation. This holds for stack changes on the main thread. As before, `ModalManager` is not synchronized with background calls such as `closeAllModalsEverywhere`. + +### Visible behaviour + +When there is no active user: +- Clicking either row now only hides the picker, as opening any modal does. The modal closes itself, and the picker is not reopened. +- After the mobile disconnects with the address or preferences screen open, the picker does not reopen by itself (observed in testing). When the state with no user and no mobile in use begins, the app checks for open left-panel modals, and the modal closes itself only after that check. Master does the same when any other left-panel screen is open. + +The change is in common code. On Android the user picker is not reachable without an active user, because deleting the last visible profile returns to onboarding. In the self-destruct case on Android, the screen can no longer throw, so `GlobalExceptionsHandler` does not have to recover. With an active user, behaviour does not change on any platform. + +### Security + +The change is UI-only and crosses no trust boundary: it reads local state and closes a local screen. It sends nothing and changes no stored data. + +### Rejected alternative + +An early `return@showCustomModal`, as the "Chat preferences" row did, would avoid the crash. But it would leave an invisible modal on the left-panel stack, with no back button. It stays until something closes the left-panel modals, such as a click on the centre panel. If a profile is created first, it comes back as the address screen. The "Chat preferences" row has this behaviour on master; this change gives it the self-closing effect too. + +## Implementation steps + +1. `views/helpers/ModalView.kt`: add `ModalManager.isLastModal(data)`, true when `data` belongs to the top modal that is not being removed. +2. `views/chatlist/UserPicker.kt`: in the "SimpleX address" row, compose `UserAddressView` only when `currentUser` is not null. Otherwise run `LaunchedEffect(modal, ModalManager.start.modalCount.value)`, which closes the modal when `isLastModal(modal)` is true. Do the same in the "Chat preferences" row for `PreferencesView`. +3. Documentation, under `apps/multiplatform/`: + - `spec/client/navigation.md`: list `isLastModal` among the `ModalManager` methods. + - `spec/client/chat-list.md`, UserPicker section: describe the address and preferences rows closing themselves with no active user. + - `product/views/user-profiles.md`: describe both rows with no active profile. + +## Verification + +Desktop AppImages from master and from this branch, each with a fresh database, unless stated otherwise. The v7.1.0-beta.6 CLI (core v7.1.0.10) acts as the linked mobile (`/connect remote ctrl`). "Leftover" is checked by creating a profile afterwards: a leftover modal reappears as the address screen when onboarding finishes. + +- master (at the merge base), case 1: NPE at `UserPicker.kt:223`. +- master (at the merge base), case 2: NPE at `UserPicker.kt:223` with the reported stack (`showCustomModal` 157, `ModalView.kt:218`). +- This branch, case 1 with the address screen on top: no crash, and the screen closes. +- This branch, case 1 with the "Address or 1-time link?" screen (opened from "SimpleX address or 1-time link?") on top: no crash. That screen stays open after the disconnect, as on master. Going back closes the address screen and shows the chat list. +- This branch, case 2, double click (seven runs, 8 to 40 ms apart): no crash, and no leftover. With `LaunchedEffect(Unit)` the address screen reappeared after onboarding in 2 of 3 runs at 30 ms. +- This branch, case 2, "Settings" then "Create SimpleX address" 100 ms apart: no crash, and Settings stays open. With the close keyed on the modal count but without the `isLastModal` check, Settings was closed too. +- This branch, with a local profile: the address screen opens and closes normally. When the mobile disconnects with its address screen open, the screen closes through `UserAddressView`'s own user-change effect, and the app returns to the local profile. +- "Chat preferences", desktop with no profile, the same CLI linked as the mobile: + - Preferences open, then the mobile disconnects. Before this change the screen disappeared, and after a profile was created it came back as "Your preferences" over the chat. With this change it does not come back, and there is no crash. + - Row clicked with no profile and no mobile: nothing opens, and nothing comes back after a profile is created. + - With a profile: the screen opens and closes normally. +- Android (Waydroid: Android 13 with arm64 translation), debug APKs of master and of this branch, fresh app data, case 3 with the address screen opened from the user picker: + - master, two runs: in one, logcat shows `App crashed, thread name: main` with the NPE at `UserPicker.kt:223`; in the other, no exception. No crash is visible in either, as in an earlier manual test. + - This branch: one run completed to the new profile with no exception in logcat. As master did not throw on every run, this checks for no new failure rather than proving the fix. An earlier run hung during self-destruct, with the main thread, a coroutine thread and a Haskell worker all waiting for the native allocator lock under arm64 translation. diff --git a/plans/2026-10-10-invite-member-via-group.md b/plans/2026-10-10-invite-member-via-group.md new file mode 100644 index 0000000000..ecc9ab06af --- /dev/null +++ b/plans/2026-10-10-invite-member-via-group.md @@ -0,0 +1,612 @@ +# Inviting group members to other groups: implementation plan + +RFC: `docs/rfcs/2026-10-10-invite-member-via-group.md`. + +Branch: `ep/group-inv`. + +Line numbers: `d93fb626b` (`ep/group-inv` after merging `master`), before any change in this plan. + +## 1. Protocol version + +`src/Simplex/Chat/Protocol.hs`. + +Version 22 is shared with owner-signed relay invitations. + +Version history comment, after line 89: + +```haskell +-- 21 - command keywords with any characters (2026-09-27) +-- 22 - owner-signed relay invitations, group invitations via member connections (2026-10-10) +``` + +`currentChatVersion` (line 95): unchanged, `VersionChat 22`. + +`signedRelayInvVersion` (lines 146-147) renamed: + +```haskell +groupInvVersion :: VersionChat +groupInvVersion = VersionChat 22 +``` + +Uses: + +| File | Location | Use | +|---|---|---| +| `src/Simplex/Chat/Library/Subscriber.hs` | line 1657 | `xGrpRelayInv`, renamed reference | +| `src/Simplex/Chat/Library/Commands.hs` | section 5.1 | `APIAddMemberViaGroup` | + +Event types, tags, parsing and encoding: unchanged. + +## 2. Migration + +Files: + +- `src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs` +- `src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs` + +SQLite up: + +```sql +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +``` + +Postgres up: + +```sql +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +``` + +Down, both: + +```sql +DROP INDEX idx_group_members_invited_via_group_member_id; +ALTER TABLE group_members DROP COLUMN invited_via_group_member_id; +``` + +Registration: + +| File | Import | List entry | +|---|---|---| +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | after line 179 | after line 356 | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | after line 56 | after line 110 | +| `simplex-chat.cabal` | after line 171 (Postgres) | after line 349 (SQLite) | + +Regenerated by tests: + +| File | Test | +|---|---| +| `src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql` | "Schema dump" | +| `src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt` | "Save query plans", last in the full test run | +| `src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql` | "Postgres schema dump", Postgres build; edited by hand | + +## 3. Command, response and event types + +`src/Simplex/Chat/Controller.hs`. + +`ChatCommand`, after line 467: + +```haskell + | APIAddMemberViaGroup {groupId :: GroupId, viaGroupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole} +``` + +`ChatResponse`, after line 879: + +```haskell + | CRSentGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, member :: GroupMember, viaGroupInfo :: GroupInfo, viaMember :: GroupMember} +``` + +`ChatEvent`, after line 1030: + +```haskell + | CEvtReceivedGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, viaGroupInfo :: GroupInfo, viaMember :: GroupMember, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} +``` + +`APIAddMember`, `CRSentGroupInvitation` and `CEvtReceivedGroupInvitation`: unchanged. + +## 4. Store + +`src/Simplex/Chat/Store/Groups.hs`. + +### 4.1. `createGroupInvitation` (line 458) + +```haskell +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Either Contact GroupMember -> VersionRangeChat -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) +createGroupInvitation db cxt user@User {userId} inviter peerChatVRange GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do +``` + +Equation for `activeConn = Nothing` (line 459): removed. +Peer range: parameter, from the inviter connection. + +| Line | `Left ct` | `Right m` | +|---|---|---| +| 504 | `createContactMemberInv_`, unchanged | host row, see below | +| 505 | membership `invitedBy = IBContact contactId` | membership `invitedBy = IBUnknown` | + +Lines 504-505: + +```haskell + (groupMemberId, invitedBy) <- case inviter of + Left contact@Contact {contactId} -> do + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange + pure (groupMemberId, IBContact contactId) + Right GroupMember {memberProfile} -> do + (hostName, memProfileId, hostProfile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing currentTs + let MemberIdRole {memberId, memberRole} = fromMember + host = + NewGroupMember + { memInfo = MemberInfo memberId memberRole (Just $ ChatVersionRange hostVRange) hostProfile fromMemberKey, + memCategory = GCHostMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUnknown, + memInvitedByGroupMemberId = Nothing, + localDisplayName = hostName, + memContactId = Nothing, + memProfileId + } + GroupMember {groupMemberId} <- createNewMember_ db user groupId host badgeVerified currentTs + pure (groupMemberId, IBUnknown) + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited invitedBy incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) +``` + +Branch for an existing group (`getInvitationGroupId_`): unchanged. + +`createNewMember_` (line 2457): `GroupInfo` parameter → `GroupId`. +Calls in `createNewGroupMember` (line 2444) and `createIntroReMember` (line 2642): `(groupId' gInfo)`. + +### 4.2. `createNewMemberViaGroup` + +New function, after `createNewContactMember` (line 1379), before `createGroupRelayRecord` (line 1439). + +```haskell +createNewMemberViaGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupInfo -> GroupMember -> Connection -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewMemberViaGroup db gVar cxt user@User {userId} GroupInfo {groupId, membership} viaMember@GroupMember {memberProfile} Connection {connChatVersion, peerChatVRange} memberRole agentConnId connRequest subMode = + createWithRandomId' db gVar $ \memId -> runExceptT $ do + createdAt <- liftIO getCurrentTime + (localDisplayName, memProfileId, profile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing createdAt + let newMember = + NewGroupMember + { memInfo = MemberInfo (MemberId memId) memberRole (Just $ ChatVersionRange peerChatVRange) profile Nothing, + memCategory = GCInviteeMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUser, + memInvitedByGroupMemberId = Just $ groupMemberId' membership, + localDisplayName, + memContactId = Nothing, + memProfileId + } + member@GroupMember {groupMemberId} <- createNewMember_ db user groupId newMember badgeVerified createdAt + liftIO $ do + DB.execute + db + "UPDATE group_members SET sent_inv_queue_info = ?, invited_via_group_member_id = ? WHERE group_member_id = ?" + (connRequest, groupMemberId' viaMember, groupMemberId) + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + pure member +``` + +### 4.3. `getMemberInvitedVia` + +New function, after `getMemberInvitation` (line 2237). + +```haskell +getMemberInvitedVia :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> IO (Maybe GroupMember) +getMemberInvitedVia db cxt user@User {userId} GroupInfo {groupId} viaMember = do + currentTs <- getCurrentTime + maybeFirstRow (toContactMember currentTs cxt user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.invited_via_group_member_id = ? AND m.member_status NOT IN (?,?,?)") + (userId, groupId, groupMemberId' viaMember, GSMemRejected, GSMemRemoved, GSMemLeft) +``` + +Excluded statuses: as `contactMember` (`Commands.hs` line 4008). + +## 5. Sending + +`src/Simplex/Chat/Library/Commands.hs`. + +### 5.1. `APIAddMemberViaGroup` handler + +After `APIAddMember` (line 2814), before `APIJoinGroup` (line 2844). + +```haskell + APIAddMemberViaGroup groupId viaGroupId gmId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do + (g@(GIK gInfo _), viaGInfo, m) <- withFastStore $ \db -> + (,,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupInfo db cxt user viaGroupId <*> getGroupMember db cxt user viaGroupId gmId + let GroupMember {localDisplayName = mName} = m + when (viaGroupId == groupId) $ throwChatError $ CEGroupDuplicateMember mName + when (useRelays' gInfo) $ throwCmdError "can't invite member to channel" + when (useRelays' viaGInfo) $ throwCmdError "can't invite member from channel" + assertUserGroupRole gInfo $ max GRAdmin memRole + assertUserGroupRole viaGInfo GRAuthor + unless (groupFeatureUserAllowed SGFDirectMessages viaGInfo) $ throwCmdError "direct messages not allowed" + when (incognitoMembership viaGInfo) $ throwChatError CEContactIncognitoCantInvite + when (incognitoMembership gInfo) $ throwChatError CEGroupIncognitoCantInvite + mConn <- case memberConn m of + Just conn | memberCurrent m -> pure conn + _ -> throwChatError CEGroupMemberNotActive + unless (m `supportsVersion` groupInvVersion) $ throwChatError CEPeerChatVRangeIncompatible + let sendInvitation = sendGrpInvitationViaGroup user viaGInfo m mConn g + withFastStore' (\db -> getMemberInvitedVia db cxt user gInfo m) >>= \case + Nothing -> do + gVar <- asks random + subMode <- chatReadVar subscriptionMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff True subMode + member <- withFastStore $ \db -> createNewMemberViaGroup db gVar cxt user gInfo m mConn memRole agentConnId cReq subMode + sendInvitation member cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member viaGInfo m + Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} + | memberStatus == GSMemInvited -> do + unless (mRole == memRole) $ withFastStore' $ \db -> updateGroupMemberRole db user member memRole + withFastStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case + Just cReq -> do + sendInvitation member {memberRole = memRole} cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member {memberRole = memRole} viaGInfo m + Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo mName + | otherwise -> throwChatError $ CEGroupDuplicateMember mName +``` + +### 5.2. `sendGrpInvitationViaGroup` + +New function, after `sendGrpInvitation` (line 4301). +`sendGrpInvitation`: unchanged. +`localDisplayName` in the item: name of the member in group A. + +```haskell + sendGrpInvitationViaGroup :: User -> GroupInfo -> GroupMember -> Connection -> GroupInfoKeys -> GroupMember -> ConnReqInvitation -> CM () + sendGrpInvitationViaGroup user viaGInfo GroupMember {localDisplayName} mConn (GIK gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} gks) GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberKey = Just $ groupMemberKey gks, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + business = businessChat, + groupLinkId = Nothing, + groupSize = Just currentMemCount + } + (msg, _, _) <- sendDirectMemberMessage mConn (XGrpInv groupInv) (groupId' viaGInfo) + let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + ci <- saveSndChatItem user (CDGroupSnd viaGInfo Nothing) msg content + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat viaGInfo Nothing) ci] +``` + +### 5.3. Parser + +After line 6172: + +```haskell + "/_add #" *> (APIAddMemberViaGroup <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <*> memberRole), +``` + +## 6. Receiving + +`src/Simplex/Chat/Library/Subscriber.hs`. + +### 6.1. Group event dispatch + +After line 1123: + +```haskell + XGrpInv gInv -> Nothing <$ xGrpInv gInfo' m'' conn' gInv msg brokerTs +``` + +### 6.2. `processGroupInvitation` (line 2700) + +| Line | After | +|---|---| +| 2706 | `forM_ activeConn $ \conn@Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do` | +| 2711 | `(g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Left ct) peerChatVRange inv customUserProfileId memberKeys` | +| 2714-2726 | local `joinGroupAsync` and its comment: moved to 6.3 | +| 2734 | `joinGroupAsync g hostId conn connRequest (Just ct) True` | +| 2738 | `joinGroupAsync g hostId conn connRequest Nothing False` | + +### 6.3. `joinGroupAsync` + +Moved from `processGroupInvitation` to the `where` block of `processAgentMessageConn`, after `processGroupInvitation`. +Comment and body: unchanged. + +```haskell + joinGroupAsync :: GroupInfoKeys -> GroupMemberId -> Connection -> ConnReqInvitation -> Maybe Contact -> Bool -> CM () + joinGroupAsync (GIK gInfo@GroupInfo {groupId, membership} gks) hostId Connection {connId, connChatVersion, peerChatVRange} connRequest hostContact_ sameLink = do +``` + +### 6.4. `xGrpInv` + +New function, before `xGrpDirectInv` (line 3885). + +```haskell + xGrpInv :: GroupInfo -> GroupMember -> Connection -> GroupInvitation -> RcvMessage -> UTCTime -> CM () + xGrpInv viaGInfo m mConn@Connection {peerChatVRange} inv msg brokerTs + | useRelays' viaGInfo = messageError "x.grp.inv: not allowed in channels" + | isJust publicGroup = messageError "x.grp.inv: can't invite to channel" + | not (groupFeatureMemberAllowed SGFDirectMessages m viaGInfo) = messageError "x.grp.inv: direct messages not allowed" + | memberBlocked m = messageWarning "x.grp.inv: member is blocked (ignoring)" + | fromRole < GRAdmin || fromRole < memRole = messageError "x.grp.inv: inviting member role" + | fromMemId == memId = messageError "x.grp.inv: duplicate member ID" + | otherwise = do + memberKeys <- atomically . C.generateKeyPair =<< asks random + let incognitoProfileId = localProfileId <$> incognitoMembershipProfile viaGInfo + (g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Right m) peerChatVRange inv incognitoProfileId memberKeys + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) + let GroupMember {groupMemberId} = membership + createInvitationItem invStatus = do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv viaGInfo Nothing m) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + groupMsgToView cInfo ci + if isTrue (autoAcceptGroupInvitations user) + then when (memberStatus membership == GSMemInvited) $ do + joinGroupAsync g hostId mConn connRequest Nothing False + createInvitationItem CIGISAccepted + else do + createInvitationItem CIGISPending + toView CEvtReceivedGroupInvitationViaGroup {user, groupInfo = gInfo, viaGroupInfo = viaGInfo, viaMember = m, fromMemberRole = fromRole, memberRole = memRole} + where + GroupInvitation {fromMember = MemberIdRole fromMemId fromRole, invitedMember = MemberIdRole memId memRole, connRequest, groupProfile = GroupProfile {publicGroup}} = inv +``` + +### 6.5. Profiles after joining + +The profile copies from 4.1 and 4.2 are replaced during the connection handshake, by existing code: + +| Side | Message | Handler | +|---|---|---| +| inviting client | `x.grp.acpt` with the profile of the invited member | `processMemberProfileUpdate` (line 805) | +| invited client | `x.grp.mem.info` with the profile of the inviting member | `processMemberProfileUpdate` (line 866) | + +## 7. Joining and invitation status + +`src/Simplex/Chat/Library/Commands.hs`. + +### 7.1. `APIJoinGroup` (line 2844) + +Lines 2846-2848: + +```haskell + (invitation, ct_) <- withFastStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId + (inv,) <$> forM (memberContactId fromMember) (\_ -> getContactViaMember db cxt user fromMember) +``` + +Line 2850, `Contact {activeConn} = ct` replaced: + +```haskell + peerChatVRange_ = case ct_ of + Nothing -> Right $ memberChatVRange' fromMember + Just ct@Contact {activeConn} -> maybe (Left ct) (\Connection {peerChatVRange} -> Right peerChatVRange) activeConn +``` + +| Line | Before | After | +|---|---|---| +| 2851 | `case activeConn of` | `case peerChatVRange_ of` | +| 2852 | `Just Connection {peerChatVRange} -> do` | `Right peerChatVRange -> do` | +| 2875 | `Nothing -> throwChatError $ CEContactNotActive ct` | `Left ct -> throwChatError $ CEContactNotActive ct` | + +Lines 2853-2872: unchanged. + +Line 2873, missing invitation item ignored: + +```haskell + updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` \case + ChatErrorStore SEChatItemNotFoundByGroupId {} -> pure () + e -> eToView e +``` + +Invitation from a contact deleted before joining (`contact_id` set to NULL, `Store/Direct.hs` line 492): the member path is taken; master returned `SEContactNotFoundByMemberId`. +The invitation item is deleted with the contact (`Store/Direct.hs` line 483). + +### 7.2. `updateCIGroupInvitationStatus` (line 4776) + +Line 4778, item binding: `AChatItem _ _ cInfo ci@ChatItem {content, meta = meta@CIMeta {itemId}}`. + +New alternative, after line 4786. +Timer: as the direct alternative (lines 4783-4786), from the preferences of group A. + +```haskell + (GroupChat g _, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) + | status == CIGISPending -> do + let content' = CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole + timed_ <- sndGroupCITimed False g Nothing + ci' <- withFastStore' $ \db -> updateGroupChatItem db user (groupId' g) ci {meta = meta {itemTimed = timed_}} content' False False Nothing + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTGroup (groupId' g) Nothing, itemId) +``` + +## 8. Terminal + +### 8.1. `src/Simplex/Chat/View.hs` + +| Line | Change | +|---|---| +| after 210 | `CRSentGroupInvitationViaGroup u g _ viaG viaM -> ttyUser u ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyMember viaM <> " in " <> ttyGroup' viaG]` | +| 517 | `viewReceivedGroupInvitation g (ttyContact' c) r` | +| after 517 | `CEvtReceivedGroupInvitationViaGroup {user = u, groupInfo = g, viaGroupInfo = viaG, viaMember = m, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g (ttyMember m <> " in " <> ttyGroup' viaG) r` | +| 735 | `CISndGroupInvitation {} -> showSndItemProhibited to` removed | +| 746 | `CIRcvGroupInvitation {} \| isJust m_ -> showRcvItemProhibited from` removed | +| 1405 | `viewReceivedGroupInvitation` takes the inviter name as `StyledString` in place of `Contact` | + +Output for an invitation from a member: + +``` +#club: alice in #team invites you to join the group as member +use /j club to accept +``` + +### 8.2. `src/Simplex/Chat/Terminal/Output.hs` + +After line 200: + +```haskell + CEvtReceivedGroupInvitationViaGroup u g viaG m _ _ -> + when (groupNtf u viaG False) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewMemberName m <> "> ", "invited you to join the group") +``` + +### 8.3. `src/Simplex/Chat/Terminal/Input.hs` + +After line 84: + +```haskell + CRSentGroupInvitationViaGroup u g _ _ _ -> whenCurrUser cc u $ setActiveGroup ct g +``` + +## 9. Directory service + +Unchanged: `APIAddMember` and `CEvtReceivedGroupInvitation` are unchanged. + +## 10. Bots API + +### 10.1. Docs + +`bots/src/API/Docs/Commands.hs`, after line 121: + +```haskell + ("APIAddMemberViaGroup", [], "Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.", ["CRSentGroupInvitationViaGroup", "CRChatCmdError"], [TD "CEPeerChatVRangeIncompatible" "Member's client version is older than required"], Just UNInteractive, "/_add #" <> Param "groupId" <> " #" <> Param "viaGroupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), +``` + +`bots/src/API/Docs/Responses.hs`, after line 94: + +```haskell + ("CRSentGroupInvitationViaGroup", "Group invitation sent to member of another group"), +``` + +`bots/src/API/Docs/Events.hs`, after line 87: + +```haskell + ("CEvtReceivedGroupInvitationViaGroup", "Received group invitation from member of another group."), +``` + +Regenerated by `tests/APIDocs.hs`: + +- `bots/api/COMMANDS.md` +- `bots/api/EVENTS.md` +- `packages/simplex-chat-client/types/typescript/src/commands.ts` +- `packages/simplex-chat-client/types/typescript/src/responses.ts` +- `packages/simplex-chat-client/types/typescript/src/events.ts` +- `packages/simplex-chat-python/src/simplex_chat/types/_commands.py` +- `packages/simplex-chat-python/src/simplex_chat/types/_responses.py` +- `packages/simplex-chat-python/src/simplex_chat/types/_events.py` + +### 10.2. Wrappers + +New wrapper `apiAddMemberViaGroup`, after `apiAddMember`: + +| File | After line | Function | +|---|---|---| +| `packages/simplex-chat-nodejs/src/api.ts` | 565 | `apiAddMemberViaGroup(groupId, viaGroupId, groupMemberId, memberRole): Promise` | +| `packages/simplex-chat-python/src/simplex_chat/api.py` | 352 | `api_add_member_via_group(group_id, via_group_id, group_member_id, member_role)` | +| `packages/simplex-chat-client/typescript/src/client.ts` | 262 | `apiAddMemberViaGroup(groupId, viaGroupId, groupMemberId, memberRole)` | + +Command: `CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})`. +Response check: type `sentGroupInvitationViaGroup`, result `member`. +JSDoc in `api.ts`: the command description from 10.1, then "Network usage: interactive." + +## 11. Apps + +Sending: unchanged. + +### 11.1. Kotlin + +`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`: + +| Line | Change | +|---|---| +| after 6817 | `@Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): CR()` | +| after 7017 | `is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup"` | +| after 7208 | `is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole")` | + +Handler, after line 3130: + +```kotlin + is CR.ReceivedGroupInvitationViaGroup -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + } + } + } +``` + +Card label: + +| File | Line | Change | +|---|---|---| +| `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` | after 1769 | `For you only` | +| `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` | after 1769 | `For %1$s only` | +| `.../views/chat/item/CIGroupInvitationView.kt` | after 31 | parameter `groupChat: Boolean = false,` | +| `.../views/chat/item/CIGroupInvitationView.kt` | after 113 | `if (groupChat) {` / `Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor)` / `}` | +| `.../views/chat/item/ChatItemView.kt` | 723, 727 | argument `groupChat = cInfo is ChatInfo.Group` | + +### 11.2. Swift + +`apps/ios/Shared/Model/AppAPITypes.swift`: + +| Line | Change | +|---|---| +| after 1193 | `case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole)` | +| after 1274 | `case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup"` | +| after 1358 | `case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)")` | + +Handler, `apps/ios/Shared/Model/SimpleXAPI.swift`, line 2708, second pattern in the `receivedGroupInvitation` case: + +```swift + case let .receivedGroupInvitation(user, groupInfo, _, _), + let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _): +``` + +Card label, `apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`, after line 41: + +```swift + if chat.chatInfo.groupInfo != nil { + Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: "")) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .overlay(DetermineWidth()) + } + +``` + +## 12. Protocol docs + +`docs/protocol/simplex-chat.md`, new paragraph after the `x.grp.inv` paragraph (line 233): + +``` +`x.grp.inv` message can also be sent to a group member via the member connection, to invite the member to another group. In this case, the message MUST only be sent when direct messages are allowed for the sender in the group of the member connection, and receiving clients MUST ignore it otherwise. +``` + +## 13. Tests + +`tests/ChatTests/Groups.hs`, new `describe "invite member via group"`, command `/_add # # member`: + +| Test | Setup | Expected | +|---|---|---| +| `testInviteMemberViaGroup` | alice owner of #team and #club; bob and cath in #team | invitation cards in #team; `cath ` and `/a`: unchanged; they cover the `APIAddMember` parser next to the new `/_add` alternative. + +`tests/APIDocs.hs`: run to regenerate the files in 10.1. + +## 14. Version + +Version 22 is not in any release: v7.1.0-beta.6 uses version 21. +If a release with version 22 ships before this feature, a separate constant with version 23 is added for this feature, and `currentChatVersion` is raised to 23. diff --git a/plans/2026-10-11-core-libs-releases.md b/plans/2026-10-11-core-libs-releases.md new file mode 100644 index 0000000000..e6f9a1347b --- /dev/null +++ b/plans/2026-10-11-core-libs-releases.md @@ -0,0 +1,182 @@ +# Core library releases: implementation plan + +Libraries for the Node.js package are released per core version, from the commit on `master` or `stable` that changes the version. + +## 1. Release identity + +``` +repository simplex-chat/simplex-chat-libs +tag core- +version version: field of simplex-chat.cabal, e.g. 7.1.0.10 +build push to master or stable, head commit changes version: compared with its first parent +source the head commit of that push +``` + +A version change that is not the head commit of its push is not released. + +Rule: a core version is final once a published package depends on it. +Its migrations and chat protocol versions stay unchanged. +A further change requires a new cabal version. + +## 2. Assets + +| Asset | Job | Backend | +|---|---|---| +| `simplex-chat-libs-linux-x86_64.zip` | `build-linux`, 22.04 x86_64 | sqlite | +| `simplex-chat-libs-linux-x86_64-postgres.zip` | `build-linux-postgres` | postgres | +| `simplex-chat-libs-linux-aarch64.zip` | `build-linux`, 22.04 aarch64 | sqlite | +| `simplex-chat-libs-macos-aarch64.zip` | `build-macos`, `macos-latest` | sqlite | +| `simplex-chat-libs-macos-x86_64.zip` | `build-macos`, `macos-15-intel` | sqlite | +| `simplex-chat-libs-windows-x86_64.zip` | `build-windows` | sqlite | + +Zip content: directory `libs/` with the top-level files of `apps/multiplatform/common/src/commonMain/cpp/desktop/libs/-/`. +Windows zip: also `libs/libsimplex.lib`. +Asset names: as built by `download-libs.js` (line 123). +Checksums: `digest` (sha256) of each asset in the GitHub release API. + +## 3. `build.yml` + +### 3.1. `maybe-release` + +The release is created before the build jobs; each build job uploads its assets, as for `v*` tags. + +``` +outputs libs_tag: tag of the created library release, empty otherwise +Clone project condition: push event; fetch-depth: 2 +``` + +Steps after "Create release": + +| Step | Condition | Action | +|---|---|---| +| Check version change | ref `master` or `stable` | output `version` when `version:` of `simplex-chat.cabal` differs from `HEAD^1:simplex-chat.cabal` | +| Clone libs repo | `version` set | checkout of simplex-chat-libs to `libs-repo`, token `NODEJS_REPO_TOKEN` | +| Commit and tag libs release | `version` set | commit and tag below; `git push --atomic origin HEAD `; output `tag` | +| Create libs release | `version` set | `softprops/action-gh-release@v2`, repository simplex-chat-libs, `tag_name` = `tag`, without files | + +Commit in simplex-chat-libs: + +``` +file core-release +content version: + commit: +author SimpleX Chat +message core- +tag core-, on this commit +``` + +`NODEJS_REPO_TOKEN`: a token of the build account; releases are attributed to the token owner. +Secrets comment: above `maybe-release`. + +A failure of `maybe-release` skips the build jobs, as for `v*` tags. + +### 3.2. Concurrency + +Workflow-level `concurrency`: removed. + +Job-level `concurrency` in `build-linux`, `build-macos`, `build-windows`: + +``` +group ${{ github.workflow }}-${{ github.ref }}---${{ needs.maybe-release.outputs.libs_tag }} +cancel-in-progress not a v* tag and libs_tag empty +``` + +Library release runs are never cancelled; other runs are cancelled by the next push to the same ref. + +### 3.3. Library steps + +| Job | Entries | Position | Build | +|---|---|---|---| +| `build-linux` | 22.04 x86_64 and 22.04 aarch64, GHC 9.6.3 (matrix key `libs: true`) | after "Build CLI" | `SKIP_VLC=1 scripts/desktop/build-lib-linux.sh` | +| `build-macos` | both (matrix key `arch`) | after "Run tests" | `SKIP_VLC=1 scripts/desktop/build-lib-mac.sh` | +| `build-windows` | `windows-latest` | after "Build CLI" | `SKIP_VLC=1 scripts/desktop/build-lib-windows.sh`; `dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll` | + +Each build step: staging in `release-libs//libs/`, then `zip -r` to `release-libs/.zip`. +Linux steps run in the build container. +Windows: `zip` added to the MSYS2 `install` list. + +Upload step, after the build steps of the job: + +``` +action softprops/action-gh-release@v2 +repository ${{ github.repository }}-libs +tag_name needs.maybe-release.outputs.libs_tag +files release-libs/*.zip +token secrets.NODEJS_REPO_TOKEN +``` + +Conditions: + +| Job | Build | Upload | +|---|---|---| +| `build-linux` | `libs_tag` set, `matrix.libs` | `libs_tag` set, `matrix.libs` | +| `build-macos` | `!cancelled()`, `libs_tag` set, CLI build succeeded | `!cancelled()`, library build succeeded | +| `build-windows` | `libs_tag` set | `libs_tag` set | + +A failed job leaves its assets absent; the other jobs upload theirs. +A re-run of a failed job uploads its assets to the same release; an asset of the same name is replaced. + +### 3.4. `build-linux-postgres` + +Edits of the existing job: + +``` +if needs.maybe-release.outputs.libs_tag, replaces the v* tag condition +Copy libs from container + files copied to postgres-libs/libs/ + zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs, in postgres-libs +Upload postgres library + replaces the artifact upload + softprops/action-gh-release@v2, files postgres-libs/*.zip, other inputs as in 3.3 +``` + +## 4. Scripts: `SKIP_VLC` + +With `SKIP_VLC` set, the VLC download is skipped. +Unset: unchanged. + +| File | Line | After | +|---|---|---| +| `scripts/desktop/build-lib-linux.sh` | 56 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-linux.sh` | +| `scripts/desktop/build-lib-mac.sh` | 154 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-mac.sh` | +| `scripts/desktop/build-lib-windows.sh` | 64 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-windows.sh` | + +## 5. `build.yml`: removed job + +`release-nodejs-libs`, with its header comment. + +## 6. Node.js package + +``` +download-libs.js RELEASE_TAG = 'core-', set in the Node.js release commit, once release core- exists +package.json version: Node.js release cycle +release commits RELEASE_TAG removed from their edits (author build@simplex.chat) +``` + +This change: `RELEASE_TAG` unchanged, `'v7.1.0-beta.6'`. + +Platforms: `linux-aarch64` added by the assets in section 2; `download-libs.js` maps `arm64` to `aarch64` (lines 46-50). +Postgres: Linux x86_64 only (lines 19-22). + +## 7. Precompiled add-on (`ep/node-add-on`) + +Add-on releases: separate from library releases, triggered by changes of the Node.js package version, designed in `ep/node-add-on`. + +Effects of this plan on `ep/node-add-on`: + +``` +release-nodejs-libs removed; its add-on upload edits move to the add-on release +RELEASE_TAG library release core-; add-on assets in a separate release +SHA256 library values from the asset digests of release core- +``` + +## 8. `build.yml` fix + +Line 531: `matrix.opnessl_dir` → `matrix.openssl_dir`. + +## 9. Verification + +1. Branch run of `build.yml` with temporary edits: `push` trigger and "Check version change" condition with the branch, tag `core-test-`, cabal version increment in the head commit of the push. +2. `npm install` of the Node.js package with `RELEASE_TAG = 'core-test-'` on Linux x86_64, Linux aarch64, macOS aarch64, macOS x86_64, Windows x86_64. +3. Postgres: `SIMPLEX_BACKEND=postgres npm install` on Linux x86_64. +4. Release `core-test-` and its tag deleted after verification. diff --git a/plans/2026-10-11-markdown-file-viewer.md b/plans/2026-10-11-markdown-file-viewer.md new file mode 100644 index 0000000000..c5152ea485 --- /dev/null +++ b/plans/2026-10-11-markdown-file-viewer.md @@ -0,0 +1,319 @@ +# Markdown file viewer: implementation plan + +Markdown files from chats are viewed in the Android, desktop and iOS apps, formatted or as text. + +## 1. Files that qualify + +By name and size only: + +``` +name file.fileName ends with .md or .markdown, case-insensitive +size file.fileSize <= MAX_MARKDOWN_VIEW_SIZE +``` + +``` +MAX_MARKDOWN_VIEW_SIZE 1_048_576 +Kotlin views/chat/item/MarkdownFileView.kt, private +iOS Shared/Views/Chat/ChatItem/MarkdownFileView.swift, private +``` + +Function, in each viewer file: + +``` +Kotlin fun isMarkdownFile(file: CIFile): Boolean +iOS func isMarkdownFile(_ file: CIFile) -> Bool +``` + +Other files, and Markdown files over the limit: tap actions unchanged. + +## 2. Tap and long press + +Tap on a qualifying file, every platform: + +``` +file status action +received or sent, file loaded viewer +desktop with a linked mobile loadRemoteFile, then viewer +RcvInvitation, RcvAborted reception started; viewer shown on completion, in the same place +other statuses as now: waiting alerts, error alerts +``` + +Long press: the existing Save item of the item menu (`ChatItemView.kt`, `ChatView.swift`). + +Places: + +``` +Kotlin CIFileView.kt, onClick and fileAction +iOS CIFileView.swift, fileAction +``` + +- Android: "Open with" of `SaveOrOpenFileMenu` is not shown for qualifying files; "Share" in the item menu stays. +- Present locally: `getLoadedFilePath(file)` (Kotlin), `getLoadedFileSource(file)` (iOS), as for other files. + +## 3. Open on completion + +In master (#7700), for images, voice messages, video and files. The viewer adds only the Markdown case to the file tap action. + +A tap that starts reception registers the file. On completion the tap action of the received file is performed: + +``` +image full screen view +voice playback +video playback; locally encrypted: decrypted first; chat list preview: full screen player +file Markdown: viewer + other: share sheet (iOS), open/save menu (Android), save dialog (desktop) +``` + +Kotlin, chat model: + +``` +fileToOpen file ID of the last tapped file + inChatList: tap in a chat list preview +openWhenReceived(file, inChatList) sets fileToOpen +receivedFileToOpen(file, inChatList) false unless fileToOpen has file.fileId and inChatList, and the file is loaded + then fileToOpen cleared + chat view: true + chat list preview: true on desktop, or when no chat is open +``` + +Item views: `OpenWhenReceivedEffect(file, inChatList, open)` (`CIFileView.kt`), on a change of `file.loaded`; in `CIImageView.kt`, `CIVoiceView.kt`, `CIVideoView.kt`, `CIFileView.kt`. + +iOS, chat model: + +``` +fileToOpen file ID of the last tapped file + chat ID at the tap; nil in the chat list + open callback of the tapped view +openWhenReceived(file, open) sets fileToOpen +receivedFileToOpen(file) fileToOpen has file.fileId, and the open chat is the chat at the tap +openReceivedFile(file) receivedFileToOpen rechecked; fileToOpen cleared; open(file) +``` + +Event `rcvFileComplete` (`SimpleXAPI.swift`): for the active user, when `receivedFileToOpen(file)`: `openReceivedFile(file)` on the main actor. + +`fileToOpen` is replaced by a later tap. + +## 4. Reading in memory + +The file is read and decoded off the main thread, before the viewer is shown. + +``` +Kotlin withLongRunningApi + encrypted: readCryptoFile(path, cryptoArgs) (CryptoFile.kt:36) + plain: File(path).readBytes() + text: UTF-8 CharsetDecoder, REPLACE with "?" + parsed: parseMarkdownFlow (renderer), once, before the viewer is shown +iOS Task + getFileData(path, cryptoArgs) (SimpleXChat/ImageUtils.swift:41) + text: Unicode.UTF8.ForwardParser, "?" for each invalid byte + html: MarkdownParser with an .html modifier, raw HTML escaped +``` + +Text decoding, as the core safeDecodeUtf8 (simplexmq Util.hs:383-386): + +``` +invalid UTF-8 "?" for each invalid byte +C0 controls U+0000-U+001F replaced with "?", except tab, line feed, carriage return +line endings CRLF and CR are line breaks: Kotlin text.lines(); iOS: replaced with LF before parsing +``` + +Limits, checked before rendering; over a limit: text view only, the switch hidden: + +``` +line length 15 000 characters +nesting Kotlin: 32 levels of the parsed tree; iOS: 16 levels estimated from line prefixes +link nesting iOS: 4 open square brackets, counted until an empty line +table rows 200 +parse error Kotlin: text view only +``` + +Text view: lines longer than 15 000 characters are split. + +- `readCryptoFile`: core `chatReadFile` (`Mobile/File.hs:87`); the file is decrypted by `CF.readFile` into memory. +- Bytes, text and HTML are held only in memory, in the viewer's state. +- Read error: alert with the error text; the viewer is not shown. +- File absent: "File not found" alert (Kotlin); no action (iOS), as for other files. + +## 5. Viewer + +``` +presentation Android: full screen, like the image viewer + desktop: over the conversation + iOS: sheet +toolbar back (Kotlin) + switch between the formatted and the text view + save +title file name +preference view chosen last; default formatted +``` + +### 5.1. Kotlin + +Renderer: `com.mikepenz:multiplatform-markdown-renderer` and `-m2`, 0.27.0 → 0.34.0 (`common/build.gradle.kts:80-81`). + +``` +0.34.0 dependencies kotlin-stdlib 2.1.20, org.jetbrains:markdown 0.7.3, kotlinx-collections-immutable 0.3.8 +compose compileOnly; built with Compose 1.8.0, the app uses 1.8.2 +app Kotlin 2.1.20, Compose Multiplatform 1.8.2 +added table component, LazyMarkdownSuccess, success lambda of Markdown() +``` + +New file `views/chat/item/MarkdownFileView.kt`: + +``` +isMarkdownFile(file) +showMarkdownFile(file) loadRemoteFile if needed, read, ModalManager.center.showModal +MarkdownFileView formatted or text view by appPrefs.markdownFileTextView +``` + +Formatted view: + +``` +renderer m2 Markdown(state), GFM flavour, the parsed state from showMarkdownFile +styles documentMarkdownStyle(h1) (OperatorView.kt), shared with the operator conditions + h1: typography h2, 26 sp, bold +lazy success lambda: LazyColumnWithScrollBar, title item, one item per top-level node: MarkdownElement in a Column +links LocalUriHandler: markdownFileUriHandler +code spans annotator: source text between the backticks; inlineCode style, inlineCodeBackground +images data:image URIs: DataImageTransformer, decoded off the main thread by decodeBase64Image + other image links: the annotator appends the source text; nothing is fetched +tables default table component +``` + +Links, classified by the core markdown parser (`parseToMarkdown`), as message links: + +``` +SimplexLink openVerifiedSimplexUri +Uri, http or https openBrowserAlert +other showInvalidLinkAlert +``` + +Displayed link targets (`displayedUri`, Kotlin and iOS; also in message link alerts and the external-link alert): + +``` +format characters (Cf) [U+XXXX] +non-ASCII in the authority [U+XXXX] +HTML not parsed (parseHtml = false) +``` + +Images: + +``` +decode decodeBase64Image(base64, window width): power-of-two subsampling, both sides kept at or above the window width + Android: decodeSampledBitmapFromByteArray, 4320 px per side + desktop: decodeBoundedBufferedImage, 16384 px per side, 4320 × 4320 decoded pixels +budget 2160 × 4320 decoded pixels per document; images over the budget are not shown +cache per document, in the transformer; the desktop base64BitmapCache is not used +``` + +Text view: `LazyColumnWithScrollBar`, title item, one monospace `Text` per line, inside `SelectionContainer`; format characters shown as `[U+XXXX]` markers. + +Bidi controls (U+061C, U+200E, U+200F, U+202A-U+202E, U+2066-U+2069): warning under the title, both views. + +Toolbar end buttons: + +``` +switch ic_code in the formatted view, ic_article in the text view; sets appPrefs.markdownFileTextView; hidden over the limits +save ic_download; rememberFileChooserLauncher; copyBytesToFile(ByteArrayInputStream(bytes)) (Files.kt:65) +``` + +`OperatorView.kt`: + +``` +markdownColor(linkText = primary) markdownColor(); link colour moved to the link text style (linkText deprecated in 0.34.0) +ConditionsMarkdown styles moved to documentMarkdownStyle(h1): DocumentMarkdownStyle (colors, typography, components, padding) + h1: body1 by default, as in the conditions +``` + +`SimpleXAPI.kt`: `markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)`. + +Centre placement on desktop: modals are drawn over the open conversation. + +``` +ModalView.kt showCustomModal: chatId is not changed +App.kt CenterPartOfScreen: ModalManager.center.showInView() after ChatView +ChatListNavLinkView.kt "Set contact name": back button on desktop +``` + +`moko-resources/base/strings.xml`: + +``` +markdown_formatted_view Formatted view +markdown_text_view Text view +markdown_file_bidi_warning This file has text direction characters. The text can be shown in a different order than it is written. +``` + +### 5.2. iOS + +New file `Shared/Views/Chat/ChatItem/MarkdownFileView.swift`, added to the SimpleX target in `project.pbxproj`. + +``` +isMarkdownFile(file) +showMarkdownFile(file) read, parse, showAppSheet +MarkdownFileView NavigationView; toolbar: switch, save +``` + +``` +formatted ConditionsWebView(html:openLink:), Ink 0.6.0, raw HTML escaped by an .html modifier +text ScrollView, LazyVStack, one monospace Text per line, text selection, format characters as [U+XXXX] +switch @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW); hidden over the limits +save saveCryptoFile(fileSource) (CIFileView.swift) +close swipe down +links openMarkdownFileLink: parseSimpleXMarkdown; simplexLink: appOpenUrl; uri, http or https: openBrowserAlert; other: showInvalidLinkAlert +``` + +`SettingsView.swift`: `DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"`, default false. + +`ConditionsWebView.swift`, shared with the operator conditions: + +``` +configuration allowsContentJavaScript = false; websiteDataStore = .nonPersistent() +head Content-Security-Policy: default-src 'none'; style-src 'nonce-'; img-src data:; base-uri 'none'; form-action 'none' +style the app's style block with the nonce; table, th, td borders; pre overflow-x auto; img max-width 100% +navigation linkActivated: openLink (openExternalLink by default) + other navigation: allowed for about: URLs only +``` + +- Ink 0.6.0 applies `.html` modifiers to block and inline HTML fragments (`HTMLConvertible.swift`); the viewer's modifier escapes `&`, `<`, `>`; code spans and blocks are escaped by Ink itself. + +Images, an `.images` modifier (`imagesWithinBudget`): + +``` +data:image, base64 shown as , rebuilt from the source only + 4320 px per side; 2160 × 4320 pixels per document; size read by UIImage(data:) +over a limit not shown +other data URIs not shown +other image links the source text; nothing is fetched +``` + +## 6. Size limit + +1 MB. +4 MB: formatted views render lazily (Kotlin: one lazy item per top-level node; iOS: WKWebView); text views render one lazy item per line. +A single top-level block (one table, code block or paragraph) is still laid out at once. + +## 7. Checks + +1. Plain and encrypted files: formatted and text views on Android, desktop and iOS. +2. Desktop linked to a mobile: file loaded from the mobile, then shown. +3. File over 1 MB: tap action as before. +4. File not received: one tap; viewer shown on completion in the same chat; not shown after leaving the chat. +5. Same for images, voice messages, video and other files, each with its tap action. +6. Tap in a chat list preview: opened on completion while the chat list is shown; Android, iOS: not opened after a chat is opened; desktop: opened while a chat is open. +7. Not opened after a reception error, cancel or abort. Kotlin: not opened after the item scrolled out of view. +8. Invalid UTF-8 and control characters: shown as "?". +9. Lines over 15 000 characters, 33 tree levels (Kotlin), 17 nesting levels (iOS), 5 nested square brackets (iOS), 201 table rows: text view only, long lines split. +10. Bidi controls: warning; format characters as [U+XXXX] in the text view. +11. Data images: decoded to the window width (Kotlin); images over 4320 px per side (Android, iOS) or beyond 2160 × 4320 pixels per document not shown. +12. Links: SimpleX links to the connect flow; http(s) links to the "Open web link?" alert; other schemes and relative links: "Invalid link"; targets with invisible or non-ASCII host characters shown with [U+XXXX]. +13. View switch kept after closing the viewer and after a restart. +14. Save: the file at the chosen destination equals the original. +15. Link inside backticks: shown as code. +16. Remote image: not loaded, the source text shown; data URI image: shown. +17. iOS inline HTML with ` +{% if not noEvents %} + +{% endif %} + diff --git a/website/src/fdroid.html b/website/src/fdroid.html index ff56b9cc95..c3441d9680 100644 --- a/website/src/fdroid.html +++ b/website/src/fdroid.html @@ -38,6 +38,11 @@ templateEngineOverride: njk 9F 35 8F F2 84 D1 F7 16 56 A2 BF AF 0E 00 5D EA E6 AA 14 14 37 20 E0 89 F1 1F F2 DD CF EB 01 BA

+

+ {{ "f-droid-page-apk-text" | i18n({}, lang ) | safe }} + aarch64, + armv7 +

@@ -288,36 +261,36 @@ active_home: true
- - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + +

{{ "index-directory-h2" | i18n({}, lang) }}

@@ -353,6 +326,20 @@ active_home: true })(); + + + Invest in SimpleX Chat @@ -18,7 +30,7 @@ eleventyExcludeFromCollections: true - + diff --git a/website/src/why.html b/website/src/why.html index 618caea632..0b2331d0f5 100644 --- a/website/src/why.html +++ b/website/src/why.html @@ -47,9 +47,15 @@ noGlossary: true {{ "why-tagline" | i18n({}, lang) | safe }}

-

- {{ "why-film" | i18n({}, lang) | safe }} -

+ +
+ + +
+

{{ "why-film" | i18n({}, lang) | safe }}

+
diff --git a/website/web.sh b/website/web.sh index 4ee1ea710d..529590ac80 100755 --- a/website/web.sh +++ b/website/web.sh @@ -38,6 +38,7 @@ for lang in "${langs[@]}"; do cp src/invitation.html src/$lang cp src/fdroid.html src/$lang cp src/why.html src/$lang + cp src/downloads.html src/$lang cp src/file.html src/$lang echo "{\"lang\":\"$lang\"}" > src/$lang/$lang.json echo "*" > src/$lang/.gitignore