diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml index d64d579520..13871a25b8 100644 --- a/.github/actions/prepare-build/action.yml +++ b/.github/actions/prepare-build/action.yml @@ -10,21 +10,19 @@ inputs: github_ref: required: true description: "Git reference" - os: - required: true - description: "Target OS" - cache_path: - required: false - default: "~/.cabal/store" - description: "Cache path" cabal_ver: required: false default: 3.10.2.0 description: "GHC version to install" +outputs: + cabal-store: + description: "Cabal store path" + value: ${{ steps.setup.outputs.cabal-store }} runs: using: "composite" steps: - name: Setup Haskell + id: setup uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ inputs.ghc_ver }} @@ -37,11 +35,3 @@ runs: distribution: 'corretto' java-version: ${{ inputs.java_ver }} cache: 'gradle' - - - name: Restore cached build - uses: actions/cache@v4 - with: - path: | - ${{ inputs.cache_path }} - dist-newstyle - key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a3400f3ffa..074a6feb48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,15 @@ on: - "scripts/desktop/**" - ".github/**" +env: + opt: ${{ github.event_name == 'pull_request' && 'O0' || 'O2' }} + ghc_options: ${{ github.event_name == 'pull_request' && '--ghc-options=-O0' || '' }} + release: ${{ startsWith(github.ref, 'refs/tags/v') }} + branch: ${{ github.base_ref || github.ref_name }} + cache_o0: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') }} + release_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') && github.ref_name || '' }} + ZSTD_CLEVEL: 9 + # This workflow uses custom actions (prepare-build and prepare-release) defined in: # # .github/actions/ @@ -66,7 +75,8 @@ jobs: # ============================= # Create release, but only if it's triggered by tag push. -# 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 pushes to master or stable: claim the simplex-chat-libs release core- for the branch +# and create its draft, create the draft of a missing add-on release, and find the package versions to publish. # On pull requests and other pushes, this job will always complete. # Secrets: @@ -79,8 +89,15 @@ jobs: maybe-release: runs-on: ubuntu-latest outputs: - libs_tag: ${{ steps.libs_commit.outputs.tag }} + libs_tag: ${{ steps.libs.outputs.tag }} core_changed: ${{ steps.core.outputs.core }} + addon_tag: ${{ steps.addon.outputs.tag }} + addon_draft: ${{ steps.addon.outputs.draft }} + libs_latest: ${{ steps.latest_libs.outputs.tag }} + publish_types: ${{ steps.libs.outputs.tag && steps.publish.outputs.types_version || '' }} + publish_nodejs: ${{ steps.publish.outputs.nodejs_version }} + publish_python: ${{ steps.publish.outputs.python_version }} + publish: ${{ steps.publish.outputs.nodejs_version || steps.publish.outputs.python_version }} steps: - name: Clone project if: github.event_name == 'push' || github.event_name == 'pull_request' @@ -121,38 +138,68 @@ 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' + - name: Library release + id: libs + if: env.release_branch 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" + version=$(sed -n 's/^version: *//p' simplex-chat.cabal) + tag=core-$version + claim_output=$(python3 scripts/ci/packages_release.py claim-core "$version" "$GITHUB_SHA" "$release_branch") + eval "$claim_output" + if [ "$claim" = skip ]; then + echo "::notice title=Library release::core $version is recorded for another commit" + exit 0 + fi + status_output=$(python3 scripts/ci/packages_release.py release-status "$tag") + eval "$status_output" + if [ "$status" = absent ]; then + gh release create "$tag" --repo "$LIBS_REPOSITORY" --draft --title "$tag" --notes "" ${commit:+--target "$commit"} + fi + if [ "$status" != published ]; then + echo "tag=$tag" >> "$GITHUB_OUTPUT" + fi - # 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 }} + - name: Add-on release + id: addon + if: env.release_branch + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: | + addon_output=$(python3 scripts/ci/packages_release.py addon-release) + eval "$addon_output" + if [ "$status" = absent ]; then + tag=nodejs-addon-${GITHUB_SHA::7} + gh release create "$tag" --repo "$LIBS_REPOSITORY" --draft --title "$tag" --notes "$notes" + fi + if [ "$status" != published ]; then + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "draft=true" >> "$GITHUB_OUTPUT" + fi + + - name: Latest library + id: latest_libs + if: steps.addon.outputs.draft + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: python3 scripts/ci/packages_release.py latest-libs --branch "$release_branch" >> "$GITHUB_OUTPUT" + + - name: Versions to publish + id: publish + if: env.release_branch + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" # ========================= # Linux Build @@ -162,9 +209,15 @@ jobs: name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.runner }} - concurrency: + permissions: + contents: write + packages: write + env: + image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} + source_hashes: dist-newstyle/sources.sha256 + concurrency: &linux-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 }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -191,22 +244,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237' - - os: 22.04 - os_underscore: 22_04 - arch: aarch64 - runner: "ubuntu-22.04-arm" - should_run: true - ghc: ${{ needs.variables.outputs.GHC_VER }} - hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' - libs: true - - os: 24.04 - os_underscore: 24_04 - arch: aarch64 - runner: "ubuntu-24.04-arm" - should_run: true - ghc: ${{ needs.variables.outputs.GHC_VER }} - hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' - steps: + steps: &linux-steps - name: Checkout Code if: matrix.should_run == true uses: actions/checkout@v3 @@ -231,19 +269,22 @@ jobs: shell: bash run: ./scripts/ci/linux_util_free_space.sh - - name: Restore cached build - if: matrix.should_run == true - uses: actions/cache@v4 - with: - path: | - ~/.cabal/store - dist-newstyle - key: ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - - name: Set up Docker Buildx if: matrix.should_run == true uses: simplex-chat/docker-setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + if: matrix.should_run == true && env.release != 'true' + shell: bash + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Compute image cache week + id: week + if: matrix.should_run == true && env.release != 'true' + run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" + - name: Build and cache Docker image if: matrix.should_run == true uses: simplex-chat/docker-build-push-action@v6 @@ -258,6 +299,8 @@ jobs: GHC=${{ matrix.ghc }} USER_UID=${{ steps.ids.outputs.uid }} USER_GID=${{ steps.ids.outputs.gid }} + cache-from: ${{ env.release != 'true' && format('type=registry,ref={0}-{1}', env.image_cache, steps.week.outputs.week) || '' }} + cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && format('type=registry,ref={0}-{1},mode=max,ignore-error=true', env.image_cache, steps.week.outputs.week) || '' }} # Docker needs these flags for AppImage build: # --device /dev/fuse @@ -267,16 +310,94 @@ jobs: if: matrix.should_run == true shell: bash run: | + mkdir -p ~/.cabal docker run -t -d \ --device /dev/fuse \ --cap-add SYS_ADMIN \ --security-opt apparmor:unconfined \ --name builder \ - -v ~/.cabal:/root/.cabal \ + -e CABAL_DIR=/home/builder/.cabal \ + -v ~/.cabal:/home/builder/.cabal \ -v /home/runner/work/_temp:/home/runner/work/_temp \ -v ${{ github.workspace }}:/project \ build/${{ matrix.os }}:latest + - name: Compute cache key prefixes + id: keys + if: matrix.should_run == true && env.release != 'true' + run: | + toolchain=$(docker exec builder dpkg-query -W -f '${db:Status-Status} ${binary:Package} ${Version}\n' 'binutils*' 'cpp*' 'gcc*' '*-dev' | grep '^installed ' | sha256sum | cut -c1-16) + target=ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + echo "dist_lib=dist-lib-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + + - name: Restore library build directory from cache + id: restore-build-lib + if: env.release != 'true' && matrix.libs && needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64' + uses: actions/cache/restore@v4 + with: + path: | + dist-lib/* + !dist-lib/src + key: ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist_lib }}- + + - name: Warn if library build directory not in cache + if: steps.restore-build-lib.outcome == 'success' && steps.restore-build-lib.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}" + + - name: Set old timestamps on unchanged sources (GHC 8.10.7) + if: matrix.ghc == '8.10.7' && steps.restore-build.outputs.cache-matched-key != '' && hashFiles(env.source_hashes) != '' + run: scripts/ci/source-timestamps.sh restore ${{ env.source_hashes }} + - name: Prepare cabal.project.local if: matrix.should_run == true shell: bash @@ -285,49 +406,88 @@ jobs: echo "package direct-sqlcipher" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local - # chmod/git commands are used to workaround permission issues when cache is restored - name: Build CLI + id: build-cli if: matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | - cabal clean + scripts/ci/prune-old-builds.sh dist-newstyle cabal update - cabal build -j --enable-tests + cabal build -j --enable-tests ${{ env.ghc_options }} mkdir -p /out for i in simplex-chat simplex-chat-test; do bin=$(find /project/dist-newstyle -name "$i" -type f -executable) chmod +x "$bin" - mv "$bin" /out/ + cp "$bin" /out/ done strip /out/simplex-chat - # Also for pull requests that change the chat core, for the package tests (packages-pr). + # Also for pull requests that change the chat core, for the package tests. - name: Build library + id: build-lib 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 + scripts/ci/prune-old-builds.sh dist-lib + mkdir -p dist-lib + rm -rf dist-lib/src + ln -s ../dist-newstyle/src dist-lib/src + DIST_DIR=dist-lib 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 + - name: Record source hashes (GHC 8.10.7) + if: matrix.should_run == true && matrix.ghc == '8.10.7' + shell: docker exec -t builder sh -eu {0} + run: scripts/ci/source-timestamps.sh record ${{ env.source_hashes }} + + - name: Compute dependency cache key + id: hash-deps + if: matrix.should_run == true && env.release != 'true' + shell: bash + run: echo "hash=$(ls ~/.cabal/store/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 with: - name: simplex-chat-libs-linux-${{ matrix.arch }} - path: release-libs/simplex-chat-libs-linux-${{ matrix.arch }}.zip - if-no-files-found: error + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: matrix.should_run == true && env.release != 'true' && matrix.libs && matrix.arch == 'x86_64' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + + - name: Save library build directory to cache + if: steps.build-lib.outcome == 'success' && env.release != 'true' && steps.restore-build-lib.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-lib/* + !dist-lib/src + key: ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}-${{ github.sha }} - 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 }} + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" - name: Build CLI deb if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true @@ -437,13 +597,6 @@ jobs: github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Fix permissions for cache - if: matrix.should_run == true - shell: bash - run: | - sudo chmod -R 777 dist-newstyle ~/.cabal - sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal - - name: Mount RAM disk for tests if: matrix.should_run == true && matrix.arch == 'x86_64' shell: bash @@ -474,6 +627,136 @@ jobs: exit 1 fi + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && ((needs.maybe-release.outputs.core_changed && steps.build-lib.outcome == 'success') || (matrix.libs && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish))) }} + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Package tests (pull request) + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.core_changed }} + timeout-minutes: 30 + shell: bash + env: + SIMPLEX_LIBS_DIR: ${{ github.workspace }}/release-libs/simplex-chat-libs-linux-${{ matrix.arch }}/libs + run: | + scripts/ci/package-tests.sh build linux-${{ matrix.arch }} + scripts/ci/package-tests.sh test + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon linux-${{ matrix.arch }} + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish linux-${{ matrix.arch }} ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "linux-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-linux-${{ matrix.arch }} + path: ${{ runner.temp }}/package-tests + + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !cancelled() && steps.build-cli.outcome == 'success' + shell: docker exec -t builder sh -eu {0} + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Set old timestamps on unchanged sources for -O0 build (GHC 8.10.7) + if: ${{ !cancelled() && matrix.ghc == '8.10.7' && steps.restore-build-o0.outputs.cache-matched-key != '' && hashFiles(env.source_hashes) != '' }} + run: scripts/ci/source-timestamps.sh restore ${{ env.source_hashes }} + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: docker exec -t builder sh -eu {0} + run: | + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests --ghc-options=-O0 + + - name: Record source hashes for -O0 build (GHC 8.10.7) + if: ${{ !cancelled() && matrix.ghc == '8.10.7' && steps.build-o0.outcome == 'success' }} + shell: docker exec -t builder sh -eu {0} + run: scripts/ci/source-timestamps.sh record ${{ env.source_hashes }} + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + + build-linux-push: + name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + if: github.event_name != 'pull_request' + runs-on: ${{ matrix.runner }} + permissions: + contents: write + packages: write + env: + image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} + source_hashes: dist-newstyle/sources.sha256 + cache_o0: false + concurrency: *linux-concurrency + strategy: + fail-fast: false + matrix: + include: + - os: 22.04 + os_underscore: 22_04 + arch: aarch64 + runner: "ubuntu-22.04-arm" + should_run: true + ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' + libs: true + - os: 24.04 + os_underscore: 24_04 + arch: aarch64 + runner: "ubuntu-24.04-arm" + should_run: true + ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' + steps: *linux-steps + # ================================= # Linux PostgreSQL Library Build # ================================= @@ -497,14 +780,6 @@ jobs: shell: bash run: ./scripts/ci/linux_util_free_space.sh - - name: Restore cached build - uses: actions/cache@v4 - with: - path: | - ~/.cabal/store - dist-newstyle - key: ubuntu-22.04-x86_64-postgres-ghc${{ needs.variables.outputs.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - - name: Set up Docker Buildx uses: simplex-chat/docker-setup-buildx-action@v3 @@ -525,13 +800,35 @@ jobs: - name: Start container shell: bash run: | + mkdir -p ~/.cabal docker run -t -d \ --name builder \ - -v ~/.cabal:/root/.cabal \ + -e CABAL_DIR=/home/builder/.cabal \ + -v ~/.cabal:/home/builder/.cabal \ -v /home/runner/work/_temp:/home/runner/work/_temp \ -v ${{ github.workspace }}:/project \ build/22.04:latest + - name: Compute cache key prefix + id: keys + run: | + toolchain=$(docker exec builder dpkg-query -W -f '${db:Status-Status} ${binary:Package} ${Version}\n' 'binutils*' 'cpp*' 'gcc*' '*-dev' | grep '^installed ' | sha256sum | cut -c1-16) + echo "deps=deps-ubuntu-22.04-x86_64-postgres-ghc${{ needs.variables.outputs.GHC_VER }}-$toolchain" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + uses: actions/cache/restore@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + - name: Prepare cabal.project.local shell: bash run: | @@ -542,10 +839,21 @@ jobs: - name: Build postgres library shell: docker exec -t builder sh -eu {0} run: | - cabal clean cabal update scripts/desktop/build-lib-linux.sh postgres + - name: Compute dependency cache key + id: hash-deps + shell: bash + run: echo "hash=$(ls ~/.cabal/store/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + - name: Copy libs from container shell: bash run: | @@ -558,18 +866,11 @@ jobs: (cd postgres-libs && zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs) - name: Upload postgres library - uses: softprops/action-gh-release@v2 - with: - 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 - run: | - sudo chmod -R 777 dist-newstyle ~/.cabal - sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" postgres-libs/*.zip --clobber --repo "${{ github.repository }}-libs" # ========================= # MacOS Build @@ -581,7 +882,7 @@ jobs: 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 }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -604,14 +905,49 @@ jobs: with: submodules: recursive + - name: Point Command Line Tools SDK to Xcode SDK + shell: bash + run: sudo ln -sfn "$(xcrun --sdk macosx --show-sdk-path)" /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk + + - name: Compute toolchain cache key + id: toolchain-key + if: env.release != 'true' + shell: bash + run: echo "key=ghcup-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-cabal3.10.2.0-$({ sw_vers -productVersion; clang --version; } | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Restore toolchain from cache + id: restore-toolchain + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: | + ~/.ghcup/ghc/${{ matrix.ghc }} + ~/.ghcup/bin/*-${{ matrix.ghc }} + ~/.ghcup/bin/cabal-3.10.2.0 + key: ${{ steps.toolchain-key.outputs.key }} + + - name: Warn if toolchain not in cache + if: env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' + run: echo "::warning title=Cache miss::${{ steps.toolchain-key.outputs.key }}" + - name: Prepare build + id: prepare uses: ./.github/actions/prepare-build with: java_ver: ${{ needs.variables.outputs.JAVA_VER }} ghc_ver: ${{ matrix.ghc }} - os: ${{ matrix.os }} github_ref: ${{ github.ref }} + - name: Save toolchain to cache + if: env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.ghcup/ghc/${{ matrix.ghc }} + ~/.ghcup/bin/*-${{ matrix.ghc }} + ~/.ghcup/bin/cabal-3.10.2.0 + key: ${{ steps.toolchain-key.outputs.key }} + - name: Install OpenSSL run: brew install openssl@3.0 @@ -628,15 +964,100 @@ jobs: echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local + - name: Compute cache key prefixes + id: keys + if: env.release != 'true' + shell: bash + run: | + toolchain=$({ sw_vers -productVersion; clang --version; } | shasum -a 256 | cut -c1-16) + target=${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: env.release != 'true' && !needs.maybe-release.outputs.libs_tag + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + - name: Build CLI id: mac_cli_build shell: bash run: | - cabal build -j --enable-tests - path=$(cabal list-bin simplex-chat) + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests ${{ env.ghc_options }} + path=$(cabal list-bin simplex-chat | tail -n 1) 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: Compute dependency cache key + id: hash-deps + if: env.release != 'true' + shell: bash + run: echo "hash=$(ls '${{ steps.prepare.outputs.cabal-store }}'/ghc-*/ | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: env.release != 'true' && matrix.arch == 'x86_64' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') uses: ./.github/actions/prepare-release @@ -673,6 +1094,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Run tests + if: matrix.arch == 'aarch64' timeout-minutes: 120 shell: bash run: | @@ -680,7 +1102,7 @@ jobs: attempts=1 ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3 while [ "$i" -le "$attempts" ]; do - if cabal test --test-show-details=direct; then + if cabal test --test-show-details=direct ${{ env.ghc_options }}; then break else echo "Attempt $i failed, retrying..." @@ -706,12 +1128,88 @@ jobs: - name: Upload library if: ${{ !cancelled() && steps.mac_lib_build.outcome == 'success' }} - uses: softprops/action-gh-release@v2 + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + uses: actions/setup-node@v7 with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: release-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon macos-${{ matrix.arch }} + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish macos-${{ matrix.arch }} ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "macos-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-macos-${{ matrix.arch }} + path: ${{ runner.temp }}/package-tests + + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !cancelled() && steps.mac_cli_build.outcome == 'success' + shell: bash + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: bash + run: | + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests --ghc-options=-O0 + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} # ========================= # Windows Build @@ -723,7 +1221,7 @@ jobs: 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 }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -739,12 +1237,11 @@ jobs: submodules: recursive - name: Prepare build + id: prepare uses: ./.github/actions/prepare-build with: java_ver: ${{ needs.variables.outputs.JAVA_VER }} ghc_ver: ${{ matrix.ghc }} - os: ${{ matrix.os }} - cache_path: "C:/cabal" github_ref: ${{ github.ref }} - name: Configure pagefile (Windows) @@ -768,6 +1265,61 @@ jobs: toolchain:p cmake:p + - name: Compute cache key prefixes + id: keys + if: env.release != 'true' + shell: msys2 {0} + run: | + toolchain=$(gcc --version | sha256sum | cut -c1-16) + target=windows-x86_64-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: env.release != 'true' && !needs.maybe-release.outputs.libs_tag + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + # rm -rf dist-newstyle/src/{direct-sq,simplexmq}* is here because of the bug in cabal's dependency which prevents second build from finishing # (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows) - name: Build CLI @@ -784,14 +1336,44 @@ jobs: echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local + scripts/ci/prune-old-builds.sh dist-newstyle rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* sed -i "s/, unix /--, unix /" simplex-chat.cabal - cabal build -j --enable-tests + cabal build -j --enable-tests ${{ env.ghc_options }} rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* path=$(cabal list-bin simplex-chat | tail -n 1) 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: Compute dependency cache key + id: hash-deps + if: env.release != 'true' + shell: msys2 {0} + run: echo "hash=$(ls "$(cygpath -u '${{ steps.prepare.outputs.cabal-store }}')"/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: env.release != 'true' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + - name: Build library if: needs.maybe-release.outputs.libs_tag shell: msys2 {0} @@ -807,12 +1389,52 @@ jobs: - name: Upload library if: needs.maybe-release.outputs.libs_tag - uses: softprops/action-gh-release@v2 + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + uses: actions/setup-node@v7 with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: release-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon windows-x86_64 + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish windows-x86_64 ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "windows-x86_64" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-windows-x86_64 + path: ${{ runner.temp }}/package-tests - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') @@ -848,33 +1470,210 @@ jobs: github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !needs.maybe-release.outputs.libs_tag && !cancelled() && steps.windows_cli_build.outcome == 'success' + shell: msys2 {0} + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: msys2 {0} + run: | + export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) + scripts/desktop/prepare-openssl-windows.sh + scripts/ci/prune-old-builds.sh dist-newstyle + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + cabal build -j --enable-tests --ghc-options=-O0 + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + # ========================= -# Packages release +# Releases and packages # ========================= -# A new library release is tested with the Node.js and Python packages and updates them in a release PR. + releases: + needs: [maybe-release, build-linux, build-linux-push, build-linux-postgres, build-macos, build-windows] + if: ${{ !cancelled() && (needs.maybe-release.outputs.libs_tag || needs.maybe-release.outputs.addon_draft) }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + steps: + - name: Checkout Code + uses: actions/checkout@v6 - 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 + - name: Publish library release + if: needs.maybe-release.outputs.libs_tag + run: python3 scripts/ci/packages_release.py publish-release "${{ needs.maybe-release.outputs.libs_tag }}" + + - name: Publish add-on release + if: ${{ !cancelled() && needs.maybe-release.outputs.addon_draft }} + run: python3 scripts/ci/packages_release.py publish-release "${{ needs.maybe-release.outputs.addon_tag }}" + +# 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. + + publish: + needs: [maybe-release, build-linux, build-linux-push, build-macos, build-windows, releases] + if: ${{ !cancelled() && (needs.maybe-release.outputs.publish || needs.maybe-release.outputs.publish_types) }} + runs-on: ubuntu-latest permissions: contents: read id-token: write - uses: ./.github/workflows/packages.yml - with: - libs_release: ${{ needs.maybe-release.outputs.libs_tag }} - secrets: inherit + # 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 }} + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 -# A pull request that changes the chat core is tested with the Node.js and Python packages. + - name: Download package tests results + if: needs.maybe-release.outputs.publish + uses: actions/download-artifact@v8 + with: + pattern: package-tests-* + path: ${{ runner.temp }}/package-tests - 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 + - name: Check package tests and pins + if: needs.maybe-release.outputs.publish + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: | + passed=$(find "$RUNNER_TEMP/package-tests" -mindepth 1 -maxdepth 1 -type d -name 'package-tests-*' | wc -l) + if [ "$passed" -ne 5 ]; then + echo "::error::package tests passed on $passed of 5 platforms" + exit 1 + fi + python3 scripts/ci/packages_release.py check-pins + + - 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 && needs.maybe-release.outputs.publish_types && needs.releases.result == 'success' + 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" + + 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" diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index a340b1ea50..b5dc74947b 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -15,7 +15,11 @@ on: type: boolean default: false backend: - description: Also start the test backend and test the released app + description: Also start the test backend + type: boolean + default: false + release: + description: Also test the latest released app, to tell a branch's failure from the runner's type: boolean default: false @@ -24,8 +28,8 @@ permissions: actions: read concurrency: - # a push that asks for no run doesn't cancel one that does - group: desktop-linux-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }} + # a push that asks for no run must not cancel one that does + group: desktop-linux-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]')) && 'requested' || github.run_id }} cancel-in-progress: true env: @@ -38,6 +42,7 @@ env: jobs: plan: name: choose jobs, find earlier builds + # the concurrency group repeats this test, as it can't read job outputs if: >- github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || @@ -49,6 +54,10 @@ jobs: outputs: run-claude: ${{ steps.jobs.outputs.claude }} run-backend: ${{ steps.jobs.outputs.backend }} + run-release: ${{ steps.jobs.outputs.release }} + claude-backend: ${{ steps.scope.outputs.backend }} + core: ${{ steps.names.outputs.core }} + core-run: ${{ steps.core.outputs.run-id }} backend: ${{ steps.names.outputs.backend }} backend-run: ${{ steps.backend.outputs.run-id }} steps: @@ -57,19 +66,46 @@ jobs: env: CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }} BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} + RELEASE: ${{ inputs.release || contains(github.event.head_commit.message, '[ci-release]') }} run: | echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" + echo "release=$RELEASE" | tee -a "$GITHUB_OUTPUT" - name: Checkout code - if: steps.jobs.outputs.backend == 'true' - uses: actions/checkout@v5 + uses: actions/checkout@v6 + + - name: Claude scope + id: scope + if: steps.jobs.outputs.claude == 'true' + env: + BACKEND: ${{ steps.jobs.outputs.backend }} + run: | + backend=$(sed -n 's/^backend: *//p' scripts/ci/desktop-linux/claude-scope) + case "$backend" in + yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;; + *) + echo "::error::claude-scope must say backend: yes or backend: no" + exit 1 + ;; + esac + if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then + echo "::error::claude-scope says the Claude session uses the test backend: ask for it too" + exit 1 + fi # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded - name: Artifact names id: names - if: steps.jobs.outputs.backend == 'true' - run: echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT" + run: | + echo "core=desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }}" >> "$GITHUB_OUTPUT" + echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT" + + - name: Find core library + id: core + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.core }}" - name: Find backend id: backend @@ -86,7 +122,7 @@ jobs: timeout-minutes: 150 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Show runner run: | @@ -96,26 +132,11 @@ jobs: df -h / - name: Setup Haskell - id: haskell uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ env.GHC_VER }} cabal-version: ${{ env.CABAL_VER }} - # before the first backend store exists, the core library's has most of the same Hackage dependencies - - name: Restore cabal store - id: cabal-cache - uses: actions/cache/restore@v5 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: desktop-linux-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} - restore-keys: | - desktop-linux-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - desktop-linux-backend-store-ghc${{ env.GHC_VER }}- - desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - - name: Install libraries run: | sudo apt-get update @@ -124,17 +145,8 @@ jobs: - name: Build backend run: scripts/ci/desktop-linux/build-backend.sh ~/backend/${{ env.BACKEND_PKG }} - - name: Save cabal store - if: ${{ !cancelled() }} - uses: actions/cache/save@v5 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ steps.cabal-cache.outputs.cache-primary-key }} - - name: Upload backend - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ needs.plan.outputs.backend }} path: ~/backend/${{ env.BACKEND_PKG }} @@ -145,11 +157,12 @@ jobs: core: name: core library for Linux needs: plan + if: needs.plan.outputs.core-run == '' runs-on: ubuntu-24.04 timeout-minutes: 150 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Show runner run: | @@ -158,79 +171,39 @@ jobs: free -h df -h / - - name: Restore core library - id: core-cache - uses: actions/cache/restore@v5 - with: - path: ~/core/${{ env.CORE_PKG }} - key: desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }} - - name: Setup Haskell - id: haskell - if: steps.core-cache.outputs.cache-hit != 'true' uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ env.GHC_VER }} cabal-version: ${{ env.CABAL_VER }} - - name: Restore cabal store - id: cabal-cache - if: steps.core-cache.outputs.cache-hit != 'true' - uses: actions/cache/restore@v5 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} - restore-keys: | - desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - desktop-linux-core-store-ghc${{ env.GHC_VER }}- - - name: Install libraries - if: steps.core-cache.outputs.cache-hit != 'true' run: | sudo apt-get update sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev patchelf - name: Build core library - if: steps.core-cache.outputs.cache-hit != 'true' run: scripts/ci/desktop-linux/build-core.sh ~/core/${{ env.CORE_PKG }} - - name: Save cabal store - if: ${{ !cancelled() && steps.core-cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@v5 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ steps.cabal-cache.outputs.cache-primary-key }} - - - name: Save core library - if: steps.core-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 - with: - path: ~/core/${{ env.CORE_PKG }} - key: ${{ steps.core-cache.outputs.cache-primary-key }} - - name: Upload core library - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: - name: desktop-core-linux-x86_64 + name: ${{ needs.plan.outputs.core }} path: ~/core/${{ env.CORE_PKG }} compression-level: 0 - retention-days: 3 + retention-days: 30 if-no-files-found: error app: name: desktop app under Xvfb needs: [plan, core, backend] - # backend is skipped when it isn't asked for or an earlier run built it + # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: submodules: recursive @@ -250,10 +223,12 @@ jobs: dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core - name: Download core library - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: - name: desktop-core-linux-x86_64 + name: ${{ needs.plan.outputs.core }} path: ~/core + run-id: ${{ needs.plan.outputs.core-run || github.run_id }} + github-token: ${{ github.token }} - name: Install core library id: core @@ -261,7 +236,7 @@ jobs: - name: Download backend if: needs.plan.outputs.run-backend == 'true' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: ${{ needs.plan.outputs.backend }} path: ~/backend @@ -290,7 +265,6 @@ jobs: with: distribution: corretto java-version: ${{ env.JAVA_VER }} - cache: gradle - name: Build app id: build @@ -303,6 +277,21 @@ jobs: du -sh "$app" LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so" + # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + - name: Pack app + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + run: tar -czf "$RUNNER_TEMP/desktop-app.tar.gz" -C apps/multiplatform/release/main/app simplex + + - name: Upload app + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: desktop-app + path: ${{ runner.temp }}/desktop-app.tar.gz + compression-level: 0 + retention-days: 1 + if-no-files-found: error + - name: Run desktop tests if: ${{ !cancelled() && steps.build.outcome == 'success' }} working-directory: apps/multiplatform @@ -329,7 +318,7 @@ jobs: - name: Upload smoke test results if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-smoke-test path: ${{ runner.temp }}/smoke-test @@ -338,7 +327,7 @@ jobs: - name: Upload desktop test report if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-test-report path: | @@ -349,7 +338,7 @@ jobs: - name: Upload backend logs if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-backend-logs path: ${{ runner.temp }}/backend/*.log @@ -361,8 +350,8 @@ jobs: # may use the token. claude: name: Claude tests the desktop app - needs: [plan, core, backend] - if: ${{ !cancelled() && !failure() && needs.plan.outputs.run-claude == 'true' }} + needs: [plan, app] + if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 60 environment: @@ -372,14 +361,13 @@ jobs: group: desktop-linux-claude-${{ github.ref }} cancel-in-progress: true env: - # the first release with Haiku 5.5's token counts right (2.1.293 added the model, 2.1.296 fixed its counts) - CLAUDE_CODE_VERSION: 2.1.296 + # the same in both app workflows + CLAUDE_CODE_VERSION: 2.1.300 CLAUDE_MODEL: claude-haiku-5-5 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: - submodules: recursive persist-credentials: false - name: Install tools @@ -388,18 +376,20 @@ jobs: sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core - - name: Download core library - uses: actions/download-artifact@v7 + - name: Download app + uses: actions/download-artifact@v8 with: - name: desktop-core-linux-x86_64 - path: ~/core + name: desktop-app + path: ${{ runner.temp }} - - name: Install core library - run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }} + - name: Unpack app + run: | + mkdir -p "$RUNNER_TEMP/app" + tar -xzf "$RUNNER_TEMP/desktop-app.tar.gz" -C "$RUNNER_TEMP/app" - name: Download backend - if: needs.plan.outputs.run-backend == 'true' - uses: actions/download-artifact@v7 + if: needs.plan.outputs.claude-backend == 'yes' + uses: actions/download-artifact@v8 with: name: ${{ needs.plan.outputs.backend }} path: ~/backend @@ -407,29 +397,11 @@ jobs: github-token: ${{ github.token }} - name: Start test backend - if: needs.plan.outputs.run-backend == 'true' + id: backend + if: needs.plan.outputs.claude-backend == 'yes' timeout-minutes: 30 run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" - - name: Point the app at the test directory - if: needs.plan.outputs.run-backend == 'true' - run: | - link=$(cat "$RUNNER_TEMP/backend/directory-link") - file=apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt - sed -i "s|^const val DIRECTORY_SERVICE_LINK = \".*\"$|const val DIRECTORY_SERVICE_LINK = \"$link\"|" "$file" - grep -F "const val DIRECTORY_SERVICE_LINK = \"$link\"" "$file" - - - name: Setup Java - uses: actions/setup-java@v5 - with: - distribution: corretto - java-version: ${{ env.JAVA_VER }} - cache: gradle - - - name: Build app - working-directory: apps/multiplatform - run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable - # ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds - name: Install Claude Code and the ui command run: | @@ -440,13 +412,13 @@ jobs: - name: Start the app env: - BACKEND: ${{ needs.plan.outputs.run-backend }} + BACKEND: ${{ steps.backend.outcome }} run: | - if [ "$BACKEND" = true ]; then + if [ "$BACKEND" = success ]; then export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server") cases=setup fi - scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/claude-test/screens" ${cases:-start} + scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/app/simplex" "$RUNNER_TEMP/claude-test/screens" ${cases:-start} # Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential # scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for. @@ -455,7 +427,7 @@ jobs: env: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1 - TEST_PROMPT: scripts/ci/desktop-linux/${{ needs.plan.outputs.run-backend == 'true' && 'claude-search.md' || 'claude-test.md' }} + TEST_PROMPT: scripts/ci/desktop-linux/${{ needs.plan.outputs.claude-backend == 'yes' && 'claude-search.md' || 'claude-test.md' }} run: scripts/ci/desktop-linux/claude-test.sh "$RUNNER_TEMP/claude-test" "$RUNNER_TEMP/claude-transcript.jsonl" "$TEST_PROMPT" - name: Report Claude usage @@ -463,12 +435,12 @@ jobs: run: scripts/ci/desktop-linux/claude-usage.py "$RUNNER_TEMP/claude-transcript.jsonl" "$RUNNER_TEMP/claude-usage.json" - name: Stop test backend - if: ${{ !cancelled() && needs.plan.outputs.run-backend == 'true' }} + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend" - name: Upload Claude test results if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-claude-test path: | @@ -481,8 +453,8 @@ jobs: if-no-files-found: warn - name: Upload backend logs - if: ${{ !cancelled() && needs.plan.outputs.run-backend == 'true' }} - uses: actions/upload-artifact@v6 + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + uses: actions/upload-artifact@v7 with: name: desktop-claude-backend-logs path: ${{ runner.temp }}/backend/*.log @@ -493,12 +465,12 @@ jobs: release: name: released app under Xvfb needs: plan - if: needs.plan.outputs.run-backend == 'true' + if: needs.plan.outputs.run-release == 'true' runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 filter: tree:0 @@ -517,7 +489,7 @@ jobs: - name: Upload smoke test results if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-release-smoke-test path: ${{ runner.temp }}/smoke-test diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml index 9b69d249b3..4a98b35d4c 100644 --- a/.github/workflows/ios-sim.yml +++ b/.github/workflows/ios-sim.yml @@ -39,25 +39,55 @@ env: jobs: plan: - name: find earlier builds + name: choose jobs, find earlier builds + # the concurrency group repeats this test, as it can't read job outputs if: >- github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:ios]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: + run-claude: ${{ steps.jobs.outputs.claude }} + run-backend: ${{ steps.jobs.outputs.backend }} + claude-backend: ${{ steps.scope.outputs.backend }} core: ${{ steps.names.outputs.core }} - backend: ${{ steps.names.outputs.backend }} core-run: ${{ steps.core.outputs.run-id }} + backend: ${{ steps.names.outputs.backend }} backend-run: ${{ steps.backend.outputs.run-id }} - claude: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }} - start-backend: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} steps: + - name: Choose jobs + id: jobs + env: + CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }} + BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} + run: | + echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" + echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Claude scope + id: scope + if: steps.jobs.outputs.claude == 'true' + env: + BACKEND: ${{ steps.jobs.outputs.backend }} + run: | + backend=$(sed -n 's/^backend: *//p' scripts/ci/ios-sim/claude-scope) + case "$backend" in + yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;; + *) + echo "::error::claude-scope must say backend: yes or backend: no" + exit 1 + ;; + esac + if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then + echo "::error::claude-scope says the Claude sessions use the test backend: ask for it too" + exit 1 + fi # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded - name: Artifact names @@ -74,6 +104,7 @@ jobs: - name: Find backend id: backend + if: steps.jobs.outputs.backend == 'true' env: GH_TOKEN: ${{ github.token }} run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.backend }}" @@ -82,11 +113,11 @@ jobs: name: core library for the iOS simulator needs: plan if: needs.plan.outputs.core-run == '' - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 240 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Show runner run: | @@ -100,29 +131,12 @@ jobs: - name: Use the macOS 26 SDK run: scripts/ci/ios-sim/use-macos26-sdk.sh - # without ~/.cabal, cabal 3.10 keeps its store in XDG directories, not where setup-haskell reports it - - name: Use ~/.cabal for cabal - run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" - - name: Setup Haskell - id: haskell uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ env.GHC_VER }} cabal-version: ${{ env.CABAL_VER }} - - name: Restore cabal store - id: cabal-cache - uses: actions/cache/restore@v4 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ios-sim-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} - restore-keys: | - ios-sim-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - ios-sim-core-store-ghc${{ env.GHC_VER }}- - - name: Install libraries run: brew install openssl@3 libffi gmp @@ -130,20 +144,11 @@ jobs: id: build run: scripts/ci/ios-sim/build-core.sh ~/Downloads/${{ env.CORE_PKG }} - - name: Save cabal store - if: ${{ !cancelled() }} - uses: actions/cache/save@v4 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ steps.cabal-cache.outputs.cache-primary-key }} - - name: Patch core library for the simulator run: scripts/ci/ios-sim/prepare-libs.sh - name: Upload core library - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ needs.plan.outputs.core }} path: ~/Downloads/${{ env.CORE_PKG }} @@ -154,12 +159,12 @@ jobs: backend: name: test backend for the app needs: plan - if: needs.plan.outputs.start-backend == 'true' && needs.plan.outputs.backend-run == '' - runs-on: macos-latest + if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == '' + runs-on: macos-26 timeout-minutes: 240 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Show runner run: | @@ -171,42 +176,17 @@ jobs: - name: Use the macOS 26 SDK run: scripts/ci/ios-sim/use-macos26-sdk.sh - - name: Use ~/.cabal for cabal - run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" - - name: Setup Haskell - id: haskell uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ env.GHC_VER }} cabal-version: ${{ env.CABAL_VER }} - - name: Restore cabal store - id: cabal-cache - uses: actions/cache/restore@v4 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ios-sim-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} - restore-keys: | - ios-sim-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - ios-sim-backend-store-ghc${{ env.GHC_VER }}- - - name: Build backend run: scripts/ci/ios-sim/build-backend.sh ~/Downloads/${{ env.BACKEND_PKG }} - - name: Save cabal store - if: ${{ !cancelled() }} - uses: actions/cache/save@v4 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ steps.cabal-cache.outputs.cache-primary-key }} - - name: Upload backend - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ needs.plan.outputs.backend }} path: ~/Downloads/${{ env.BACKEND_PKG }} @@ -219,20 +199,13 @@ jobs: needs: [plan, core, backend] # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} - runs-on: macos-latest - environment: - name: ${{ needs.plan.outputs.claude == 'true' && 'app-sim-claude' || '' }} - deployment: false - timeout-minutes: 330 + runs-on: macos-26 + timeout-minutes: 120 env: - CLAUDE_RUN: ${{ needs.plan.outputs.claude }} - START_BACKEND: ${{ needs.plan.outputs.start-backend }} + START_BACKEND: ${{ needs.plan.outputs.run-backend }} steps: - # the job later holds the Claude token, so the checkout's GitHub token is not left in .git/config - name: Checkout code - uses: actions/checkout@v4 - with: - persist-credentials: false + uses: actions/checkout@v6 - name: Show runner run: | @@ -247,7 +220,7 @@ jobs: run: scripts/ci/ios-sim/use-macos26-sdk.sh - name: Download core library - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ needs.plan.outputs.core }} path: ~/Downloads @@ -261,7 +234,7 @@ jobs: - name: Download backend if: env.START_BACKEND == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ needs.plan.outputs.backend }} path: ~/Downloads @@ -298,6 +271,21 @@ jobs: DEVELOPMENT_TEAM= \ PROVISIONING_PROFILE_SPECIFIER= + # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + - name: Pack app + if: needs.plan.outputs.run-claude == 'true' + run: tar -czf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator" SimpleX.app + + - name: Upload app + if: needs.plan.outputs.run-claude == 'true' + uses: actions/upload-artifact@v7 + with: + name: ios-sim-app + path: ${{ runner.temp }}/SimpleX.app.tar.gz + compression-level: 0 + retention-days: 1 + if-no-files-found: error + - name: Install AXe run: scripts/ci/ios-sim/install-axe.sh @@ -306,31 +294,13 @@ jobs: BACKEND_DIR: ${{ env.START_BACKEND == 'true' && format('{0}/backend', runner.temp) || '' }} run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test" - - name: Install Claude Code - if: env.CLAUDE_RUN == 'true' - run: | - npm install -g @anthropic-ai/claude-code@2.1.300 - claude --version - - - name: Run the device test plan with Claude - if: env.CLAUDE_RUN == 'true' - timeout-minutes: 240 - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - BACKEND_DIR: ${{ env.START_BACKEND == 'true' && format('{0}/backend', runner.temp) || '' }} - run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test" - - - name: Report Claude usage - if: ${{ always() && env.CLAUDE_RUN == 'true' }} - run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test" - - name: Stop test backend if: ${{ !cancelled() && env.START_BACKEND == 'true' }} run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend" - name: Upload smoke test results if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ios-sim-smoke-test path: ${{ runner.temp }}/smoke-test @@ -339,7 +309,7 @@ jobs: - name: Upload backend logs if: ${{ !cancelled() && env.START_BACKEND == 'true' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ios-sim-backend-logs path: | @@ -348,11 +318,104 @@ jobs: retention-days: 3 if-no-files-found: warn + # The app-sim-claude environment's branch rule decides which branches may use the token. + claude: + name: Claude tests the iOS app + needs: [plan, app] + if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} + runs-on: macos-26 + timeout-minutes: 330 + environment: + name: app-sim-claude + deployment: false + concurrency: + group: ios-sim-claude-${{ github.ref }} + cancel-in-progress: true + env: + # the same in both app workflows + CLAUDE_CODE_VERSION: 2.1.300 + steps: + # the job holds the Claude token, so the checkout's GitHub token is not left in .git/config + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Show runner + run: | + sw_vers + xcodebuild -version + xcrun simctl list runtimes + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + - name: Download app + uses: actions/download-artifact@v8 + with: + name: ios-sim-app + path: ${{ runner.temp }} + + - name: Unpack app + run: tar -xzf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP" + + - name: Download backend + if: needs.plan.outputs.claude-backend == 'yes' + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/Downloads + run-id: ${{ needs.plan.outputs.backend-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Start test backend + id: backend + if: needs.plan.outputs.claude-backend == 'yes' + timeout-minutes: 30 + run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" + + - name: Install AXe + run: scripts/ci/ios-sim/install-axe.sh + + - name: Run smoke test + run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/SimpleX.app" "$RUNNER_TEMP/claude-test/smoke-test" + + - name: Install Claude Code + run: | + npm install -g "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION" + claude --version + + - name: Run the device test plan with Claude + timeout-minutes: 240 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + BACKEND_DIR: ${{ steps.backend.outcome == 'success' && format('{0}/backend', runner.temp) || '' }} + run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test" + + - name: Report Claude usage + if: ${{ always() }} + run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test" + + - name: Stop test backend + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend" + - name: Upload Claude test results - if: ${{ always() && env.CLAUDE_RUN == 'true' }} - uses: actions/upload-artifact@v4 + if: ${{ always() }} + uses: actions/upload-artifact@v7 with: name: ios-sim-claude-test path: ${{ runner.temp }}/claude-test retention-days: 14 if-no-files-found: warn + + - name: Upload backend logs + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + uses: actions/upload-artifact@v7 + with: + name: ios-sim-claude-backend-logs + path: | + ${{ runner.temp }}/backend/*.log + ${{ runner.temp }}/backend/seed.md + retention-days: 3 + if-no-files-found: warn diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 01c2c1be33..e5ed17c4f8 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,64 +1,6 @@ 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/**" @@ -83,48 +25,19 @@ 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" + git fetch -q --depth=1 origin "$BASE" + python3 scripts/ci/packages_release.py build-plan --base "$BASE" >> "$GITHUB_OUTPUT" build: name: "build ${{ matrix.platform }}" @@ -139,7 +52,6 @@ jobs: defaults: run: shell: bash - working-directory: packages/simplex-chat-nodejs steps: - name: Checkout Code uses: actions/checkout@v6 @@ -154,229 +66,9 @@ jobs: 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 + run: scripts/ci/package-tests.sh build ${{ matrix.platform }} - - 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) + - name: Test simplex-chat (npm and PyPI) 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 + run: scripts/ci/package-tests.sh test diff --git a/.gitignore b/.gitignore index 4ceea4d1a1..1bb18ef356 100644 --- a/.gitignore +++ b/.gitignore @@ -89,9 +89,3 @@ apps/android/app/release apps/multiplatform/.kotlin/sessions # anywhere in the tree: this file holds a real BTCPay api_key and webhook_secret badge_service.ini - -# Badge service Docker deployment: the Dockerfile, compose, and example config are tracked; the -# live config (a secret) and its data volume are not. -scripts/badge-service/badge_service.ini -scripts/badge-service/data/ -scripts/badge-service/web/ diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 22d0da3829..32dffbf2e2 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -461,9 +461,14 @@ struct ContentView: View { } func connectViaUrl_(_ url: URL) { + // an app link must not tear down a redemption in flight + if isAppLink(url) && isBadgeLinkIssuing() { return } dismissAllSheets() { var path = url.path - if path == "/r" { + if isAppLink(url) { + // branched on the scheme before the connection dispatch, which an app link must never reach + openAppLink(url) + } else if path == "/r" { showAlert( NSLocalizedString("Relay address", comment: "alert title"), message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") @@ -498,6 +503,24 @@ struct ContentView: View { } } +// the app's own scheme, for every link that is not a connection link, which stays on simplex: +private let appLinkScheme = "simplexchat" +private let badgeLinkPath = "/badge/code/" + +func isAppLink(_ url: URL) -> Bool { + url.scheme?.lowercased() == appLinkScheme +} + +// a link type added in a later version reaches this build too, so an unknown path asks for an update +private func openAppLink(_ url: URL) { + let path = url.path + if path.hasPrefix(badgeLinkPath) { + openBadgeLink(String(path.dropFirst(badgeLinkPath.count))) + } else { + AlertManager.shared.showAlert(Alert(title: Text("This link is not supported by this app version. Please check for app updates."))) + } +} + final class AlertManager: ObservableObject { static let shared = AlertManager() @Published var presentAlert = false diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index be55a77895..c15b400ac1 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -194,6 +194,8 @@ enum ChatCommand: ChatCmdProtocol { case apiStandaloneFileInfo(url: String) // badges case apiRedeemBadgeCode(userId: Int64, code: String) + case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, payment: ServicePayment) + case apiCreateBadgeInvoice(userId: Int64) case apiGetBadgeState(userId: Int64) case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64) case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) @@ -423,6 +425,9 @@ enum ChatCommand: ChatCmdProtocol { case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" + case let .apiPurchaseBadge(userId, echoedInvoiceId, payment): + return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "") \(encodeJSON(payment))" + case let .apiCreateBadgeInvoice(userId): return "/_badge invoice \(userId)" case let .apiGetBadgeState(userId): return "/_badge state \(userId)" case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)" case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): @@ -616,6 +621,8 @@ enum ChatCommand: ChatCmdProtocol { case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" + case .apiPurchaseBadge: return "apiPurchaseBadge" + case .apiCreateBadgeInvoice: return "apiCreateBadgeInvoice" case .apiGetBadgeState: return "apiGetBadgeState" case .apiGetBadgeLedger: return "apiGetBadgeLedger" case .apiAckBadgeAlert: return "apiAckBadgeAlert" @@ -672,9 +679,11 @@ enum ChatCommand: ChatCmdProtocol { return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) case let .testStorageEncryption(key): return .testStorageEncryption(key: obfuscate(key)) - // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString case let .apiRedeemBadgeCode(userId, code): return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code)) + case let .apiPurchaseBadge(userId, echoedInvoiceId, .apple(jws)): + return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .apple(jws: obfuscate(jws))) default: return self } } @@ -724,6 +733,29 @@ enum ChatCommand: ChatCmdProtocol { } } +enum ServicePayment: Encodable { + case apple(jws: String) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .apple(jws): + try container.encode("apple", forKey: .type) + try container.encode(jws, forKey: .jws) + } + } + + private enum CodingKeys: String, CodingKey { + case type, jws + } +} + +struct OpenStorePurchase: Decodable { + var invoiceId: String? + var transactionRef: String? + var creditError: BadgeIssueFailure? +} + // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. // Spec: spec/api.md#ChatResponse0 enum ChatResponse0: Decodable, ChatAPIResult { @@ -1059,7 +1091,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) - case badgeState(user: UserRef, badgeState: BadgeState?) + case badgeInvoice(user: UserRef, invoiceId: String) + case badgeState(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]?) case badgeLedger(user: UserRef, badgeLedger: [StatementEntry]) var responseType: String { @@ -1113,6 +1146,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .archiveImported: "archiveImported" case .appSettings: "appSettings" case .badgeRedeemed: "badgeRedeemed" + case .badgeInvoice: "badgeInvoice" case .badgeState: "badgeState" case .badgeLedger: "badgeLedger" } @@ -1169,7 +1203,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") - case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeInvoice(u, invoiceId): return withUser(u, invoiceId) + case let .badgeState(u, badgeState, storePurchases): return withUser(u, "\(String(describing: badgeState))\nstorePurchases: \(String(describing: storePurchases ?? []))") case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger)) } } @@ -1257,6 +1292,8 @@ enum ChatEvent: Decodable, ChatAPIResult { // badges case badgeChanged(user: User, badgeState: BadgeState?) case badgeAlert(user: UserRef, badgeAlert: BadgeAlert) + case storePurchaseCredited(user: UserRef, invoiceId: String) + case storePurchaseRefused(user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) var responseType: String { switch self { @@ -1332,6 +1369,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .contactPQEnabled: "contactPQEnabled" case .badgeChanged: "badgeChanged" case .badgeAlert: "badgeAlert" + case .storePurchaseCredited: "storePurchaseCredited" + case .storePurchaseRefused: "storePurchaseRefused" } } @@ -1417,6 +1456,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState)) case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert)) + case let .storePurchaseCredited(u, invoiceId): return withUser(u, "invoiceId: \(invoiceId)") + case let .storePurchaseRefused(u, invoiceId, refusal): return withUser(u, "invoiceId: \(invoiceId)\nrefusal: \(refusal.tag)") } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 58495a63ca..2330e116fd 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2226,13 +2226,42 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: throw r.unexpected } +// a refusal is thrown, as BREServiceError with the code +enum BadgePurchaseResult { + case held(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) + case credited(user: User, badgeState: BadgeState?) +} + +// log: false because a store receipt is a bearer secret, like a badge code - it is in the command. +func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment) async throws -> BadgePurchaseResult { + let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment), log: false) + switch r { + case let .badgeState(user, badgeState, storePurchases): return .held(user: user, badgeState: badgeState, storePurchases: storePurchases ?? []) + case let .badgeRedeemed(user, _, _, badgeState): return .credited(user: user, badgeState: badgeState) + default: throw r.unexpected + } +} + +func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String { + let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId)) + if case let .badgeInvoice(_, invoiceId) = r { return invoiceId } + throw r.unexpected +} + // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened -func redeemErrorText(_ error: Error) -> String { +func redeemErrorText(_ error: Error, purchase: Bool) -> String { if case let .error(.badgeRedeemError(e)) = error as? ChatError { switch e { case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message") - case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") - case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") + case .invalidReceipt: break + case .serviceNotConfigured: + return purchase + ? NSLocalizedString("This app version cannot buy badges.", comment: "alert message") + : NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") + case .badgeActive: + return purchase + ? NSLocalizedString("This profile already has a badge.", comment: "alert message") + : NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text } case let .invalidResponse(message): return String.localizedStringWithFormat(NSLocalizedString("The badge service sent an unexpected response: %@", comment: "alert message"), message) @@ -2242,15 +2271,15 @@ func redeemErrorText(_ error: Error) -> String { return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error)) } -func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? { +func apiGetBadgeState(_ userId: Int64) async throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) { let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) } throw r.unexpected } -func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? { +func apiGetBadgeStateSync(_ userId: Int64) throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) { let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) } throw r.unexpected } @@ -2262,7 +2291,7 @@ func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws - func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? { let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, _) = r { return badgeState } throw r.unexpected } @@ -2391,6 +2420,7 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws ChatReceiver.shared.start() m.chatRunning = true chatLastStartGroupDefault.set(Date.now) + Task { await BadgeStore.shared.presentUnfinished() } if shouldDeleteDatabaseBackupsDefault.get() { deleteDatabaseBackups() shouldDeleteDatabaseBackupsDefault.set(false) @@ -2419,6 +2449,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws { m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd) m.users = try listUsers() try getUserChatData() + Task { await BadgeStore.shared.presentUnfinished() } } func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws { @@ -2434,6 +2465,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: S m.users = users } try await getUserChatDataAsync(keepingChatId: keepingChatId) + Task { await BadgeStore.shared.presentUnfinished() } await MainActor.run { if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) { invitation.user = currentUser @@ -2461,22 +2493,32 @@ func getUserChatData() throws { private func loadBadgeState() { do { let userId = try currentUserId("loadBadgeState") - let badgeState = try apiGetBadgeStateSync(userId) + let (badgeState, storePurchases) = try apiGetBadgeStateSync(userId) BadgeModel.shared.set(userId: userId, badgeState: badgeState) + BadgeStore.shared.setStorePurchases(userId, storePurchases) } catch let error { logger.error("loadBadgeState: \(responseError(error))") } } -private func loadBadgeStateAsync(_ userId: Int64) async { +func loadBadgeStateAsync(_ userId: Int64) async { do { - let badgeState = try await apiGetBadgeState(userId) - await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) } + let (badgeState, storePurchases) = try await apiGetBadgeState(userId) + await MainActor.run { + BadgeModel.shared.set(userId: userId, badgeState: badgeState) + BadgeStore.shared.setStorePurchases(userId, storePurchases) + } } catch let error { logger.error("loadBadgeState: \(responseError(error))") } } +func loadCurrentBadgeState() async { + if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) { + await loadBadgeStateAsync(userId) + } +} + private func getUserChatDataAsync(keepingChatId: String?) async throws { let m = ChatModel.shared let tm = ChatTagsModel.shared @@ -3086,6 +3128,10 @@ func processReceivedMsg(_ res: ChatEvent) async { BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert) } } + case let .storePurchaseCredited(user, invoiceId): + Task { await BadgeStore.shared.storePurchaseCredited(user, invoiceId) } + case let .storePurchaseRefused(user, invoiceId, refusal): + Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/SharedAssets.xcassets/Contents.json b/apps/ios/Shared/SharedAssets.xcassets/Contents.json new file mode 100644 index 0000000000..4aa7c5350b --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json new file mode 100644 index 0000000000..300553af5f --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "agent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg new file mode 100644 index 0000000000..fd0fdb485a --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 1e9a97c31b..654a31c001 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -43,7 +43,8 @@ struct SimpleXApp: App { .environmentObject(chatModel) .environmentObject(AppTheme.shared) .onOpenURL { url in - logger.debug("ContentView.onOpenURL: \(url)") + // an app link can carry a secret, such as a badge code that anyone who reads it can redeem + logger.debug("ContentView.onOpenURL: \(isAppLink(url) ? "app link" : url.absoluteString)") if AppChatState.shared.value == .active { chatModel.appOpenUrl = url } else { @@ -100,6 +101,7 @@ struct SimpleXApp: App { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { await updateCallInvitations() } + Task { await BadgeStore.shared.presentUnfinished() } if let url = chatModel.appOpenUrlLater { await MainActor.run { chatModel.appOpenUrlLater = nil diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 8d092bbc19..0351884085 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -9,6 +9,7 @@ import Foundation import Combine import StoreKit +import SimpleXChat // TODO [badges] product ids will come from app config and prices from the badge service catalog; // hardcoded here so the App Store integration can be tested before the purchase API lands. @@ -27,11 +28,27 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in BadgePeriod.allCases.map { badgeProductId(level, $0) } } -// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. -// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction, -// which is how the service learns which invoice a store transaction settles. +// the only products sent to the badge service: nothing delivers a subscription yet +let badgeOneTimeProductIds: Set = Set(BadgeLevel.allCases.map { badgeProductId($0, .oneMonth) }) + +// subscriptions are for sale once renewals are delivered +let badgePeriodsForSale: [BadgePeriod] = [.oneMonth] + +// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. +// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction. func newBadgeInvoiceId() -> UUID { UUID() } +// the page's app flag rides in the fragment, which never reaches the service +let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true" +// the dev deployment, for testing store purchases: swap with the line above +// let badgePageUrl = "https://smp7.simplex.im/#/tier?app=true" + +// where the store allows a link out to the badge page: the US for now, a set expected to widen. +// An unknown storefront does not count, as this decides whether the store sees a link out of the app. +var badgeBrowserAllowed: Bool { + SKPaymentQueue.default().storefront?.countryCode == "USA" +} + enum BadgePrice { case loading case price(String) @@ -49,21 +66,32 @@ struct BadgeStoreReceipt { // the signed token the badge service verifies - never transaction.jsonRepresentation let jws: String let productId: String - let transactionId: UInt64 let invoiceId: UUID? - let environment: String? let signatureVerified: Bool + let transaction: Transaction + + var echoedInvoiceId: String? { invoiceId.map(coreInvoiceId) } } +// core mints the id in lower case, and UUID formats it in upper case +func coreInvoiceId(_ invoiceId: UUID) -> String { invoiceId.uuidString.lowercased() } + enum BadgePurchaseOutcome { case purchased(BadgeStoreReceipt) - case pending + case pending(invoiceId: String?) case cancelled } +enum BadgePurchaseState { + case issuing + case waitingForApproval +} + enum BadgeStoreError: Error { case productUnavailable(productId: String) case unknownPurchaseResult + case noActiveProfile + case invalidInvoiceId(String) } final class BadgeStore: ObservableObject { @@ -73,14 +101,53 @@ final class BadgeStore: ObservableObject { @Published private var state: LoadState = .notLoaded private var products: [String: Product] = [:] + // core's open store purchases for the profile they were read for: core knows whose a purchase is + @Published private var storePurchases: (userId: Int64, purchases: [OpenStorePurchase])? = nil + // whether a store sheet this run opened has not returned + @Published private var buying = false + // kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing + // by invoice id, so a pending purchase shows only under the profile whose record it names + @Published private var waitingForApproval: Set = [] + // set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was + // closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again + @Published private var reconciledOnce = false + // the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run + private var sweepFailureShown = false + private var transactionUpdates: Task? = nil private init() {} - func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice { + func purchaseState(_ userId: Int64?) -> BadgePurchaseState? { + let purchases = openStorePurchases(userId) + if purchases.contains(where: { $0.transactionRef != nil }) { return .issuing } + if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval } + return nil + } + + func creditError(_ userId: Int64?) -> BadgeIssueFailure? { + openStorePurchases(userId).compactMap(\.creditError).first + } + + var checkingPurchases: Bool { !reconciledOnce } + + func canBuy(_ userId: Int64?) -> Bool { + reconciledOnce && !buying && purchaseState(userId) == nil + } + + func setStorePurchases(_ userId: Int64, _ purchases: [OpenStorePurchase]) { + storePurchases = (userId, purchases) + } + + private func openStorePurchases(_ userId: Int64?) -> [OpenStorePurchase] { + guard let storePurchases, storePurchases.userId == userId else { return [] } + return storePurchases.purchases + } + + func price(_ level: BadgeLevel, _ period: BadgePeriod, compact: Bool = true) -> BadgePrice { switch state { case .notLoaded, .loading: return .loading case .loaded, .failed: - if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) } + if let p = products[badgeProductId(level, period)] { return .price(compact ? compactPrice(p) : p.displayPrice) } return .unavailable } } @@ -119,33 +186,158 @@ final class BadgeStore: ObservableObject { } } - func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome { + // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> BadgePurchaseOutcome { let productId = badgeProductId(level, period) guard let product = await MainActor.run(body: { products[productId] }) else { throw BadgeStoreError.productUnavailable(productId: productId) } - switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { - case let .success(verification): - let transaction: Transaction - let signatureVerified: Bool - switch verification { - case let .verified(t): - transaction = t - signatureVerified = true - case let .unverified(t, _): - transaction = t - signatureVerified = false + // a subscription is never sent to core, so nothing would ever finish it later + guard badgeOneTimeProductIds.contains(productId) else { + let outcome = try await storePurchase(product, newBadgeInvoiceId()) + if case let .purchased(receipt) = outcome { await receipt.transaction.finish() } + return outcome + } + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { + throw BadgeStoreError.noActiveProfile + } + let invoice = try await apiCreateBadgeInvoice(userId) + guard let invoiceId = UUID(uuidString: invoice) else { throw BadgeStoreError.invalidInvoiceId(invoice) } + await MainActor.run { buying = true } + await loadBadgeStateAsync(userId) + do { + let outcome = try await storePurchase(product, invoiceId) + if case let .purchased(receipt) = outcome, receipt.signatureVerified { + try await handOver(receipt) } - // nothing is delivered in this build, so the transaction is finished right away; once the - // service issues credentials it must only be finished after the credential is stored - await transaction.finish() - return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified)) - case .pending: return .pending + await MainActor.run { buying = false } + return outcome + } catch let error { + await MainActor.run { buying = false } + throw error + } + } + + private func storePurchase(_ product: Product, _ invoiceId: UUID) async throws -> BadgePurchaseOutcome { + switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { + case let .success(verification): return .purchased(storeReceipt(verification)) + case .pending: + let pending = coreInvoiceId(invoiceId) + await MainActor.run { _ = waitingForApproval.insert(pending) } + return .pending(invoiceId: pending) case .userCancelled: return .cancelled @unknown default: throw BadgeStoreError.unknownPurchaseResult } } + // Core holds the receipt and credits it; the transaction stays unfinished until core answers it credited + // or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way. + private func handOver(_ receipt: BadgeStoreReceipt) async throws { + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return } + // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer + do { + switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) { + case let .held(user, badgeState, storePurchases): + await MainActor.run { + // the answer is the owner's, which may be another profile and a hidden one + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + setStorePurchases(user.userId, storePurchases) + } + } + case let .credited(user, badgeState): + await MainActor.run { + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + ChatModel.shared.updateUser(user) + if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) } + } + } + await receipt.transaction.finish() + } + } catch let error where badgeReceiptRefused(error) { + logger.error("BadgeStore.handOver: \(responseError(error))") + await receipt.transaction.finish() + } + } + + func storePurchaseCredited(_ user: UserRef, _ invoiceId: String) async { + await MainActor.run { + // an open badges screen is the notice only for the active profile, whose badge it shows + if !active(user) || !badgesViewShown { showBadgeAddedAlert(user) } + } + await storePurchaseResolved(user, invoiceId) + } + + func storePurchaseRefused(_ user: UserRef, _ invoiceId: String, _ refusal: BadgeIssueFailure) async { + await MainActor.run { + if active(user) && openStorePurchases(user.userId).contains(where: { $0.invoiceId == invoiceId }) { + showAlert(refusal.purchaseAlertTitle, message: refusal.purchaseText) + } + } + await storePurchaseResolved(user, invoiceId) + } + + private func storePurchaseResolved(_ user: UserRef, _ invoiceId: String) async { + // the row goes before the store is read, or the screen shows it open while the store answers; + // the transaction is finished whoever owns it, as only the app can, while the cached row is the active profile's + await MainActor.run { + if active(user), let open = storePurchases, open.userId == user.userId { + storePurchases = (open.userId, open.purchases.filter { $0.invoiceId != invoiceId }) + } + } + for await verification in Transaction.unfinished { + let receipt = storeReceipt(verification) + if receipt.echoedInvoiceId == invoiceId { + await receipt.transaction.finish() + return + } + } + } + + // at launch, on return to the foreground and on a profile switch, never on a timer + func presentUnfinished() async { + await listenForTransactions() + for await verification in Transaction.unfinished { + await reconcile(verification) + } + await loadCurrentBadgeState() + await MainActor.run { reconciledOnce = true } + } + + // transactions the store settles outside a purchase call, such as an approved Ask to Buy + @MainActor + private func listenForTransactions() { + if transactionUpdates == nil { + transactionUpdates = Task.detached { + for await verification in Transaction.updates { + await BadgeStore.shared.reconcile(verification) + } + } + } + } + + // an unverified one-time transaction is never sent, and left unfinished rather than lost + private func reconcile(_ verification: VerificationResult) async { + let receipt = storeReceipt(verification) + if !badgeOneTimeProductIds.contains(receipt.productId) { + await receipt.transaction.finish() + } else if receipt.signatureVerified { + // the receipt stays unfinished, so the next sweep hands it over again + do { try await handOver(receipt) } catch let error { + logger.error("BadgeStore.reconcile: \(responseError(error))") + await MainActor.run { + // showAlert presents on the top view controller, which the launch sweep can precede; + // an alert with nowhere to go must not spend the one this run gets + if !sweepFailureShown, getTopViewController() != nil { + sweepFailureShown = true + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(error, purchase: true)) + } + } + } + } + } + @MainActor private func startLoading() -> Bool { switch state { @@ -169,15 +361,25 @@ private func compactPrice(_ product: Product) -> String { : product.displayPrice } -private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt { - var environment: String? = nil - if #available(iOS 16.0, *) { environment = t.environment.rawValue } +private func storeReceipt(_ verification: VerificationResult) -> BadgeStoreReceipt { + let (t, signatureVerified) = switch verification { + case let .verified(t): (t, true) + case let .unverified(t, _): (t, false) + } return BadgeStoreReceipt( - jws: jws, + jws: verification.jwsRepresentation, productId: t.productID, - transactionId: t.id, invoiceId: t.appAccountToken, - environment: environment, - signatureVerified: signatureVerified + signatureVerified: signatureVerified, + transaction: t ) } + +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it; +// a sweep that hands over a receipt refused while the app was not running learns it only from this answer +private func badgeReceiptRefused(_ error: Error) -> Bool { + if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { + return code == .receiptInvalid || code == .receiptUsed || code == .testPurchase + } + return false +} diff --git a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift index 45d94c3ed6..10b68be473 100644 --- a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift +++ b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift @@ -9,15 +9,9 @@ import SwiftUI import SimpleXChat -struct BadgeUserPreview: View { +struct BadgeUserPreview: View { @EnvironmentObject var chatModel: ChatModel let level: BadgeLevel - let trailing: () -> Trailing - - init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) { - self.level = level - self.trailing = trailing - } var body: some View { let user = chatModel.currentUser @@ -29,12 +23,9 @@ struct BadgeUserPreview: View { ) return VStack(spacing: 12) { ProfileImage(imageStr: user?.image, size: 128) - HStack(alignment: .center, spacing: 6) { - NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) - .lineLimit(1) - .minimumScaleFactor(0.75) - trailing() - } + NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) + .lineLimit(1) + .minimumScaleFactor(0.75) } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift new file mode 100644 index 0000000000..2c0dd851ed --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift @@ -0,0 +1,131 @@ +// +// BadgesCheckOrderView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesCheckOrderView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var store = BadgeStore.shared + let level: BadgeLevel + let period: BadgePeriod + @State private var purchasing = false + // presented from this view, not AlertManager: its host is behind the sheet these views open in + @State private var alert: SomeAlert? + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("Check your order") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 0) { + orderRow("Your badge", Text(level.title)) + orderRow("Duration", Text(period.label)) + Divider() + .padding(.top, 8) + .padding(.bottom, 6) + orderRow("Total", period.priceText(store.price(level, period, compact: false))) + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.top, 20) + + Spacer(minLength: 20) + + VStack(spacing: 10) { + payButton() + .padding(.vertical, 10) + BadgeBillingFooter(period: period) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + .alert(item: $alert) { $0.alert } + } + + private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View { + HStack { + Text(title) + .foregroundColor(theme.colors.secondary) + Spacer() + value + .fontWeight(.medium) + } + .font(.body) + .padding(.vertical, 8) + } + + private func payButton() -> some View { + let price = store.price(level, period, compact: false) + let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId) + return Button { + purchase() + } label: { + period.payText(price) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func purchase() { + purchasing = true + Task { + do { + if case let .purchased(receipt) = try await store.purchase(level, period), !receipt.signatureVerified { + await MainActor.run { + alert = SomeAlert( + alert: mkAlert( + title: "Cannot verify this purchase", + message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store." + ), + id: "badgePurchaseUnverified" + ) + } + } + await MainActor.run { purchasing = false } + } catch let error { + logger.error("BadgesCheckOrderView.purchase: \(String(describing: error))") + await MainActor.run { + purchasing = false + alert = SomeAlert( + alert: Alert( + title: Text("Purchase error"), + message: Text(verbatim: redeemErrorText(error, purchase: true)) + ), + id: "badgePurchaseError" + ) + } + } + } + } +} + +struct BadgesCheckOrderView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesCheckOrderView(level: .supporter, period: .oneMonth) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift similarity index 67% rename from apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift rename to apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift index d47bcaa0a7..ba7b5b8613 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift @@ -1,5 +1,5 @@ // -// BadgesYourLevelView.swift +// BadgesChooseBadgeView.swift // SimpleX (iOS) // // Created by spaced4ndy on 28.07.2026. @@ -23,17 +23,24 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } - var filesDescription: LocalizedStringKey { + var fileSize: LocalizedStringKey { switch self { - case .supporter: "Send 2GB files" - case .legend: "Send 5GB files" + case .supporter: "Files up to 2 GB" + case .legend: "Files up to 5 GB" } } - var tagline: LocalizedStringKey { + var fileStorage: LocalizedStringKey { switch self { - case .supporter: "Optional profile badge\nand 2GB files" - case .legend: "Optional profile badge\nand 5GB files" + case .supporter: "Stored for 7 days" + case .legend: "Stored for 21 days" + } + } + + var summary: LocalizedStringKey { + switch self { + case .supporter: "Supporter: 2 GB files available for 7 days." + case .legend: "Legend: 5 GB files available for 21 days." } } @@ -45,32 +52,33 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } -struct BadgesYourLevelView: View { +struct BadgesChooseBadgeView: View { @EnvironmentObject var theme: AppTheme @ObservedObject private var store = BadgeStore.shared @State private var selectedLevel: BadgeLevel = .supporter @State private var continueActive = false - @State private var howItWorksActive = false var body: some View { GeometryReader { g in ScrollView { VStack(alignment: .center, spacing: 16) { - Text("Your level") + Text("Choose your badge") .font(.largeTitle) .bold() .foregroundColor(theme.colors.primary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - BadgeUserPreview(level: selectedLevel) { - Image(systemName: "chevron.down") - .font(.body) - .foregroundColor(theme.colors.primary) - } - .padding(.top, 4) + Text("Larger files that stay available longer.") + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 20) + BadgeUserPreview(level: selectedLevel) + .padding(.top, 4) + + Spacer(minLength: 12) // fixedSize + maxHeight on the cards so both match the taller one when a // store price wraps in one of them @@ -80,13 +88,20 @@ struct BadgesYourLevelView: View { } .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 20) + Spacer(minLength: 12) VStack(spacing: 10) { continueButton() .padding(.vertical, 10) - howItWorksButton() - .frame(height: 22) + // redeeming a code is here only when Support SimpleX offers the browser instead + Group { + if badgeBrowserAllowed { + RedeemCodeButton() + } else { + Color.clear + } + } + .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -110,17 +125,20 @@ struct BadgesYourLevelView: View { Image(badgeImageName(level.badgeType)) .resizable() .scaledToFit() - .frame(width: 60, height: 60) - .padding(.top, 20) + .frame(width: 44, height: 44) + .padding(.top, 16) Text(level.title) .font(.title3) .fontWeight(.bold) - Text(level.filesDescription) - .font(.subheadline) - .foregroundColor(theme.colors.secondary) BadgePeriod.monthly.priceText(store.price(level, .monthly)) .font(.body) - .padding(.bottom, 20) + VStack(spacing: 2) { + Text(level.fileSize) + Text(level.fileStorage) + } + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + .padding(.bottom, 16) } .multilineTextAlignment(.center) .padding(.horizontal, 12) @@ -145,30 +163,7 @@ struct BadgesYourLevelView: View { .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $continueActive) { - BadgesPayView(level: selectedLevel) - .modifier(ThemedBackground()) - } label: { - EmptyView() - } - .frame(width: 1, height: 1) - .hidden() - } - } - - private func howItWorksButton() -> some View { - ZStack { - Button { - howItWorksActive = true - } label: { - HStack(spacing: 4) { - Image(systemName: "info.circle") - Text("How badges protect your privacy").fontWeight(.medium) - } - .font(.body) - } - - NavigationLink(isActive: $howItWorksActive) { - BadgesHowItWorksView() + BadgesHowLongView(level: selectedLevel) .modifier(ThemedBackground()) } label: { EmptyView() @@ -179,10 +174,10 @@ struct BadgesYourLevelView: View { } } -struct BadgesYourLevelView_Previews: PreviewProvider { +struct BadgesChooseBadgeView_Previews: PreviewProvider { static var previews: some View { NavigationView { - BadgesYourLevelView() + BadgesChooseBadgeView() } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift new file mode 100644 index 0000000000..2377a79a3b --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -0,0 +1,222 @@ +// +// BadgesHowLongView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum BadgePeriod: String, CaseIterable, Identifiable { + case oneMonth + case monthly + case annual + + var id: String { rawValue } + + var icon: String { + switch self { + case .oneMonth: "calendar" + case .monthly: "arrow.clockwise" + case .annual: "arrow.clockwise" + } + } + + var label: LocalizedStringKey { + switch self { + case .oneMonth: "1 month" + case .monthly: "Monthly" + case .annual: "Annual" + } + } + + var months: Int { + switch self { + case .oneMonth: 1 + case .monthly: 1 + case .annual: 12 + } + } + + func priceText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text(verbatim: "…") + case .unavailable: return Text(verbatim: "—") + case let .price(p): + switch self { + case .oneMonth: return Text(verbatim: p) + case .monthly: return Text("\(p)/month") + case .annual: return Text("\(p)/year") + } + } + } + + func payText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text("Loading…") + case .unavailable: return Text("Not available") + case let .price(p): + switch self { + case .oneMonth: return Text("Pay \(p)") + case .monthly: return Text("Pay \(p)/month") + case .annual: return Text("Pay \(p)/year") + } + } + } +} + +struct BadgesHowLongView: View { + @EnvironmentObject var theme: AppTheme + @ObservedObject private var store = BadgeStore.shared + let level: BadgeLevel + @State private var selectedPeriod: BadgePeriod = badgePeriodsForSale.contains(.monthly) ? .monthly : .oneMonth + @State private var continueActive = false + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("How long?") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text(level.summary) + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: level) + .padding(.top, 4) + + Spacer(minLength: 20) + + // fixedSize + maxHeight on the cards so they all match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts + HStack(alignment: .top, spacing: 12) { + ForEach(BadgePeriod.allCases) { periodCard($0) } + } + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 20) + + VStack(spacing: 10) { + continueButton() + .padding(.vertical, 10) + BadgeBillingFooter(period: selectedPeriod) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + } + + private func periodCard(_ period: BadgePeriod) -> some View { + let isSelected = period == selectedPeriod + let forSale = badgePeriodsForSale.contains(period) + return Button { + selectedPeriod = period + } label: { + VStack(spacing: 12) { + Image(systemName: period.icon) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + .opacity(forSale ? 1 : 0.4) + Text(period.label) + .font(.subheadline) + period.priceText(store.price(level, period)) + .font(.headline) + if let percent = savingsPercent(period) { + Text("\(percent)% off") + .font(.footnote) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + } + } + .foregroundColor(forSale ? nil : theme.colors.secondary) + .multilineTextAlignment(.center) + .padding(.vertical, 25) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + } + .buttonStyle(.plain) + .disabled(!forSale) + } + + private func savingsPercent(_ period: BadgePeriod) -> Int? { + period == .annual ? store.annualSavings(level) : nil + } + + private func continueButton() -> some View { + ZStack { + Button { + continueActive = true + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $continueActive) { + BadgesCheckOrderView(level: level, period: selectedPeriod) + .modifier(ThemedBackground(grouped: true)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } +} + +struct BadgeBillingFooter: View { + @EnvironmentObject var theme: AppTheme + let period: BadgePeriod + + var body: some View { + Text(billingFooter) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(height: 22) + } + + private var billingFooter: LocalizedStringKey { + // TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld); + // a top-up must count from the end of the existing balance + let endDate = Calendar.current.date(byAdding: .month, value: period.months, to: Date()) ?? Date() + let date = DateFormatter.localizedString(from: endDate, dateStyle: .long, timeStyle: .none) + switch period { + case .monthly, .annual: return "Renews on \(date). Cancel anytime." + case .oneMonth: return "Ends on \(date)." + } + } +} + +struct BadgesHowLongView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesHowLongView(level: .supporter) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesPayView.swift b/apps/ios/Shared/Views/Badges/BadgesPayView.swift deleted file mode 100644 index 707bdb3059..0000000000 --- a/apps/ios/Shared/Views/Badges/BadgesPayView.swift +++ /dev/null @@ -1,269 +0,0 @@ -// -// BadgesPayView.swift -// SimpleX (iOS) -// -// Created by spaced4ndy on 28.07.2026. -// Copyright © 2026 SimpleX Chat. All rights reserved. -// - -import SwiftUI -import SimpleXChat - -// TODO [badges]: replace with types produced by the badge purchase API when it lands. -enum BadgePeriod: String, CaseIterable, Identifiable { - case oneMonth - case monthly - case annual - - var id: String { rawValue } - - var icon: String { - switch self { - case .oneMonth: "calendar" - case .monthly: "arrow.clockwise" - case .annual: "arrow.clockwise" - } - } - - var label: LocalizedStringKey { - switch self { - case .oneMonth: "1 month" - case .monthly: "Monthly" - case .annual: "Annual" - } - } - - func priceText(_ price: BadgePrice) -> Text { - switch price { - case .loading: return Text(verbatim: "…") - case .unavailable: return Text(verbatim: "—") - case let .price(p): - switch self { - case .oneMonth: return Text(verbatim: p) - case .monthly: return Text("\(p)/month") - case .annual: return Text("\(p)/year") - } - } - } - - func payText(_ price: BadgePrice) -> Text { - switch price { - case .loading: return Text("Loading…") - case .unavailable: return Text("Not available") - case let .price(p): - switch self { - case .oneMonth: return Text("Pay \(p)") - case .monthly: return Text("Pay \(p)/month") - case .annual: return Text("Pay \(p)/year") - } - } - } -} - -struct BadgesPayView: View { - @EnvironmentObject var theme: AppTheme - @ObservedObject private var store = BadgeStore.shared - let level: BadgeLevel - @State private var selectedPeriod: BadgePeriod = .monthly - @State private var purchasing = false - // presented from this view, not AlertManager: its host is behind the sheet these views open in - @State private var alert: SomeAlert? - - var body: some View { - GeometryReader { g in - ScrollView { - VStack(alignment: .center, spacing: 16) { - Text(level.title) - .font(.largeTitle) - .bold() - .foregroundColor(theme.colors.primary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - - BadgeUserPreview(level: level) - .padding(.top, 4) - - Text(level.tagline) - .font(.body) - .foregroundColor(theme.colors.onBackground) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 4) - - Spacer(minLength: 20) - - // fixedSize + maxHeight on the cards so all three match the tallest one - - // only Annual carries a savings line, and prices wrap at large fonts - HStack(alignment: .top, spacing: 12) { - periodCard(.oneMonth) - periodCard(.monthly) - periodCard(.annual) - } - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 20) - - VStack(spacing: 10) { - payButton() - .padding(.vertical, 10) - Text(billingFooter) - .font(.footnote) - .foregroundColor(theme.colors.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .frame(height: 22) - } - .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) - } - .padding(.horizontal, 25) - .padding(.top, 0) - .padding(.bottom, 20) - .frame(minHeight: g.size.height) - } - } - .frame(maxHeight: .infinity) - .navigationBarTitleDisplayMode(.inline) - .task { await store.load() } - .alert(item: $alert) { $0.alert } - } - - private func periodCard(_ period: BadgePeriod) -> some View { - let isSelected = period == selectedPeriod - return Button { - selectedPeriod = period - } label: { - VStack(spacing: 12) { - Image(systemName: period.icon) - .resizable() - .scaledToFit() - .frame(width: 32, height: 32) - .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) - Text(period.label) - .font(.title3) - .fontWeight(.bold) - period.priceText(store.price(level, period)) - .font(.body) - if let percent = savingsPercent(period) { - Text("Save \(percent)%") - .font(.footnote) - .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) - } - } - .multilineTextAlignment(.center) - .padding(.vertical, 25) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) - ) - } - .buttonStyle(.plain) - } - - private func savingsPercent(_ period: BadgePeriod) -> Int? { - period == .annual ? store.annualSavings(level) : nil - } - - private func payButton() -> some View { - let price = store.price(level, selectedPeriod) - let disabled = !price.canPurchase || purchasing - return Button { - purchase() - } label: { - selectedPeriod.payText(price) - } - .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) - .disabled(disabled) - } - - private func purchase() { - let period = selectedPeriod - let invoiceId = newBadgeInvoiceId() - purchasing = true - Task { - do { - let outcome = try await store.purchase(level, period, invoiceId: invoiceId) - await MainActor.run { - purchasing = false - switch outcome { - case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId) - case .pending: - alert = SomeAlert( - alert: mkAlert( - title: "Purchase pending", - message: "The purchase is awaiting approval. This build does not deliver purchases approved later." - ), - id: "badgePurchasePending" - ) - case .cancelled: break - } - } - } catch let error { - logger.error("BadgesPayView.purchase: \(String(describing: error))") - await MainActor.run { - purchasing = false - alert = SomeAlert( - alert: Alert( - title: Text("Purchase error"), - message: Text(verbatim: String(describing: error)) - ), - id: "badgePurchaseError" - ) - } - } - } - } - - // TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. - private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) { - let returnedInvoice: String - if let returned = receipt.invoiceId { - returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)" - } else { - returnedInvoice = "none" - } - var lines = [ - "Product: \(receipt.productId)", - "Invoice: \(invoiceId.uuidString)", - "Invoice returned by Apple: \(returnedInvoice)", - "Transaction: \(receipt.transactionId)" - ] - if let environment = receipt.environment { lines.append("Environment: \(environment)") } - lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")") - lines.append("Token: \(receipt.jws.count) bytes") - let summary = lines.joined(separator: "\n") - // logged as well as shown: the alert races StoreKit's own sheets, the log always lands - logger.debug("badge purchase succeeded\n\(summary)") - alert = SomeAlert( - alert: Alert( - title: Text("Purchase successful"), - message: Text(verbatim: summary), - primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws }, - secondaryButton: .cancel(Text("Ok")) - ), - id: "badgePurchased" - ) - } - - private var billingFooter: LocalizedStringKey { - // TODO [badges] source the actual date from the purchase state machine when wired. - var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22 - let stubDate = Calendar.current.date(from: comps) ?? Date() - let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) - switch selectedPeriod { - case .monthly, .annual: return "Renews on \(date). Cancel anytime." - case .oneMonth: return "Ends on \(date)." - } - } -} - -struct BadgesPayView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - BadgesPayView(level: .supporter) - } - } -} diff --git a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift new file mode 100644 index 0000000000..702304acd4 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift @@ -0,0 +1,100 @@ +// +// BadgesPurchaseStateView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesPurchaseStateView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) private var dismiss + let title: LocalizedStringKey + var message: LocalizedStringKey? = nil + var failure: BadgeIssueFailure? = nil + var showsAsSheet: Bool = false + + var body: some View { + GeometryReader { g in + ScrollView { + content + .padding(.horizontal, 25) + .padding(.top, showsAsSheet ? 48 : 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private var content: some View { + VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + if let message { + Text(message) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + if let failure { + VStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text(failure.purchaseText) + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + ProgressView().scaleEffect(2) + + Spacer() + + VStack(spacing: 10) { + Button { + dismiss() + } label: { + Text("Dismiss") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + .padding() + } + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + } + } +} + +extension BadgePurchaseState { + var title: LocalizedStringKey { + switch self { + case .issuing: "Issuing your badge" + case .waitingForApproval: "Waiting for approval" + } + } + + var message: LocalizedStringKey { + switch self { + case .issuing: "Your payment is complete. The badge will be added to this profile." + case .waitingForApproval: "Nothing has been charged." + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index 6ea0c58edb..0cf0ec0673 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -33,10 +33,52 @@ private func formatBadgeCodeInput(_ s: String) -> String { return groups.joined(separator: "-") } +enum BadgeRedeemOutcome { + case redeemed + case refused(message: String) + case cancelled +} + +// sets the badge before returning, so a caller that dismisses on .redeemed lands on Your Badge +// instead of showing the switch from Support. .cancelled when the user cancels the retry alert. +func redeemBadgeCode(_ user: User, _ code: String) async -> BadgeRedeemOutcome { + do { + guard let redeemed = try await apiRedeemBadgeCode(user.userId, code) else { return .cancelled } + return await MainActor.run { () -> BadgeRedeemOutcome in + BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) + ChatModel.shared.updateUser(redeemed.user) + if let badgeState = redeemed.badgeState, !badgeState.shown { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + return .refused(message: redeemed.newBadge + ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") + : NSLocalizedString("This code has already been used.", comment: "alert message") + ) + } + UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) + return .redeemed + } + } catch let error { + logger.error("apiRedeemBadgeCode: \(responseError(error))") + return .refused(message: redeemErrorText(error, purchase: false)) + } +} + +func showCannotRedeemAlert(_ message: String) { + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) +} + +// a hidden profile is named only while it is the active one, which is unlocked and on screen +func showBadgeAddedAlert(_ user: any UserLike) { + guard let owner = ChatModel.shared.getUser(user.userId), active(owner) || !owner.hidden else { return } + showAlert( + NSLocalizedString("Badge added", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("The badge was added to the profile %@.", comment: "alert message"), owner.displayName) + ) +} + struct BadgesRedeemCodeView: View { @EnvironmentObject var theme: AppTheme @EnvironmentObject var chatModel: ChatModel - @AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false @Environment(\.dismiss) var dismiss: DismissAction @State private var code = "" @State private var canonicalCode: String? = nil @@ -184,39 +226,217 @@ struct BadgesRedeemCodeView: View { guard let sending = canonicalCode, let user = chatModel.currentUser else { return } submitting = true Task { - do { - guard let redeemed = try await apiRedeemBadgeCode(user.userId, sending) else { - await MainActor.run { submitting = false } - return - } - await MainActor.run { - submitting = false - // set before dismissing: BadgesView then switches Support to Your Badge while this screen - // still covers it, so the pop lands on Your Badge instead of showing the switch - BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) - chatModel.updateUser(redeemed.user) - if let badgeState = redeemed.badgeState, !badgeState.shown { - // a replay adds no purchase; a fresh code's badge can be retired on arrival - let message = redeemed.newBadge - ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") - : NSLocalizedString("This code has already been used.", comment: "alert message") - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) - } else { - supporterBannerShown = true - dismiss() - } - } - } catch let error { - logger.error("apiRedeemBadgeCode: \(responseError(error))") - await MainActor.run { - submitting = false - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error)) + let outcome = await redeemBadgeCode(user, sending) + await MainActor.run { + submitting = false + switch outcome { + case .redeemed: dismiss() + case let .refused(message): showCannotRedeemAlert(message) + case .cancelled: break } } } } } +private weak var badgeLinkSheet: UIViewController? +// the open screen's own state, not a copy of its step, so the two cannot disagree +private weak var badgeLinkState: BadgeLinkState? + +func isBadgeLinkIssuing() -> Bool { + badgeLinkSheet?.presentingViewController != nil && badgeLinkState?.step == .issuing +} + +func openBadgeLink(_ codeText: String) { + guard let code = parseBadgeCode(codeText) else { + return showCannotRedeemAlert(NSLocalizedString("This code is not valid.", comment: "alert message")) + } + let state = BadgeLinkState() + badgeLinkSheet = showAppSheet { + NavigationView { + BadgesRedeemLinkView(code: code, state: state) + .modifier(ThemedBackground()) + } + } + badgeLinkState = state +} + +enum BadgeLinkStep { + case confirming + case issuing + case redeemed + case viewingBadge +} + +final class BadgeLinkState: ObservableObject { + @Published var step = BadgeLinkStep.confirming +} + +// Any web page can send a badge link, and a profile holds one badge at a time, +// so this screen asks before redeeming, names the profile, and offers nothing but the redemption. +// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an +// interrupted request is not resumed, and the code stays on the page that issued the link. +struct BadgesRedeemLinkView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var badgeModel = BadgeModel.shared + let code: String + @ObservedObject var state: BadgeLinkState + + var body: some View { + switch state.step { + case .confirming: if profileHasBadge { badgeHeld() } else { confirming() } + case .issuing: beingIssued() + case .redeemed, .viewingBadge: BadgesView(showsAsSheet: true) + } + } + + // read for the profile the screen names, when it renders, so the two cannot disagree; + // core refuses a code while the profile shows a badge + private var profileHasBadge: Bool { + badgeModel.userId == chatModel.currentUser?.userId && badgeModel.badgeState?.shown == true + } + + // the screen cannot tell a repeated link for the badge it shows from a code for another badge, + // so it asserts neither + private func badgeHeld() -> some View { + linkStep( + "Profile already has a badge", + primary: ("View your badge", { state.step = .viewingBadge }), + textButton: ("Cancel", { closeIfShowing() }) + ) { + linkText(NSLocalizedString("A badge can be added to another profile, or here once this badge ends.", comment: "badge link, profile has a badge")) + linkText(NSLocalizedString("Its code is on the page you bought it on, under Show code.", comment: "badge link, profile has a badge")) + } + } + + private func confirming() -> some View { + linkStep( + "Add badge to your profile?", + primary: ("Add badge", { redeemFromLink() }), + textButton: ("Cancel", { closeIfShowing() }) + ) { + linkText(String.localizedStringWithFormat(NSLocalizedString("The badge will be added to the profile %@.", comment: "badge link confirmation"), chatModel.currentUser?.displayName ?? "")) + } + } + + // leaving does not cancel: the badge is still added after the screen closes + private func beingIssued() -> some View { + linkStep("Issuing your badge", textButton: ("Dismiss", { closeIfShowing() })) { + linkText(NSLocalizedString("The badge will be added to this profile.", comment: "badge link, being issued")) + Spacer() + ProgressView().scaleEffect(2) + } + } + + // with no primary, the text button takes its place, so a button does not move between steps + private func linkStep( + _ title: LocalizedStringKey, + primary: (label: LocalizedStringKey, action: () -> Void)? = nil, + textButton: (label: LocalizedStringKey, action: () -> Void), + @ViewBuilder content: () -> Content + ) -> some View { + let textButtonLabel = Text(textButton.label) + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + return VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + content() + + Spacer() + + VStack(spacing: 10) { + if let primary { + Button { + primary.action() + } label: { + Text(primary.label) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .padding(.vertical, 10) + + Button { + textButton.action() + } label: { + textButtonLabel + } + .frame(height: 22) + } else { + Button { + textButton.action() + } label: { + // the inset OnboardingButtonStyle gives a primary + textButtonLabel.padding() + } + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + } + } + .padding(.horizontal, 25) + .padding(.top, 48) + .padding(.bottom, 20) + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func linkText(_ text: String) -> some View { + Text(text) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + private func redeemFromLink() { + // a second tap before the screen changes must not send the code again + guard state.step == .confirming else { return } + guard let user = chatModel.currentUser else { return closeIfShowing() } + state.step = .issuing + Task { + let outcome = await redeemBadgeCode(user, code) + await MainActor.run { + switch outcome { + case .redeemed: + if !isShowing { showBadgeAddedAlert(user) } + // a covered screen is not left on the spinner; a closed one is not written to + if isOpen { state.step = .redeemed } + case let .refused(message): closeIfShowing { showCannotRedeemAlert(message) } + case .cancelled: closeIfShowing() + } + } + } + } + + private var isOpen: Bool { + badgeLinkState === state && badgeLinkSheet?.presentingViewController != nil + } + + // an alert over this screen still counts: dismissing from the presenter dismisses it too + private var isShowing: Bool { + guard isOpen, let sheet = badgeLinkSheet else { return false } + return sheet.presentedViewController == nil || sheet.presentedViewController is UIAlertController + } + + // An outcome can arrive after this screen was closed or covered: it must not close another screen, + // and a covered one goes back to asking, so it is never left locked on the spinner. + private func closeIfShowing(then: @escaping () -> Void = {}) { + if isShowing, let presenter = badgeLinkSheet?.presentingViewController { + presenter.dismiss(animated: true, completion: then) + } else { + if isOpen { state.step = .confirming } + then() + } + } +} + struct BadgesRedeemCodeView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 55d05f2615..97e91bfb69 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -11,13 +11,14 @@ import SimpleXChat struct BadgesSupportSimplexView: View { @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var store = BadgeStore.shared // set true when presented as a sheet root (from the chat-list banner) — that path doesn't // reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top var showsAsSheet: Bool = false @State private var whyBuiltActive = false @State private var howItWorksActive = false @State private var chooseLevelActive = false - @State private var redeemCodeActive = false var body: some View { GeometryReader { g in @@ -34,8 +35,6 @@ struct BadgesSupportSimplexView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - // TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen - // returns to the flow and howItWorksButton() moves there, leaving this one alone here. howItWorksButton() Spacer(minLength: 0) @@ -46,11 +45,27 @@ struct BadgesSupportSimplexView: View { Spacer(minLength: 0) + // In-app purchase is not released: the badge is chosen on the site, and the code + // redeemed here. Revert this commit to restore the block below. + // VStack(spacing: 10) { + // chooseLevelButton() + // .padding(.vertical, 10) + // Group { + // if badgeBrowserAllowed { + // buyInBrowserButton() + // } else { + // RedeemCodeButton() + // } + // } + // .frame(height: 22) + // } VStack(spacing: 10) { - redeemCodeButton() + chooseBadgeOnSiteButton() .padding(.vertical, 10) - getCodeButton() - .frame(height: 22) + Group { + RedeemCodeButton() + } + .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -65,18 +80,19 @@ struct BadgesSupportSimplexView: View { .navigationBarTitleDisplayMode(.inline) } - // the in-app purchase path, kept compiling and uncalled until payments return after the MVP private func chooseLevelButton() -> some View { - ZStack { + let disabled = !store.canBuy(chatModel.currentUser?.userId) + return ZStack { Button { chooseLevelActive = true } label: { - Text("Choose your level") + Text("Choose your badge") } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) NavigationLink(isActive: $chooseLevelActive) { - BadgesYourLevelView() + BadgesChooseBadgeView() .modifier(ThemedBackground()) } label: { EmptyView() @@ -125,14 +141,42 @@ struct BadgesSupportSimplexView: View { } } - private func redeemCodeButton() -> some View { + // In-app purchase is not released: delete with the commented block above to restore it. + private func chooseBadgeOnSiteButton() -> some View { + Button { + UIApplication.shared.open(URL(string: badgePageUrl)!) + } label: { + Text("Choose your badge") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + } + + private func buyInBrowserButton() -> some View { + Button { + UIApplication.shared.open(URL(string: badgePageUrl)!) + } label: { + Text("Buy in browser") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + } +} + +struct RedeemCodeButton: View { + @EnvironmentObject var theme: AppTheme + @State private var redeemCodeActive = false + + var body: some View { ZStack { Button { redeemCodeActive = true } label: { Text("Redeem badge code") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $redeemCodeActive) { BadgesRedeemCodeView() @@ -144,17 +188,6 @@ struct BadgesSupportSimplexView: View { .hidden() } } - - private func getCodeButton() -> some View { - Button { - openExternalLink(URL(string: "https://simplex.chat/badges/")!) - } label: { - Text("Get your code") - .font(.body) - .fontWeight(.medium) - .foregroundColor(theme.colors.primary) - } - } } struct PhoneSupporterHero: View { diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index df12094a6b..c6a9749dd4 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct BadgesView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject private var badgeModel = BadgeModel.shared + @ObservedObject private var store = BadgeStore.shared var showsAsSheet: Bool = false private var shownBadge: BadgeState? { @@ -25,15 +26,28 @@ struct BadgesView: View { if let badgeState = shownBadge { BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet) .transition(.opacity) + } else if let purchaseState = store.purchaseState(chatModel.currentUser?.userId) { + // holds the purchase screens' slot, so a consumable cannot be bought twice + BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet) + .transition(.opacity) + } else if store.checkingPurchases { + BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet) + .transition(.opacity) } else { BadgesSupportSimplexView(showsAsSheet: showsAsSheet) .transition(.opacity) } } .animation(.default, value: shownBadge != nil) + .animation(.default, value: store.purchaseState(chatModel.currentUser?.userId)) + .animation(.default, value: store.checkingPurchases) + .onAppear { badgesViewShown = true } + .onDisappear { badgesViewShown = false } } } +private(set) var badgesViewShown = false + var supportSimpleXAlertAction: UIAlertAction { UIAlertAction(title: NSLocalizedString("Support SimpleX", comment: "alert button"), style: .default) { _ in openBadgesView() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift index aaaa29929d..1eebc5cf19 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift @@ -50,7 +50,7 @@ struct CIChatLinkHeader: View { HStack(alignment: .top) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: 44, color: Color(uiColor: .tertiaryLabel) ) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index fecd1ba891..efec44e943 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -115,7 +115,7 @@ struct CIGroupInvitationView: View { return HStack(alignment: .top) { ProfileImage( imageStr: groupInvitation.groupProfile.image, - iconName: "person.2.circle.fill", + icon: Image(systemName: "person.2.circle.fill"), size: 44, color: color ) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index f822970ea4..b6bac78d46 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -334,7 +334,7 @@ struct FramedItemView: View { @inline(__always) private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, markdownBlocks: qi.markdownBlocks, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) .lineLimit(lines) .padding(.bottom, 6) } @@ -364,13 +364,16 @@ struct FramedItemView: View { chat: chat, text: text, formattedText: ft, + markdownBlocks: text == "" ? nil : ci.markdownBlocks, textStyle: .body, meta: ci.meta, mentions: ci.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, prefix: txtPrefix, - stripLink: stripLink + stripLink: stripLink, + blockLayout: true, + cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote ) .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index f95e31369e..10bd784b7e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -32,6 +32,7 @@ struct MsgContentView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil + var markdownBlocks: [MarkdownBlock]? = nil var textStyle: UIFont.TextStyle var sender: String? = nil var meta: CIMeta? = nil @@ -40,6 +41,8 @@ struct MsgContentView: View { var rightToLeft = false var prefix: NSAttributedString? = nil var stripLink: String? = nil + var blockLayout = false + var cardColor: Color? = nil @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? @@ -51,7 +54,7 @@ struct MsgContentView: View { @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true var body: some View { - let v = msgContentView() + let v = contentView() if meta?.isLive == true { v.onAppear { let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) @@ -95,11 +98,67 @@ struct MsgContentView: View { typingIdx = 0 } + @ViewBuilder + private func contentView() -> some View { + let fts = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } + if blockLayout, let fts, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: textStyle, + cardColor: cardColor ?? theme.appColors.receivedQuote, + rightToLeft: rightToLeft, + blockText: { index, range, font, secretStart, last in blockTextView(fts, blocks, index, range, font, secretStart, last) }, + metaLine: { metaLineView() } + ) + } else { + msgContentView() + } + } + + private func blockTextView(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> some View { + let r = messageText( + text, + fts, + markdownBlocks: blocks, + textStyle: textStyle, + sender: index == 0 ? sender : nil, + mentions: mentions, + userMemberId: userMemberId, + showSecrets: showSecrets, + commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady, + backgroundColor: containerBackground, + prefix: index == 0 ? prefix : nil, + range: range, + baseFont: font, + secretStart: secretStart + ) + let s = r.string + let t: Text + if last, let mt = meta { + if mt.isLive { + s.append(typingIndicator(mt.recent)) + } + t = Text(AttributedString(s)) + reserveSpaceForMeta(mt) + } else { + t = Text(AttributedString(s)) + } + return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) }) + } + + @ViewBuilder + private func metaLineView() -> some View { + if let mt = meta { + (mt.isLive ? Text(AttributedString(typingIndicator(mt.recent))) : Text(verbatim: "")) + reserveSpaceForMeta(mt, newLine: false) + } + } + @inline(__always) private func msgContentView() -> some View { let r = messageText( text, formattedText, + markdownBlocks: markdownBlocks, textStyle: textStyle, sender: sender, mentions: mentions, @@ -131,8 +190,8 @@ struct MsgContentView: View { } @inline(__always) - private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) + private func reserveSpaceForMeta(_ mt: CIMeta, newLine: Bool = true) -> Text { + (rightToLeft && newLine ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) } } @@ -317,8 +376,11 @@ private struct FullProfileDescriptionView: View { .listRowBackground(Color.clear) Section { - let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background) - msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(description) + MarkdownView( + text: description, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } @@ -338,9 +400,11 @@ func markdownText( showSecrets: Set? = nil, backgroundColor: Color ) -> MsgTextResult { - messageText( + let parsed = parseSimpleXMarkdownText(s) + return messageText( s, - parseSimpleXMarkdown(s), + parsed?.formattedText, + markdownBlocks: parsed?.markdownBlocks, textStyle: textStyle, sender: sender, preview: preview, @@ -396,6 +460,7 @@ func markdownProfileDescription( func messageText( _ text: String, _ formattedText: [FormattedText]?, + markdownBlocks: [MarkdownBlock]? = nil, textStyle: UIFont.TextStyle = .body, sender: String?, preview: Bool = false, @@ -405,21 +470,24 @@ func messageText( commands: Bool = false, backgroundColor: UIColor, prefix: NSAttributedString? = nil, - stripLink: String? = nil + stripLink: String? = nil, + range: Range? = nil, + baseFont: UIFont? = nil, + secretStart: Int = 0 ) -> MsgTextResult { let text = if let stripLink { stripTextLink(text, stripLink) } else { text } let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } let res = NSMutableAttributedString() - let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) - let font = UIFont.preferredFont(forTextStyle: textStyle) + let descr = baseFont?.fontDescriptor ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) + let font = baseFont ?? UIFont.preferredFont(forTextStyle: textStyle) let plain: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: UIColor.label ] let secretColor = backgroundColor.withAlphaComponent(1) - var link: [NSAttributedString.Key: Any]? var hasSecrets = false var handleTaps = false + var secretIdx: Int = secretStart if let sender { if preview { @@ -437,19 +505,57 @@ func messageText( } if let fts = formattedText, fts.count > 0 { + if let range { + let start = min(max(range.lowerBound, 0), fts.count) + appendSegments(fts[start.. (descr: UIFontDescriptor, font: UIFont) { + if !heading { return (descr, font) } + let d = descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]) + return (d, UIFont(descriptor: d, size: d.pointSize)) + } + + func appendSegments(_ fts: ArraySlice, _ heading: Bool) { + let (descr, font) = segmentFonts(heading) + let plain: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.label + ] + var link: [NSAttributedString.Key: Any]? var bold: UIFont? var italic: UIFont? var snippet: UIFont? var small: UIFont? var mention: UIFont? - var secretIdx: Int = 0 for ft in fts { var t = ft.text var attrs = plain switch (ft.format) { - case .bold: - bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) - attrs[.font] = bold + case let .bold(singleStar): + if singleStar && markdownBlocks != nil { + italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) + attrs[.font] = italic + } else { + bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) + attrs[.font] = bold + } case .italic: italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) attrs[.font] = italic @@ -496,7 +602,7 @@ func messageText( if let text { t = text } if !preview { attrs[linkAttrKey] = uri - attrs[webLinkAttrKey] = true + if !ft.isEmailLink { attrs[webLinkAttrKey] = true } handleTaps = true } case let .simplexLink(text, linkType, simplexUri, smpHosts): @@ -563,24 +669,319 @@ func messageText( attrs[modalAttrKey] = Format.modal(modalName: modalName, text: text) handleTaps = true } + case .escape: t = "" + case let .markup(token): + if range != nil { t = token.drawnText } else if markdownBlocks != nil { t = token.previewText } case .unknown: () case .none: () } res.append(NSAttributedString(string: t, attributes: attrs)) } - } else { - res.append(NSMutableAttributedString(string: text, attributes: plain)) - } - - return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps) - func linkAttrs() -> [NSAttributedString.Key: Any] { - link = link ?? [ - .font: font, - .foregroundColor: uiLinkColor, - .underlineStyle: NSUnderlineStyle.single.rawValue - ] - return link! + func linkAttrs() -> [NSAttributedString.Key: Any] { + link = link ?? [ + .font: font, + .foregroundColor: uiLinkColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + return link! + } + } +} + +let swipeExclusionSpace = "swipeExclusion" + +private let maxGridCells = 1000 + +struct SwipeExclusionFrames: PreferenceKey { + static var defaultValue: [CGRect] = [] + + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value += nextValue() + } +} + +struct MarkdownView: View { + var text: String + var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? + var sender: String? = nil + var mentions: [String: CIMention]? = nil + var userMemberId: String? = nil + var backgroundColor: Color + var cardColor: Color + @Binding var showSecrets: Set + + var body: some View { + if let fts = formattedText, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: .body, + cardColor: cardColor, + rightToLeft: isRightToLeft(text), + blockText: { index, range, font, secretStart, _ in + let r = messageText( + text, fts, markdownBlocks: blocks, sender: index == 0 ? sender : nil, mentions: mentions, userMemberId: userMemberId, + showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor), range: range, baseFont: font, secretStart: secretStart + ) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + }, + metaLine: { EmptyView() } + ) + } else { + let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + } + } +} + +private struct BlocksLayout { + let font: UIFont + let starts: [Int] + let secrets: [Int] + let lastIsText: Bool + + init(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ textStyle: UIFont.TextStyle) { + font = UIFont.preferredFont(forTextStyle: textStyle) + starts = blocks.reduce(into: [0]) { $0.append($0[$0.count - 1] + $1.textRanges.count) } + secrets = fts.reduce(into: [0]) { $0.append($0[$0.count - 1] + ($1.isSecret ? 1 : 0)) } + lastIsText = switch blocks.last { + case .paragraph, .heading, .quote, .list: true + default: false + } + } +} + +struct MarkdownBlocksView: View { + @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness + var formattedText: [FormattedText] + var blocks: [MarkdownBlock] + var textStyle: UIFont.TextStyle + var cardColor: Color + var rightToLeft: Bool + @ViewBuilder var blockText: (_ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> BlockText + @ViewBuilder var metaLine: () -> MetaLine + + var body: some View { + let l = BlocksLayout(formattedText, blocks, textStyle) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(blocks.enumerated()), id: \.offset) { k, block in + if k > 0 && paragraphBreak(blocks[k - 1].endIndex, block.startIndex) { + Color.clear.frame(height: l.font.lineHeight / 2) + } + blockView(block, l.starts[k], l) + } + if !l.lastIsText { + metaLine() + } + } + .multilineTextAlignment(.leading) + .environment(\.layoutDirection, rightToLeft ? .rightToLeft : .leftToRight) + } + + private func text(_ index: Int, _ r: SegmentRange, _ font: UIFont, _ l: BlocksLayout) -> BlockText { + blockText(index, r.from.. some View { + switch block { + case let .paragraph(content): + text(start, content, l.font, l) + case let .heading(level, content): + text(start, content, headingFont(l.font, level), l) + case let .quote(content): + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2).fill(Color(uiColor: .label).opacity(0.16)).frame(width: 4) + text(start, content, l.font, l).opacity(0.72) + } + .fixedSize(horizontal: false, vertical: true) + case let .list(items): + ForEach(Array(listEntries(items, 0).enumerated()), id: \.offset) { j, e in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(e.entry.number ?? "•").font(.system(size: l.font.pointSize)) + text(start + j, e.entry.content, l.font, l) + } + .padding(.leading, 20 * CGFloat(e.level)) + } + case let .codeBlock(_, content): + CodeBlockCard(color: cardColor, radius: 12 * roundness, copyText: codeText(content)) { + text(start, content, monospacedFont(l.font), l).fixedSize(horizontal: true, vertical: false) + } + case let .table(columns, header, rows): + tableView(columns, [header] + rows, start, l) + case .rule: + Rectangle().fill(Color(uiColor: .secondaryLabel).opacity(0.35)).frame(height: 1).padding(.vertical, 6) + } + } + + @ViewBuilder private func tableView(_ columns: [TableAlign], _ tableRows: [[SegmentRange]], _ start: Int, _ l: BlocksLayout) -> some View { + let rowStarts = tableRows.reduce(into: [start]) { $0.append($0[$0.count - 1] + $1.count) } + let columnCount = max(columns.count, tableRows.map(\.count).max() ?? 0) + let lineColor = Color(uiColor: .secondaryLabel).opacity(0.35) + let radius = 12 * roundness + if #available(iOS 16, *) { + if columnCount * tableRows.count <= maxGridCells { + HScroll { + Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { + ForEach(Array(tableRows.enumerated()), id: \.offset) { r, row in + GridRow { + ForEach(0.. some View { + let source = tableSource(tableRows) + return CodeBlockCard(color: cardColor, radius: radius, copyText: source) { + Text(verbatim: source).font(.system(size: l.font.pointSize * 0.875, design: .monospaced)).fixedSize(horizontal: true, vertical: false) + } + } + + private func paragraphBreak(_ from: Int, _ to: Int) -> Bool { + let start = min(max(from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max(r.from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max((tableRows.first?.first?.from ?? 1) - 1, 0), formattedText.count) + let end = min((tableRows.last?.last?.to ?? 0) + 1, formattedText.count) + return formattedText[start.. UIFont { + let scale: CGFloat = switch level { + case 1: 1.5 + case 2: 1.3 + case 3: 1.15 + default: 1 + } + return UIFont(descriptor: boldFont(font).fontDescriptor, size: font.pointSize * scale) +} + +private func boldFont(_ font: UIFont) -> UIFont { + UIFont(descriptor: font.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: font.pointSize) +} + +private func monospacedFont(_ font: UIFont) -> UIFont { + UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.875, weight: .regular) +} + +private func cellAlignment(_ align: TableAlign) -> Alignment { + switch align { + case .right: .trailing + case .center: .center + default: .leading + } +} + +private struct CodeBlockCard: View { + @EnvironmentObject var theme: AppTheme + var color: Color + var radius: CGFloat + var copyText: String + @ViewBuilder var content: () -> Content + @State private var showCopy = false + + var body: some View { + HScroll { + content() + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .topTrailing) { + if showCopy { + Button { + UIPasteboard.general.string = copyText + showCopy = false + } label: { + Image(systemName: "doc.on.doc") + .foregroundColor(theme.colors.primary) + .frame(width: 28, height: 28) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius * 2 / 3)) + } + .padding(4) + } + } + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .onTapGesture { showCopy.toggle() } + } +} + +private struct HScroll: View { + @ViewBuilder var content: () -> Content + + var body: some View { + if #available(iOS 16, *) { + ViewThatFits(in: .horizontal) { + content() + scrollView() + } + } else { + scrollView() + } + } + + private func scrollView() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + content() + } + .background { + GeometryReader { g in + Color.clear.preference(key: SwipeExclusionFrames.self, value: [g.frame(in: .named(swipeExclusionSpace))]) + } + } } } @@ -608,7 +1009,9 @@ func stripTextLink(_ text: String, _ link: String) -> String { func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? { guard var ft, ft.last?.text == link else { return ft } ft.removeLast() - if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { + if let i = ft.indices.last, case .markup = ft[i].format { + ft.removeLast() + } else if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { ft[i].text = String(ft[i].text.dropLast()) if ft[i].text.isEmpty { ft.removeLast() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index ffcf525300..3fff0e34d1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -267,7 +267,7 @@ struct ChatItemInfoView: View { private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { let backgroundColor = chatItemFrameColor(ci, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor) + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, itemVersion.markdownBlocks, nil, backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(backgroundColor) @@ -295,9 +295,12 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View { + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ markdownBlocks: [MarkdownBlock]?, _ sender: String? = nil, backgroundColor: Color) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) + TextBubble( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote + ) } else { Text("no text") .italic() @@ -309,15 +312,19 @@ struct ChatItemInfoView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? var sender: String? = nil var mentions: [String: CIMention]? var userMemberId: String? var backgroundColor: Color + var cardColor: Color @State private var showSecrets: Set = [] var body: some View { - let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + MarkdownView( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: cardColor, showSecrets: $showSecrets + ) } } @@ -342,7 +349,7 @@ struct ChatItemInfoView: View { private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { let backgroundColor = quotedMsgFrameColor(qi, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor) + textBubble(qi.text, qi.formattedText, qi.markdownBlocks, qi.getSender(nil), backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(quotedMsgFrameColor(qi, theme)) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index d18d8ae145..2a903fcd91 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1735,6 +1735,7 @@ struct ChatView: View { @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil @State private var swipeOffset: CGFloat = 0 + @State private var swipeExclusionFrames: [CGRect] = [] var revealed: Bool { revealedItems.contains(chatItem.id) } @@ -1968,7 +1969,7 @@ struct ChatView: View { .padding(.trailing, 12) } HStack(alignment: .top, spacing: 10) { - ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + ProfileImage(imageStr: groupInfo.image, icon: Image(systemName: groupInfo.chatIconName), size: memberImageSize, backgroundColor: theme.colors.background) .simultaneousGesture(TapGesture().onEnded { showChatInfoSheet = true }) @@ -2052,7 +2053,7 @@ struct ChatView: View { } HStack(alignment: .top, spacing: 10) { if ci.meta.showGroupAsSender { - ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + ProfileImage(imageStr: groupInfo.image, icon: Image(systemName: groupInfo.chatIconName), size: memberImageSize, backgroundColor: theme.colors.background) .simultaneousGesture(TapGesture().onEnded { showChatInfoSheet = true }) @@ -2156,12 +2157,14 @@ struct ChatView: View { .padding(.bottom, 4) } } + .coordinateSpace(name: swipeExclusionSpace) + .onPreferenceChange(SwipeExclusionFrames.self) { swipeExclusionFrames = $0 } .offset(x: swipeOffset) .contentShape(Rectangle()) .simultaneousGesture( - DragGesture(minimumDistance: 10) + DragGesture(minimumDistance: 10, coordinateSpace: .named(swipeExclusionSpace)) .onChanged { value in - guard canReply else { return } + guard canReply, !swipeExclusionFrames.contains(where: { $0.contains(value.startLocation) }) else { return } let x = value.translation.width if x < 0 { swipeOffset = max(x * 0.63, -56) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift index 650ea8a87f..78f29c3b60 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift @@ -11,7 +11,7 @@ struct ComposeChatLinkView: View { HStack(alignment: .center, spacing: 8) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: 44 ) .padding(.leading, 12) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 1f328b2061..13614692cb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> some View { - let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) + let r = messageText(contextItem.text, contextItem.formattedText, markdownBlocks: contextItem.markdownBlocks, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) let t = attachment() + Text(AttributedString(r.string)) return t.if(r.hasSecrets, transform: hiddenSecretsView) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 126fcc57b3..19478753af 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -37,7 +37,7 @@ struct GroupProfileView: View { var body: some View { List { - EditProfileImage(profileImage: $groupProfile.image, iconName: groupInfo.chatIconName, showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $groupProfile.image, icon: Image(systemName: groupInfo.chatIconName), showChooseSource: $showChooseSource) .if(!focusDisplayName) { $0.padding(.top) } Section { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index f58f2c213d..1bef869a66 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -59,8 +59,11 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(welcomeText) + return MarkdownView( + text: welcomeText, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 5ee991a466..7f50f09a31 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -299,7 +299,7 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { let (itemText, itemFormattedText) = chatItemPreviewText(cItem) - let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) + let r = messageText(itemText, itemFormattedText, markdownBlocks: itemFormattedText == nil ? nil : cItem.markdownBlocks, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) { @@ -444,7 +444,7 @@ struct ChatPreviewView: View { smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: dynamicMediaSize, color: Color(uiColor: .tertiaryLabel) ) diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift index 51ae63f07e..d505cdead7 100644 --- a/apps/ios/Shared/Views/Helpers/AppSheet.swift +++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift @@ -31,14 +31,18 @@ private struct PrivacySensitive: ViewModifier { // Presented from the top view controller instead of a .sheet on a parent view, so an alert button or // a view that is itself in a sheet can open it. -func showAppSheet(@ViewBuilder content: () -> Content) { +@discardableResult +func showAppSheet(@ViewBuilder content: () -> Content) -> UIViewController? { if let topController = getTopViewController() { let v = content() .modifier(PrivacySensitive()) .environmentObject(ChatModel.shared) .environmentObject(AppTheme.shared) - topController.present(UIHostingController(rootView: v), animated: true) + let sheet = UIHostingController(rootView: v) + topController.present(sheet, animated: true) + return sheet } + return nil } extension View { diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift index 40d62e009b..a0f21aef9b 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -19,7 +19,7 @@ struct ChatInfoImage: View { let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color } return ProfileImage( imageStr: chat.chatInfo.image, - iconName: chatIconName(chat.chatInfo), + icon: chatIcon(chat.chatInfo), size: size, color: iconColor ) diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index 9c2916880c..05b4a06967 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -12,7 +12,7 @@ import SimpleXChat struct ProfileImage: View { @EnvironmentObject var theme: AppTheme var imageStr: String? = nil - var iconName: String = "person.crop.circle.fill" + var icon: Image = Image(systemName: "person.crop.circle.fill") var size: CGFloat var color = Color(uiColor: .tertiarySystemGroupedBackground) var backgroundColor: Color? = nil @@ -24,7 +24,7 @@ struct ProfileImage: View { clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred) } else { let c = color.asAnotherColorFromSecondaryVariant(theme) - Image(systemName: iconName) + icon .resizable() .foregroundColor(c) .scaledToFit() diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 7d1e5ce827..fa953b2ec1 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -50,7 +50,7 @@ struct AddChannelView: View { Spacer(minLength: 0) ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128) + ProfileImage(imageStr: profile.image, icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: 128) if profile.image != nil { Button { profile.image = nil @@ -285,7 +285,7 @@ struct AddChannelView: View { let total = groupRelays.count return List { Group { - ProfileImage(imageStr: gInfo.groupProfile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128) + ProfileImage(imageStr: gInfo.groupProfile.image, icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: 128) .frame(maxWidth: .infinity, alignment: .center) Text(gInfo.groupProfile.displayName) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 47afee5f06..a4f02e51ba 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -71,7 +71,7 @@ struct AddGroupView: View { Spacer(minLength: 0) ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128) + ProfileImage(imageStr: profile.image, icon: Image(systemName: "person.2.circle.fill"), size: 128) if profile.image != nil { Button { profile.image = nil diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index ef691b10f9..b558802d36 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1129,11 +1129,9 @@ private func showPrepareContactAlert( profileImage: ProfileImage( imageStr: contactShortLinkData.profile.image, - iconName: contactShortLinkData.business - ? "briefcase.circle.fill" - : contactShortLinkData.profile.peerType == .bot - ? "cube.fill" - : "person.crop.circle.fill", + icon: contactShortLinkData.business + ? Image(systemName: "briefcase.circle.fill") + : peerTypeIcon(contactShortLinkData.profile.peerType), size: alertProfileImageSize ), profileBadge: contactShortLinkData.localBadge, @@ -1186,9 +1184,9 @@ private func showPrepareGroupAlert( profileImage: ProfileImage( imageStr: groupShortLinkData.groupProfile.image, - iconName: isChannel + icon: Image(systemName: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" - : "person.2.circle.fill", + : "person.2.circle.fill"), size: alertProfileImageSize ), theme: theme, @@ -1240,7 +1238,7 @@ private func showOpenKnownContactAlert( profileImage: ProfileImage( imageStr: contact.profile.image, - iconName: contact.chatIconName, + icon: contact.chatIcon, size: alertProfileImageSize ), profileBadge: contact.active ? contact.profile.localBadge : nil, @@ -1288,7 +1286,7 @@ private func showOpenKnownGroupAlert( profileImage: ProfileImage( imageStr: groupInfo.groupProfile.image, - iconName: groupInfo.chatIconName, + icon: Image(systemName: groupInfo.chatIconName), size: alertProfileImageSize ), theme: theme, @@ -1605,7 +1603,7 @@ func planAndConnect( profileImage: ProfileImage( imageStr: groupSLinkData.groupProfile.image, - iconName: "antenna.radiowaves.left.and.right.circle.fill", + icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: alertProfileImageSize ), theme: theme, @@ -1632,7 +1630,7 @@ func planAndConnect( profileImage: ProfileImage( imageStr: groupSLinkData.groupProfile.image, - iconName: "person.2.circle.fill", + icon: Image(systemName: "person.2.circle.fill"), size: alertProfileImageSize ), theme: theme, diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 8fe8a1c45a..64c877f9cd 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -29,7 +29,7 @@ struct UserProfile: View { var body: some View { List { - EditProfileImage(profileImage: $profile.image, iconName: "person.crop.circle.fill", showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $profile.image, icon: Image(systemName: "person.crop.circle.fill"), showChooseSource: $showChooseSource) .padding(.top) Section { @@ -221,7 +221,7 @@ struct EditProfileImage: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @Binding var profileImage: String? - var iconName: String + var icon: Image @Binding var showChooseSource: Bool var body: some View { @@ -237,7 +237,7 @@ struct EditProfileImage: View { } } else { ZStack(alignment: .center) { - ProfileImage(imageStr: profileImage, iconName: iconName, size: 160) + ProfileImage(imageStr: profileImage, icon: icon, size: 160) editImageButton { showChooseSource = true } } } diff --git a/apps/ios/SimpleX SE/ShareView.swift b/apps/ios/SimpleX SE/ShareView.swift index 3bdbc950ba..fd163f74e5 100644 --- a/apps/ios/SimpleX SE/ShareView.swift +++ b/apps/ios/SimpleX SE/ShareView.swift @@ -25,7 +25,7 @@ struct ShareView: View { HStack { profileImage( chatInfoId: chat.chatInfo.id, - iconName: chatIconName(chat.chatInfo), + icon: chatIcon(chat.chatInfo), size: 30 ) Text(chat.chatInfo.displayName).foregroundStyle( @@ -209,11 +209,11 @@ struct ShareView: View { .background(Material.ultraThin) } - @ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, iconName: String, size: Double) -> some View { + @ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, icon: Image, size: Double) -> some View { if let uiImage = model.profileImages[chatInfoId] { clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius) } else { - Image(systemName: iconName) + icon .resizable() .foregroundColor(Color(uiColor: .tertiaryLabel)) .frame(width: size, height: size) diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 72bd9b0dc3..06edbf9ce9 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -42,6 +42,16 @@ simplex + + CFBundleTypeRole + Editor + CFBundleURLName + chat.simplex.app.deeplink + CFBundleURLSchemes + + simplexchat + + ITSAppUsesNonExemptEncryption diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3d9bbd111a..ef12bda216 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -166,6 +166,10 @@ 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; }; 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; }; 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; + 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */; }; + 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2B30666C640032110E /* BadgesHowLongView.swift */; }; + 64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */; }; + 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 */; }; @@ -182,13 +186,11 @@ 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; - 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; }; 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; }; 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; }; 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; }; 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; }; 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; }; - 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; }; 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; @@ -269,6 +271,8 @@ E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; + E5C0BBEB2F82B45500EA7527 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */; }; + E5C0BBEC2F82B45500EA7527 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */; }; E5D5A0012F9B0000AAAA0001 /* DirectorySearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5D5A0022F9B0000AAAA0001 /* DirectorySearch.swift */; }; E5D5A0032F9B0000AAAA0001 /* DirectorySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5D5A0042F9B0000AAAA0001 /* DirectorySearchView.swift */; }; E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */; }; @@ -561,6 +565,10 @@ 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; + 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = ""; }; + 647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = ""; }; + 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesCheckOrderView.swift; sourceTree = ""; }; + 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 = ""; }; @@ -579,11 +587,9 @@ 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; - 64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; 64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = ""; }; - 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; @@ -660,6 +666,7 @@ E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = ""; }; + E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = ""; }; E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; E5D5A0022F9B0000AAAA0001 /* DirectorySearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectorySearch.swift; sourceTree = ""; }; @@ -954,6 +961,7 @@ 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */, + E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */, D7AA2C3429A936B400737B40 /* MediaEncryption.playground */, 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); @@ -1226,14 +1234,16 @@ 64C03BF8302F423300072BDE /* Badges */ = { isa = PBXGroup; children = ( + 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */, + 647C9E2B30666C640032110E /* BadgesHowLongView.swift */, + 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */, + 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */, 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */, 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, - 64C03BF1302F423300072BDE /* BadgesPayView.swift */, 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */, 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */, 64C03BF4302F423300072BDE /* BadgeStore.swift */, - 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */, 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */, 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */, 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */, @@ -1506,6 +1516,7 @@ 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, + E5C0BBEB2F82B45500EA7527 /* SharedAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1538,6 +1549,7 @@ files = ( E5DCF9712C590272007928CC /* Localizable.strings in Resources */, E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, + E5C0BBEC2F82B45500EA7527 /* SharedAssets.xcassets in Resources */, E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1661,13 +1673,11 @@ 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */, 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, - 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */, 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */, 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */, 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */, 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */, 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */, - 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */, 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */, @@ -1684,6 +1694,10 @@ CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, + 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */, + 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */, + 64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */, + 64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */, 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 3b860d524b..04e5973085 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -149,12 +149,15 @@ public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTi } public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { + parseSimpleXMarkdownText(s)?.formattedText +} + +public func parseSimpleXMarkdownText(_ s: String) -> ParsedMarkdown? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_markdown(&c) { if let d = dataFromCString(cjson) { do { - let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) - return r.formattedText + return try jsonDecoder.decode(ParsedMarkdown.self, from: d) } catch { logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)") } @@ -176,8 +179,9 @@ public func parseBadgeCode(_ s: String) -> String? { return canonical.isEmpty ? nil : canonical } -struct ParsedMarkdown: Decodable { - var formattedText: [FormattedText]? +public struct ParsedMarkdown: Decodable { + public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? } public func parseServerAddress(_ s: String) -> ServerAddress? { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 65b22532b2..9b9bdeada9 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -871,6 +871,7 @@ public enum ChatErrorType: Decodable, Hashable { public enum BadgeRedeemError: Decodable, Hashable { case invalidCode + case invalidReceipt case serviceNotConfigured case badgeActive case serviceError(serviceError: BadgeServiceErrorCode) @@ -891,12 +892,14 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case paymentNotEntitled case paymentPending case providerUnavailable + case providerNotConfigured case rateLimited case codeInvalid case codeUsed case codeExpired case receiptInvalid case receiptUsed + case testPurchase case internalError case unknown(String) @@ -912,11 +915,13 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case .paymentNotEntitled: "payment_not_entitled" case .paymentPending: "payment_pending" case .providerUnavailable: "provider_unavailable" + case .providerNotConfigured: "provider_not_configured" case .rateLimited: "rate_limited" case .codeInvalid: "code_invalid" case .codeUsed: "code_used" case .codeExpired: "code_expired" case .receiptInvalid: "receipt_invalid" + case .testPurchase: "test_purchase" case .receiptUsed: "receipt_used" case .internalError: "internal" case let .unknown(s): s @@ -935,11 +940,13 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case "payment_not_entitled": self = .paymentNotEntitled case "payment_pending": self = .paymentPending case "provider_unavailable": self = .providerUnavailable + case "provider_not_configured": self = .providerNotConfigured case "rate_limited": self = .rateLimited case "code_invalid": self = .codeInvalid case "code_used": self = .codeUsed case "code_expired": self = .codeExpired case "receipt_invalid": self = .receiptInvalid + case "test_purchase": self = .testPurchase case "receipt_used": self = .receiptUsed case "internal": self = .internalError case let s: self = .unknown(s) @@ -956,8 +963,14 @@ public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? { case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") case .unknownPurchaseKey: NSLocalizedString("The badge service does not recognize this badge.", comment: "alert message") case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message") - case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable, - .paymentNotEntitled, .paymentPending, .providerUnavailable, .receiptInvalid, .receiptUsed, .unknown: nil + case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message") + case .testPurchase: NSLocalizedString("Sandbox purchases are not credited. Badges can only be bought in the App Store version of the app.", comment: "alert message") + case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message") + case .paymentPending: NSLocalizedString("The payment has not been confirmed yet.", comment: "alert message") + case .providerUnavailable: NSLocalizedString("The store could not be reached.", comment: "alert message") + case .providerNotConfigured: NSLocalizedString("Badges cannot be bought in the app yet.", comment: "alert message") + case .productUnavailable: NSLocalizedString("This badge is not on sale.", comment: "alert message") + case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .paymentNotEntitled, .unknown: nil } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 186d7dab64..8bdd952372 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -228,6 +228,7 @@ public enum ChatPeerType: Hashable { case human case bot case business + case agent case unknown(String) public var text: String { @@ -235,6 +236,7 @@ public enum ChatPeerType: Hashable { case .human: "human" case .bot: "bot" case .business: "business" + case .agent: "agent" case let .unknown(s): s } } @@ -246,6 +248,7 @@ extension ChatPeerType: Codable { case "human": self = .human case "bot": self = .bot case "business": self = .business + case "agent": self = .agent case let s: self = .unknown(s) } } @@ -256,6 +259,14 @@ extension ChatPeerType: Codable { } } +public func peerTypeIcon(_ peerType: ChatPeerType?) -> Image { + switch peerType { + case .bot: Image(systemName: "cube.fill") + case .agent: Image("agent") + default: Image(systemName: "person.crop.circle.fill") + } +} + // Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. // Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. public enum BadgeType: Hashable { @@ -369,6 +380,24 @@ public enum BadgeIssueFailure: Decodable, Hashable { } } + public var purchaseText: String { + switch self { + case let .serviceError(code, _): + badgeServiceErrorText(code) + ?? String.localizedStringWithFormat(NSLocalizedString("The badge service refused the purchase: %@", comment: "badge purchase error"), code.text) + case .invalidCredential: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") + case .serviceTimeout, .network, .unexpected: text + } + } + + public var purchaseAlertTitle: String { + switch self { + case .serviceError(.testPurchase, _): + NSLocalizedString("Sandbox purchase", comment: "alert title") + default: NSLocalizedString("Purchase error", comment: "alert title") + } + } + // the stored form, for support public var tag: String { switch self { @@ -2514,8 +2543,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { activeConn?.customUserProfileId != nil } - public var chatIconName: String { - isBot ? "cube.fill" : "person.crop.circle.fill" + public var chatIcon: Image { + peerTypeIcon(profile.peerType) } public func allowsFeature(_ feature: ChatFeature) -> Bool { @@ -3764,11 +3793,12 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, markdownBlocks: [MarkdownBlock]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText + self.markdownBlocks = markdownBlocks self.mentions = mentions self.quotedItem = quotedItem self.reactions = reactions @@ -3779,6 +3809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var meta: CIMeta public var content: CIContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var mentions: [String: CIMention]? public var quotedItem: CIQuote? public var reactions: [CIReactionCount] @@ -3788,7 +3819,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file + case chatDir, meta, content, formattedText, markdownBlocks, mentions, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -4830,6 +4861,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) @@ -5643,14 +5675,119 @@ public struct FormattedText: Decodable, Hashable { public var linkUri: String? { switch format { case .uri: text - case let .hyperLink(_, linkUri): linkUri + case let .hyperLink(_, linkUri): isEmailLink ? nil : linkUri default: nil } } + + public var isEmailLink: Bool { + if case let .hyperLink(_, linkUri) = format { linkUri.hasPrefix("mailto:") } else { false } + } +} + +public enum BlockToken: Decodable, Hashable { + case lineBreak + case softBreak + case hardBreak + case paragraphBreak(lineBreaks: Int) + case indent + case headingMark(level: Int) + case headingClose + case headingUnderline(level: Int) + case listMark(number: String?) + case quoteMark + case fenceMark(language: String?) + case rowMark + case cellMark + case delimiterRow(columns: [TableAlign]) + case ruleMark + + public var drawnText: String { + switch self { + case .softBreak: " " + case .lineBreak, .hardBreak: "\n" + case let .paragraphBreak(lineBreaks): String(repeating: "\n", count: lineBreaks) + default: "" + } + } + + public var previewText: String { + switch self { + case .softBreak, .hardBreak, .cellMark: " " + case .lineBreak, .paragraphBreak: "\n" + case let .listMark(number): (number ?? "•") + " " + default: "" + } + } +} + +public enum MarkdownBlock: Decodable, Hashable { + case paragraph(content: SegmentRange) + case heading(level: Int, content: SegmentRange) + case codeBlock(language: String?, content: SegmentRange) + case quote(content: SegmentRange) + case list(items: [ListEntry]) + case table(columns: [TableAlign], header: [SegmentRange], rows: [[SegmentRange]]) + case rule(mark: SegmentRange) +} + +public struct ListEntry: Decodable, Hashable { + public var number: String? + public var content: SegmentRange + public var items: [ListEntry] +} + +public struct SegmentRange: Decodable, Hashable { + public var from: Int + public var to: Int +} + +public enum TableAlign: String, Decodable, Hashable { + case none + case left + case center + case right +} + +public struct BlockTextRange { + public var range: SegmentRange + public var inRow: Bool +} + +extension MarkdownBlock { + public var textRanges: [BlockTextRange] { + switch self { + case let .paragraph(content), let .heading(_, content), let .codeBlock(_, content), let .quote(content): + [BlockTextRange(range: content, inRow: false)] + case let .list(items): + listEntries(items, 0).map { BlockTextRange(range: $0.entry.content, inRow: false) } + case let .table(_, header, rows): + ([header] + rows).flatMap { row in row.enumerated().map { c, cell in BlockTextRange(range: cell, inRow: c > 0) } } + case .rule: + [] + } + } +} + +public func listEntries(_ items: [ListEntry], _ level: Int) -> [(entry: ListEntry, level: Int)] { + items.flatMap { [(entry: $0, level: level)] + listEntries($0.items, level + 1) } +} + +public func blockTextRanges(_ blocks: [MarkdownBlock]) -> [BlockTextRange] { + blocks.flatMap { $0.textRanges } +} + +public func headingSegments(_ size: Int, _ blocks: [MarkdownBlock]) -> [Bool] { + var bold = [Bool](repeating: false, count: size) + for case let .heading(_, content) in blocks { + let from = min(max(content.from, 0), size) + for i in from.. Bool { } } -public func chatIconName(_ cInfo: ChatInfo) -> String { +public func chatIcon(_ cInfo: ChatInfo) -> Image { switch cInfo { - case let .direct(contact): contact.chatIconName - case let .group(groupInfo, _): groupInfo.chatIconName - case .local: "folder.circle.fill" - case .contactRequest: "person.crop.circle.fill" - default: "circle.fill" + case let .direct(contact): contact.chatIcon + case let .group(groupInfo, _): Image(systemName: groupInfo.chatIconName) + case .local: Image(systemName: "folder.circle.fill") + case .contactRequest: Image(systemName: "person.crop.circle.fill") + default: Image(systemName: "circle.fill") } } diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 552805e381..be6245da7d 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -201,10 +201,11 @@ func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String { if let md = cItem.formattedText { var res = "" for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text + switch ft.format { + case .secret: res = res + "..." + case let .markup(token): res = res + token.previewText + case .escape: () + default: res = res + ft.text } } return res diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index a7b1fec2a3..08d80735a9 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -189,7 +189,7 @@ Renders emoji-only messages (messages containing only emoji characters) in a lar **File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) -Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of `markdownBlocks` (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by `MarkdownBlocksView`; code blocks and tables scroll sideways. ### DeletedItemView / MarkedDeletedItemView diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md index 0dafc24484..c61ae3e0af 100644 --- a/apps/ios/spec/client/navigation.md +++ b/apps/ios/spec/client/navigation.md @@ -200,6 +200,8 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181). +App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectViaUrl_()` branches on that scheme before the path switch, so an app link never reaches `planAndConnect()`, and `openAppLink()` dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to `openBadgeLink()` in [`BadgesRedeemCodeView.swift`](../../Shared/Views/Badges/BadgesRedeemCodeView.swift), which presents `BadgesRedeemLinkView` as an app sheet. The sheet names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the sheet renders), which core would refuse, the sheet does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown (`showBadgeAddedAlert()`, which the store lane shares). While the sheet shows a redemption in flight (`isBadgeLinkIssuing()`), `connectViaUrl_()` ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so `onOpenURL` logs it as "app link" rather than the URL. + ### Call Deep Link Call invitations from notifications: diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md index 1be8a1a630..79e7ff59e3 100644 --- a/apps/multiplatform/CODE.md +++ b/apps/multiplatform/CODE.md @@ -288,9 +288,13 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) | Source Location | Spec Document | Product Document | |----------------|---------------|-----------------| -| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md | +| desktop/.../desktop/Main.kt | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md | | common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | | common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md | +| common/.../common/AppLinks.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md | +| common/.../common/SingleInstance.kt (desktopMain) | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md | +| common/.../common/WindowActivation.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | +| common/.../common/platform/AppLinkScheme.desktop.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md | | common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md | | common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md | | common/.../common/platform/AnimatedImage.desktop.kt (desktopMain) | spec/client/chat-view.md | product/views/chat.md | diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index 1d8fc5e151..0cad378da6 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -5,6 +5,7 @@ import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} @@ -22,3 +23,11 @@ suspend fun loadBadgeProducts(oneTimeIds: List, subscriptio @Suppress("UNUSED_PARAMETER") suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + +@Suppress("UNUSED_PARAMETER") +suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} + +@Suppress("UNUSED_PARAMETER") +suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {} + +suspend fun unfinishedBadgePurchases(): List = emptyList() diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 4075421e52..85b9da1316 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -5,6 +5,7 @@ import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry import chat.simplex.common.platform.mainActivity import chat.simplex.common.views.badges.* +import chat.simplex.common.views.helpers.withLongRunningApi import com.android.billingclient.api.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -40,7 +41,7 @@ fun loadPlayStoreCountry() { // One long-lived client for badges: ProductDetails obtained from it are passed back to it when the // purchase is launched, and the purchase result arrives on its listener rather than as a return value. -// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher +// volatile: written by the purchase on a background dispatcher, read by the listener on another thread @Volatile private var badgeBillingClient: BillingClient? = null @Volatile private var badgeOffers: Map = emptyMap() @Volatile private var badgePurchase: CompletableDeferred? = null @@ -80,8 +81,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable val client = connectedBadgeBillingClient() val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId) - val details = offer.details - val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details) + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(offer.details) offer.offerToken?.let { productParams.setOfferToken(it) } val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productParams.build())) @@ -94,19 +94,42 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc if (launched.responseCode != BillingClient.BillingResponseCode.OK) { throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) } - val outcome = purchase.await() - if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt) - return outcome + return purchase.await() } finally { badgePurchase = null } } +// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment +suspend fun unfinishedBadgePurchases(): List { + val client = connectedBadgeBillingClient() + val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build() + val queried = CompletableDeferred>() + client.queryPurchasesAsync(params) { result, purchases -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queried.complete(purchases) + } else { + queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + } + } + return queried.await().mapNotNull(::badgePurchaseOutcome) +} + private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> - val pending = badgePurchase ?: return@PurchasesUpdatedListener + val pending = badgePurchase + if (pending == null) { + // settled outside a purchase call, such as a slow payment completing + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } } + } + return@PurchasesUpdatedListener + } when { - result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> - pending.complete(badgePurchaseOutcome(purchases)) + result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> { + val outcomes = purchases.mapNotNull(::badgePurchaseOutcome) + pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled) + withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } } + } result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> pending.complete(BadgePurchaseOutcome.Cancelled) else -> @@ -114,17 +137,17 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p } } -private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcome { - val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled - if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending - return BadgePurchaseOutcome.Purchased( +private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) { + Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId) + Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = purchase.purchaseToken, productId = purchase.products.firstOrNull() ?: "", - orderId = purchase.orderId, - invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId + invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId, + acknowledged = purchase.isAcknowledged ) ) + else -> null } private suspend fun connectedBadgeBillingClient(): BillingClient { @@ -203,21 +226,34 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { return BadgeOffer(id, product, this, offer.offerToken) } -// nothing is delivered in this build, so the purchase is finished right away; once the service issues -// credentials it must only be finished after the credential is stored. One-time products are consumed -// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged -// purchase after 3 days. -private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) { - val done = CompletableDeferred() - if (details.productType == BillingClient.ProductType.SUBS) { - val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() - client.acknowledgePurchase(params) { done.complete(it) } - } else { +// acknowledged without consuming, so Play stops its 3-day refund clock and still lists the purchase until it is finished +suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) { + if (!receipt.acknowledged) acknowledge(connectedBadgeBillingClient(), receipt.token) +} + +// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase +// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase +suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) { + val client = connectedBadgeBillingClient() + if (receipt.productId in badgeOneTimeProductIds) { + val done = CompletableDeferred() val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() client.consumeAsync(params) { result, _ -> done.complete(result) } - } - val result = done.await() - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}") + checkBillingResult(done.await()) + } else { + acknowledge(client, receipt.token) + } +} + +private suspend fun acknowledge(client: BillingClient, token: String) { + val done = CompletableDeferred() + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build() + client.acknowledgePurchase(params) { done.complete(it) } + checkBillingResult(done.await()) +} + +private fun checkBillingResult(result: BillingResult) { + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) } } diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 5fa9c7fefa..1cce1126a9 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -74,6 +74,15 @@ + + + + + + + + + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 1a4c836b2e..9093b1427b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -28,7 +28,9 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStore import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt import chat.simplex.common.views.call.* import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* @@ -113,6 +115,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } }.onFailure { Log.e(TAG, it.stackTraceToString()) } } + BadgeStore.presentUnfinished() } } Lifecycle.Event.ON_RESUME -> { @@ -352,6 +355,12 @@ class SimplexApp: Application(), LifecycleEventObserver { override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) + override suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) = acknowledgeBadgePurchase(receipt) + + override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt) + + override suspend fun androidUnfinishedBadgePurchases(): List = unfinishedBadgePurchases() + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt index a5e105ce93..f192b4da35 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt @@ -25,6 +25,9 @@ val defaultLocale: Locale = Locale.getDefault() actual fun isAppVisibleAndFocused(): Boolean = isAppOnForeground +// the manifest declares the scheme +actual fun appLinkSchemeRegistered(): Boolean = true + @SuppressLint("StaticFieldLeak") lateinit var androidAppContext: Context var mainActivity: WeakReference = WeakReference(null) 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 65ce8a4438..016a3532ca 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 @@ -1969,7 +1969,7 @@ data class Contact( activeConn?.customUserProfileId != null val chatIconName: ImageResource get() = - if (isBot) MR.images.ic_cube else MR.images.ic_account_circle_filled + peerTypeIcon(profile.peerType) fun allowsFeature(feature: ChatFeature): Boolean = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO @@ -2180,6 +2180,7 @@ sealed class ChatPeerType { @Serializable @SerialName("human") object Human: ChatPeerType() @Serializable @SerialName("bot") object Bot: ChatPeerType() @Serializable @SerialName("business") object Business: ChatPeerType() + @Serializable @SerialName("agent") object Agent: ChatPeerType() @Serializable @SerialName("unknown") data class Unknown(val type: String): ChatPeerType() val text: String @@ -2187,6 +2188,7 @@ sealed class ChatPeerType { is Human -> "human" is Bot -> "bot" is Business -> "business" + is Agent -> "agent" is Unknown -> type } } @@ -2198,11 +2200,19 @@ object ChatPeerTypeSerializer : KSerializer { "human" -> ChatPeerType.Human "bot" -> ChatPeerType.Bot "business" -> ChatPeerType.Business + "agent" -> ChatPeerType.Agent else -> ChatPeerType.Unknown(v) } override fun serialize(encoder: Encoder, value: ChatPeerType) = encoder.encodeString(value.text) } +fun peerTypeIcon(peerType: ChatPeerType?): ImageResource = + when (peerType) { + ChatPeerType.Bot -> MR.images.ic_cube + ChatPeerType.Agent -> MR.images.ic_agent + else -> MR.images.ic_account_circle_filled + } + // Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. // Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. @Serializable(with = BadgeTypeSerializer::class) @@ -2301,6 +2311,12 @@ sealed class BadgeIssueFailure { is Unexpected -> String.format(generalGetString(MR.strings.badges_error_unexpected), message) } + val purchaseText: String get() = when (this) { + is ServiceError -> badgeServiceErrorText(code) ?: String.format(generalGetString(MR.strings.badges_error_purchase_refused), code.text) + is InvalidCredential -> generalGetString(MR.strings.badges_error_credential_not_verified) + is ServiceTimeout, is Network, is Unexpected -> text + } + // the stored form, for support val tag: String get() = when (this) { is ServiceError -> "serviceError ${if (retryable) "retry" else "final"} ${code.text}" @@ -3406,6 +3422,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val markdownBlocks: List? = null, val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, @@ -4411,7 +4428,8 @@ class CIQuote ( val sharedMsgId: String? = null, val sentAt: Instant, val content: MsgContent, - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ): ItemContent { override val text: String by lazy { if (content.text == "" && content is MsgContent.MCVoice) @@ -5163,7 +5181,7 @@ class FormattedText(val text: String, val format: Format? = null) { val linkUri: String? get() = when (format) { is Format.Uri -> text - is Format.HyperLink -> format.linkUri + is Format.HyperLink -> if (format.isEmail) null else format.linkUri else -> null } @@ -5174,7 +5192,7 @@ class FormattedText(val text: String, val format: Format? = null) { @Serializable sealed class Format { - @Serializable @SerialName("bold") class Bold: Format() + @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() @Serializable @SerialName("italic") class Italic: Format() @Serializable @SerialName("strikeThrough") class StrikeThrough: Format() @Serializable @SerialName("snippet") class Snippet: Format() @@ -5182,7 +5200,9 @@ sealed class Format { @Serializable @SerialName("small") class Small: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() + @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() { + val isEmail: Boolean get() = linkUri.startsWith("mailto:") + } @Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() { val simplexLinkText: String get() = "${linkType.description} $viaHosts" @@ -5199,6 +5219,8 @@ sealed class Format { } @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() @Serializable @SerialName("unknown") class Unknown: Format() val style: SpanStyle @Composable get() = when (this) { @@ -5218,6 +5240,8 @@ sealed class Format { is Email -> linkStyle is Phone -> linkStyle is Modal -> linkStyle + is Escape -> SpanStyle() + is Markup -> SpanStyle() is Unknown -> SpanStyle() } @@ -5228,6 +5252,87 @@ sealed class Format { } } +@Serializable +sealed class BlockToken { + @Serializable @SerialName("lineBreak") class LineBreak: BlockToken() + @Serializable @SerialName("softBreak") class SoftBreak: BlockToken() + @Serializable @SerialName("hardBreak") class HardBreak: BlockToken() + @Serializable @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int): BlockToken() + @Serializable @SerialName("indent") class Indent: BlockToken() + @Serializable @SerialName("headingMark") class HeadingMark(val level: Int): BlockToken() + @Serializable @SerialName("headingClose") class HeadingClose: BlockToken() + @Serializable @SerialName("headingUnderline") class HeadingUnderline(val level: Int): BlockToken() + @Serializable @SerialName("listMark") class ListMark(val number: String? = null): BlockToken() + @Serializable @SerialName("quoteMark") class QuoteMark: BlockToken() + @Serializable @SerialName("fenceMark") class FenceMark(val language: String? = null): BlockToken() + @Serializable @SerialName("rowMark") class RowMark: BlockToken() + @Serializable @SerialName("cellMark") class CellMark: BlockToken() + @Serializable @SerialName("delimiterRow") class DelimiterRow(val columns: List): BlockToken() + @Serializable @SerialName("ruleMark") class RuleMark: BlockToken() + + val drawnText: String get() = when (this) { + is SoftBreak -> " " + is LineBreak, is HardBreak -> "\n" + is ParagraphBreak -> "\n".repeat(lineBreaks) + else -> "" + } + + val previewText: String get() = when (this) { + is SoftBreak, is HardBreak, is CellMark -> " " + is LineBreak, is ParagraphBreak -> "\n" + is ListMark -> (number ?: "•") + " " + else -> "" + } +} + +@Serializable +sealed class MarkdownBlock { + @Serializable @SerialName("paragraph") class Paragraph(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("heading") class Heading(val level: Int, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("quote") class Quote(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("list") class ItemList(val items: List): MarkdownBlock() + @Serializable @SerialName("table") class Table(val columns: List, val header: List, val rows: List>): MarkdownBlock() + @Serializable @SerialName("rule") class Rule(val mark: SegmentRange): MarkdownBlock() +} + +@Serializable +class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) + +@Serializable +class SegmentRange(val from: Int, val to: Int) + +@Serializable +enum class TableAlign { + @SerialName("none") None, + @SerialName("left") Left, + @SerialName("center") Center, + @SerialName("right") Right +} + +class BlockTextRange(val range: SegmentRange, val inRow: Boolean) + +val MarkdownBlock.textRanges: List get() = when (this) { + is MarkdownBlock.Paragraph -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Heading -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.CodeBlock -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Quote -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.ItemList -> listEntries(items, 0).map { (entry, _) -> BlockTextRange(entry.content, false) } + is MarkdownBlock.Table -> (listOf(header) + rows).flatMap { row -> row.mapIndexed { c, cell -> BlockTextRange(cell, c > 0) } } + is MarkdownBlock.Rule -> emptyList() +} + +fun listEntries(items: List, level: Int): List> = + items.flatMap { listOf(it to level) + listEntries(it.items, level + 1) } + +fun blockTextRanges(blocks: List): List = blocks.flatMap { it.textRanges } + +fun headingSegments(size: Int, blocks: List): BooleanArray { + val bold = BooleanArray(size) + blocks.forEach { if (it is MarkdownBlock.Heading) for (i in it.content.from.coerceIn(0, size) until it.content.to.coerceIn(0, size)) bold[i] = true } + return bold +} + @Serializable enum class SimplexLinkType(val linkType: String) { contact("contact"), @@ -5713,6 +5818,7 @@ data class ChatItemVersion( val chatItemVersionId: Long, val msgContent: MsgContent, val formattedText: List?, + val markdownBlocks: List? = null, val itemVersionTs: Instant, val createdAt: Instant, ) 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 e06653b5eb..d054745a75 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 @@ -27,6 +27,8 @@ import chat.simplex.common.model.SMPProxyMode.Always import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgeStore +import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert @@ -595,13 +597,38 @@ object ChatController { } } + // log = false because a store receipt is a bearer secret, like a badge code - it is in the command. + suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment): BadgePurchaseResult { + val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment), log = false) + return when { + r is API.Result && r.res is CR.BadgeStateR -> BadgePurchaseResult.Held(r.res.user, r.res.badgeState, r.res.storePurchases) + r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Credited(r.res.user.updateRemoteHostId(rh), r.res.badgeState) + r is API.Error -> BadgePurchaseResult.Failed(r.err) + else -> { + // the response type alone - it names a case or a JSON key, never the service's message + Log.e(TAG, "apiPurchaseBadge: unexpected ${r.responseType}") + BadgePurchaseResult.Failed(null) + } + } + } + + suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String { + val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId)) + if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId + if (r is API.Error) throw BadgeStoreError.ApiError(r.err) + throw Exception("apiCreateBadgeInvoice: unexpected ${r.responseType}") + } + // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened - fun redeemErrorText(err: ChatError?): String { + fun redeemErrorText(err: ChatError?, purchase: Boolean): String { if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) { when (val e = err.errorType.badgeRedeemError) { is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code) - is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured) - is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active) + is BadgeRedeemError.InvalidReceipt -> {} + is BadgeRedeemError.ServiceNotConfigured -> + return generalGetString(if (purchase) MR.strings.badges_error_service_not_configured_purchase else MR.strings.badges_error_service_not_configured) + is BadgeRedeemError.BadgeActive -> + return generalGetString(if (purchase) MR.strings.badges_error_already_active_purchase else MR.strings.badges_error_already_active) is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it } is BadgeRedeemError.InvalidResponse -> return String.format(generalGetString(MR.strings.badges_error_bad_service_response), e.message) is BadgeRedeemError.UnknownKeyIndex, is BadgeRedeemError.CredentialNotVerified -> return generalGetString(MR.strings.badges_error_credential_not_verified) @@ -610,9 +637,9 @@ object ChatController { return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}" } - suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? { + suspend fun apiGetBadgeState(rh: Long?, userId: Long): Pair> { val r = sendCmd(rh, CC.ApiGetBadgeState(userId)) - if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState + if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState to r.res.storePurchases throw Exception("apiGetBadgeState: unexpected ${r.responseType}") } @@ -645,11 +672,14 @@ object ChatController { // Not thrown: a failed badge read must not stop the app starting, and the model is left alone // rather than set to nil, which would read as "no badge". - private suspend fun loadBadgeState(rhId: Long?) { + suspend fun loadBadgeState(rhId: Long?) { try { val userId = currentUserId("loadBadgeState") - val badgeState = apiGetBadgeState(rhId, userId) - withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) } + val (badgeState, storePurchases) = apiGetBadgeState(rhId, userId) + withContext(Dispatchers.Main) { + BadgeModel.set(rhId, userId, badgeState) + BadgeStore.setStorePurchases(rhId, userId, storePurchases) + } } catch (e: Exception) { Log.e(TAG, "loadBadgeState: ${e.message}") } @@ -694,6 +724,7 @@ object ChatController { } apiStartChat() appPrefs.chatStopped.set(false) + withLongRunningApi { BadgeStore.presentUnfinished() } if (appPrefs.shouldDeleteDatabaseBackups.get()) { deleteDatabaseBackups() appPrefs.shouldDeleteDatabaseBackups.set(false) @@ -763,6 +794,7 @@ object ChatController { chatModel.users.clear() chatModel.users.addAll(users) getUserChatData(rhId, keepingChatId = keepingChatId) + withLongRunningApi { BadgeStore.presentUnfinished() } val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId } if (invitation != null && currentUser != null) { chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser)) @@ -3615,6 +3647,10 @@ object ChatController { BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert) } } + is CR.StorePurchaseCredited -> + withLongRunningApi { BadgeStore.storePurchaseCredited(rhId, r.user, r.invoiceId) } + is CR.StorePurchaseRefused -> + withLongRunningApi { BadgeStore.storePurchaseRefused(rhId, r.user, r.invoiceId, r.refusal) } else -> Log.d(TAG , "unsupported event: ${msg.responseType}") } @@ -3656,7 +3692,7 @@ object ChatController { getUserChatData(null) } - private fun activeUser(rhId: Long?, user: UserLike): Boolean = + fun activeUser(rhId: Long?, user: UserLike): Boolean = rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { @@ -3951,6 +3987,17 @@ sealed class BadgeRedeemResult { class Failed(val err: ChatError?): BadgeRedeemResult() } +@Serializable +data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null, val creditError: BadgeIssueFailure? = null) + +// a refusal is Failed, with the code in BREServiceError +sealed class BadgePurchaseResult { + class Held(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List): BadgePurchaseResult() + class Credited(val user: User, val badgeState: BadgeState?): BadgePurchaseResult() + // err is null for a response of an unexpected type, which is logged where it is received + class Failed(val err: ChatError?): BadgePurchaseResult() +} + // ChatCommand // Spec: spec/api.md#CC sealed class CC { @@ -4132,6 +4179,8 @@ sealed class CC { class ApiStandaloneFileInfo(val url: String): CC() // badges class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() + class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC() + class ApiCreateBadgeInvoice(val userId: Long): CC() class ApiGetBadgeState(val userId: Long): CC() class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC() class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() @@ -4361,6 +4410,8 @@ sealed class CC { is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" is ApiStandaloneFileInfo -> "/_download info $url" is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" + is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}" + is ApiCreateBadgeInvoice -> "/_badge invoice $userId" is ApiGetBadgeState -> "/_badge state $userId" is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId" is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" @@ -4546,6 +4597,8 @@ sealed class CC { is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" + is ApiPurchaseBadge -> "apiPurchaseBadge" + is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice" is ApiGetBadgeState -> "apiGetBadgeState" is ApiGetBadgeLedger -> "apiGetBadgeLedger" is ApiAckBadgeAlert -> "apiAckBadgeAlert" @@ -4570,8 +4623,11 @@ sealed class CC { is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) - // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code)) + is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) { + is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token)) + }) else -> this } @@ -4605,6 +4661,12 @@ sealed class CC { } } +// the field must stay typed as the sealed base, or kotlinx omits the "type" tag core parses by +@Serializable +sealed class ServicePayment { + @Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment() +} + fun onOff(b: Boolean): String = if (b) "on" else "off" // /_badge ack takes the kind in core's text encoding, not the JSON tag @@ -6944,10 +7006,13 @@ sealed class CR { // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR() - @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List = emptyList()): CR() @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() + @Serializable @SerialName("storePurchaseCredited") class StorePurchaseCredited(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("storePurchaseRefused") class StorePurchaseRefused(val user: UserRef, val invoiceId: String, val refusal: BadgeIssueFailure): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -7137,10 +7202,13 @@ sealed class CR { is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" is BadgeRedeemed -> "badgeRedeemed" + is BadgeInvoice -> "badgeInvoice" is BadgeStateR -> "badgeState" is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" is BadgeAlertR -> "badgeAlert" + is StorePurchaseCredited -> "storePurchaseCredited" + is StorePurchaseRefused -> "storePurchaseRefused" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7347,10 +7415,13 @@ sealed class CR { is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") - is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) + is BadgeInvoice -> withUser(user, invoiceId) + is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}") is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) + is StorePurchaseCredited -> withUser(user, "invoiceId: $invoiceId") + is StorePurchaseRefused -> withUser(user, "invoiceId: $invoiceId\nrefusal: ${refusal.tag}") is Response -> json is Invalid -> str } @@ -7406,6 +7477,7 @@ sealed class SimplexDomainError { sealed class BadgeRedeemError { val string: String get() = when (this) { is InvalidCode -> "invalidCode" + is InvalidReceipt -> "invalidReceipt" is ServiceNotConfigured -> "serviceNotConfigured" is BadgeActive -> "badgeActive" is ServiceError -> "serviceError ${serviceError.text}" @@ -7414,6 +7486,7 @@ sealed class BadgeRedeemError { is CredentialNotVerified -> "credentialNotVerified" } @Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError() + @Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError() @Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError() @Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError() @Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError() @@ -7435,12 +7508,14 @@ sealed class BadgeServiceErrorCode { object PaymentNotEntitled: BadgeServiceErrorCode() object PaymentPending: BadgeServiceErrorCode() object ProviderUnavailable: BadgeServiceErrorCode() + object ProviderNotConfigured: BadgeServiceErrorCode() object RateLimited: BadgeServiceErrorCode() object CodeInvalid: BadgeServiceErrorCode() object CodeUsed: BadgeServiceErrorCode() object CodeExpired: BadgeServiceErrorCode() object ReceiptInvalid: BadgeServiceErrorCode() object ReceiptUsed: BadgeServiceErrorCode() + object TestPurchase: BadgeServiceErrorCode() object Internal: BadgeServiceErrorCode() data class Unknown(val code: String): BadgeServiceErrorCode() @@ -7456,12 +7531,14 @@ sealed class BadgeServiceErrorCode { is PaymentNotEntitled -> "payment_not_entitled" is PaymentPending -> "payment_pending" is ProviderUnavailable -> "provider_unavailable" + is ProviderNotConfigured -> "provider_not_configured" is RateLimited -> "rate_limited" is CodeInvalid -> "code_invalid" is CodeUsed -> "code_used" is CodeExpired -> "code_expired" is ReceiptInvalid -> "receipt_invalid" is ReceiptUsed -> "receipt_used" + is TestPurchase -> "test_purchase" is Internal -> "internal" is Unknown -> code } @@ -7475,6 +7552,13 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) { is BadgeServiceErrorCode.UnsupportedVersion -> generalGetString(MR.strings.badges_error_unsupported_version) is BadgeServiceErrorCode.UnknownPurchaseKey -> generalGetString(MR.strings.badges_error_unknown_purchase) is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal) + is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid) + is BadgeServiceErrorCode.TestPurchase -> generalGetString(MR.strings.badges_error_test_purchase) + is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used) + is BadgeServiceErrorCode.PaymentPending -> generalGetString(MR.strings.badges_error_payment_pending) + is BadgeServiceErrorCode.ProviderUnavailable -> generalGetString(MR.strings.badges_error_provider_unavailable) + is BadgeServiceErrorCode.ProviderNotConfigured -> generalGetString(MR.strings.badges_error_provider_not_configured) + is BadgeServiceErrorCode.ProductUnavailable -> generalGetString(MR.strings.badges_error_product_unavailable) else -> null } @@ -7492,12 +7576,14 @@ object BadgeServiceErrorCodeSerializer : KSerializer { "payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled "payment_pending" -> BadgeServiceErrorCode.PaymentPending "provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable + "provider_not_configured" -> BadgeServiceErrorCode.ProviderNotConfigured "rate_limited" -> BadgeServiceErrorCode.RateLimited "code_invalid" -> BadgeServiceErrorCode.CodeInvalid "code_used" -> BadgeServiceErrorCode.CodeUsed "code_expired" -> BadgeServiceErrorCode.CodeExpired "receipt_invalid" -> BadgeServiceErrorCode.ReceiptInvalid "receipt_used" -> BadgeServiceErrorCode.ReceiptUsed + "test_purchase" -> BadgeServiceErrorCode.TestPurchase "internal" -> BadgeServiceErrorCode.Internal else -> BadgeServiceErrorCode.Unknown(v) } @@ -8083,7 +8169,7 @@ sealed class StoreError { is UserChatRelayNotFound -> "userChatRelayNotFound $chatRelayId" is GroupRelayNotFound -> "groupRelayNotFound $groupRelayId" is GroupRelayNotFoundByMemberId -> "groupRelayNotFoundByMemberId $groupMemberId" - is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" + is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $forwardedByGroupMemberId" is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" is RemoteHostUnknown -> "remoteHostUnknown" is RemoteHostDuplicateCA -> "remoteHostDuplicateCA" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index a6ede20e41..1da9d8ff40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -25,6 +25,8 @@ expect val deviceName: String expect fun isAppVisibleAndFocused(): Boolean +expect fun appLinkSchemeRegistered(): Boolean + val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index ee47403cd3..290d93db71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -136,7 +136,12 @@ abstract class NtfManager { return if (md != null) { var res = "" for (ft in md) { - res += if (ft.format is Format.Secret) "..." else ft.text + res += when (ft.format) { + is Format.Secret -> "..." + is Format.Markup -> ft.format.token.previewText + is Format.Escape -> "" + else -> ft.text + } } res } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 7d126096e8..f4e902b967 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -13,6 +13,7 @@ import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt import kotlinx.coroutines.Job import java.io.Closeable @@ -44,6 +45,9 @@ interface PlatformInterface { return emptyList() } suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} + suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {} + suspend fun androidUnfinishedBadgePurchases(): List = emptyList() val androidApiLevel: Int? get() = null // The build distributed via Google Play, which has to follow its policies val androidIsPlayStoreBuild: Boolean get() = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index f8a37c39a7..ed971fc1e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -1,7 +1,20 @@ package chat.simplex.common.views.badges +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.ModalViewId +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import java.text.NumberFormat import java.util.Currency import java.util.UUID @@ -29,11 +42,34 @@ val badgeStoreProductIds: List = BadgeLevel.entries.flatMap BadgePeriod.entries.map { badgeStoreProductId(level, it) } } -// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. -// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service -// learns which invoice a store transaction settles. +// the only products sent to the badge service: nothing delivers a subscription yet +val badgeOneTimeProductIds: Set = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet() + +// subscriptions are for sale once renewals are delivered +val badgePeriodsForSale: List = listOf(BadgePeriod.OneMonth) + +// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. +// Sent to Play as obfuscatedAccountId and echoed back on the purchase. fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() +// the page's app flag rides in the fragment, which never reaches the service +fun badgePageUrl(linkReturns: Boolean): String = + if (linkReturns) "https://badges.simplex.chat/#/tier?app=true" + else "https://badges.simplex.chat/#/tier?app=desktop" +// the dev deployment, for testing store purchases: swap with the two lines above +// if (linkReturns) "https://smp7.simplex.im/#/tier?app=true" +// else "https://smp7.simplex.im/#/tier?app=desktop" + +// where the store allows a link out to the badge page: the US for now, a set expected to widen. +// An unknown country does not count, as this decides whether the store sees a link out of the app. +@Composable +fun badgeBrowserAllowed(): Boolean { + if (androidPlayStoreCountry.value == null) { + LaunchedEffect(Unit) { platform.androidLoadPlayStoreCountry() } + } + return androidPlayStoreCountry.value == "US" +} + // what the platform store knows about one product; ProductDetails cannot cross into commonMain data class BadgeProduct( val id: BadgeStoreProductId, @@ -58,10 +94,8 @@ data class BadgeStoreReceipt( // the token the badge service verifies with the Publisher API val token: String, val productId: String, - val orderId: String?, val invoiceId: String?, - // only set for test products - a real Play purchase has no environment to report - val environment: String? = null + val acknowledged: Boolean = false ) // TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens @@ -69,6 +103,9 @@ data class BadgeStoreReceipt( // receipt says so. const val useBadgeTestProducts = false +// test products stand in for a store, so the lane is offered without one +val badgeStoreAvailable: Boolean get() = useBadgeTestProducts || platform.androidHasPlatformStore + private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) = BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD") @@ -83,20 +120,29 @@ private val testBadgeProducts: List = listOf( sealed class BadgePurchaseOutcome { class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome() - object Pending: BadgePurchaseOutcome() + class Pending(val invoiceId: String?): BadgePurchaseOutcome() object Cancelled: BadgePurchaseOutcome() } +enum class BadgePurchaseState { + Issuing, + WaitingForApproval +} + sealed class BadgeStoreError: Exception() { class ProductUnavailable(val productId: String): BadgeStoreError() class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() object StoreUnavailable: BadgeStoreError() + object NoActiveProfile: BadgeStoreError() + class ApiError(val err: ChatError): BadgeStoreError() override val message: String get() = when (this) { is ProductUnavailable -> "productUnavailable(productId: $productId)" is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" is StoreUnavailable -> "storeUnavailable" + is NoActiveProfile -> "noActiveProfile" + is ApiError -> "apiError(${err.string})" } } @@ -106,12 +152,50 @@ object BadgeStore { private val state = mutableStateOf(LoadState.NotLoaded) // snapshot state so a composable reading only the products still recomposes when they arrive private val products = mutableStateOf>(emptyMap()) + // core's open store purchases for the profile they were read for: core knows whose a purchase is + private val storePurchases = mutableStateOf>?>(null) + // whether a store sheet this run opened has not returned + private val buying = mutableStateOf(false) + // by invoice id, so a pending purchase shows only under the profile whose record it names + private val waitingForApproval = mutableStateOf>(emptySet()) + // set once presentUnfinished has read the store, or failed to: until then, a slow payment completed while + // the app was closed, or a purchase it died before handing over, are both unknown, so canBuy refuses + private val reconciledOnce = mutableStateOf(false) + // the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run + private var sweepFailureShown = false - fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) { + fun purchaseState(userId: Long?): BadgePurchaseState? { + if (!badgeStoreAvailable) return null + val purchases = openStorePurchases(userId) + return when { + purchases.any { it.transactionRef != null } -> BadgePurchaseState.Issuing + purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval + else -> null + } + } + + fun creditError(userId: Long?): BadgeIssueFailure? = + openStorePurchases(userId).firstNotNullOfOrNull { it.creditError } + + val checkingPurchases: Boolean get() = badgeStoreAvailable && !reconciledOnce.value + + fun canBuy(userId: Long?): Boolean = + badgeStoreAvailable && reconciledOnce.value && !buying.value && purchaseState(userId) == null + + fun setStorePurchases(rhId: Long?, userId: Long, purchases: List) { + storePurchases.value = Triple(rhId, userId, purchases) + } + + private fun openStorePurchases(userId: Long?): List { + val (rhId, readFor, purchases) = storePurchases.value ?: return emptyList() + return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList() + } + + fun price(level: BadgeLevel, period: BadgePeriod, compact: Boolean = true): BadgePrice = when (state.value) { LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading LoadState.Loaded, LoadState.Failed -> { val p = products.value[badgeStoreProductId(level, period)] - if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable + if (p != null) BadgePrice.Price(if (compact) compactPrice(p) else p.displayPrice) else BadgePrice.Unavailable } } @@ -146,21 +230,189 @@ object BadgeStore { } } - suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { + // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + suspend fun purchase(level: BadgeLevel, period: BadgePeriod): BadgePurchaseOutcome { val id = badgeStoreProductId(level, period) if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) - if (useBadgeTestProducts) { - return BadgePurchaseOutcome.Purchased( + // a subscription is never sent to core, so nothing would ever finish it later + if (id.productId !in badgeOneTimeProductIds) { + val outcome = storePurchase(id, newBadgeInvoiceId()) + if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt) + return outcome + } + val rhId = chatModel.remoteHostId() + val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile + val invoiceId = chatModel.controller.apiCreateBadgeInvoice(rhId, userId) + withContext(Dispatchers.Main) { buying.value = true } + chatModel.controller.loadBadgeState(rhId) + try { + val outcome = storePurchase(id, invoiceId) + if (outcome is BadgePurchaseOutcome.Purchased) { + handOver(outcome.receipt) + } + return outcome + } finally { + withContext(Dispatchers.Main + NonCancellable) { buying.value = false } + } + } + + private suspend fun storePurchase(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome { + val outcome = if (useBadgeTestProducts) { + BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = "test-${UUID.randomUUID()}", productId = id.productId, - orderId = null, - invoiceId = invoiceId, - environment = "test products" + invoiceId = invoiceId ) ) + } else { + platform.androidPurchaseBadge(id, invoiceId) + } + if (outcome is BadgePurchaseOutcome.Pending && outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } + return outcome + } + + // Core holds the receipt and credits it; the purchase stays unfinished until core answers it credited + // or refused, as an unfinished purchase is what the store re-delivers if anything is lost on the way. + private suspend fun handOver(receipt: BadgeStoreReceipt) { + val rhId = chatModel.remoteHostId() + val userId = chatModel.currentUser.value?.userId ?: return + when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token))) { + is BadgePurchaseResult.Held -> { + // core holds it durably now, so Play must not refund it after three days unacknowledged + acknowledge(receipt) + withContext(Dispatchers.Main) { + // the answer is the owner's, which may be another profile and a hidden one + if (chatModel.controller.activeUser(rhId, r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + setStorePurchases(rhId, r.user.userId, r.storePurchases) + } + } + } + is BadgePurchaseResult.Credited -> { + withContext(Dispatchers.Main) { + if (chatModel.controller.activeUser(rhId, r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + chatModel.updateUser(r.user) + if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true) + } + } + finish(receipt) + } + // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer + is BadgePurchaseResult.Failed -> + if (badgeReceiptRefused(r.err)) { + Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}") + finish(receipt) + } else { + throw r.err?.let(BadgeStoreError::ApiError) ?: Exception("apiPurchaseBadge: unexpected response") + } + } + } + + suspend fun storePurchaseCredited(rhId: Long?, user: UserRef, invoiceId: String) { + withContext(Dispatchers.Main) { + // not isLastModalOpen: the purchase screens pushed over the badges screen close once the badge appears + val badgesOpen = ModalManager.start.hasModalOpen(ModalViewId.BADGES) || ModalManager.end.hasModalOpen(ModalViewId.BADGES) + // an open badges screen is the notice only for the active profile, whose badge it shows + if (!chatModel.controller.activeUser(rhId, user) || !badgesOpen) showBadgeAddedAlert(rhId, user) + } + storePurchaseResolved(rhId, user, invoiceId) + } + + suspend fun storePurchaseRefused(rhId: Long?, user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) { + withContext(Dispatchers.Main) { + if (chatModel.controller.activeUser(rhId, user) && openStorePurchases(user.userId).any { it.invoiceId == invoiceId }) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = refusal.purchaseText) + } + } + storePurchaseResolved(rhId, user, invoiceId) + } + + private suspend fun storePurchaseResolved(rhId: Long?, user: UserRef, invoiceId: String) { + // the row goes before the store is read, or the screen shows it open while the store answers; + // the purchase is finished whoever owns it, as only the app can, while the cached row is the active profile's + withContext(Dispatchers.Main) { + val (readOn, readFor, purchases) = storePurchases.value ?: return@withContext + if (chatModel.controller.activeUser(rhId, user) && readOn == rhId && readFor == user.userId) { + storePurchases.value = Triple(readOn, readFor, purchases.filter { it.invoiceId != invoiceId }) + } + } + if (rhId != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return + try { + platform.androidUnfinishedBadgePurchases() + .firstNotNullOfOrNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.takeIf { r -> r.invoiceId == invoiceId } } + ?.let { finish(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + // the purchase stays unfinished, and the next sweep hands it over and finishes it + Log.e(TAG, "BadgeStore.storePurchaseResolved: ${e.message}") + } + } + + // at launch, on return to the foreground and on a profile switch, never on a timer + suspend fun presentUnfinished() { + try { + if (!useBadgeTestProducts && platform.androidHasPlatformStore) { + try { + val purchases = platform.androidUnfinishedBadgePurchases() + // Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here + withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() } + purchases.forEach { reconcile(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}") + } + } + chatModel.controller.loadBadgeState(chatModel.remoteHostId()) + } finally { + withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true } + } + } + + suspend fun reconcile(outcome: BadgePurchaseOutcome) { + when (outcome) { + is BadgePurchaseOutcome.Purchased -> + if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt) + // the purchase stays unfinished, so the next sweep hands it over again + else try { + handOver(outcome.receipt) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}") + withContext(Dispatchers.Main) { + if (!sweepFailureShown) { + sweepFailureShown = true + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) + else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + ) + } + } + } + is BadgePurchaseOutcome.Pending -> + if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } + is BadgePurchaseOutcome.Cancelled -> {} + } + } + + private suspend fun acknowledge(receipt: BadgeStoreReceipt) { + try { + if (!useBadgeTestProducts) platform.androidAcknowledgeBadgePurchase(receipt) + } catch (e: Exception) { + // the next sweep hands the purchase over again, and acknowledges it then + Log.e(TAG, "BadgeStore.acknowledge: ${e.message}") + } + } + + private suspend fun finish(receipt: BadgeStoreReceipt) { + try { + if (!useBadgeTestProducts) platform.androidFinishBadgePurchase(receipt) + } catch (e: Exception) { + // still unfinished: the store re-delivers it, and the next answer for it finishes it + Log.e(TAG, "BadgeStore.finish: ${e.message}") } - return platform.androidPurchaseBadge(id, invoiceId) } private fun startLoading(): Boolean = when (state.value) { @@ -185,3 +437,11 @@ private fun compactPrice(product: BadgeProduct): String { product.displayPrice } } + +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it; +// a sweep that hands over a receipt refused while the app was not running learns it only from this answer +private fun badgeReceiptRefused(err: ChatError?): Boolean { + val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError + val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError + return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed || code is BadgeServiceErrorCode.TestPurchase +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt index 0eda3cd751..535eb920ca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt @@ -19,7 +19,7 @@ import chat.simplex.res.* import kotlinx.datetime.Instant @Composable -fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { +fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier) { val user = chatModel.currentUser.value val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname) val previewBadge = LocalBadge( @@ -29,15 +29,12 @@ fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: ) Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { ProfileImage(size = 128.dp, image = user?.image) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - NameWithBadge( - name = displayName, - badge = previewBadge, - style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - trailing() - } + NameWithBadge( + name = displayName, + badge = previewBadge, + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt new file mode 100644 index 0000000000..fbb3d1a017 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -0,0 +1,107 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.res.* + +@Composable +fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { + val purchasing = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) + + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_check_your_order_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Column( + Modifier + .fillMaxWidth() + .padding(top = 20.dp) + .clip(RoundedCornerShape(16.dp)) + .background(sectionCardColor()) + .padding(horizontal = 24.dp, vertical = 12.dp) + ) { + OrderRow(MR.strings.badges_your_badge, stringResource(level.title)) + OrderRow(MR.strings.badges_order_duration, stringResource(period.label)) + Divider(Modifier.padding(top = 8.dp, bottom = 6.dp)) + OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period, compact = false))) + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PayButton(level, period, purchasing) + BadgeBillingFooter(period) + } + } +} + +@Composable +private fun OrderRow(title: StringResource, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary) + Spacer(Modifier.weight(1f)) + Text(value, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun PayButton(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState) { + val price = BadgeStore.price(level, period, compact = false) + val (labelId, labelArg) = period.payLabel(price) + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = labelId, + labelArg = labelArg, + onboarding = null, + enabled = price.canPurchase && !purchasing.value && BadgeStore.canBuy(chatModel.currentUser.value?.userId), + onclick = { purchase(level, period, purchasing) } + ) +} + +private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState) { + purchasing.value = true + // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls + withLongRunningApi { + try { + BadgeStore.purchase(level, period) + purchasing.value = false + } catch (e: Exception) { + Log.e(TAG, "BadgesCheckOrderView.purchase: ${e.stackTraceToString()}") + purchasing.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) + else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt similarity index 70% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt index 44b5de021a..8925f8718f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt @@ -34,16 +34,22 @@ enum class BadgeLevel { Legend -> MR.strings.badges_level_legend } - val filesDescription: StringResource + val fileSize: StringResource get() = when (this) { - Supporter -> MR.strings.badges_level_supporter_files - Legend -> MR.strings.badges_level_legend_files + Supporter -> MR.strings.badges_level_supporter_file_size + Legend -> MR.strings.badges_level_legend_file_size } - val tagline: StringResource + val fileStorage: StringResource get() = when (this) { - Supporter -> MR.strings.badges_level_supporter_tagline - Legend -> MR.strings.badges_level_legend_tagline + Supporter -> MR.strings.badges_level_supporter_file_storage + Legend -> MR.strings.badges_level_legend_file_storage + } + + val summary: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_summary + Legend -> MR.strings.badges_level_legend_summary } val badgeType: BadgeType @@ -54,10 +60,11 @@ enum class BadgeLevel { } @Composable -fun BadgesYourLevelView(modalManager: ModalManager) { +fun BadgesChooseBadgeView(modalManager: ModalManager, unwindToDepth: Int) { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -66,7 +73,7 @@ fun BadgesYourLevelView(modalManager: ModalManager) { maxIntrinsicSize = true, ) { Text( - stringResource(MR.strings.badges_your_level_title), + stringResource(MR.strings.badges_choose_your_badge_title), style = MaterialTheme.typography.h1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary, @@ -74,15 +81,17 @@ fun BadgesYourLevelView(modalManager: ModalManager) { modifier = Modifier.fillMaxWidth() ) - BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) { - Icon( - painterResource(MR.images.ic_keyboard_arrow_down), - contentDescription = null, - tint = MaterialTheme.colors.primary - ) - } + Text( + stringResource(MR.strings.badges_choose_your_badge_lede), + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) + + Spacer(Modifier.weight(1f).heightIn(min = 12.dp)) // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height // when 2-line labels at large fonts would otherwise size them differently. @@ -94,17 +103,18 @@ fun BadgesYourLevelView(modalManager: ModalManager) { LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } } - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 12.dp)) // Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under // the action button (matches onboarding pattern where its own 7.5dp top padding is the gap). Column(horizontalAlignment = Alignment.CenterHorizontally) { - ContinueButton(selectedLevel, modalManager) - TextButtonBelowOnboardingButton( - text = stringResource(MR.strings.badges_how_it_works_button), - icon = painterResource(MR.images.ic_info), - onClick = { modalManager.showModal { BadgesHowItWorksView() } } - ) + ContinueButton(selectedLevel, modalManager, unwindToDepth) + // redeeming a code is here only when Support SimpleX offers the browser instead + if (badgeBrowserAllowed()) { + RedeemCodeButton(modalManager, unwindToDepth) + } else { + TextButtonBelowOnboardingButton("", null) + } } } } @@ -123,7 +133,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo .background(cardBackground, shape) .border(2.dp, borderColor, shape) .clickable { onSelect(level) } - .padding(vertical = 20.dp, horizontal = 12.dp), + .padding(vertical = 16.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp) ) { @@ -131,22 +141,25 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo painterResource(badgeImage(level.badgeType)), contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.size(60.dp) + modifier = Modifier.size(44.dp) ) Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(level.fileSize), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Text(stringResource(level.fileStorage), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } } } @Composable -private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager) { +private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), labelId = MR.strings.badges_continue, onboarding = null, onclick = { - modalManager.showModal { BadgesPayView(selectedLevel) } + modalManager.showModal { BadgesHowLongView(selectedLevel, modalManager, unwindToDepth) } } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt similarity index 55% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index f8fbd3a416..8b58049276 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -9,10 +9,9 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -45,6 +44,13 @@ enum class BadgePeriod { Annual -> MR.strings.badges_period_annual } + val months: Int + get() = when (this) { + OneMonth -> 1 + Monthly -> 1 + Annual -> 12 + } + @Composable fun priceText(price: BadgePrice): String = when (price) { is BadgePrice.Loading -> "…" @@ -68,12 +74,11 @@ enum class BadgePeriod { } @Composable -fun BadgesPayView(level: BadgeLevel) { - var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } - val purchasing = remember { mutableStateOf(false) } - val clipboard = LocalClipboardManager.current +fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) { + var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) } LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -82,7 +87,7 @@ fun BadgesPayView(level: BadgeLevel) { maxIntrinsicSize = true, ) { Text( - stringResource(level.title), + stringResource(MR.strings.badges_how_long_title), style = MaterialTheme.typography.h1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary, @@ -90,51 +95,58 @@ fun BadgesPayView(level: BadgeLevel) { modifier = Modifier.fillMaxWidth() ) - BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) - Text( - stringResource(level.tagline), + stringResource(level.summary), style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground, + color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + modifier = Modifier.fillMaxWidth() ) + BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - + // IntrinsicSize.Max + fillMaxHeight on children so the cards all match the tallest one - // only Annual carries a savings line, and prices wrap at large fonts. Row( Modifier.fillMaxWidth().height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + BadgePeriod.entries.forEach { period -> + PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + } } Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a - // TextButton so the footer has no hover/click affordance. Column(horizontalAlignment = Alignment.CenterHorizontally) { - PayButton(level, selectedPeriod, purchasing, clipboard) - Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), - Modifier.padding(vertical = 5.dp), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.secondary, - textAlign = TextAlign.Center - ) - } + ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) + BadgeBillingFooter(selectedPeriod) } } } +// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a +// TextButton so the footer has no hover/click affordance. +@Composable +fun BadgeBillingFooter(period: BadgePeriod) { + Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + stringResource(billingFooter(period)).format(billingDate(period)), + Modifier.padding(vertical = 5.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } +} + @Composable private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { val isSelected = period == selectedPeriod + val forSale = period in badgePeriodsForSale + val textColor = if (forSale) Color.Unspecified else MaterialTheme.colors.secondary val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) @@ -145,7 +157,7 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B .clip(shape) .background(cardBackground, shape) .border(2.dp, borderColor, shape) - .clickable { onSelect(period) } + .clickable(enabled = forSale) { onSelect(period) } .padding(vertical = 20.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) @@ -154,10 +166,10 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B painterResource(period.icon), contentDescription = null, tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp).alpha(if (forSale) 1f else 0.4f) ) - Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Text(stringResource(period.label), style = MaterialTheme.typography.body2, color = textColor, textAlign = TextAlign.Center) + Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, fontWeight = FontWeight.SemiBold, color = textColor, textAlign = TextAlign.Center) val percent = savingsPercent(level, period) if (percent != null) { Text( @@ -173,76 +185,15 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null -// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and -// this button stays disabled - it will offer Stripe/crypto payment instead @Composable -private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { - val price = BadgeStore.price(level, selectedPeriod) - val (labelId, labelArg) = selectedPeriod.payLabel(price) +private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = labelId, - labelArg = labelArg, + labelId = MR.strings.badges_continue, onboarding = null, - enabled = price.canPurchase && !purchasing.value, - onclick = { purchase(level, selectedPeriod, purchasing, clipboard) } - ) -} - -private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { - val invoiceId = newBadgeInvoiceId() - purchasing.value = true - // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls - withLongRunningApi { - try { - val outcome = BadgeStore.purchase(level, period, invoiceId) - purchasing.value = false - when (outcome) { - is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard) - is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_pending), - text = generalGetString(MR.strings.badges_purchase_pending_desc) - ) - is BadgePurchaseOutcome.Cancelled -> {} - } - } catch (e: Exception) { - Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}") - purchasing.value = false - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_error), - text = e.toString() - ) + onclick = { + modalManager.showModal(cardScreen = true) { BadgesCheckOrderView(level, selectedPeriod, modalManager, unwindToDepth) } } - } -} - -// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. -private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) { - val returnedInvoice = when (receipt.invoiceId) { - null -> "none" - invoiceId -> "yes" - else -> "mismatch: ${receipt.invoiceId}" - } - val lines = mutableListOf( - "Product: ${receipt.productId}", - "Invoice: $invoiceId", - "Invoice returned by Google: $returnedInvoice", - "Order: ${receipt.orderId ?: "none"}" - ) - if (receipt.environment != null) lines.add("Environment: ${receipt.environment}") - lines.add("Token: ${receipt.token.length} bytes") - val summary = lines.joinToString("\n") - Log.d(TAG, "badge purchase succeeded\n$summary") - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.badges_purchase_successful), - text = summary, - confirmText = "Copy token", - onConfirm = { - clipboard.setText(AnnotatedString(receipt.token)) - showToast(generalGetString(MR.strings.copied)) - }, - dismissText = generalGetString(MR.strings.ok), - parseHtml = false ) } @@ -251,9 +202,10 @@ private fun billingFooter(period: BadgePeriod): StringResource = when (period) { BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month } -// TODO [badges] source the actual date from the purchase state machine when wired. -private fun stubBillingDate(): String { - val date = java.time.LocalDate.of(2026, 7, 22) +// TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld); +// a top-up must count from the end of the existing balance +private fun billingDate(period: BadgePeriod): String { + val date = java.time.LocalDate.now().plusMonths(period.months.toLong()) val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG) return date.format(formatter) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt new file mode 100644 index 0000000000..4648a12774 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt @@ -0,0 +1,87 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeIssueFailure +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.* + +@Composable +fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, failure: BadgeIssueFailure? = null, onDismiss: () -> Unit) { + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + if (message != null) { + Text( + stringResource(message), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + if (failure != null) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_warning), contentDescription = null, tint = Color.Red) + Text( + failure.purchaseText, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(Modifier.weight(1f)) + + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TextButtonBelowOnboardingButton(stringResource(MR.strings.badges_dismiss), onDismiss) + TextButtonBelowOnboardingButton("", null) + } + } +} + +val BadgePurchaseState.title: StringResource + get() = when (this) { + BadgePurchaseState.Issuing -> MR.strings.badges_issuing_title + BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_title + } + +val BadgePurchaseState.message: StringResource + get() = when (this) { + BadgePurchaseState.Issuing -> MR.strings.badges_issuing_body + BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_body + } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index 62bdc2962e..094f623032 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -57,13 +58,60 @@ private fun formatBadgeCodeInput(s: String): String { return groups.joinToString("-") } +sealed class BadgeRedeemOutcome { + object Redeemed: BadgeRedeemOutcome() + class Refused(val message: String): BadgeRedeemOutcome() + object Cancelled: BadgeRedeemOutcome() +} + +// sets the badge before returning, so a caller that dismisses on Redeemed lands on Your Badge +// instead of showing the switch from Support. Cancelled when the user cancels the retry alert. +suspend fun redeemBadgeCode(rhId: Long?, user: User, code: String): BadgeRedeemOutcome = + when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, code)) { + null -> BadgeRedeemOutcome.Cancelled + is BadgeRedeemResult.Redeemed -> withContext(Dispatchers.Main) { + val badgeState = result.badgeState + BadgeModel.set(rhId, user.userId, badgeState) + chatModel.updateUser(result.user) + if (badgeState != null && !badgeState.shown) { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + BadgeRedeemOutcome.Refused( + generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used) + ) + } else { + appPrefs.supporterBannerShown.set(true) + BadgeRedeemOutcome.Redeemed + } + } + is BadgeRedeemResult.Failed -> { + Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") + BadgeRedeemOutcome.Refused(chatModel.controller.redeemErrorText(result.err, purchase = false)) + } + } + +fun showCannotRedeemAlert(message: String) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_error_title), text = message) +} + +// a hidden profile is named only while it is the active one, which is unlocked and on screen +fun showBadgeAddedAlert(rhId: Long?, user: UserLike) { + val owner = if (chatModel.controller.activeUser(rhId, user)) chatModel.currentUser.value + else chatModel.users.firstOrNull { it.user.remoteHostId == rhId && it.user.userId == user.userId }?.user?.takeIf { !it.hidden } + if (owner != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_link_added_title), + text = String.format(generalGetString(MR.strings.badges_link_added_profile), owner.displayName) + ) + } +} + @Composable -fun BadgesRedeemCodeView(modalManager: ModalManager) { +fun BadgesRedeemCodeView(modalManager: ModalManager, unwindToDepth: Int) { val rhId = remember { chatModel.remoteHostId() } - val supporterBannerShown = remember { appPrefs.supporterBannerShown } val code = remember { mutableStateOf(TextFieldValue("")) } val canonicalCode = remember { mutableStateOf(null) } val submitting = remember { mutableStateOf(false) } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) // when the text is unchanged, the field's own value is kept: it carries the cursor position and the // keyboard's composition state, which BasicTextField loses unless they are passed back to it @@ -78,37 +126,14 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) { val user = chatModel.currentUser.value ?: return submitting.value = true withBGApi { - when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, sending)) { - null -> withContext(Dispatchers.Main) { submitting.value = false } - is BadgeRedeemResult.Redeemed -> { - val badgeState = result.badgeState - withContext(Dispatchers.Main) { - submitting.value = false - // set before dismissing: BadgesView then switches Support to Your Badge while this screen - // still covers it, so the pop lands on Your Badge instead of showing the switch - BadgeModel.set(rhId, user.userId, badgeState) - chatModel.updateUser(result.user) - if (badgeState != null && !badgeState.shown) { - // a replay adds no purchase; a fresh code's badge can be retired on arrival - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_error_title), - text = generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used) - ) - } else { - supporterBannerShown.set(true) - modalManager.closeModal() - } - } - } - is BadgeRedeemResult.Failed -> { - Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") - withContext(Dispatchers.Main) { - submitting.value = false - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_error_title), - text = chatModel.controller.redeemErrorText(result.err) - ) - } + val outcome = redeemBadgeCode(rhId, user, sending) + withContext(Dispatchers.Main) { + submitting.value = false + when (outcome) { + // a shown badge closes this screen itself, and a second close would take the screen beneath + is BadgeRedeemOutcome.Redeemed -> if (currentShownBadge() == null) modalManager.closeModal() + is BadgeRedeemOutcome.Refused -> showCannotRedeemAlert(outcome.message) + is BadgeRedeemOutcome.Cancelled -> {} } } } @@ -244,3 +269,189 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) { onclick = onClick ) } + +// the open screen's own state, not a copy of its step, so the two cannot disagree +private var badgeLinkStep: MutableState? = null + +private fun isBadgeLinkOpen(): Boolean = + ModalManager.start.hasModalOpen(ModalViewId.BADGE_LINK) + +fun isBadgeLinkIssuing(): Boolean = + isBadgeLinkOpen() && badgeLinkStep?.value == BadgeLinkStep.Issuing + +fun openBadgeLink(rhId: Long?, codeText: String) { + val code = parseBadgeCode(codeText) + ?: return showCannotRedeemAlert(generalGetString(MR.strings.badges_error_invalid_code)) + // opens over the chat list, as iOS dismisses every sheet before a link + ModalManager.closeAllModalsEverywhere() + // held by the modal, not remembered: a modal is composed only while on top, and rotation recreates the activity, + // either of which would reset remembered state to Confirming with a request in flight + val step = mutableStateOf(BadgeLinkStep.Confirming) + ModalManager.start.showCustomModal(id = ModalViewId.BADGE_LINK) { close -> + BadgesRedeemLinkView(rhId, code, step, close) + } + badgeLinkStep = step +} + +enum class BadgeLinkStep { + Confirming, + Issuing, + Redeemed, + ViewingBadge +} + +// Any web page can send a badge link, and a profile holds one badge at a time, +// so this screen asks before redeeming, names the profile, and offers nothing but the redemption. +// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an +// interrupted request is not resumed, and the code stays on the page that issued the link. +@Composable +fun BadgesRedeemLinkView(rhId: Long?, code: String, step: MutableState, close: () -> Unit) { + fun isOpen(): Boolean = + badgeLinkStep === step && isBadgeLinkOpen() + + fun isShowing(): Boolean = + isOpen() && ModalManager.start.isLastModalOpen(ModalViewId.BADGE_LINK) + + // An outcome can arrive after this screen was closed or covered: it must not close another screen, + // and a covered one goes back to asking, so it is never left locked on the spinner. + fun closeIfShowing() { + if (isShowing()) { + close() + } else if (isOpen()) { + step.value = BadgeLinkStep.Confirming + } + } + + fun redeemFromLink() { + // a second tap before the screen changes must not send the code again + if (step.value != BadgeLinkStep.Confirming) return + val user = chatModel.currentUser.value ?: return closeIfShowing() + step.value = BadgeLinkStep.Issuing + withBGApi { + val outcome = redeemBadgeCode(rhId, user, code) + withContext(Dispatchers.Main) { + when (outcome) { + is BadgeRedeemOutcome.Redeemed -> { + if (!isShowing()) showBadgeAddedAlert(rhId, user) + // a covered screen is not left on the spinner; a closed one is not written to + if (isOpen()) step.value = BadgeLinkStep.Redeemed + } + is BadgeRedeemOutcome.Refused -> { + closeIfShowing() + showCannotRedeemAlert(outcome.message) + } + is BadgeRedeemOutcome.Cancelled -> closeIfShowing() + } + } + } + } + + // read for the profile the screen names, when it renders, so the two cannot disagree; + // core refuses a code while the profile shows a badge + val profileHasBadge = BadgeModel.isCurrent(rhId, chatModel.currentUser.value?.userId) && BadgeModel.badgeState.value?.shown == true + + when (step.value) { + BadgeLinkStep.Confirming -> ModalView(::closeIfShowing) { + if (profileHasBadge) { + BadgeHeld(onViewBadge = { step.value = BadgeLinkStep.ViewingBadge }, onCancel = ::closeIfShowing) + } else { + Confirming(onConfirm = ::redeemFromLink, onCancel = ::closeIfShowing) + } + } + BadgeLinkStep.Issuing -> ModalView(::closeIfShowing) { BeingIssued(onDismiss = ::closeIfShowing) } + BadgeLinkStep.Redeemed, BadgeLinkStep.ViewingBadge -> BadgesView(ModalManager.start, close) + } +} + +// the screen cannot tell a repeated link for the badge it shows from a code for another badge, +// so it asserts neither +@Composable +private fun BadgeHeld(onViewBadge: () -> Unit, onCancel: () -> Unit) { + LinkStep( + MR.strings.badges_link_held_title, + primary = MR.strings.badges_link_view_badge to onViewBadge, + textButton = MR.strings.cancel_verb to onCancel + ) { + LinkText(stringResource(MR.strings.badges_link_held_other_profile)) + LinkText(stringResource(MR.strings.badges_link_held_page)) + } +} + +@Composable +private fun Confirming(onConfirm: () -> Unit, onCancel: () -> Unit) { + LinkStep( + MR.strings.badges_link_confirm_title, + primary = MR.strings.badges_link_add_badge to onConfirm, + textButton = MR.strings.cancel_verb to onCancel + ) { + LinkText(String.format(stringResource(MR.strings.badges_link_confirm_profile), chatModel.currentUser.value?.displayName ?: "")) + } +} + +// leaving does not cancel: the badge is still added after the screen closes +@Composable +private fun BeingIssued(onDismiss: () -> Unit) { + LinkStep(MR.strings.badges_issuing_title, textButton = MR.strings.badges_dismiss to onDismiss) { + LinkText(stringResource(MR.strings.badges_link_issuing_profile)) + Spacer(Modifier.weight(1f)) + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + } +} + +// with no primary, the text button takes its place, so a button does not move between steps +@Composable +private fun LinkStep( + title: StringResource, + primary: Pair Unit>? = null, + textButton: Pair Unit>, + content: @Composable ColumnScope.() -> Unit +) { + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + content() + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (primary != null) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = primary.first, + onboarding = null, + onclick = primary.second + ) + TextButtonBelowOnboardingButton(stringResource(textButton.first), textButton.second) + } else { + TextButtonBelowOnboardingButton(stringResource(textButton.first), textButton.second) + TextButtonBelowOnboardingButton("", null) + } + } + } +} + +@Composable +private fun LinkText(text: String) { + Text( + text, + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index a58ab552f8..c0e1c968b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -32,7 +32,7 @@ import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton import chat.simplex.res.* @Composable -fun BadgesSupportSimplexView(modalManager: ModalManager) { +fun BadgesSupportSimplexView(modalManager: ModalManager, unwindToDepth: Int) { ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -55,8 +55,6 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { modifier = Modifier.fillMaxWidth() ) - // TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen - // returns to the flow and HowItWorksButton() moves there, leaving this one alone here. HowItWorksButton(modalManager) Spacer(Modifier.weight(1f)) @@ -65,22 +63,37 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { Spacer(Modifier.weight(1f)) + // In-app purchase is not released: the badge is chosen on the site, and the code redeemed + // here. Revert this commit to restore the block below. + // Column(horizontalAlignment = Alignment.CenterHorizontally) { + // if (badgeStoreAvailable) { + // ChooseLevelButton(modalManager, unwindToDepth) + // if (badgeBrowserAllowed()) { + // BuyInBrowserTextButton() + // } else { + // RedeemCodeButton(modalManager, unwindToDepth) + // } + // } else { + // BuyInBrowserButton(modalManager, unwindToDepth) + // RedeemCodeButton(modalManager, unwindToDepth) + // } + // } Column(horizontalAlignment = Alignment.CenterHorizontally) { - RedeemCodeButton(modalManager) - GetCodeButton() + ChooseBadgeOnSiteButton(modalManager, unwindToDepth) + RedeemCodeButton(modalManager, unwindToDepth) } } } -// the in-app purchase path, kept compiling and uncalled until payments return after the MVP @Composable -private fun ChooseLevelButton(modalManager: ModalManager) { +private fun ChooseLevelButton(modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.badges_choose_your_level, + labelId = MR.strings.badges_choose_your_badge_title, onboarding = null, + enabled = BadgeStore.canBuy(chatModel.currentUser.value?.userId), onclick = { - modalManager.showModal { BadgesYourLevelView(modalManager) } + modalManager.showModal { BadgesChooseBadgeView(modalManager, unwindToDepth) } } ) } @@ -111,23 +124,56 @@ private fun HowItWorksButton(modalManager: ModalManager) { } @Composable -private fun RedeemCodeButton(modalManager: ModalManager) { +fun RedeemCodeButton(modalManager: ModalManager, unwindToDepth: Int) { + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_redeem_code_button), + onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager, unwindToDepth) } } + ) +} + +@Composable +private fun BuyInBrowserTextButton() { + val uriHandler = LocalUriHandler.current + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_buy_in_browser), + onClick = { uriHandler.openUriCatching(badgePageUrl(appLinkSchemeRegistered())) } + ) +} + +// In-app purchase is not released: delete with the commented block above to restore it. +@Composable +private fun ChooseBadgeOnSiteButton(modalManager: ModalManager, unwindToDepth: Int) { + val uriHandler = LocalUriHandler.current OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.badges_redeem_code_button, + labelId = MR.strings.badges_choose_your_badge_title, onboarding = null, onclick = { - modalManager.showModal { BadgesRedeemCodeView(modalManager) } + val linkReturns = appLinkSchemeRegistered() + uriHandler.openUriCatching(badgePageUrl(linkReturns)) + // with no scheme to bring the code back, the code is pasted into this screen, opened beside the browser + if (!linkReturns && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) { + modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) } + } } ) } @Composable -private fun GetCodeButton() { +private fun BuyInBrowserButton(modalManager: ModalManager, unwindToDepth: Int) { val uriHandler = LocalUriHandler.current - TextButtonBelowOnboardingButton( - text = stringResource(MR.strings.badges_get_your_code), - onClick = { uriHandler.openExternalLink("https://simplex.chat/badges/") } + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_buy_in_browser, + onboarding = null, + onclick = { + val linkReturns = appLinkSchemeRegistered() + uriHandler.openUriCatching(badgePageUrl(linkReturns)) + // with no scheme to bring the code back, the code is pasted into this screen, opened beside the browser + if (!linkReturns && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) { + modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) } + } + } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index ff2660497e..0e91b9ec7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -2,34 +2,62 @@ package chat.simplex.common.views.badges import androidx.compose.animation.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import chat.simplex.common.model.BadgeModel import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView +import chat.simplex.common.views.helpers.ModalViewId +import chat.simplex.res.* @OptIn(ExperimentalAnimationApi::class) @Composable fun BadgesView(modalManager: ModalManager, close: () -> Unit) { - val shownBadge: BadgeState? = run { - if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null - val badgeState = BadgeModel.badgeState.value - if (badgeState != null && badgeState.shown) badgeState else null - } + val shownBadge = currentShownBadge() + val unwindToDepth = remember { modalManager.openModalCount() } // the card look is a modal setting, so the modal is composed here to follow the screen shown ModalView(close, cardScreen = shownBadge != null) { - AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState -> + AnimatedContent( + targetState = Triple(shownBadge, BadgeStore.purchaseState(chatModel.currentUser.value?.userId), BadgeStore.checkingPurchases), + transitionSpec = { fadeIn() with fadeOut() }, + contentKey = { (badgeState, purchaseState, checkingPurchases) -> Triple(badgeState != null, purchaseState, checkingPurchases) } + ) { (badgeState, purchaseState, checkingPurchases) -> if (badgeState != null) { BadgesYourBadgeView(badgeState, modalManager) + } else if (purchaseState != null) { + // holds the purchase screens' slot, so a consumable cannot be bought twice + BadgesPurchaseStateView(purchaseState.title, purchaseState.message, BadgeStore.creditError(chatModel.currentUser.value?.userId), onDismiss = close) + } else if (checkingPurchases) { + BadgesPurchaseStateView(MR.strings.badges_checking_purchases_title, null, onDismiss = close) } else { - BadgesSupportSimplexView(modalManager) + BadgesSupportSimplexView(modalManager, unwindToDepth) } } } } +fun currentShownBadge(): BadgeState? { + if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return null + val badgeState = BadgeModel.badgeState.value + return if (badgeState != null && badgeState.shown) badgeState else null +} + +// The badges modal shows the purchase state itself, so screens pushed over it have to go once a purchase +// appears. Only the top one is composed, so this runs there, closing them in one effect because a close per +// recomposition restarts showInView's transition, and stopping on a depth because closeModal defers removal. +// TODO [badges] ModalManager has no close-above and records no parentage; with either, the depth goes away. +@Composable +fun CloseWhenSupportGivesWay(modalManager: ModalManager, unwindToDepth: Int) { + val gaveWay = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null || currentShownBadge() != null + LaunchedEffect(gaveWay) { + if (gaveWay) while (modalManager.openModalCount() > unwindToDepth) modalManager.closeModal() + } +} + // ModalManager.end, not start: every caller is in the chat, which on desktop is the right pane fun openBadgesView() { - ModalManager.end.showCustomModal { close -> BadgesView(ModalManager.end, close) } + ModalManager.end.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.end, close) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 9c019ad535..82575984f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -791,8 +791,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val linkMode = chatModel.simplexLinkMode.value if (descr == null) { if (short != null) { + val parsed = parseToMarkdownText(short) MarkdownText( - short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4, + short, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } @@ -801,8 +802,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val truncated = firstLine.length > 100 val multiline = descr.length > firstLine.length if (short == null && !truncated && !multiline) { + val parsed = parseToMarkdownText(descr) MarkdownText( - descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4, + descr, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index e4f93a0b8a..0c7f1937f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -52,17 +52,19 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools val selection = remember { mutableStateOf(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { + fun TextBubble(text: String, formattedText: List?, markdownBlocks: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( - text, if (text.isEmpty()) emptyList() else formattedText, + text, if (text.isEmpty()) emptyList() else formattedText, markdownBlocks, sender = sender, mentions = mentions, userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, - onLinkLongClick = { showMenu.value = true } + onLinkLongClick = { showMenu.value = true }, + blockLayout = true, + cardColor = if (sent) appColors.sentQuote else appColors.receivedQuote ) } else { Text( @@ -85,7 +87,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + TextBubble(text, ciVersion.formattedText, ciVersion.markdownBlocks, sender = null, showMenu = showMenu, mentions = ci.mentions, userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null @@ -137,7 +139,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu) + TextBubble(text, qi.formattedText, qi.markdownBlocks, sender = qi.sender(null), showMenu) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index b477f56118..7f92ca0651 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -57,7 +57,7 @@ fun ContextItemView( } } else null MarkdownText( - contextItem.text, contextItem.formattedText, + contextItem.text, contextItem.formattedText, contextItem.markdownBlocks, sender = null, toggleSecrets = false, maxLines = lines, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index 20f5acd3e0..e04f75d799 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.itemPrefixText -import chat.simplex.common.views.chat.item.itemSegmentDisplayText +import chat.simplex.common.views.chat.item.itemDisplay import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -267,23 +267,22 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple if (sel.first < prefix.length) { sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1)) } - val formattedText = ci.formattedText ?: run { + val parts = itemDisplay(ci, linkMode)?.parts ?: run { val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length) val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length) if (start < end) sb.append(ci.text, start, end) return sb.toString() } var displayOffset = prefix.length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length val overlapStart = maxOf(displayOffset, sel.first) val overlapEnd = minOf(displayEnd, sel.last + 1) if (overlapStart < overlapEnd) { - if (ft.text.length == segDisplay.length) { - sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + if (source.length == segDisplay.length) { + sb.append(source, overlapStart - displayOffset, overlapEnd - displayOffset) } else { - sb.append(ft.text) + sb.append(source) } } displayOffset = displayEnd @@ -293,12 +292,11 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple // Snaps a boundary offset to include full transformed segments. private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { - val formattedText = ci.formattedText ?: return offset + val parts = itemDisplay(ci, linkMode)?.parts ?: return offset var displayOffset = itemPrefixText(ci).length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length - if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + if (offset > displayOffset && offset < displayEnd && source.length != segDisplay.length) { return if (expandRight) displayEnd else displayOffset } displayOffset = displayEnd @@ -425,18 +423,55 @@ private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { return idx } -class ItemSelection( +class TextSelection( val highlightRange: IntRange?, val positionModifier: Modifier, - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? + val onTextLayoutResult: (TextLayoutResult) -> Unit ) +class SelectionText(var base: Int) { + var bounds: Rect? = null + var layout: TextLayoutResult? = null +} + +class ItemSelection( + private val highlightRange: IntRange?, + private val texts: MutableMap?, + private val bases: List +) { + fun text(index: Int): TextSelection? { + val texts = texts ?: return null + val base = bases.getOrNull(index) ?: return null + val t = texts.getOrPut(index) { SelectionText(base) } + t.base = base + val highlight = highlightRange?.let { (it.first - base).coerceAtLeast(0) .. (it.last - base) }?.takeUnless { it.isEmpty() } + return TextSelection( + highlight, + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + t.bounds = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + }, + { t.layout = it } + ) + } +} + +private class TextHit(val text: SelectionText, val bounds: Rect, val layout: TextLayoutResult, val offset: Int) + +private fun textAt(texts: Collection, x: Float, y: Float): TextHit? = + texts.mapNotNull { t -> + val b = t.bounds ?: return@mapNotNull null + val l = t.layout?.takeIf { it.layoutInput.text.isNotEmpty() } ?: return@mapNotNull null + Triple(t, b, l) + }.minByOrNull { (_, b, _) -> maxOf(b.top - y, 0f, y - b.bottom) * 4 + maxOf(b.left - x, 0f, x - b.right) } + ?.let { (t, b, l) -> TextHit(t, b, l, l.getOffsetForPosition(Offset(x - b.left, y - b.top))) } + // Sets up selection tracking for a text item: anchor/focus offset resolution, // highlight range computation, and position/layout result capture. @Composable -fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { - val boundsState = remember { mutableStateOf(null) } - val layoutResultState = remember { mutableStateOf(null) } +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean, bases: List): ItemSelection { + val texts = remember { HashMap() } + texts.keys.retainAll { it < bases.size } if (selectionManager != null && selectionIndex >= 0 && !isLive) { val isAnchor = remember(selectionIndex) { @@ -444,12 +479,8 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, } LaunchedEffect(isAnchor.value) { if (!isAnchor.value) return@LaunchedEffect - val bounds = boundsState.value ?: return@LaunchedEffect - val layout = layoutResultState.value ?: return@LaunchedEffect - val offset = layout.getOffsetForPosition( - Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top) - ) - selectionManager.setAnchorOffset(offset) + val hit = textAt(texts.values, selectionManager.anchorWindowX, selectionManager.anchorWindowY) ?: return@LaunchedEffect + selectionManager.setAnchorOffset(hit.text.base + hit.offset) } val isFocus = remember(selectionIndex) { @@ -459,10 +490,9 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, LaunchedEffect(Unit) { snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } .collect { (py, px) -> - val bounds = boundsState.value ?: return@collect - val layout = layoutResultState.value ?: return@collect - val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) - val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val hit = textAt(texts.values, px, py) ?: return@collect + val bounds = hit.bounds + val charBox = hit.layout.getBoundingBox(hit.offset.coerceIn(0, hit.layout.layoutInput.text.length - 1)) val ls = selectionManager.listState?.value val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } val charRect = if (ls != null && itemInfo != null) { @@ -474,7 +504,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, bottom = bounds.top + charBox.bottom - itemWindowY ) } else Rect.Zero - selectionManager.updateFocusOffset(offset, charRect) + selectionManager.updateFocusOffset(hit.text.base + hit.offset, charRect) } } } @@ -484,18 +514,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value } else null - val positionModifier = if (selectionManager != null) { - Modifier.onGloballyPositioned { - val pos = it.positionInWindow() - boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) - } - } else Modifier - - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { - { layoutResultState.value = it } - } else null - - return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) + return ItemSelection(highlightRange, if (selectionManager != null) texts else null, bases) } // Sets up full-item selection for emoji items (no character-level tracking). diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index ec8b545647..ef847c93cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -146,13 +146,16 @@ private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boole val uriHandler = LocalUriHandler.current Column { SelectionContainer(Modifier.fillMaxWidth()) { + val parsed = if (markdown) remember(text) { parseToMarkdownText(text) } else null MarkdownText( text, - formattedText = if (markdown) remember(text) { parseToMarkdown(text) } else null, + formattedText = parsed?.formattedText, + markdownBlocks = parsed?.markdownBlocks, toggleSecrets = false, modifier = Modifier.fillMaxHeight().padding(horizontal = DEFAULT_PADDING), linkMode = linkMode, uriHandler = uriHandler, - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp) + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), + blockLayout = true ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index fd422b649a..ca370c1b52 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -61,6 +61,7 @@ fun FramedItemView( MarkdownText( qi.text, qi.formattedText, + qi.markdownBlocks, toggleSecrets = true, maxLines = lines, overflow = TextOverflow.Ellipsis, @@ -457,11 +458,12 @@ fun CIMarkdownText( ) { val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text - val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) } + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases) - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { MarkdownText( - text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, + text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, mentions = ci.mentions, userMemberId = when { @@ -470,8 +472,9 @@ fun CIMarkdownText( }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, stripLink = stripLink, - selectionRange = selection.highlightRange, - onTextLayoutResult = selection.onTextLayoutResult + selection = selection, + blockLayout = true, + cardColor = if (ci.chatDir.sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt new file mode 100644 index 0000000000..75a778e567 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt @@ -0,0 +1,248 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.takeOrElse +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun MarkdownBlocks( + formattedText: List, + blocks: List, + style: TextStyle, + modifier: Modifier, + cardColor: Color, + blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit, + metaLine: @Composable () -> Unit +) { + val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f) + val cardShape = RoundedCornerShape(12.dp * roundness) + val lineColor = MaterialTheme.colors.secondary.copy(alpha = 0.35f) + val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface } + val fontSize = style.fontSize.takeOrElse { 16.sp } + val lineHeight = style.lineHeight.takeOrElse { 22.sp } + val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } } + val lastIsText = when (blocks.lastOrNull()) { + is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true + else -> false + } + @Composable + fun BlockText(index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier = Modifier, softWrap: Boolean = true) { + blockText(index, range, style, modifier, softWrap, lastIsText && index == starts.last() - 1) + } + Column(modifier) { + blocks.forEachIndexed { k, block -> + if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) { + Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) + } + val start = starts[k] + when (block) { + is MarkdownBlock.Paragraph -> BlockText(start, block.content, style) + is MarkdownBlock.Heading -> { + val scale = when (block.level) { + 1 -> 1.5f + 2 -> 1.3f + 3 -> 1.15f + else -> 1f + } + BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold)) + } + is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) { + Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f))) + Spacer(Modifier.width(10.dp)) + BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f))) + } + is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) -> + Row(Modifier.padding(start = 20.dp * level)) { + Text(entry.number ?: "•", Modifier.widthIn(min = 14.dp), style = style) + Spacer(Modifier.width(6.dp)) + BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false)) + } + } + is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m -> + BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false) + } + is MarkdownBlock.Table -> { + val tableRows = listOf(block.header) + block.rows + val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size } + Box(Modifier.horizontalScroll(rememberScrollState())) { + TableGrid( + block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor, + Modifier.clip(cardShape).border(1.dp, lineColor, cardShape) + ) { + tableRows.forEachIndexed { r, row -> + row.forEachIndexed { c, cell -> + BlockText( + rowStarts[r] + c, cell, if (r == 0) style.copy(fontWeight = FontWeight.Bold) else style, + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), softWrap = false + ) + } + } + } + } + } + is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor)) + } + } + if (!lastIsText) metaLine() + } +} + +private val MarkdownBlock.start: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.from + else -> textRanges.firstOrNull()?.range?.from ?: 0 +} + +private val MarkdownBlock.end: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.to + else -> textRanges.lastOrNull()?.range?.to ?: 0 +} + +private fun paragraphBreak(fts: List, from: Int, to: Int): Boolean = + (from.coerceIn(0, fts.size) until to.coerceIn(0, fts.size)).any { (fts[it].format as? Format.Markup)?.token is BlockToken.ParagraphBreak } + +private fun codeText(fts: List, r: SegmentRange): String = + (r.from.coerceIn(0, fts.size) until r.to.coerceIn(0, fts.size)).joinToString("") { i -> + when (val f = fts[i].format) { + is Format.Markup -> f.token.drawnText + is Format.Escape -> "" + else -> fts[i].text + } + } + +@Composable +private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) { + var tapped by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val clipboard = LocalClipboardManager.current + Box( + Modifier + .fillMaxWidth() + .clip(shape) + .background(color) + .hoverable(interactionSource) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + if (waitForUpOrCancellation() != null) tapped = !tapped + } + } + ) { + content(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp)) + if (hovered || tapped) { + Box(Modifier.matchParentSize(), contentAlignment = Alignment.TopEnd) { + Box( + Modifier + .padding(4.dp) + .size(28.dp) + .clip(RoundedCornerShape(8.dp * roundness)) + .background(color) + .clickable { + clipboard.setText(AnnotatedString(copyText)) + showToast(generalGetString(MR.strings.copied)) + }, + contentAlignment = Alignment.Center + ) { + Icon(painterResource(MR.images.ic_content_copy), stringResource(MR.strings.copy_verb), Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + } + } + } + } +} + +private class TableGridLines { + var xs: List = emptyList() + var ys: List = emptyList() +} + +@Composable +private fun TableGrid( + columns: List, + rowSizes: List, + headerColor: Color, + stripeColor: Color, + lineColor: Color, + modifier: Modifier, + content: @Composable () -> Unit +) { + val lines = remember { TableGridLines() } + Layout( + content, + modifier.drawBehind { + val xs = lines.xs + val ys = lines.ys + if (ys.size < 2) return@drawBehind + drawRect(headerColor, Offset.Zero, Size(size.width, ys[1].toFloat())) + for (r in 2 until ys.size - 1 step 2) { + drawRect(stripeColor, Offset(0f, ys[r].toFloat()), Size(size.width, (ys[r + 1] - ys[r]).toFloat())) + } + val stroke = 1.dp.toPx() + xs.drop(1).dropLast(1).forEach { x -> drawLine(lineColor, Offset(x.toFloat(), 0f), Offset(x.toFloat(), size.height), stroke) } + ys.drop(1).dropLast(1).forEach { y -> drawLine(lineColor, Offset(0f, y.toFloat()), Offset(size.width, y.toFloat()), stroke) } + } + ) { measurables, _ -> + val cells = rowSizes.flatMapIndexed { r, n -> List(n) { c -> r to c } }.take(measurables.size) + val widths = IntArray(maxOf(columns.size, rowSizes.maxOrNull() ?: 0)) + cells.forEachIndexed { k, (_, c) -> widths[c] = maxOf(widths[c], measurables[k].maxIntrinsicWidth(Constraints.Infinity)) } + val placeables = cells.mapIndexed { k, (_, c) -> measurables[k].measure(Constraints(maxWidth = widths[c])) } + val heights = IntArray(rowSizes.size) + placeables.forEachIndexed { k, p -> heights[cells[k].first] = maxOf(heights[cells[k].first], p.height) } + val xs = widths.toList().runningFold(0) { a, w -> a + w } + val ys = heights.toList().runningFold(0) { a, h -> a + h } + lines.xs = xs + lines.ys = ys + layout(xs.last(), ys.last()) { + placeables.forEachIndexed { k, p -> + val (r, c) = cells[k] + val free = widths[c] - p.width + val x = xs[c] + when (columns.getOrNull(c)) { + TableAlign.Right -> free + TableAlign.Center -> free / 2 + else -> 0 + } + p.place(x, ys[r]) + } + } + } +} 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 c198451464..9b3162744d 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 @@ -12,8 +12,20 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.* import androidx.compose.ui.platform.* @@ -22,11 +34,14 @@ import androidx.compose.ui.text.AnnotatedString.Range import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.appColors +import chat.simplex.common.views.chat.ItemSelection import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -53,14 +68,17 @@ private fun showFullProfileDescription(description: String) { ModalManager.end.showModalCloseable { _ -> ColumnWithScrollBar { AppBarTitle(generalGetString(MR.strings.profile_description__field)) + val parsed = parseToMarkdownText(description) MarkdownText( description, - parseToMarkdown(description), + parsed?.formattedText, + parsed?.markdownBlocks, toggleSecrets = true, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), uriHandler = LocalUriHandler.current, linkMode = chatModel.simplexLinkMode.value, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), + blockLayout = true, ) } } @@ -85,10 +103,10 @@ private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) // Display text for a single formatted segment — must be coordinated with MarkdownText. -fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = +fun segmentDisplayText(ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode): String = when (ft.format) { is Format.Mention -> { - val mention = ci.mentions?.get(ft.format.memberName) + val mention = mentions?.get(ft.format.memberName) if (mention?.memberRef != null) { val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" @@ -104,13 +122,37 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin if (t != null) "$t ${ft.format.viaHosts}" else ft.text } is Format.Command -> ft.text + is Format.Escape -> "" + is Format.Markup -> ft.format.token.drawnText else -> ft.text } -// Full display text for a chat item — joins segment display texts. -fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { - val formattedText = ci.formattedText ?: return ci.text - return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +class ItemDisplay(val parts: List>, val bases: List) + +fun itemDisplay(ci: ChatItem, linkMode: SimplexLinkMode): ItemDisplay? { + val fts = ci.formattedText ?: return null + val segment = { ft: FormattedText -> + val display = segmentDisplayText(ft, ci.mentions, linkMode) + display to if (ft.format is Format.Markup) display else ft.text + } + val blocks = ci.markdownBlocks ?: return ItemDisplay(fts.map(segment), listOf(0)) + val parts = mutableListOf>() + var offset = itemPrefixText(ci).length + val bases = blockTextRanges(blocks).mapIndexed { i, t -> + if (i > 0) { + val separator = if (t.inRow) "\t" else "\n" + parts += separator to separator + offset += 1 + } + val base = if (i == 0) 0 else offset + for (j in t.range.from.coerceIn(0, fts.size) until t.range.to.coerceIn(0, fts.size)) { + val part = segment(fts[j]) + parts += part + offset += part.first.length + } + base + } + return ItemDisplay(parts, bases) } // Display-only prefix rendered before ci.text (e.g. "Spam: " for reports). @@ -120,11 +162,12 @@ fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) else -> "" } -// Text transformations in MarkdownText must match itemSegmentDisplayText above +// Text transformations in MarkdownText must match segmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, formattedText: List? = null, + markdownBlocks: List? = null, sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, @@ -145,15 +188,44 @@ fun MarkdownText ( showTimestamp: Boolean = true, prefix: AnnotatedString? = null, stripLink: String? = null, - selectionRange: IntRange? = null, - onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null + selection: ItemSelection? = null, + textIndex: Int = 0, + blockLayout: Boolean = false, + range: IntRange? = null, + softWrap: Boolean = true, + cardColor: Color? = null, + parentDirection: LayoutDirection? = null ) { val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } - val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { + if (blockLayout && formattedText != null && markdownBlocks != null && range == null) { + val direction = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) { + MarkdownBlocks( + formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote, + blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last -> + MarkdownText( + text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL, + mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle, + uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick, + showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null, + selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction + ) + }, + metaLine = { + MarkdownText( + text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style, + linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY + ) + } + ) + } + return + } + val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) { "\n" } else if (meta != null) { reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) @@ -196,6 +268,7 @@ fun MarkdownText ( } ) } + val textSelection = selection?.text(textIndex) if (formattedText == null) { var selectableEnd = 0 val annotatedText = buildAnnotatedString { @@ -210,9 +283,9 @@ fun MarkdownText ( } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = modifier.then(textSelection.positionModifier), maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = textSelection.onTextLayoutResult) } else { Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } @@ -222,29 +295,38 @@ fun MarkdownText ( var hasSecrets = false var hasCommands = false var hasModals = false + val hiddenSecrets = mutableListOf() val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) - for ((i, ft) in formattedText.withIndex()) { + val headings = if (range == null && markdownBlocks != null) headingSegments(formattedText.size, markdownBlocks) else null + fun appendSecret(s: String) { + val start = length + withStyle(hiddenSecretStyle) { append(s) } + hiddenSecrets += start until length + } + for (i in range?.let { it.first.coerceAtLeast(0) .. minOf(it.last, formattedText.lastIndex) } ?: formattedText.indices) { + val ft = formattedText[i] + val heading = headings?.get(i) == true + if (heading) pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) if (ft.format == null) append(ft.text) else when(ft.format) { - is Format.Bold -> withStyle(ft.format.style) { append(ft.text) } + is Format.Bold -> withStyle(if (markdownBlocks != null && ft.format.singleStar) Format.Italic().style else ft.format.style) { append(ft.text) } is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } is Format.Small -> withStyle(ft.format.style) { append(ft.text) } is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } is Format.Secret -> { - val ftStyle = ft.format.style if (toggleSecrets) { hasSecrets = true val key = i.toString() withAnnotation(tag = "SECRET", annotation = key) { - if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } + if (showSecrets[key] == true) append(ft.text) else appendSecret(ft.text) } } else { - withStyle(ftStyle) { append(ft.text) } + appendSecret(ft.text) } } is Format.Mention -> { @@ -290,7 +372,7 @@ fun MarkdownText ( is Format.HyperLink -> { hasLinks = true val ftStyle = Format.linkStyle - withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) { + withAnnotation(tag = if (ft.format.isEmail) "OTHER_URL" else "WEB_URL", annotation = ft.format.linkUri) { withStyle(ftStyle) { append(ft.format.showText ?: ft.text) } } } @@ -338,8 +420,15 @@ fun MarkdownText ( withStyle(ftStyle) { append(ft.text) } } } + is Format.Escape -> {} + is Format.Markup -> append( + if (range != null) ft.format.token.drawnText + else if (markdownBlocks != null) ft.format.token.previewText + else ft.text + ) is Format.Unknown -> append(ft.text) } + if (heading) pop() } selectableEnd = this.length if (meta?.isLive == true) { @@ -350,10 +439,21 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + val layoutResult = remember { mutableStateOf(null) } + val onTextLayout: (TextLayoutResult) -> Unit = { r -> + layoutResult.value = r + textSelection?.onTextLayoutResult?.invoke(r) + } + val textModifier = modifier + .then(textSelection?.positionModifier ?: Modifier) + .then(if (hiddenSecrets.isEmpty()) Modifier else Modifier.drawWithContent { + drawContent() + drawSecretPattern(hiddenSecrets, layoutResult.value) + }) if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || hasModals) { val icon = remember { mutableStateOf(PointerIcon.Text) } - ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = textModifier.pointerHoverIcon(icon.value), softWrap = softWrap, maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -410,13 +510,38 @@ fun MarkdownText ( || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset) }, - onTextLayout = { onTextLayoutResult?.invoke(it) } + onTextLayout = onTextLayout ) } else { - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayout) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + Text(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf(), onTextLayout = onTextLayout) + } + } + } + } +} + +private val hiddenSecretStyle = SpanStyle(color = Color.Transparent) + +private val secretTile: ImageBitmap? by lazy { MR.images.vertical_logo.toComposeImageBitmap() } + +private fun ContentDrawScope.drawSecretPattern(ranges: List, layout: TextLayoutResult?) { + val tile = secretTile ?: return + if (layout == null) return + val brush = ShaderBrush(ImageShader(tile, TileMode.Repeated, TileMode.Repeated)) + val tileScale = 8.dp.toPx() / tile.width + val saturation = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.65f) }) + val textLength = layout.layoutInput.text.length + ranges.forEach { r -> + val path = layout.getPathForRange(r.first.coerceIn(0, textLength), (r.last + 1).coerceIn(0, textLength)) + val bounds = path.getBounds() + val side = (bounds.width + bounds.height) / tileScale + clipPath(path) { + rotate(45f, bounds.center) { + scale(tileScale, bounds.center) { + drawRect(brush, Offset(bounds.center.x - side / 2, bounds.center.y - side / 2), Size(side, side), alpha = 0.35f, colorFilter = saturation) } } } @@ -500,6 +625,7 @@ private fun SelectableText( text: AnnotatedString, style: TextStyle, modifier: Modifier = Modifier, + softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, selectionRange: IntRange? = null, @@ -511,6 +637,7 @@ private fun SelectableText( text = text, modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)), style = style, + softWrap = softWrap, maxLines = maxLines, overflow = overflow, onTextLayout = { @@ -624,7 +751,9 @@ fun stripFormattedTextLink(ft: List?, link: String): List= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + if (i >= 0 && result[i].format is Format.Markup) { + result.removeAt(i) + } else if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { result[i] = FormattedText(result[i].text.dropLast(1), null) if (result[i].text.isEmpty()) result.removeAt(result.lastIndex) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 5ff3d686a4..de223fe361 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -883,6 +883,10 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = rhId to uri + } else if (isAppLink(uri)) { + // branched on the scheme before the connection dispatch, which an app link must never reach + // an app link must not tear down a redemption in flight + if (!isBadgeLinkIssuing()) openAppLink(rhId, uri) } else { withBGApi { chatModel.appOpenUrlConnecting.value = true @@ -891,6 +895,31 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { } } +// the app's own scheme, for every link that is not a connection link, which stays on simplex: +internal const val appLinkScheme = "simplexchat" +internal const val connectionLinkScheme = "simplex" +private const val badgeLinkPath = "/badge/code/" + +// the prefix of the raw text, not a parsed URI: a link that does not parse is still an app link +fun isAppLink(uri: String): Boolean = + uri.startsWith("$appLinkScheme:", ignoreCase = true) + +fun isConnectionLink(uri: String): Boolean = + uri.startsWith("$connectionLinkScheme:", ignoreCase = true) + +// a link type added in a later version reaches this build too, so an unknown path asks for an update +private fun openAppLink(rhId: Long?, uri: String) { + // a link that does not parse is dropped, as iOS never receives one; one that parses without + // a path, such as simplexchat:badge/code/X, is an unknown link on both platforms + val parsed = uriCreateOrNull(uri) ?: return + val path = parsed.path ?: "" + if (path.startsWith(badgeLinkPath)) { + openBadgeLink(rhId, path.removePrefix(badgeLinkPath)) + } else { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.app_link_not_supported)) + } +} + @Composable private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState, onlineSearch: OnlineSearchModel) { Box { @@ -1161,7 +1190,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat SupportSimpleXBanner( title = stringResource(MR.strings.badges_support_ended), subtitle = String.format(stringResource(MR.strings.badges_support_ended_on), alert.dateText), - onTap = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }, + onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_support_ended)) } ) } @@ -1174,7 +1203,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat title = stringResource(MR.strings.badges_renewal_failed), subtitle = stringResource(MR.strings.badges_tap_for_details), warning = true, - onTap = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }, + onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_renewal_failed)) } ) } @@ -1187,7 +1216,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat showDismiss = supporterBannerTapped.value, onTap = { appPrefs.supporterBannerTapped.set(true) - ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } + ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = ::showSupportSimpleXDismissAlert ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 7e11fdfb97..89d2dfb811 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -277,6 +277,7 @@ fun ChatPreviewView( MarkdownText( text, formattedText, + if (formattedText == null) null else ci.markdownBlocks, sender = when { cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName else -> null 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 f8e81c8ee2..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 @@ -90,7 +90,10 @@ class ModalData(val keyboardCoversBar: Boolean = true) { enum class ModalViewId { SECONDARY_CHAT, - CONTEXT_USER_PICKER_INCOGNITO + CONTEXT_USER_PICKER_INCOGNITO, + BADGE_LINK, + BADGE_REDEEM_CODE, + BADGES } class ModalManager(private val placement: ModalPlacement? = null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index 8ae27ca17f..7d0851e65b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -132,13 +132,16 @@ fun PlainTextEditor( @Serializable data class ParsedFormattedText( - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ) -fun parseToMarkdown(text: String): List? { +fun parseToMarkdown(text: String): List? = parseToMarkdownText(text)?.formattedText + +fun parseToMarkdownText(text: String): ParsedFormattedText? { val formatted = chatParseMarkdown(text) return try { - json.decodeFromString(ParsedFormattedText.serializer(), formatted).formattedText + json.decodeFromString(ParsedFormattedText.serializer(), formatted) } catch (e: Exception) { Log.e(TAG, "Failed to parse into markdown: " + e.stackTraceToString()) null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 61cb73ca0b..8b145ef206 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -717,8 +717,7 @@ fun showPrepareContactAlert( image = contactShortLinkData.profile.image, icon = if (contactShortLinkData.business) MR.images.ic_work_filled_padded - else if (contactShortLinkData.profile.peerType == ChatPeerType.Bot) MR.images.ic_cube - else MR.images.ic_account_circle_filled + else peerTypeIcon(contactShortLinkData.profile.peerType) ) }, profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index bd929bc2f7..3e36ebda0f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -110,7 +110,7 @@ fun SettingsLayout( SectionView { // Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render // a gray top bar / back button that badges views don't want (they have their own inline titles). - SectionItemView(click = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }) { + SectionItemView(click = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }) { val badgeType = chatModel.currentUser.value?.profile?.localBadge?.badge?.badgeType ?: BadgeType.Supporter Image(painterResource(badgeImage(badgeType)), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) TextIconSpaced() 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 2d7304b175..1d322012d4 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -25,6 +25,7 @@ You are an owner Invalid link Please check that SimpleX link is correct. + This link is not supported by this app version. Please check for app updates. Opening database… @@ -3179,8 +3180,10 @@ The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. Supporter Legend - Send 2GB files - Send 5GB files + Files up to 2 GB + Stored for 7 days + Files up to 5 GB + Stored for 21 days %1$s/month %1$s/year Pay %1$s/month @@ -3188,19 +3191,20 @@ Pay %1$s Loading… Not available - Save %1$s - Optional profile badge\nand 2GB files - Optional profile badge\nand 5GB files + %1$s off + Supporter: 2 GB files available for 7 days. + Legend: 5 GB files available for 21 days. 1 month Monthly Annual Renews on %1$s. Cancel anytime. Ends on %1$s. - Your level + Choose your badge + Larger files that stay available longer. + How long? Support SimpleX Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile. Why SimpleX is built. - Choose your level Redeem badge code Continue How badges protect your privacy @@ -3214,19 +3218,36 @@ Get badge + better files You can support SimpleX later in Settings. Support SimpleX to send larger files that stay available longer - Purchase successful - Purchase pending - The purchase is awaiting approval. This build does not deliver purchases approved later. Purchase error - Get your code + Check your order + Duration + Total + Issuing your badge + Your payment is complete. The badge will be added to this profile. + Waiting for approval + Nothing has been charged. + Checking your purchases + Buy in browser Redeem code + Add badge to your profile? + The badge will be added to the profile %1$s. + Add badge + Profile already has a badge + A badge can be added to another profile, or here once this badge ends. + Its code is on the page you bought it on, under Show code. + View your badge + The badge will be added to this profile. + Badge added + The badge was added to the profile %1$s. Paste the code you received. SB-XXXXX-XXXXX-XXXXX-XXXXX Redeem Cannot redeem code This code is not valid. This app version cannot redeem badge codes. + This app version cannot buy badges. This profile already has a badge. Redeem the code on another profile, or once this badge ends. + This profile already has a badge. This code was not recognized. This code has already been used. This code has expired. @@ -3236,6 +3257,13 @@ This app version is too old for the badge service. Please update the app. The badge service does not recognize this badge. The badge service reported an internal error. + The store did not confirm this purchase. + This purchase was already credited to a badge on another device or profile. + Test purchases are not credited. Badges can only be bought with a real purchase. + The payment has not been confirmed yet. + The store could not be reached. + Badges cannot be bought in the app yet. + This badge is not on sale. The code was accepted, but the badge it grants has already ended. The code could not be redeemed. The code you scanned is not a badge code. @@ -3270,6 +3298,7 @@ Badge renewal failed Tap for details The badge service refused the renewal: %1$s + The badge service refused the purchase: %1$s The badge service did not respond. The badge service could not be reached. The badge issued by the service cannot be verified. diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg new file mode 100644 index 0000000000..e03226d798 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png new file mode 100644 index 0000000000..f916e43ea9 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png differ diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png new file mode 100644 index 0000000000..bb35878f0c Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png differ diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png new file mode 100644 index 0000000000..c55f481b36 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png differ diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt new file mode 100644 index 0000000000..c6baf89fcb --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt @@ -0,0 +1,19 @@ +package chat.simplex.app + +import chat.simplex.common.views.badges.badgePageUrl +import kotlin.test.Test +import kotlin.test.assertTrue + +class BadgePageUrlTest { + @Test + fun pageEndsWithALinkBackWhenTheLinkReturnsToTheApp() { + val url = badgePageUrl(linkReturns = true) + assertTrue(url.endsWith("#/tier?app=true"), "url: $url") + } + + @Test + fun pageEndsWithACodeToPasteWhenNoLinkReturns() { + val url = badgePageUrl(linkReturns = false) + assertTrue(url.endsWith("#/tier?app=desktop"), "url: $url") + } +} diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt new file mode 100644 index 0000000000..ccf78ba276 --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt @@ -0,0 +1,133 @@ +package chat.simplex.app + +import chat.simplex.common.model.* +import chat.simplex.common.views.chat.item.itemDisplay +import chat.simplex.common.views.chat.item.segmentDisplayText +import chat.simplex.common.views.helpers.ParsedFormattedText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class MarkdownBlocksTest { + private val headingAndParagraph = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"Title "},{"format":{"type":"bold","singleStar":true},"text":"x"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"text":"Para a"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"text":"b"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":" \n"},{"text":"c"}],"markdownBlocks":[{"type":"heading","level":1,"content":{"from":1,"to":3}},{"type":"paragraph","content":{"from":4,"to":9}}]}""") + + private val nestedList = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"listMark"}},"text":"- "},{"text":"x"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"indent"}},"text":" "},{"text":"y"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"listMark"}},"text":" - "},{"text":"z"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"listMark","number":"2."}},"text":"2. "},{"text":"w"}],"markdownBlocks":[{"type":"list","items":[{"content":{"from":1,"to":5},"items":[{"content":{"from":7,"to":8},"items":[]}]},{"number":"2.","content":{"from":10,"to":11},"items":[]}]}]}""") + + private val quote = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q1"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q2"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":">"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"format":{"type":"bold","singleStar":false},"text":"q3"}],"markdownBlocks":[{"type":"quote","content":{"from":1,"to":10}}]}""") + + private val codeBlockAndRule = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"fenceMark","language":"bash"}},"text":"```bash"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"ls -l"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"pwd"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"fenceMark"}},"text":"```"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"ruleMark"}},"text":"---"}],"markdownBlocks":[{"type":"codeBlock","language":"bash","content":{"from":2,"to":6}},{"type":"rule","mark":{"from":9,"to":10}}]}""") + + private val tableAligned = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"a"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"b"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"c"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"d"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"delimiterRow","columns":["none","left","right","center"]}},"text":"|---|:--|--:|:-:|"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"xxx"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"format":{"type":"bold","singleStar":false},"text":"yyy"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"zzz"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"}],"markdownBlocks":[{"type":"table","columns":["none","left","right","center"],"header":[{"from":1,"to":2},{"from":3,"to":4},{"from":5,"to":6},{"from":7,"to":8}],"rows":[[{"from":13,"to":14},{"from":15,"to":16},{"from":17,"to":18}]]}]}""") + + private val gaps = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}},"text":"\n\n"},{"text":"a"},{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":3}},"text":"\n\n\n"},{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"h"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"}],"markdownBlocks":[{"type":"paragraph","content":{"from":1,"to":2}},{"type":"heading","level":1,"content":{"from":4,"to":5}}]}""") + + private val escapes = parsed("""{"formattedText":[{"text":"text "},{"format":{"type":"escape"},"text":"\\"},{"text":"*not"},{"format":{"type":"escape"},"text":"\\"},{"text":"*\nmore"}]}""") + + @Test + fun testDecodeTokens() { + val tokens = (headingAndParagraph.formattedText!! + nestedList.formattedText!! + codeBlockAndRule.formattedText!! + tableAligned.formattedText!! + gaps.formattedText!!) + .mapNotNull { (it.format as? Format.Markup)?.token } + assertEquals(1, tokens.filterIsInstance().first().level) + assertEquals(listOf(null, null, "2."), tokens.filterIsInstance().map { it.number }) + assertEquals(listOf("bash", null), tokens.filterIsInstance().map { it.language }) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), tokens.filterIsInstance().first().columns) + assertEquals(listOf(2, 3), tokens.filterIsInstance().map { it.lineBreaks }) + listOf( + BlockToken.LineBreak::class, BlockToken.SoftBreak::class, BlockToken.HardBreak::class, BlockToken.Indent::class, + BlockToken.RowMark::class, BlockToken.CellMark::class, BlockToken.RuleMark::class + ).forEach { kind -> assert(tokens.any { kind.isInstance(it) }) { "no $kind" } } + assertIs((quote.formattedText!![0].format as Format.Markup).token) + val bold = headingAndParagraph.formattedText!!.mapNotNull { it.format as? Format.Bold } + quote.formattedText!!.mapNotNull { it.format as? Format.Bold } + assertEquals(listOf(true, false), bold.map { it.singleStar }) + assertEquals(listOf("text ", "", "*not", "", "*\nmore"), escapes.formattedText!!.map(::display)) + assertNull(escapes.markdownBlocks) + } + + @Test + fun testDecodeBlocks() { + val heading = assertIs(headingAndParagraph.markdownBlocks!![0]) + assertEquals(1, heading.level) + assertEquals(1 to 3, heading.content.pair) + assertEquals(4 to 9, assertIs(headingAndParagraph.markdownBlocks!![1]).content.pair) + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(null, "2."), list.items.map { it.number }) + assertEquals(7 to 8, list.items[0].items.single().content.pair) + assertEquals(1 to 10, assertIs(quote.markdownBlocks!!.single()).content.pair) + val code = assertIs(codeBlockAndRule.markdownBlocks!![0]) + assertEquals("bash", code.language) + assertEquals(2 to 6, code.content.pair) + assertEquals(9 to 10, assertIs(codeBlockAndRule.markdownBlocks!![1]).mark.pair) + val table = assertIs(tableAligned.markdownBlocks!!.single()) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), table.columns) + assertEquals(listOf(1 to 2, 3 to 4, 5 to 6, 7 to 8), table.header.map { it.pair }) + assertEquals(listOf(listOf(13 to 14, 15 to 16, 17 to 18)), table.rows.map { row -> row.map { it.pair } }) + } + + @Test + fun testBlockTextRanges() { + assertEquals(listOf(1 to 3, 4 to 9), texts(headingAndParagraph).map { it.first }) + assertEquals(listOf(1 to 5, 7 to 8, 10 to 11), texts(nestedList).map { it.first }) + assertEquals(listOf(1 to 10), texts(quote).map { it.first }) + assertEquals(listOf(2 to 6), texts(codeBlockAndRule).map { it.first }) + assertEquals( + listOf(1 to 2 to false, 3 to 4 to true, 5 to 6 to true, 7 to 8 to true, 13 to 14 to false, 15 to 16 to true, 17 to 18 to true), + texts(tableAligned) + ) + } + + @Test + fun testListEntryLevels() { + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(1 to 0, 7 to 1, 10 to 0), listEntries(list.items, 0).map { (entry, level) -> entry.content.from to level }) + } + + @Test + fun testHeadingSegments() { + assertEquals(listOf(1, 2), headingSegments(9, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(4), headingSegments(6, gaps.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(1), headingSegments(2, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + } + + @Test + fun testTokenTexts() { + val tokens = listOf( + BlockToken.SoftBreak(), BlockToken.LineBreak(), BlockToken.HardBreak(), BlockToken.ParagraphBreak(3), BlockToken.Indent(), + BlockToken.HeadingMark(2), BlockToken.ListMark("1."), BlockToken.ListMark(null), BlockToken.QuoteMark(), BlockToken.FenceMark(null), + BlockToken.RowMark(), BlockToken.CellMark(), BlockToken.DelimiterRow(listOf(TableAlign.None)), BlockToken.RuleMark() + ) + assertEquals(listOf(" ", "\n", "\n", "\n\n\n", "", "", "", "", "", "", "", "", "", ""), tokens.map { it.drawnText }) + assertEquals(listOf(" ", "\n", " ", "\n", "", "", "1. ", "• ", "", "", "", " ", "", ""), tokens.map { it.previewText }) + assertEquals("", display(FormattedText("\\", Format.Escape()))) + assertEquals("Title x\nPara a b c", preview(headingAndParagraph)) + assertEquals("• x y\n• z\n2. w", preview(nestedList)) + } + + @Test + fun testItemDisplay() { + val item = ChatItem.getSampleData(text = "> q1\n> q2\n>\n> **q3**").copy(formattedText = quote.formattedText, markdownBlocks = quote.markdownBlocks) + val display = itemDisplay(item, SimplexLinkMode.DESCRIPTION)!! + assertEquals("q1 q2\n\nq3", display.parts.joinToString("") { it.first }) + assertEquals(listOf(0), display.bases) + val table = item.copy(formattedText = tableAligned.formattedText, markdownBlocks = tableAligned.markdownBlocks) + val tableDisplay = itemDisplay(table, SimplexLinkMode.DESCRIPTION)!! + assertEquals("a\tb\tc\td\nxxx\tyyy\tzzz", tableDisplay.parts.joinToString("") { it.first }) + assertEquals(listOf(0, 2, 4, 6, 8, 12, 16), tableDisplay.bases) + val plain = item.copy(formattedText = escapes.formattedText, markdownBlocks = null) + val plainDisplay = itemDisplay(plain, SimplexLinkMode.DESCRIPTION)!! + assertEquals(listOf("text " to "text ", "" to "\\", "*not" to "*not", "" to "\\", "*\nmore" to "*\nmore"), plainDisplay.parts) + assertEquals(listOf(0), plainDisplay.bases) + } + + private val SegmentRange.pair: Pair get() = from to to + + private fun parsed(s: String): ParsedFormattedText = json.decodeFromString(ParsedFormattedText.serializer(), s) + + private fun display(ft: FormattedText): String = segmentDisplayText(ft, null, SimplexLinkMode.DESCRIPTION) + + private fun texts(p: ParsedFormattedText): List, Boolean>> = + blockTextRanges(p.markdownBlocks!!).map { it.range.pair to it.inRow } + + private fun preview(p: ParsedFormattedText): String = + p.formattedText!!.joinToString("") { ft -> (ft.format as? Format.Markup)?.token?.previewText ?: ft.text } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt new file mode 100644 index 0000000000..067d4edfd6 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt @@ -0,0 +1,33 @@ +package chat.simplex.common + +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chatlist.isAppLink +import chat.simplex.common.views.chatlist.isConnectionLink +import java.awt.Desktop + +// A one-time link with its post-quantum key is about 2 KB, near 4 KB with a second such key; +// the bound caps what the app accepts from outside. +internal const val MAX_LINK_BYTES = 8192 + +internal fun isAcceptedLink(uri: String): Boolean = + uri.toByteArray(Charsets.UTF_8).size <= MAX_LINK_BYTES && (isAppLink(uri) || isConnectionLink(uri)) + +fun linkFromArgs(args: Array): String? = + args.singleOrNull()?.takeIf(::isAcceptedLink) + +// The link is stored before showWindow, so a failure to show the window cannot lose it. +internal fun openDesktopLink(uri: String) { + chatModel.appOpenUrl.value = chatModel.remoteHostId() to uri + showWindow() +} + +// macOS delivers a link as an Apple Event, never in args; the JDK queues it until a handler is set +fun installOpenUriHandler() { + if (!Desktop.isDesktopSupported()) return + val desktop = Desktop.getDesktop() + if (!desktop.isSupported(Desktop.Action.APP_OPEN_URI)) return + desktop.setOpenURIHandler { event -> + val uri = event.uri.toString() + if (isAcceptedLink(uri)) openDesktopLink(uri) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 3e48621d03..77dad81aa4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -259,11 +259,16 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState { lockHandle = result.lock singleInstanceLock = true - deleteShowFile() - startShowFileWatcher() + deleteStaleSignalFiles(dataDir.toPath(), lockAttemptTime) return true } LockResult.Failed -> { return true } LockResult.Taken -> { - // Ensure the signal file exists (createShowFile is a no-op if it does) - // and wait up to 1s for the primary's watcher to consume it. If still - // there after the wait, the primary is hung — let the user decide. - createShowFile() - val deadline = System.currentTimeMillis() + 1000 - while (Files.exists(showPath) && System.currentTimeMillis() < deadline) { - try { Thread.sleep(50) } catch (_: InterruptedException) { break } - } - if (!Files.exists(showPath)) return false - val start = showSingleInstanceAlert() - if (start) deleteShowFile() - return start + if (desktopPlatform.isWindows()) allowPrimaryForeground() + return startDespiteRunningInstance(dataDir.toPath(), link, SIGNAL_WAIT_MILLIS, ::showSingleInstanceAlert) } } } @@ -84,20 +90,129 @@ private fun tryAcquireLock(): LockResult { } } -private fun deleteShowFile() { - try { Files.deleteIfExists(showPath) } catch (e: IOException) { - Log.w(TAG, "single-instance: cannot delete show file: ${e.message}") +// A signal still present after the wait means the running instance is hung, so the user decides. +internal fun startDespiteRunningInstance(dir: Path, link: String?, waitMillis: Long, askToStart: () -> Boolean): Boolean { + signalRunningInstance(dir, link) + val show = dir.resolve(SHOW_FILE) + val deadline = System.currentTimeMillis() + waitMillis + while (Files.exists(show) && System.currentTimeMillis() < deadline) { + try { Thread.sleep(SIGNAL_POLL_MILLIS) } catch (_: InterruptedException) { break } + } + if (!Files.exists(show) || !askToStart()) return false + // The running instance can recover while the alert is up; once it has taken the signal, it opens the link itself. + return try { + Files.deleteIfExists(show) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot withdraw signal file: ${e.message}") + true } } -private fun createShowFile() { - try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) { - // Another duplicate already signalled; primary will pick it up. - } catch (e: IOException) { - Log.w(TAG, "single-instance: cannot create show file: ${e.message}") +private fun deleteSignalFile(path: Path) { + try { Files.deleteIfExists(path) } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete signal file: ${e.message}") } } +internal fun deleteStaleSignalFiles(dir: Path, lockAttemptTime: FileTime) { + val staleBefore = FileTime.from(lockAttemptTime.toInstant().minus(FILE_TIME_TOLERANCE)) + try { + // only a running instance takes a signal, so a taken file at startup was left by a crash + Files.deleteIfExists(dir.resolve(TAKEN_SHOW_FILE)) + Files.newDirectoryStream(dir, "$SHOW_FILE*").use { files -> + files.forEach { file -> + try { + if (Files.getLastModifiedTime(file) < staleBefore) Files.deleteIfExists(file) + } catch (_: NoSuchFileException) { + // a second process renamed its temp file meanwhile + } + } + } + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete stale signal files: ${e.message}") + } catch (e: DirectoryIteratorException) { + Log.w(TAG, "single-instance: cannot delete stale signal files: ${e.cause?.message}") + } +} + +// The temp file is owner-only on POSIX, as the link is a bearer secret, and the rename makes it appear whole. +internal fun signalRunningInstance(dir: Path, link: String?) { + var tmp: Path? = null + try { + tmp = Files.createTempFile(dir, SHOW_FILE, SHOW_TMP_SUFFIX) + Files.write(tmp, (link ?: "").toByteArray(Charsets.UTF_8)) + Files.move(tmp, dir.resolve(SHOW_FILE), ATOMIC_MOVE) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot signal running instance: ${e.message}") + tmp?.let(::deleteSignalFile) + // an empty signal needs no free space and still brings the running instance forward + try { + Files.createFile(dir.resolve(SHOW_FILE)) + } catch (_: FileAlreadyExistsException) { + // another process has already signalled + } catch (createError: IOException) { + Log.w(TAG, "single-instance: cannot create signal file: ${createError.message}") + } + } +} + +// The signal is renamed before it is read, so a newer signal renamed over it meanwhile is not deleted unread. +internal fun takeSignal(dir: Path, move: (Path, Path) -> Unit = ::moveAtomically): ShowSignal? { + val taken = dir.resolve(TAKEN_SHOW_FILE) + try { + moveWithRetry(dir.resolve(SHOW_FILE), taken, move) + } catch (_: NoSuchFileException) { + return null + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot take signal file: ${e.message}") + return ShowSignal(null) + } + val bytes = try { + Files.newInputStream(taken).use { it.readNBytes(MAX_LINK_BYTES + 1) } + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot read signal file: ${e.message}") + null + } finally { + deleteSignalFile(taken) + } + return ShowSignal(bytes?.toString(Charsets.UTF_8)?.takeIf(::isAcceptedLink)) +} + +private fun moveAtomically(from: Path, to: Path) { + Files.move(from, to, ATOMIC_MOVE) +} + +// Another program, such as an antivirus scanner on Windows, can hold the file open for a moment. +private fun moveWithRetry(from: Path, to: Path, move: (Path, Path) -> Unit) { + try { + move(from, to) + } catch (e: NoSuchFileException) { + throw e + } catch (e: IOException) { + try { + Thread.sleep(SIGNAL_RETRY_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } + move(from, to) + } +} + +// Win32 ASFW_ANY lets every process take the foreground +private const val ASFW_ANY = -1 + +// Windows lets only the foreground process raise a window; the launching browser passed that right to this process. +private fun allowPrimaryForeground() { + val allowed = try { + NativeLibrary.getInstance("user32").getFunction("AllowSetForegroundWindow").invokeInt(arrayOf(ASFW_ANY)) != 0 + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "single-instance: AllowSetForegroundWindow unavailable: ${e.message}") + return + } + if (!allowed) Log.w(TAG, "single-instance: AllowSetForegroundWindow refused") +} + private fun showSingleInstanceAlert(): Boolean { val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title) val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding) @@ -109,26 +224,34 @@ private fun showSingleInstanceAlert(): Boolean { return result == javax.swing.JOptionPane.YES_OPTION } -private fun startShowFileWatcher() { +fun startShowFileWatcher() { if (watcher != null) return + val dir = dataDir.toPath() val ws = try { - dataDir.toPath().fileSystem.newWatchService() + dir.fileSystem.newWatchService() } catch (e: IOException) { Log.w(TAG, "single-instance: WatchService failed: ${e.message}") return } - dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE) + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) watcher = ws thread(name = "simplex-single-instance", isDaemon = true) { - while (true) { - val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread } - for (event in key.pollEvents()) { - if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") { - deleteShowFile() - SwingUtilities.invokeLater { showWindow() } - } + watchShowSignals(ws, dir) { signal -> + SwingUtilities.invokeLater { + if (signal.link != null) openDesktopLink(signal.link) else showWindow() } - if (!key.reset()) return@thread } } } + +// dir must already be registered with ws; a signal written before that raised no event, so it is taken first. +internal fun watchShowSignals(ws: WatchService, dir: Path, onSignal: (ShowSignal) -> Unit) { + takeSignal(dir)?.let(onSignal) + while (true) { + val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return } catch (_: InterruptedException) { return } + for (event in key.pollEvents()) { + if ((event.context() as? Path)?.fileName?.toString() == SHOW_FILE) takeSignal(dir)?.let(onSignal) + } + if (!key.reset()) return + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt new file mode 100644 index 0000000000..0859a75225 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt @@ -0,0 +1,63 @@ +package chat.simplex.common + +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.platform.unix.X11 +import java.awt.Toolkit +import java.awt.Window + +// EWMH source indication of a pager, which window managers exempt from focus stealing prevention +private const val NET_ACTIVE_WINDOW_SOURCE_PAGER = 2L +private const val CLIENT_MESSAGE_FORMAT_BITS = 32 + +// AWT's toFront() sends only XRaiseWindow, which wlroots compositors (Sway) drop and focus stealing +// prevention refuses; _NET_ACTIVE_WINDOW is the request X11 and XWayland window managers act on. +internal fun requestX11Activation(window: Window) { + val x11: X11 + val windowId: Long + try { + x11 = X11.INSTANCE + windowId = Native.getWindowID(window) + } catch (e: LinkageError) { + Log.w(TAG, "window activation: native library unavailable: ${e.message}") + return + } catch (e: IllegalStateException) { + Log.w(TAG, "window activation: no native window: ${e.message}") + return + } + // The map request goes out on AWT's connection; flushing it first lets the window manager map the window before activating it. + Toolkit.getDefaultToolkit().sync() + sendActivation(x11, windowId) +} + +internal fun sendActivation(x11: X11, windowId: Long) { + val display = x11.XOpenDisplay(null) + if (display == null) { + Log.w(TAG, "window activation: cannot open the X display") + return + } + try { + val event = activationEvent(X11.Window(windowId), x11.XInternAtom(display, "_NET_ACTIVE_WINDOW", false)) + val mask = NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()) + x11.XSendEvent(display, x11.XDefaultRootWindow(display), 0, mask, event) + x11.XFlush(display) + } finally { + x11.XCloseDisplay(display) + } +} + +internal fun activationEvent(window: X11.Window, activeWindowAtom: X11.Atom): X11.XEvent { + val event = X11.XEvent() + event.setType(X11.XClientMessageEvent::class.java) + event.xclient.type = X11.ClientMessage + event.xclient.window = window + event.xclient.message_type = activeWindowAtom + event.xclient.format = CLIENT_MESSAGE_FORMAT_BITS + event.xclient.data.setType(Array::class.java) + event.xclient.data.l[0] = NativeLong(NET_ACTIVE_WINDOW_SOURCE_PAGER) + // the request carries no user interaction timestamp + event.xclient.data.l[1] = NativeLong(X11.CurrentTime.toLong()) + return event +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt new file mode 100644 index 0000000000..1187316959 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt @@ -0,0 +1,368 @@ +package chat.simplex.common.platform + +import chat.simplex.common.views.chatlist.appLinkScheme +import chat.simplex.common.views.chatlist.connectionLinkScheme +import com.sun.jna.NativeLibrary +import com.sun.jna.WString +import com.sun.jna.platform.win32.Advapi32Util +import com.sun.jna.platform.win32.Win32Exception +import com.sun.jna.platform.win32.WinReg.HKEY +import com.sun.jna.platform.win32.WinReg.HKEY_CURRENT_USER +import com.sun.jna.platform.win32.WinReg.HKEY_LOCAL_MACHINE +import com.sun.jna.ptr.IntByReference +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +internal const val APP_LINK_MIME_TYPE = "x-scheme-handler/$appLinkScheme" +internal const val CONNECTION_LINK_MIME_TYPE = "x-scheme-handler/$connectionLinkScheme" +private const val WINDOWS_CLASSES_KEY = "Software\\Classes" +private const val WINDOWS_PROG_ID_PREFIX = "SimpleX." +private const val WINDOWS_CAPABILITIES_KEY = "Software\\chat.simplex.app\\Capabilities" +private const val WINDOWS_REGISTERED_APPLICATIONS_KEY = "Software\\RegisteredApplications" +private const val APP_DESCRIPTION = "Private and secure open-source messenger - no user IDs (not even random numbers)" +private const val WINDOWS_FIRST_ICON_INDEX = 0 +// values from shlwapi.h and shlobj_core.h +private const val ASSOCF_IS_PROTOCOL = 0x1000 +private const val ASSOCSTR_EXECUTABLE = 2 +private const val S_OK = 0 +private const val S_FALSE = 1 +private const val SHCNE_ASSOCCHANGED = 0x08000000 +private const val SHCNF_IDLIST = 0 +// the Win32 name of a key's default value +private const val REGISTRY_DEFAULT_VALUE = "" +internal const val APPIMAGE_ENTRY_NAME = "chat.simplex.app-links.desktop" +private const val APPLICATIONS_DIR = "applications" +// the XDG Base Directory default when XDG_DATA_DIRS is unset +private const val DEFAULT_XDG_DATA_DIRS = "/usr/local/share:/usr/share" +private const val APP_DISPLAY_NAME = "SimpleX Chat" +private const val XDG_MIME = "xdg-mime" +// jpackage names the deb's desktop entry -.desktop +private const val DEB_ENTRY_NAME = "simplex-simplex.desktop" +private const val PROCESS_TIMEOUT_SECONDS = 5L +private const val ROOT_USER = "root" +// the characters the desktop entry spec requires an Exec argument to quote +private const val EXEC_RESERVED_CHARACTERS = " \t\n\"'\\><~|&;$*?#()`" + +internal sealed interface DesktopInstallation { + data object MacBundle : DesktopInstallation + data class WindowsExe(val path: String) : DesktopInstallation + data object Flatpak : DesktopInstallation + data class AppImage(val path: String) : DesktopInstallation + data object LinuxPackage : DesktopInstallation + data object Unpackaged : DesktopInstallation +} + +// Only simplexchat: decides how the badge page ends, so simplex: is registered without being checked here. +@Volatile +private var registered = false + +actual fun appLinkSchemeRegistered(): Boolean = registered + +fun registerLinkSchemes() { + thread(name = "simplex-link-schemes", isDaemon = true) { + registered = registerForThisInstallation() + } +} + +// appPath is jpackage.app-path, set by jpackage's launcher, so only a packaged app has one. +internal fun desktopInstallation(platform: DesktopPlatform, appPath: String?, env: Map): DesktopInstallation { + val appImage = env["APPIMAGE"] + val appDir = env["APPDIR"] + return when { + appPath == null -> DesktopInstallation.Unpackaged + platform.isMac() -> DesktopInstallation.MacBundle + platform.isWindows() -> DesktopInstallation.WindowsExe(appPath) + env["container"] == "flatpak" -> DesktopInstallation.Flatpak + // The AppImage runtime exports APPIMAGE to every child process, so it describes this app only + // when the launcher runs from the AppImage's own mount. + appImage != null && appDir != null && Path.of(appPath).startsWith(realPathOrSelf(appDir)) -> DesktopInstallation.AppImage(appImage) + else -> DesktopInstallation.LinuxPackage + } +} + +// jpackage.app-path is resolved through /proc/self/exe, while APPDIR keeps any symlink in TMPDIR. +private fun realPathOrSelf(path: String): Path = + try { + Path.of(path).toRealPath() + } catch (_: IOException) { + Path.of(path) + } + +private fun registerForThisInstallation(): Boolean = + when (val installation = desktopInstallation(desktopPlatform, System.getProperty("jpackage.app-path"), System.getenv())) { + // The bundle's Info.plist and the Flatpak's exported desktop entry register the schemes. + DesktopInstallation.MacBundle, DesktopInstallation.Flatpak -> true + is DesktopInstallation.WindowsExe -> registerWindowsSchemes(installation.path, SystemWindowsShell) + is DesktopInstallation.AppImage -> + registerAppImageScheme(installation.path, File(unixDataHome, APPLICATIONS_DIR), systemApplicationsDirs(), ::runProcess) + DesktopInstallation.LinuxPackage -> linuxPackageRegistered(::runProcess) + DesktopInstallation.Unpackaged -> false + } + +internal fun windowsOpenCommand(appPath: String): String = "\"$appPath\" \"%1\"" + +internal fun windowsCommandProgram(command: String): String = + if (command.startsWith("\"")) command.substring(1).substringBefore('"') else command.substringBefore(' ') + +internal data class RegistryValue(val key: String, val name: String, val value: String) + +private fun windowsSchemeKey(scheme: String): String = "$WINDOWS_CLASSES_KEY\\$scheme" + +private fun windowsCommandKey(scheme: String): String = "${windowsSchemeKey(scheme)}\\shell\\open\\command" + +private fun windowsProgId(scheme: String): String = "$WINDOWS_PROG_ID_PREFIX$scheme" + +private fun windowsProgIdKey(scheme: String): String = "$WINDOWS_CLASSES_KEY\\${windowsProgId(scheme)}" + +private fun windowsProgIdCommandKey(scheme: String): String = "${windowsProgIdKey(scheme)}\\shell\\open\\command" + +private fun windowsIcon(appPath: String): String = "\"$appPath\",$WINDOWS_FIRST_ICON_INDEX" + +// The scheme key declares the URL scheme, but its command opens links only while no app registered through +// Capabilities claims the scheme: Windows settings offer only the ProgIds that Capabilities name. +internal fun windowsSchemeValues(scheme: String, appPath: String): List { + val schemeKey = windowsSchemeKey(scheme) + return listOf( + RegistryValue(key = schemeKey, name = REGISTRY_DEFAULT_VALUE, value = "URL:$APP_DISPLAY_NAME"), + // without this value Windows does not treat the key as a URL scheme + RegistryValue(key = schemeKey, name = "URL Protocol", value = ""), + RegistryValue(key = "$schemeKey\\DefaultIcon", name = REGISTRY_DEFAULT_VALUE, value = windowsIcon(appPath)), + RegistryValue(key = windowsCommandKey(scheme), name = REGISTRY_DEFAULT_VALUE, value = windowsOpenCommand(appPath)), + RegistryValue(key = "$WINDOWS_CAPABILITIES_KEY\\URLAssociations", name = scheme, value = windowsProgId(scheme)), + ) +} + +// The ProgId is what links resolve to once the user picks SimpleX in settings, so it is repaired even while +// another program holds the scheme key, but another installed copy's ProgId is kept like any other handler. +internal fun windowsProgIdValues(scheme: String, appPath: String): List { + val progIdKey = windowsProgIdKey(scheme) + return listOf( + RegistryValue(key = progIdKey, name = REGISTRY_DEFAULT_VALUE, value = "URL:$APP_DISPLAY_NAME"), + RegistryValue(key = "$progIdKey\\DefaultIcon", name = REGISTRY_DEFAULT_VALUE, value = windowsIcon(appPath)), + RegistryValue(key = windowsProgIdCommandKey(scheme), name = REGISTRY_DEFAULT_VALUE, value = windowsOpenCommand(appPath)), + ) +} + +// The application is listed last, so Windows never lists it before its capabilities are complete. +internal val windowsApplicationValues: List = listOf( + RegistryValue(key = WINDOWS_CAPABILITIES_KEY, name = "ApplicationName", value = APP_DISPLAY_NAME), + RegistryValue(key = WINDOWS_CAPABILITIES_KEY, name = "ApplicationDescription", value = APP_DESCRIPTION), + RegistryValue(key = WINDOWS_REGISTERED_APPLICATIONS_KEY, name = APP_DISPLAY_NAME, value = WINDOWS_CAPABILITIES_KEY), +) + +internal interface WindowsShell { + fun readUser(key: String, name: String): String? + fun readMachine(key: String, name: String): String? + fun writeUser(value: RegistryValue) + // the program the shell starts for a link, after any choice made in Windows settings + fun linkHandler(scheme: String): String? + fun notifyAssociationsChanged() +} + +internal fun registerWindowsSchemes(appPath: String, shell: WindowsShell): Boolean { + var changed = false + val tracked = object : WindowsShell by shell { + override fun writeUser(value: RegistryValue) { + shell.writeUser(value) + changed = true + } + } + val associated = listOf(appLinkScheme, connectionLinkScheme).filter { registerWindowsScheme(it, appPath, tracked) } + if (associated.isNotEmpty()) writeMissing(windowsApplicationValues, tracked, "the application") + // The notification also clears Explorer's icon cache, so it is sent only after a change. + if (changed) shell.notifyAssociationsChanged() + return shell.linkHandler(appLinkScheme)?.let { isThisProgram(it, appPath) } == true +} + +private fun registerWindowsScheme(scheme: String, appPath: String, shell: WindowsShell): Boolean = + !heldByAnotherProgram(windowsProgIdCommandKey(scheme), appPath, shell) && + writeMissing(windowsProgIdValues(scheme, appPath), shell, "the $scheme: ProgId") && + !heldByAnotherProgram(windowsCommandKey(scheme), appPath, shell) && + writeMissing(windowsSchemeValues(scheme, appPath), shell, "the $scheme: scheme") + +private fun isThisProgram(program: String, appPath: String): Boolean = + File(program).absolutePath.equals(File(appPath).absolutePath, ignoreCase = true) + +// A per-user key hides the machine-wide one, so the machine-wide command counts only without it. +private fun heldByAnotherProgram(commandKey: String, appPath: String, shell: WindowsShell): Boolean = + try { + val current = shell.readUser(commandKey, REGISTRY_DEFAULT_VALUE) ?: shell.readMachine(commandKey, REGISTRY_DEFAULT_VALUE) + val program = current?.let(::windowsCommandProgram) + program != null && !isThisProgram(program, appPath) && File(program).isFile + } catch (e: Win32Exception) { + Log.w(TAG, "link scheme: cannot read the handler under $commandKey: ${e.message}") + true + } + +private fun writeMissing(values: List, shell: WindowsShell, what: String): Boolean { + fun stored() = values.all { shell.readUser(it.key, it.name) == it.value } + return try { + if (!stored()) values.forEach(shell::writeUser) + stored() + } catch (e: Win32Exception) { + Log.w(TAG, "link scheme: cannot register $what in the registry: ${e.message}") + false + } +} + +private object SystemWindowsShell : WindowsShell { + override fun readUser(key: String, name: String): String? = registryString(HKEY_CURRENT_USER, key, name) + + override fun readMachine(key: String, name: String): String? = registryString(HKEY_LOCAL_MACHINE, key, name) + + override fun writeUser(value: RegistryValue) { + Advapi32Util.registryCreateKey(HKEY_CURRENT_USER, value.key) + Advapi32Util.registrySetStringValue(HKEY_CURRENT_USER, value.key, value.name, value.value) + } + + // jna-platform 5.14 binds neither AssocQueryStringW nor SHChangeNotify. + override fun linkHandler(scheme: String): String? = + try { + val assocQueryString = NativeLibrary.getInstance("shlwapi").getFunction("AssocQueryStringW") + val length = IntByReference(0) + // with no buffer the call reports the length it needs + val sized = assocQueryString.invokeInt(arrayOf(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, WString(scheme), null, null, length)) + if (sized != S_FALSE) { + null + } else { + val out = CharArray(length.value) + val result = assocQueryString.invokeInt(arrayOf(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, WString(scheme), null, out, length)) + if (result == S_OK) String(out).substringBefore('\u0000') else null + } + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "link scheme: cannot query the $scheme: handler: ${e.message}") + null + } + + override fun notifyAssociationsChanged() { + try { + NativeLibrary.getInstance("shell32").getFunction("SHChangeNotify").invokeVoid(arrayOf(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, null, null)) + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "link scheme: cannot announce changed associations: ${e.message}") + } + } +} + +// registryGetStringValue throws a RuntimeException for a value of another type, which another program could write. +private fun registryString(hive: HKEY, key: String, name: String): String? = + if (Advapi32Util.registryValueExists(hive, key, name)) { + Advapi32Util.registryGetValue(hive, key, name) as? String + } else { + null + } + +// Per the desktop entry spec, quoting escapes " ` $ \ and the string escape then doubles every backslash. +// xdg-open 1.1.3 cannot run a quoted program, so quotes are added only when required. +internal fun desktopExecArgument(arg: String): String { + if (arg.none { it in EXEC_RESERVED_CHARACTERS }) return arg + val quoted = arg.replace("\\", "\\\\").replace("\"", "\\\"").replace("`", "\\`").replace("$", "\\$") + return "\"" + quoted.replace("\\", "\\\\") + "\"" +} + +internal fun appImageDesktopEntry(appImagePath: String): String? { + // A line break would end the Exec value and start a key of the path's choosing. + // GLib looks the program up before it expands %%, so no escaping lets it find a path containing %. + if (appImagePath.any { it == '\n' || it == '\r' || it == '%' }) return null + return """ + |[Desktop Entry] + |Type=Application + |Name=$APP_DISPLAY_NAME + |NoDisplay=true + |Exec=${desktopExecArgument(appImagePath)} %u + |MimeType=$APP_LINK_MIME_TYPE;$CONNECTION_LINK_MIME_TYPE; + |""".trimMargin() +} + +// The entry runs this file for every link, so neither it nor any directory above it may be replaceable by another user, +// including the user's own directory inside /tmp, which can be cleaned away and recreated by anyone. +internal fun onlyOwnerCanReplace(path: Path, user: String): Boolean = + try { + generateSequence(path) { it.parent }.all { onlyOwnerCanWrite(Files.readAttributes(it, PosixFileAttributes::class.java), user) } + } catch (_: IOException) { + false + } + +// A group named after the user is a user private group, which umask 002 systems make group-writable. +internal fun onlyOwnerCanWrite(attributes: PosixFileAttributes, user: String): Boolean { + val permissions = attributes.permissions() + return attributes.owner().name in setOf(user, ROOT_USER) && + PosixFilePermission.OTHERS_WRITE !in permissions && + (PosixFilePermission.GROUP_WRITE !in permissions || attributes.group().name == user) +} + +internal fun registerAppImageScheme(appImagePath: String, applicationsDir: File, systemApplicationsDirs: List, run: (List) -> String?): Boolean { + if (!onlyOwnerCanReplace(Path.of(appImagePath), System.getProperty("user.name"))) { + Log.w(TAG, "link scheme: not registered, as other users may replace $appImagePath") + return false + } + val content = appImageDesktopEntry(appImagePath) ?: return false + val entry = File(applicationsDir, APPIMAGE_ENTRY_NAME) + try { + if (!entry.isFile || entry.readText() != content) { + applicationsDir.mkdirs() + entry.writeText(content) + run(listOf("update-desktop-database", applicationsDir.path)) + } + } catch (e: IOException) { + Log.w(TAG, "link scheme: cannot write desktop entry: ${e.message}") + return false + } + val entryDirs = listOf(applicationsDir) + systemApplicationsDirs + val registered = makeAppImageEntryDefault(APP_LINK_MIME_TYPE, entryDirs, run) + makeAppImageEntryDefault(CONNECTION_LINK_MIME_TYPE, entryDirs, run) + return registered +} + +private fun makeAppImageEntryDefault(mimeType: String, entryDirs: List, run: (List) -> String?): Boolean { + val current = xdgDefaultSchemeHandler(mimeType, run) + if (current == APPIMAGE_ENTRY_NAME) return true + // another installed handler keeps the default; only a missing one is replaced + if (current != null && entryDirs.any { File(it, current).isFile }) return false + run(listOf(XDG_MIME, "default", APPIMAGE_ENTRY_NAME, mimeType)) + return xdgDefaultSchemeHandler(mimeType, run) == APPIMAGE_ENTRY_NAME +} + +private fun xdgDefaultSchemeHandler(mimeType: String, run: (List) -> String?): String? = + run(listOf(XDG_MIME, "query", "default", mimeType))?.trim()?.takeIf { it.isNotEmpty() } + +private fun systemApplicationsDirs(): List = + (System.getenv("XDG_DATA_DIRS")?.takeIf { it.isNotEmpty() } ?: DEFAULT_XDG_DATA_DIRS).split(File.pathSeparator).map { File(it, APPLICATIONS_DIR) } + +internal fun linuxPackageRegistered(run: (List) -> String?): Boolean = + xdgDefaultSchemeHandler(APP_LINK_MIME_TYPE, run) == DEB_ENTRY_NAME + +internal fun runProcess(command: List): String? { + val process = try { + ProcessBuilder(command).redirectError(ProcessBuilder.Redirect.DISCARD).start() + } catch (e: IOException) { + Log.w(TAG, "link scheme: cannot run ${command.first()}: ${e.message}") + return null + } + return try { + process.outputStream.close() + if (!process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + Log.w(TAG, "link scheme: ${command.first()} timed out") + null + } else if (process.exitValue() == 0) { + process.inputStream.bufferedReader().readText() + } else { + null + } + } catch (e: IOException) { + Log.w(TAG, "link scheme: ${command.first()} failed: ${e.message}") + null + } catch (_: InterruptedException) { + process.destroyForcibly() + Thread.currentThread().interrupt() + null + } finally { + process.inputStream.close() + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index 7ea41d3593..8dd4ba96b6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -5,8 +5,10 @@ import java.util.* private val home = System.getProperty("user.home") private val unixConfigPath = (System.getenv("XDG_CONFIG_HOME") ?: "$home/.config") + "/simplex" -private val unixDataPath = (System.getenv("XDG_DATA_HOME") ?: "$home/.local/share") + "/simplex" -val desktopPlatform = detectDesktopPlatform() +internal val unixDataHome = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share" +private val unixDataPath = "$unixDataHome/simplex" +// DesktopPlatform's constants read this file's paths, so an eager value is null when the enum initializes first. +val desktopPlatform by lazy { detectDesktopPlatform() } enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String, val githubAssetName: String) { LINUX_X86_64("so", unixConfigPath, unixDataPath, "simplex-desktop-x86_64.AppImage"), diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt new file mode 100644 index 0000000000..0723c12ec3 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt @@ -0,0 +1,14 @@ +package chat.simplex.app + +internal const val BADGE_LINK_PREFIX = "simplexchat:/badge/code/" +internal const val BADGE_LINK = "${BADGE_LINK_PREFIX}SB0000000000000000000" +internal const val OTHER_BADGE_LINK = "${BADGE_LINK_PREFIX}SB1111111111111111111" + +internal fun badgeLinkOfBytes(size: Int): String = BADGE_LINK_PREFIX + "A".repeat(size - BADGE_LINK_PREFIX.length) + +internal const val SHORT_CONNECTION_LINK = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" +private const val ONE_TIME_LINK_PREFIX = "simplex:/invitation#/?v=2-7&smp=smp%3A%2F%2Fexample&e2e=v%3D2-3%26kem_key%3D" +// an sntrup761 public key is 1158 bytes, 1544 characters in base64url +private const val KEM_KEY_CHARACTERS = 1544 + +internal fun oneTimeLinkWithKemKey(): String = ONE_TIME_LINK_PREFIX + "A".repeat(KEM_KEY_CHARACTERS) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt new file mode 100644 index 0000000000..b86ead9733 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt @@ -0,0 +1,680 @@ +package chat.simplex.app + +import chat.simplex.common.platform.APPIMAGE_ENTRY_NAME +import chat.simplex.common.platform.DesktopInstallation +import chat.simplex.common.platform.DesktopPlatform +import chat.simplex.common.platform.RegistryValue +import chat.simplex.common.platform.APP_LINK_MIME_TYPE +import chat.simplex.common.platform.CONNECTION_LINK_MIME_TYPE +import chat.simplex.common.platform.appImageDesktopEntry +import chat.simplex.common.platform.desktopExecArgument +import chat.simplex.common.platform.desktopInstallation +import chat.simplex.common.platform.linuxPackageRegistered +import chat.simplex.common.platform.onlyOwnerCanReplace +import chat.simplex.common.platform.onlyOwnerCanWrite +import chat.simplex.common.platform.registerAppImageScheme +import chat.simplex.common.platform.registerWindowsSchemes +import chat.simplex.common.platform.runProcess +import chat.simplex.common.platform.windowsCommandProgram +import chat.simplex.common.platform.windowsApplicationValues +import chat.simplex.common.platform.windowsOpenCommand +import chat.simplex.common.platform.WindowsShell +import chat.simplex.common.platform.windowsProgIdValues +import chat.simplex.common.platform.windowsSchemeValues +import com.sun.jna.platform.win32.Win32Exception +import com.sun.jna.platform.win32.WinError +import com.sun.jna.platform.win32.WinNT +import org.junit.Assume.assumeNoException +import org.junit.Assume.assumeTrue +import java.io.File +import java.lang.reflect.Proxy +import java.nio.file.FileSystemException +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.GroupPrincipal +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.attribute.UserPrincipal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private const val OWNER_WRITABLE = "rwxr-xr-x" +private const val GROUP_WRITABLE = "rwxrwxr-x" +private const val OTHERS_WRITABLE = "rwxr-xrwx" +private const val WORLD_WRITABLE = "rwxrwxrwx" +private const val APPIMAGE_FILE_NAME = "SimpleX.AppImage" +private const val XDG_MIME = "xdg-mime" + +class AppLinkSchemeTest { + private val windowsExe = "C:\\Program Files\\SimpleX\\SimpleX.exe" + private val appLinkCommandKey = "Software\\Classes\\simplexchat\\shell\\open\\command" + private val connectionCommandKey = "Software\\Classes\\simplex\\shell\\open\\command" + private val appLinkCommand = appLinkCommandKey to "" + private val connectionCommand = connectionCommandKey to "" + private val olderWindowsCommand = "\"C:\\Old\\SimpleX.exe\" \"%1\"" + private val appImagePath = "/home/user/$APPIMAGE_FILE_NAME" + + @Test + fun windowsCommandQuotesTheAppAndTheLink() { + assertEquals( + "\"C:\\Program Files\\SimpleX\\SimpleX.exe\" \"%1\"", + windowsOpenCommand(windowsExe) + ) + } + + @Test + fun windowsSchemeIsAUrlProtocolNamingItsProgId() { + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\simplexchat", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\simplexchat", name = "URL Protocol", value = ""), + RegistryValue(key = "Software\\Classes\\simplexchat\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = appLinkCommandKey, name = "", value = "\"$windowsExe\" \"%1\""), + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities\\URLAssociations", name = "simplexchat", value = "SimpleX.simplexchat"), + ), + windowsSchemeValues("simplexchat", windowsExe), + "app link scheme" + ) + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\simplex", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\simplex", name = "URL Protocol", value = ""), + RegistryValue(key = "Software\\Classes\\simplex\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = connectionCommandKey, name = "", value = "\"$windowsExe\" \"%1\""), + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities\\URLAssociations", name = "simplex", value = "SimpleX.simplex"), + ), + windowsSchemeValues("simplex", windowsExe), + "connection link scheme" + ) + } + + @Test + fun windowsProgIdOpensTheExe() { + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat\\shell\\open\\command", name = "", value = "\"$windowsExe\" \"%1\""), + ), + windowsProgIdValues("simplexchat", windowsExe) + ) + } + + @Test + fun windowsApplicationIsListedInDefaultAppsAfterItsCapabilities() { + assertEquals( + listOf( + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities", name = "ApplicationName", value = "SimpleX Chat"), + RegistryValue( + key = "Software\\chat.simplex.app\\Capabilities", + name = "ApplicationDescription", + value = "Private and secure open-source messenger - no user IDs (not even random numbers)" + ), + RegistryValue(key = "Software\\RegisteredApplications", name = "SimpleX Chat", value = "Software\\chat.simplex.app\\Capabilities"), + ), + windowsApplicationValues + ) + } + + @Test + fun windowsRegistrationWritesEveryValueWhenTheCommandsPointAtAMissingProgram() { + val shell = FakeWindowsShell(user = mutableMapOf(appLinkCommand to olderWindowsCommand, connectionCommand to olderWindowsCommand)) + registerWindowsSchemes(windowsExe, shell) + assertEquals(fullRegistration(), shell.writes) + assertTrue(shell.notified, "Windows is told that associations changed") + } + + @Test + fun windowsCommandProgramIsTheQuotedOrFirstArgument() { + assertEquals("C:\\Program Files\\Other\\Other.exe", windowsCommandProgram("\"C:\\Program Files\\Other\\Other.exe\" \"%1\""), "quoted") + assertEquals("C:\\Other\\Other.exe", windowsCommandProgram("C:\\Other\\Other.exe %1"), "unquoted") + assertEquals("C:\\Other\\Other.exe", windowsCommandProgram("C:\\Other\\Other.exe"), "no arguments") + } + + @Test + fun windowsRegistrationLeavesAFullRegistrationAlone() { + val shell = FakeWindowsShell(user = registered()) + registerWindowsSchemes(windowsExe, shell) + assertEquals(listOf(), shell.writes, "nothing is written again") + assertFalse(shell.notified, "an unchanged registration does not clear Explorer's caches") + } + + @Test + fun windowsRegistrationRepairsAValueMissingBesideAMatchingCommand() { + val shell = FakeWindowsShell(user = registered().apply { remove("Software\\Classes\\simplexchat" to "URL Protocol") }) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsSchemeValues("simplexchat", windowsExe), shell.writes, "only the incomplete scheme is rewritten") + } + + @Test + fun windowsRegistrationAnnouncesARepairedApplicationListing() { + val shell = FakeWindowsShell(user = registered().apply { remove("Software\\RegisteredApplications" to "SimpleX Chat") }) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsApplicationValues, shell.writes, "only the application listing is rewritten") + assertTrue(shell.notified, "Windows is told that associations changed") + } + + @Test + fun windowsRegistrationKeepsAnotherInstalledHandler() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val shell = FakeWindowsShell(user = mutableMapOf(appLinkCommand to otherCommand, connectionCommand to otherCommand)) + registerWindowsSchemes(windowsExe, shell) + assertEquals( + windowsProgIdValues("simplexchat", windowsExe) + windowsProgIdValues("simplex", windowsExe), + shell.writes, + "only this app's own ProgIds are written; no scheme is taken and no application is listed without one" + ) + } + + @Test + fun windowsRegistrationKeepsAMachineWideHandler() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val shell = FakeWindowsShell(machine = mutableMapOf(appLinkCommand to otherCommand)) + registerWindowsSchemes(windowsExe, shell) + assertTrue(shell.writes.none { it.key == appLinkCommandKey }, "a per-user key would hide the machine-wide handler") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the free scheme is taken") + } + + @Test + fun windowsRegistrationRepairsItsProgIdWhileAnotherProgramHoldsTheScheme() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val staleProgId = windowsProgIdValues("simplexchat", "C:\\Old\\SimpleX.exe").associate { (it.key to it.name) to it.value } + val shell = FakeWindowsShell(user = (staleProgId + (appLinkCommand to otherCommand)).toMutableMap()) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsProgIdValues("simplexchat", windowsExe), shell.writes.take(3), "the ProgId points at this exe again") + } + + @Test + fun windowsRegistrationReportsTheProgramTheShellStarts() { + assertFalse(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = "C:\\Other\\Other.exe")), "another app picked in settings") + assertTrue(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = windowsExe.uppercase())), "paths differ only in case") + assertFalse(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = null)), "the shell has no answer") + } + + @Test + fun windowsRegistrationContinuesPastARegistryError() { + val shell = FakeWindowsShell(failingWrites = setOf("Software\\Classes\\simplexchat")) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "the app link scheme could not be written") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the connection link scheme is still written") + assertTrue(windowsApplicationValues.all { it in shell.writes }, "the application is listed for the scheme that is ours") + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseHandlerCannotBeRead() { + val shell = FakeWindowsShell(failingReads = setOf(appLinkCommandKey, connectionCommandKey)) + registerWindowsSchemes(windowsExe, shell) + assertEquals( + windowsProgIdValues("simplexchat", windowsExe) + windowsProgIdValues("simplex", windowsExe), + shell.writes, + "an unreadable handler is left alone, and no application is listed without a scheme" + ) + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseMachineWideHandlerCannotBeRead() { + val shell = FakeWindowsShell(failingMachineReads = setOf(appLinkCommandKey)) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "the app link scheme is left alone") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the readable scheme is taken") + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseProgIdCannotBeWritten() { + val shell = FakeWindowsShell(failingWrites = setOf("Software\\Classes\\SimpleX.simplexchat")) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "URLAssociations must not name a missing ProgId") + } + + @Test + fun windowsRegistrationKeepsAnotherInstalledCopysProgId() = withTempDir { dir -> + val otherCopy = Files.createFile(dir.resolve("SimpleX.exe")).toString() + val otherProgId = windowsProgIdValues("simplexchat", otherCopy).associate { (it.key to it.name) to it.value } + val shell = FakeWindowsShell(user = otherProgId.toMutableMap()) + registerWindowsSchemes(windowsExe, shell) + assertTrue(shell.writes.none { it.key.contains("simplexchat") }, "links picked for the other copy keep reaching it: neither its ProgId nor the scheme is taken") + } + + @Test + fun windowsRegistrationTreatsItsOwnProgramAsItsOwnHandler() = withTempDir { dir -> + val exe = Files.createFile(dir.resolve("SimpleX.exe")).toString() + val otherExe = Files.createFile(dir.resolve("Other.exe")).toString() + val shell = FakeWindowsShell( + user = mutableMapOf(appLinkCommand to "\"$exe\" %1"), + machine = mutableMapOf(appLinkCommand to "\"$otherExe\" \"%1\""), + ) + registerWindowsSchemes(exe, shell) + assertTrue(windowsSchemeValues("simplexchat", exe).all { it in shell.writes }, "a command for this exe is updated, and the hidden machine-wide one is ignored") + } + + private fun fullRegistration(): List = + windowsProgIdValues("simplexchat", windowsExe) + windowsSchemeValues("simplexchat", windowsExe) + + windowsProgIdValues("simplex", windowsExe) + windowsSchemeValues("simplex", windowsExe) + windowsApplicationValues + + private fun registered(): MutableMap, String> = + fullRegistration().associate { (it.key to it.name) to it.value }.toMutableMap() + + private inner class FakeWindowsShell( + private val user: MutableMap, String> = mutableMapOf(), + private val machine: Map, String> = mapOf(), + private val handler: String? = windowsExe, + private val failingReads: Set = setOf(), + private val failingMachineReads: Set = setOf(), + private val failingWrites: Set = setOf(), + ) : WindowsShell { + val writes = mutableListOf() + var notified = false + + override fun readUser(key: String, name: String): String? { + if (key in failingReads) throw AccessDenied() + return user[key to name] + } + + override fun readMachine(key: String, name: String): String? { + if (key in failingMachineReads) throw AccessDenied() + return machine[key to name] + } + + override fun writeUser(value: RegistryValue) { + if (value.key in failingWrites) throw AccessDenied() + writes += value + user[value.key to value.name] = value.value + } + + override fun linkHandler(scheme: String): String? = handler + + override fun notifyAssociationsChanged() { + notified = true + } + } + + // Win32Exception's public constructors format the message through kernel32, which only Windows has. + private class AccessDenied : Win32Exception(WinError.ERROR_ACCESS_DENIED, WinNT.HRESULT(WinError.ERROR_ACCESS_DENIED), "access denied") + + @Test + fun execArgumentOfAPlainPathIsLeftBare() { + assertEquals("/home/user/Downloads/simplex-desktop-x86_64.AppImage", desktopExecArgument("/home/user/Downloads/simplex-desktop-x86_64.AppImage")) + } + + @Test + fun execArgumentIsQuotedForEveryReservedCharacter() { + for (c in " \t\n\"'\\><~|&;$*?#()`") { + val arg = desktopExecArgument("/a${c}b") + assertTrue(arg.startsWith("\"") && arg.endsWith("\""), "reserved character ${c.code} must be quoted, got $arg") + } + } + + @Test + fun execArgumentKeepsSpacesInsideTheQuotes() { + assertEquals("\"/home/user/My Apps/simplex.AppImage\"", desktopExecArgument("/home/user/My Apps/simplex.AppImage")) + } + + @Test + fun execArgumentEscapesReservedCharacters() { + assertEquals("\"/a\\\\\"b\"", desktopExecArgument("/a\"b"), "double quote") + assertEquals("\"/a\\\\`b\"", desktopExecArgument("/a`b"), "backtick") + assertEquals("\"/a\\\\\$b\"", desktopExecArgument("/a\$b"), "dollar") + } + + @Test + fun execArgumentTurnsABackslashIntoFour() { + assertEquals("\"/a\\\\\\\\b\"", desktopExecArgument("/a\\b")) + } + + @Test + fun appImageEntryHandlesTheSchemeAndStaysOutOfMenus() { + assertEquals( + """ + |[Desktop Entry] + |Type=Application + |Name=SimpleX Chat + |NoDisplay=true + |Exec="/home/user/My Apps/simplex.AppImage" %u + |MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex; + |""".trimMargin(), + appImageDesktopEntry("/home/user/My Apps/simplex.AppImage") + ) + } + + @Test + fun appImageEntryIsRefusedForAPathItCannotRun() { + assertNull(appImageDesktopEntry("/a\nExec=evil"), "line feed") + assertNull(appImageDesktopEntry("/a\rb"), "carriage return") + assertNull(appImageDesktopEntry("/a%1.AppImage"), "percent") + } + + private val linux = DesktopPlatform.LINUX_X86_64 + private val linuxAppPath = "/opt/simplex/bin/simplex" + + @Test + fun unpackagedRunHasNoInstallation() { + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(linux, null, mapOf()), "linux") + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(DesktopPlatform.MAC_AARCH64, null, mapOf()), "macOS") + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(DesktopPlatform.WINDOWS_X86_64, null, mapOf()), "windows") + } + + @Test + fun packagedMacAndWindowsAreRecognised() { + assertEquals(DesktopInstallation.MacBundle, desktopInstallation(DesktopPlatform.MAC_AARCH64, "/Applications/SimpleX.app/Contents/MacOS/SimpleX", mapOf())) + assertEquals(DesktopInstallation.WindowsExe(windowsExe), desktopInstallation(DesktopPlatform.WINDOWS_X86_64, windowsExe, mapOf())) + } + + @Test + fun flatpakWinsWhenTheAppImageCheckWouldAlsoMatch() { + val env = mapOf("container" to "flatpak", "APPIMAGE" to "/home/user/a.AppImage", "APPDIR" to "/tmp/.mount_a") + assertEquals(DesktopInstallation.Flatpak, desktopInstallation(linux, "/tmp/.mount_a/usr/bin/simplex", env)) + } + + @Test + fun appImageIsRecognisedWhenTheLauncherRunsFromItsMount() { + val env = mapOf("APPIMAGE" to appImagePath, "APPDIR" to "/tmp/.mount_Simple") + assertEquals( + DesktopInstallation.AppImage(appImagePath), + desktopInstallation(linux, "/tmp/.mount_Simple/usr/bin/simplex", env) + ) + } + + @Test + fun appImageIsRecognisedWhenItsMountIsReachedThroughASymlink() = withTempDir { tmp -> + val mount = Files.createDirectories(tmp.resolve("real/.mount_Simple/usr/bin")) + val linkedTmp = try { + Files.createSymbolicLink(tmp.resolve("linked"), tmp.resolve("real")) + } catch (e: FileSystemException) { + assumeNoException("symbolic links cannot be created here", e) + return@withTempDir + } + val env = mapOf("APPIMAGE" to appImagePath, "APPDIR" to linkedTmp.resolve(".mount_Simple").toString()) + assertEquals( + DesktopInstallation.AppImage(appImagePath), + desktopInstallation(linux, mount.toRealPath().resolve("simplex").toString(), env) + ) + } + + @Test + fun inheritedAppImageVariablesDoNotMakeTheDebAnAppImage() { + val inherited = mapOf("APPIMAGE" to "/home/user/Terminal.AppImage", "APPDIR" to "/tmp/.mount_Termin") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, linuxAppPath, inherited), "another AppImage's mount") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, linuxAppPath, mapOf("APPIMAGE" to "/home/user/a.AppImage")), "no APPDIR") + val prefix = mapOf("APPIMAGE" to "/home/user/a.AppImage", "APPDIR" to "/tmp/.mount_ab") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, "/tmp/.mount_abc/usr/bin/simplex", prefix), "mount path that only shares a prefix") + } + + @Test + fun fileOnlyItsOwnerCanWriteIsAccepted() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "alice", group = "staff", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun rootOwnedFileIsAccepted() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "root", group = "root", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun fileOfAnotherOwnerIsRefused() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "bob", group = "staff", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun worldWritableFileIsRefused() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "alice", group = "alice", OTHERS_WRITABLE), user = "alice")) + } + + @Test + fun groupWritableFileIsRefusedForASharedGroup() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "alice", group = "staff", GROUP_WRITABLE), user = "alice")) + } + + @Test + fun groupWritableFileIsAcceptedForTheUsersOwnGroup() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "alice", group = "alice", GROUP_WRITABLE), user = "alice"), "owned by the user") + assertTrue(onlyOwnerCanWrite(attributes(owner = "root", group = "alice", GROUP_WRITABLE), user = "alice"), "owned by root") + assertFalse(onlyOwnerCanWrite(attributes(owner = "root", group = "root", GROUP_WRITABLE), user = "alice"), "root's own group") + } + + private fun attributes(owner: String, group: String, permissions: String): PosixFileAttributes = + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PosixFileAttributes::class.java)) { _, method, _ -> + when (method.name) { + "owner" -> UserPrincipal { owner } + "group" -> GroupPrincipal { group } + "permissions" -> PosixFilePermissions.fromString(permissions) + else -> throw UnsupportedOperationException(method.name) + } + } as PosixFileAttributes + + @Test + fun appImageOnlyItsOwnerCanWriteIsAccepted() = withAppImage { + assertTrue(onlyOwnerCanReplace(it, Files.getOwner(it).name)) + } + + @Test + fun appImageInAWorldWritableDirectoryIsRefused() = withAppImage(directoryPermissions = WORLD_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name), "the directory must be checked as well as the file") + } + + @Test + fun appImageUnderAWorldWritableAncestorIsRefused() = withAppImage(ancestorPermissions = WORLD_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name), "every directory up to the root must be checked") + } + + @Test + fun worldWritableAppImageIsRefused() = withAppImage(filePermissions = OTHERS_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name)) + } + + @Test + fun appImageOfAnotherOwnerIsRefused() = withAppImage { + val owner = Files.getOwner(it).name + assumeTrue("root is accepted as an owner", owner != "root") + assertFalse(onlyOwnerCanReplace(it, "$owner-other"), "a file owned by $owner checked for $owner-other") + } + + @Test + fun missingAppImageIsRefused() { + assumePosix() + assertFalse(onlyOwnerCanReplace(Path.of("/nonexistent-simplex-dir/SimpleX.AppImage"), "root")) + } + + // Built under the module's build directory, as the check walks up to the root and the system temp directory is open to everyone. + private fun withAppImage( + directoryPermissions: String = OWNER_WRITABLE, + filePermissions: String = OWNER_WRITABLE, + ancestorPermissions: String = OWNER_WRITABLE, + fileName: String = APPIMAGE_FILE_NAME, + check: (Path) -> Unit + ) { + assumePosix() + val base = Path.of("build").toAbsolutePath() + val user = System.getProperty("user.name") + assumeTrue( + "$base and every directory above it must be owned by $user or root and closed to others", + Files.isDirectory(base) && generateSequence(base) { it.parent }.all { + val attributes = Files.readAttributes(it, PosixFileAttributes::class.java) + attributes.owner().name in setOf(user, "root") && + PosixFilePermission.OTHERS_WRITE !in attributes.permissions() && + (PosixFilePermission.GROUP_WRITE !in attributes.permissions() || attributes.group().name == user) + } + ) + withTempDir(base) { tmp -> + val ancestor = Files.createDirectory(tmp.resolve("shared")) + val dir = Files.createDirectory(ancestor.resolve("apps")) + val appImage = Files.createFile(dir.resolve(fileName)) + Files.setPosixFilePermissions(appImage, PosixFilePermissions.fromString(filePermissions)) + Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString(directoryPermissions)) + Files.setPosixFilePermissions(ancestor, PosixFilePermissions.fromString(ancestorPermissions)) + check(appImage) + } + } + + private fun assumePosix() = + assumeTrue("needs POSIX permissions", "posix" in FileSystems.getDefault().supportedFileAttributeViews()) + + @Test + fun runProcessReturnsTheOutputOfASuccessfulRun() { + assumeShell() + assertEquals("x\n", runProcess(listOf("sh", "-c", "echo x"))) + } + + @Test + fun runProcessKeepsErrorOutputOutOfTheResult() { + assumeShell() + // more than a pipe holds, so error output left in a pipe would block the process until the timeout + assertEquals("x\n", runProcess(listOf("sh", "-c", "head -c 100000 /dev/zero >&2; echo x"))) + } + + @Test + fun runProcessGivesTheProcessAnEndOfInput() { + assumeShell() + assertEquals("x\n", runProcess(listOf("sh", "-c", "cat >/dev/null; echo x")), "a process reading its input must not wait for the timeout") + } + + @Test + fun runProcessReturnsNothingForAFailedRun() { + assumeShell() + assertNull(runProcess(listOf("sh", "-c", "echo x; exit 3"))) + } + + private fun queryDefault(mimeType: String) = listOf(XDG_MIME, "query", "default", mimeType) + private fun setDefault(mimeType: String) = listOf(XDG_MIME, "default", APPIMAGE_ENTRY_NAME, mimeType) + private val queryBothDefaults = listOf(queryDefault(APP_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE)) + private val setBothDefaults = listOf( + queryDefault(APP_LINK_MIME_TYPE), setDefault(APP_LINK_MIME_TYPE), queryDefault(APP_LINK_MIME_TYPE), + queryDefault(CONNECTION_LINK_MIME_TYPE), setDefault(CONNECTION_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE), + ) + + @Test + fun appImageRegistrationWritesTheEntryAndMakesItTheDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = null) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the entry became the default handler") + assertEquals(appImageDesktopEntry(appImage), File(applicationsDir, APPIMAGE_ENTRY_NAME).readText(), "the missing directory is created") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationLeavesAnUnchangedEntryThatIsAlreadyTheDefault() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals(queryBothDefaults, xdg.calls, "nothing is rewritten or set again") + } + + @Test + fun appImageRegistrationTakesTheDefaultFromAHandlerNoLongerInstalled() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the entry became the default handler again") + assertEquals(setBothDefaults, xdg.calls, "the entry is not rewritten, only made the default") + } + + @Test + fun appImageRegistrationSetsOnlyTheDefaultThatPointsElsewhere() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME, connectionDefault = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals( + listOf(queryDefault(APP_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE), setDefault(CONNECTION_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE)), + xdg.calls + ) + } + + @Test + fun appImageRegistrationRewritesTheEntryOfAMovedAppImage() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry("/old/SimpleX.AppImage")!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals(appImageDesktopEntry(appImage), File(applicationsDir, APPIMAGE_ENTRY_NAME).readText()) + assertEquals(listOf(updateDatabase(applicationsDir)) + queryBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationFailsWhenAnotherHandlerKeepsTheAppLinkDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = "other.desktop", keptMimeTypes = setOf(APP_LINK_MIME_TYPE)) + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the default handler did not change") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls, "the connection link default is still set") + } + + @Test + fun appImageRegistrationSucceedsWhenAnotherHandlerKeepsOnlyTheConnectionDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = "other.desktop", keptMimeTypes = setOf(CONNECTION_LINK_MIME_TYPE)) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "only simplexchat: decides the badge page") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationKeepsAnotherInstalledHandler() = withRegistration { appImage, applicationsDir -> + val systemDir = File(applicationsDir.parentFile, "system-applications").apply { mkdirs() } + File(systemDir, "other.desktop").writeText("[Desktop Entry]\n") + val xdg = FakeXdgMime(default = "other.desktop") + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(systemDir), xdg::run), "links stay with the installed handler") + assertEquals(listOf(updateDatabase(applicationsDir)) + queryBothDefaults, xdg.calls, "the user's handler is not replaced") + } + + @Test + fun appImageRegistrationKeepsAnotherInstalledConnectionHandlerOnly() = withRegistration { appImage, applicationsDir -> + val systemDir = File(applicationsDir.parentFile, "system-applications").apply { mkdirs() } + File(systemDir, "other.desktop").writeText("[Desktop Entry]\n") + val xdg = FakeXdgMime(default = null, connectionDefault = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(systemDir), xdg::run), "app links have no handler, so the entry takes them") + assertEquals( + listOf( + updateDatabase(applicationsDir), queryDefault(APP_LINK_MIME_TYPE), setDefault(APP_LINK_MIME_TYPE), queryDefault(APP_LINK_MIME_TYPE), + queryDefault(CONNECTION_LINK_MIME_TYPE), + ), + xdg.calls + ) + } + + @Test + fun appImageRegistrationIsRefusedForAPathTheEntryCannotRun() = + withRegistration(fileName = "Simple%X.AppImage") { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = null) + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "a path with % cannot be registered") + assertEquals(listOf(), xdg.calls, "nothing is run") + assertFalse(applicationsDir.exists(), "no entry is written") + } + + @Test + fun linuxPackageCountsAsRegisteredOnlyWhenItsEntryIsTheDefault() { + assertTrue(linuxPackageRegistered(FakeXdgMime(default = "simplex-simplex.desktop")::run), "the deb's own entry") + assertFalse(linuxPackageRegistered(FakeXdgMime(default = "other.desktop")::run), "another handler") + assertFalse(linuxPackageRegistered(FakeXdgMime(default = null)::run), "no handler") + assertFalse( + linuxPackageRegistered(FakeXdgMime(default = "other.desktop", connectionDefault = "simplex-simplex.desktop")::run), + "only the connection link handler" + ) + } + + private fun updateDatabase(applicationsDir: File) = listOf("update-desktop-database", applicationsDir.path) + + private class FakeXdgMime(default: String?, connectionDefault: String? = default, private val keptMimeTypes: Set = setOf()) { + private val defaults = mutableMapOf(APP_LINK_MIME_TYPE to default, CONNECTION_LINK_MIME_TYPE to connectionDefault) + val calls = mutableListOf>() + + fun run(command: List): String? { + calls += command + if (command.take(3) == listOf(XDG_MIME, "query", "default")) return defaults[command[3]]?.let { "$it\n" } + // xdg-mime sets the default only when given an entry and then a MIME type, the order it requires + if (command.size == 4 && command[1] == "default" && command[2].endsWith(".desktop") && command[3] !in keptMimeTypes) { + defaults[command[3]] = command[2] + } + return "" + } + } + + private fun withRegistration(fileName: String = APPIMAGE_FILE_NAME, test: (appImage: String, applicationsDir: File) -> Unit) = + withAppImage(fileName = fileName) { appImage -> + assumeTrue("the AppImage must be owned by user.name", Files.getOwner(appImage).name == System.getProperty("user.name")) + test(appImage.toString(), appImage.parent.parent.parent.resolve("share/applications").toFile()) + } + + private fun assumeShell() = + assumeTrue("needs sh", System.getenv("PATH")?.split(File.pathSeparator)?.any { Files.isExecutable(Path.of(it, "sh")) } == true) +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt new file mode 100644 index 0000000000..59c3c8c667 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt @@ -0,0 +1,71 @@ +package chat.simplex.app + +import chat.simplex.common.MAX_LINK_BYTES +import chat.simplex.common.linkFromArgs +import chat.simplex.common.isAcceptedLink +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AppLinksTest { + @Test + fun acceptsTheOnlyArgumentWhenItIsAnAppLink() { + assertEquals(BADGE_LINK, linkFromArgs(arrayOf(BADGE_LINK))) + } + + @Test + fun acceptsTheSchemeInAnyCase() { + val link = "SimplexChat:/badge/code/SB0000000000000000000" + assertEquals(link, linkFromArgs(arrayOf(link))) + } + + @Test + fun ignoresNoArguments() { + assertNull(linkFromArgs(arrayOf())) + } + + @Test + fun acceptsAConnectionLink() { + assertEquals(SHORT_CONNECTION_LINK, linkFromArgs(arrayOf(SHORT_CONNECTION_LINK)), "short link") + val oneTimeLink = oneTimeLinkWithKemKey() + assertEquals(oneTimeLink, linkFromArgs(arrayOf(oneTimeLink)), "one-time link of ${oneTimeLink.length} bytes with its post-quantum key") + val upperCase = "SimpleX:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" + assertEquals(upperCase, linkFromArgs(arrayOf(upperCase)), "scheme in another case") + } + + @Test + fun ignoresWebAndOtherLinks() { + assertNull(linkFromArgs(arrayOf("https://simplex.chat/contact#/?v=2-7")), "web link") + assertNull(linkFromArgs(arrayOf("simplexfile:/x")), "another scheme sharing the prefix") + assertNull(linkFromArgs(arrayOf("/home/user/simplex:/a")), "path") + } + + @Test + fun ignoresALinkThatIsNotTheOnlyArgument() { + // a URL split by quotes in a handler command arrives as several arguments + assertNull(linkFromArgs(arrayOf("--flag", BADGE_LINK)), "flag before link") + assertNull(linkFromArgs(arrayOf(BADGE_LINK, "extra")), "argument after link") + } + + @Test + fun ignoresAnEmptyArgument() { + assertNull(linkFromArgs(arrayOf(""))) + } + + @Test + fun lengthBoundIsInclusive() { + val atLimit = badgeLinkOfBytes(MAX_LINK_BYTES) + val overLimit = atLimit + "A" + assertTrue(isAcceptedLink(atLimit), "link of exactly $MAX_LINK_BYTES bytes") + assertFalse(isAcceptedLink(overLimit), "link of ${overLimit.length} bytes") + assertNull(linkFromArgs(arrayOf(overLimit))) + } + + @Test + fun lengthBoundCountsUtf8Bytes() { + // The link has 4213 characters but 8413 bytes, so a bound counted in characters would accept it. + assertNull(linkFromArgs(arrayOf("simplexchat:/" + "é".repeat(4200)))) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt index 1b495c1774..0bfa16b4d3 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt @@ -1,14 +1,48 @@ package chat.simplex.app +import chat.simplex.common.FILE_TIME_TOLERANCE +import chat.simplex.common.MAX_LINK_BYTES +import chat.simplex.common.SHOW_FILE +import chat.simplex.common.SHOW_TMP_SUFFIX +import chat.simplex.common.ShowSignal +import chat.simplex.common.TAKEN_SHOW_FILE +import chat.simplex.common.deleteStaleSignalFiles +import chat.simplex.common.signalRunningInstance +import chat.simplex.common.startDespiteRunningInstance +import chat.simplex.common.takeSignal +import chat.simplex.common.watchShowSignals +import org.junit.Assume.assumeFalse +import org.junit.Assume.assumeTrue import java.nio.channels.FileChannel import java.nio.channels.OverlappingFileLockException +import java.nio.file.FileSystemException import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption.CREATE import java.nio.file.StandardOpenOption.READ import java.nio.file.StandardOpenOption.WRITE +import java.nio.file.StandardWatchEventKinds +import java.nio.file.attribute.FileTime +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermission +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// generous for a loaded build machine; the watcher normally delivers within milliseconds +private const val SIGNAL_WAIT_SECONDS = 15L +// short, as the running instance in these tests never answers within the wait +private const val RUNNING_INSTANCE_WAIT_MILLIS = 300L class SingleInstanceTest { @Test @@ -43,14 +77,245 @@ class SingleInstanceTest { second.close() } - private fun withTempDir(block: (java.nio.file.Path) -> Unit) { - val tmp = Files.createTempDirectory("simplex-singleinstance-test") - try { - block(tmp) - } finally { - Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach { - try { Files.delete(it) } catch (_: java.io.IOException) {} - } + @Test + fun secondProcessExitsWhenTheRunningInstanceTakesTheSignal() = withTempDir { dir -> + val taken = LinkedBlockingQueue() + val runningInstance = thread { + while (!Files.exists(dir.resolve(SHOW_FILE))) Thread.sleep(5) + takeSignal(dir)?.let(taken::add) + } + val start = startDespiteRunningInstance(dir, BADGE_LINK, TimeUnit.SECONDS.toMillis(SIGNAL_WAIT_SECONDS)) { + error("a responsive instance must not raise the alert") + } + runningInstance.join() + assertFalse(start, "the second process must exit") + assertEquals(BADGE_LINK, taken.poll()?.link, "the running instance opens the link") + } + + @Test + fun secondProcessExitsWhenTheUserDeclinesToStart() = withTempDir { dir -> + assertFalse(startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { false }) + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the signal stays for the running instance to take once it recovers") + } + + @Test + fun secondProcessStartsAndWithdrawsTheSignalWhenTheUserStartsAnyway() = withTempDir { dir -> + assertTrue(startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { true }) + assertEquals(listOf(), fileNames(dir), "the withdrawn signal must not open the link in the running instance too") + } + + @Test + fun secondProcessExitsWhenTheRunningInstanceRecoversDuringTheAlert() = withTempDir { dir -> + var opened: String? = null + val start = startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { + opened = takeSignal(dir)?.link + true + } + assertEquals(BADGE_LINK, opened, "the recovered instance opened the link") + assertFalse(start, "starting anyway would open the same link a second time") + } + + @Test + fun signalCarriesTheLinkAndIsTakenOnce() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "only the signal may exist after signalling") + assertEquals(BADGE_LINK, takeSignal(dir)?.link) + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + assertNull(takeSignal(dir), "a signal already taken must not be taken again") + } + + @Test + fun signalCarriesAOneTimeLinkWithItsPostQuantumKey() = withTempDir { dir -> + val link = oneTimeLinkWithKemKey() + signalRunningInstance(dir, link) + assertEquals(link, takeSignal(dir)?.link, "a connection link of ${link.length} bytes") + } + + @Test + fun signalWithoutLinkIsTakenWithNoLink() = withTempDir { dir -> + signalRunningInstance(dir, null) + val signal = takeSignal(dir) + assertNotNull(signal, "an empty signal must still be taken") + assertNull(signal.link, "an empty signal carries no link") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + assertNull(takeSignal(dir), "a signal already taken must not be taken again") + } + + @Test + fun laterSignalReplacesAnUntakenOne() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + signalRunningInstance(dir, OTHER_BADGE_LINK) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "the later signal must replace the earlier one") + assertEquals(OTHER_BADGE_LINK, takeSignal(dir)?.link) + } + + @Test + fun signalIsReadableOnlyByItsOwner() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + val view = Files.getFileAttributeView(dir.resolve(SHOW_FILE), PosixFileAttributeView::class.java) + assumeTrue("POSIX file permissions are not supported here", view != null) + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + view.readAttributes().permissions() + ) + } + + @Test + fun takeSignalReplacesATakenFileLeftByACrash() = withTempDir { dir -> + Files.writeString(dir.resolve(TAKEN_SHOW_FILE), OTHER_BADGE_LINK) + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the new signal, not the leftover, must be read") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + } + + @Test + fun takeSignalRetriesAMoveThatFailsOnce() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + var attempts = 0 + val signal = takeSignal(dir) { from, to -> + attempts++ + if (attempts == 1) throw FileSystemException(from.toString(), null, "held open by another program") + Files.move(from, to, StandardCopyOption.ATOMIC_MOVE) + } + assertEquals(2, attempts, "one failed attempt and one retry") + assertEquals(BADGE_LINK, signal?.link, "the retry must still deliver the link") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + } + + @Test + fun takeSignalLeavesTheSignalWhenTheRetryFailsToo() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + var attempts = 0 + val signal = takeSignal(dir) { from, _ -> + attempts++ + throw FileSystemException(from.toString(), null, "held open by another program") + } + assertEquals(2, attempts, "one failed attempt and one retry") + assertNotNull(signal, "the window still comes forward") + assertNull(signal.link, "a signal that cannot be read carries no link") + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the signal stays for the next attempt") + } + + @Test + fun takeSignalAcceptsALinkOfExactlyTheBound() = withTempDir { dir -> + val atBound = badgeLinkOfBytes(MAX_LINK_BYTES) + signalRunningInstance(dir, atBound) + assertEquals(atBound, takeSignal(dir)?.link, "a link of exactly $MAX_LINK_BYTES bytes") + } + + @Test + fun takeSignalDropsContentThatIsNotAnAcceptedLink() = withTempDir { dir -> + val show = dir.resolve(SHOW_FILE) + fun assertTakenWithoutLink(case: String) { + val signal = takeSignal(dir) + assertNotNull(signal, "$case: rejected content is still a signal, so the window comes forward") + assertNull(signal.link, case) + } + Files.writeString(show, "https://simplex.chat/contact#/?v=2-7") + assertTakenWithoutLink("web link") + assertEquals(listOf(), fileNames(dir), "a rejected signal must still be removed") + + Files.writeString(show, badgeLinkOfBytes(MAX_LINK_BYTES + 1)) + assertTakenWithoutLink("link over the length bound") + + // 4214 characters but 8414 bytes; a read cut at the bound would end on a whole character and decode to an accepted link + Files.writeString(show, "simplexchat:/x" + "é".repeat(4200)) + assertTakenWithoutLink("multi-byte link over the byte bound") + assertEquals(listOf(), fileNames(dir), "a rejected signal must still be removed") + } + + @Test + fun deleteStaleSignalFilesRemovesOldTempAndTakenFilesOnly() = withTempDir { dir -> + val lockAttemptTime = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val old = FileTime.from(lockAttemptTime.minusSeconds(3600)) + Files.setLastModifiedTime(Files.createFile(dir.resolve("simplex.started")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("simplex_v1_chat.db")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("other.tmp")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("${SHOW_FILE}123$SHOW_TMP_SUFFIX")), old) + Files.createFile(dir.resolve("${SHOW_FILE}456$SHOW_TMP_SUFFIX")) + Files.createFile(dir.resolve(TAKEN_SHOW_FILE)) + Files.createFile(dir.resolve(SHOW_FILE)) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals( + listOf("other.tmp", SHOW_FILE, "${SHOW_FILE}456$SHOW_TMP_SUFFIX", "simplex.started", "simplex_v1_chat.db"), + fileNames(dir), + "an old temp file and a taken file must go; a fresh temp file is a signal being written, and the lock and database stay" + ) + } + + @Test + fun deleteStaleSignalFilesKeepsASignalWithinTheToleranceAndDropsAnOlderOne() = withTempDir { dir -> + val lockAttemptTime = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val show = dir.resolve(SHOW_FILE) + signalRunningInstance(dir, BADGE_LINK) + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minus(FILE_TIME_TOLERANCE))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "a signal exactly at the tolerance must be kept") + + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minusSeconds(1))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "a signal one second behind the lock attempt must be kept") + + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minus(FILE_TIME_TOLERANCE).minusSeconds(1))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(), fileNames(dir), "a signal older than the tolerance must be deleted") + } + + @Test + fun watcherTakesASignalWrittenBeforeItsWatch() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + watch(dir) { signals -> + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "a signal written before the watch raised no event") } } + + @Test + fun watcherTakesEverySignalArrivingWhileItWatches() = withTempDir { dir -> + assumeWatchServiceReportsEachCreation(dir) + watch(dir) { signals -> + // Each signal is written after the previous one arrived, so later ones can come only from watch events. + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "first signal") + signalRunningInstance(dir, OTHER_BADGE_LINK) + assertEquals(OTHER_BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "second signal, seen as a creation") + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "third signal, after the watch key was reset") + } + } + + @Test + fun signalIsWrittenThroughATempFileBesideIt() = withTempDir { dir -> + // a temp file elsewhere may sit on another file system, where the atomic move fails and the link is dropped + assumeWatchServiceReportsEachCreation(dir) + val created = dir.fileSystem.newWatchService().use { ws -> + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) + signalRunningInstance(dir, BADGE_LINK) + ws.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.pollEvents()?.map { it.context().toString() }.orEmpty() + } + assertTrue( + created.any { it != TAKEN_SHOW_FILE && it.startsWith(SHOW_FILE) && it.endsWith(SHOW_TMP_SUFFIX) }, + "a temp file must be created in the signal directory, created: $created" + ) + } + + // macOS polls the directory and keeps a taken name cached, so it misses short-lived files and reports a reused name as a modification + private fun assumeWatchServiceReportsEachCreation(dir: Path) = + assumeFalse("needs a watch service that reports each creation", dir.fileSystem.newWatchService().use { it.javaClass.simpleName == "PollingWatchService" }) + + private fun watch(dir: Path, test: (signals: LinkedBlockingQueue) -> Unit) { + val signals = LinkedBlockingQueue() + val watcherThread = dir.fileSystem.newWatchService().use { ws -> + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) + thread(isDaemon = true) { watchShowSignals(ws, dir) { signals.put(it) } }.also { + test(signals) + // a second, duplicate delivery would arrive within this wait + assertNull(signals.poll(1, TimeUnit.SECONDS), "each signal is taken once") + } + } + watcherThread.join(TimeUnit.SECONDS.toMillis(SIGNAL_WAIT_SECONDS)) + assertFalse(watcherThread.isAlive, "closing the watch service ends the watcher") + } + + private fun fileNames(dir: Path): List = + Files.list(dir).use { files -> files.map { it.fileName.toString() }.sorted().toList() } } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt new file mode 100644 index 0000000000..95b65da588 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt @@ -0,0 +1,20 @@ +package chat.simplex.app + +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +private const val TEMP_DIR_PREFIX = "simplex-desktop-test" + +internal fun withTempDir(parent: Path? = null, block: (Path) -> Unit) { + val tmp = if (parent == null) Files.createTempDirectory(TEMP_DIR_PREFIX) else Files.createTempDirectory(parent, TEMP_DIR_PREFIX) + try { + block(tmp) + } finally { + Files.walk(tmp).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { + try { Files.delete(it) } catch (_: IOException) {} + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt new file mode 100644 index 0000000000..76dd2b46fb --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt @@ -0,0 +1,86 @@ +package chat.simplex.app + +import chat.simplex.common.activationEvent +import chat.simplex.common.sendActivation +import com.sun.jna.NativeLong +import com.sun.jna.platform.unix.X11 +import org.junit.Assume.assumeNoException +import java.lang.reflect.Proxy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame + +private const val WINDOW_ID = 42L +private const val ATOM_ID = 7L + +class WindowActivationTest { + @Test + fun activationEventAsksTheWindowManagerToActivateTheWindowAsAPager() { + assumeLibX11() + val atom = X11.Atom(ATOM_ID) + val event = activationEvent(X11.Window(WINDOW_ID), atom) + val message = event.xclient + assertEquals(WINDOW_ID, message.window.toLong(), "target window") + assertEquals(atom, message.message_type, "_NET_ACTIVE_WINDOW atom") + assertEquals(32, message.format, "data format in bits") + // JNA writes only the selected member of a union, so the bytes XSendEvent receives are checked too + event.write() + assertEquals(X11.ClientMessage, event.pointer.getInt(0), "event type in native memory") + assertEquals(2L, message.data.pointer.getNativeLong(0).toLong(), "source indication in native memory: a pager, which focus stealing prevention lets through") + assertEquals(0L, message.data.pointer.getNativeLong(NativeLong.SIZE.toLong()).toLong(), "timestamp in native memory: CurrentTime") + } + + @Test + fun activationIsSentToTheRootWindowAndTheDisplayIsClosed() { + assumeLibX11() + val x11 = FakeX11() + sendActivation(x11.proxy, WINDOW_ID) + assertEquals(listOf("XOpenDisplay", "XInternAtom", "XDefaultRootWindow", "XSendEvent", "XFlush", "XCloseDisplay"), x11.calls) + assertNull(x11.displayName, "the display named by DISPLAY") + assertEquals("_NET_ACTIVE_WINDOW", x11.atomName, "atom of the request") + val send = x11.sendArgs + assertSame(x11.display, send[0], "display") + assertEquals(x11.root, send[1], "the request goes to the root window, where the window manager listens") + assertEquals(NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()), send[3], "event mask the window manager selects") + val event = (send[4] as X11.XEvent).xclient + assertEquals(WINDOW_ID, event.window.toLong(), "window to activate") + assertEquals(x11.atom, event.message_type, "the interned atom is the request type") + } + + @Test + fun displayIsClosedWhenSendingFails() { + assumeLibX11() + val x11 = FakeX11(sendFails = true) + assertFailsWith { sendActivation(x11.proxy, WINDOW_ID) } + assertEquals("XCloseDisplay", x11.calls.last(), "the display must be closed after a failure") + } + + private class FakeX11(private val sendFails: Boolean = false) { + val display = X11.Display() + val root = X11.Window(1) + val atom = X11.Atom(ATOM_ID) + val calls = mutableListOf() + var displayName: Any? = null + var atomName: Any? = null + var sendArgs: Array = emptyArray() + + val proxy = Proxy.newProxyInstance(X11::class.java.classLoader, arrayOf(X11::class.java)) { _, method, args -> + calls += method.name + when (method.name) { + "XOpenDisplay" -> { displayName = args[0]; display } + "XInternAtom" -> { atomName = args[1]; atom } + "XDefaultRootWindow" -> root + "XSendEvent" -> { sendArgs = args.copyOf(); if (sendFails) throw IllegalStateException("send failed"); 1 } + "XFlush", "XCloseDisplay" -> 0 + else -> throw UnsupportedOperationException(method.name) + } + } as X11 + } + + // JNA loads libX11 to lay out any X11 structure, and only X11 desktops have it + private fun assumeLibX11() { + try { X11.INSTANCE } catch (e: LinkageError) { assumeNoException("libX11 is not available", e) } + } +} diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 7c7455c7f3..caaa1ba272 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -85,6 +85,18 @@ compose { extraKeysRawXml = """ NSMicrophoneUsageDescription SimpleX needs microphone access to record voice messages + CFBundleURLTypes + + + CFBundleURLName + $bundleID + CFBundleURLSchemes + + simplexchat + simplex + + + """ } val identity = rootProject.extra["desktop.mac.signing.identity"] as String? diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index e407448d1b..46640b3dbb 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -9,19 +9,27 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.* import chat.simplex.common.acquireSingleInstance +import chat.simplex.common.installOpenUriHandler +import chat.simplex.common.linkFromArgs import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.size import chat.simplex.common.platform.* import chat.simplex.common.platform.DesktopPlatform import chat.simplex.common.showApp +import chat.simplex.common.singleInstanceLock +import chat.simplex.common.startShowFileWatcher import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import kotlinx.coroutines.* import java.io.File -fun main() { +fun main(args: Array) { try { - if (!acquireSingleInstance()) return + val link = linkFromArgs(args) + if (!acquireSingleInstance(link)) return + link?.let { chatModel.appOpenUrl.value = null to it } + // started after the launch link is stored, so a link it forwards later is not overwritten + if (singleInstanceLock) startShowFileWatcher() // Clean shared temp dirs only in the owning instance (not in a Files.desktop val // initializer, which a transient second instance would also run). Early: before settings writes. preferencesTmpDir.deleteRecursively() @@ -31,13 +39,14 @@ fun main() { runMigrations() setupUpdateChecker() initApp() + registerLinkSchemes() tmpDir.deleteRecursively() tmpDir.mkdir() // Only the owning instance cleans tmpDir on exit (see preferencesTmpDir above). tmpDir.deleteOnExit() - // showApp is inside the try: its first statements (SystemTray probe, Compose setup) are the - // process's first AWT init, which is itself a known startup failure cause (#4146). Crashes - // after the window appears are handled by the WindowExceptionHandler in showApp instead. + // installOpenUriHandler and showApp stay inside the try: whichever touches AWT first makes the process's + // first AWT init, a known startup failure cause (#4146); later crashes go to showApp's WindowExceptionHandler. + if (desktopPlatform.isMac()) installOpenUriHandler() return showApp() } catch (e: Throwable) { showStartupError(e) // the jpackage launcher otherwise hides the error behind "Failed to launch JVM" (#4146) diff --git a/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop b/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop index 79281a4dc8..68844ed5e6 100644 --- a/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop +++ b/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop @@ -2,6 +2,7 @@ Type=Application Name=SimpleX Chat Comment=Private and secure open-source messenger - no user IDs (not even random numbers) -Exec=/opt/simplex/bin/simplex +Exec=/opt/simplex/bin/simplex %u Icon=/opt/simplex/lib/simplex Categories=Network;Chat; +MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex; diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index b349f2819a..d76d4d55f4 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -102,6 +102,7 @@ Key files that exist only on one platform, grouped by concern. | `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | JVM entry point, Haskell/VLC init, PlatformInterface setup | | `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Compose Desktop window creation and lifecycle | | `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | Window position/size persistence | +| `common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt` | Deep link handling from launch arguments and macOS | | `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | In-app update checker | | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt` | VLC-based video detection | | `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | VLC video player implementation | diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index 07f6742d6a..1f87152e64 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -13,10 +13,11 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des 5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified) 6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented) 7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) -8. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-08-desktop-emoji-picker-without-skin-tones-and-localized-search) -9. [Online Search: Groups Joined Through Another Link Are Not Recognised](#gap-09-online-search-groups-joined-through-another-link-are-not-recognised) -10. [Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address](#gap-10-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address) -11. [Online Search: New Chat Does Not Narrow Contacts by Name While Typing](#gap-11-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing) +8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration) +9. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-09-desktop-emoji-picker-without-skin-tones-and-localized-search) +10. [Online Search: Groups Joined Through Another Link Are Not Recognised](#gap-10-online-search-groups-joined-through-another-link-are-not-recognised) +11. [Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address](#gap-11-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address) +12. [Online Search: New Chat Does Not Narrow Contacts by Name While Typing](#gap-12-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing) --- @@ -296,7 +297,39 @@ This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self --- -## GAP-08: Desktop: Emoji Picker Without Skin Tones and Localized Search +## GAP-08: Desktop: App Link Registration + +**Severity:** Low +**Category:** Platform +**Platform:** Desktop only + +### Description + +Desktop registers the `simplexchat:` scheme so the badge page can return to the app, and the `simplex:` scheme so connection links open in it. The registration has known limits: + +- Uninstalling or deleting the app leaves the per-user registration behind: the HKCU keys on Windows (the scheme keys, the ProgIds, `Capabilities` and the `RegisteredApplications` entry) and the hidden AppImage desktop entry on Linux. A later link then points at a missing program. On Windows, if the app was installed into a folder other users can write to (for example a custom folder at the root of `C:\`), another user could put a program at that path and have it run on the next link; the default Program Files install is not affected. This risk is accepted. +- A Flatpak counts as registered without a check, as it cannot query the host's default handler. It works only once the Flathub release pins a commit with the desktop file's MimeType line and its wrapper passes `"$@"` to the app. +- On macOS a second copy of the app does not forward a link to the running one; macOS delivers the link to the running bundle itself. +- With two installations (a deb and an AppImage, or two installed copies of the Windows app), the scheme stays with whichever registered first while it is installed; the other gets `app=desktop`. On Windows, when several apps registered through `Capabilities` handle a scheme and none was picked, Windows chooses or asks; the app cannot set `UserChoice` itself. +- The link arrives as a command-line argument, which other local users can read through `/proc` on Linux. This risk is accepted. + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt` -- registration and its result for the badge page +- `common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt` -- forwarding to a running instance + +### Impact + +A stale registration or an unregistered Flatpak sends the user back to a link that opens nothing; the badge page's *Show code* and the Redeem code screen remain. + +### Recommendation + +1. Remove the registration in an uninstaller where one exists (MSI), and on AppImage when the entry's file is gone. +2. Removing the command-line exposure needs the page to encrypt the code to a key the app passes in the URL fragment. + +--- + +## GAP-09: Desktop: Emoji Picker Without Skin Tones and Localized Search **Severity:** Low **Category:** Feature / Platform @@ -322,7 +355,7 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag --- -## GAP-09: Online Search: Groups Joined Through Another Link Are Not Recognised +## GAP-10: Online Search: Groups Joined Through Another Link Are Not Recognised **Severity:** Low **Category:** UI / UX @@ -350,7 +383,7 @@ Have the directory send a stable group identifier the core can match locally, or --- -## GAP-10: Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address +## GAP-11: Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address **Severity:** Low **Category:** Privacy / UX @@ -376,7 +409,7 @@ Decide whether the alert copy should also say that the address a name points to --- -## GAP-11: Online Search: New Chat Does Not Narrow Contacts by Name While Typing +## GAP-12: Online Search: New Chat Does Not Narrow Contacts by Name While Typing **Severity:** Low **Category:** UI / UX diff --git a/apps/multiplatform/product/rules.md b/apps/multiplatform/product/rules.md index c84a2ccc70..7d9af4c3bb 100644 --- a/apps/multiplatform/product/rules.md +++ b/apps/multiplatform/product/rules.md @@ -300,7 +300,7 @@ This document specifies invariants enforced by the Android and Desktop (Kotlin/C ### RULE-22: Nothing Is Shown Twice -**Invariant:** A name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. A directory result whose link refers to a chat the screen doesn't list MUST be drawn as that screen's chat row, which opens the chat, never as "Join …". When every result of the last page loaded is hidden as a chat the user has, the Directory part MUST say so ("All results on this page are already in your chats"); a result hidden only because a name was resolved through its link does not count. Limit: a regular group joined through another link, the directory's full link or by invitation is not found by the lookup and still appears as a directory result with "Join group" (GAP-09). +**Invariant:** A name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. A directory result whose link refers to a chat the screen doesn't list MUST be drawn as that screen's chat row, which opens the chat, never as "Join …". When every result of the last page loaded is hidden as a chat the user has, the Directory part MUST say so ("All results on this page are already in your chats"); a result hidden only because a name was resolved through its link does not count. Limit: a regular group joined through another link, the directory's full link or by invitation is not found by the lookup and still appears as a directory result with "Join group" (GAP-10). **Enforcement:** `OnlineSearchModel.rows(shownChatIds)` filters at render time with the ids of the chats each screen shows and computes `pageInChats`; `planLocalChatId` derives a result's local chat id from the connection plan, and `NameSearchResult.resolvedLink` is the link the core resolved. `directoryRows` (chat list) and `onlineSearchSections` (New chat) draw a result whose chat is loaded with `ChatListNavLinkView` / `ContactListNavLinkView`. diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index 4672657ead..418862dee2 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -107,7 +107,7 @@ Common Module (commonMain) | Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source | | Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) | | Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories | -| Gaps | [product/gaps.md](../product/gaps.md) | 7 known gaps with recommendations | +| Gaps | [product/gaps.md](../product/gaps.md) | 8 known gaps with recommendations | | Flows | [product/flows/](../product/flows/) | onboarding, messaging, connection, calling, file-transfer, group-lifecycle | | Views | [product/views/](../product/views/) | chat-list, chat, settings, onboarding, call, new-chat, contact-info, group-info, user-profiles | @@ -117,21 +117,21 @@ Common Module (commonMain) | Component | File | Key Symbol | Line | |---|---|---|---| -| Android Application | [`SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L44) | `class SimplexApp` | 44 | +| Android Application | [`SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L46) | `class SimplexApp` | 46 | | Android Activity | [`MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L24) | `class MainActivity` | 24 | -| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L22) | `fun main()` | 22 | +| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) | `fun main(args: Array)` | 26 | | Desktop App Window | [`DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L34) | `fun showApp()` | 34 | | Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 | | Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L48) | `fun AppScreen()` | 48 | | JNI Bridge | [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | -| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L537) | `object ChatController` | 537 | -| Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L155) | `object ChatModel` | 155 | -| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L103) | `class AppPreferences` | 103 | -| Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L19) | `interface PlatformInterface` | 19 | +| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L541) | `object ChatController` | 541 | +| Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L157) | `object ChatModel` | 157 | +| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L105) | `class AppPreferences` | 105 | +| Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L20) | `interface PlatformInterface` | 20 | | Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | `abstract class NtfManager` | 20 | | Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 | -| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L33) | `fun initHaskell(packageName: String)` | 33 | -| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L46) | `fun runMigrations()` | 46 | +| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L36) | `fun initHaskell(packageName: String)` | 36 | +| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L48) | `fun runMigrations()` | 48 | | Android Service | [`SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt#L41) | `class SimplexService` | 41 | | Gradle Root | [`settings.gradle.kts`](../settings.gradle.kts#L21) | `include(":android", ":desktop", ":common")` | 21 | | Common Build | [`build.gradle.kts`](../common/build.gradle.kts#L39) | `kotlin { androidTarget(); jvm("desktop") }` | 39 | diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index 9911a2670f..cdc43a794a 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -222,21 +222,29 @@ Lifecycle callbacks in `SimplexApp` (implements `LifecycleEventObserver`): ### Desktop -Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) +Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) ``` -main() +main(args) + +-- linkFromArgs(args) // A simplexchat: or simplex: link passed by the OS + +-- acquireSingleInstance(link) // Or forward the link to the running instance and exit + +-- appOpenUrl = link // Stored before the watcher can forward a newer link + +-- startShowFileWatcher() // Only while holding the single-instance lock +-- initHaskell() // Load native lib from resources dir, call initHS() | +-- System.load(libapp-lib.so/dll/dylib) | +-- initHS() +-- runMigrations() +-- setupUpdateChecker() +-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart + +-- registerLinkSchemes() // Background thread, per-OS registration and detection + +-- installOpenUriHandler() // macOS only: links arrive as Apple Events +-- showApp() // Compose window with AppScreen() ``` [`showApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) creates a Compose `Window` with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window. +[`showWindow()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L258) un-minimizes and raises the window when a second launch or a link reaches the running app. On Linux it also sends `_NET_ACTIVE_WINDOW` through [`requestX11Activation()`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt#L17), because wlroots compositors such as Sway ignore the raise that AWT's `toFront()` sends under XWayland. On Windows the second process, which the browser left holding the foreground right, calls `AllowSetForegroundWindow` before it signals so that the running instance may raise its window. During a crash restart `showWindow()` skips the disposed window, so it does not come back; the new window opens visible. + [`initApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) sets the `ntfManager` implementation (desktop notifications via `NtfManager` in `common/model/`) and calls `initChatControllerOnStart()`. --- @@ -341,8 +349,8 @@ Examples from platform files: [`PlatformInterface`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) is an interface with default no-op implementations. It is assigned at runtime by each platform entry point: -- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L187) (line 187) -- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L50) (line 50) +- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L193) +- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L59) The global variable is declared at [`Platform.kt line 50`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L50): ```kotlin @@ -417,6 +425,8 @@ var platform: PlatformInterface = object : PlatformInterface {} | SimplexService.kt | [`android/src/main/java/chat/simplex/app/SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt) | Android foreground service | | Main.kt | [`desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt) | Desktop `main()` | | DesktopApp.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt) | `showApp()`, `SimplexWindowState` | +| SingleInstance.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt) | `acquireSingleInstance()`, `startShowFileWatcher()`, signal file hand-over | +| WindowActivation.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt) | `requestX11Activation()`, `sendActivation()`, `activationEvent()` | ### Theme diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index 8f21544100..134de8a1d6 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -465,7 +465,7 @@ Only store-derived chat ids and the link the core resolved can hide a row, never A directory result that is a chat the user has but that the screen doesn't list (the local filter doesn't match it) stays in `rows.directory` and is drawn as a chat row (see Rendering). When its chat id is not among the loaded chats (`chatModel.getChat` returns null), the Directory row is drawn as before, and tapping it goes through `planAndConnect`, which opens the known chat. -[GAP] A regular group the user joined through a different link, through the directory's full link, or by invitation, is not found by the store lookup and still appears as a Directory row with "Join group"; tapping it goes through `planAndConnect`. See [GAP-09](../../product/gaps.md#gap-09-online-search-groups-joined-through-another-link-are-not-recognised). +[GAP] A regular group the user joined through a different link, through the directory's full link, or by invitation, is not found by the store lookup and still appears as a Directory row with "Join group"; tapping it goes through `planAndConnect`. See [GAP-10](../../product/gaps.md#gap-10-online-search-groups-joined-through-another-link-are-not-recognised). ### Rendering @@ -489,4 +489,4 @@ Each part renders only when it has rows: the Names part when `rows.names` is not A name or directory row tap first hides the keyboard (`hideKeyboard(view)`), then runs `planAndConnect` with `withBGApi`. Text from the directory and from name owners is rendered with `Text(String)` (never parsed as markdown) with line limits. -[GAP] Resolving a SimpleX name with `PRMUnknown` also loads the resolved address's link data from the server that hosts it; the alert names only the directory and the name-resolving server. See [GAP-10](../../product/gaps.md#gap-10-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address). +[GAP] Resolving a SimpleX name with `PRMUnknown` also loads the resolved address's link data from the server that hosts it; the alert names only the directory and the name-resolving server. See [GAP-11](../../product/gaps.md#gap-11-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address). diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index 3bad2e09c4..c7d6f0674f 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -316,7 +316,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `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 | +| `TextItemView.kt` | Plain text message with markdown; blocks of `markdownBlocks` drawn as a column of views by `MarkdownBlocks` (`MarkdownBlocks.kt`) | ### `views/chat/group/` diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 30b65d9fb3..19802496d4 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -108,6 +108,14 @@ When onboarding is complete: 1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call). 2. Routes to `AndroidScreen` or `DesktopScreen` based on platform. +### URL Deep Link + +On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L775), which re-queues it while no profile is active. + +On desktop the same state is set from three places, for `simplexchat:` app links and `simplex:` connection links alike. [`isAcceptedLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L12) accepts either scheme within 8192 bytes in UTF-8, which leaves room for a one-time link with its post-quantum key (about 2 KB) and for a second such key. A link that starts the app arrives as the only argument of [`main()`](../../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) on Windows and Linux ([`linkFromArgs()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L15)). A link for an app that is already running starts a second process, which writes it into the single-instance signal file ([`signalRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L138)); the running instance renames the file before reading and deleting it ([`takeSignal()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L159)), retrying once if another program holds the file open, handles a signal already present when its watcher ([`watchShowSignals()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L247)) starts unless it predates the lock attempt by more than 2 s ([`deleteStaleSignalFiles()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L116)), and passes the link to [`openDesktopLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L19), which queues it for the active host and shows the window. If the running instance does not take the signal within 1 s, the second process asks whether to start anyway ([`startDespiteRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L93)); on *Yes* it withdraws the signal first and exits instead if the running instance has recovered and taken it, so the link opens once. On macOS the link is an Apple Event handled by [`installOpenUriHandler()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L25). From there `connectIfOpenedViaUri()` handles the link as on Android: a connection link goes to `planAndConnect()`, which asks before connecting. Both schemes are declared by the macOS Info.plist, the deb, AppImage and Flatpak desktop entries, and at runtime by [`registerLinkSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L67) (HKCU on Windows, a hidden desktop entry for an AppImage). Only `simplexchat:` is checked: [`appLinkSchemeRegistered()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L65) decides the badge page's ending through [`badgePageUrl()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt#L53): `app=true` when a link is expected to reach this installation, otherwise `app=desktop` with the Redeem code screen opened beside the browser. [`desktopInstallation()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L74) tells the installation kind apart. A packaged macOS app and a Flatpak count as registered without a check, since a Flatpak cannot query the host's default handler ([GAP](../../product/gaps.md#gap-08-desktop-app-link-registration)); Windows, where each scheme key is paired with a `SimpleX.` ProgId named in `Capabilities` and listed in `RegisteredApplications`, so the app appears in Windows default-app settings for each scheme that no other program holds, when the shell (`AssocQueryStringW`) reports this exe as the program that opens `simplexchat:` links, which accounts for any app picked in settings ([`registerWindowsSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L168)); an AppImage ([`registerAppImageScheme()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L299)) or deb when its own desktop entry is the default `simplexchat:` handler. Registration takes a scheme only when no handler is set or the current one is no longer installed: another installed program, such as a deb beside an AppImage or on Windows a per-user or machine-wide handler, keeps it, while this app still repairs its own ProgId unless another installed copy holds it, and the page then ends with `app=desktop`. An AppImage is not registered ([`onlyOwnerCanReplace()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L284)) when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it (POSIX ACLs are not inspected), or when its path contains a line break or `%`. An unpackaged run registers nothing. + +App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L797) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L804) dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L282), which shows `BadgesRedeemLinkView` as a `ModalManager.start` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the screen composes), which core would refuse, the screen does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or back closes the screen without cancelling anything; if the redemption then succeeds with the screen closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown ([`showBadgeAddedAlert()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L97), which the store lane shares). Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L279)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string. + ### Overlay Layers (bottom of MainScreen) | Layer | Condition | Content | @@ -379,6 +387,8 @@ The stage value is stored in `appPrefs.onboardingStage` and persisted across app | `views/call/CallView.kt` | In-call fullscreen view (ActiveCallView) | | `views/localauth/PasswordEntry.kt` | Column divider utility (contains VerticalDivider) | | `views/newchat/NewChatSheet.kt` | New chat sheet: action buttons, contacts list, online search sections; Archived contacts (`DeletedContactsView`) | +| `AppLinks.kt` (desktopMain) | Desktop app links from launch arguments and macOS Apple Events | +| `platform/AppLinkScheme.desktop.kt` (desktopMain) | Runtime `simplexchat:` registration and its result for the badge page | --- @@ -405,4 +415,4 @@ Both triggers do nothing for empty text or a pasted link ([`searchTrimmed()`](.. **Archived contacts.** [`DeletedContactsView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L763-L877) passes no `onSearch` to `ContactsSearchBar`: its field keeps the Search IME label (every `SearchTextField` uses `ImeAction.Search`), the key does nothing, and it shows no online rows; its empty state is unchanged. -[GAP] New chat does not resolve a typed name locally while typing (the `ContactsSearchBar` collector resolves a name only when its "Connect to …" row is tapped), so a known contact whose name does not contain the typed text is not in "Your contacts" and appears as a Names row; tapping it opens the existing chat. See [GAP-11](../../product/gaps.md#gap-11-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing). +[GAP] New chat does not resolve a typed name locally while typing (the `ContactsSearchBar` collector resolves a name only when its "Connect to …" row is tapped), so a known contact whose name does not contain the typed text is not in "Your contacts" and appears as a Names row; tapping it opens the existing chat. See [GAP-12](../../product/gaps.md#gap-12-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing). diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index 69cc463ea7..2a111b6b48 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -421,6 +421,10 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` |-------------|--------------------------|------------|-------| | `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery | | `StoreWindowState.kt` | — | Low | Window position/size persistence | +| `AppLinks.kt` | — | Medium | `simplexchat:` and `simplex:` links from launch arguments and macOS Apple Events | +| `SingleInstance.kt` | — | Medium | Single-instance lock; forwards a second launch and its link to the running app | +| `WindowActivation.kt` | — | Low | `_NET_ACTIVE_WINDOW` request that raises the window on X11 and XWayland | +| `platform/AppLinkScheme.desktop.kt` | — | Medium | Runtime `simplexchat:` and `simplex:` scheme registration (Windows HKCU, AppImage desktop entry) | | `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | | `platform/AppCommon.desktop.kt` | PC1 through PC32 | Medium | Desktop app initialization actual declarations | | `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md index 83b7ca4c40..bb1cec2230 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -51,7 +51,7 @@ simplex-badge-service --help `simplex-directory-service --run-cli`). This mode is the chat/RPC side and the `//` commands below: it starts no web listener and no poller, and serves no group commands, whatever `--service-config` says. It still updates a code's group message when the code is redeemed or - revoked. + revoked. `[apple]`, `[google]` and `[dev] accept_unverified_store_receipts` apply to it. - `--no-address`: skip address creation on start-up (for operators who provision the address themselves). The service cannot sign credentials without an issuer key and refuses to start without one: @@ -84,7 +84,8 @@ Other options: - `--service-config INI_FILE`: path to `badge_service.ini`. Omit it to run the chat/RPC side only; the process never starts a web listener without it, and never starts one under `--run-cli`, which parses and validates the whole file (`[listener] static_dir` included) but - uses only its `[issuer]` section. An issuer key is still required either way. + uses only its `[issuer]`, `[apple]` and `[google]` sections and `[dev] accept_unverified_store_receipts`. An issuer key is + still required either way. - `--service-name NAME`: the bot's display name, without `*`s or spaces (default `SimpleX Badges`). - `--client-service`: use the client service certificate. - also accepts the standard SimpleX Chat core options — database path, SMP/XFTP servers, @@ -208,6 +209,68 @@ the exception: each answers 200, 400 or 413 with an empty body, because its prov caller and nothing it could read would change what the route does. A wrong verb on any route, those two included, answers `method_not_allowed`. +### Store purchases (App Store and Google Play) + +Each store is verified only when its section is present; without one, every receipt from that store +is answered `provider_not_configured` and the app keeps the purchase to present again. A section +missing a key, a root certificate that cannot be read or is not a root, an unreadable service +account key, or either section beside +`[dev] accept_unverified_store_receipts = on`, stops the service at startup. + +```ini +[apple] +bundle_id = chat.simplex.app +root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer + +[google] +package_name = chat.simplex.app +service_account_file = /etc/simplex-badge-service/play-service-account.json +``` + +- `[apple]` verifies the signed transaction offline, against the roots `root_certificates` lists, + comma-separated, each in DER. Today that is Apple Root CA - G3 as Apple publishes it at + . Each must be self-signed. + Verification reads no validity period, so an expired root keeps verifying. A transaction for + another bundle id, or an intermediate no listed root signed, is answered `internal` and logged as + an error rather than refused, since `bundle_id` may be wrong or a root missing; check both when those errors appear, unless the purchase was made in Xcode's + StoreKit testing, which the App Store does not sign, so no root verifies it. When Apple signs + under a new root, list it beside the old one rather than in its place: a purchase signed under the + old root still verifies, and one answered `internal` meanwhile is credited when the app presents + it again. +- `[google]` asks the Google Play Developer API about each purchase, and acknowledges it before + crediting it, as the service account whose JSON key `service_account_file` holds. The account + needs both Play Console permissions Google's + [getting started](https://developers.google.com/android-publisher/getting_started) lists for its + billing APIs: "View financial data, orders, and cancellation survey responses" to read a + purchase, and "Manage orders and subscriptions" to acknowledge it. Without the second, a Play + purchase the app has not already acknowledged itself is answered `internal` and logged as an + error naming the acknowledgement, and the app presents it again once the permission is granted, + which must be within three days, before Play refunds it. The key signs in + for an access token, so keep the file readable by this service alone. + Play purchases are verified on a lane of their own, so a slow or unreachable Play delays only + other Play purchases, not code redemptions, renewals or App Store purchases. + +Which answers are terminal for a purchase and which leave it to be presented again is listed per +store in [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md#commands). + +### Accepting store receipts unverified, for local testing + +```ini +[dev] +accept_unverified_store_receipts = on +``` + +Without a store verifier every store purchase is answered `provider_not_configured`. With this on, +the service accepts any well-formed receipt without asking or verifying anything: an Apple JWS for +the `transactionId` and `productId` its payload names, unsigned or not, and a Play product id and +token as given. It applies to both run modes, since `--run-cli` answers service requests too, and +the service logs a warning at every start while it is on. Off by default, and only `on`/`off` +parse. The service refuses to start with it on beside an `[apple]` or `[google]` section. + +With it on, anyone who can reach the service address can mint badges by sending a made-up receipt. +That is harmless only while this deployment signs with an issuer key released apps do not carry, so +they refuse what it signs — never turn it on where the key is one those apps trust. + ## Issuing codes Operators issue codes two ways: from the service's own command line in `--run-cli` mode, and from the diff --git a/apps/simplex-badge-service/badge_service.ini.example b/apps/simplex-badge-service/badge_service.ini.example index 00584fed41..26abb5aa7e 100644 --- a/apps/simplex-badge-service/badge_service.ini.example +++ b/apps/simplex-badge-service/badge_service.ini.example @@ -40,6 +40,25 @@ session_minutes = 60 waiting_seconds = 3 idle_seconds = 60 +; verifies App Store purchases offline. Omit the whole section and the service answers every App Store +; receipt provider_not_configured, which the app retries later. +; root_certificates lists, comma-separated, the roots trusted to sign receipts, each in DER. Today that +; is Apple Root CA - G3 as Apple publishes it: https://www.apple.com/certificateauthority/AppleRootCA-G3.cer +; When Apple adds a root, list it beside the old one rather than in its place. +;[apple] +;bundle_id = chat.simplex.app +;root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer + +; verifies Play purchases with the Google Play Developer API. Omit the whole section and the service answers +; every Play receipt provider_not_configured, which the app retries later. +; service_account_file is the JSON key Google issues for a service account with the Play Console +; permissions "View financial data, orders, and cancellation survey responses" and "Manage orders and +; subscriptions", to read and acknowledge purchases. Without the second, a purchase the app has not +; already acknowledged itself is answered internal until the permission is granted. Keep the file readable by this service alone. +;[google] +;package_name = chat.simplex.app +;service_account_file = /etc/simplex-badge-service/play-service-account.json + ; the key this service signs credentials with. index is the index clients verify against, so ; private_key has to be one whose public half already ships in the apps at that index: a fresh ; `simplex-chat badge keygen` pair is refused until it does. @@ -60,3 +79,9 @@ idle_seconds = 60 ;[group] ;display_name = SimpleX Badges ;description = Welcome to the badges group + +; local testing only, in both run modes: accepts any well-formed App Store or Play receipt without +; verifying it, so anyone who can reach this service can mint badges. The service refuses to start +; with this on beside an [apple] or [google] section. +[dev] +accept_unverified_store_receipts = off diff --git a/apps/simplex-badge-service/src/BadgeService/Catalog.hs b/apps/simplex-badge-service/src/BadgeService/Catalog.hs index b15185da68..85b678d8d5 100644 --- a/apps/simplex-badge-service/src/BadgeService/Catalog.hs +++ b/apps/simplex-badge-service/src/BadgeService/Catalog.hs @@ -11,6 +11,8 @@ module BadgeService.Catalog priceOffer, catalogCurrency, defaultCatalog, + StoreProduct (..), + storeProduct, ) where @@ -21,7 +23,7 @@ import Data.Word (Word32, Word64, Word8) import Simplex.Chat.Badges (BadgeType (..)) import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..)) import Simplex.Chat.Badges.Types (BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..)) -import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), PaymentProvider (..)) data OfferInvalid = OIZeroMonths | OIFreeMonthsExceedTerm | OIDiscountTooLarge | OIAmountUnsellable deriving (Eq, Show) @@ -133,3 +135,20 @@ defaultCatalog seededAt = (prices, offers) status = BISActive, createdAt = seededAt } + +data StoreProduct = StoreProduct + { badgeType :: BadgeType, + months :: Int + } + deriving (Eq, Show) + +-- | A receipt proves only the product paid for, so the badge comes from here, never from the client. +-- Mirrors the one-time SKUs of BadgeStore.swift and .kt; subscriptions wait until renewals add months. +-- Not part of the seeded catalog above, which exists to pin prices this service sets: a store sets its own. +storeProduct :: PaymentProvider -> Text -> Maybe StoreProduct +storeProduct provider productId = case (provider, productId) of + (PPApple, "BADGE_SUPPORTER_01") -> Just $ StoreProduct BTSupporter 1 + (PPApple, "BADGE_LEGEND_01") -> Just $ StoreProduct BTLegend 1 + (PPGoogle, "badge_supporter_01") -> Just $ StoreProduct BTSupporter 1 + (PPGoogle, "badge_legend_01") -> Just $ StoreProduct BTLegend 1 + _ -> Nothing diff --git a/apps/simplex-badge-service/src/BadgeService/Config.hs b/apps/simplex-badge-service/src/BadgeService/Config.hs index 9e33f4459d..72e774fdac 100644 --- a/apps/simplex-badge-service/src/BadgeService/Config.hs +++ b/apps/simplex-badge-service/src/BadgeService/Config.hs @@ -10,6 +10,8 @@ module BadgeService.Config SpeedPolicy (..), speedPolicyName, PollConfig (..), + AppleStoreConfig (..), + PlayStoreConfig (..), BadgeIssuerKey (..), GroupConfig (..), ServiceConfig (..), @@ -22,11 +24,13 @@ where import qualified Control.Exception as E import BadgeService.Log (logWarn) -import Control.Monad (mfilter) +import Control.Monad (mfilter, when) import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile) import qualified Data.ByteString.Char8 as B import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections) -import Data.Maybe (fromMaybe) +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as TIO @@ -91,6 +95,20 @@ instance Show StripeConfig where data PollConfig = PollConfig {pWaitingSeconds :: Int, pIdleSeconds :: Int} deriving (Eq, Show) +data AppleStoreConfig = AppleStoreConfig + { aBundleId :: Text, + aRootCertificates :: NonEmpty FilePath + } + deriving (Eq, Show) + +data PlayStoreConfig = PlayStoreConfig + { gPackageName :: Text, + gServiceAccountFile :: FilePath, + gApiHost :: Text, + gTokenUrl :: Text + } + deriving (Eq, Show) + data BadgeIssuerKey = BadgeIssuerKey { keyIdx :: Int, secretKey :: BBSSecretKey @@ -112,8 +130,12 @@ data ServiceConfig = ServiceConfig btcpay :: Maybe BTCPayConfig, stripe :: Maybe StripeConfig, poll :: PollConfig, + appleStore :: Maybe AppleStoreConfig, + playStore :: Maybe PlayStoreConfig, issuer :: Maybe BadgeIssuerKey, - group :: Maybe GroupConfig + group :: Maybe GroupConfig, + -- Local testing only; anyone who can reach the service can mint badges with any well-formed receipt. + devAcceptUnverifiedStoreReceipts :: Bool } deriving (Eq, Show) @@ -126,6 +148,12 @@ defaultStripeHost = "https://api.stripe.com" defaultSessionMinutes :: Int defaultSessionMinutes = 60 +defaultPlayApiHost :: Text +defaultPlayApiHost = "https://androidpublisher.googleapis.com" + +defaultPlayTokenUrl :: Text +defaultPlayTokenUrl = "https://oauth2.googleapis.com/token" + -- | At most one day, so a card order expires long before the poller stops checking it at 72 hours. minSessionMinutes, maxSessionMinutes :: Int minSessionMinutes = 1 @@ -164,7 +192,10 @@ knownSettings = ("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]), ("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]), ("poll", ["waiting_seconds", "idle_seconds"]), + ("apple", ["bundle_id", "root_certificates"]), + ("google", ["package_name", "service_account_file"]), ("group", ["display_name", "description"]), + ("dev", ["accept_unverified_store_receipts"]), ("issuer", ["index", "private_key"]) ] @@ -196,14 +227,22 @@ parseConfig ini = do grp <- groupSection pWaitingSeconds <- cadence "waiting_seconds" 3 pIdleSeconds <- cadence "idle_seconds" 60 + apple <- appleSection + google <- googleSection + devUnverifiedReceipts <- bool "dev" "accept_unverified_store_receipts" False + when (devUnverifiedReceipts && (isJust apple || isJust google)) $ + Left "[dev] accept_unverified_store_receipts must be off where [apple] or [google] verifies store receipts" pure ServiceConfig { listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor}, btcpay = btc, stripe = str, poll = PollConfig {pWaitingSeconds, pIdleSeconds}, + appleStore = apple, + playStore = google, issuer = iss, - group = grp + group = grp, + devAcceptUnverifiedStoreReceipts = devUnverifiedReceipts } where hasSection s = s `elem` sections ini @@ -295,6 +334,22 @@ parseConfig ini = do sSessionMinutes <- sessionMinutes let sHost = defaultStripeHost pure (Just StripeConfig {sSecretKey, sPublishableKey, sWebhookSecret, sSessionMinutes, sHost}) + appleSection + | not (hasSection "apple") = Right Nothing + | otherwise = do + aBundleId <- required "apple" "bundle_id" + aRootCertificates <- rootCertificates + pure (Just AppleStoreConfig {aBundleId, aRootCertificates}) + rootCertificates = do + v <- required "apple" "root_certificates" + maybe (Left "apple.root_certificates must list at least one file") Right $ + L.nonEmpty $ map T.unpack $ filter (not . T.null) $ map T.strip $ T.splitOn "," v + googleSection + | not (hasSection "google") = Right Nothing + | otherwise = do + gPackageName <- required "google" "package_name" + gServiceAccountFile <- T.unpack <$> required "google" "service_account_file" + pure (Just PlayStoreConfig {gPackageName, gServiceAccountFile, gApiHost = defaultPlayApiHost, gTokenUrl = defaultPlayTokenUrl}) sessionMinutes = do v <- num "stripe" "session_minutes" defaultSessionMinutes if v >= minSessionMinutes && v <= maxSessionMinutes diff --git a/apps/simplex-badge-service/src/BadgeService/Poller.hs b/apps/simplex-badge-service/src/BadgeService/Poller.hs index c22df44ddb..989faa4396 100644 --- a/apps/simplex-badge-service/src/BadgeService/Poller.hs +++ b/apps/simplex-badge-service/src/BadgeService/Poller.hs @@ -30,7 +30,7 @@ where import BadgeService.Config (PollConfig (..)) import BadgeService.Orders (decide, settleOrder) import BadgeService.Providers (ListPass (..), PaymentSignal (..), Provider (..), ProviderError (..), Received (..), expiresItself, settleWindow) -import BadgeService.Store.Invoices (InvoiceRow (..), OverdueInvoice (..), expireOverdue, getInvoiceByProviderRef, overdueInvoices, providerText, unpaidRefs) +import BadgeService.Store.Invoices (InvoiceRow (..), OverdueInvoice (..), expireOverdue, getInvoiceByProviderRef, overdueInvoices, unpaidRefs) import BadgeService.Waiters (Waiters, publish, waitingCount, waitingCountSTM) import Control.Concurrent.STM import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try) @@ -47,6 +47,7 @@ import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCu import Numeric.Natural (Natural) import Simplex.Chat.PaymentService.Types (InvoiceStatus (..), PaymentProvider (..)) import Simplex.Messaging.Agent.Store.Common (DBStore) +import Simplex.Messaging.Encoding.String (textEncode) import Simplex.Messaging.Util (tshow) -- | Allows for our clock running ahead of the provider's; an expired invoice can still be marked paid. @@ -145,7 +146,7 @@ readsPerPass = 25 coveringProvider :: PollerEnv -> UTCTime -> (Text, Text) -> IO (Maybe Provider) coveringProvider env@PollerEnv {peProviders} now (provider, ref) = - case find ((== provider) . providerText . pProvider) peProviders of + case find ((== provider) . textEncode . pProvider) peProviders of Just p -> pure (Just p) Nothing -> do due <- dueToWarn env now ("no provider for " <> provider) @@ -309,7 +310,7 @@ reportStrangers env provider now = \case home = case provider of PPCrypto -> "BTCPay store" PPStripe -> "Stripe account" - other -> providerText other <> " account" + other -> textEncode other <> " account" dueToWarn :: PollerEnv -> UTCTime -> Text -> IO Bool dueToWarn PollerEnv {peSkipped} now reason = atomically $ do diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 0e189a3489..72389c8cc7 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -7,22 +7,26 @@ module BadgeService.Service ( ServiceState (..), newServiceState, + configureStoreVerifier, welcomeGetOpts, checkIssuerKey, badgeService, badgeServiceCLI, badgeServiceResponse, badgeErrorRetryAfter, + shownServiceRequest, + survive, IssueCodeOpts (..), issueBadgeCode, ) where -import BadgeService.Catalog (defaultCatalog) +import BadgeService.Catalog (StoreProduct (..), defaultCatalog, storeProduct) import BadgeService.Codes (issueFailedText, issueOneCode, singleUse) -import BadgeService.Config (BadgeIssuerKey (..), ServiceConfig (..), readServiceConfig) +import BadgeService.Config (AppleStoreConfig (..), BadgeIssuerKey (..), PlayStoreConfig (..), ServiceConfig (..), readServiceConfig) import BadgeService.Group (GroupEvent, ensureManagedGroup, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane) import BadgeService.Group.Command (badgeTypeP, codeP, maxMonths, textTokenP) +import BadgeService.Log (logError, logInfo, logWarn) import BadgeService.Options import BadgeService.Poller (newPollerEnv, newReadHints, runPoller) import BadgeService.Providers.BTCPay (btcpayProvider) @@ -30,11 +34,14 @@ import BadgeService.Providers.Stripe (stripeProvider) import BadgeService.Store import BadgeService.Store.Invoices (seedCatalog, truncateToSecond) import BadgeService.Store.Migrate (runBadgeServiceMigrations) +import BadgeService.StoreReceipts +import BadgeService.StoreReceipts.Apple (readAppleRoot, verifyAppleTransaction) +import BadgeService.StoreReceipts.Google (playStoreVerifier) +import BadgeService.StoreReceipts.Mock (mockStoreVerifier) import BadgeService.Waiters (Waiters, newWaiters) import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener) import Control.Applicative (optional) import Control.Concurrent.STM -import BadgeService.Log (logError, logInfo) import Control.Monad import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as J @@ -43,6 +50,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import Data.Either (fromRight) import Data.Functor (($>), (<&>)) +import Data.Int (Int64) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, maybeToList) import qualified Data.Text as T @@ -58,14 +66,17 @@ import Simplex.Chat.Bot.Store (withDB, withDB') import Simplex.Chat.Controller import Simplex.Chat.Core (sendChatCmd, simplexChatCore) import Simplex.Chat.Options (printDbOpts) +import Simplex.Chat.PaymentService (ServicePayment (..)) +import Simplex.Chat.PaymentService.Types (StoreTransactionRef (..)) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types (AgentInvId (..), User (..)) import Simplex.Messaging.Agent.Store.Common (DBStore) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsPublicKey) import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (catchOwn', raceAny_, safeDecodeUtf8, tshow) import Simplex.Messaging.Version (isCompatible) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) @@ -73,15 +84,18 @@ import System.Exit (exitFailure) data ServiceState = ServiceState { serviceCC :: TMVar ChatController, serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object), - groupEventQ :: TQueue GroupEvent + groupEventQ :: TQueue GroupEvent, + storeVerifier :: StoreVerifier } +-- | With no store verifier, which badge_service.ini configures, every store receipt not already credited +-- is answered provider_not_configured. newServiceState :: IO ServiceState newServiceState = do serviceCC <- newEmptyTMVarIO serviceRequestQ <- newTQueueIO groupEventQ <- newTQueueIO - pure ServiceState {serviceCC, serviceRequestQ, groupEventQ} + pure ServiceState {serviceCC, serviceRequestQ, groupEventQ, storeVerifier = noStoreVerifier} welcomeGetOpts :: IO BadgeServiceOpts welcomeGetOpts = do @@ -120,11 +134,37 @@ readConfigOrExit path = Left e -> putStrLn (path <> ": " <> e) >> exitFailure Right sc -> pure sc +-- | The config refuses the dev flag beside a real verifier, so only one of them applies. +configureStoreVerifier :: Maybe ServiceConfig -> ServiceState -> IO ServiceState +configureStoreVerifier serviceCfg env@ServiceState {storeVerifier = v} = case serviceCfg of + Just ServiceConfig {devAcceptUnverifiedStoreReceipts = True} -> do + logWarn "[dev] accept_unverified_store_receipts is on: any store receipt is accepted without verification" + pure env {storeVerifier = mockStoreVerifier} + Just ServiceConfig {appleStore, playStore} -> do + apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificates} -> + (\roots -> verifyAppleTransaction roots aBundleId) <$> mapM (orExit . readAppleRoot) aRootCertificates + google <- forM playStore $ orExit . playStoreVerifier + forM_ appleStore $ \AppleStoreConfig {aBundleId} -> logInfo $ "verifying App Store receipts for " <> aBundleId + forM_ playStore $ \PlayStoreConfig {gPackageName} -> logInfo $ "verifying Play purchases for " <> gPackageName + pure + env + { storeVerifier = + v + { verifyApple = maybe (verifyApple v) Just apple, + verifyGoogle = maybe (verifyGoogle v) (Just . fst) google, + acknowledgeGoogle = maybe (acknowledgeGoogle v) (Just . snd) google + } + } + Nothing -> pure env + where + orExit = (>>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure) + badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO () badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do serviceCfg <- traverse readConfigOrExit serviceConfigFile key <- requireIssuerKey opts serviceCfg cfg waiters <- newWaiters + requestEnv <- configureStoreVerifier serviceCfg env let groupCfg = serviceCfg >>= \ServiceConfig {group} -> group trackerQ_ = groupEventQ env <$ groupCfg readEvents cc = @@ -139,7 +179,7 @@ badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do lanes <- maybe (pure []) (serviceLanes waiters cc) serviceCfg -- The group is resolved before the other lanes start, so a failed lookup exits at once rather than waiting for them to end. groupLane_ <- forM groupCfg $ \gc -> runGroupLane cc (groupEventQ env) <$> ensureManagedGroup cc gc - raceAny_ $ processQueuedRequests key trackerQ_ env : maybeToList groupLane_ <> lanes + raceAny_ $ processQueuedRequests key trackerQ_ requestEnv : maybeToList groupLane_ <> lanes chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, @@ -175,13 +215,12 @@ badgeServiceCLI :: BadgeServiceOpts -> IO () badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do serviceCfg <- traverse readConfigOrExit serviceConfigFile key <- requireIssuerKey opts serviceCfg terminalChatConfig - env <- newServiceState - let eventHook _cc ev = do - case ev of - Right (CEvtServiceRequest u reqId sigKey reqData) -> - atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) - _ -> pure () - pure ev + env <- newServiceState >>= configureStoreVerifier serviceCfg + let eventHook _cc = \case + Right (CEvtServiceRequest u reqId sigKey reqData) -> do + atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) + pure $ Right $ CEvtServiceRequest u reqId sigKey (shownServiceRequest reqData) + ev -> pure ev chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, @@ -194,6 +233,12 @@ badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do processQueuedRequests key Nothing env ] +-- | The terminal prints each request, and requests carry codes and store receipts, which are bearer secrets. +shownServiceRequest :: J.Object -> J.Object +shownServiceRequest reqData = case KM.lookup "request" reqData of + Just (J.Object cmd) | Just t <- KM.lookup "type" cmd -> KM.singleton "request" $ J.object ["type" J..= t] + _ -> KM.empty + badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand) badgeCmdHook cc = \case CustomChatCommand cmd -> Left <$> runBadgeCmd cc cmd @@ -244,9 +289,27 @@ issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = processQueuedRequests :: BadgeIssuerKey -> Maybe (TQueue GroupEvent) -> ServiceState -> IO () processQueuedRequests key trackerQ_ env = do cc <- atomically $ readTMVar $ serviceCC env - forever $ do - (u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env - handleServiceRequest key cc trackerQ_ u reqId sigKey reqData + -- a Play hand-over can wait two verifyTimeouts on Play, so it waits on a lane of its own + playQ <- newTQueueIO + let answer (u, reqId, sigKey, reqData) = handleServiceRequest key (storeVerifier env) cc trackerQ_ u reqId sigKey reqData + route req@(_, _, _, reqData) + | presentsPlayReceipt reqData = atomically $ writeTQueue playQ req + | otherwise = answer req + lane q act = forever $ do + req@(_, reqId, _, _) <- atomically $ readTQueue q + survive ("request " <> safeDecodeUtf8 (strEncode reqId)) $ act req + raceAny_ [lane (serviceRequestQ env) route, lane playQ answer] + +-- | Only what creates a purchase leaves the main lane: an append to an existing ledger is guarded by nothing but that lane being serial. +presentsPlayReceipt :: J.Object -> Bool +presentsPlayReceipt reqData = case J.fromJSON (J.Object reqData) of + J.Success BadgeServiceRequest {request = BSCPurchaseBadge {payment = SPGoogle {}, upgrade = Nothing}} -> True + _ -> False + +-- | Cancellation is rethrown, since that is how the race stops this thread. The +-- exception itself is not logged: it can quote the request, which carries codes and store receipts. +survive :: T.Text -> IO () -> IO () +survive what action = action `catchOwn'` \_ -> logError $ "badge service: " <> what <> " failed; the next one will be handled" badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO () badgePreStartHook opts ChatController {config, chatStore} = @@ -263,11 +326,13 @@ badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc void $ atomically $ tryPutTMVar (serviceCC env) cc -handleServiceRequest :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () -handleServiceRequest key cc trackerQ_ User {userId} reqId sigKey reqData = do +handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () +handleServiceRequest key verifier cc trackerQ_ User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) logInfo $ "badge service request " <> reqIdT - resp <- badgeServiceResponse key cc trackerQ_ sigKey reqData + resp <- + badgeServiceResponse key verifier cc trackerQ_ sigKey reqData `catchOwn'` \_ -> + logError ("badge service request " <> reqIdT <> " failed") $> errorResponse BSEInternal sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e @@ -289,8 +354,8 @@ badgeErrorRetryAfter = \case -- | The agent verified the signature, so sigKey is a key the sender holds; a differing purchaseKey would let a client claim a purchase it cannot sign for. -badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse -badgeServiceResponse key cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object reqData) of +badgeServiceResponse :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse +badgeServiceResponse key verifier cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object reqData) of J.Error _ -> pure $ errorResponse BSEBadRequest J.Success BadgeServiceRequest {version, purchaseKey, request} | not (version `isCompatible` supportedBadgeServiceVRange) -> pure $ errorResponse BSEUnsupportedVersion @@ -299,10 +364,16 @@ badgeServiceResponse key cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of Just k -> redeemCode key cc trackerQ_ k masterKey code Nothing -> pure $ errorResponse BSEBadRequest + BSCPurchaseBadge {masterKey, payment, upgrade} + | Just receipt_ <- toStoreReceipt verifier payment -> case (purchaseKey, upgrade) of + (Just k, Nothing) -> either storeRefusalResponse (purchaseWithReceipt key cc k masterKey) receipt_ + -- store upgrades are not built, and ignoring one would credit its months at the discounted price + (Just _, Just _) -> pure $ errorResponse BSEBadRequest + (Nothing, _) -> pure $ errorResponse BSEBadRequest BSCIssueBadge {balance} -> case purchaseKey of Just k -> issueBadgeCmd key cc k balance Nothing -> pure $ errorResponse BSEBadRequest - -- Every command but redeemBadgeCode needs a key the service already knows; that one creates the purchase, so its key is unknown on a first redemption. + -- Every command but redeemBadgeCode and purchaseBadge from a store needs a key the service already knows; those two create the purchase, so their key is unknown on a first purchase. _ -> case purchaseKey of Nothing -> pure $ errorResponse BSEUnsupportedVersion Just k -> @@ -339,31 +410,21 @@ redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode withDB "getBadgeCode" cc (readCode now code) >>= \case Left _ -> pure $ errorResponse BSEInternal Right (Left resp) -> pure resp - Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> do - (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc - -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the - -- months it adds are counted from a start already in the past - let granted = grantEntry now grantUuid months SCCode $ emptyEntry now badgeType - -- A grant of at least one month starting now always has a month to issue. - case issueEntry now issueUuid granted of - Nothing -> pure $ errorResponse BSEInternal - Just issued -> credentialForEntry key masterKey issued >>= \case - Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal - Right signed -> do - -- If the code was revoked or used up while signing, the claim fails. Read the code again to tell the client why. - r <- withDB "writeCodeRedemption" cc $ \db -> - liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case - Nothing -> - readCode now code db >>= \case - Left resp -> pure (resp, False) - Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, False) - Just purchaseId -> liftIO $ do - appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed - entries_ <- getLedgerEntries db purchaseId 0 - pure (maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_, True) - let (resp, claimed) = fromRight (errorResponse BSEInternal, False) r - when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code - pure resp + Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> + signFirstMonth key cc masterKey badgeType months SCCode now >>= \case + Left resp -> pure resp + Right firstMonth -> do + -- If the code was revoked or used up while signing, the claim fails. Read the code again to tell the client why. + r <- withDB "writeCodeRedemption" cc $ \db -> + liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case + Nothing -> + readCode now code db >>= \case + Left resp -> pure (resp, False) + Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, False) + Just purchaseId -> liftIO $ (,True) <$> firstMonthResponse db purchaseId Nothing firstMonth + let (resp, claimed) = fromRight (errorResponse BSEInternal, False) r + when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code + pure resp where readCode now code db = liftIO $ getBadgeCode db (badgeCodeHash code) >>= \case @@ -387,6 +448,99 @@ redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode | maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired | otherwise -> pure $ Right c +-- | Every refusal is answered before anything is written, so it leaves the receipt unclaimed; and +-- nothing is written until the credential is signed. +purchaseWithReceipt :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> StoreReceipt -> IO BadgeServiceResponse +purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = providerRef}, verifyReceipt, acknowledgeReceipt} = + withDB' "getStorePayment" cc (\db -> getStorePaymentCredit db provider providerRef) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right credit + -- only the key it credited can ask, and it is told only what it was given, so the store is not asked + | creditedBy credit -> answerCredit credit + | otherwise -> + verifyReceipt >>= \case + Left refusal -> storeRefusalResponse refusal + -- the claim was read from the evidence before it was verified, so it must name the transaction the store vouched for + Right VerifiedStoreTransaction {transactionRef} + | transactionRef /= providerRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed" + Right VerifiedStoreTransaction {quantity} + | quantity /= 1 -> storeRefusalResponse $ SRVerifierFailed $ "verified a quantity of " <> tshow quantity + Right VerifiedStoreTransaction {testPurchase = True} -> storeRefusalResponse SRTestPurchase + -- another key's credit is told only once the store vouched for the receipt, or it would reveal which transactions were credited + Right tx -> case credit of + Uncredited -> newPurchase tx + _ -> answerCredit credit + where + creditedBy = \case + Credited CreditedPurchase {purchaseKey = k} -> k == purchaseKey + _ -> False + answerCredit credit = + withDB' "answerStoreCredit" cc (\db -> creditedResponse db purchaseKey credit) <&> \case + Right (Left resp) -> resp + _ -> errorResponse BSEInternal + -- the product is read only for a receipt not yet credited, so retiring it leaves its replays answered + newPurchase tx@VerifiedStoreTransaction {productId, paid} = case storeProduct provider productId of + Nothing -> pure $ errorResponse BSEProductUnavailable + Just StoreProduct {badgeType, months} -> + maybe (pure $ Right ()) ($ tx) acknowledgeReceipt >>= \case + Left refusal -> storeRefusalResponse refusal + Right () -> do + now <- badgeNow cc + signFirstMonth key cc masterKey badgeType months (SCPayment Nothing) now >>= \case + Left resp -> pure resp + Right firstMonth -> do + paymentId <- randomId cc + r <- withDB "writeStorePurchase" cc $ \db -> + liftIO (createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType} now) >>= \case + -- Credited to another key, or to this one by a request that ran alongside it, while signing. + Nothing -> + liftIO (getStorePaymentCredit db provider providerRef >>= creditedResponse db purchaseKey) >>= \case + Left resp -> pure resp + Right () -> logError ("badge service: a payment for " <> providerRef <> " exists but funds no purchase") $> errorResponse BSEInternal + Just purchaseId -> liftIO $ firstMonthResponse db purchaseId (Just paymentId) firstMonth + pure $ fromRight (errorResponse BSEInternal) r + +-- | A forged receipt and a refunded one are both receipt_invalid, so the client learns nothing a +-- forger could iterate on. A test purchase is named, since the device that made it already knows. +storeRefusalResponse :: StoreRefusal -> IO BadgeServiceResponse +storeRefusalResponse = \case + SRInvalid reason -> logInfo ("store receipt refused: " <> reason) $> errorResponse BSEReceiptInvalid + SRTestPurchase -> logInfo "store receipt refused: test purchase" $> errorResponse BSETestPurchase + SRPending -> pure $ errorResponse BSEPaymentPending + SRUnreachable reason -> logWarn ("store unreachable: " <> reason) $> errorResponse BSEProviderUnavailable + SRVerifierFailed reason -> logError ("store receipt not verified: " <> reason) $> errorResponse BSEInternal + SRNotConfigured -> logWarn "store receipt refused: no verifier for this store is configured" $> errorResponse BSEProviderNotConfigured + +creditedResponse :: DB.Connection -> C.PublicKeyEd25519 -> FundingCredit -> IO (Either BadgeServiceResponse ()) +creditedResponse db purchaseKey = \case + Uncredited -> pure $ Right () + CreditedUnreadable -> pure $ Left $ errorResponse BSEInternal + Credited CreditedPurchase {purchaseKey = k, badgePurchaseId, credential} + | k /= purchaseKey -> pure $ Left $ errorResponse BSEReceiptUsed + | otherwise -> + maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) + <$> getLedgerEntries db badgePurchaseId 0 + +signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either BadgeServiceResponse (StatementEntry, (StatementEntry, BadgeCredential))) +signFirstMonth key cc masterKey badgeType months credit now = do + (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc + -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the + -- months it adds are counted from a start already in the past + let granted = grantEntry now grantUuid months credit $ emptyEntry now badgeType + -- A grant of at least one month starting now always has a month to issue. + case issueEntry now issueUuid granted of + Nothing -> pure $ Left $ errorResponse BSEInternal + Just issued -> + credentialForEntry key masterKey issued >>= \case + Left e -> logError ("badge service signing failed: " <> T.pack e) $> Left (errorResponse BSEInternal) + Right signed -> pure $ Right (granted, signed) + +firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> (StatementEntry, (StatementEntry, BadgeCredential)) -> IO BadgeServiceResponse +firstMonthResponse db purchaseId creditPaymentId_ (granted, signed) = do + appendLedgerPlan db purchaseId creditPaymentId_ [granted] $ Just $ issuanceAfter granted signed + entries_ <- getLedgerEntries db purchaseId 0 + pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ + -- | The purchase is reached through the verified signer key and no other way. issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO BadgeServiceResponse issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do @@ -414,7 +568,7 @@ issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do writeIssued purchaseId tip rows t issuance_ = do r <- withDB "issueBadge" cc $ \db -> liftIO $ do tip' <- getLedgerTip db purchaseId - when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId rows issuance_ + when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId Nothing rows issuance_ issueResponse db purchaseId t pure $ fromRight (errorResponse BSEInternal) r -- Only the asserted entry's identity is read, never the months it claims. diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index b51e47bdad..6d238e78f4 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -4,12 +4,16 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} module BadgeService.Store ( IssuedCode (..), KeyRedemption (..), KeyPurchase (..), + FundingCredit (..), + CreditedPurchase (..), NewCodePurchase (..), + NewStorePurchase (..), ServicePurchase (..), ManagedGroup (..), getManagedGroup, @@ -18,6 +22,7 @@ module BadgeService.Store markOwnerBootstrapped, getBadgeCode, getCodePurchaseForKey, + getStorePaymentCredit, purchaseKeyExists, getPurchaseByKey, getLedgerTip, @@ -26,6 +31,7 @@ module BadgeService.Store getCurrentIssuance, appendLedgerPlan, createCodePurchase, + createStorePurchase, insertBadgeCode, setCodeGroupItem, CodeTracker (..), @@ -36,7 +42,7 @@ module BadgeService.Store ) where -import BadgeService.Store.Invoices (executeChanging) +import BadgeService.Store.Invoices (executeChanging, paymentStatusText) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB @@ -46,12 +52,14 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType) import Simplex.Chat.Badges.Ledger -import Simplex.Chat.Badges.Service (StatementEntry (..)) +import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementEntry (..), StatementEntryType (..)) import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), InvoiceId (..), PaymentProvider, PaymentStatus (..)) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (textEncode) import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow') #if defined(dbPostgres) @@ -83,6 +91,17 @@ data KeyPurchase = KeyPurchase credential :: BadgeCredential } +data FundingCredit + = Uncredited + | Credited CreditedPurchase + | CreditedUnreadable + +data CreditedPurchase = CreditedPurchase + { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, + credential :: BadgeCredential + } + data NewCodePurchase = NewCodePurchase { badgeCodeId :: Int64, purchaseKey :: C.PublicKeyEd25519, @@ -90,6 +109,16 @@ data NewCodePurchase = NewCodePurchase badgeType :: BadgeType } +data NewStorePurchase = NewStorePurchase + { paymentId :: Text, + provider :: PaymentProvider, + providerRef :: Text, + paid :: Maybe (CurrencyAmount, Text), + purchaseKey :: C.PublicKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType + } + data ServicePurchase = ServicePurchase { badgePurchaseId :: Int64, masterKey :: BadgeMasterKey, @@ -171,7 +200,29 @@ getCodePurchaseForKey db badgeCodeId key = toRedemption (badgePurchaseId, credential_) = case decodeCredential =<< credential_ of Just credential -> KeyRedeemed KeyPurchase {badgePurchaseId, credential} Nothing -> KeyRedeemedUnreadable - decodeCredential (Binary bs) = J.decodeStrict' bs + +getStorePaymentCredit :: DB.Connection -> PaymentProvider -> Text -> IO FundingCredit +getStorePaymentCredit db provider providerRef = + maybeFirstRow' Uncredited toCredit $ + DB.query + db + [sql| + SELECT p.badge_purchase_id, p.purchase_key, i.credential + FROM sx_badge_service_payments pay + JOIN sx_badge_service_badge_purchases p ON p.payment_id = pay.payment_id + LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id + WHERE pay.provider = ? AND pay.provider_ref = ? + ORDER BY i.period_end DESC + LIMIT 1 + |] + (textEncode provider, providerRef) + where + toCredit (badgePurchaseId, purchaseKey, credential_) = case decodeCredential =<< credential_ of + Just credential -> Credited CreditedPurchase {badgePurchaseId, purchaseKey, credential} + Nothing -> CreditedUnreadable + +decodeCredential :: Binary ByteString -> Maybe BadgeCredential +decodeCredential (Binary bs) = J.decodeStrict' bs purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool purchaseKeyExists db key = @@ -199,11 +250,12 @@ getLedgerTip db purchaseId = DB.query db [sql| - SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, - entry_type, entry_credit_type, entry_debit_type, service_created_at - FROM sx_badge_service_badge_ledger - WHERE badge_purchase_id = ? - ORDER BY entry_id DESC + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id + FROM sx_badge_service_badge_ledger l + LEFT JOIN sx_badge_service_payments pay ON pay.payment_id = l.payment_id + WHERE l.badge_purchase_id = ? + ORDER BY l.entry_id DESC LIMIT 1 |] (Only purchaseId) @@ -223,18 +275,19 @@ getLedgerEntries db purchaseId afterEntryId = <$> DB.query db [sql| - SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, - entry_type, entry_credit_type, entry_debit_type, service_created_at - FROM sx_badge_service_badge_ledger - WHERE badge_purchase_id = ? AND entry_id > ? - ORDER BY entry_id + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id + FROM sx_badge_service_badge_ledger l + LEFT JOIN sx_badge_service_payments pay ON pay.payment_id = l.payment_id + WHERE l.badge_purchase_id = ? AND l.entry_id > ? + ORDER BY l.entry_id |] (purchaseId, afterEntryId) -toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) -> Maybe StatementEntry -toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) = +toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) :. (Maybe Text, Maybe Text) -> Maybe StatementEntry +toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) :. (paymentId_, invoiceId_)) = (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType}) - <$> entryTypeFromColumns entryType_ credit_ debit_ + <$> entryTypeFromColumns ((InvoiceId <$> invoiceId_) <$ paymentId_) entryType_ credit_ debit_ getCurrentIssuance :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe BadgeCredential) getCurrentIssuance db purchaseId now = do @@ -252,9 +305,10 @@ getCurrentIssuance db purchaseId now = do [Only (Binary bs)] -> J.decodeStrict' bs _ -> Nothing --- TODO write the reference columns (payment_id, charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today. -appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () -appendLedgerPlan db purchaseId rows issuance_ = do +-- | creditPaymentId_ names the payment behind a payment credit among the rows. +-- TODO write the other reference columns (charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today. +appendLedgerPlan :: DB.Connection -> Int64 -> Maybe Text -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () +appendLedgerPlan db purchaseId creditPaymentId_ rows issuance_ = do mapM_ appendRow rows case issuance_ of Nothing -> pure () @@ -273,15 +327,18 @@ appendLedgerPlan db purchaseId rows issuance_ = do where appendRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, createdAt, entryType} = do let (entryTypeT, creditType, debitType) = entryTypeColumns entryType + paymentId_ = case entryType of + SECredit SCPayment {} -> creditPaymentId_ + _ -> Nothing DB.execute db [sql| INSERT INTO sx_badge_service_badge_ledger (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, - balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type, payment_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType)) + ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType, paymentId_)) insertedRowId db -- The claim takes one use before adding the purchase, so a concurrent revoke or redemption waits on this row and sees the new count. @@ -306,6 +363,32 @@ createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = Bad (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) Just <$> insertedRowId db +-- | In the transaction of the credential rows. The payment's unique (provider, provider_ref) is the +-- claim: a transaction presented twice at once, or under another key, inserts nothing the second time. +createStorePurchase :: DB.Connection -> NewStorePurchase -> UTCTime -> IO (Maybe Int64) +createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do + claimed <- + executeChanging + db + [sql| + INSERT INTO sx_badge_service_payments (payment_id, provider, provider_ref, amount, currency, status, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT (provider, provider_ref) DO NOTHING + |] + (paymentId, textEncode provider, providerRef, (\(CurrencyAmount a) -> a) . fst <$> paid, snd <$> paid, paymentStatusText PSSettled, now, now) + if claimed == 0 + then pure Nothing + else do + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_purchases + (purchase_key, master_key, initial_badge_type, current_badge_type, status, payment_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, paymentId, now, now) + Just <$> insertedRowId db + -- | Revoked and AlreadyRevoked carry the code id, so the caller can retire the code's group tracker, -- or repair one an earlier revoke left live. data RevokeResult = Revoked Int64 | AlreadyRevoked Int64 | AlreadyRedeemed | NoSuchCode diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs b/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs index a3e177400e..57f9341308 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs @@ -17,7 +17,6 @@ module BadgeService.Store.Invoices getInvoice, getInvoiceByProviderRef, unpaidRefs, - providerText, codeHashExists, OverdueInvoice (..), overdueInvoices, @@ -59,7 +58,7 @@ import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), PaymentStatus (..), ServicePaymentDestination (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withConnection, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Encoding.String (textEncode) +import Simplex.Messaging.Encoding.String (textDecode, textEncode) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) #if defined(dbPostgres) @@ -290,25 +289,6 @@ qSeedBadgeOffer = "INSERT INTO @badge_offers (offer_id, price_id, months, free_months, discount, status, created_at) " <> "VALUES (?,?,?,?,?,?,?) ON CONFLICT (offer_id) DO NOTHING" -providerText :: PaymentProvider -> Text -providerText = \case - PPApple -> "apple" - PPGoogle -> "google" - PPStripe -> "stripe" - PPCrypto -> "crypto" - PPCode -> "code" - PPReceipt -> "receipt" - -textToProvider :: Text -> Maybe PaymentProvider -textToProvider = \case - "apple" -> Just PPApple - "google" -> Just PPGoogle - "stripe" -> Just PPStripe - "crypto" -> Just PPCrypto - "code" -> Just PPCode - "receipt" -> Just PPReceipt - _ -> Nothing - cryptoCurrencyText :: CryptoCurrency -> Text cryptoCurrencyText CCBtc = "btc" cryptoCurrencyText CCXmr = "xmr" @@ -380,7 +360,7 @@ mkInvoiceRow :. (cryptoAmt, expiresAt, statusTxt, createdAt) :. (pAmount, pCryptoPaid, pCryptoDue, pPaidInFull, pStatus, pUpdatedAt) ) = do - provider <- note "invoices.provider" (textToProvider providerTxt) + provider <- note "invoices.provider" (textDecode providerTxt) status <- note "invoices.status" (textToInvoiceStatus statusTxt) destination <- note "invoice payment destination" (mkDestination url addr cryptoCur cryptoAmt) pure @@ -462,7 +442,7 @@ insertInvoiceRows db NewInvoice {..} = do DB.execute db qInsertInvoice - ( (invId, providerText niProvider, price, discountAmount, Nothing :: Maybe Word32, amount, niCurrency) + ( (invId, textEncode niProvider, price, discountAmount, Nothing :: Maybe Word32, amount, niCurrency) :. (url, addr, cryptoCur, cryptoAmt, expiresAt, invoiceStatusText ISOpen, createdAt, createdAt) ) DB.execute @@ -526,7 +506,7 @@ overdueInvoices st cutoff = withConnection st $ \db -> do rows <- DB.query db qOverdueInvoices (Only (truncateToSecond cutoff)) either (E.throwIO . StoreDecodeError) pure (traverse toOverdue rows) where - toOverdue (i, providerTxt, oiProviderRef, oiCreatedAt) = case textToProvider providerTxt of + toOverdue (i, providerTxt, oiProviderRef, oiCreatedAt) = case textDecode providerTxt of Just oiProvider -> Right OverdueInvoice {oiInvoiceId = InvoiceId i, oiProvider, oiProviderRef, oiCreatedAt} Nothing -> Left ("invoices.provider: " <> providerTxt) @@ -591,7 +571,7 @@ upsertPayment db InvoiceRow {irInvoiceId, irProvider, irProviderRef, irCurrency} DB.execute db qUpsertPayment - ( (invId, invId, providerText irProvider, irProviderRef, amount) + ( (invId, invId, textEncode irProvider, irProviderRef, amount) :. (irCurrency, cryptoAmount, cryptoDue, if paidInFull then 1 :: Int else 0, paymentStatusText status, at, at) ) where diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs index f096d3098d..92d3b5fd47 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -18,7 +18,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), - ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops) + ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops), + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -152,6 +153,44 @@ ALTER TABLE @badge_codes DROP COLUMN redeem_limit; DROP TABLE @group; |] +m20260925_badge_store_receipts :: Text +m20260925_badge_store_receipts = + withPrefix + servicePrefix + -- The unique provider_ref is what makes one store transaction fund one purchase: presented twice + -- at once, or under two keys, only one insert of its payment succeeds. + [r| +DROP INDEX @idx_payments_provider_ref; + +CREATE UNIQUE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); +|] + +down_m20260925_badge_store_receipts :: Text +down_m20260925_badge_store_receipts = + withPrefix + servicePrefix + [r| +DROP INDEX @idx_badge_ledger_payment; + +ALTER TABLE @badge_ledger DROP COLUMN payment_id; + +DROP INDEX @idx_badge_purchases_payment; + +ALTER TABLE @badge_purchases DROP COLUMN payment_id; + +DROP INDEX @idx_payments_provider_ref; + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index cd89859686..cd3d97f0ac 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -19,7 +19,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), - ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops) + ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops), + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -153,6 +154,44 @@ ALTER TABLE @badge_codes DROP COLUMN redeem_limit; DROP TABLE @group; |] +m20260925_badge_store_receipts :: Query +m20260925_badge_store_receipts = + withPrefix + servicePrefix + -- The unique provider_ref is what makes one store transaction fund one purchase: presented twice + -- at once, or under two keys, only one insert of its payment succeeds. + [sql| +DROP INDEX @idx_payments_provider_ref; + +CREATE UNIQUE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); +|] + +down_m20260925_badge_store_receipts :: Query +down_m20260925_badge_store_receipts = + withPrefix + servicePrefix + [sql| +DROP INDEX @idx_badge_ledger_payment; + +ALTER TABLE @badge_ledger DROP COLUMN payment_id; + +DROP INDEX @idx_badge_purchases_payment; + +ALTER TABLE @badge_purchases DROP COLUMN payment_id; + +DROP INDEX @idx_payments_provider_ref; + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs new file mode 100644 index 0000000000..2789e80c54 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs @@ -0,0 +1,129 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.StoreReceipts + ( VerifiedStoreTransaction (..), + StoreRefusal (..), + StoreVerifier (..), + StoreReceipt (..), + noStoreVerifier, + toStoreReceipt, + ) +where + +import Control.Exception (evaluate) +import Data.Char (isAlphaNum, isAscii, isAsciiLower, isAsciiUpper, isControl, isDigit, isPrint, isSpace) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionId, googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (CurrencyAmount, PaymentProvider (..), StoreTransactionRef (..)) +import Simplex.Messaging.Util (catchOwn', tshow) +import System.Timeout (timeout) + +-- | What a store vouches for about one completed transaction. +-- TODO [badges] PaymentFunding's PFApple and PFGoogle sketch the same thing and have no producer; +-- delete them, or build them out and reconcile. +data VerifiedStoreTransaction = VerifiedStoreTransaction + { transactionRef :: Text, -- from what was verified: Apple's transactionId, googlePurchaseRef of the token asked about + productId :: Text, + quantity :: Int, -- as the store reported it; the service refuses any but 1, as the apps never buy more + -- costs the buyer nothing: Apple's Sandbox, which a public TestFlight build buys in, and Google's license testers + testPurchase :: Bool, + paid :: Maybe (CurrencyAmount, Text), -- in minor units; Google's purchase record carries no price + alreadyAcknowledged :: Bool -- Play's record says acknowledged or consumed; other stores have nothing to acknowledge + } + deriving (Eq, Show) + +-- | The reasons are for the service's log alone and must never quote the receipt. +-- SRInvalid and SRTestPurchase are terminal: the client drops the purchase's keys and nothing +-- presents it again. So only a verdict no later attempt could change is either; when in doubt, SRUnreachable. +data StoreRefusal + = SRInvalid Text -- a verdict that cannot change, and the client consumes the purchase: forged, malformed, refunded + | SRTestPurchase -- the store vouched for it, but it cost nothing; terminal, and named so the app can say why + | SRPending -- a real purchase the store has not settled; it may yet + | SRUnreachable Text -- no verdict: the store was not asked, did not answer, or does not know the token (a Play 404 may be lag) + | SRVerifierFailed Text -- this service's own failure, its bug or misconfiguration, never the store's answer + | SRNotConfigured -- no verifier for this store is deployed; the purchase may be real + deriving (Eq, Show) + +-- | Apple signs its receipt and ships the certificate chain in it, so it is verified with no network +-- call; a Play token is opaque and must be asked about, which is why only the Play fields are in IO. +data StoreVerifier = StoreVerifier + { -- the JWS + verifyApple :: Maybe (Text -> Either StoreRefusal VerifiedStoreTransaction), + verifyGoogle :: Maybe (Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction)), -- the product id and the token + acknowledgeGoogle :: Maybe (Text -> Text -> IO (Either StoreRefusal ())), + -- microseconds; a lane answers one request at a time, so a verifier that does not finish holds up the rest of its lane + verifyTimeout :: Int + } + +-- | A store payment, named by the store's own reference before anything is verified. +data StoreReceipt = StoreReceipt + { txRef :: StoreTransactionRef, + verifyReceipt :: IO (Either StoreRefusal VerifiedStoreTransaction), + acknowledgeReceipt :: Maybe (VerifiedStoreTransaction -> IO (Either StoreRefusal ())) + } + +noStoreVerifier :: StoreVerifier +noStoreVerifier = StoreVerifier {verifyApple = Nothing, verifyGoogle = Nothing, acknowledgeGoogle = Nothing, verifyTimeout = 10000000} + +-- | Nothing for a payment no store made. Exceptions are not logged, since they can quote the receipt +-- or, from Google, a URL holding the token. +toStoreReceipt :: StoreVerifier -> ServicePayment -> Maybe (Either StoreRefusal StoreReceipt) +toStoreReceipt StoreVerifier {verifyApple, verifyGoogle, acknowledgeGoogle, verifyTimeout} = \case + SPApple {jws} -> Just $ case appleTransactionId jws of + Nothing -> Left $ SRInvalid "names no transaction" + Just ref -> Right $ StoreReceipt (StoreTransactionRef PPApple ref) (maybe unconfigured (\verify -> offline $ verify jws) verifyApple) Nothing + SPGoogle {productId, token} + -- the claim is the token's hash, so neither string may name any purchase but the one it claims, + -- whatever path a verifier builds from them + | not (googleProductId productId) -> Just $ Left $ SRInvalid "not a Play product id" + -- Play documents no token grammar, so this is our guess, and refusing to ask Play is not its verdict + | not (googleToken token) -> Just $ Left $ SRUnreachable $ "a Play token this service will not send: " <> tokenShape token + | otherwise -> + Just $ Right $ + StoreReceipt + (StoreTransactionRef PPGoogle (googlePurchaseRef token)) + (maybe unconfigured (\verify -> online "google verifier" $ verify productId token) verifyGoogle) + ((\acknowledge VerifiedStoreTransaction {alreadyAcknowledged} -> if alreadyAcknowledged then pure (Right ()) else online "acknowledging: google" $ acknowledge productId token) <$> acknowledgeGoogle) + SPInvoice {} -> Nothing + SPReceipt {} -> Nothing + where + unconfigured = pure $ Left SRNotConfigured + -- nothing was fetched, so a throw or an overrun is this service's failure or a shape it does not know, never an outage + offline verdict = + (fromMaybe (Left $ SRVerifierFailed "apple verifier timed out") <$> timeout verifyTimeout (forced verdict)) + `catchOwn'` \_ -> pure $ Left $ SRVerifierFailed "apple verifier threw" + online what call = + (fromMaybe (Left $ SRUnreachable $ what <> " timed out") <$> timeout verifyTimeout (call >>= forced)) + `catchOwn'` \_ -> pure $ Left $ SRUnreachable $ what <> " threw" + -- a verdict holding a thunk that throws would otherwise throw later, outside these handlers + forced = either (fmap Left . evaluate) (fmap Right . evaluate) + +googleProductId :: Text -> Bool +googleProductId pid = case T.uncons pid of + Just (c, _) -> T.length pid <= 150 && (isAsciiLower c || isDigit c) && T.all (\x -> isAsciiLower x || isDigit x || x == '_' || x == '.') pid + Nothing -> False + +-- | Not empty or only dots, which a URL path resolves away. +googleToken :: Text -> Bool +googleToken t = not (T.all (== '.') t) && T.length t <= 4096 && T.all (\x -> isAsciiLower x || isAsciiUpper x || isDigit x || x == '.' || x == '_' || x == '-') t + +-- | What the log may say about a token: its length and the kinds of character in it, never any of them. +tokenShape :: Text -> Text +tokenShape t = tshow (T.length t) <> " characters: " <> T.intercalate ", " [kind | (kind, isKind) <- kinds, T.any isKind t] + where + kinds = + [ ("lowercase", isAsciiLower), + ("uppercase", isAsciiUpper), + ("digits", isDigit), + ("'.'", (== '.')), + ("'_'", (== '_')), + ("'-'", (== '-')), + ("other printable ASCII", \c -> isAscii c && isPrint c && not (isAlphaNum c) && c `notElem` ("._-" :: String)), + ("whitespace or control", \c -> isSpace c || isControl c), + ("non-ASCII", not . isAscii) + ] diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs new file mode 100644 index 0000000000..c94c6dec61 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs @@ -0,0 +1,189 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.StoreReceipts.Apple + ( verifyAppleTransaction, + readAppleRoot, + ) +where + +import BadgeService.StoreReceipts (StoreRefusal (..), VerifiedStoreTransaction (..)) +import Control.Exception (IOException, try) +import Control.Monad (guard, unless) +import Crypto.Hash.Algorithms (SHA256 (..)) +import Crypto.Number.Serialize (os2ip) +import qualified Crypto.PubKey.ECC.ECDSA as ECDSA +import qualified Crypto.PubKey.ECC.Types as ECC +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as JM +import qualified Data.Aeson.Types as JT +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64U +import Data.Foldable (toList) +import Data.List.NonEmpty (NonEmpty) +import Data.Maybe (isJust) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Word (Word32) +import Data.X509 +import Data.X509.EC (unserializePoint) +import Data.X509.Validation (SignatureFailure (..), SignatureVerification (..), verifySignedSignature) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) +import Simplex.Messaging.Util (eitherToMaybe) + +verifyAppleTransaction :: NonEmpty SignedCertificate -> Text -> Text -> Either StoreRefusal VerifiedStoreTransaction +verifyAppleTransaction roots ourBundleId jws = case T.splitOn "." jws of + [header, payload, signature] -> do + leaf <- signingChain roots =<< certificateChain header + sig <- jwsSignature signature + key <- leafKey leaf + unless (ECDSA.verify SHA256 key sig (encodeUtf8 $ header <> "." <> payload)) $ + refused "the signature does not verify" + tx <- either (const $ failed "the payload Apple signed is not a transaction") Right $ do + o <- maybe (Left "not base64url JSON") Right $ jsonObject payload + JT.parseEither appleTransactionP o + transaction ourBundleId tx + _ -> refused "not three dot-separated parts" + +-- | A header or chain in a shape Apple may yet adopt proves no forgery, so it fails rather than refuses. +certificateChain :: Text -> Either StoreRefusal [ByteString] +certificateChain header = do + o <- maybe (refused "the header is not base64url JSON") Right $ jsonObject header + case JM.lookup "alg" o of + Just (J.String "ES256") -> Right () + _ -> failed "the header does not name ES256" + case JM.lookup "x5c" o of + Just (J.Array certs) -> traverse der $ toList certs + _ -> failed "the header carries no x5c chain" + where + der = \case + J.String c | Right bs <- B64.decode (encodeUtf8 c) -> Right bs + _ -> refused "an x5c certificate is not base64" + +-- | The root in the chain is ignored: trust comes from the configured ones alone. +signingChain :: NonEmpty SignedCertificate -> [ByteString] -> Either StoreRefusal SignedCertificate +signingChain roots = \case + [leafDer, intermediateDer, _] -> do + leaf <- certificate leafDer + intermediate <- certificate intermediateDer + -- a root may be missing or wrong as likely as the receipt forged, so this retries rather than refuses + unless (any (`signedBy` intermediate) roots) $ failed "the intermediate is not signed by a configured root" + case verifySignedSignature leaf (certPubKey $ getCertificate intermediate) of + SignaturePass -> Right () + SignatureFailed SignatureInvalid -> refused "the leaf is not signed by the intermediate" + SignatureFailed _ -> failed "the intermediate's key cannot check the leaf's signature algorithm" + -- Apple issues other certificates under this root, developers' among them; only these two mark the receipt-signing chain + unless (marked receiptSigningLeaf leaf && marked appleIntermediate intermediate) $ + failed "Apple issued this chain, but not to sign receipts" + pure leaf + _ -> failed "x5c is not leaf, intermediate and root" + where + certificate = either (const $ failed "an x5c certificate does not decode") Right . decodeSignedCertificate + marked oid cert = case certExtensions (getCertificate cert) of + Extensions (Just exts) -> any ((== oid) . extRawOID) exts + Extensions Nothing -> False + receiptSigningLeaf = [1, 2, 840, 113635, 100, 6, 11, 1] + appleIntermediate = [1, 2, 840, 113635, 100, 6, 2, 1] + +leafKey :: SignedCertificate -> Either StoreRefusal ECDSA.PublicKey +leafKey leaf = case certPubKey (getCertificate leaf) of + PubKeyEC (PubKeyEC_Named ECC.SEC_p256r1 pt) | Just p <- unserializePoint p256 pt -> Right $ ECDSA.PublicKey p256 p + _ -> failed "the leaf Apple issued holds no P-256 key" + where + p256 = ECC.getCurveByName ECC.SEC_p256r1 + +-- | JWS carries ES256 as r and s, 32 bytes each, not the DER a certificate uses. +jwsSignature :: Text -> Either StoreRefusal ECDSA.Signature +jwsSignature s = case B64U.decodeUnpadded (encodeUtf8 s) of + Right bs | B.length bs == 64 -> let (r, s') = B.splitAt 32 bs in Right $ ECDSA.Signature (os2ip r) (os2ip s') + _ -> refused "the signature is not 64 base64url bytes" + +data AppleTransaction = AppleTransaction + { transactionId :: Text, + productId :: Text, + bundleId :: Text, + environment :: Text, + quantity :: Int, + revoked :: Bool, + price :: Maybe Integer, + currency :: Maybe Text + } + +appleTransactionP :: J.Object -> JT.Parser AppleTransaction +appleTransactionP o = do + transactionId <- o J..: "transactionId" + productId <- o J..: "productId" + bundleId <- o J..: "bundleId" + environment <- o J..: "environment" + quantity <- o J..: "quantity" + revoked <- isJust <$> (o J..:? "revocationDate" :: JT.Parser (Maybe J.Value)) + price <- o J..:? "price" + currency <- o J..:? "currency" + pure AppleTransaction {transactionId, productId, bundleId, environment, quantity, revoked, price, currency} + +transaction :: Text -> AppleTransaction -> Either StoreRefusal VerifiedStoreTransaction +transaction ourBundleId AppleTransaction {transactionId, productId, bundleId, environment, quantity, revoked, price, currency} + -- the configured bundle id may be the wrong one, and refusing for good would destroy every genuine purchase + | bundleId /= ourBundleId = failed "another app's bundle id, or ours misconfigured" + | revoked = refused "refunded or revoked" + | otherwise = do + testPurchase <- case environment of + "Production" -> Right False + "Sandbox" -> Right True + _ -> failed "an environment this verifier does not know" + Right + VerifiedStoreTransaction + { transactionRef = transactionId, + productId, + quantity, + testPurchase, + paid = paidOf price currency, + alreadyAcknowledged = False + } + +-- | Apple's price is in milliunits of the currency, whatever its minor unit. +paidOf :: Maybe Integer -> Maybe Text -> Maybe (CurrencyAmount, Text) +paidOf price_ currency_ = do + milliunits <- price_ + code <- currency_ + digits <- minorUnitDigits code + let (minor, rest) = (milliunits * 10 ^ digits) `divMod` 1000 + guard $ rest == 0 && minor >= 0 && minor <= toInteger (maxBound :: Word32) + pure (CurrencyAmount $ fromInteger minor, code) + +-- | ISO 4217 as of its 2026-09-17 list; four-digit and non-currency codes are not recorded. +minorUnitDigits :: Text -> Maybe Integer +minorUnitDigits c + | c `elem` ["BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"] = Just 0 + | c `elem` ["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"] = Just 3 + | c `elem` ["CLF", "UYW", "UYI", "XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XDR", "XPD", "XPT", "XSU", "XTS", "XUA", "XXX"] = Nothing + | T.length c == 3 && T.all (\x -> x >= 'A' && x <= 'Z') c = Just 2 + | otherwise = Nothing + +jsonObject :: Text -> Maybe J.Object +jsonObject part = J.decodeStrict' =<< eitherToMaybe (B64U.decodeUnpadded $ encodeUtf8 part) + +refused :: Text -> Either StoreRefusal a +refused = Left . SRInvalid + +failed :: Text -> Either StoreRefusal a +failed = Left . SRVerifierFailed . ("apple verifier: " <>) + +signedBy :: SignedCertificate -> SignedCertificate -> Bool +signedBy issuer cert = verifySignedSignature cert (certPubKey $ getCertificate issuer) == SignaturePass + +readAppleRoot :: FilePath -> IO (Either String SignedCertificate) +readAppleRoot path = + try (B.readFile path) >>= \case + Left (_ :: IOException) -> pure $ Left $ path <> ": could not be read" + Right der -> pure $ either (const $ Left $ path <> ": is not a DER certificate") checkRoot $ decodeSignedCertificate der + where + checkRoot root + | signedBy root root = Right root + | otherwise = Left $ path <> ": is not a self-signed root certificate" diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs new file mode 100644 index 0000000000..98cb1fc050 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs @@ -0,0 +1,232 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} + +module BadgeService.StoreReceipts.Google (playStoreVerifier) where + +import BadgeService.Config (PlayStoreConfig (..)) +import BadgeService.StoreReceipts (StoreRefusal (..), VerifiedStoreTransaction (..)) +import Control.Concurrent.STM +import Control.Exception (IOException, try) +import Crypto.Hash.Algorithms (SHA256 (..)) +import qualified Crypto.PubKey.RSA as RSA +import qualified Crypto.PubKey.RSA.PKCS15 as RSA +import qualified Data.Aeson as J +import Data.Bifunctor (bimap) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Lazy as LB +import Data.Functor (($>)) +import Data.Int (Int64) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime) +import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds) +import Data.X509 (PrivKey (..)) +import Data.X509.Memory (readKeyFileFromMemory) +import Network.HTTP.Client + ( HttpException (..), + Manager, + Request (..), + RequestBody (..), + Response (..), + brReadSome, + parseRequest, + withResponse, + ) +import Network.HTTP.Client.TLS (newTlsManager) +import Network.HTTP.Types (Status (..), hAuthorization, hContentType, methodPost, renderSimpleQuery) +import Simplex.Chat.PaymentService (googlePurchaseRef) +import Simplex.Messaging.Util (tshow) + +data ServiceAccount = ServiceAccount {clientEmail :: Text, privateKey :: RSA.PrivateKey} + +data PlayEnv = PlayEnv + { config :: PlayStoreConfig, + account :: ServiceAccount, + manager :: Manager, + accessToken :: TVar (Maybe (ByteString, UTCTime)) + } + +playStoreVerifier :: PlayStoreConfig -> IO (Either String (Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction), Text -> Text -> IO (Either StoreRefusal ()))) +playStoreVerifier config@PlayStoreConfig {gServiceAccountFile} = + readServiceAccount gServiceAccountFile >>= \case + Left e -> pure $ Left e + Right account -> do + manager <- newTlsManager + accessToken <- newTVarIO Nothing + let env = PlayEnv {config, account, manager, accessToken} + pure $ Right (verifyPurchase env, acknowledgePurchase env) + +readServiceAccount :: FilePath -> IO (Either String ServiceAccount) +readServiceAccount path = + try (B.readFile path) >>= \case + Left (_ :: IOException) -> pure $ Left $ path <> ": could not be read" + Right s -> pure $ case J.decodeStrict' s of + Just (ServiceAccountFile clientEmail pem) -> case readKeyFileFromMemory (encodeUtf8 pem) of + [PrivKeyRSA privateKey] -> Right ServiceAccount {clientEmail, privateKey} + _ -> Left $ path <> ": private_key is not one RSA key" + Nothing -> Left $ path <> ": is not a service account key with client_email and private_key" + +data ServiceAccountFile = ServiceAccountFile Text Text + +instance J.FromJSON ServiceAccountFile where + parseJSON = J.withObject "service account key" $ \o -> ServiceAccountFile <$> o J..: "client_email" <*> o J..: "private_key" + +verifyPurchase :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +verifyPurchase env productId token = (>>= purchaseVerdict productId token) <$> readPurchase env productId token + +-- | Acknowledged, never consumed: consuming drops the purchase from the app's purchase query, +-- which is how the app finds a purchase it has not yet settled. +acknowledgePurchase :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal ()) +acknowledgePurchase env productId token = + bimap acknowledging (const ()) <$> playCall env (\req -> req {method = methodPost}) (purchaseUrl env productId token <> ":acknowledge") + where + acknowledging = \case + SRUnreachable reason -> SRUnreachable $ "acknowledging: " <> reason + SRVerifierFailed reason -> SRVerifierFailed $ "acknowledging: " <> reason + refusal -> refusal + +readPurchase :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal ProductPurchase) +readPurchase env productId token = (>>= decoded) <$> playCall env id (purchaseUrl env productId token) + where + decoded = either (const $ Left $ SRVerifierFailed "could not read Play's purchase record") Right . J.eitherDecode' + +purchaseUrl :: PlayEnv -> Text -> Text -> Text +purchaseUrl PlayEnv {config = PlayStoreConfig {gApiHost, gPackageName}} productId token = + T.intercalate "/" [gApiHost, "androidpublisher/v3/applications", gPackageName, "purchases/products", productId, "tokens", token] + +playCall :: PlayEnv -> (Request -> Request) -> Text -> IO (Either StoreRefusal LB.ByteString) +playCall env@PlayEnv {accessToken} prepare url = + bearerToken env >>= \case + Left refusal -> pure $ Left refusal + Right bearer -> + playRequest env (\req -> (prepare req) {requestHeaders = [(hAuthorization, "Bearer " <> bearer)]}) url >>= \case + Left refusal -> pure $ Left refusal + Right (code, body) | code >= 200 && code < 300 -> pure $ Right body + Right (401, _) -> atomically (writeTVar accessToken Nothing) $> Left (SRVerifierFailed "Play refused this service's access token") + Right (403, _) -> pure $ Left $ SRVerifierFailed "the service account lacks the Play Console permission for this call" + -- a 404 among them, which Play may answer for a token it issued but has not yet recorded + Right (code, _) -> pure $ Left $ SRUnreachable $ "Play answered HTTP " <> tshow code + +purchaseVerdict :: Text -> Text -> ProductPurchase -> Either StoreRefusal VerifiedStoreTransaction +purchaseVerdict productId token ProductPurchase {purchaseState, purchaseType, purchaseQuantity, purchaseProductId, alreadyAcknowledged} + | maybe False (/= productId) purchaseProductId = Left $ SRVerifierFailed "Play answered about another product" + | otherwise = case purchaseState of + 0 -> purchased <$> testPurchaseOf purchaseType + -- a canceled purchase stays canceled, and buying again issues a new token + 1 -> Left $ SRInvalid "canceled" + 2 -> Left SRPending + s -> Left $ SRVerifierFailed $ "an unknown purchaseState " <> tshow s + where + purchased testPurchase = + VerifiedStoreTransaction + { transactionRef = googlePurchaseRef token, + productId, + quantity = fromMaybe 1 purchaseQuantity, + testPurchase, + paid = Nothing, + alreadyAcknowledged + } + testPurchaseOf = \case + Nothing -> Right False + Just 0 -> Right True + -- a promo code, which only this app's developer can issue + Just 1 -> Right False + Just t -> Left $ SRVerifierFailed $ "a purchaseType this service does not credit: " <> tshow t + +data ProductPurchase = ProductPurchase + { purchaseState :: Int, + purchaseType :: Maybe Int, + purchaseQuantity :: Maybe Int, + purchaseProductId :: Maybe Text, + alreadyAcknowledged :: Bool + } + +instance J.FromJSON ProductPurchase where + parseJSON = J.withObject "ProductPurchase" $ \o -> do + purchaseState <- o J..: "purchaseState" + purchaseType <- o J..:? "purchaseType" + purchaseQuantity <- o J..:? "quantity" + purchaseProductId <- o J..:? "productId" + acknowledgementState <- o J..:? "acknowledgementState" + consumptionState <- o J..:? "consumptionState" + let alreadyAcknowledged = acknowledgementState == Just (1 :: Int) || consumptionState == Just (1 :: Int) + pure ProductPurchase {purchaseState, purchaseType, purchaseQuantity, purchaseProductId, alreadyAcknowledged} + +bearerToken :: PlayEnv -> IO (Either StoreRefusal ByteString) +bearerToken env@PlayEnv {accessToken} = do + now <- getCurrentTime + readTVarIO accessToken >>= \case + Just (bearer, validUntil) | now < validUntil -> pure $ Right bearer + _ -> requestAccessToken env now + +requestAccessToken :: PlayEnv -> UTCTime -> IO (Either StoreRefusal ByteString) +requestAccessToken env@PlayEnv {config = PlayStoreConfig {gTokenUrl}, account, accessToken} now = + signedAssertion account gTokenUrl now >>= \case + Nothing -> pure $ Left $ SRVerifierFailed "could not sign the service account assertion" + Just assertion -> + playRequest env (tokenForm assertion) gTokenUrl >>= \case + Left refusal -> pure $ Left refusal + Right (200, body) -> case J.eitherDecode' body of + Right (GrantedToken bearer expiresIn) -> do + atomically $ writeTVar accessToken $ Just (bearer, addUTCTime (fromIntegral expiresIn - refreshMargin) now) + pure $ Right bearer + Left _ -> pure $ Left $ SRVerifierFailed "could not read Google's access token" + Right (code, _) + | code >= 500 || code == 429 -> pure $ Left $ SRUnreachable $ "Google's token endpoint answered HTTP " <> tshow code + | otherwise -> pure $ Left $ SRVerifierFailed $ "Google refused the service account assertion: HTTP " <> tshow code + where + tokenForm assertion req = + req + { method = methodPost, + requestHeaders = [(hContentType, "application/x-www-form-urlencoded")], + requestBody = RequestBodyBS $ renderSimpleQuery False [("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), ("assertion", assertion)] + } + +refreshMargin :: NominalDiffTime +refreshMargin = 300 + +data GrantedToken = GrantedToken ByteString Int + +instance J.FromJSON GrantedToken where + parseJSON = J.withObject "access token" $ \o -> GrantedToken . encodeUtf8 <$> o J..: "access_token" <*> o J..: "expires_in" + +signedAssertion :: ServiceAccount -> Text -> UTCTime -> IO (Maybe ByteString) +signedAssertion ServiceAccount {clientEmail, privateKey} audience now = + either (const Nothing) (\sig -> Just $ signingInput <> "." <> B64U.encodeUnpadded sig) <$> RSA.signSafer (Just SHA256) privateKey signingInput + where + issuedAt = floor (utcTimeToPOSIXSeconds now) :: Int64 + signingInput = segment (J.object ["alg" J..= ("RS256" :: Text), "typ" J..= ("JWT" :: Text)]) <> "." <> segment claims + claims = + J.object + [ "iss" J..= clientEmail, + "scope" J..= ("https://www.googleapis.com/auth/androidpublisher" :: Text), + "aud" J..= audience, + "iat" J..= issuedAt, + "exp" J..= (issuedAt + 3600) + ] + segment = B64U.encodeUnpadded . LB.toStrict . J.encode + +playRequest :: PlayEnv -> (Request -> Request) -> Text -> IO (Either StoreRefusal (Int, LB.ByteString)) +playRequest PlayEnv {manager} prepare url = + try (parseRequest (T.unpack url) >>= \req -> withResponse (prepare req) manager readBody) >>= \case + -- http-client's exceptions show the request, whose URL can hold the purchase token, so only the kind is kept + Left (e :: HttpException) -> pure $ Left $ SRUnreachable $ "request to Google failed: " <> failureKind e + Right (code, body) + | LB.length body > maxPlayBytes -> pure $ Left $ SRVerifierFailed $ "Google answered HTTP " <> tshow code <> " with over " <> tshow maxPlayBytes <> " bytes" + | otherwise -> pure $ Right (code, body) + where + readBody resp = (statusCode (responseStatus resp),) <$> brReadSome (responseBody resp) (fromIntegral maxPlayBytes + 1) + failureKind = \case + HttpExceptionRequest _ content -> T.pack $ takeWhile (/= ' ') $ show content + InvalidUrlException _ _ -> "InvalidUrlException" + +maxPlayBytes :: Int64 +maxPlayBytes = 1024 * 1024 diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs new file mode 100644 index 0000000000..f7b1a14c9b --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs @@ -0,0 +1,41 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.StoreReceipts.Mock (mockStoreVerifier) where + +import BadgeService.StoreReceipts +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as JM +import qualified Data.ByteString.Base64.URL as B64U +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat.PaymentService (appleTransactionId, googlePurchaseRef) +import Simplex.Messaging.Util (eitherToMaybe) + +-- | Vouches for any well-formed receipt without verifying anything. Each transaction is named by +-- the same function its claim is named by, so the two cannot differ. +mockStoreVerifier :: StoreVerifier +mockStoreVerifier = noStoreVerifier {verifyApple = Just mockApple, verifyGoogle = Just mockGoogle} + +mockApple :: Text -> Either StoreRefusal VerifiedStoreTransaction +mockApple signed = case T.splitOn "." signed of + [_, payload, _] -> do + o <- case J.decodeStrict' =<< eitherToMaybe (B64U.decodeUnpadded $ encodeUtf8 payload) of + Just (J.Object obj) -> Right obj + _ -> Left $ SRInvalid "payload is not base64url JSON" + productId <- case JM.lookup "productId" o of + Just (J.String p) -> Right p + _ -> Left $ SRInvalid "no productId" + transactionRef <- maybe (Left $ SRInvalid "no transactionId") Right $ appleTransactionId signed + Right $ vouched transactionRef productId + _ -> Left $ SRInvalid "not three dot-separated parts" + +mockGoogle :: Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +mockGoogle productId token = pure $ Right $ vouched (googlePurchaseRef token) productId + +vouched :: Text -> Text -> VerifiedStoreTransaction +vouched transactionRef productId = + -- not a test purchase, though nothing was paid: a test purchase is refused as test_purchase, which is + -- terminal and drops the client's keys, so every dev purchase would fail for good + VerifiedStoreTransaction {transactionRef, productId, quantity = 1, testPurchase = False, paid = Nothing, alreadyAcknowledged = False} diff --git a/apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer b/apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer new file mode 100644 index 0000000000..228bfa39cb Binary files /dev/null and b/apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer differ diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json new file mode 100644 index 0000000000..72ee85416c --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345672", + "originalTransactionId": "2000000812345672", + "bundleId": "chat.simplex.app", + "productId": "BADGE_LEGEND_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "0b6a7e1d-5c3f-4a8e-b2d4-9e1f6c7a8b20", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Production", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 70000, + "currency": "USD" +} diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json new file mode 100644 index 0000000000..e61c63d2a7 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345673", + "originalTransactionId": "2000000812345673", + "bundleId": "chat.simplex.app", + "productId": "BADGE_LEGEND_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "4d2c9a61-8e3b-4f70-a1c5-6b0e9d2f3a47", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Sandbox", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 70000, + "currency": "USD" +} diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json new file mode 100644 index 0000000000..267184c036 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345671", + "originalTransactionId": "2000000812345671", + "bundleId": "chat.simplex.app", + "productId": "BADGE_SUPPORTER_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "7f7c0f43-4b7b-4c2e-9d57-1b7a2b1f3c11", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Production", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 7000, + "currency": "USD" +} diff --git a/apps/simplex-badge-service/web/README.md b/apps/simplex-badge-service/web/README.md index 0004b44944..ae2f5b37e5 100644 --- a/apps/simplex-badge-service/web/README.md +++ b/apps/simplex-badge-service/web/README.md @@ -56,10 +56,10 @@ is plain TypeScript, tested directly in Node. | `format.ts` | Money, countdowns and elapsed time as the words a screen prints | | `store.ts` | The three `localStorage` keys — session, orders and the chosen theme | | `order.ts` | What an order's state means: what to keep from a response, which screen it selects, and its history row | -| `routing.ts` | Reading `?order=` and the store into what to render | +| `routing.ts` | Reading `?order=`, the step hash with its `?app=` flag, and the store into what to render; the `simplexchat:` link that hands a code to the app | | `api.ts` | The three routes — create, cancel and read — and the long-poll wait loop, with `fetch`, sleep and the clock injected | | `flow.ts` | Payment flow control logic — pure, no DOM, no globals | -| `stripe.ts` | Loading Stripe.js, mounting the Payment Element, confirming, and the no-key stand-in | +| `stripe.ts` | Loading Stripe.js, mounting the Payment Element, confirming, and refusing the card path with no key | | `icons.ts` | The badge art, the payment marks and the hamburger, built with `createElementNS`. The brand mark is NOT here: it is a served file | | `qr.ts` | A QR encoder written for this page, with no dependency, no network and no raster | | `screens.ts` | Every screen of the spec, and the header and its menu, built node by node — markup is never assigned from a string | @@ -183,6 +183,29 @@ which is what the page's own wait loop relies on — no polling on a timer. Card invoices (`"method":"card"`) get a `clientSecret` in the response instead of an address; BTC and XMR get `address` and `cryptoAmount`. +### Opening the page as the app does + +The SimpleX app opens the page at a step hash carrying a flag, which decides +only how the code is handed over at the end. Both endings can be driven here: + +``` +http://localhost:8099/#/tier?app=true # Return to SimpleX, with Show code under it +http://localhost:8099/#/tier?app=desktop # the code screen, saying the Redeem code screen is already open +``` + +With no flag the page ends as it always has. + +**Open each in a fresh private window.** The flag is read once, at load, and +kept in the session; nothing re-reads it when the hash changes, so editing the +address bar of a page that is already open does nothing. A session left over +from an earlier purchase in the same browser has already spent its flag at +checkout, and the ending falls back to the plain code screen. + +Pay with Monero or Bitcoin and settle it with `/control/settle` above — the +card path needs a Stripe key. A code the browser could not store also falls +back to the plain screen, so a browser that blocks `localStorage` never shows +the `app=true` ending at all. + ### The Stripe key The publishable key lives in a `` element @@ -191,12 +214,10 @@ in `public/index.html`, committed empty. `mock/server.py` substitutes set to anything but a `pk_`-prefixed key (a secret or restricted key would otherwise be baked into a page anyone can read). -With no key set, the card path renders a labelled development stand-in -instead of a Stripe Payment Element: its button does what a successful -confirm does, and settling it calls the mock's `/control/settle` directly. -**This stand-in cannot appear when a key is set** — the code path that -builds it is unreachable once Stripe.js has actually loaded. To see the real -Payment Element, set a test key: +With no key set there is no card path at all: `cardPlan` answers +`unconfigured`, `renderCardForm` returns before it builds anything, and +choosing Card reaches "The card form did not load". Drive a purchase with +Monero or Bitcoin instead, or set a test key: ``` STRIPE_PUBLISHABLE_KEY=pk_test_... python3 mock/server.py --port 8099 diff --git a/apps/simplex-badge-service/web/mock/server.py b/apps/simplex-badge-service/web/mock/server.py index af7612a084..009aea31d4 100644 --- a/apps/simplex-badge-service/web/mock/server.py +++ b/apps/simplex-badge-service/web/mock/server.py @@ -9,9 +9,9 @@ Environment: STRIPE_PUBLISHABLE_KEY substituted into the served index.html. Public by design, but still not committed: unset, the page has NO card form and - renders the development stand-in instead, whose - button does what a successful confirm does and whose - settling is POST /control/settle/ below. + choosing Card reaches "The card form did not load", + so drive a purchase with btc or xmr and settle it + with POST /control/settle/ below. Set it to a `pk_test_...` key to drive the real Stripe path; a secret key here is refused at start. """ @@ -111,8 +111,8 @@ def public_view(inv): def with_publishable_key(html): """The publishable key is compiled into the page. Here it comes from the environment, so nothing that could be a real key is ever written - back into public/index.html. Unset leaves the committed empty value, which - is what selects the development stand-in.""" + back into public/index.html. Unset leaves the committed empty value, with + which the page offers no card form at all.""" if not STRIPE_PUBLISHABLE_KEY: return html text, found = KEY_META.subn(lambda m: m.group(1) + STRIPE_PUBLISHABLE_KEY + m.group(2), html.decode()) @@ -314,7 +314,7 @@ def main(): print(f"mock badge service on http://localhost:{port}", flush=True) print("stripe: " + (f"publishable key {STRIPE_PUBLISHABLE_KEY[:11]}… — the real card form" if STRIPE_PUBLISHABLE_KEY - else "no STRIPE_PUBLISHABLE_KEY — the card path renders the development stand-in"), + else "no STRIPE_PUBLISHABLE_KEY — no card form; pay with btc or xmr"), flush=True) server.serve_forever() diff --git a/apps/simplex-badge-service/web/public/index.html b/apps/simplex-badge-service/web/public/index.html index dcf5e7d4b2..9885481483 100644 --- a/apps/simplex-badge-service/web/public/index.html +++ b/apps/simplex-badge-service/web/public/index.html @@ -20,17 +20,17 @@ Support SimpleX - - + + - + - + - + diff --git a/apps/simplex-badge-service/web/public/styles.css b/apps/simplex-badge-service/web/public/styles.css index 2fee434fcd..760cc259e7 100644 --- a/apps/simplex-badge-service/web/public/styles.css +++ b/apps/simplex-badge-service/web/public/styles.css @@ -477,6 +477,10 @@ footer a:hover { text-decoration: underline; } .panel > .primary { margin-top: auto; } .panel > .notes { margin-top: auto; } .notes + .primary { margin-top: 20px; } +/* The ending's button keeps the foot position every screen gives it, but the text above it + keeps a floor: once Show code fills the screen there is no free space for the auto margin, + and the button would otherwise land against the line it follows. */ +.panel.ending > .lede { margin-bottom: 24px; } .notes .muted { margin: 4px auto 0; max-width: 480px; } /* The checkout's note slot holds one line's height whether or not the line is shown, so choosing a method moves nothing above or below it. */ diff --git a/apps/simplex-badge-service/web/public/sw.js b/apps/simplex-badge-service/web/public/sw.js index 8faa9d0c1f..447530421a 100644 --- a/apps/simplex-badge-service/web/public/sw.js +++ b/apps/simplex-badge-service/web/public/sw.js @@ -2,7 +2,7 @@ // runs this exact file in a Node `vm` with a fake Cache API and reads `self.sw`. /** Rewritten by `build.js` from the bytes of the compiled modules, the stylesheet and the images. */ -const BUILD = "9539a668abb6a8c8"; +const BUILD = "c1e049e40dfe7be6"; const ASSETS = `/assets/${BUILD}/`; /** One cache per build, so eviction is "everything that is not this one". */ const CACHE = `sb-${BUILD}`; diff --git a/apps/simplex-badge-service/web/src/domain.ts b/apps/simplex-badge-service/web/src/domain.ts index 4bae3d13b9..3baba3656f 100644 --- a/apps/simplex-badge-service/web/src/domain.ts +++ b/apps/simplex-badge-service/web/src/domain.ts @@ -5,18 +5,22 @@ export const THEMES = ["light", "dark", "system"] as const; const ORDER_STATUSES = ["open", "paid", "expired"] as const; const METHODS = ["card", "btc", "xmr"] as const; const CHAINS = ["btc", "xmr"] as const; +const APPS = ["mobile", "desktop"] as const; export type Step = (typeof STEPS)[number]; export type Theme = (typeof THEMES)[number]; export type OrderStatus = (typeof ORDER_STATUSES)[number]; export type Method = (typeof METHODS)[number]; export type Chain = (typeof CHAINS)[number]; +/** The SimpleX app that opened the page, which decides how the code is handed over at the end. */ +export type App = (typeof APPS)[number]; export const readStep = oneOf(STEPS); export const readTheme = oneOf(THEMES); export const readStatus = oneOf(ORDER_STATUSES); export const readMethod = oneOf(METHODS); export const readChain = oneOf(CHAINS); +export const readApp = oneOf(APPS); /** The optional fields are written `| undefined` so a patch can set one back to undefined to clear it. */ export interface SessionRecord { @@ -24,6 +28,7 @@ export interface SessionRecord { priceId?: string | undefined; offerId?: string | undefined; method?: Method | undefined; + app?: App | undefined; } export interface OrderIdentity { @@ -39,6 +44,7 @@ export interface OrderLocalState { submitted: boolean | undefined; canceled: boolean | undefined; method: Method | undefined; + app: App | undefined; } /** Every field is a required key even when undefined, so a withdrawn field cannot survive in the stored record. */ diff --git a/apps/simplex-badge-service/web/src/flow.ts b/apps/simplex-badge-service/web/src/flow.ts index 13da0949d0..af675eac04 100644 --- a/apps/simplex-badge-service/web/src/flow.ts +++ b/apps/simplex-badge-service/web/src/flow.ts @@ -9,7 +9,7 @@ import { applyView, closedInvoice, offlineInvoice, recordFromView, serverState, viewFor, withoutCode, type PaymentView, } from "./order.js"; -import type { Method, OrderRecord, OrderStatus } from "./domain.js"; +import type { App, Method, OrderRecord, OrderStatus } from "./domain.js"; import type { Store } from "./store.js"; export const GIVE_UP_MS = 15 * 60 * 1000; @@ -285,7 +285,7 @@ export class Flow { constructor(private readonly d: FlowDeps) {} - async checkout(sel: Selection): Promise { + async checkout(sel: Selection, app?: App): Promise { for (let attempt = 0; attempt < CODE_ATTEMPTS; attempt++) { const wipes = this.d.store.wipeCount; const code = this.d.newCode(); @@ -316,7 +316,7 @@ export class Flow { months: created.months, createdAt: new Date(this.d.now()).toISOString(), }, - { code: display(code), submitted: undefined, canceled: undefined, method: sel.method }, + { code: display(code), submitted: undefined, canceled: undefined, method: sel.method, app }, serverState({ ...created, status: "open" }, undefined), ); // The buyer can empty this browser while the invoice is bought, so the order is saved only diff --git a/apps/simplex-badge-service/web/src/main.ts b/apps/simplex-badge-service/web/src/main.ts index 310fa93220..3bb4917ddc 100644 --- a/apps/simplex-badge-service/web/src/main.ts +++ b/apps/simplex-badge-service/web/src/main.ts @@ -5,12 +5,12 @@ import { Flow, type CheckoutOutcome, type Selection } from "./flow.js"; import { applyView, historyRows, selectionFromOrder, type PaymentView, withoutDestination } from "./order.js"; import { money, moneyCompact } from "./format.js"; import * as api from "./api.js"; -import { resolveLoad } from "./routing.js"; +import { appCodeLink, readHash, resolveLoad } from "./routing.js"; import * as screens from "./screens.js"; import { appearanceFor, cardPlan, loadStripeJs, mountCard, publishableKey, type CardFailure, type ConfirmOutcome } from "./stripe.js"; import { Store, type StorageLike } from "./store.js"; import { STEPS } from "./domain.js"; -import type { Method, OrderRecord, SessionRecord, Step, Theme } from "./domain.js"; +import type { App, Method, OrderRecord, SessionRecord, Step, Theme } from "./domain.js"; import { EMBED_READY, HEIGHT_MESSAGE, NAV_MESSAGE, THEME_MESSAGE, bgFromMessage, isNewPurchaseMessage, returnUrlFromMessage, routeFromMessage, themeFromMessage, trustedHost } from "./embed.js"; const app = document.getElementById("app"); @@ -268,7 +268,8 @@ const panels: HTMLElement[] = []; let index = 0; function landingIndex(): number { - const fromHash = STEPS.findIndex((s) => HASHES[s] === location.hash); + const { route } = readHash(location.hash); + const fromHash = STEPS.findIndex((s) => HASHES[s] === route); return fromHash >= 0 ? fromHash + 1 : 0; } @@ -486,7 +487,7 @@ async function pay(): Promise { const since = flow.epoch; let outcome: CheckoutOutcome; try { - outcome = await flow.checkout(sel); + outcome = await flow.checkout(sel, session.app); } finally { checkoutInFlight = false; } @@ -507,7 +508,7 @@ async function pay(): Promise { } case "catalogChanged": root.replaceChildren(screens.catalogChanged(() => { - newInvoice(); + newInvoice(session.app); })); return; case "rateLimited": { @@ -606,7 +607,7 @@ async function cancelInvoice(orderId: string): Promise { return; } store.markCanceled(orderId); - newInvoice(); + newInvoice(store.order(orderId)?.app); } // The Cancel button's disabled attribute does not survive a repaint, so this flag allows one cancel at a time. @@ -631,8 +632,10 @@ function resetToLanding(nav: "push" | "replace"): void { announceLocation(); } -function newInvoice(): void { +// A purchase started over keeps the app that opened the page, or its order would end by showing the code. +function newInvoice(openedBy: App | undefined): void { store.clearSession(); + if (openedBy !== undefined) store.saveSession({ app: openedBy }); resetToLanding("replace"); } @@ -664,10 +667,10 @@ function paint(view: PaymentView): void { syncChrome(); switch (view.screen) { case "unknownOrder": - root.replaceChildren(screens.unknownOrder(newInvoice)); + root.replaceChildren(screens.unknownOrder(() => { newInvoice(store.session().app); })); return; case "codeIssued": - root.replaceChildren(screens.codeIssued({ code: view.code, savedLocally: view.savedLocally })); + root.replaceChildren(codeScreen(view)); return; case "paidNoCode": root.replaceChildren(screens.paidNoCode({ order: view.order, settledAt: view.invoice?.settledAt })); @@ -695,7 +698,7 @@ function paint(view: PaymentView): void { root.replaceChildren(screens.windowClosed({ order: view.order, invoice: view.invoice, offline: isOffline(), canceled: store.order(view.order.orderId)?.canceled === true, - onNewInvoice: newInvoice, + onNewInvoice: () => { newInvoice(view.order.app); }, })); return; case "cardForm": @@ -704,12 +707,38 @@ function paint(view: PaymentView): void { case "detailsUnavailable": root.replaceChildren(screens.detailsUnavailable({ order: view.order, - onCheckAgain: () => checkAgain(view.order.orderId), onNewInvoice: newInvoice, + onCheckAgain: () => checkAgain(view.order.orderId), onNewInvoice: () => { newInvoice(view.order.app); }, })); return; } } +type CodeView = Extract; + +const codeRevealed = new Set(); +const appLaunched = new Set(); + +function codeScreen(view: CodeView): HTMLElement { + const { orderId } = view.order; + // An unsaved code exists only on this screen, so it is never held back behind a link that may fail. + if (view.order.app !== "mobile" || !view.savedLocally) { + return screens.codeIssued({ code: view.code, savedLocally: view.savedLocally, app: view.order.app }); + } + const link = appCodeLink(view.code); + const ending = screens.returnToApp({ + onReturn: () => { location.href = link; }, + code: codeRevealed.has(orderId) + ? { kind: "shown", code: view.code, savedLocally: view.savedLocally } + : { kind: "hidden", onShow: () => { codeRevealed.add(orderId); paint(view); } }, + }); + // A connectivity event repaints this screen, and each repaint must not fire the scheme again. + if (!appLaunched.has(orderId)) { + appLaunched.add(orderId); + ending.append(screens.appLauncher(link)); + } + return ending; +} + let cardElementDestroy: (() => void) | null = null; let cardConfirmPending = false; @@ -783,7 +812,7 @@ function cardFailureScreen(view: CardView, reason: CardFailure): HTMLElement { return screens.cardUnavailable({ order: view.order, reason, onRetry: () => { renderCardForm(view); }, - onNewInvoice: newInvoice, + onNewInvoice: () => { newInvoice(view.order.app); }, }); } @@ -925,6 +954,10 @@ function registerServiceWorker(): void { // The shell prerenders the welcome page, so the document is that page until the first render, and a load resolving to a codes or order screen never mounts the wizard for measureWelcome to read. if (embedded && welcomeHeight <= 0) welcomeHeight = Math.ceil(document.documentElement.getBoundingClientRect().height); +// Read before the first sync rewrites the hash, which drops the flag from the URL. +const entryApp = readHash(location.hash).app; +if (entryApp !== undefined) store.saveSession({ app: entryApp }); + syncFromLocation(true); document.documentElement.classList.remove("sb-booting"); registerServiceWorker(); diff --git a/apps/simplex-badge-service/web/src/order.ts b/apps/simplex-badge-service/web/src/order.ts index 0370c55135..6bb40aa1ba 100644 --- a/apps/simplex-badge-service/web/src/order.ts +++ b/apps/simplex-badge-service/web/src/order.ts @@ -138,6 +138,7 @@ function localState(base: OrderRecord, memory: OrderRecord | undefined, method: submitted: memory?.submitted === true || base.submitted === true ? true : undefined, canceled: memory?.canceled === true || base.canceled === true ? true : undefined, method: method ?? base.method, + app: memory?.app ?? base.app, }; } diff --git a/apps/simplex-badge-service/web/src/routing.ts b/apps/simplex-badge-service/web/src/routing.ts index f5b50cbcb2..8557e3e9c6 100644 --- a/apps/simplex-badge-service/web/src/routing.ts +++ b/apps/simplex-badge-service/web/src/routing.ts @@ -1,4 +1,4 @@ -import type { Method, OrderRecord } from "./domain.js"; +import type { App, Method, OrderRecord } from "./domain.js"; export type Screen = "awaitingPayment" | "awaitingConfirmation" | "cardForm"; @@ -6,6 +6,24 @@ export type Load = { kind: "order"; orderId: string } | { kind: "step" }; export interface UrlParts { search: string } +export interface HashParts { route: string; app: App | undefined } + +// The flag rides in the fragment, which is never sent to the service nor carried in a Referer. +export function readHash(hash: string): HashParts { + const at = hash.indexOf("?"); + if (at < 0) return { route: hash, app: undefined }; + const flag = new URLSearchParams(hash.slice(at + 1)).get("app"); + return { + route: hash.slice(0, at), + app: flag === "true" ? "mobile" : flag === "desktop" ? "desktop" : undefined, + }; +} + +// A bearer token: whoever reads this URL can redeem the badge. +export function appCodeLink(code: string): string { + return `simplexchat:/badge/code/${encodeURIComponent(code)}`; +} + export function resolveLoad(url: UrlParts, newestOpen: OrderRecord | undefined): Load { const orderId = new URLSearchParams(url.search).get("order"); if (orderId) return { kind: "order", orderId }; diff --git a/apps/simplex-badge-service/web/src/screens.ts b/apps/simplex-badge-service/web/src/screens.ts index 04cb54f756..8ebda80496 100644 --- a/apps/simplex-badge-service/web/src/screens.ts +++ b/apps/simplex-badge-service/web/src/screens.ts @@ -3,7 +3,7 @@ import { badgeIcon, chevronLeft, hamburger, hasBadgeArt, methodMark, wefunderMar import { paymentUri, qrSvg } from "./qr.js"; import type { HistoryRow, UnpaidOrder } from "./order.js"; import type { InvoiceView } from "./api.js"; -import { THEMES, type Method, type Theme } from "./domain.js"; +import { THEMES, type App, type Method, type Theme } from "./domain.js"; import type { CardFailure } from "./stripe.js"; import { CROWDFUNDING_ACTIVE, WEFUNDER_URL, dollars } from "./crowdfunding.js"; @@ -785,9 +785,18 @@ export function windowClosed(o: WindowClosedOptions): HTMLElement { export interface CodeIssuedOptions { code: string; savedLocally: boolean; + app?: App | undefined; } -export function codeIssued(o: CodeIssuedOptions): HTMLElement { +export const REDEEM_IN_SETTINGS = "Settings → Supporter perks → Redeem code"; +export const REDEEM_SCREEN_OPEN = "The Redeem code screen is already open."; + +function codeWithCopy(code: string): HTMLElement[] { + const copy = copyControl("Copy code", code, "primary outline"); + return [el("div", { class: "code" }, code), copy.control, copy.status]; +} + +function codeDetails(o: CodeIssuedOptions): HTMLElement { const onlyCopy = o.savedLocally ? el("div", { class: "warn" }, el("span", { class: "title" }, "This is the only copy."), @@ -796,29 +805,69 @@ export function codeIssued(o: CodeIssuedOptions): HTMLElement { : el("div", { class: "warn" }, el("span", { class: "title" }, "This code could not be saved in this browser."), el("p", {}, "Copy it now. It is shown here and nowhere else.")); - const copy = copyControl("Copy code", o.code, "primary outline"); - const p = panel( - el("div", { class: "tick" }, "✓"), - el("h1", { class: "tight center" }, "Paid. Here is your code."), - el("div", { class: "code" }, o.code), - copy.control, - copy.status, - ); - const qr = qrFigure(o.code, "Badge code as a scannable code", "scan to carry it to your phone"); - const details = el("div", { class: "details" }, + return el("div", { class: "details" }, el("div", { class: "rows plain" }, el("span", { class: "label" }, "Redeem it in the app"), - el("div", {}, "Settings → Supporter perks → Redeem code"), + el("div", {}, o.app === "desktop" ? REDEEM_SCREEN_OPEN : REDEEM_IN_SETTINGS), ), onlyCopy, ); +} + +export function codeIssued(o: CodeIssuedOptions): HTMLElement { + const p = panel( + el("div", { class: "tick" }, "✓"), + el("h1", { class: "tight center" }, "Paid. Here is your code."), + ...codeWithCopy(o.code), + ); + const qr = qrFigure(o.code, "Badge code as a scannable code", "scan to carry it to your phone"); const split = el("div", { class: "split" }); if (qr !== null) split.append(qr); - split.append(details); + split.append(codeDetails(o)); p.append(split); return p; } +export const RETURN_TO_APP = "Return to SimpleX"; +export const SHOW_CODE = "Show code"; + +export type AppCode = + | { kind: "hidden"; onShow: () => void } + | { kind: "shown"; code: string; savedLocally: boolean }; + +export interface ReturnToAppOptions { + onReturn: () => void; + code: AppCode; +} + +// The buyer is on the phone the code is for, so the revealed code comes without the QR that would carry it there. +export function returnToApp(o: ReturnToAppOptions): HTMLElement { + const p = panel( + el("div", { class: "tick" }, "✓"), + el("h1", { class: "tight" }, "Paid"), + el("p", { class: "lede" }, + el("span", { class: "line" }, "Your badge is ready."), " ", + el("span", { class: "line" }, "Return to SimpleX to add it."), + ), + button(RETURN_TO_APP, o.onReturn), + ); + p.classList.add("ending"); + switch (o.code.kind) { + case "hidden": + p.append(el("p", { class: "row-line center" }, button(SHOW_CODE, o.code.onShow, "link"))); + return p; + case "shown": + p.append(...codeWithCopy(o.code.code), el("div", { class: "split" }, codeDetails(o.code))); + return p; + } +} + +// A refused or unhandled scheme fails inside this frame, so no error page replaces the screen and no entry +// joins the history; the frame's first navigation replaces its about:blank. +export function appLauncher(link: string): HTMLIFrameElement { + return el("iframe", { src: link, hidden: "", "aria-hidden": "true", tabindex: "-1" }); +} + const MONTHS: readonly string[] = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", diff --git a/apps/simplex-badge-service/web/src/store.ts b/apps/simplex-badge-service/web/src/store.ts index 767b63e780..339334c77a 100644 --- a/apps/simplex-badge-service/web/src/store.ts +++ b/apps/simplex-badge-service/web/src/store.ts @@ -1,5 +1,5 @@ import { - composeOrder, readChain, readMethod, readStatus, readStep, readTheme, + composeOrder, readApp, readChain, readMethod, readStatus, readStep, readTheme, type OrderRecord, type SessionRecord, type Theme, } from "./domain.js"; import { asObject, filledText, flag, positiveInteger, text, nonNegativeInteger } from "./parse.js"; @@ -36,7 +36,7 @@ function readOrder(value: unknown): OrderRecord | undefined { badgeType: text(o.badgeType) ?? "", months: positiveInteger(o.months) ?? 0, }, - { code: text(o.code), submitted: flag(o.submitted), canceled: flag(o.canceled), method: readMethod(o.method) }, + { code: text(o.code), submitted: flag(o.submitted), canceled: flag(o.canceled), method: readMethod(o.method), app: readApp(o.app) }, { status, amount: positiveInteger(o.amount), @@ -61,9 +61,11 @@ function readSession(value: unknown): SessionRecord | undefined { const priceId = text(s.priceId); const offerId = text(s.offerId); const method = readMethod(s.method); + const app = readApp(s.app); if (priceId !== undefined) session.priceId = priceId; if (offerId !== undefined) session.offerId = offerId; if (method !== undefined) session.method = method; + if (app !== undefined) session.app = app; return session; } @@ -119,7 +121,7 @@ export class Store { return list.flatMap((o) => readOrder(o) ?? []); } - // The record replaces the stored entry, but the browser-only fields (code, submitted, canceled) are preserved because the service never sends them back. + // The record replaces the stored entry, but the browser-only fields (code, submitted, canceled, app) are preserved because the service never sends them back. saveOrder(record: OrderRecord): boolean { const list = this.orders(); const at = list.findIndex((o) => o.orderId === record.orderId); @@ -130,6 +132,7 @@ export class Store { ...(record.code === undefined && kept.code !== undefined ? { code: kept.code } : {}), ...(kept.submitted === true ? { submitted: true } : {}), ...(kept.canceled === true ? { canceled: true } : {}), + ...(record.app === undefined && kept.app !== undefined ? { app: kept.app } : {}), }; } else { if (list.length >= CAP) { diff --git a/apps/simplex-badge-service/web/test/main-app-desktop.test.ts b/apps/simplex-badge-service/web/test/main-app-desktop.test.ts new file mode 100644 index 0000000000..4c6d956cb0 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-desktop.test.ts @@ -0,0 +1,50 @@ +// Opened by the desktop app, which has no scheme handler: the code is shown as ever, and the app has Redeem code open beside it. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import type { StubElement } from "./stub-dom.js"; + +const appTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ url: "/#/tier?app=desktop" }); +const { app, history, storage, fetches } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); + +appTest("main: #/tier?app=desktop lands on Choose your badge", () => { + assert.equal(headingOf(inView()), "Choose your badge"); + assert.equal(history.url, "/#/tier"); +}); + +appTest("main: paid from the desktop app, the code screen says Redeem code is already open", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("1 month"))!.click(); + primaryOf(inView())!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_desk", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "0.02", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 0.02 XMR", `the payment screen, not ${heading()}`); + for (const f of fetches) { + const sent = `${f.url} ${String(f.init?.body ?? "")}`; + assert.ok(!/app=|"app"|desktop/.test(sent), `no request names the app: ${sent}`); + } + + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_desk"); + await until(() => heading() === "Paid. Here is your code.", `the code screen, not ${heading()}`); + + const code = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<{ code: string }>)[0]!.code; + assert.equal(screenOf(app).all("div.code")[0]!.textContent, code, "the code, shown as it always was"); + assert.ok(screenOf(app).textContent.includes("The Redeem code screen is already open.")); + assert.ok(!screenOf(app).textContent.includes("Settings → Supporter perks"), "the one changed line"); + assert.equal(screenOf(app).all("iframe").length, 0, "desktop has no scheme to try"); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-mobile.test.ts b/apps/simplex-badge-service/web/test/main-app-mobile.test.ts new file mode 100644 index 0000000000..40c5682a48 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-mobile.test.ts @@ -0,0 +1,119 @@ +// Opened by the mobile app: the code leaves by the simplexchat: link, and the page shows it only when asked. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, settle, timedTest, until } from "./boot.js"; +import type { StubElement } from "./stub-dom.js"; + +const appTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ url: "/#/tier?app=true" }); +const { app, history, location, storage, fetches } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); +const stored = (): Record => + (JSON.parse(storage.getItem("sb.orders.v1") ?? "[]") as Array>)[0] ?? {}; + +appTest("main: #/tier?app=true lands on Choose your badge, with the flag in the session and off the URL", () => { + assert.equal(headingOf(inView()), "Choose your badge"); + assert.equal(history.url, "/#/tier", "the tail is dropped once read"); + assert.deepEqual(history.stack, ["/", "/#/tier"], "and Back still reaches the landing"); + assert.equal((JSON.parse(storage.getItem("sb.session.v1")!) as { app?: unknown }).app, "mobile"); +}); + +appTest("main: the flag survives the steps and is stamped on the order, and no request carries it", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!.click(); + primaryOf(inView())!.click(); + assert.equal(headingOf(inView()), "Check your order"); + + page.respondWith({ status: 200, body: { + invoiceId: "inv_mob", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "1.482", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 1.482 XMR", `the payment screen, not ${heading()}`); + + assert.equal(location.search, "?order=inv_mob"); + assert.equal(stored().app, "mobile", "the order carries the flag across the ?order= round trip"); + assert.equal(storage.getItem("sb.session.v1"), null, "the session that carried it here is done"); + const created = fetches.find((f) => f.url === "/api/invoice")!; + assert.deepEqual(Object.keys(JSON.parse(String(created.init!.body)) as object).sort(), + ["codeHash", "method", "offerId", "priceId"], "the service is told nothing about the app"); + for (const f of fetches) { + const sent = `${f.url} ${String(f.init?.body ?? "")}`; + assert.ok(!/app=|"app"|mobile/.test(sent), `no request names the app: ${sent}`); + } +}); + +appTest("main: once paid, the ending tries the link once and holds the code back", async () => { + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_mob"); + await until(() => heading() === "Paid", `the return-to-app ending, not ${heading()}`); + + const code = stored().code as string; + const link = `simplexchat:/badge/code/${code}`; + const frames = screenOf(app).all("iframe"); + assert.equal(frames.length, 1, "one automatic attempt"); + assert.equal(frames[0]!.getAttribute("src"), link); + assert.ok(frames[0]!.hasAttribute("hidden"), "a refusal stays inside a frame nobody sees"); + assert.equal(screenOf(app).serialize().split(code).length - 1, 1, "the frame's src is the only place the code appears"); + assert.ok(!screenOf(app).textContent.includes(code), "and none of it is text on the screen"); +}); + +appTest("main: a repaint keeps the ending but does not fire the link again", async () => { + page.setOffline(true); + await settle(); + page.setOffline(false); + await settle(); + assert.equal(heading(), "Paid"); + assert.equal(screenOf(app).all("iframe").length, 0); +}); + +appTest("main: with the automatic attempt refused, Return to SimpleX still navigates, off the page's history", () => { + const before = [...history.stack]; + screenOf(app).all("button.primary").find((b) => b.textContent === "Return to SimpleX")!.click(); + assert.equal((location as { href?: string }).href, `simplexchat:/badge/code/${stored().code as string}`); + assert.deepEqual(history.stack, before, "the page adds no history entry of its own"); + assert.equal(heading(), "Paid", "and the screen is still there to fall back on"); +}); + +appTest("main: Show code reveals the stored code in place, keeps Return to SimpleX, and it stays revealed", async () => { + screenOf(app).all("button.link").find((b) => b.textContent === "Show code")!.click(); + const code = stored().code as string; + const revealed = (): void => { + assert.equal(heading(), "Paid", "still the ending, not the plain code screen"); + assert.equal(screenOf(app).all("div.code")[0]!.textContent, code); + assert.equal(screenOf(app).all("svg").filter((s) => s.getAttribute("class") === "qr").length, 0, "no QR"); + assert.ok(screenOf(app).all("button.primary").some((b) => b.textContent === "Return to SimpleX")); + }; + revealed(); + assert.equal(screenOf(app).all("iframe").length, 0, "revealing does not fire the link again"); + + page.clipboard.writes.length = 0; + screenOf(app).all("button.primary").find((b) => b.textContent === "Copy code")!.click(); + await settle(); + assert.deepEqual(page.clipboard.writes, [code]); + + page.setOffline(true); + await settle(); + page.setOffline(false); + await settle(); + revealed(); + + screenOf(app).all("button.primary").find((b) => b.textContent === "Return to SimpleX")!.click(); + assert.equal((location as { href?: string }).href, `simplexchat:/badge/code/${code}`, "the button still works once the code is out"); +}); + +appTest("main: nothing the page wrote to history or storage outside the order holds the link", () => { + for (const url of history.stack) assert.ok(!url.includes("simplexchat") && !url.includes("SB-"), url); + for (const [key, value] of storage.m) { + if (key !== "sb.orders.v1") assert.ok(!value.includes("SB-") && !value.includes("simplexchat"), key); + } +}); diff --git a/apps/simplex-badge-service/web/test/main-app-reload.test.ts b/apps/simplex-badge-service/web/test/main-app-reload.test.ts new file mode 100644 index 0000000000..f372dd7ec8 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-reload.test.ts @@ -0,0 +1,31 @@ +// A reload mid-purchase: the flag is off the URL by now, so only the session can still carry it to the order. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage } from "./stub-dom.js"; + +const reloadTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +const storage = new MemStorage(); +storage.setItem("sb.session.v1", JSON.stringify({ step: "months", priceId: "price_legend", offerId: "offer_12m", app: "mobile" })); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage, url: "/#/months" }); +const { app } = page; +await import("../src/main.js"); + +reloadTest("main: after a reload the session still carries the flag, and the order it makes is stamped", async () => { + assert.equal(headingOf(inViewOf(app)), "How long?"); + assert.equal((JSON.parse(storage.getItem("sb.session.v1")!) as { app?: unknown }).app, "mobile"); + + primaryOf(inViewOf(app))!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_reload", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "1.482", cryptoCurrency: "xmr", + } }); + inViewOf(app).all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => headingOf(screenOf(app)) === "Send 1.482 XMR", "the payment screen"); + const order = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<{ app?: string }>)[0]!; + assert.equal(order.app, "mobile"); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-return.test.ts b/apps/simplex-badge-service/web/test/main-app-return.test.ts new file mode 100644 index 0000000000..cfb53206b4 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-return.test.ts @@ -0,0 +1,38 @@ +// A mobile-app order comes back from Stripe with neither the query nor the fragment, so the ending is read off the order alone. +// This is also the reload after Show code: a fresh load puts the ending back, with the code one tap away. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, installPage, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage } from "./stub-dom.js"; + +const returnTest = timedTest(3000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); +const CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP"; + +const storage = new MemStorage(); +storage.setItem("sb.orders.v1", JSON.stringify([{ + orderId: "inv_card", badgeType: "supporter", months: 1, + createdAt: new Date(NOW - 60_000).toISOString(), status: "paid", paidInFull: true, + submitted: true, code: CODE, amount: 700, currency: "usd", method: "card", app: "mobile", +}])); +storage.setItem("sb.cardReturn.v1", JSON.stringify({ orderId: "inv_card", at: NOW - 5_000 })); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage, url: "/?payment_intent=pi_1&redirect_status=succeeded" }); +const { app } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); + +returnTest("main: back from Stripe, the order's own flag picks the return-to-app ending", async () => { + await until(() => heading() === "Paid", `the return-to-app ending, not ${heading()}`); + assert.equal(page.location.search, "?order=inv_card"); + assert.equal(screenOf(app).all("iframe")[0]!.getAttribute("src"), `simplexchat:/badge/code/${CODE}`); + assert.ok(!screenOf(app).textContent.includes(CODE)); +}); + +returnTest("main: Show code reveals the stored code", () => { + screenOf(app).all("button.link").find((b) => b.textContent === "Show code")!.click(); + assert.equal(heading(), "Paid"); + assert.equal(screenOf(app).all("div.code")[0]!.textContent, CODE); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts b/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts new file mode 100644 index 0000000000..ddead5d2ef --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts @@ -0,0 +1,49 @@ +// Opened by the mobile app, but every stored row already holds a code, so the new order's code cannot be saved. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage, type StubElement } from "./stub-dom.js"; + +const unsavedTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +const full = new MemStorage(); +full.m.set("sb.orders.v1", JSON.stringify( + Array.from({ length: 50 }, (_, i) => ({ + orderId: `inv_old_${i}`, badgeType: "supporter", months: 1, + createdAt: new Date(NOW - (i + 2) * 86_400_000).toISOString(), status: "paid", paidInFull: true, + code: `SB-OLD${String(i).padStart(2, "0")}-YGQTM-PUYZ9-2TUXP`, amount: 700, currency: "usd", + })), +)); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage: full, url: "/#/tier?app=true" }); +const { app } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); + +unsavedTest("main: a code this browser could not save is shown at once, not held back behind the link", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("1 month"))!.click(); + primaryOf(inView())!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_unsaved", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "0.02", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 0.02 XMR", `the payment screen, not ${heading()}`); + + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_unsaved"); + await until(() => heading() === "Paid. Here is your code.", `the plain code screen, not ${heading()}`); + + assert.match(screenOf(app).all("div.code")[0]!.textContent, /^SB-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}$/); + assert.ok(screenOf(app).textContent.includes("This code could not be saved in this browser.")); + assert.equal(screenOf(app).all("iframe").length, 0, "no link is tried"); + assert.equal(screenOf(app).all("button").filter((b) => b.textContent === "Show code").length, 0); +}); diff --git a/apps/simplex-badge-service/web/test/main-card-full-store.test.ts b/apps/simplex-badge-service/web/test/main-card-full-store.test.ts index bad13b57ef..57cc836222 100644 --- a/apps/simplex-badge-service/web/test/main-card-full-store.test.ts +++ b/apps/simplex-badge-service/web/test/main-card-full-store.test.ts @@ -22,7 +22,7 @@ mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); const page = installPage({ storage: full }); const { app } = page; -// The publishable key makes the card path use the real Payment Element, not a stand-in. +// The publishable key is what makes the card path exist at all; with none, it refuses. const keyMeta = new StubElement("meta"); keyMeta.setAttribute("id", "stripe-publishable-key"); keyMeta.setAttribute("name", "stripe-publishable-key"); diff --git a/apps/simplex-badge-service/web/test/main-card-return.test.ts b/apps/simplex-badge-service/web/test/main-card-return.test.ts index 53752dff42..b4277a9b59 100644 --- a/apps/simplex-badge-service/web/test/main-card-return.test.ts +++ b/apps/simplex-badge-service/web/test/main-card-return.test.ts @@ -37,6 +37,9 @@ returnTest("main: a card return resumes the exact order it was for, not the newe assert.ok(codeShape.test(screenOf(app).serialize()), "the code the buyer paid for is on it"); assert.ok(screenOf(app).serialize().includes(CODE), "and it is the code of inv_card, the order that was paid"); assert.ok(!screenOf(app).serialize().includes("48HqK2Xmv"), "not the newer open order's address"); + assert.ok(screenOf(app).textContent.includes("Settings → Supporter perks → Redeem code"), + "an order bought with no app flag ends as the page always has"); + assert.equal(screenOf(app).all("iframe").length, 0, "with no link to try"); }); returnTest("main: the return is spent — the URL is the order and the remembered id is cleared", () => { diff --git a/apps/simplex-badge-service/web/test/routing.test.ts b/apps/simplex-badge-service/web/test/routing.test.ts index 67ff1c2151..f4f704505a 100644 --- a/apps/simplex-badge-service/web/test/routing.test.ts +++ b/apps/simplex-badge-service/web/test/routing.test.ts @@ -1,6 +1,6 @@ import { timedTest } from "./boot.js"; import assert from "node:assert/strict"; -import { resolveLoad, screenForOpenOrder } from "../src/routing.js"; +import { appCodeLink, readHash, resolveLoad, screenForOpenOrder } from "../src/routing.js"; import { type OrderRecord } from "../src/domain.js"; import { Store } from "../src/store.js"; @@ -68,3 +68,20 @@ routingTest("routing: the flag survives clearSession, a second order and a reloa assert.equal(reloaded.order("inv_second")!.submitted, undefined, "and it did not spread to the order that followed it"); }); + +routingTest("routing: the app flag is read off the hash's tail, and the route is the hash without it", () => { + assert.deepEqual(readHash("#/tier?app=true"), { route: "#/tier", app: "mobile" }); + assert.deepEqual(readHash("#/tier?app=desktop"), { route: "#/tier", app: "desktop" }); + assert.deepEqual(readHash("#/tier"), { route: "#/tier", app: undefined }, "no tail, no flag"); + assert.deepEqual(readHash(""), { route: "", app: undefined }); +}); + +routingTest("routing: an app value outside the contract is no flag at all", () => { + for (const tail of ["app=", "app=false", "app=TRUE", "app=ios", "other=true"]) { + assert.deepEqual(readHash(`#/tier?${tail}`), { route: "#/tier", app: undefined }, tail); + } +}); + +routingTest("routing: the link back to the app is the simplexchat: scheme with the code as its last segment", () => { + assert.equal(appCodeLink("SB-YDC8A-YGQTM-PUYZ9-2TUXP"), "simplexchat:/badge/code/SB-YDC8A-YGQTM-PUYZ9-2TUXP"); +}); diff --git a/apps/simplex-badge-service/web/test/screens.test.ts b/apps/simplex-badge-service/web/test/screens.test.ts index efe74e6535..50370be92b 100644 --- a/apps/simplex-badge-service/web/test/screens.test.ts +++ b/apps/simplex-badge-service/web/test/screens.test.ts @@ -688,6 +688,66 @@ domTest("screens: the code screen is the one screen that prints a code, with the assert.deepEqual(copied, [HELD_CODE]); }); +domTest("screens: opened from the desktop app, the code screen says the Redeem code screen is already open", () => { + const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true, app: "desktop" })); + assert.ok(p.textContent.includes("Redeem it in the app")); + assert.ok(p.textContent.includes(screens.REDEEM_SCREEN_OPEN)); + assert.ok(!p.textContent.includes(screens.REDEEM_IN_SETTINGS), "one way to redeem, the one already on screen"); + assert.ok(p.textContent.includes(HELD_CODE), "and the code itself is unchanged"); +}); + +domTest("screens: the return-to-app ending opens on the tick, offers the link and Show code, and prints no code", () => { + let returned = 0; + let shown = 0; + const p = render(screens.returnToApp({ + onReturn: () => { returned += 1; }, + code: { kind: "hidden", onShow: () => { shown += 1; } }, + })); + assert.equal(p.children[0], p.all("div.tick")[0], "the payment went through, and this is the first thing that says so"); + assert.equal(p.all("div.tick")[0]!.textContent, "✓"); + assert.equal(p.all("h1")[0]!.textContent, "Paid"); + assert.ok(p.textContent.includes("Your badge is ready.")); + assert.ok(p.textContent.includes("Return to SimpleX to add it.")); + assertNoCode(p, "the return-to-app ending"); + p.all("button.primary").find((b) => b.textContent === screens.RETURN_TO_APP)!.click(); + p.all("button.link").find((b) => b.textContent === screens.SHOW_CODE)!.click(); + assert.deepEqual([returned, shown], [1, 1]); +}); + +domTest("screens: Show code reveals the code in place, under a Return to SimpleX that stays, and with no QR", () => { + copied.length = 0; + let returned = 0; + const p = render(screens.returnToApp({ + onReturn: () => { returned += 1; }, + code: { kind: "shown", code: HELD_CODE, savedLocally: true }, + })); + assert.equal(p.all("h1")[0]!.textContent, "Paid", "the ending stays the ending"); + assert.equal(p.all("div.tick").length, 1); + assert.equal(p.all("div.code")[0]!.textContent, HELD_CODE); + assert.equal(p.all("button").filter((b) => b.textContent === screens.SHOW_CODE).length, 0, "Show code has given way"); + assert.equal(p.all("svg").filter((s) => s.getAttribute("class") === "qr").length, 0, + "the buyer is already on the phone, so there is nothing to scan it to"); + assert.ok(!p.textContent.includes("scan to carry it to your phone")); + for (const line of ["Redeem it in the app", screens.REDEEM_IN_SETTINGS, "This is the only copy.", + "Saved in this browser and nowhere else."]) { + assert.ok(p.textContent.includes(line), `the revealed code is missing: ${line}`); + } + + p.all("button.primary").find((b) => b.textContent === "Copy code")!.click(); + assert.deepEqual(copied, [HELD_CODE], "the copy control copies the code"); + p.all("button.primary").find((b) => b.textContent === screens.RETURN_TO_APP)!.click(); + assert.equal(returned, 1, "and the link is still one tap away"); +}); + +domTest("screens: the launcher is a hidden frame out of the tab order, pointed at the link", () => { + const f = render(screens.appLauncher("simplexchat:/badge/code/X")); + assert.equal(f.tagName, "iframe"); + assert.equal(f.getAttribute("src"), "simplexchat:/badge/code/X"); + assert.ok(f.hasAttribute("hidden")); + assert.equal(f.getAttribute("aria-hidden"), "true"); + assert.equal(f.getAttribute("tabindex"), "-1"); +}); + domTest("screens: the code screen drops its saved-copy clause where the local write failed", () => { const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: false })); assert.ok(p.textContent.includes("This code could not be saved in this browser.")); diff --git a/apps/simplex-badge-service/web/test/store.test.ts b/apps/simplex-badge-service/web/test/store.test.ts index d4e8c5a8c4..08ce22b8f5 100644 --- a/apps/simplex-badge-service/web/test/store.test.ts +++ b/apps/simplex-badge-service/web/test/store.test.ts @@ -190,6 +190,27 @@ storeTest("store: the two facts only this browser holds survive a write that omi assert.equal(s.order("1")!.submitted, true); }); +storeTest("store: the app that opened the page round-trips the session, and a value outside the contract reads as none", () => { + const mem = new MemoryStorage(); + const s = new Store(mem); + s.saveSession({ app: "mobile" }); + s.saveSession({ step: "months", priceId: "price_legend" }); + assert.equal(new Store(mem).session().app, "mobile", "a later step and a reload keep it"); + mem.setItem("sb.session.v1", JSON.stringify({ step: "tier", app: "true" })); + assert.equal(s.session().app, undefined, "the stored value is the page's own word, never the URL's"); +}); + +storeTest("store: an order keeps the app it was bought from through a write that omits it and a reload", () => { + const mem = new MemoryStorage(); + const s = new Store(mem); + s.saveOrder(order("1", { app: "desktop" })); + s.saveOrder(order("1", { status: "paid" })); + assert.equal(s.order("1")!.app, "desktop", "the service never sends it, so its answer cannot clear it"); + assert.equal(new Store(mem).order("1")!.app, "desktop"); + s.saveOrder(order("2")); + assert.equal(s.order("2")!.app, undefined, "and it is not a page-wide flag"); +}); + storeTest("store: markSubmitted is per order, sticky, and survives everything but Forget", () => { const mem = new MemoryStorage(); const s = new Store(mem); diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index 9c2376588d..4ec87a9f69 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -25,6 +25,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.List (isPrefixOf) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -92,7 +93,9 @@ data DirectoryEntry = DirectoryEntry simplexName :: Maybe Text, groupLink :: PublicLink, shortDescr :: Maybe MarkdownList, + shortDescrBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), imageFile :: Maybe String, activeAt :: Maybe UTCTime, createdAt :: Maybe UTCTime @@ -132,6 +135,8 @@ groupDirectoryEntry now g@GroupInfo {groupProfile} gLink_ = linkLine = groupLinkLine displayName $ decodeUtf8 linkText in Just $ maybe linkLine (<> "\n\n" <> linkLine) description _ -> description + shortDescr' = toFormattedText <$> shortDescr + welcomeMessage' = toFormattedText <$> description' entry groupLink = let de = DirectoryEntry @@ -139,8 +144,10 @@ groupDirectoryEntry now g@GroupInfo {groupProfile} gLink_ = displayName, simplexName = groupSimplexName g, groupLink, - shortDescr = toFormattedText <$> shortDescr, - welcomeMessage = toFormattedText <$> description', + shortDescr = fst <$> shortDescr', + shortDescrBlocks = snd =<< shortDescr', + welcomeMessage = fst <$> welcomeMessage', + welcomeMessageBlocks = snd =<< welcomeMessage', imageFile = fst <$> imgData, activeAt = groupActiveAt now g, createdAt = groupCreatedAt now g @@ -210,5 +217,5 @@ generateListing dir gs = do where saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst -toFormattedText :: Text -> MarkdownList -toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t +toFormattedText :: Text -> (MarkdownList, Maybe (NonEmpty MarkdownBlock)) +toFormattedText t = let ParsedMarkdown ft blocks = parsedMarkdown t in (fromMaybe [FormattedText Nothing t] ft, blocks) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 79a94ff66c..85ac65b8df 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -1203,10 +1203,10 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." ] - <> ["/'filter " <> tshow gId <> " name' - enable name filter" | isNothing (rejectNames a)] - <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge" | isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " name captcha' - enable both" | isNothing (rejectNames a) || isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " off' - disable filter" | isJust (rejectNames a) || isJust (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name' - enable name filter " | isNothing (rejectNames a)] + <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge " | isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name captcha' - enable both " | isNothing (rejectNames a) || isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " off' - disable filter " | isJust (rejectNames a) || isJust (passCaptcha a)] showCondition = \case Nothing -> "_disabled_" Just PCAll -> "_enabled_" diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f1122979a9..7b3985e132 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -18,6 +18,7 @@ This file is generated automatically. - [BadgeServiceErrorCode](#badgeserviceerrorcode) - [BadgeStatus](#badgestatus) - [BadgeType](#badgetype) +- [BlockToken](#blocktoken) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -131,8 +132,10 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [ListEntry](#listentry) - [LocalBadge](#localbadge) - [LocalProfile](#localprofile) +- [MarkdownBlock](#markdownblock) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) - [MsgContent](#msgcontent) @@ -182,6 +185,7 @@ This file is generated automatically. - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) - [SecurityCode](#securitycode) +- [SegmentRange](#segmentrange) - [SimplePreference](#simplepreference) - [SimplexDomain](#simplexdomain) - [SimplexDomainClaim](#simplexdomainclaim) @@ -201,6 +205,7 @@ This file is generated automatically. - [SubscriptionStatus](#subscriptionstatus) - [SupportGroupPreference](#supportgrouppreference) - [SwitchPhase](#switchphase) +- [TableAlign](#tablealign) - [TimedMessagesGroupPreference](#timedmessagesgrouppreference) - [TimedMessagesPreference](#timedmessagespreference) - [TransportError](#transporterror) @@ -441,6 +446,9 @@ Remote controller app version range (min and max as version strings). InvalidCode: - type: "invalidCode" +InvalidReceipt: +- type: "invalidReceipt" + ServiceNotConfigured: - type: "serviceNotConfigured" @@ -477,12 +485,14 @@ CredentialNotVerified: - "payment_not_entitled" - "payment_pending" - "provider_unavailable" +- "provider_not_configured" - "rate_limited" - "code_invalid" - "code_used" - "code_expired" - "receipt_invalid" - "receipt_used" +- "test_purchase" - "internal" @@ -508,6 +518,64 @@ CredentialNotVerified: - "investor" +--- + +## BlockToken + +**Discriminated union type**: + +LineBreak: +- type: "lineBreak" + +SoftBreak: +- type: "softBreak" + +HardBreak: +- type: "hardBreak" + +ParagraphBreak: +- type: "paragraphBreak" +- lineBreaks: int + +Indent: +- type: "indent" + +HeadingMark: +- type: "headingMark" +- level: int + +HeadingClose: +- type: "headingClose" + +HeadingUnderline: +- type: "headingUnderline" +- level: int + +ListMark: +- type: "listMark" +- number: string? + +QuoteMark: +- type: "quoteMark" + +FenceMark: +- type: "fenceMark" +- language: string? + +RowMark: +- type: "rowMark" + +CellMark: +- type: "cellMark" + +DelimiterRow: +- type: "delimiterRow" +- columns: [[TableAlign](#tablealign)] + +RuleMark: +- type: "ruleMark" + + --- ## BlockingInfo @@ -1002,6 +1070,7 @@ GroupLink: - sentAt: UTCTime - content: [MsgContent](#msgcontent) - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? --- @@ -1500,6 +1569,7 @@ ContactConnection: - content: [CIContent](#cicontent) - mentions: {string : [CIMention](#cimention)} - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? - quotedItem: [CIQuote](#ciquote)? - reactions: [[CIReactionCount](#cireactioncount)] - file: [CIFile](#cifile)? @@ -1540,6 +1610,7 @@ Search: - "human" - "bot" - "business" +- "agent" --- @@ -2277,10 +2348,13 @@ NO_FILE: ## Format +The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + **Discriminated union type**: Bold: - type: "bold" +- singleStar: bool Italic: - type: "italic" @@ -2334,6 +2408,13 @@ Email: Phone: - type: "phone" +Escape: +- type: "escape" + +Markup: +- type: "markup" +- token: [BlockToken](#blocktoken) + --- @@ -2871,6 +2952,16 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## ListEntry + +**Record type**: +- number: string? +- content: [SegmentRange](#segmentrange) +- items: [[ListEntry](#listentry)] + + --- ## LocalBadge @@ -2900,6 +2991,45 @@ Unknown: - contactDomainVerified: bool? +--- + +## MarkdownBlock + +**Discriminated union type**: + +Paragraph: +- type: "paragraph" +- content: [SegmentRange](#segmentrange) + +Heading: +- type: "heading" +- level: int +- content: [SegmentRange](#segmentrange) + +CodeBlock: +- type: "codeBlock" +- language: string? +- content: [SegmentRange](#segmentrange) + +Quote: +- type: "quote" +- content: [SegmentRange](#segmentrange) + +List: +- type: "list" +- items: [[ListEntry](#listentry)] + +Table: +- type: "table" +- columns: [[TableAlign](#tablealign)] +- header: [[SegmentRange](#segmentrange)] +- rows: [[[SegmentRange](#segmentrange)]] + +Rule: +- type: "rule" +- mark: [SegmentRange](#segmentrange) + + --- ## MemberCriteria @@ -3781,6 +3911,15 @@ A_SERVICE: - verifiedAt: UTCTime +--- + +## SegmentRange + +**Record type**: +- from: int +- to: int + + --- ## SimplePreference @@ -4402,6 +4541,17 @@ NoSub: - "completed" +--- + +## TableAlign + +**Enum type**: +- "none" +- "left" +- "center" +- "right" + + --- ## TimedMessagesGroupPreference diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 0599095320..b23d2f0d1d 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -371,6 +371,7 @@ undocumentedCommands = "APIConnectPreparedGroup", "APIContactInfo", "APIContactQueueInfo", + "APICreateBadgeInvoice", "APICreateChatItems", "APICreateChatTag", "APICreateMemberContact", @@ -417,6 +418,7 @@ undocumentedCommands = "APIPlanForwardChatItems", "APIPrepareContact", "APIPrepareGroup", + "APIPurchaseBadge", "APIRedeemBadgeCode", "APIRegisterToken", "APIRejectCall", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index e310fd54ef..a566010823 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -223,6 +223,8 @@ undocumentedEvents = "CEvtSndFileRedirectStartXFTP", "CEvtSndFileStart", -- legacy SMP files "CEvtSndStandaloneFileComplete", + "CEvtStorePurchaseCredited", + "CEvtStorePurchaseRefused", "CEvtConnectionsDiff", "CEvtSubscriptionEnd", "CEvtTerminalEvent", diff --git a/bots/src/API/Docs/Generate/Python.hs b/bots/src/API/Docs/Generate/Python.hs index 64aa1d1062..8d60bced77 100644 --- a/bots/src/API/Docs/Generate/Python.hs +++ b/bots/src/API/Docs/Generate/Python.hs @@ -60,10 +60,7 @@ commandsCodeText = <> respAliasLine where constrName = T.pack $ fstToUpper tag - classDef = - ("class " <> constrName <> "(TypedDict):\n") - <> bodyOrPass (fieldsCodePy " " "T." params) - <> "\n" + classDef = typedDictCode constrName (fieldsCodePy "T." params) <> "\n" cmdStringFunc = ("\ndef " <> constrName <> "_cmd_string(self: " <> constrName <> ") -> str:\n") <> " return " <> pySelfSyntaxText "T." (fstToUpper tag, params) syntax <> "\n" @@ -143,9 +140,7 @@ typesCodeText = "FileProtocol" -> map toUpper m _ -> m typeDefCode = case typeDef of - ATDRecord fields -> - ("\nclass " <> name' <> "(TypedDict):\n") - <> bodyOrPass (fieldsCodePy " " "" fields) + ATDRecord fields -> "\n" <> typedDictCode name' (fieldsCodePy "" fields) ATDEnum cs -> "\n" <> name' <> " = Literal[" <> T.intercalate ", " (map (\m -> "\"" <> T.pack (enumValue m) <> "\"") $ L.toList cs) @@ -231,9 +226,7 @@ unionTypeCodePy memberName typesNamespace name cs = constrTypeRef (ATUnionMember tag _) = memberName name tag tagLiterals = T.intercalate ", " $ map (\(ATUnionMember tag _) -> "\"" <> T.pack tag <> "\"") $ L.toList cs memberClass (ATUnionMember tag fields) = - ("\nclass " <> memberName name tag <> "(TypedDict):\n") - <> (" type: Literal[\"" <> T.pack tag <> "\"]\n") - <> fieldsCodePy " " typesNamespace fields + "\n" <> typedDictCode (memberName name tag) (("type", "Literal[\"" <> T.pack tag <> "\"]", "") : fieldsCodePy typesNamespace fields) -- | Render the right-hand side of a union alias: either inline (one line) or -- multi-line wrapped in parentheses with `|` separators between alternatives. @@ -248,21 +241,27 @@ unionAliasRhs lhs constr cs (h : t) -> h : map ("| " <>) t cs' = map constr cs --- | Emit a body of `pass` if there are no fields, otherwise the rendered --- fields as-is. -bodyOrPass :: Text -> Text -bodyOrPass body - | T.null body = " pass\n" - | otherwise = body +typedDictCode :: Text -> [(Text, Text, Text)] -> Text +typedDictCode name fields + | any (\(field, _, _) -> field `elem` pyKeywords) fields = + (name <> " = TypedDict(\"" <> name <> "\", {\n") + <> foldMap (\(field, t, comment) -> " \"" <> field <> "\": " <> t <> "," <> comment <> "\n") fields + <> "})\n" + | null fields = "class " <> name <> "(TypedDict):\n pass\n" + | otherwise = + ("class " <> name <> "(TypedDict):\n") + <> foldMap (\(field, t, comment) -> " " <> field <> ": " <> t <> comment <> "\n") fields --- | Render record fields for a TypedDict body. Each field becomes --- `: [ # ]`. Optional fields wrap the type in --- `NotRequired[...]`. -fieldsCodePy :: Text -> Text -> [APIRecordField] -> Text -fieldsCodePy indent namespace = foldMap render +pyKeywords :: [Text] +pyKeywords = + ["False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield"] + +-- | Render record fields for a TypedDict as name, type and comment. +-- Optional fields wrap the type in `NotRequired[...]`. +fieldsCodePy :: Text -> [APIRecordField] -> [(Text, Text, Text)] +fieldsCodePy namespace = map render where - render (APIRecordField name t) = - indent <> T.pack name <> ": " <> wrapOptional t (typeText t) <> typeComment t <> "\n" + render (APIRecordField name t) = (T.pack name, wrapOptional t (typeText t), typeComment t) wrapOptional t inner = case t of ATOptional _ -> "NotRequired[" <> inner <> "]" _ -> inner diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index c97e96e302..132e7adf38 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -135,6 +135,7 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeInvoice", "CRBadgeLedger", "CRBadgeRedeemed", "CRBadgeState", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 3adcaef783..ab76cc6581 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -224,6 +224,7 @@ chatTypesDocsData = (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), + (sti @BlockToken, STUnion, "", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), (sti @BusinessChatInfo, STRecord, "", [], "", ""), (sti @BusinessChatType, STEnum, "BC", [], "", ""), @@ -285,7 +286,7 @@ chatTypesDocsData = (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @FileType, STEnum' (consLower "FT"), "", [], "", ""), - (sti @Format, STUnion, "", ["Unknown"], "", ""), + (sti @Format, STUnion, "", ["Unknown"], "", "The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars."), (sti @FormattedText, STRecord, "", [], "", ""), (sti @FullGroupPreferences, STRecord, "", [], "", ""), (sti @FullPreferences, STRecord, "", [], "", ""), @@ -321,8 +322,10 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @ListEntry, STRecord, "", [], "", ""), (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), (sti @MsgContent, STUnion, "MC", [], "", ""), @@ -371,6 +374,7 @@ chatTypesDocsData = (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), + (sti @SegmentRange, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), (sti @SimplexDomain, STRecord, "", [], "", ""), (sti @SimplexDomainClaim, STRecord, "", [], "", ""), @@ -391,6 +395,7 @@ chatTypesDocsData = (sti @SubscriptionStatus, STUnion, "SS", [], "", ""), (sti @SupportGroupPreference, STRecord, "", [], "", ""), (sti @SwitchPhase, STEnum, "SP", [], "", ""), + (sti @TableAlign, STEnum, "TA", [], "", ""), (sti @TimedMessagesGroupPreference, STRecord, "", [], "", ""), (sti @TimedMessagesPreference, STRecord, "", [], "", ""), (sti @TransportError, STUnion, "TE", [], "", ""), @@ -457,6 +462,7 @@ deriving instance Generic BadgeRedeemError deriving instance Generic BadgeServiceErrorCode deriving instance Generic BlockingInfo deriving instance Generic BlockingReason +deriving instance Generic BlockToken deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType @@ -561,8 +567,10 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic ListEntry deriving instance Generic BadgeInfo deriving instance Generic LocalProfile +deriving instance Generic MarkdownBlock deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink deriving instance Generic MsgContent @@ -610,6 +618,7 @@ deriving instance Generic RemoteCtrlSessionState deriving instance Generic RemoteCtrlStopReason deriving instance Generic ReportReason deriving instance Generic SecurityCode +deriving instance Generic SegmentRange deriving instance Generic SimplexDomain deriving instance Generic SimplexDomainClaim deriving instance Generic SimplexDomainError @@ -629,6 +638,7 @@ deriving instance Generic StoreError deriving instance Generic SubscriptionStatus deriving instance Generic SupportGroupPreference deriving instance Generic SwitchPhase +deriving instance Generic TableAlign deriving instance Generic TimedMessagesGroupPreference deriving instance Generic TimedMessagesPreference deriving instance Generic TransportError diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 3ecfa17409..7dbf4510e9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -57,11 +57,7 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t - `stable` - stable release of the apps, can be used for updates to the previous stable release (GHC 9.6.3). -- `stable-android` - used to build stable Android core library with Nix (GHC 8.10.7) - only for Android armv7a. - -- `master` - branch for beta version releases (compatible with both GHC 9.6.3 and 8.10.7). - -- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7) - only for Android armv7a. +- `master` - branch for beta version releases (compatible with both GHC 9.6.3 and 8.10.7). Nix builds Android armv7a core library with GHC 8.10.7 and all other core libraries with GHC 9.6.3. **In simplexmq repo** @@ -71,18 +67,13 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t 1. Make PRs to `master` branch _only_ for both simplex-chat and simplexmq repos. -2. To build core libraries for Android, iOS and Windows: -- merge `master` branch to `master-android` branch. -- push to GitHub. +2. To build core libraries for Android, iOS and Windows, push `master` branch to GitHub. -3. All libraries should be built from `master` branch, Android armv7a - from `master-android` branch. +3. All libraries should be built from `master` branch. 4. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. -5. After the public release to App Store and Play Store, merge: -- `master` to `stable` -- `master` to `master-android` (and compile/update code) -- `master-android` to `stable-android` +5. After the public release to App Store and Play Store, merge `master` to `stable`. 6. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. diff --git a/docs/contributing/RELEASES.md b/docs/contributing/RELEASES.md new file mode 100644 index 0000000000..ea6123e4c8 --- /dev/null +++ b/docs/contributing/RELEASES.md @@ -0,0 +1,153 @@ +# Library and package releases + +## Releases + +| Release | Location | Version | Published on | +|---|---|---|---| +| libsimplex | simplex-chat-libs release `core-` | `version` of `simplex-chat.cabal` | push to master or stable with a new core version | +| Node.js add-on | simplex-chat-libs release `nodejs-addon-<7 characters of the commit>` | | push to master or stable with unreleased add-on sources | +| `@simplex-chat/types` | npm | `version` of `packages/simplex-chat-client/types/typescript/package.json` | library release with an unpublished types version | +| `simplex-chat` | npm | `version` of `packages/simplex-chat-nodejs/package.json` | push to master or stable with an unpublished version | +| `simplex-chat` | PyPI | `__version__` of `packages/simplex-chat-python/src/simplex_chat/_version.py` | push to master or stable with an unpublished version | + +Add-on sources: +- `packages/simplex-chat-nodejs/binding.gyp` +- `packages/simplex-chat-nodejs/cpp/` +- `node-addon-api` version in `devDependencies` of `packages/simplex-chat-nodejs/package.json` + +Release states in simplex-chat-libs: + +``` +draft created by maybe-release; assets uploaded by the build jobs +published every asset present; published by the releases job +``` + +npm tag: `beta` for a version with a prerelease part, `latest` otherwise. + +Releases and publications are independent of the Haskell test results. + +`releases.toml` of simplex-chat-libs, written by `build.yml`: + +``` +core.. commit of the library release; branch: master or stable +npm.. commit of the publication +pypi.. commit of the publication +``` + +## Library release + +1. On master or stable, after `git pull`: + + ``` + python3 scripts/ci/packages_release.py core-release + ``` + + Checked: version higher than the highest version recorded for the branch. + + Written: + - `version` of `simplex-chat.cabal` + - with types changed since the previous library release of the branch: types `version` (bumped), `simplexChatCore`, line `SimpleX Chat core:` of the types README + + `--types-version` sets the types version, for the first release of an app version, e.g. `--types-version 7.1.0-beta.7.1`. + +2. Commit, push to the same branch. + +3. Run of `build.yml`: + - `maybe-release`: version claimed for the commit in `releases.toml`, draft `core-` created + - build jobs: 6 library zips uploaded + - `releases`: release published + - `publish`: `@simplex-chat/types` published, when its version is unpublished + - `record`: types version recorded + +Failed claim, with every build job skipped: +- version lower than the highest version recorded for the branch +- types changed, types version published +- types changed, `simplexChatCore` different from the core version + +After a failed claim, a fix commit is pushed; the version is claimed by the next push. + +Incomplete draft: completed by a re-run of the failed jobs in the run of the claim. The version is skipped by later pushes, as it is recorded for another commit. + +## Add-on release + +Run of `build.yml` on a push to master or stable with unreleased add-on sources: +- `maybe-release`: draft `nodejs-addon-<7 characters of the commit>` created, notes `sources: ` +- build jobs, 5 platforms: add-on built, tested with the latest library release of the branch, uploaded +- `releases`: release published + +Incomplete draft: completed by a re-run or by the next push with the same add-on sources. + +## Package release + +1. On master or stable, after `git pull`: + + ``` + python3 scripts/ci/packages_release.py prepare [nodejs] [python] + ``` + + Both packages when none is listed. + + Checked: + - published library release recorded for the branch + - add-on release published with every asset (Node.js) + - types version published on npm (Node.js) + - Python types unchanged since the commit of the pinned library release (Python) + - pinned releases published with the pinned SHA-256 + - every pinned asset downloaded with a matching SHA-256 + + Written: + - pins in `packages/simplex-chat-nodejs/src/release.ts` and `_version.py`: latest library release of the branch, add-on release of the current sources (Node.js), SHA-256 of every asset + - `@simplex-chat/types` dependency: `^` (Node.js) + - package versions: a published version bumped when the pins or the package files changed + + `--nodejs-version` and `--python-version` set the versions, for the first release of an app version, e.g. `--nodejs-version 7.1.0-beta.7.1 --python-version 7.1.0b7`. + +2. Commit, push to the same branch. + +3. Run of `build.yml`: + - build jobs, 5 platforms: packages tested, Node.js with the pinned add-on + - `publish`: package tests of 5 platforms and pins checked, unpublished versions published with trusted publishing + - `record`: published versions recorded + +Versions unpublished after a failure: published by the next push to the branch. + +## Local publication + +1. Step 1 of the package release. + +2. Package tests in a Python virtual environment, on the local platform (`linux-x86_64`, `linux-aarch64`, `macos-aarch64`, `macos-x86_64` or `windows-x86_64`): + + ``` + scripts/ci/package-tests.sh publish nodejs python + ``` + +3. In `packages/simplex-chat-nodejs`, with the npm tag of the version: + + ``` + npm publish --access public --tag beta + ``` + +4. In `packages/simplex-chat-python`: + + ``` + python -m pip install build twine + python -m build + twine upload dist/simplex_chat-* + ``` + +5. Commit, push to the same branch. Locally published versions are skipped by `build.yml` and are not recorded in `releases.toml`. + +## Settings + +- simplex-chat-libs: release immutability enabled +- secret `NODEJS_REPO_TOKEN`: contents of simplex-chat-libs, read and write +- trusted publisher of `simplex-chat` and `@simplex-chat/types` on npm and of `simplex-chat` on PyPI: workflow `build.yml` +- secrets `NPM_TOKEN` and `PYPI_API_TOKEN`: used instead of trusted publishing when set +- variables `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` and `PYPI_UPLOAD_URL`: publication under other names, for test repositories + +## Pull requests + +- `packages.yml`, for package changes: + - core unchanged: add-on built and packages tested on 5 platforms + - core changed: add-on built on 5 platforms when its sources changed +- `build.yml`, for core changes: package tests on Linux x86_64 with the library built from the pull request diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 8c43ca6b28..249baa5431 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -20,21 +20,43 @@ A timeout hides the outcome, so the client repeats the identical signed request - `getBadgeInvoice` — returns the open invoice again; a new invoice is created only when none is open. - `redeemBadgeCode` — a code already redeemed by the signing key returns its latest `badgeCredential` and writes nothing; once other keys have taken all its uses, `code_used`. The client must therefore keep the key it first signed with, or a retry cannot be recognised. -- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing. +- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing; a store payment credited to another key, `receipt_used`. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. -- `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. +- `purchaseBadge` with the recovery `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. ## Commands -`purchaseBadge` and `upgradeBadgeSubscription` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; `badgeExtra` is reserved and must be empty. `issueBadge` carries no `badgeRequest`: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state. +The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), shared by every credential of that week; `badgeExtra` is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries `badgeRequest`. `upgradeBadgeSubscription` still has the field in the schema; that is wrong and it must be dropped when the command is implemented. - `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. - `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. - `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when other keys have taken all its uses (a code has one use unless issued with more), `code_expired` past a redemption deadline. -- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. + - A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. A receipt already credited to the signing key is answered from the record, without asking the store. + - Errors: `receipt_invalid`, `test_purchase`, `payment_pending`, `provider_unavailable` — see the classes below; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store. `receipt_invalid`, `test_purchase` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger. + - **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good. + - **Terminal**, `receipt_invalid` — only a verdict no later attempt could change: the store answered about this purchase and cannot change its mind, or the evidence can never verify. Anything that might answer differently later — the store's lag, an outage, this service's own misconfiguration or bug, a shape of evidence this service does not know yet — is one of the other classes. + - **Terminal and named**, `test_purchase` — the store vouched for a purchase that cost nothing: Apple's `Sandbox`, or Play's `purchaseType` test. It is separated from `receipt_invalid` for one reason: the device that made the purchase already knows it was a test one, so naming it reveals nothing a forger could iterate on, and it lets the app say why instead of reporting a generic failure. Every other refusal stays lumped together for exactly that reason. + - **Pending**, `payment_pending` — a real purchase the store has not settled. + - **Unreachable**, `provider_unavailable` — the store was not asked or did not answer. + - **Internal**, `internal` — this service's own failure, never the store's answer. + - **App Store.** Apple is verified offline, so it has no "not yet". + - `receipt_invalid`: a JWS that is not three base64url parts or names no `transactionId`; an `x5c` certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a `revocationDate`, refunded or revoked. + - `test_purchase`: refused by the service after the verifier vouched for it, a `Sandbox` transaction. + - `internal`: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's `bundleId`, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1. + - `product_unavailable`: a product this service does not price — the verifier vouched for the transaction either way. + - `provider_not_configured`: the deployment has no `[apple]` section. + - **Google Play.** Only a purchase record is a verdict. + - `receipt_invalid`: `purchaseState` canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one. + - `test_purchase`: refused by the service after the verifier vouched for it, `purchaseType` test, a license tester's purchase. + - `payment_pending`: `purchaseState` pending. + - `provider_unavailable`: network failure or timeout, and every error response from `purchases.products.get` but 401 and 403 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400 or 410 calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away. + - `internal`: Google refusing this service's credentials or permissions — a 401 or 403 from `purchases.products.get`, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a `purchaseState` this service does not know; and a `purchaseType` other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase. + - `product_unavailable` and `provider_not_configured` as for the App Store, the latter without a `[google]` section. + - Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — `provider_unavailable` when Play does not answer, `internal` when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers `internal` to every Play purchase the app has not already acknowledged itself. - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. -- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. +- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. Its `badgeRequest` is to be dropped (see above). - `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). @@ -63,4 +85,4 @@ An assertion that names an entry the service holds is a prefix: the service proc ## Errors -`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. +`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; `test_purchase` is named apart from them because the device that bought it already knows; `receipt_used` — credited to another key; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 01581a5bac..f9ca9d4a14 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -95,7 +95,7 @@ "properties": {"jws": {"type": "string"}} }, "google": { - "properties": {"token": {"type": "string"}} + "properties": {"productId": {"type": "string"}, "token": {"type": "string"}} }, "invoice": { "properties": {"invoiceId": {"type": "string"}} @@ -309,7 +309,7 @@ }, "purchaseBadge": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "masterKey": {"ref": "base64url"}, "payment": {"ref": "payment"} }, "optionalProperties": { @@ -321,7 +321,10 @@ }, "upgradeBadgeSubscription": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "badgeRequest": { + "ref": "badgeRequest", + "metadata": {"comment": "known wrong: to be dropped when implemented, see badges-rpc.md"} + }, "payment": { "ref": "payment", "metadata": {"comment": "store payments only"} diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 4e9c3c73a0..5fb4794094 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -25,7 +25,7 @@ "metadata": {"comment": "profile bio (160 characters)"} }, "contactLink": {"ref": "connReqUri"}, - "peerType": {"enum": ["human", "bot"]}, + "peerType": {"enum": ["human", "bot", "agent"]}, "preferences": { "optionalProperties": { "commands": { diff --git a/flake.lock b/flake.lock index eac7357cd8..2b1d187945 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,37 @@ "type": "github" } }, + "HTTP_2": { + "flake": false, + "locked": { + "lastModified": 1451647621, + "narHash": "sha256-oHIyw3x0iKBexEo49YeUDV1k74ZtyYKGR2gNJXXRxts=", + "owner": "phadej", + "repo": "HTTP", + "rev": "9bc0996d412fef1787449d841277ef663ad9a915", + "type": "github" + }, + "original": { + "owner": "phadej", + "repo": "HTTP", + "type": "github" + } + }, + "blank": { + "locked": { + "lastModified": 1625557891, + "narHash": "sha256-O8/MWsPBGhhyPoPLHZAuoZiiHo9q6FLlEeIDEXuj6T4=", + "owner": "divnix", + "repo": "blank", + "rev": "5a5d2684073d9f563072ed07c871d577a6c614a8", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "blank", + "type": "github" + } + }, "cabal-32": { "flake": false, "locked": { @@ -33,6 +64,23 @@ "type": "github" } }, + "cabal-32_2": { + "flake": false, + "locked": { + "lastModified": 1603716527, + "narHash": "sha256-X0TFfdD4KZpwl0Zr6x+PLxUt/VyKQfX7ylXHdmZIL+w=", + "owner": "haskell", + "repo": "cabal", + "rev": "48bf10787e27364730dd37a42b603cee8d6af7ee", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.2", + "repo": "cabal", + "type": "github" + } + }, "cabal-34": { "flake": false, "locked": { @@ -50,6 +98,23 @@ "type": "github" } }, + "cabal-34_2": { + "flake": false, + "locked": { + "lastModified": 1645834128, + "narHash": "sha256-wG3d+dOt14z8+ydz4SL7pwGfe7SiimxcD/LOuPCV6xM=", + "owner": "haskell", + "repo": "cabal", + "rev": "5ff598c67f53f7c4f48e31d722ba37172230c462", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.4", + "repo": "cabal", + "type": "github" + } + }, "cabal-36": { "flake": false, "locked": { @@ -67,6 +132,23 @@ "type": "github" } }, + "cabal-36_2": { + "flake": false, + "locked": { + "lastModified": 1669081697, + "narHash": "sha256-I5or+V7LZvMxfbYgZATU4awzkicBwwok4mVoje+sGmU=", + "owner": "haskell", + "repo": "cabal", + "rev": "8fd619e33d34924a94e691c5fea2c42f0fc7f144", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.6", + "repo": "cabal", + "type": "github" + } + }, "cardano-shell": { "flake": false, "locked": { @@ -83,6 +165,80 @@ "type": "github" } }, + "cardano-shell_2": { + "flake": false, + "locked": { + "lastModified": 1608537748, + "narHash": "sha256-PulY1GfiMgKVnBci3ex4ptk2UNYMXqGjJOxcPy2KYT4=", + "owner": "input-output-hk", + "repo": "cardano-shell", + "rev": "9392c75087cb9a3d453998f4230930dea3a95725", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "cardano-shell", + "type": "github" + } + }, + "devshell": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1663445644, + "narHash": "sha256-+xVlcK60x7VY1vRJbNUEAHi17ZuoQxAIH4S4iUFUGBA=", + "owner": "numtide", + "repo": "devshell", + "rev": "e3dc3e21594fe07bdb24bdf1c8657acaa4cb8f66", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "dmerge": { + "inputs": { + "nixlib": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ], + "yants": [ + "haskellNix-ghc8107", + "tullia", + "std", + "yants" + ] + }, + "locked": { + "lastModified": 1659548052, + "narHash": "sha256-fzI2gp1skGA8mQo/FBFrUAtY0GQkAIAaV/V127TJPyY=", + "owner": "divnix", + "repo": "data-merge", + "rev": "d160d18ce7b1a45b88344aa3f13ed1163954b497", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "data-merge", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -100,6 +256,39 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1672831974, + "narHash": "sha256-z9k3MfslLjWQfnjBtEtJZdq3H7kyi2kQtUThfTgdRk0=", + "owner": "input-output-hk", + "repo": "flake-compat", + "rev": "45f2638735f8cdc40fe302742b79f248d23eb368", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "hkm/gitlab-fix", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_3": { + "flake": false, + "locked": { + "lastModified": 1650374568, + "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "b4a34015c698c7793d592d66adbab377907a2be8", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" @@ -136,6 +325,51 @@ "type": "github" } }, + "flake-utils_2": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_3": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_4": { + "locked": { + "lastModified": 1659877975, + "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "ghc-8.6.5-iohk": { "flake": false, "locked": { @@ -153,6 +387,23 @@ "type": "github" } }, + "ghc-8.6.5-iohk_2": { + "flake": false, + "locked": { + "lastModified": 1600920045, + "narHash": "sha256-DO6kxJz248djebZLpSzTGD6s8WRpNI9BTwUeOf5RwY8=", + "owner": "input-output-hk", + "repo": "ghc", + "rev": "95713a6ecce4551240da7c96b6176f980af75cae", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "release/8.6.5-iohk", + "repo": "ghc", + "type": "github" + } + }, "ghc98X": { "flake": false, "locked": { @@ -190,6 +441,25 @@ "url": "https://gitlab.haskell.org/ghc/ghc" } }, + "gomod2nix": { + "inputs": { + "nixpkgs": "nixpkgs_3", + "utils": "utils" + }, + "locked": { + "lastModified": 1655245309, + "narHash": "sha256-d/YPoQ/vFn1+GTmSdvbSBSTOai61FONxB4+Lt6w/IVI=", + "owner": "tweag", + "repo": "gomod2nix", + "rev": "40d32f82fc60d66402eb0972e6e368aeab3faf58", + "type": "github" + }, + "original": { + "owner": "tweag", + "repo": "gomod2nix", + "type": "github" + } + }, "hackage": { "flake": false, "locked": { @@ -206,6 +476,23 @@ "type": "github" } }, + "hackage-ghc8107": { + "flake": false, + "locked": { + "lastModified": 1702340598, + "narHash": "sha256-CC0HI+6iKPtH+8r/ZfcpW5v/OYvL7zMwpr0xfkXV1zU=", + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "type": "github" + } + }, "haskellNix": { "inputs": { "HTTP": "HTTP", @@ -260,6 +547,50 @@ "type": "github" } }, + "haskellNix-ghc8107": { + "inputs": { + "HTTP": "HTTP_2", + "cabal-32": "cabal-32_2", + "cabal-34": "cabal-34_2", + "cabal-36": "cabal-36_2", + "cardano-shell": "cardano-shell_2", + "flake-compat": "flake-compat_2", + "flake-utils": "flake-utils_2", + "ghc-8.6.5-iohk": "ghc-8.6.5-iohk_2", + "hackage": [ + "hackage-ghc8107" + ], + "hpc-coveralls": "hpc-coveralls_2", + "hydra": "hydra_2", + "iserv-proxy": "iserv-proxy_2", + "nixpkgs": [ + "nixpkgs-ghc8107" + ], + "nixpkgs-2003": "nixpkgs-2003_2", + "nixpkgs-2105": "nixpkgs-2105_2", + "nixpkgs-2111": "nixpkgs-2111_2", + "nixpkgs-2205": "nixpkgs-2205_2", + "nixpkgs-2211": "nixpkgs-2211_2", + "nixpkgs-unstable": "nixpkgs-unstable_2", + "old-ghc-nix": "old-ghc-nix_2", + "stackage": "stackage_2", + "tullia": "tullia" + }, + "locked": { + "lastModified": 1677975916, + "narHash": "sha256-dbe8lEEPyfzjdRwpePClv7J9p9lQg7BwbBqAMCw4RLw=", + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4", + "type": "github" + } + }, "hls-1.10": { "flake": false, "locked": { @@ -395,6 +726,22 @@ "type": "github" } }, + "hpc-coveralls_2": { + "flake": false, + "locked": { + "lastModified": 1607498076, + "narHash": "sha256-8uqsEtivphgZWYeUo5RDUhp6bO9j2vaaProQxHBltQk=", + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "rev": "14df0f7d229f4cd2e79f8eabb1a740097fdfa430", + "type": "github" + }, + "original": { + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "type": "github" + } + }, "hydra": { "inputs": { "nix": "nix", @@ -418,6 +765,52 @@ "type": "indirect" } }, + "hydra_2": { + "inputs": { + "nix": "nix_2", + "nixpkgs": [ + "haskellNix-ghc8107", + "hydra", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1671755331, + "narHash": "sha256-hXsgJj0Cy0ZiCiYdW2OdBz5WmFyOMKuw4zyxKpgUKm4=", + "owner": "NixOS", + "repo": "hydra", + "rev": "f48f00ee6d5727ae3e488cbf9ce157460853fea8", + "type": "github" + }, + "original": { + "id": "hydra", + "type": "indirect" + } + }, + "incl": { + "inputs": { + "nixlib": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1669263024, + "narHash": "sha256-E/+23NKtxAqYG/0ydYgxlgarKnxmDbg6rCMWnOBqn9Q=", + "owner": "divnix", + "repo": "incl", + "rev": "ce7bebaee048e4cd7ebdb4cee7885e00c4e2abca", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "incl", + "type": "github" + } + }, "iserv-proxy": { "flake": false, "locked": { @@ -435,6 +828,23 @@ "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" } }, + "iserv-proxy_2": { + "flake": false, + "locked": { + "lastModified": 1670983692, + "narHash": "sha256-avLo34JnI9HNyOuauK5R69usJm+GfW3MlyGlYxZhTgY=", + "ref": "hkm/remote-iserv", + "rev": "50d0abb3317ac439a4e7495b185a64af9b7b9300", + "revCount": 10, + "type": "git", + "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" + }, + "original": { + "ref": "hkm/remote-iserv", + "type": "git", + "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" + } + }, "lowdown-src": { "flake": false, "locked": { @@ -451,10 +861,26 @@ "type": "github" } }, + "lowdown-src_2": { + "flake": false, + "locked": { + "lastModified": 1633514407, + "narHash": "sha256-Dw32tiMjdK9t3ETl5fzGrutQTzh2rufgZV4A/BbxuD4=", + "owner": "kristapsdz", + "repo": "lowdown", + "rev": "d2c2b44ff6c27b936ec27358a2653caaef8f73b8", + "type": "github" + }, + "original": { + "owner": "kristapsdz", + "repo": "lowdown", + "type": "github" + } + }, "mac2ios": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1699767871, @@ -470,6 +896,35 @@ "type": "github" } }, + "n2c": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1665039323, + "narHash": "sha256-SAh3ZjFGsaCI8FRzXQyp56qcGdAqgKEfJWPCQ0Sr7tQ=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "b008fe329ffb59b67bf9e7b08ede6ee792f2741a", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, "nix": { "inputs": { "lowdown-src": "lowdown-src", @@ -491,6 +946,116 @@ "type": "github" } }, + "nix-nomad": { + "inputs": { + "flake-compat": "flake-compat_3", + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "nix2container", + "flake-utils" + ], + "gomod2nix": "gomod2nix", + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "nixpkgs" + ], + "nixpkgs-lib": [ + "haskellNix-ghc8107", + "tullia", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1658277770, + "narHash": "sha256-T/PgG3wUn8Z2rnzfxf2VqlR1CBjInPE0l1yVzXxPnt0=", + "owner": "tristanpemble", + "repo": "nix-nomad", + "rev": "054adcbdd0a836ae1c20951b67ed549131fd2d70", + "type": "github" + }, + "original": { + "owner": "tristanpemble", + "repo": "nix-nomad", + "type": "github" + } + }, + "nix2container": { + "inputs": { + "flake-utils": "flake-utils_3", + "nixpkgs": "nixpkgs_4" + }, + "locked": { + "lastModified": 1658567952, + "narHash": "sha256-XZ4ETYAMU7XcpEeAFP3NOl9yDXNuZAen/aIJ84G+VgA=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "60bb43d405991c1378baf15a40b5811a53e32ffa", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, + "nix_2": { + "inputs": { + "lowdown-src": "lowdown-src_2", + "nixpkgs": "nixpkgs_2", + "nixpkgs-regression": "nixpkgs-regression_2" + }, + "locked": { + "lastModified": 1661606874, + "narHash": "sha256-9+rpYzI+SmxJn+EbYxjGv68Ucp22bdFUSy/4LkHkkDQ=", + "owner": "NixOS", + "repo": "nix", + "rev": "11e45768b34fdafdcf019ddbd337afa16127ff0f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "2.11.0", + "repo": "nix", + "type": "github" + } + }, + "nixago": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixago-exts": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1661824785, + "narHash": "sha256-/PnwdWoO/JugJZHtDUioQp3uRiWeXHUdgvoyNbXesz8=", + "owner": "nix-community", + "repo": "nixago", + "rev": "8c1f9e5f1578d4b2ea989f618588d62a335083c3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixago", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1657693803, @@ -523,6 +1088,22 @@ "type": "github" } }, + "nixpkgs-2003_2": { + "locked": { + "lastModified": 1620055814, + "narHash": "sha256-8LEHoYSJiL901bTMVatq+rf8y7QtWuZhwwpKE2fyaRY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1db42b7fe3878f3f5f7a4f2dc210772fd080e205", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-20.03-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2105": { "locked": { "lastModified": 1659914493, @@ -539,6 +1120,22 @@ "type": "github" } }, + "nixpkgs-2105_2": { + "locked": { + "lastModified": 1659914493, + "narHash": "sha256-lkA5X3VNMKirvA+SUzvEhfA7XquWLci+CGi505YFAIs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "022caabb5f2265ad4006c1fa5b1ebe69fb0c3faf", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2111": { "locked": { "lastModified": 1659446231, @@ -555,6 +1152,22 @@ "type": "github" } }, + "nixpkgs-2111_2": { + "locked": { + "lastModified": 1659446231, + "narHash": "sha256-hekabNdTdgR/iLsgce5TGWmfIDZ86qjPhxDg/8TlzhE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eabc38219184cc3e04a974fe31857d8e0eac098d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2205": { "locked": { "lastModified": 1685573264, @@ -571,6 +1184,22 @@ "type": "github" } }, + "nixpkgs-2205_2": { + "locked": { + "lastModified": 1672580127, + "narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0874168639713f547c05947c76124f78441ea46c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-22.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2211": { "locked": { "lastModified": 1688392541, @@ -587,6 +1216,22 @@ "type": "github" } }, + "nixpkgs-2211_2": { + "locked": { + "lastModified": 1675730325, + "narHash": "sha256-uNvD7fzO5hNlltNQUAFBPlcEjNG5Gkbhl/ROiX+GZU4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b7ce17b1ebf600a72178f6302c77b6382d09323f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-22.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2305": { "locked": { "lastModified": 1705033721, @@ -619,6 +1264,22 @@ "type": "github" } }, + "nixpkgs-ghc8107": { + "locked": { + "lastModified": 1676726892, + "narHash": "sha256-M7OYVR6dKmzmlebIjybFf3l18S2uur8lMyWWnHQooLY=", + "owner": "angerman", + "repo": "nixpkgs", + "rev": "729469087592bdea58b360de59dadf6d58714c42", + "type": "github" + }, + "original": { + "owner": "angerman", + "repo": "nixpkgs", + "rev": "729469087592bdea58b360de59dadf6d58714c42", + "type": "github" + } + }, "nixpkgs-lib": { "locked": { "dir": "lib", @@ -653,6 +1314,22 @@ "type": "github" } }, + "nixpkgs-regression_2": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, "nixpkgs-unstable": { "locked": { "lastModified": 1694822471, @@ -669,7 +1346,86 @@ "type": "github" } }, + "nixpkgs-unstable_2": { + "locked": { + "lastModified": 1675758091, + "narHash": "sha256-7gFSQbSVAFUHtGCNHPF7mPc5CcqDk9M2+inlVPZSneg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "747927516efcb5e31ba03b7ff32f61f6d47e7d87", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_2": { + "locked": { + "lastModified": 1657693803, + "narHash": "sha256-G++2CJ9u0E7NNTAi9n5G8TdDmGJXcIjkJ3NF8cetQB8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "365e1b3a859281cf11b94f87231adeabbdd878a2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-22.05-small", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1653581809, + "narHash": "sha256-Uvka0V5MTGbeOfWte25+tfRL3moECDh1VwokWSZUdoY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "83658b28fe638a170a19b8933aa008b30640fbd1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1654807842, + "narHash": "sha256-ADymZpr6LuTEBXcy6RtFHcUZdjKTBRTMYwu19WOx17E=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fc909087cc3386955f21b4665731dbdaceefb1d8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_5": { + "locked": { + "lastModified": 1665087388, + "narHash": "sha256-FZFPuW9NWHJteATOf79rZfwfRn5fE0wi9kRzvGfDHPA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "95fda953f6db2e9496d2682c4fc7b82f959878f7", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_6": { "locked": { "lastModified": 1698434055, "narHash": "sha256-Phxi5mUKSoL7A0IYUiYtkI9e8NcGaaV5PJEaJApU1Ko=", @@ -685,6 +1441,21 @@ "type": "github" } }, + "nosys": { + "locked": { + "lastModified": 1667881534, + "narHash": "sha256-FhwJ15uPLRsvaxtt/bNuqE/ykMpNAPF0upozFKhTtXM=", + "owner": "divnix", + "repo": "nosys", + "rev": "2d0d5207f6a230e9d0f660903f8db9807b54814f", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "nosys", + "type": "github" + } + }, "old-ghc-nix": { "flake": false, "locked": { @@ -702,16 +1473,36 @@ "type": "github" } }, + "old-ghc-nix_2": { + "flake": false, + "locked": { + "lastModified": 1631092763, + "narHash": "sha256-sIKgO+z7tj4lw3u6oBZxqIhDrzSkvpHtv0Kki+lh9Fg=", + "owner": "angerman", + "repo": "old-ghc-nix", + "rev": "af48a7a7353e418119b6dfe3cd1463a657f342b8", + "type": "github" + }, + "original": { + "owner": "angerman", + "ref": "master", + "repo": "old-ghc-nix", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", "hackage": "hackage", + "hackage-ghc8107": "hackage-ghc8107", "haskellNix": "haskellNix", + "haskellNix-ghc8107": "haskellNix-ghc8107", "mac2ios": "mac2ios", "nixpkgs": [ "haskellNix", "nixpkgs-2305" - ] + ], + "nixpkgs-ghc8107": "nixpkgs-ghc8107" } }, "stackage": { @@ -730,6 +1521,67 @@ "type": "github" } }, + "stackage_2": { + "flake": false, + "locked": { + "lastModified": 1677888571, + "narHash": "sha256-YkhRNOaN6QVagZo1cfykYV8KqkI8/q6r2F5+jypOma4=", + "owner": "input-output-hk", + "repo": "stackage.nix", + "rev": "cb50e6fabdfb2d7e655059039012ad0623f06a27", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "stackage.nix", + "type": "github" + } + }, + "std": { + "inputs": { + "arion": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "blank": "blank", + "devshell": "devshell", + "dmerge": "dmerge", + "flake-utils": "flake-utils_4", + "incl": "incl", + "makes": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "microvm": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "n2c": "n2c", + "nixago": "nixago", + "nixpkgs": "nixpkgs_5", + "nosys": "nosys", + "yants": "yants" + }, + "locked": { + "lastModified": 1674526466, + "narHash": "sha256-tMTaS0bqLx6VJ+K+ZT6xqsXNpzvSXJTmogkraBGzymg=", + "owner": "divnix", + "repo": "std", + "rev": "516387e3d8d059b50e742a2ff1909ed3c8f82826", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "std", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, @@ -744,6 +1596,68 @@ "repo": "default", "type": "github" } + }, + "tullia": { + "inputs": { + "nix-nomad": "nix-nomad", + "nix2container": "nix2container", + "nixpkgs": [ + "haskellNix-ghc8107", + "nixpkgs" + ], + "std": "std" + }, + "locked": { + "lastModified": 1675695930, + "narHash": "sha256-B7rEZ/DBUMlK1AcJ9ajnAPPxqXY6zW2SBX+51bZV0Ac=", + "owner": "input-output-hk", + "repo": "tullia", + "rev": "621365f2c725608f381b3ad5b57afef389fd4c31", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "tullia", + "type": "github" + } + }, + "utils": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "yants": { + "inputs": { + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1667096281, + "narHash": "sha256-wRRec6ze0gJHmGn6m57/zhz/Kdvp9HS4Nl5fkQ+uIuA=", + "owner": "divnix", + "repo": "yants", + "rev": "d18f356ec25cb94dc9c275870c3a7927a10f8c3c", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "yants", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 84f84c34ec..26ece60cbf 100644 --- a/flake.nix +++ b/flake.nix @@ -8,8 +8,17 @@ flake = false; }; inputs.haskellNix.inputs.hackage.follows = "hackage"; + # armv7a android is built with GHC 8.10.7; its toolchain is pinned by revision so updates of the inputs above don't move it. + inputs.haskellNix-ghc8107.url = "github:input-output-hk/haskell.nix/ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4"; + inputs.nixpkgs-ghc8107.url = "github:angerman/nixpkgs/729469087592bdea58b360de59dadf6d58714c42"; + inputs.hackage-ghc8107 = { + url = "github:input-output-hk/hackage.nix/24617c569995e38bf3b83b48eec6628a50fdb4fb"; + flake = false; + }; + inputs.haskellNix-ghc8107.inputs.nixpkgs.follows = "nixpkgs-ghc8107"; + inputs.haskellNix-ghc8107.inputs.hackage.follows = "hackage-ghc8107"; inputs.flake-utils.url = "github:numtide/flake-utils"; - outputs = { self, haskellNix, nixpkgs, flake-utils, mac2ios, ... }: + outputs = { self, haskellNix, haskellNix-ghc8107, nixpkgs, flake-utils, mac2ios, ... }: let systems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; in flake-utils.lib.eachSystem systems (system: # this android26 overlay makes the pkgsCross.{aarch64-android,armv7a-android-prebuilt} to set stdVer to 26 (Android 8). @@ -29,8 +38,9 @@ }; in # `appendOverlays` with a singleton is identical to `extend`. let pkgs = haskellNix.legacyPackages.${system}.appendOverlays [android26]; in - let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { - compiler-nix-name = "ghc963"; + let pkgs8107 = haskellNix-ghc8107.legacyPackages.${system}.appendOverlays [android26]; in + let drv' = { extra-modules, pkgs', compiler-nix-name ? "ghc963", ... }: pkgs'.haskell-nix.project { + inherit compiler-nix-name; index-state = "2023-12-12T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. @@ -126,16 +136,20 @@ "x86_64-linux" = let androidPkgs = pkgs.pkgsCross.aarch64-android; - android32Pkgs = pkgs.pkgsCross.armv7a-android-prebuilt; + android32Pkgs = pkgs8107.pkgsCross.armv7a-android-prebuilt; # For some reason building libiconv with nixpgks android setup produces # LANGINFO_CODESET to be found, which is not compatible with android sdk 23; # so we'll patch up iconv to not include that. - androidIconv = (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { + mkAndroidIconv = androidPkgs: (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { postConfigure = '' echo "#undef HAVE_LANGINFO_CODESET" >> libcharset/config.h echo "#undef HAVE_LANGINFO_CODESET" >> lib/config.h ''; }); + androidIconv = mkAndroidIconv androidPkgs; + # Only interpolated in a commented-out line of armv7a postInstall; it must come from the + # GHC 8.10.7 nixpkgs to keep that derivation identical to the former master-android build. + androidIconv8107 = mkAndroidIconv pkgs8107.pkgsCross.aarch64-android; # Similarly to icovn, for reasons beyond my current knowledge, nixpkgs andorid # toolchain makes configure believe we have MEMFD_CREATE, which we don't in # sdk 23. @@ -275,37 +289,22 @@ # STATIC aarch64-linux "${pkgs.pkgsCross.aarch64-multiplatform-musl.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.aarch64-multiplatform-musl).simplex-chat.components.exes.simplex-chat; - "armv7a-android:lib:support" = (drv android32Pkgs).android-support.components.library.override (p: { - smallAddressSpace = true; - # we won't want -dyamic (see aarch64-android:lib:simplex-chat) - enableShared = false; - # we also do not want to have any dependencies listed (especially no rts!) - enableStatic = false; - - # This used to work with 8.10.7... - # setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; - # ... but now with 9.6+ - # we have to do the -shared thing by hand. - postBuild = '' - armv7a-unknown-linux-androideabi-ghc -shared -o libsupport.so \ - -optl-Wl,-u,setLineBuffering \ - -optl-Wl,-u,pipe_std_to_socket \ - dist/build/*.a - ''; - + "armv7a-android:lib:support" = (drv' { pkgs' = android32Pkgs; extra-modules = []; compiler-nix-name = "ghc8107"; }).android-support.components.library.override { + smallAddressSpace = true; enableShared = false; + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; postInstall = '' mkdir -p $out/_pkg cp libsupport.so $out/_pkg - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsupport.zip *) + ${pkgs8107.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so + (cd $out/_pkg; ${pkgs8107.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsupport.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }); + }; # The android-support package is at # https://github.com/simplex-chat/android-support "aarch64-android:lib:support" = (drv androidPkgs).android-support.components.library.override (p: { @@ -340,10 +339,10 @@ }); "armv7a-android:lib:simplex-chat" = (drv' { pkgs' = android32Pkgs; + compiler-nix-name = "ghc8107"; extra-modules = [{ - packages.text.flags.simdutf = false; packages.direct-sqlcipher.flags.openssl = true; - packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ + packages.direct-sqlcipher.components.library.libs = pkgs8107.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) ]; packages.direct-sqlcipher.patches = [ @@ -351,70 +350,21 @@ ]; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; - packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + packages.simplexmq.components.library.libs = pkgs8107.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) ]; - # 32 bit patches - packages.basement.patches = [ - ./scripts/nix/basement-pr-573.patch - ]; - packages.memory.patches = [ - ./scripts/nix/memory-pr-99.patch - ]; }]; - }).simplex-chat.components.library.override (p: { - smallAddressSpace = true; - # we want -shared, but not -dyanmic, hence `enableShared = false`. - enableShared = false; - # we _do_ want rts, and other libs. Hence `enableStatic = true`. - enableStatic = true; + }).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for # template haskell cross compilation. Thus we just pass them as linker options (-optl). - setupBuildFlags = p.component.setupBuildFlags - # flags to tell GHC we want to produce a -shared object, and we want to also link - # - the ffi library (ffi) - ++ map (x: "--ghc-option=${x}") [ - "-shared" "-o" "libsimplex.so" - "-threaded" - # "-debug" - "-optl-lffi" - "-j1" - ] - # This is fairly idiotic. LLD will strip out foreign exported - # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` - # to ensure they stay in the produced library. Having them - # _undefined_ and _lazy_ (lld will tell with -y that the - # symbol is lazy), makes them _defined_. m( - ++ map (sym: "--ghc-option=-optl-Wl,-u,${sym}") [ - "chat_close_store" - "chat_decrypt_file" - "chat_decrypt_media" - "chat_encrypt_file" - "chat_encrypt_media" - "chat_migrate_init" - "chat_migrate_init_queue" - "chat_parse_markdown" - "chat_parse_server" - "chat_parse_uri" - "chat_password_hash" - "chat_read_file" - "chat_recv_msg" - "chat_recv_msg_wait" - "chat_send_cmd" - "chat_send_cmd_retry" - "chat_send_remote_cmd" - "chat_send_remote_cmd_retry" - "chat_valid_name" - "chat_parse_badge_code" - "chat_json_length" - "chat_badge_keygen" - "chat_badge_issue" - "chat_write_file" - ]; + # 32-bit ARM only: processAgentMessageConn is too large for ARM's PC-relative constant-pool + # range at -O2; -O1 is appended after the cabal -O2 here, so it wins (aarch64 stays -O2). + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi" "-j1" "-O1"]; postInstall = '' set -x - ${pkgs.tree}/bin/tree $out + ${pkgs8107.tree}/bin/tree $out mkdir -p $out/_pkg # copy over includes, we might want those, but maybe not. # cp -r $out/lib/*/*/include $out/_pkg/ @@ -425,7 +375,7 @@ # find ./dist -name "libHS*-ghc*.a" -exec cp {} $out/_pkg \; # find ${android32FFI}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${android32Pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; - # find ${androidIconv}/lib -name "*.a" -exec cp {} $out/_pkg \; + # find ${androidIconv8107}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${android32Pkgs.stdenv.cc.libc}/lib -name "*.a" -exec cp {} $out/_pkg \; echo ${android32Pkgs.openssl.override { enableKTLS = false; }} find ${(android32Pkgs.openssl.override { enableKTLS = false; }).out}/lib -name "*.so" -exec cp {} $out/_pkg \; @@ -433,22 +383,22 @@ # remove the .1 and other version suffixes from .so's. Androids linker # doesn't play nice with them. for lib in $out/_pkg/*.so; do - for dep in $(${pkgs.patchelf}/bin/patchelf --print-needed "$lib"); do + for dep in $(${pkgs8107.patchelf}/bin/patchelf --print-needed "$lib"); do if [[ "''${dep##*.so}" ]]; then echo "$lib : $dep -> ''${dep%%.so*}.so" chmod +w "$lib" - ${pkgs.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" + ${pkgs8107.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" fi done done for lib in $out/_pkg/*.so; do chmod +w "$lib" - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" - [[ "$lib" != *libsimplex.so ]] && ${pkgs.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" + ${pkgs8107.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" + [[ "$lib" != *libsimplex.so ]] && ${pkgs8107.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" done - ${pkgs.tree}/bin/tree $out/_pkg + ${pkgs8107.tree}/bin/tree $out/_pkg # Strip from debug symbols find "$out/_pkg" -type f -name "*.so" -exec ${android32Pkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + @@ -458,13 +408,13 @@ find "$out/_pkg" -type d -exec chmod 755 {} + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) + (cd $out/_pkg; ${pkgs8107.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }); + }; "aarch64-android:lib:simplex-chat" = (drv' { pkgs' = androidPkgs; extra-modules = [{ diff --git a/packages/simplex-chat-client/types/typescript/README.md b/packages/simplex-chat-client/types/typescript/README.md index e30cf0d0c3..8d5f88657d 100644 --- a/packages/simplex-chat-client/types/typescript/README.md +++ b/packages/simplex-chat-client/types/typescript/README.md @@ -4,6 +4,8 @@ This TypeScript library provides auto-generated types for bots API: commands and It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) Node.js library. +SimpleX Chat core: 7.1.0.12 + [API reference](https://github.com/simplex-chat/simplex-chat/tree/stable/bots). ## License diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 7bf43e0840..983292abd1 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,7 @@ { "name": "@simplex-chat/types", - "version": "0.12.1", + "version": "7.1.0-beta.6.1", + "simplexChatCore": "7.1.0.12", "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/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index d7db7f259f..cef7f41de5 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -249,6 +249,7 @@ export interface BadgeProof { export type BadgeRedeemError = | BadgeRedeemError.InvalidCode + | BadgeRedeemError.InvalidReceipt | BadgeRedeemError.ServiceNotConfigured | BadgeRedeemError.BadgeActive | BadgeRedeemError.ServiceError @@ -259,6 +260,7 @@ export type BadgeRedeemError = export namespace BadgeRedeemError { export type Tag = | "invalidCode" + | "invalidReceipt" | "serviceNotConfigured" | "badgeActive" | "serviceError" @@ -274,6 +276,10 @@ export namespace BadgeRedeemError { type: "invalidCode" } + export interface InvalidReceipt extends Interface { + type: "invalidReceipt" + } + export interface ServiceNotConfigured extends Interface { type: "serviceNotConfigured" } @@ -312,12 +318,14 @@ export enum BadgeServiceErrorCode { Payment_not_entitled = "payment_not_entitled", Payment_pending = "payment_pending", Provider_unavailable = "provider_unavailable", + Provider_not_configured = "provider_not_configured", Rate_limited = "rate_limited", Code_invalid = "code_invalid", Code_used = "code_used", Code_expired = "code_expired", Receipt_invalid = "receipt_invalid", Receipt_used = "receipt_used", + Test_purchase = "test_purchase", Internal = "internal", } @@ -335,6 +343,112 @@ export enum BadgeType { Investor = "investor", } +export type BlockToken = + | BlockToken.LineBreak + | BlockToken.SoftBreak + | BlockToken.HardBreak + | BlockToken.ParagraphBreak + | BlockToken.Indent + | BlockToken.HeadingMark + | BlockToken.HeadingClose + | BlockToken.HeadingUnderline + | BlockToken.ListMark + | BlockToken.QuoteMark + | BlockToken.FenceMark + | BlockToken.RowMark + | BlockToken.CellMark + | BlockToken.DelimiterRow + | BlockToken.RuleMark + +export namespace BlockToken { + export type Tag = + | "lineBreak" + | "softBreak" + | "hardBreak" + | "paragraphBreak" + | "indent" + | "headingMark" + | "headingClose" + | "headingUnderline" + | "listMark" + | "quoteMark" + | "fenceMark" + | "rowMark" + | "cellMark" + | "delimiterRow" + | "ruleMark" + + interface Interface { + type: Tag + } + + export interface LineBreak extends Interface { + type: "lineBreak" + } + + export interface SoftBreak extends Interface { + type: "softBreak" + } + + export interface HardBreak extends Interface { + type: "hardBreak" + } + + export interface ParagraphBreak extends Interface { + type: "paragraphBreak" + lineBreaks: number // int + } + + export interface Indent extends Interface { + type: "indent" + } + + export interface HeadingMark extends Interface { + type: "headingMark" + level: number // int + } + + export interface HeadingClose extends Interface { + type: "headingClose" + } + + export interface HeadingUnderline extends Interface { + type: "headingUnderline" + level: number // int + } + + export interface ListMark extends Interface { + type: "listMark" + number?: string + } + + export interface QuoteMark extends Interface { + type: "quoteMark" + } + + export interface FenceMark extends Interface { + type: "fenceMark" + language?: string + } + + export interface RowMark extends Interface { + type: "rowMark" + } + + export interface CellMark extends Interface { + type: "cellMark" + } + + export interface DelimiterRow extends Interface { + type: "delimiterRow" + columns: TableAlign[] + } + + export interface RuleMark extends Interface { + type: "ruleMark" + } +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -990,6 +1104,7 @@ export interface CIQuote { sentAt: string // ISO-8601 timestamp content: MsgContent formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] } export interface CIReaction { @@ -1752,6 +1867,7 @@ export interface ChatItem { content: CIContent mentions: {[key: string]: CIMention} formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] quotedItem?: CIQuote reactions: CIReactionCount[] file?: CIFile @@ -1788,6 +1904,7 @@ export enum ChatPeerType { Human = "human", Bot = "bot", Business = "business", + Agent = "agent", } // Used in API commands. Chat scope can only be passed with groups. @@ -2560,6 +2677,7 @@ export enum FileType { Normal = "normal", Roster = "roster", } +// The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. export type Format = | Format.Bold @@ -2577,6 +2695,8 @@ export type Format = | Format.Mention | Format.Email | Format.Phone + | Format.Escape + | Format.Markup export namespace Format { export type Tag = @@ -2595,6 +2715,8 @@ export namespace Format { | "mention" | "email" | "phone" + | "escape" + | "markup" interface Interface { type: Tag @@ -2602,6 +2724,7 @@ export namespace Format { export interface Bold extends Interface { type: "bold" + singleStar: boolean } export interface Italic extends Interface { @@ -2669,6 +2792,15 @@ export namespace Format { export interface Phone extends Interface { type: "phone" } + + export interface Escape extends Interface { + type: "escape" + } + + export interface Markup extends Interface { + type: "markup" + token: BlockToken + } } export interface FormattedText { @@ -3133,6 +3265,12 @@ export interface LinkPreview { content?: LinkContent } +export interface ListEntry { + number?: string + content: SegmentRange + items: ListEntry[] +} + export interface LocalBadge { badge: BadgeInfo status: BadgeStatus @@ -3154,6 +3292,62 @@ export interface LocalProfile { contactDomainVerified?: boolean } +export type MarkdownBlock = + | MarkdownBlock.Paragraph + | MarkdownBlock.Heading + | MarkdownBlock.CodeBlock + | MarkdownBlock.Quote + | MarkdownBlock.List + | MarkdownBlock.Table + | MarkdownBlock.Rule + +export namespace MarkdownBlock { + export type Tag = "paragraph" | "heading" | "codeBlock" | "quote" | "list" | "table" | "rule" + + interface Interface { + type: Tag + } + + export interface Paragraph extends Interface { + type: "paragraph" + content: SegmentRange + } + + export interface Heading extends Interface { + type: "heading" + level: number // int + content: SegmentRange + } + + export interface CodeBlock extends Interface { + type: "codeBlock" + language?: string + content: SegmentRange + } + + export interface Quote extends Interface { + type: "quote" + content: SegmentRange + } + + export interface List extends Interface { + type: "list" + items: ListEntry[] // non-empty + } + + export interface Table extends Interface { + type: "table" + columns: TableAlign[] // non-empty + header: SegmentRange[] + rows: SegmentRange[][] + } + + export interface Rule extends Interface { + type: "rule" + mark: SegmentRange + } +} + export enum MemberCriteria { All = "all", } @@ -4207,6 +4401,11 @@ export interface SecurityCode { verifiedAt: string // ISO-8601 timestamp } +export interface SegmentRange { + from: number // int + to: number // int +} + export interface SimplePreference { allow: FeatureAllowed } @@ -5122,6 +5321,13 @@ export enum SwitchPhase { Completed = "completed", } +export enum TableAlign { + None = "none", + Left = "left", + Center = "center", + Right = "right", +} + export interface TimedMessagesGroupPreference { enable: GroupFeatureEnabled ttl?: number // int diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 67eefdee4a..eaf9de68fb 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.6.3", + "version": "7.1.0-beta.6.4", "main": "dist/index.js", "types": "dist/index.d.ts", "gypfile": false, @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.12.1", + "@simplex-chat/types": "^7.1.0-beta.6.1", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3" }, diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts index b56435e210..ad9dfa4c78 100644 --- a/packages/simplex-chat-nodejs/src/release.ts +++ b/packages/simplex-chat-nodejs/src/release.ts @@ -1,16 +1,16 @@ // Written by scripts/ci/packages_release.py from the simplex-chat-libs releases. -export const LIBS_RELEASE = "core-7.1.0.11" +export const LIBS_RELEASE = "core-7.1.0.12" 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-libs-linux-aarch64.zip": "924b8e14eaba5e309fa584c4bfe598421cf64983d63790c1c45247f592f60055", + "simplex-chat-libs-linux-x86_64-postgres.zip": "323f908b45dde751d6082f8885d07d81fba8a5ab8b2df7d3b8ab3a87e56299e5", + "simplex-chat-libs-linux-x86_64.zip": "995409e963db408bcf896361b669944484f845b6744fefe479aebb0ca2e7c5cd", + "simplex-chat-libs-macos-aarch64.zip": "64464aa48b996e5a96b4aa651bc57bc69e412d3c3804b589c67bd9dc52faddbf", + "simplex-chat-libs-macos-x86_64.zip": "94664f96ff7a2cd5a419096e7e843928faa31cb3addb8869fa1e4b5a0d0b3629", + "simplex-chat-libs-windows-x86_64.zip": "2e10844e5c119921139c373ee2971341aceef7dc4198429d3be6b1570a009d15", "simplex-chat-nodejs-linux-aarch64.node": "3636a82c56c48229e0326fa03d6ceb5c16dd3afbcc1e86337783dc53a7328d67", "simplex-chat-nodejs-linux-x86_64.node": "55f3479b03fdb57591179ed730da744f1aa362e7529df55221b60028415746e9", "simplex-chat-nodejs-macos-aarch64.node": "a3472872fb0816f435f52ee5471088e6545ebffb95e89fbf8e1ab82275f2d56e", diff --git a/packages/simplex-chat-python/README.md b/packages/simplex-chat-python/README.md index b86886d591..aa892d32ab 100644 --- a/packages/simplex-chat-python/README.md +++ b/packages/simplex-chat-python/README.md @@ -56,15 +56,6 @@ ruff check && pyright && pytest tests/ Wire types under `src/simplex_chat/types/_*.py` are generated. Regenerate with `cabal test simplex-chat-test --test-options='--match Python'`. -## Release - -Manual for now. Bump `_version.py:__version__`, build a wheel, upload to PyPI: - -```bash -uv build --wheel -uv publish --token "$PYPI_TOKEN" -``` - ## License [AGPL-3.0](./LICENSE) diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 0786a81795..e5075096d4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -1,16 +1,16 @@ """Package version and the simplex-chat-libs release it loads. -Written by scripts/ci/packages_release.py: the release PR bumps the version +Written by scripts/ci/packages_release.py prepare: it 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_RELEASE = "core-7.1.0.11" # simplex-chat-libs release tag +__version__ = "7.1.0b6.post1" # PEP 440 — read by hatchling for wheel metadata +LIBS_RELEASE = "core-7.1.0.12" # 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", + "simplex-chat-libs-linux-aarch64.zip": "924b8e14eaba5e309fa584c4bfe598421cf64983d63790c1c45247f592f60055", + "simplex-chat-libs-linux-x86_64-postgres.zip": "323f908b45dde751d6082f8885d07d81fba8a5ab8b2df7d3b8ab3a87e56299e5", + "simplex-chat-libs-linux-x86_64.zip": "995409e963db408bcf896361b669944484f845b6744fefe479aebb0ca2e7c5cd", + "simplex-chat-libs-macos-aarch64.zip": "64464aa48b996e5a96b4aa651bc57bc69e412d3c3804b589c67bd9dc52faddbf", + "simplex-chat-libs-macos-x86_64.zip": "94664f96ff7a2cd5a419096e7e843928faa31cb3addb8869fa1e4b5a0d0b3629", + "simplex-chat-libs-windows-x86_64.zip": "2e10844e5c119921139c373ee2971341aceef7dc4198429d3be6b1570a009d15", } diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index ab06a24f53..5457f9c154 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -188,6 +188,9 @@ class BadgeProof(TypedDict): class BadgeRedeemError_invalidCode(TypedDict): type: Literal["invalidCode"] +class BadgeRedeemError_invalidReceipt(TypedDict): + type: Literal["invalidReceipt"] + class BadgeRedeemError_serviceNotConfigured(TypedDict): type: Literal["serviceNotConfigured"] @@ -210,6 +213,7 @@ class BadgeRedeemError_credentialNotVerified(TypedDict): BadgeRedeemError = ( BadgeRedeemError_invalidCode + | BadgeRedeemError_invalidReceipt | BadgeRedeemError_serviceNotConfigured | BadgeRedeemError_badgeActive | BadgeRedeemError_serviceError @@ -218,14 +222,85 @@ BadgeRedeemError = ( | BadgeRedeemError_credentialNotVerified ) -BadgeRedeemError_Tag = Literal["invalidCode", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] +BadgeRedeemError_Tag = Literal["invalidCode", "invalidReceipt", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] -BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"] +BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "provider_not_configured", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "test_purchase", "internal"] BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] BadgeType = Literal["supporter", "legend", "investor"] +class BlockToken_lineBreak(TypedDict): + type: Literal["lineBreak"] + +class BlockToken_softBreak(TypedDict): + type: Literal["softBreak"] + +class BlockToken_hardBreak(TypedDict): + type: Literal["hardBreak"] + +class BlockToken_paragraphBreak(TypedDict): + type: Literal["paragraphBreak"] + lineBreaks: int # int + +class BlockToken_indent(TypedDict): + type: Literal["indent"] + +class BlockToken_headingMark(TypedDict): + type: Literal["headingMark"] + level: int # int + +class BlockToken_headingClose(TypedDict): + type: Literal["headingClose"] + +class BlockToken_headingUnderline(TypedDict): + type: Literal["headingUnderline"] + level: int # int + +class BlockToken_listMark(TypedDict): + type: Literal["listMark"] + number: NotRequired[str] + +class BlockToken_quoteMark(TypedDict): + type: Literal["quoteMark"] + +class BlockToken_fenceMark(TypedDict): + type: Literal["fenceMark"] + language: NotRequired[str] + +class BlockToken_rowMark(TypedDict): + type: Literal["rowMark"] + +class BlockToken_cellMark(TypedDict): + type: Literal["cellMark"] + +class BlockToken_delimiterRow(TypedDict): + type: Literal["delimiterRow"] + columns: list["TableAlign"] + +class BlockToken_ruleMark(TypedDict): + type: Literal["ruleMark"] + +BlockToken = ( + BlockToken_lineBreak + | BlockToken_softBreak + | BlockToken_hardBreak + | BlockToken_paragraphBreak + | BlockToken_indent + | BlockToken_headingMark + | BlockToken_headingClose + | BlockToken_headingUnderline + | BlockToken_listMark + | BlockToken_quoteMark + | BlockToken_fenceMark + | BlockToken_rowMark + | BlockToken_cellMark + | BlockToken_delimiterRow + | BlockToken_ruleMark +) + +BlockToken_Tag = Literal["lineBreak", "softBreak", "hardBreak", "paragraphBreak", "indent", "headingMark", "headingClose", "headingUnderline", "listMark", "quoteMark", "fenceMark", "rowMark", "cellMark", "delimiterRow", "ruleMark"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -688,6 +763,7 @@ class CIQuote(TypedDict): sentAt: str # ISO-8601 timestamp content: "MsgContent" formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty class CIReaction(TypedDict): chatDir: "CIDirection" @@ -1220,6 +1296,7 @@ class ChatItem(TypedDict): content: "CIContent" mentions: dict[str, "CIMention"] formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty quotedItem: NotRequired["CIQuote"] reactions: list["CIReactionCount"] file: NotRequired["CIFile"] @@ -1243,7 +1320,7 @@ ChatListQuery = ChatListQuery_filters | ChatListQuery_search ChatListQuery_Tag = Literal["filters", "search"] -ChatPeerType = Literal["human", "bot", "business"] +ChatPeerType = Literal["human", "bot", "business", "agent"] # Used in API commands. Chat scope can only be passed with groups. @@ -1791,8 +1868,11 @@ class FileTransferMeta(TypedDict): FileType = Literal["normal", "roster"] +# The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + class Format_bold(TypedDict): type: Literal["bold"] + singleStar: bool class Format_italic(TypedDict): type: Literal["italic"] @@ -1846,6 +1926,13 @@ class Format_email(TypedDict): class Format_phone(TypedDict): type: Literal["phone"] +class Format_escape(TypedDict): + type: Literal["escape"] + +class Format_markup(TypedDict): + type: Literal["markup"] + token: "BlockToken" + Format = ( Format_bold | Format_italic @@ -1862,9 +1949,11 @@ Format = ( | Format_mention | Format_email | Format_phone + | Format_escape + | Format_markup ) -Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone"] +Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone", "escape", "markup"] class FormattedText(TypedDict): format: NotRequired["Format"] @@ -2187,6 +2276,11 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class ListEntry(TypedDict): + number: NotRequired[str] + content: "SegmentRange" + items: list["ListEntry"] + class LocalBadge(TypedDict): badge: "BadgeInfo" status: "BadgeStatus" @@ -2206,6 +2300,50 @@ class LocalProfile(TypedDict): contactDomain: NotRequired["SimplexDomainClaim"] contactDomainVerified: NotRequired[bool] +class MarkdownBlock_paragraph(TypedDict): + type: Literal["paragraph"] + content: "SegmentRange" + +class MarkdownBlock_heading(TypedDict): + type: Literal["heading"] + level: int # int + content: "SegmentRange" + +class MarkdownBlock_codeBlock(TypedDict): + type: Literal["codeBlock"] + language: NotRequired[str] + content: "SegmentRange" + +class MarkdownBlock_quote(TypedDict): + type: Literal["quote"] + content: "SegmentRange" + +class MarkdownBlock_list(TypedDict): + type: Literal["list"] + items: list["ListEntry"] # non-empty + +class MarkdownBlock_table(TypedDict): + type: Literal["table"] + columns: list["TableAlign"] # non-empty + header: list["SegmentRange"] + rows: list[list["SegmentRange"]] + +class MarkdownBlock_rule(TypedDict): + type: Literal["rule"] + mark: "SegmentRange" + +MarkdownBlock = ( + MarkdownBlock_paragraph + | MarkdownBlock_heading + | MarkdownBlock_codeBlock + | MarkdownBlock_quote + | MarkdownBlock_list + | MarkdownBlock_table + | MarkdownBlock_rule +) + +MarkdownBlock_Tag = Literal["paragraph", "heading", "codeBlock", "quote", "list", "table", "rule"] + MemberCriteria = Literal["all"] # Connection link sent in a message - only short links are allowed. @@ -2933,6 +3071,11 @@ class SecurityCode(TypedDict): securityCode: str verifiedAt: str # ISO-8601 timestamp +SegmentRange = TypedDict("SegmentRange", { + "from": int, # int + "to": int, # int +}) + class SimplePreference(TypedDict): allow: "FeatureAllowed" @@ -3569,6 +3712,8 @@ class SupportGroupPreference(TypedDict): SwitchPhase = Literal["started", "confirmed", "secured", "completed"] +TableAlign = Literal["none", "left", "center", "right"] + class TimedMessagesGroupPreference(TypedDict): enable: "GroupFeatureEnabled" ttl: NotRequired[int] # int diff --git a/plans/2026-09-24-badge-buy-in-browser.md b/plans/2026-09-24-badge-buy-in-browser.md new file mode 100644 index 0000000000..ec79f0564e --- /dev/null +++ b/plans/2026-09-24-badge-buy-in-browser.md @@ -0,0 +1,116 @@ +# Buying a badge in the browser + +The web page already sells badge codes. This makes the app the way in without making the app a shop: the app opens the page, the page runs the checkout it already has, and at the end hands the code back through a deep link instead of putting it on screen. The buyer never sees a code, and the code purchase itself is untouched. + +Wireframes: `plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw`. Panel ids below (A1, B3, D2…) refer to it, and the screens are drawn there rather than described again here. + +## Scope + +**Part 1** is everything in the wireframes except recurring payments: the browser lane on both builds, and the store lane's one-time product. Monthly and Annual stay on B3 and keep exactly the behaviour they have now — a real store transaction followed by a diagnostic alert, nothing sent anywhere. They must not reach the service, which cannot yet add months to a badge that already exists. + +The two lanes of part 1 are independent and unequal: the browser lane needs nothing from the service and can ship on its own, while the store lane cannot start until `purchaseBadge` accepts store receipts. + +**Part 2** is subscriptions, outlined at the end. + +Everything in the app sections below is both platforms unless it says otherwise. + +## Order of work + +1. **The renames**, on their own. Both screens are unreachable today, so nothing visible changes and the diff is strings and two titles. +2. **The page** — the `app` parameter, the second-screen start, both endings. Nothing sends the flag yet, so this is inert whenever it lands. It sits on the unmerged badge-service branch, which decides where it gets committed. +3. **The app's browser lane** — Buy in browser, the link handler, A6, and the desktop pair. End to end once 2 is deployed; before that it degrades to today's flow, where the buyer carries the code by hand. +4. **`purchaseBadge` with store receipts**, which depends on none of the above and can run alongside it. +5. **The app's store lane**, last, because nothing in it completes without 4. + +(*) **Desktop deep links** sat outside this order entirely. + +## The page + +Three changes, all in `apps/simplex-badge-service/web`. + +**Start at the second screen.** The page already routes on the fragment, and `#/tier` is one of its own step hashes, landing on Choose your badge — the screen after its own Support SimpleX, which the app has just shown. But `landingIndex()` compares the whole hash, so `#/tier?app=true` matches no step and falls to the welcome panel. Parsing the flag's tail is what makes this work; it is not free. + +The app opens the page at its own host, `https://badges.simplex.chat/#/tier?app=true`. Not `simplex.chat/badges/`, which the app links to today: that is an Eleventy page whose layout is a bare `` to a fixed URL, so the fragment does not survive it. The redirect stays as it is for people who type it. Both platforms should hold the page URL as one constant, since a dev deployment of the service has its own page and the app has to be able to point at it. + +**Carry an `app` flag through the flow.** It rides in the fragment beside the step — `#/tier?app=true`, or `?app=desktop` — rather than in the query, because a fragment is never sent: the service learns nothing about which visitors came from the app, and it is stripped from the `Referer` when the page hands off to a payment provider. Nothing server-side needs it, and both things it decides are rendering. + +Only the ending needs it, and getting it there takes two carriers, because nothing single-handedly spans the flow. The session record holds it from the first load to checkout. At `created` the session is cleared and the URL is rewritten to a relative `?order=`, which drops the fragment, and a card payment returns to `origin + pathname` with neither — so the flag is then stamped onto the order record, beside the browser-only fields it already keeps, and the ending reads the order alone. An order resumed in another browser has no record, falls back to the plain page, and shows the code; that is correct. Keeping the flag in the session past checkout instead would be smaller and wrong: it would outlive the purchase, and it could not be cleared on a flagless load, because a reload and the card return are both flagless. + +**Replace the final screen for `app=true` (A5).** Try the deep link on load, then offer *Return to SimpleX* with *Show code* under it. `Show code` reveals the existing `codeIssued` content, unchanged — which means the code must still be minted and saved in the browser exactly as now. For `app=desktop` (D2), keep `codeIssued` as it is and change only the "Redeem it in the app" line to say the screen is already open. With no flag, nothing changes. + +## The link + +`simplexchat:/badge/code/` — a scheme of its own, not a path added to `simplex:`. That costs a registration on every platform, including the two where `simplex:` was already free, and buys a badge link that cannot be mistaken for a connection link and an existing dispatch left alone: the URL arrives at the same entry point the app already has, branches on scheme before the path switch that feeds `planAndConnect`, and never reaches it. + +Two things the code must get right: + +The link is a bearer token. Anything that can read it can redeem the badge — no worse than a code on screen, but it must not be logged. iOS logs every incoming URL in `SimpleXApp.onOpenURL` today and Kotlin should be checked for the same; those lines have to stop short of the code. Any web page can send one, so the screen it opens should do nothing but redeem. + +A link may arrive when no profile is active, or for a different profile than the one that bought it. Redeem into the active profile, as the redeem screen does, and let the existing errors speak if the code is spent. + +## The app: non-store (A1, A6) + +On Support SimpleX, *Get your code* becomes the primary button *Buy in browser* and *Redeem badge code* drops to the text button. The tap must open the browser directly, not through `openExternalLink`, which alerts for anything that is not a SimpleX connection link. The info button stays as it is: the TODO in that file proposing to swap it for *Why SimpleX is built* should not be followed. `chooseLevelButton()` is never called in these builds — there is no in-app purchase here at all. + +A6 is the one new screen: a title, a spinner, and a call to the existing redeem API with the code from the link. It is reachable only from the link, never from navigation, and a link can arrive from anywhere in the app — present it the way the badge sheet is already presented from an alert. Its failures are the redeem screen's failures and should use the same alerts. On success the app lands on Your badge by the path it already takes. + +## The app: store builds (B1–B5) + +B1's primary becomes *Choose your badge* — `chooseLevelButton()`, which already exists and is called by nothing. Its text button is *Buy in browser* where the store rules allow it and *Redeem code* where they do not, so the slot is never empty and codes are always reachable. Define that predicate once and use it for both the button and B2's — the rule is expected to be wider than the crowdfunding banner's and is not settled, so it should be one thing to change later. + +B2 (`BadgesChooseBadgeView`) gains *Redeem code* only when B1's slot is taken by *Buy in browser*. The privacy link stays on B1 in both builds and does not move here. + +B3 (`BadgesHowLongView`) loses its Pay button to *Continue*; `purchase()` moves to B4, which is new. Three of B5's outcomes need building: a verified transaction becomes a funded-not-issued state, `.pending` becomes an awaiting-the-store state, and an unverified one is refused with an alert and never sent. Both of those states belong on `BadgeState` and both must hold the Support SimpleX slot, because a consumable can otherwise be bought twice. Cancelled does nothing, and other billing errors keep today's alert. + +Two invariants that are easy to miss. A store transaction must not be finished until the credential is stored — `BadgeStore` says so in a comment already and does the opposite because nothing is delivered yet. And both platforms need reconciliation at launch (`Transaction.updates`, `queryPurchasesAsync`), because an outcome can arrive while the app is closed. + +## Desktop (D1, D2) + +Where the scheme is not registered, there is no deep link: *Buy in browser* opens the page with `app=desktop` and the Redeem code screen at the same time, so the code has somewhere to go the moment it appears. This is also the fallback for any platform where the scheme turns out not to work: nothing else in the app depends on the link existing. + +## Desktop deep links (*) + +This was optional and off the critical path, and it blocked nothing, since D1 works without it. + +The plan is `plans/2026-09-28-desktop-deep-links.md`. The feature is implemented, and verification on real machines is pending. Desktop joins lane A wherever the scheme is registered, and D1 is the fallback rather than the design. + +Before that work, nothing registered a scheme on desktop. `appOpenUrl` was wired in commonMain with no desktop implementation behind it, and `desktop/build.gradle.kts` declared Deb, Dmg, Msi and Exe with no protocol entry. Registration is per-OS, three separate small jobs: `CFBundleURLTypes` in the bundle plist plus `Desktop.setOpenURIHandler` on macOS, registry keys under `HKCU\Software\Classes` on Windows (the app now writes them at runtime), and a `.desktop` entry with `MimeType=x-scheme-handler/simplexchat` on Linux. None of them covers an unpackaged run, so the fallback stays either way. + +Getting the URL to an app that is already running was the part that half existed. `SingleInstance.kt` took a file lock and used a watched file to bring the running instance forward on a second launch; the implementation gives that channel a payload rather than inventing IPC for it. + +## The service + +The browser lane needs nothing. The store lane needs `purchaseBadge` to accept a store receipt and verify it — Apple's JWS offline, Google's token through the Publisher API, per `docs/protocol/badges-rpc.md`. No provider adapter for either exists today. + +Two properties matter more than the plumbing. It must be idempotent per signing key, since the app will retry. And only a store's verdict about a purchase may be a plain refusal: the client consumes a purchase answered `receipt_invalid`, so a store that has merely not answered — or that does not yet know a token it has just issued — must be answered retryably, or a paid badge is thrown away. Junk tokens from tampered clients are the cheap side of that trade: each costs one request per client trigger, nothing loops on the service, and Play refunds an unacknowledged purchase after three days. `docs/protocol/badges-rpc.md` has the classes. + +`badges-rpc.md` also specifies `getBadgeCatalog` and `getBadgeInvoice`. Neither is needed by anything here and neither should be built: the page owns the catalog and the invoice. + +## Naming + +Two renames: *Your level* becomes *Choose your badge* with the page's lede, and the duration screen's title stops being the level name and becomes *How long?* with the chosen badge on the line below. This is the page's vocabulary; the app adopts it so the two halves of one purchase read the same. + +The views take their screens' names too, in a commit of their own: `BadgesYourLevelView` becomes `BadgesChooseBadgeView` and `BadgesPayView` becomes `BadgesHowLongView`, matching the directory's habit of naming a view after its title. `BadgeLevel` and `BadgePeriod` are domain types rather than views and keep their names — and `BadgeLevel` is marked for replacement by the purchase API's own types, so renaming it would be churn. Kotlin resource ids change along with their values — `badges_your_level_title` becomes `badges_choose_your_badge_title` and so on. Keeping an id while changing its English would leave every translation of it saying the old thing and still being shown, since a changed base value does not invalidate a translation; renaming retires them and gets the string translated afresh, which is what iOS does for free, its literals being their own keys. An id removed from base has to be removed from each translation file too, or the build fails formatting them. None of this costs anything today: no `badges_*` string has been through a translation round, so `base/strings.xml` is the only file that holds any. It stops being free the moment they are translated. + +## Tests + +The page has screen and routing tests; extend those rather than starting a parallel suite, and cover all three cases, including no flag at all. No UI tests on the app side — verify those by review. + +The store purchase needs end-to-end coverage and `BadgeServiceTests` is already the place: it runs a real client against a real service over SMP and covers redeeming, issuing, renewal and the worker. Everything from `APIPurchaseBadge` rightwards belongs there, with the receipt an opaque blob and verification behind a test provider that accepts one known token — the same seam the crypto and card providers already sit behind. Worth covering: a valid receipt issues a credential; the same receipt twice returns that same credential; an unknown token is refused and the refusal is not retryable; a receipt whose purchase key is not the signer is refused, as the code path is already tested for. + +The store SDK call itself cannot be automated. StoreKit needs a configuration file on a device, Play needs a real billing connection, and `.pending` needs a sandbox family account with Ask to Buy. That part is QA, and `useBadgeTestProducts` already exists for walking the Kotlin flow without Play Console products. + +## Comments + +Default to none. Add one only where behaviour or a consequence cannot be carried by names and structure — the transaction-finish ordering, the unlogged code, the retryability rule above are the kind of thing that earns one. Do not comment each function, do not restate the code, and do not reference this plan, a panel id or a ticket in the source. + +## Open questions + +- **Will a browser follow the scheme without a gesture?** The attempt is made from a hidden frame, so a refusal stays inside the frame instead of replacing the screen with an error page and putting the code into history. Expect it to do nothing on iOS Safari, which has ignored custom schemes in frames for years, and on Chrome, which blocks an external-protocol launch with no user gesture — and a payment settling minutes after the last tap has none. So *Return to SimpleX* is the real path and the automatic attempt is a bonus where it happens to work. What remains is to try it on real devices once the app registers the scheme. +- **Where the browser button is allowed.** Which markets, and whether the same answer governs both builds. + +## Part 2: subscriptions + +Monthly and Annual become real purchases. The service side is the work: a subscription renews without the app present, so the badge has to gain months from a provider notification rather than from a `purchaseBadge` call, which means Apple's server notifications and Google's Pub/Sub, and a way to add months to a badge that already exists. The app side is smaller but not only copy: `BadgeState.renewsAt` and `willRenew` are hardcoded to nothing today and have to become real, and `BadgesYourBadgeView`'s Ends section says prepaid months have no billing date, which stops being true and needs a second wording. + +The browser lane never gains subscriptions: the page sells prepaid months and says so. diff --git a/plans/2026-09-28-desktop-deep-links-report.md b/plans/2026-09-28-desktop-deep-links-report.md new file mode 100644 index 0000000000..08561f6808 --- /dev/null +++ b/plans/2026-09-28-desktop-deep-links-report.md @@ -0,0 +1,150 @@ +# Desktop deep links: report + +Packaged desktop builds on macOS, Windows and Linux receive `simplexchat:` links from the badge page, and `simplex:` connection links, with the app closed, running or hidden in the tray. Unit tests and builds pass; no packaged build has been run on real machines yet, so the manual checklist below is open. + +The plan is `plans/2026-09-28-desktop-deep-links.md`. + +## Flow + +``` +page: Return to SimpleX → simplexchat:/badge/code/ + macOS Apple Event → installOpenUriHandler → openDesktopLink + Windows, Linux new process main(args) → linkFromArgs + lock acquired → chatModel.appOpenUrl (cold start), then the watcher starts + lock taken → simplex.show with the link → running app → openDesktopLink +openDesktopLink: chatModel.appOpenUrl = remoteHostId() to link, then showWindow() +App.kt LaunchedEffect → connectIfOpenedViaUri → openAppLink → BadgesRedeemLinkView (asks before redeeming) +``` + +## Registration + +| Build | Registered by | What is written | Registered when | +|---|---|---|---| +| macOS `.dmg` | Info.plist, at build | `CFBundleURLTypes` with schemes `simplexchat` and `simplex` in `desktop/build.gradle.kts` | copied to /Applications; LaunchServices picks it up | +| Windows `.msi`, `.exe` | the app, each start | `HKCU\Software\Classes\simplexchat` and `\simplex`: `URL Protocol`, `DefaultIcon`, `shell\open\command = "" "%1"`; ProgIds `SimpleX.simplexchat` and `SimpleX.simplex` with the same command; `HKCU\Software\chat.simplex.app\Capabilities` with `URLAssociations`, listed in `HKCU\Software\RegisteredApplications` | first launch per user; rewritten when a value is missing or the command's program is gone, not while another installed program, per-user or machine-wide, holds it; registered when Windows (`AssocQueryStringW`) names this exe as the program for `simplexchat:` | +| Linux `.deb` | `make-deb-linux.sh`, at build | jpackage's `simplex-simplex.desktop` gets `Exec=... %u` and `MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;` | install; postinst `xdg-desktop-menu install` refreshes the MIME cache | +| AppImage | the app, each start; also the bundled entry | hidden `$XDG_DATA_HOME/applications/chat.simplex.app-links.desktop` with `Exec= %u`, the path quoted only when the desktop-entry spec requires it, then `xdg-mime default` for each scheme; the bundled `chat.simplex.app.desktop` declares the MimeType for AppImage integration tools | first launch; rewritten if the AppImage moved; the default is set only when none is set or the current handler's entry is no longer installed | +| Flatpak | `scripts/flatpak/chat.simplex.simplex.desktop` | `MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;` (Exec already has `%U`) | install of a Flathub release whose pinned commit includes this file | + +The registration code is in `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt`. It runs once on a background thread after `initApp()`. The installation kind comes from `desktopInstallation(platform, appPath, env)`. An unpackaged run (no `jpackage.app-path`) registers nothing, and `APPIMAGE` counts only when the launcher runs from `APPDIR`, resolved through symlinks, because the AppImage runtime exports it to every child process. The AppImage entry is rewritten, followed by `update-desktop-database`, only when its content changed. An AppImage is not registered when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it, since the entry would run that file for every link; POSIX ACLs are not inspected. A path with a line break or `%` is not registered either, since GLib looks the program up before it expands `%%`. The path in `Exec` is quoted only when the desktop-entry spec requires it, because `xdg-open` 1.1.3 (Debian 12, Ubuntu 24.04) cannot run a quoted program in its generic mode, which Sway and other desktops it does not recognise use. + +## Receiving the link + +- **Closed, Windows and Linux:** the link is the only argument of `main`. `linkFromArgs` accepts it only when it is the single argument, starts with `simplexchat:` or `simplex:` and is at most 8192 bytes in UTF-8, enough for a one-time link with its post-quantum key (about 2 KB) and for a second such key. +- **Running, Windows and Linux:** the second process writes the link to an owner-only temp file in `dataDir` and renames it to `simplex.show`; if that fails, it writes an empty signal, which still raises the running app. The running app renames it to a private name, accepts at most 8192 bytes, deletes it, opens the link and shows its window. A signal present when the watcher starts is handled too, unless it predates the lock attempt by more than a 2 s tolerance for file time granularity; such a signal is left by an earlier session and is deleted instead, as are old temp files and a taken file left by a crash. On Windows the second process first calls `AllowSetForegroundWindow` so the window can come to the front, and logs a refusal. +- **Hung running app:** the existing "not responding" alert stays. "Start anyway" withdraws the signal and the new process opens the link itself; if the running app recovered and took the signal while the alert was up, the new process exits instead, so the link opens once. If renaming the signal fails, for example while another program holds it open, the running app retries once after 100 ms. +- **macOS:** the link arrives as an Apple Event, closed or running. `installOpenUriHandler` sets the handler on macOS only; the JDK holds an event that arrives during startup until then. +- **Bringing the window forward on Linux:** AWT's `toFront()` sends only `XRaiseWindow`, which Sway drops and focus stealing prevention refuses. `showWindow` makes the window visible at once and also sends `_NET_ACTIVE_WINDOW` (pager source) through JNA, after syncing AWT's connection so the map request comes first. On Sway the result follows `focus_on_window_activation`: the default `urgent` marks the window urgent, `smart` or `focus` focuses it. This was verified on headless Sway 1.9 with XWayland. + +## Badge page ending + +`appLinkSchemeRegistered()` decides the page flag when *Buy in browser* is tapped: + +| Result | Page URL | App side | +|---|---|---| +| registered | `#/tier?app=true` | page ends with *Return to SimpleX* and *Show code* | +| not registered | `#/tier?app=desktop` | Redeem code screen opens beside the browser (fallback) | + +Only `simplexchat:` counts here; `simplex:` is registered alongside it without being checked. By build, macOS is registered when packaged; Windows when the shell (`AssocQueryStringW`) names this exe as the program for `simplexchat:`; Flatpak always; AppImage and deb when `xdg-mime` reports their own entry as default. Android always reports registered. The button reads the flag once, so the URL and the fallback screen agree. + +## Security + +- The link is a bearer token. No log line on this path includes it. +- Any web page or program can send a link. The redeem screen from #7592 asks before redeeming and names the profile. +- The code arrives as a command-line argument. On Linux any local user can read it through `/proc//cmdline` unless `/proc` is mounted with `hidepid`; on a cold start the argument belongs to the app itself and stays readable until it exits, otherwise to the second process for about a second. On Windows only the same user and administrators can read it. This is an accepted risk; removing it needs the page to encrypt the code to a key the app passes in the URL fragment. +- The signal file holding the link is owner-only on POSIX and deleted as soon as it is read. It stays on disk only if the running app is hung. A later start that takes the lock deletes it without opening the link once it is more than 2 s older than the lock attempt; while the hung app still holds the lock, a later launch replaces the file and shows the alert again. +- Another program can claim the same scheme (on Windows, an app registered through `Capabilities` or picked in default-app settings wins; on Linux, the last `xdg-mime default` wins). That risk is accepted, and *Show code* on the page remains. + +## Automated verification (done) + +- `./gradlew :common:desktopTest`: all tests pass; this work adds `AppLinksTest`, `AppLinkSchemeTest`, `BadgePageUrlTest`, `WindowActivationTest` and nineteen `SingleInstanceTest` cases. + - `AppLinksTest`: accepts `simplexchat:` and `simplex:` in any case, including a one-time link with its post-quantum key; rejects `https:`, another scheme sharing the prefix, a path, empty, a link that is not the only argument, and a link over 8192 bytes, including a multi-byte one shorter than that in characters. + - `SingleInstanceTest`: link and empty signals round trip and are taken once; a later signal replaces an untaken one, and a taken file left by a crash is replaced; the file is owner-only (on POSIX); a link of exactly 8192 bytes and a one-time link with its post-quantum key are accepted; non-link content, an oversized link and a multi-byte link over the byte bound are dropped and still removed; the temp file is created beside the signal (skipped where the watch service polls, as on macOS); the startup sweep removes old temp files and a taken file but keeps a fresh temp file and the old lock and database files, and keeps a signal within the 2 s tolerance but drops an older one; the watcher takes a signal written before its watch, and successive signals arriving while it watches (skipped where the watch service polls, as on macOS), through a real `WatchService`, each once. + - `AppLinkSchemeTest`: Windows command quoting, the registry values, the deb counting as registered only when its own entry is the default, the ProgIds and the application listing, and registration with a stand-in shell (writing every value when the commands point at a missing program, leaving a full registration alone without notifying Windows, repairing a missing value or a missing application listing and notifying, keeping another installed program's or another copy's handler per-user or machine-wide, repairing this app's ProgId while another program holds the scheme, updating a command for this exe with other arguments, leaving a scheme alone when its handler cannot be read or its ProgId cannot be written, and reporting registered only when the shell names this exe, in any letter case); desktop-entry quoting that leaves a plain path bare, quotes every reserved character, keeps spaces inside the quotes and escapes `"`, `` ` ``, `$` and `\`; the full AppImage entry and its refusal of line breaks and `%`; `desktopInstallation` precedence, including Flatpak over a matching AppImage, inherited AppImage variables, a mount path that only shares a prefix, and a mount reached through a symlink; the AppImage ownership check for world-writable and group-writable files and directories, a world-writable ancestor, another owner, a root-owned file, and a missing file; AppImage registration writing the entry into a missing directory and setting both defaults, leaving unchanged defaults alone, setting the defaults again for an unchanged entry, setting only the default that points elsewhere, rewriting the entry of a moved AppImage, failing when another handler keeps the `simplexchat:` default, succeeding when it keeps only the `simplex:` one, and refusing a path the entry cannot run; `runProcess` output, discarded error output, an end of input and failure. + The ownership, AppImage registration and `runProcess` cases need POSIX permissions and `sh`, and the symlinked-mount case needs symlink rights, so they are skipped on Windows. + - `BadgePageUrlTest`: `app=true` when the link returns, `app=desktop` otherwise. + - `WindowActivationTest`: the `_NET_ACTIVE_WINDOW` client message targets the window with the pager source and `CurrentTime`, checked in native memory; it is sent to the root window with the window manager's event mask, and the display is closed even when sending fails (skipped where libX11 cannot load, as on Windows and macOS). +- `./gradlew :desktop:compileKotlinJvm` and `./gradlew :common:compileDebugKotlinAndroid`: succeed, no new warnings. +- The `make-deb-linux.sh` sed applied to the v7.0.3 deb's desktop file gives the intended lines, and `update-desktop-database` lists it for `x-scheme-handler/simplexchat`. +- The AppImage entry passes `desktop-file-validate` and reaches the MIME cache. + +## Manual verification (to do) + +Test with `simplexchat:/badge/code/` from a real purchase, or with the page mock (`apps/simplex-badge-service/web/mock/server.py`, open `#/tier?app=true` in a private window, settle with `/control/settle/`). + +### macOS (signed `.dmg`, installed to /Applications) +- [ ] `lsregister -dump | grep simplexchat` lists the installed app +- [ ] app closed: `open 'simplexchat:/badge/code/'` starts it and shows the redeem screen +- [ ] app running, window behind others: link raises it and shows the redeem screen +- [ ] app hidden in the tray: link shows the window +- [ ] dmg still mounted beside the installed copy: the installed copy opens +- [ ] Safari, Chrome, Firefox: *Return to SimpleX* prompts, then opens the app + +### Windows (`.msi` install) +- [ ] after first launch, `HKCU\Software\Classes\simplexchat\shell\open\command` is `"C:\Program Files\SimpleX\SimpleX.exe" "%1"` +- [ ] app closed: `start simplexchat:/badge/code/` starts it and shows the redeem screen +- [ ] app running: link reaches the running app, window comes to the front +- [ ] app hidden in the tray: link shows the window +- [ ] the argument arrives as one piece after the launcher restarts itself +- [ ] Edge, Chrome, Firefox: *Return to SimpleX* prompts, then opens the app +- [ ] after an MSI upgrade, the key still points at the installed exe +- [ ] SimpleX is offered under *Settings → Default apps → SIMPLEXCHAT*; picking another app there makes the page end with `app=desktop` after a restart +- [ ] fresh install, no choice made: the page ends with `app=true`, and `start simplexchat:/badge/code/` opens SimpleX +- [ ] with another app registered through `Capabilities` and no choice made: the page's ending matches what `start simplexchat:` actually opens (`app=true` only if SimpleX opens) + +### Linux +- [ ] deb on GNOME and KDE: `xdg-mime query default x-scheme-handler/simplexchat` gives `simplex-simplex.desktop` +- [ ] deb, app closed and running: `xdg-open 'simplexchat:/badge/code/'` shows the redeem screen +- [ ] AppImage: after first launch the hidden entry exists and is the default; moving the AppImage and relaunching updates it +- [ ] Flatpak: link opens the Flatpak app (needs the Flathub update) +- [ ] Firefox and Chrome from the distribution, snap Firefox on Ubuntu, Flatpak Firefox: *Return to SimpleX* opens the app +- [ ] GNOME on Wayland: window comes forward +- [ ] Sway: default config marks SimpleX urgent; with `focus_on_window_activation focus` the link focuses it +- [ ] deb and AppImage: `xdg-mime query default x-scheme-handler/simplex` names the app's entry + +### All platforms +- [ ] app locked with a passcode: the redeem screen appears after unlock +- [ ] no active profile: the link waits until a profile is active +- [ ] desktop connected to a mobile: the badge goes to the active remote profile +- [ ] a second link while one redeems is ignored +- [ ] no log file or console output contains the code +- [ ] a `simplex:` contact or one-time link from a browser, app closed and running: the connect dialog opens and asks before connecting +- [ ] unpackaged `./gradlew run`: page gets `app=desktop`, Redeem code screen opens beside the browser +- [ ] `./gradlew runDistributable` and the installed app keep each other's registration while both exes exist; delete the build output (or the HKCU keys) to give links back to the installed app +- [ ] two installed copies: the scheme stays with the first; after uninstalling it, the next start of the other copy takes the scheme + +## Known gaps + +The registration gaps below are also recorded as GAP-08 in `apps/multiplatform/product/gaps.md`. + +- A Flatpak always counts as registered, since the sandbox cannot query the host's default handler. If another installation holds the default, the page sends `app=true` and the link opens that installation; *Show code* still works. +- On Sway the window only becomes urgent unless the user sets `focus_on_window_activation smart` or `focus`. No client-side request can do more: Sway accepts activation tokens only for native Wayland windows. +- The Flathub release bump must pin a simplex-chat commit that includes the desktop file's MimeType line, and the Flathub wrapper must pass `"$@"` (done on its `update` branch). +- The HKCU key and the AppImage entry stay after uninstalling or deleting the app. On Windows, after an install into a folder other users can write to (such as a custom folder at the root of `C:\`), another user could place a program at the registered path; this accepted risk does not apply to the default Program Files install. +- On macOS, if LaunchServices starts a second copy of the app while one runs, that copy forwards no link: the URL arrives as an Apple Event the second copy never handles. +- jpackage's deb desktop file already has `Categories=Unknown`, which `desktop-file-validate` rejects, and a generic `Name=simplex`. The scheme still registers, and this work leaves both as they are. + +## Commits + +| Commit | Change | +|---|---| +| `tests: close the walk stream in withTempDir` | `Files.walk` closed in the existing `SingleInstanceTest` helper | +| `desktop: receive app links from args and macOS` | `AppLinks.kt`, `main(args)`, macOS handler | +| `desktop: forward an app link to the running app` | signal file payload in `SingleInstance.kt` | +| `desktop: initialize the desktop platform lazily` | `desktopPlatform` class initialization cycle | +| `tests: share the temp directory helper` | `withTempDir` moved to `TempDir.kt` for the scheme tests | +| `desktop: register simplexchat scheme in packages` | Info.plist, deb, AppImage and Flatpak desktop entries | +| `desktop: register simplexchat scheme at runtime` | `AppLinkScheme.desktop.kt` (Windows, AppImage, detection) | +| `ui: end the badge page with a link on desktop` | `appLinkSchemeRegistered()`, `badgePageUrl(linkReturns)`, fallback | +| `desktop: raise the window over other apps` | `isVisible` in `showWindow` and its skip of a disposed window during a crash restart, `_NET_ACTIVE_WINDOW` in `WindowActivation.kt`, `AllowSetForegroundWindow` on Windows | +| `docs: describe desktop deep links in the spec` | `navigation.md`, `architecture.md`, spec `README.md`, `impact.md`, `CODE.md` Document Map, `concepts.md`, `gaps.md` (GAP-08) | +| `docs: plan desktop deep links` | badge plan, plan | +| `docs: report on desktop deep links` | this report | +| `desktop: open simplex connection links` | `simplex:` accepted and registered beside `simplexchat:`, one 8192-byte bound, desktop link names without `App` | +| `docs: describe simplex links on desktop` | spec, `gaps.md`, plan and this report | +| `desktop: keep a link from opening twice` | `startDespiteRunningInstance()`: "Start anyway" withdraws the signal, and exits if the running app already took it | +| `desktop: retry taking a signal held open` | `takeSignal` retries a failed rename once | +| `desktop: keep the user's AppImage link handler` | the AppImage takes a scheme only when no handler is set or the current handler's entry is no longer installed | +| `desktop: register Windows links through Capabilities` | scheme keys and `SimpleX.` ProgIds, `Capabilities` with `URLAssociations` and `RegisteredApplications`; every value compared and repaired; another installed program's or copy's handler kept, per-user or machine-wide; registered when `AssocQueryStringW` names this exe; `SHChangeNotify` only after a change | +| `desktop: add %u to the deb entry only once` | the deb's `Exec` sed skips a line that already has a field code | +| `docs: describe the review fixes` | spec, `gaps.md`, plan and this report | diff --git a/plans/2026-09-28-desktop-deep-links.md b/plans/2026-09-28-desktop-deep-links.md new file mode 100644 index 0000000000..1097cab987 --- /dev/null +++ b/plans/2026-09-28-desktop-deep-links.md @@ -0,0 +1,145 @@ +# Desktop deep links: `simplexchat:` on Linux, Windows, macOS + +## Context + +The badge page (`badges.simplex.chat`) ends a purchase opened from the app with *Return to SimpleX*, which navigates to `simplexchat:/badge/code/`. Android and iOS handle it since #7592 (`isAppLink` → `openAppLink` → `openBadgeLink` → `BadgesRedeemLinkView`, which confirms before redeeming). Desktop registers no scheme, so it opens the page with `app=desktop` and relies on the user pasting the code (D1). Goal: every packaged desktop build receives the link, cold or running, and sends `app=true` only when a link is expected to reach this installation. D1 stays as the fallback. + +The decisions were to build on #7592, forward to a running instance through the existing signal file with a payload, register `simplexchat:` only (`simplex:` connection links were added afterwards, see the last section), and self-register at runtime on Windows (HKCU) and AppImage. + +## Research summary + +- **macOS**: `CFBundleURLTypes` in Info.plist; LaunchServices registers on copy to /Applications. URL arrives only as an Apple Event through `Desktop.setOpenURIHandler`; JDK 17 queues it until the handler is set (`_OpenURIDispatcher`), so cold start is safe for AWT/Compose. The running app receives it in-process, so no second process starts. +- **Windows**: `HKCU\Software\Classes\simplexchat` with `URL Protocol`, `shell\open\command = "" "%1"`. Compose 1.8.2 exposes no `--resource-dir`, so installer registration needs a WiX fork; runtime HKCU is what Signal, Element, Telegram do. URL arrives as argv of a new process. `jpackage.app-path` is set by the jpackage launcher (JDK 16+). +- **Linux**: `.desktop` with `MimeType=x-scheme-handler/simplexchat;` and `Exec=... %u`, visible once `mimeinfo.cache` is rebuilt. The v7.0.3 deb ships `/opt/simplex/lib/simplex-simplex.desktop` with `Exec=/opt/simplex/bin/simplex` and empty `MimeType=`; its postinst runs `xdg-desktop-menu install`, which runs `update-desktop-database`. AppImage has no install step, so it self-registers in `~/.local/share/applications`. Flatpak exports the `.desktop` MimeType (file is in `scripts/flatpak/`). +- **Browsers**: all prompt before launching; Chrome needs a user gesture (the page's button provides it). No reliable way for the page to know the app opened, so *Show code* stays. +- **Security**: link is a bearer token in argv, which on Linux other local users can read through `/proc` (for the whole session after a cold start); this risk is accepted. The lane already confirms before redeeming. Nothing may log the URI. + +## Flow + +``` +browser ─ simplexchat:/badge/code/X + macOS: Apple Event → setOpenURIHandler → openDesktopLink + Windows/Linux: new process main(args) → linkFromArgs + lock acquired → chatModel.appOpenUrl (cold start), then the watcher starts + lock taken → simplex.show payload → primary watcher → openDesktopLink +openDesktopLink: chatModel.appOpenUrl = remoteHostId() to uri; showWindow() +App.kt LaunchedEffect (existing) → connectIfOpenedViaUri → openAppLink (lane) +``` + +## Changes + +1. **`common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt`** (new, beside `SingleInstance.kt`) + - `isAcceptedLink(uri)`: `isAppLink` (`ChatListView.kt`) within `MAX_LINK_BYTES` UTF-8 bytes. + - `linkFromArgs(args)`: the only argument, when it is an accepted link; anything else is ignored. + - `openDesktopLink(uri)`: on the EDT, `chatModel.appOpenUrl.value = chatModel.remoteHostId() to uri`, then `showWindow()` (`DesktopApp.kt:258`), so a failure to show the window cannot lose the link. `remoteHostId()` matches the redeem screen (`BadgesRedeemCodeView.kt:98`). + - `installOpenUriHandler()`: called on macOS only (so Windows and Linux do not start AWT early); sets a handler that forwards accepted links to `openDesktopLink`. + +2. **`desktop/.../Main.kt`**: `main(args)`. Compute the link and pass it to `acquireSingleInstance(link)`; when this process runs, put it into `chatModel.appOpenUrl` (`null` host), then start the watcher if this process holds the lock, so a link the watcher forwards later is not overwritten. A short-lived second process never touches `chatModel`. After `initApp()`: start registration (step 4) and `installOpenUriHandler()`. Keep everything inside the existing try. + +3. **`SingleInstance.kt`**: the signal carries an optional payload. + - Second process: `Files.createTempFile(dataDir, "simplex.show", ".tmp")` (owner-only on POSIX), write the link or nothing, then `Files.move(tmp, showPath, ATOMIC_MOVE)`, which replaces an untaken signal on Linux and Windows. If that fails, fall back to an empty signal, which still brings the running instance forward. The rename arrives as `ENTRY_CREATE` on inotify and Windows. Existing 1 s wait and hung-primary alert stay; on "start anyway" the payload file is deleted and `main` handles the link itself. + - Primary watcher: rename `simplex.show` to a private temp name, so a newer signal renamed over it is not deleted unread, then accept at most `MAX_LINK_BYTES` bytes, delete it, and post `openDesktopLink(link)` or `showWindow()`. + - Primary start deletes a taken file left by a crash, and any `simplex.show*` file older than its lock attempt minus a 2 s tolerance for file time granularity: such a signal or temp file was left by an earlier session, while a fresh temp file is a signal being written. `main` then starts the watcher, which registers the watch and handles a signal already present, which raised no event. + - Windows: call `AllowSetForegroundWindow(ASFW_ANY)` before signalling so the primary can come forward (best effort). jna-platform 5.14 does not bind it, so it is looked up through `NativeLibrary`. + - Take the directory as a parameter internally so tests can use a temp dir. + - No log line includes the link. + +4. **`common/src/desktopMain/.../platform/AppLinkScheme.desktop.kt`** (new): `registerLinkSchemes()` runs once on a background thread and sets a `@Volatile` result read by the UI. `unixDataHome` is exposed from `Platform.desktop.kt` and `appLinkScheme` from `ChatListView.kt` for reuse. + - macOS: registered when `jpackage.app-path` is set (packaged bundle). + - Windows: when `jpackage.app-path` is set, read the HKCU command via `Advapi32Util`. If it differs from `"" "%1"`, write the default value `URL:SimpleX Chat`, `URL Protocol`, `DefaultIcon`, and `shell\open\command`. Registered if the key now matches. + - Linux: + - `container=flatpak` set (the check `AppUpdater` uses) → registered. + - AppImage, when `APPIMAGE` and `APPDIR` are set and the launcher runs from `APPDIR`, resolved through symlinks (the runtime exports `APPIMAGE` to every child process) → write `$XDG_DATA_HOME/applications/chat.simplex.app-links.desktop`: `NoDisplay=true`, `Exec= %u` with the path quoted only when the desktop-entry spec requires it (`xdg-open` 1.1.3 cannot run a quoted program), and the MimeType line; a path with a line break or `%` is refused (GLib looks the program up before it expands `%%`), and so is an AppImage whose file or any directory above it another user could replace (owned by anyone but the user or root, writable by others, or group-writable by a group other than the user's own; POSIX ACLs are not inspected). Rewrite it and run `update-desktop-database` only when the content differs, then run `xdg-mime default` unless the entry is already the default. + - Otherwise (deb): registered when `xdg-mime query default x-scheme-handler/simplexchat` names the deb's own `simplex-simplex.desktop`. Processes are run without a shell and with a timeout. + - The decision is the function `desktopInstallation(platform, appPath, env)`, so it is unit tested. + - `desktopPlatform` becomes lazy in its own commit: the new tests reference a `DesktopPlatform` constant first, and the eager value was then null through a class initialization cycle. + - Unpackaged runs register nothing and report false. + +5. **Common UI** + - `expect fun appLinkSchemeRegistered(): Boolean` in `platform/AppCommon.kt`, with Android actual `true` and desktop actual returning step 4's result. + - `BadgeStore.kt`: `badgePageUrl(linkReturns)` returns `app=true` or `app=desktop`; the button reads the flag once and passes it, so the URL and the fallback screen agree. + - `BadgesSupportSimplexView.kt` `BuyInBrowserButton`: open the Redeem code screen beside the browser only when not registered (replaces `appPlatform.isDesktop`), and update its comment. + +6. **Packaging** + - `desktop/build.gradle.kts`: add `CFBundleURLTypes` (`CFBundleURLName` `chat.simplex.app`, scheme `simplexchat`) to the existing `extraKeysRawXml`. + - `scripts/desktop/make-deb-linux.sh`: after `dpkg-deb -R`, sed `extracted/opt/*imple*/lib/*.desktop` so `Exec` ends in ` %u` and `MimeType=x-scheme-handler/simplexchat;`, and fail if the MimeType line is missing. This must run before the timestamp `touch`. + - `desktop/src/jvmMain/resources/distribute/SimpleX.desktop` (AppImage only): add the MimeType line and `%u` to Exec. In `scripts/desktop/make-appimage-linux.sh`, make the Exec sed replace only the program, so `%u` is kept. + - `scripts/flatpak/chat.simplex.simplex.desktop`: add the MimeType line. Confirm the Flathub manifest takes this file. + +7. **Window activation** (`WindowActivation.kt`, `DesktopApp.kt showWindow`): `showWindow` skips a disposed window (during a crash restart), sets `isVisible` immediately, and on Linux also sends `_NET_ACTIVE_WINDOW` (pager source) through JNA after syncing AWT's connection, because AWT's `toFront()` sends only `XRaiseWindow`, which Sway drops. + +8. **Docs** + - `apps/multiplatform/spec/client/navigation.md`: replace "Desktop registers no URL scheme" with the desktop paths. + - `apps/multiplatform/spec/architecture.md` and `spec/README.md`: the desktop `main()` steps, `showWindow()` and the line references they shift. + - `spec/impact.md` and the `CODE.md` Document Map: rows for the new desktop files, with `Main.kt` and `SingleInstance.kt` mapped to `navigation.md` as well; the Source Files tables in `navigation.md` and `architecture.md`; `product/concepts.md`: a row for `AppLinks.kt`; `product/gaps.md`: GAP-08 for the known registration gaps, marked `[GAP]` in `navigation.md`. + - `plans/2026-09-24-badge-buy-in-browser.md`: mark the desktop deep links section as done. + +## Commits + +1. `tests: close the walk stream in withTempDir` +2. `desktop: receive app links from args and macOS` +3. `desktop: forward an app link to the running app` +4. `desktop: initialize the desktop platform lazily` +5. `tests: share the temp directory helper` +6. `desktop: register simplexchat scheme in packages` +7. `desktop: register simplexchat scheme at runtime` +8. `ui: end the badge page with a link on desktop` +9. `desktop: raise the window over other apps` +10. `docs: describe desktop deep links in the spec` +11. `docs: plan desktop deep links` +12. `docs: report on desktop deep links` +13. `desktop: open simplex connection links` +14. `docs: describe simplex links on desktop` +15. `desktop: keep a link from opening twice` +16. `desktop: retry taking a signal held open` +17. `desktop: keep the user's AppImage link handler` +18. `desktop: register Windows links through Capabilities` +19. `desktop: add %u to the deb entry only once` +20. `docs: describe the review fixes` + +## Verification + +- **Unit tests** (`common/src/desktopTest`, beside `SingleInstanceTest.kt`): + - `linkFromArgs`: accepts `simplexchat:` in any case; rejects `https:`, empty args, a link that is not the only argument, and a link over the bound in bytes, including a multi-byte one within it in characters. + - Signal payload: link and empty round trip, taken once, replace of an untaken signal and of a taken file left by a crash, owner-only file on POSIX, a link of exactly the byte bound, rejected content including a multi-byte link over the bound, a temp file created beside the signal, a sweep that removes old temp files and a taken file but keeps a fresh temp file and the old lock and database files, keeps a signal within the 2 s tolerance and drops an older one, a watcher that takes a signal written before its watch, and successive signals arriving while it watches, each once, through a real `WatchService`. + - The Windows command string, the registry values and registration with a stand-in registry, the deb's registered check, the AppImage desktop entry for a path with spaces, Exec quoting that leaves a plain path bare and quotes and escapes reserved characters, its refusal of line breaks and `%`, `runProcess` output, discarded error output, an end of input and failure, and `desktopInstallation` precedence including Flatpak over AppImage, inherited AppImage variables, a mount path that only shares a prefix, and a mount reached through a symlink; the AppImage ownership check for world-writable and group-writable files and directories, a world-writable ancestor, another owner, a root-owned file, and a missing file; AppImage registration with a stand-in for `xdg-mime`: a new entry in a missing directory, an unchanged default, an unchanged entry that is no longer the default, a moved AppImage, a default that does not change, and a path the entry cannot run. + - `badgePageUrl` (in `common/src/commonTest`): `app=true` when the link returns, `app=desktop` otherwise. + - `activationEvent` and `sendActivation`: a `_NET_ACTIVE_WINDOW` client message for the window, with the pager source and `CurrentTime`, checked in native memory, sent to the root window through a stand-in for libX11 that also checks the display is closed. +- **Commands**: `./gradlew :common:desktopTest`, `./gradlew :desktop:compileKotlinJvm` and `./gradlew :common:compileDebugKotlinAndroid`, with no new warnings. +- **Manual matrix** on packaged builds. States: app closed, running, hidden in tray, locked with passcode, no active profile. + - Launch commands: `open 'simplexchat:/badge/code/'`, `start simplexchat:/badge/code/`, `xdg-open ...`. + - End to end through the page mock (`apps/simplex-badge-service/web/mock/server.py`, `#/tier?app=true`, settle via `/control/settle/`) in Chrome, Firefox and Safari. + - Linux: deb on GNOME and KDE, snap Firefox, Flatpak, AppImage. + - Check `lsregister -dump | grep simplexchat` on macOS and the HKCU key on Windows. + - Confirm that no log contains the code. +- **Unpackaged `./gradlew run`**: reports not registered and keeps D1. + +## Risks to confirm on hardware + +- How the Windows launcher re-quotes argv when it restarts itself. +- macOS cold start with Compose 1.8.2. +- Snap Firefox on Ubuntu opening custom schemes. +- GNOME Wayland raising the window (best effort). +- Stale HKCU keys and AppImage entries stay after uninstall or deletion; the plan accepts that. +- The deb's existing `Categories=Unknown`, which `desktop-file-validate` rejects, is out of scope and left as is. + +## `simplex:` connection links + +Android already opens `simplex:` links through `connectIfOpenedViaUri`, which sends them to `planAndConnect` (it asks before connecting). Desktop now receives them by the same paths as app links. + +- `isAcceptedLink` accepts `isAppLink` or `isConnectionLink` (`ChatListView.kt`). One bound covers both: `MAX_LINK_BYTES` is 8192, as a one-time link with its post-quantum key is about 2 KB and a second such key would bring it near 4 KB (the sntrup761 key alone is 1544 characters in base64url). The desktop names lose the `App` part: `linkFromArgs`, `openDesktopLink`, `registerLinkSchemes`. +- Every registration declares both schemes: the Info.plist, the deb, AppImage and Flatpak desktop entries, an HKCU key per scheme on Windows, and `xdg-mime default` per MIME type for an AppImage. +- `appLinkSchemeRegistered()` still reports `simplexchat:` only, as it decides how the badge page ends; `simplex:` is registered without being checked. +- Tests: a short link, a one-time link with its key, and the scheme in another case are accepted; another scheme sharing the prefix and a path are not; a forwarded one-time link round trips through the signal file; Windows and AppImage registration write both schemes, and only `simplexchat:` decides the result. + +## Review fixes + +- "Start anyway" after the not-responding alert withdraws the signal with `deleteIfExists`; when the running instance has already taken it, the second process exits, so the link opens once (`startDespiteRunningInstance`, extracted from `acquireSingleInstance` so it is tested). +- `takeSignal` retries a failed rename once after 100 ms, as another program (an antivirus scanner on Windows) can hold the file open for a moment. The move is a parameter, so tests make it fail. +- Windows registration compares and repairs every value, not only the command; reads a value of another type as missing instead of letting JNA's `RuntimeException` end the registration thread; and reports not registered when `UserChoice` names another ProgId (replaced later by asking the shell, below). +- Registration takes a scheme only when no handler is set or the current one is no longer installed: on Windows when the command's program file is missing, for an AppImage when the current default's desktop entry is in no applications directory. Another installation keeps it, and the badge page falls back to `app=desktop`. +- The deb's `Exec` sed adds `%u` only to a line without a field code. +- Windows: a test on Windows 11 showed that a scheme key alone loses to any app registered through `Capabilities`: links went to that app without asking, and default-app settings did not offer SimpleX. Each scheme now also gets a `SimpleX.` ProgId named in `HKCU\Software\chat.simplex.app\Capabilities\URLAssociations`, and the application is listed in `HKCU\Software\RegisteredApplications` last. `UserChoice` was compared with that ProgId until the shell check below replaced it. The scheme key stays, as it declares the URL scheme to Windows. +- Not changed: stale-signal detection still compares file times with the local clock (network file systems with clock skew above 2 s are an accepted edge case). +- A second review of the Windows change: Windows 11 can keep the user's pick in `UserChoiceLatest` and stop updating `UserChoice`, and with several capable apps and no pick Windows chooses or asks. So the registered check no longer reads either key: it asks the shell which program opens `simplexchat:` links (`AssocQueryStringW` with `ASSOCSTR_EXECUTABLE`) and compares it with this exe. This app's own ProgId is rewritten even while another program holds the scheme, so a pick in settings keeps working after a reinstall elsewhere; the "another program" check also reads `HKLM\Software\Classes`, which a per-user key would otherwise hide; the application is listed only when at least one scheme is this app's; and `SHChangeNotify(SHCNE_ASSOCCHANGED)` follows any write. Both functions are called through `NativeLibrary`, as jna-platform 5.14 binds neither. +- A third review: `SHChangeNotify` also clears Explorer's icon cache, so it is sent only after a write. Another installed copy's ProgId (its command names an existing exe other than this one) is kept like any other handler, so two installed copies do not take the user's pick from each other at every start. A command naming this exe with other arguments or letter case counts as this app's own and is updated. A ProgId that cannot be written keeps its scheme unregistered, so `URLAssociations` never names a missing ProgId. diff --git a/plans/2026-10-09-badge-topups-subscriptions.md b/plans/2026-10-09-badge-topups-subscriptions.md new file mode 100644 index 0000000000..f43f5d50aa --- /dev/null +++ b/plans/2026-10-09-badge-topups-subscriptions.md @@ -0,0 +1,447 @@ +# Badge top-ups and store subscriptions — design space + +Branch `badge-in-app-checkout` builds a store lane for one-time badge purchases. It is not released. Two things are not built: + +- **Top-up:** buying more months while a badge is shown. +- **Subscriptions:** the stores' monthly and annual plans. + +This document compares two top-up designs, sets out how top-up and subscriptions depend on each other, lists the work and external waits of each step, and lists what the product lead must decide. It is a design space, not an implementation plan. + +## Summary + +**Does one need the other?** + +- Subscriptions do not need top-up, if Subscribe is offered only when no badge is shown and tier changes use a tier-changing grant. +- Top-up A needs nothing from subscriptions. +- Top-up B shares machinery with same-ledger subscriptions: lapse-before-grant and the late-landing problem. + +**Smallest correct top-up: design A.** Store Buy opens in a short window before the shown badge ends. The payment is credited at once as a new purchase that becomes the shown badge. Nothing changes in the service or the ledger. The buyer gives up the overlap, at most the window. + +**Subscriptions on top cost:** + +- service: a charges table, a ledger reader that understands charges, renewal discovery, a database lock, a tier-changing grant, and a verify path for Play subscriptions; +- client core: asking the service at zero months, keeping the badge on until it answers, and the store's expiry on the wire; +- apps: sale, restore, manage and cancel screens; +- external: store subscription setup, notifications, Pub/Sub, quotas, translations. + +**Late-presented renewals.** A grant added before unused months are written off loses the paid month. Writing off first fixes that, but coverage then restarts at presentation, 0–7 days late by default. Dating the grant to the store's billing period removes the drift. + +**Recommendations** (judgement): A for top-up, if subscriptions are far off; same-ledger subscriptions over A-style ones. + +**Decisions for the lead:** review test purchases; A, B, both or neither; A's window and downgrades; subscribing while a badge is shown; Family Sharing; refund clawback in the first release; Play App Signing custody. + +**Top risks:** the store product type; Play's owned-product rule and product identity; refunds while a payment is held; synchronised renewal requests. + +**Biggest open engineering question:** same-ledger subscriptions need a design for charges that land after the badge came off. The only worked fix is broad. + +## Reading notes + +**Labels.** + +- **Fact:** read in code at the place cited, or traced through it. +- **Model:** stated in the draft model, cited by section or line. +- **Judgement:** a conclusion drawn here. +- **Inference:** a consequence of cited facts, not run. +- **Unverified:** Apple or Google behaviour, and intent read from code comments. + +**Sources.** + +- Code is `badge-in-app-checkout` unless marked "vb", the unmerged `f/badge-store-verifiers`. "mq" is simplexmq at `e24d537`. +- The service is under `apps/simplex-badge-service/src/BadgeService/`. Its deployed backend is Postgres, so its `Migrations.hs` is `Store/Postgres/Migrations.hs`. +- Core is under `src/Simplex/Chat/`: `Library/Commands.hs`, `Badges/Ledger.hs`, `Store/Badges.hs`. `Chat.hs` is `src/Simplex/Chat.hs`. +- `M20260915_user_badges.hs` lines are the SQLite variant; the Postgres one is a line earlier. +- `badges-rpc.md` and `badges-overview.md` are under `docs/protocol/`. +- Swift files are under `apps/ios/Shared/`, Kotlin ones under `apps/multiplatform/`. `PlayStore.kt` is the `google` flavour's. +- **Model** is the draft `plans/2026-07-30-supporter-badges-v3-ux.md`. + +**Terms.** + +- **Shown badge:** the purchase in `users.shown_badge_id`. +- **Retirement:** taking the shown badge off at paid-through. +- **Held payment:** a store payment reaching a profile with a shown badge. It waits until retirement. +- **Hand-over:** the app passing a store receipt to core (`APIPurchaseBadge`). A **sweep** hands over every unfinished store purchase. +- **Tip:** a purchase's last ledger entry. **Run:** the months since the ledger's anchor. **Coverage** ends at paid-through. +- **Re-bind:** moving a purchase to new keys after the old keys are lost. +- **Step 1** is the one-time lane's release, which both features build on. + +## How it works today + +**Only the newest purchase is managed** (fact). + +- The badge worker and badge state read the newest purchase (`Store/Badges.hs:398-411`; `Commands.hs:5549`, `:5634`). +- Requests need a shown credential (`Commands.hs:5687-5691`). +- Retirement acts only on a shown newest purchase (`Commands.hs:5827-5835`). + +**While a badge is shown, nothing creates a purchase** (fact). Four guards refuse: + +- invoice creation (`Commands.hs:3587`); +- code redemption (`Commands.hs:5326`); +- store-receipt selection (`Store/Badges.hs:227`); +- store-receipt attempt (`Commands.hs:5529`). + +**When a held payment is credited** (fact unless marked). + +- Only retirement clears the shown badge. +- On iOS, retirement waits for a chat run; background refresh counts. +- On Kotlin, chat is never suspended (`Chat.hs:211`), so retirement happens at paid-through (`Commands.hs:5699-5707`). +- Credit then waits for chat start (`Commands.hs:269`), iOS activation (`:619`) or a hand-over (`:5298`). +- At start and activation, retirement runs asynchronously after the held-receipt check, so credit likely slips to a later trigger (inference). + +**A held payment while it waits.** + +- Its store transaction stays unfinished (fact, `BadgeStore.swift:240-261`). +- On Play it is acknowledged at hand-over but not consumed (fact, `BadgeStore.kt:281-283`), so Play's 3-day auto-refund does not apply. +- Play verification is online; a canceled `purchaseState` is refused (fact, vb `Google.hs:122-128`). Whether a refund sets that state is unverified. +- Apple verification is offline, over the stored transaction (fact, vb `Apple.hs:39-49`; `Store/Badges.hs:136`). A refund during the hold is credited unless the app re-hands a fresh transaction. +- Its product is mapped only at credit (fact, `Service.hs:446-448`; `Catalog.hs:148-154`). An unmapped product is retried daily and never finished (`Commands.hs:5507-5511`, `:5534-5541`). +- Another device on the same Play account sweeps unconsumed purchases (fact, `BadgeStore.kt:353-361`; `PlayStore.kt:103-116`). It records the purchase under its own profile and can credit it first (`Store/Badges.hs:144-148`). The buyer then gets `receipt_used` (`Service.hs:479`). Whether Play lists the purchase there is unverified. +- Whether Apple delivers another device's unfinished consumable is unverified. + +**The ledger** (fact, `Ledger.hs:93-101`). + +- `grantEntry` adds to the balance and keeps the anchor and tier. +- It restarts the run only when the balance is zero. + +**A grant must follow a lapse** (fact, traced). + +- A 2-month code is redeemed on 2 Oct, leaving 1 month unissued from 2 Nov. The app is away until 15 Feb. +- A bare +1 grant gives 2 months from 2 Nov. The next issue writes off min(2, 3) = 2, so the new month is lost. +- Lapsing first writes off 1, and the grant restarts the run on 15 Feb. + +**The service never grants onto an existing purchase** (fact). + +- A purchase starts from an empty ledger (`Service.hs:447-463`, `:489`). +- A receipt under a known key fails on `UNIQUE(purchase_key)` (`M20260915_user_badges.hs:92`) and is answered `internal`. The Play purchase is already acknowledged, so it is paid and never credited. +- Every refusal is answered before any database write (`Service.hs:420-463`). + +**Other facts.** + +- Store one-time months are issued at credit (`Catalog.hs:150-153`; `Service.hs:489-491`). An issued credential stays valid after a refund (model §3, O6). +- A Play quantity other than 1 is answered `internal` (`Service.hs:431-432`, `:471`) and retried forever (`Commands.hs:5732`). It is paid and never credited. +- With vb, Sandbox and licence-tester purchases are refused as `receipt_invalid` (`Service.hs:433`). +- Without vb, a well-formed receipt not credited to the presenting key is answered `provider_not_configured` (`Service.hs:424-427`). A malformed one is refused first, except a Play token failing the grammar check, which is retried as `provider_unavailable` (`StoreReceipts.hs:71-80`). +- vb will not start its dev mock beside a real verifier (vb `Config.hs:231-232`). + +## Top-up A: a new purchase from inside a window + +**What it is** (judgement; recommended). + +- Store Buy is allowed in a short window before the shown badge's paid-through, for example its last 7 days, until retirement. +- The payment is credited at once as a new purchase under new keys. It becomes the shown badge and is presented at once, as every credit is today (`Commands.hs:5865`, `:5515-5520`) and as the model's MVP says (model 2.5, line 486). +- Today Buy works only after retirement (fact, `Commands.hs:3587`; `Store/Badges.hs:227`). A's gain: the buyer pays before the badge ends, and the badge never goes off. + +**Why credit at hand-over, not at retirement** (judgement). + +- At hand-over, the new month starts at credit (`Service.hs:489`). The buyer loses the overlap, bounded by the window. +- At retirement, no days are lost, but every payment is held with the exposures above. +- Contacts would also see the badge removed and re-added. +- A warm iOS background refresh (`BGManager.swift:129`) and a backgrounded Kotlin app need new trigger plumbing to credit at retirement. + +**Changes.** + +- The invoice and receipt guards admit a purchase inside the window (`Commands.hs:3587`, `:5529`). +- The selection guard is SQL on `shown_badge_id IS NULL` (`Store/Badges.hs:227`), but paid-through is computed in Haskell, so that query is restructured. +- The query should filter by window, and the worker should wake when the window opens (judgement). Otherwise each refused attempt shows the buyer a `BIFUnexpected` error until a daily retry succeeds (`Commands.hs:5537-5541`, `:5725`, `:5625-5628`). +- The purchase is credited as its own tier (`Service.hs:447-449`), so no tier rule is needed for correctness. Downgrades are decision 3. +- The guards live in client core, so they bind honest clients only. +- The badge screens on both platforms rework their state. A shown badge replaces the purchase flow on iOS (`BadgesView.swift:26-31`) and closes purchase screens on Kotlin (`BadgesView.kt:28-32`, `:51-57`). + +**Recommended, cosmetic: tolerate a credit landing during retirement** (judgement). + +- Credit takes the badge lock; retirement takes the profile lock (`Commands.hs:5504`, `:5830`). +- Retirement reads the purchase once (`:5549`), then clears the profile badge unguarded (`:5833`). +- A credit committing in between makes contacts see the badge removed, then re-added on the next pass (`:5824`, `:5869`) (inference). +- The stale pass also raises "support ended" for the old purchase (`:5567`, `:5573`, `:5592`). The app may keep it until its next reload. +- Fix: clear the profile badge conditionally in retirement's transaction, and derive the alert from the shown purchase. + +**Optional** (judgement). + +- A prepaid-ending alert three days before paid-through. Without it the window is found only on the badge screen, because the chat-list pitch is hidden while a badge is shown (`BadgesView.swift:67-69`; `OnboardingCards.kt:66-67`). The apps render only two alert kinds today (`ChatListView.swift:389`, `:393`), so it is new UI. +- Deferring presentation until the old credential expires, as renewals do (`Commands.hs:5822-5824`). It keeps the request and the profile update on different days (objective 3, `badges-overview.md:205`). It must skip downgrades, and delays a higher tier's perks by up to 8 days. + +**Limits.** + +- The apps allow one pending purchase per profile (`BadgeStore.swift:120-135`, `BadgeStore.kt:167-183`). +- A is store-only. Codes still wait for retirement, so foss, desktop and link-out buyers get no top-up. +- A sold code expires 365 days after sale (`Orders.hs:21-22`, `:112`; `Service.hs:413`). A code bought d days into a 12-month badge stays redeemable on that profile for about d days after retirement. It can be redeemed on another profile at any time. +- Admitting codes in the window would fix both, at the cost of the code-screen rework (`BadgesRedeemCodeView.swift:288-310`, `BadgesRedeemCodeView.kt:369-377`). + +**What it links.** No payments by key. Timing links them: the new purchase starts near the old paid-through. + +## Top-up B: credit onto the shown badge + +**What it is** (judgement). The payment's months extend the shown badge's ledger, under its key. The badge is never retired and re-added. + +**Service.** + +- The request carries a store payment and the client's last ledger entry, signed by the purchase key. +- `upgradeBadgeSubscription` already has that shape (`Badges/Service.hs:94-98`; `badges-rpc.md:44`), less its `badgeRequest` field, which must be dropped (`badges-rpc.md:30`). It is unimplemented (`Service.hs:341-348`). +- The service lapses the tip, then grants, then issues if due. Lapsing first is required (see the grant trace above). +- Today's issue path writes only if the tip has not moved, but replies normally either way (`Service.hs:519-532`). B must answer a skipped write as an error. Otherwise the client marks the payment credited with nothing credited. +- Payments must be looked up through the ledger. Today's lookup joins only `badge_purchases.payment_id` (`Store.hs:204-218`), so a top-up re-presented by another device would get `internal` forever (`Service.hs:457-461`). +- A SKU of another tier is refused before any write. `grantEntry` keeps the ledger's tier, so crediting it would grant months at the wrong tier: legend months for a supporter payment, or the reverse. +- B is store-only. Admitting codes would also need the code lookup moved to the ledger, or a retried multi-use code would grant twice (`Store.hs:185-202`). + +**Client.** + +- The invoice and receipt guards admit a top-up in the window. The code guard stays: a code redeemed while a badge is shown would become newest and strand the stacked months (`Store/Badges.hs:303`, `:398-411`). +- The receipt row is marked as a top-up of the shown purchase at Buy, and signs with that purchase's keys. +- The row can fall back to A until its first request is sent, not after (`Service.hs:474-482`). +- A top-up during coverage usually issues nothing (`Ledger.hs:106`), but `requestBadgeStash` rejects a reply without a credential (`Commands.hs:5344-5346`). That path changes. +- A credited row with no purchase reads as refused today (`Store/Badges.hs:185-187`, `:289`). A credited mark fixes that. +- Retirement must re-read the tip in its own transaction; today it uses a stale one (`Commands.hs:5552`, `:5556`). + +**Late landing.** + +- Suppose the target retires and another purchase becomes newest before the credit lands. +- Re-showing the target strands the newer purchase and locks out later Buys and codes (`Store/Badges.hs:446-452`). Not re-showing it strands the new months. +- Fix one: defer retirement while a sent request's outcome is unknown. It has no bound while the device is offline. +- Fix two: manage every purchase with unissued months or an active store charge. That is broad: newest-only is assumed by `getUserBadgePurchase`, the single `BadgeState` (`Badges/Types.hs:270-284`), alert derivation (`Commands.hs:5590-5612`), retirement and both apps. + +**Costs.** + +- Stacked months are lost with the chat database. Re-bind cannot recover a finished one-time purchase if the store does not re-deliver it (unverified). +- A topped-up month lapses if the app does not run during it. Today every store month is issued at credit. +- Unissued months keep their value after a refund, unless a stacking cap or refund handling exists. +- It links payments by key, including a store payment to a code-funded badge's key. That goes beyond the exceptions at `badges-rpc.md:13` and against `badges-overview.md:206`. + +## How top-up and subscriptions depend on each other + +**Top-up A needs nothing from subscriptions** (judgement). + +- Exception: if Apple requires a month of access to be a non-renewing subscription (unverified), restore work moves into step 1 (risk 1). + +**Subscriptions need nothing from top-up, under conditions** (judgement). + +- The first charge: offer Subscribe only when no badge is shown, as one-time Buy works today. Then existing holders wait for retirement to subscribe. +- Tier changes always reach a profile showing that subscription's badge, and Apple's store UI can change tier without the app (unverified). Same-ledger subscriptions need a tier-changing grant anyway (Subscriptions), which handles this. +- Subscribing while a badge is shown would reuse A's guards and screens. + +**Two ways to credit renewals.** + +- **Same-ledger:** each renewal credits the subscription's own purchase. +- **A-style:** each renewal is credited as a new purchase under fresh keys. + +**What same-ledger shares with B** (judgement). + +- Lapse-before-grant on an existing ledger. +- Keeping the badge on until the service answers whether a charge landed. +- The late-landing problem: a subscription revived after grace (Apple billing retry, Play account hold, resubscription; unverified) can land after another purchase became newest. B's broad fix two solves it. +- If the app also hands renewals over: B's client half, its ledger payment lookup, and the tip re-read at retirement. +- Not shared: B's cross-tier refusal, its top-up rows, and stacking. + +**What A-style costs** (judgement; not recommended). + +- On iOS, a renewal handed over at launch races retirement, so the badge can come off and on (inference). Avoiding that needs the same retirement deferral as same-ledger. +- Each renewal's month starts at its credit (`Service.hs:489-491`). A backlog handed over together forfeits all but one month. +- On Apple, a renewal is filed under whichever profile is active at hand-over (`Store/Badges.hs:139-157`). +- On Play, renewals share one token (unverified), so a renewal resolves to the old record and is never credited (`Commands.hs:5299-5302`). +- Fresh keys buy no unlinkability: the stores link renewals anyway (unverified). + +**Either way.** + +- Step 4 must keep subscription badges out of A's window, or a top-up and a renewal compete for the next credit (`Store/Badges.hs:228`). +- That needs a way to tell a subscription-funded badge apart. The client keeps no product column (`Store/Badges.hs:289`, `:296-301`), though ledger rows store their credit type. + +**The model is split** (judgement). + +- Its ledger example tops up on the same ledger (model line 379). Its same-row upgrade (§2.10) is B's credit, deferred to phase 2 (§7). +- But it creates a row per purchase (model line 417). +- On subscription tier changes, the model makes a new row with new keys (§2.10), while the protocol keeps the key (`badges-rpc.md:50`, `:54`). + +## Subscriptions: the work + +**Crediting time** (judgement, traced through `Ledger.hs:93-116`). + +- A charge learned while coverage runs is added, and issued at the next request. +- A charge learned after coverage ended restarts the run, so coverage drifts by the gap. +- Today there is no request at zero months (`Commands.hs:5689`). An added one would naturally fall a day before the credential expires, 0–7 days after paid-through (`Ledger.hs:240-243`), so drift is the default (inference). +- So the grant should be dated to the store's period start (model O2). +- On an existing ledger, the client's check marks such a grant backdated if it predates the tip (`Ledger.hs:164`). That check changes. +- For a new ledger, the service seeds at now (`Service.hs:489`). Seeding at the period start also needs `signFirstMonth` to lapse first (`Service.hs:489-491`), or it issues an elapsed month. + +**Tier changes.** + +- Apple: a subscription group lets the store's UI change tier (unverified). Play: tiers are separate products (fact, `BadgeStore.kt:31`, `:36`). +- The protocol has the service discover changes (`badges-rpc.md:55`). No such path exists (`Service.hs:341-348`). +- `grantEntry` keeps the tier, so a tier-changing grant is needed. The service signs from the tip's tier (`Service.hs:358`). +- The client stores the tier once (`Store/Badges.hs:297`) and must update it. +- An immediate Apple upgrade refunds the old plan pro rata (unverified), so it needs refund handling. + +**Charges.** + +- Use the commented draft schema (`M20260915_user_badges.hs:155-227`). +- The ledger reader has no charge case (fact, `Ledger.hs:214-225`). A charge row breaks the statement read (`Store.hs:272-290`), so the purchase stops issuing and every reply fails. + +**Discovery.** + +- Use Apple's App Store Server API and Play's `subscriptionsv2` (unverified). +- Reading them when the client asks avoids a background writer. But renewal requests arrive together each week (`Ledger.hs:236-243`; `Commands.hs:5696-5707`), and each store call takes up to 10 s (`StoreReceipts.hs:65`) against a 30 s client deadline. +- A background lane is a second ledger writer. The deployed Postgres runs today's check-then-append at default isolation (`Service.hs:527-533`), so it needs a lock before reading the tip. +- A renewal the app hands over is claimed by its own transaction id (`Commands.hs:5316`), and must be mapped to the original. +- Handed-over Play subscriptions need a verify path: vb checks only one-time purchases (vb `Google.hs:105-107`). +- Store API quotas and raising them are external (unverified). + +**Client core.** + +- At zero months, ask the service whether a charge landed, and keep the badge on until it answers. That needs the store's expiry on the client; no response carries it today (`Badges/Service.hs:121-125`). +- Raise the subscription alerts the code names but never raises (`Badges/Types.hs:120`). + +**Re-bind and restore.** + +- The model's store re-bind is unbuilt. Apple requires a restore mechanism (unverified). +- The ledger reader has no transfer or upgrade case either (`Ledger.hs:212-219`), so re-bind rows would break it like charge rows. + +**Apps.** Subscriptions are not for sale today (fact, `BadgeStore.swift:35`, `BadgeStore.kt:49`). Selling needs the sale screens, hand-over, restore, manage and cancel, and store disclosures. + +## Order, work and waits + +**Order** (judgement), assuming refund clawback is not in the first release (decision 6): + +1. Release the one-time lane. +2. Top-up A, if subscriptions are far off (decision 2). +3. Refund handling. +4. Subscriptions, same-ledger, after the late-landing design. + +Every external item below is unverified: external state cannot be checked from the repo. + +### Step 1: the one-time lane + +**Ours.** + +- Merge `badge-in-app-checkout` and vb. Revert the TEST ONLY URLs (`BadgeStore.swift:42-44`, `BadgeStore.kt:56-61`). +- Deploy the verifiers with a production issuer key at index 1–8 (mq `Crypto/Entitlement.hs:132-143`) and the Apple root certificate. +- Deploy the production service holding the keys of the address compiled into core (`Chat.hs:74`), or replace that address before the release build. It cannot change without an app release. +- Publish a stable simplexmq server release with entitlement support, upgrade our XFTP servers, and configure retention, which is opt-in per server (mq `FileTransfer/Server/Main.hs:166-170`). +- Build the Google Play bundle the repo documents (`apps/multiplatform/README.md:27-28`). No script or CI job builds it (`scripts/android/build-android.sh:137`). +- Spread the weekly renewal requests across their day-long lead, as the TODO at `Commands.hs:5696-5698` designs. +- Drop queued requests older than the client's deadline. The queue records no receive time today (`Service.hs:82`). +- If a load test shows it is needed, take store calls off the serial request loop, where one Play credit can block it for about 20 s (vb `Service.hs:444`, `:468`). Concurrent requests then need the Postgres tip lock. +- A test deployment: a separate service with its own address and the index-9 key, both compiled into core (`Chat.hs:73-74`). That means separate core builds per platform. XFTP retention cannot be tested, since servers trust keys 1–8 only (mq `Agent/Env/SQLite.hs:234`). +- A badge-service section in `PRIVACY.md`; terms of sale and a refund policy for the web checkout. +- The model's prerequisites: a release with the 7-day recipient display grace before purchases ship (model line 518). It is in 7.1 (`Badges.hs:166-182`) but not stable 7.0.4. The model's Monday removal updates are not assumed here; the code removes at paid-through (`Commands.hs:5829-5834`). + +**External.** + +- How 7.0.x reached Play. The `stable` branch already carries the Play Billing flavour (`origin/stable:apps/multiplatform/android/build.gradle.kts:38-42`). If it shipped as a bundle, Play App Signing and a billing build on a track already exist. +- Play App Signing, if not already enrolled (decision 7). The F-Droid recipe must build `assembleFossRelease` (`apps/multiplatform/README.md:33`). +- The 7.1 release with display grace, its store reviews, and recipients updating. +- Apple's Paid Applications Agreement and Google's payments profile, first, since they likely gate all test purchases. +- Products, created only after the product type is settled (risk 1). Play's multi-quantity off. Some products may already exist (`BadgeStore.kt:31`; `BadgeStore.swift:19-23`), and ids cannot be reused (unverified). +- Subscription products created now fix their group and base-plan structure now. Testing subscriptions (risk 6) can use throwaway ids in a separate group. +- Any existing subscription SKUs deactivated. The app finishes or acknowledges a subscription without crediting it (`BadgeStore.swift:196-199`; `BadgeStore.kt:238-241`), so a buyer would be charged and get nothing. +- Play: a Cloud project, a service-account key and two Console permissions (vb README). The order permission likely also allows refunds (unverified), so the test deployment's key needs production custody. +- Store policy and consumer law on holding a paid purchase behind a shown badge, which step 1 already does. +- App Privacy and Data safety declarations, and EU trader status. +- TestFlight beta review and Play testing-track review for test builds. +- Translations by volunteers on Weblate (`README.md:109`). +- Flux: six preset XFTP servers, three chosen at random (`Operators/Presets.hs:118-127`; `Chat.hs:320-337`). Retention must be configured on all six, and they must accept 2 and 5 GB files, which clients allow from launch (`Badges.hs:259-267`). +- Store review and policy: the first in-app purchase, the US link-out, a web-bought code unlocking a perk, and test purchases in review (decision 1). +- The web checkout `badges.simplex.chat`: DNS and TLS, Stripe activation (needs published terms), a BTCPay Server with Bitcoin and Monero (`badges-rpc.md:33`), merchant and tax registration. Deploying the site itself is ours (`scripts/badge-service/Dockerfile:12-18`). +- Sandbox tests: an account-stable `appTransactionId` (risk 3) and a refund of an unfinished purchase (risk 4). +- A Play licence-tester check that a supporter token under the legend product is refused (risk 2). +- Staff purchases on both stores' production paths after approval, since test purchases are refused (vb `Google.hs:140-145`). + +### Step 2: top-up A + +- Store review, with a demo profile near paid-through or a screen recording, if the reviewer tries the new Buy entry. +- Store policy and consumer law on forfeiting the overlap (unverified). +- Translations. + +### Step 3: refunds + +**Ours, all new** (fact: the service has no refund code; the client already decodes refund debits, `Ledger.hs:144`, `:229`). + +- A refund debit write path, an Apple notification endpoint, and a voided-purchase poller. +- They write beside the request loop (`Service.hs:160`, `:181`), so they need the Postgres lock. +- A record of refunded transactions, checked at credit. A refund during a hold has no ledger yet (`Service.hs:455-456`), and Apple's JWS shows a refund only if re-delivered (vb `Apple.hs:122`, `:131`). +- A per-payment debit rule for ledgers holding several payments (B, same-ledger subscriptions). The model's refund debits the whole balance (model lines 297, 347), which would wipe months other payments bought. + +**External.** Play's Voided Purchases API; Apple's Server Notifications V2 with an API key; Stripe disputes. + +### Step 4: subscriptions + +- The App Store subscription group: SKUs, ranking, prices, localization, review, grace period. +- Play subscriptions with grace and account-hold settings. +- Family Sharing (decision 5). +- Pub/Sub for push discovery. +- Terms and privacy links, and translations. + +## Decisions for the lead + +**1. Test purchases in store review.** App Review likely buys with test money (unverified), which is refused, and the app shows "Purchase error" (`BadgeStore.swift:272-276`). Options: + +- Accept test purchases. That gives public TestFlight users free badges (inference, `StoreReceipts.hs:34`). +- Accept them, and restrict public TestFlight meanwhile. +- Keep refusing, with clearer copy and a note to reviewers. Whether review accepts that is unverified; a rejection blocks step 1. + +**2. A, B, both, or neither before subscriptions?** + +- Recommended same-ledger subscriptions need nothing from A, and give store buyers continuity on their own. A is worth building first only if subscriptions are far off. +- B adds to A: one key per badge, no lost overlap, and the model's same-row upgrade. +- B costs: the late-landing fix, a stacking cap or refund handling, months that lapse if the app does not run, and linked payments. + +**3. A's window length, and downgrades.** + +- A longer window is more convenient, but costs more overlap. +- A legend holder buying a supporter month in a 7-day window gives up about $16 of legend for a $7 month (web prices, `Catalog.hs:109-110`). Refuse, warn, or allow? + +**4. Subscribing while a badge is shown?** + +- Not at all: subscriptions need nothing from A, and existing holders wait for retirement. +- Store-funded holders: supersede the badge as A does, losing up to a month. +- Multi-month code holders: forfeit the code's remaining months, use B, or wait. + +**5. Family Sharing for subscriptions?** It cannot be turned off once on (unverified), and creates claimants on other devices. + +**6. Refund clawback in the first release?** + +- The draft model requires it (§7). +- It saves nothing on store one-time months, which are issued at credit. +- It saves a month refunded during a hold, or a web purchase of 3 or 12 months. +- The model's other first-release items are also unbuilt: a Stripe statement descriptor, and receipts (`Service.hs:367`). + +**7. Play App Signing custody**, if not already enrolled: hand Google the signing key, or reverse the documented bundle path. + +## Risks and open questions + +Ranked by cost if wrong. + +**1. The product type** (unverified). + +- Both stores fix it when a product is created. Ids are hard-coded (`Catalog.hs:150-153`; `BadgeStore.kt:28-38`). +- If a month of access must be a non-renewing subscription, restore moves into step 1. +- Settle from the stores' guidance, and check which products exist. + +**2. Owned-product rules and product identity** (unverified). + +- A held Play purchase may block buying its tier on other devices of the same account (`PlayStore.kt:234`). The same question applies to an unfinished iOS consumable. +- vb trusts the client's product id when Play's record omits it (vb `Google.hs:105-107`, `:124`), so cross-tier integrity rests on Play refusing a mismatched token. +- Settle with a licence tester and a Sandbox account. + +**3. Account-stable `appTransactionId`** (unverified). If present, every Apple purchase is linkable anyway, so A's timing linkage matters on Play only. + +**4. Refunds during a hold.** + +- On Apple, a modified client can withhold the revoked transaction. +- An honest one can also lose a race at launch: the receipt worker may present the stored JWS before the sweep replaces it (`Commands.hs:269`; `SimpleXAPI.swift:2397`). +- Only step 3 closes this. The loss equals today's refund-after-credit baseline. + +**5. Synchronised renewal requests** (inference). + +- Every request fires a day before the shared weekly expiry, at the same instant on Android and desktop. +- The service answers one at a time (`Service.hs:267-272`) against a 30 s client deadline. Per-request time is unmeasured. +- The queue has no age check, so a backlog serves clients that have already gone. +- Spreading and dropping stale requests (step 1) is the fix; a load test sizes it. + +**6. Renewal visibility and revival** (unverified). Whether Play renewals reach the app, which Apple fields persist, and how subscriptions are revived. Settle with test subscriptions. + +**7. The late-landing design for same-ledger subscriptions.** The only worked fix is broad. A narrower one, crediting a revival after grace as a fresh purchase, is unexamined. + +**8. Open checks.** + +- How 7.0.x reached Play. +- Whether a subscription can move between groups. +- Whether App Review accepts "Purchase error". +- Store API quotas. +- Binding a store payment to its purchase key, against another device's claim. The invoice id the apps send is a random UUID any device can read back (`Commands.hs:3590`; `BadgeStore.swift:372`), so this needs a key-derived token and a protocol change. diff --git a/plans/2026-10-13-badge-subscription-renewals.md b/plans/2026-10-13-badge-subscription-renewals.md new file mode 100644 index 0000000000..056c059810 --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals.md @@ -0,0 +1,353 @@ +# Plan: store subscription renewals for supporter badges + +Run: 001-badge-subscriptions · Base branch: `f/badge-subscriptions`, cut from `badge-in-app-checkout` once `f/badge-store-verifiers` is merged into it · Repos: simplex-chat-2 · Estimated diff: ~1700 changed lines (~900 code and docs, ~800 tests), one PR in eight slices + +This run makes App Store and Google Play subscriptions renew badges: in the badge service, including its real connections to Apple's App Store Server API and Google Play's subscriptions API, and in the shared app core. It changes no iOS or Android screens, so nothing here puts a subscription on sale. + +Sources: the draft model `plans/2026-07-30-supporter-badges-v3-ux.md` (§3 charges and O2, §5, §7), `plans/2026-08-04-badges-mvp-scope.md` milestone 4, and `docs/protocol/badges-rpc.md`. + +Code is named by file and function, never by line, because `f/badge-store-verifiers` lands first and moves lines. That branch's store verifiers are the starting point: + +- Apple JWS verification, offline and pinned to Apple's root: `verifyAppleTransaction` in `BadgeService/StoreReceipts/Apple.hs`. +- Play reads and acknowledgement of one-time products, with a cached service-account token: `playStoreVerifier`, `playCall`, `bearerToken` in `BadgeService/StoreReceipts/Google.hs`. +- Store config: `AppleStoreConfig`, `PlayStoreConfig` in `BadgeService/Config.hs`. +- Refusal classes, including `SRTestPurchase` answered `test_purchase`: `StoreRefusal` in `BadgeService/StoreReceipts.hs`. + +Service paths below are under `apps/simplex-badge-service/src/`; core paths under `src/Simplex/Chat/`. + +## 1. Goal and acceptance criteria + +A badge bought as a store subscription keeps renewing. When its paid months run out, the app sends its usual signed issue request at the usual weekly moment. The service asks the store whether the subscription charged again, grants the charge's months dated to the store's billing period, and returns the next credential. The app keeps the badge until the service says the subscription has ended. + +Renewal mechanics, tested with an in-process fake store: + +- AC1: A verified subscription receipt creates a purchase whose ledger is one `charge` credit dated to the store's period start and one badge debit, records one charge row, issues the first month, and answers with `subscription = renewing`. A test purchase is answered `test_purchase`. Test: `testSubscriptionPurchase`. +- AC2: At zero months a subscription purchase is still asked about, a day before its shown credential expires. The badge stays shown past paidThrough. A new store charge is granted at its period start, and the next credential is issued and presented when the old one expires, so neither the user nor their contacts see a gap. Test: `testSubscriptionRenews`. +- AC3: When the store has not charged yet (billing retry), the service answers with no credential and `subscription = billingRetry` and writes no ledger row; the client records no failure, keeps the badge and asks again a day later. Once the charge lands, its month is issued with coverage from the store's period start. Test: `testSubscriptionAwaitsCharge`. +- AC4: When the service answers `subscription = ended` at or after paidThrough, the client retires the badge, raises the support-ended alert and sends no further requests for that purchase. Test: `testSubscriptionEnds`. +- AC5: Each store charge is granted at most once. A repeated issue request inside the issued period returns the same credential without asking the store, and a store reporting the same charge again adds no second grant and no second charge row. Test: `testSubscriptionChargeGrantedOnce`. +- AC6: The store is asked only when a subscription purchase's paid coverage has ended, and at most once per subscription per check interval (one hour), whether or not the store answered. Requests in between are answered from the stored state. Test: `testSubscriptionStoreChecksBounded`. +- AC7: On a renewal check, a revoked charge, a charge for another tier, a charge in a trial or introductory offer, and one whose period has not started or has already ended are recorded and not granted. Test: `testSubscriptionChargeNotGranted`. +- AC8: An unreachable store during a renewal check answers `provider_unavailable` with `retryAfter` of the check interval, stamps the check time, and writes no charge or ledger row. Test: `testSubscriptionStoreUnreachable`. +- AC9: A first purchase with no grantable charge is answered per §4's purchase rule and writes nothing. Test: `testSubscriptionPurchaseNotGrantable`. +- AC10: Every row the charge planner produces verifies on the client: `balanceChecked` gives `Just True` for the charge credit, the lapses and the badge debit. Cases: a first charge, a monthly renewal, an annual renewal with a month left at the period start, a renewal after billing-retry recovery, an annual subscriber away past the year end whose charge recovers inside the store's grace period, and a period start equal to the balance start. Test: `testChargeRowsVerify` (unit). +- AC11: A ledger holding a charge row is stored and read back by the service, and `entryTypeFromColumns` rebuilds `SCCharge` from its charge id. Tests: `testEntryTypeColumns`, the service schema down/up cycle. +- AC12: An Apple auto-renewable transaction is claimed by its `originalTransactionId`, every other Apple transaction by its `transactionId`, and the real Apple verifier vouches with the same ref. A renewal transaction handed over by the app resolves to the credited receipt record and creates no purchase; a held record that adopts a renewal JWS before it is credited is still credited. Tests: `testAppleTransactionRef` (unit), an Apple renewal-payload case in `StoreVerifierTests`, `testAppleRenewalResolvesToPurchase`. +- AC13: The `subscription` response field round-trips in JSON, is absent for prepaid purchases, and an unknown state decodes as `SSUnknown` rather than failing the response. Test: credential response JSON tests (unit). + +Store connections, tested against fake HTTP stores: + +- AC14: The Apple subscription check signs an App Store Server API token with the configured in-app purchase key, reads the subscription's status by its original transaction id, verifies the signed transaction and renewal info against the pinned Apple root, and maps them to a `SubscriptionCheck`. Its HTTP answers map to refusal classes as §4 says. Test: Apple subscription cases in `StoreVerifierTests`. +- AC15: The Play subscription check reads `purchases.subscriptionsv2` by token through the existing service-account client, maps state, plan, period and order id, and acknowledges an unacknowledged subscription before it is credited. Test: Play subscription cases in `StoreVerifierTests`. +- AC16: A service configured without the Apple API key, or without Play, answers that store's subscription receipts `provider_not_configured`, and its one-time purchases behave as before. Test: `ConfigTests` and a service test. + +Unchanged: + +- AC17: Prepaid purchases (codes and one-time store purchases) behave exactly as before. The existing "Supporter badges", "SimpleX badge service" and "SimpleX badge service e2e" suites pass, except two tests that pin `subscr_badge_supporter_01` as `product_unavailable`: `testStoreUnknownProduct`, whose subscription case moves to AC1, and `testStoreAcknowledgesBeforeCrediting`, whose unpriced case switches to a product no catalog maps (for example `badge_unknown_01`). + +## 2. Non-goals + +- Tier changes (upgrades and downgrades): a charge for another tier is recorded and not granted. +- Refunds: no refund handling or debit. A charge the store reports as revoked is simply not granted. +- Store notifications (App Store Server Notifications, Play real-time notifications). +- Resubscription after the badge was removed, and restoring on a new device. +- Spreading the weekly request moment, and taking store calls off the serial request loop. +- App screens, and handing subscription transactions over from the apps; the dev mock verifier's subscription support comes with them. +- Family Sharing, which stays off: family-shared Apple transactions are refused. + +## 3. Current state + +What the code does on `badge-in-app-checkout` with `f/badge-store-verifiers` merged: + +- **The client stops at zero months.** `badgeRequestDue` (`Library/Commands.hs`) requires months left and a shown credential. `retireExpiredBadge` takes the badge off at paidThrough, after which the purchase is never asked about. Both rules rest on one premise: no months arrive after paidThrough. Subscriptions remove that premise, so both change together. +- **The service never reads a store after the first purchase and never grants onto an existing ledger.** `issueBadgeCmd` (`BadgeService/Service.hs`) lapses and issues. `signFirstMonth` seeds an empty ledger at `now` and issues without lapsing. `writeIssued` answers through `issueResponse`, which returns the current issuance, even when it skipped its write. +- **A charge row cannot be stored or read by the service.** `appendLedgerPlan` (`BadgeService/Store.hs`) writes no charge reference. `entryTypeFromColumns` (`Badges/Ledger.hs`) has no charge case, so a charge row reads as an empty ledger at the tip and fails any statement read. No charges table and no ledger `charge_id` exist on either side. +- **Apple renewals are not tied to their purchase.** Claims use `appleTransactionId` (`PaymentService.hs`), the transaction's own id, both in the client's `storeTransactionRef` and in the service's `toStoreReceipt`. A renewal has a new id, so the client's `holdStoreReceipt` (`Store/Badges.hs`) would give it a new receipt row with fresh keys. A Play renewal keeps its token and already resolves to the credited row. +- **The catalog sells no subscription.** `storeProduct` (`BadgeService/Catalog.hs`) maps only the four one-time SKUs; `testStoreUnknownProduct` pins `subscr_badge_supporter_01` as `product_unavailable`. +- **The verifiers read one-time purchases only.** Apple's is offline and parses no `originalTransactionId`, `expiresDate`, `type` or offer fields; it calls no App Store Server API. Google's reads `purchases/products` and acknowledges products only. +- **The client's statement check** (`balanceChecked`, `Badges/Ledger.hs`) re-runs each credit through `grantEntry` at its `createdAt` and flags a row dated before its predecessor. Its verdict is stored in `balance_checked` and read by nothing. +- **The client's "issued no credential" check** in `requestBadgeIssue` fires when the stored tip still has months left after an answer with no credential. +- **Wire:** version 1, aeson with unknown fields ignored and `Maybe` fields optional. `BSPBadgeCredential {credential, receipt, statement}` (`Badges/Service.hs`). `SCCharge {chargeId}` already exists. An unknown enum value fails the whole response unless its type has a fallback constructor, as `BSEUnknown`, `SCUnknown` and `SDUnknown` do. +- **Timing today:** a credential expires at the end of the Monday after its paid month (`endOfMondayAfter`), 1-8 days after paidThrough. The app asks for the next one a day before that and shows it at that moment. Recipients show a badge as active until 7 days after its expiry, then faded as expired for a month, then hide it (`mkBadgeStatus`, `Badges.hs`); servers honour file perks for a day after expiry. + +Invariants this run keeps: + +- The serial request loop is the only ledger writer. +- Every row is its operation re-run at its `createdAt`, and `createdAt` never decreases along a ledger. +- Lapse comes before grant. +- The tier comes from what the store charged, never from the client. +- A refusal writes no ledger row, and no ledger row or issuance is written before the credential is signed. (A renewal check does record its check time, the subscription's state and any charge it saw.) +- Claims are by the store's own reference. + +## 4. Design + +The core change: + +- A subscription purchase is an ordinary purchase whose ledger is refilled by store charges instead of being paid once. +- Its store identity is the subscription, not one transaction: Apple's original transaction id (Play's token already is). Every renewal therefore resolves to the same purchase and the same receipt record on both sides, and the service has one thing to ask the store about. +- Months enter a subscription ledger only as `charge` credits whose operation time is the store's billing period start. The grant row's `createdAt` is that period start, as `grantEntry` already stamps its `t`. Coverage follows the store's periods without drift, every row verifies by re-running its operation at its `createdAt`, and `StatementEntry` gains no field. +- The first charge and every renewal go through one pure planner: lapse what ran out before the period start, grant the charge's months at the period start, lapse what ran out since, then issue at now. +- The service asks the store only when a subscription purchase's paid coverage has ended, and writes nothing unless a charge is granted. +- The client keeps asking at the shared weekly moment and keeps the badge until the service answers that the subscription has ended. + +### Timing and grace, end to end + +- The app asks at the shared weekly moment, a day before the shown credential's expiry. That moment always falls after paidThrough (expiry is 1-8 days after it), and stores charge at the period's end, so a renewed subscription gets its new credential before the old one expires and swaps it in at expiry. The badge stays active for the user and their contacts throughout, and requests stay indistinguishable by timing. +- If the store has not charged (billing retry), no month is issued without a charge. The badge stays active by the existing rules alone: servers honour file perks until a day after the last credential's expiry, and contacts show it as active until 7 days after it. After that, contacts show it faded as expired, as for any lapsed badge. The app asks once a day meanwhile. +- The badge stays on the profile until the service answers `ended`, so a payment the store recovers late re-activates it with a fresh credential. Taking it off at the end of grace would need the resubscription work this run leaves out, since a removed purchase is never asked about again. +- While it stays shown, the profile cannot start a store purchase or redeem a code, and held receipts wait, because every purchase path refuses while a badge is shown. Nothing is charged meanwhile, so no money is lost; the iOS run decides what the user is told. +- The stores bound the wait themselves (Apple retries for up to 60 days, Play holds for up to 30; unverified), after which the service answers `ended` and the app removes the badge. +- Store grace periods are off. During a store's grace period the subscriber keeps uninterrupted paid service, but this run issues nothing until the charge lands, so a subscriber who recovers inside grace could go without file perks for part of what they paid for. With Apple's Billing Grace Period off (it is opt-in, and offers only 3, 16 or 28 days), "the subscriber's days of paid service pause until Apple is able to collect payment", which is exactly this run's rule; Play's grace is set to its minimum (reportedly 0, with about a day applied; unverified). Contacts still see the badge as active until 7 days after the last credential's expiry. + +### Shared code (`src/Simplex/Chat/`) + +- `PaymentService.hs`: + - `appleTransactionId` becomes `appleTransactionRef` (structural rename, slice 1). + - It then returns the payload's `originalTransactionId` when `type` is `"Auto-Renewable Subscription"`, the `transactionId` otherwise (slice 3a). + - The client's `storeTransactionRef` and the service's `toStoreReceipt` keep calling this one function, so client and service claims cannot disagree. + - The real Apple verifier must vouch with the same ref, because `purchaseWithReceipt` answers `internal` whenever the verified ref differs from the claim. So in the same slice, `transaction` in `StoreReceipts/Apple.hs` parses `type` and `originalTransactionId` and vouches with the original id for an auto-renewable transaction, as the dev mock already does by calling the claim function. Without this, a held Apple subscription record that adopts a renewal JWS would be answered `internal` on every attempt, and the buyer would never be credited. + - The `googlePurchaseRef` comment states that the hash stays the claim while the service also keeps a subscription's token to ask Play about it. +- `Badges/Service.hs`: + - New enum `SubscriptionState = SSRenewing | SSCancelled | SSBillingRetry | SSEnded | SSUnknown Text`, JSON `renewing`, `cancelled`, `billingRetry`, `ended`, and any other string as `SSUnknown`. + - Meaning, as the service last read the store: renewing (auto-renew on), cancelled (auto-renew off, the paid period still runs), billingRetry (a charge failed and the store is retrying it: grace, retry or hold), ended (no charge will come: expired, revoked or refunded). The client reads `SSUnknown` as "not ended". + - `BSPBadgeCredential` gains `subscription :: Maybe SubscriptionState`, present exactly when the purchase is funded by a subscription. The version stays 1: the field is optional, and older clients ignore it. +- `Badges/Ledger.hs`: + - `entryTypeFromColumns` takes the row's charge id and rebuilds `SCCharge` from it (slice 2). + - A pure planner (suggested name `chargeEntries`) takes the tip or a seed, the charge's period start, its months, the charge id, `now` and fresh entry ids. It returns `lapse periodStart` (if any), `grant periodStart months (SCCharge chargeId)`, `lapse now` (if any). The caller issues at `now` with `issueEntry` as today. + - The rows' `createdAt` are non-decreasing whenever the period start is not before the tip's `createdAt`. That holds on every path: the tip was issued before its month ended, the next charge's period starts around that month's end, and the service writes no row while a subscription waits for its charge. + +### Badge service (`apps/simplex-badge-service/src/BadgeService/`) + +- Catalog (`Catalog.hs`): + - `storeProduct` answers a sum: `StoreOneTime {badgeType, months}` for today's four SKUs, `StoreSubscription` for the subscription products (Apple `SUBSCR_BADGE_{SUPPORTER,LEGEND}_{MONTH,YEAR}_01`, Play `subscr_badge_{supporter,legend}_01`). + - A new `storePlan provider planId` maps what a charge was for to `(badgeType, months)`. Apple plans are those four product ids. Play plans are the base plans `subscr-badge-supporter-month-02`, `subscr-badge-supporter-year-01`, `subscr-badge-legend-month-01`, `subscr-badge-legend-year-01`, because a Play receipt names the product and only Play's subscription record names the base plan. + - Months are 1 or 12 and come from the plan, never from the period's length. +- Verifier interface (`StoreReceipts.hs`): + - `StoreVerifier` gains `checkSubscription :: Maybe (SubscriptionRef -> IO (Either StoreRefusal SubscriptionCheck))`, run under the verifier timeout. Timeouts and other throws are unreachable, as for the online Google calls; a `StoreVerifierFailure`, which the Apple chain check throws for a chain it cannot place, is caught first and is a verifier failure (`internal`), as the offline Apple path treats it. + - `SubscriptionRef` names what to ask about: Apple's original transaction id, or Play's token alone (`subscriptionsv2.get` needs nothing else). The Play product id is needed only by the purchase-path acknowledgement, which has it from the receipt. + - `SubscriptionCheck` holds the `SubscriptionState`, a test-purchase flag, and the latest paid period if any, as `StoreCharge {chargeRef, planId, periodStart, periodEnd, paid, revoked, offer}`. `chargeRef` is Apple's `transactionId` or Play's order id; `revoked` is true when the store reports that charge refunded or revoked; `offer` is true when the charge is in a trial or introductory offer. + - `noStoreVerifier` and the dev mock leave it `Nothing`, which answers `provider_not_configured`. +- Store connections (`StoreReceipts/Apple.hs`, `StoreReceipts/Google.hs`, `Config.hs`): + - Apple: `AppleStoreConfig` gains the App Store Server API key (key id, issuer id, the `.p8` private key file) and an API host defaulting to production. Without the key, Apple one-time purchases verify as today and Apple subscriptions answer `provider_not_configured`. + - The Apple check calls "Get All Subscription Statuses" for the original transaction id, authenticated by an ES256 JWT (issuer id, key id, bundle id, audience `appstoreconnect-v1`, short expiry, reused until near expiry). It verifies each signed transaction and signed renewal info with the same pinned-root chain check `verifyAppleTransaction` uses, refactored so the chain check and payload parsing are separate functions. + - Apple mapping: status 1 is renewing or cancelled by the renewal info's `autoRenewStatus`; 3 (billing retry) and 4 (grace) are billingRetry; 2 (expired) and 5 (revoked) are ended. The latest transaction gives `transactionId`, `productId` (the plan), `purchaseDate` and `expiresDate` (the period), `revocationDate` (revoked), `offerType` (offer), `environment` (test purchase) and `inAppOwnershipType` (`FAMILY_SHARED` is refused as `receipt_invalid`). + - Apple HTTP answers: 401 and 403 are this service's failure (`internal`, logged); 404, 429, 5xx, timeouts and transport errors are unreachable; a body whose signed transaction or renewal info does not verify against the pinned root, whether the check returns `Left` or throws, is a verifier failure, never a refusal. + - The production host answers 404 for a Sandbox transaction id, so a test purchase must never reach it: on the purchase path the offline JWS check's `environment` refuses a test purchase as `test_purchase` before `checkSubscription` is called. The API transaction's `environment` is checked again as a defence, which only a Sandbox host would exercise. + - Play: the check calls `purchases.subscriptionsv2.get` by token through the existing `playCall` and `bearerToken`, with the same HTTP classes as today's product reads. + - Play mapping: `SUBSCRIPTION_STATE_ACTIVE` is renewing or cancelled by `autoRenewingPlan.autoRenewEnabled`; `IN_GRACE_PERIOD` and `ON_HOLD` are billingRetry; `CANCELED` is cancelled until `expiryTime`; `EXPIRED` and `PENDING_PURCHASE_CANCELED` are ended; `PENDING` is answered `payment_pending` as a pending refusal. The line item gives the plan (`offerDetails.basePlanId`), the period end (`expiryTime`), the charge ref (`latestSuccessfulOrderId`) and the offer flag from its current phase (`offerPhase` in a free trial or introductory price). `offerDetails` is not the flag: it names the offer the subscription was bought with and stays for its life, so reading it would refuse every later full-price renewal of a subscription bought on an offer. Play reports no current-period start, so it is the period end minus the plan's length. `testPurchase` present marks a test purchase. Play's revocation of an order shows only as the subscription ending, so Play charges are never `revoked`. + - Play acknowledgement: an unacknowledged subscription is acknowledged with `purchases.subscriptions.acknowledge` before it is credited, as one-time purchases are today, so Play's three-day refund never hits a credited subscription. +- Schema: one new service migration `20261012_badge_subscriptions`, SQLite and Postgres, appended to both lists. It follows the commented draft in the client migration `M20260915_user_badges` for the parts this run uses, and the draft drops those parts, as the service's `20260925_badge_store_receipts` did for `payment_id`. + - `@subscription_charges(charge_id TEXT PK, payment_id → @payments ON DELETE CASCADE, provider_charge_ref, plan_id, period_start, period_end, amount NULL, currency NULL, created_at, UNIQUE(payment_id, provider_charge_ref))`. Amount and currency are nullable because Play reports no price; `charged_at` is not stored. + - `@badge_ledger.charge_id → @subscription_charges`, with an index. + - `@payments.subscription_state`, `@payments.subscription_checked_at`, `@payments.provider_token`. + - The service's never-written `@payments.subscription_renews_at`, `grace_until` and `cancelled` are dropped (and restored by the down migration), so the state has one representation. The client schema does not change. +- The Play token: kept only for subscription payments, because every renewal check needs it and the client no longer holds it once credited (`createStashBadgePurchase` clears it). It is never logged, never returned and never exported. The claim stays its hash. +- Charge ids are `randomId` text, like payment ids. This settles the MVP scope's two "settle before milestone 4" items: the wire `chargeId` is a service-assigned random id, and ledger entries reference payments and charges by those ids and purchases by key, never by a local integer. +- Storage (`Store.hs`): + - `appendLedgerPlan` writes `charge_id` from the `SCCharge` it is given, and the ledger reads select it (slice 2). + - New functions read a purchase's subscription from its payment row, insert a charge if new (`ON CONFLICT (payment_id, provider_charge_ref) DO NOTHING`), tell whether a charge is granted (a ledger row references it), and record the state and check time. +- Grant rule, for both paths: a charge is grantable when no ledger row references it, it is not revoked, it is not in an offer, the state is not `ended`, its plan's tier is the ledger's (or, on a first purchase, any plan of the catalog), and `periodStart <= now < periodEnd`. +- Purchase (`Service.hs` `purchaseWithReceipt`/`newPurchase`), for a subscription product: + - Play: the receipt is verified by one `checkSubscription` call, routed on the receipt's product id; the one-time `verifyGoogle` is not called. + - Apple: the JWS is verified offline as today; a test purchase is refused there; then one `checkSubscription` call. + - A test purchase is answered `test_purchase`, as one-time purchases are. + - With a grantable charge: acknowledge it (Play), plan from `emptyEntry periodStart tier`, sign the issued month, and write payment (claimed by `(provider, subscription ref)` as today), purchase, charge, ledger rows, issuance, state and token in one transaction. + - With no grantable charge, the receipt worker reschedules at whatever `retryAfter` the answer gives, with no cap on attempts, and no payment row exists yet to bound checks by. So: + - The period has not started yet, or the store answers pending: `payment_pending` with today's 300 s. + - Play, with the state `ended`: `receipt_invalid`, terminal. Play issues a new token when a subscription is bought again after it ended, so this one can never be credited again; this is the same premise as the existing one-time rule that "a canceled purchase stays canceled, and buying again issues a new token". + - Anything else (a revoked or offer charge while the state is not `ended`, a period over while the store still retries, no charge yet, an unknown plan, and an Apple subscription whose state is `ended`): `payment_pending` with `retryAfter` of a day. The record stays presentable, so a later charge on the same token, or an Apple resubscription under the same original id, can still be credited from it. + - The implementer confirms each case against the refusal classes `StoreRefusal` documents; a case that does not fit them is escalated, not guessed. +- Renewal (`Service.hs` `issueBadgeCmd`), for a subscription purchase: + - While its paid coverage runs (`paidThrough tip > now`), the path is today's: lapse, issue if a month is due, otherwise answer the current issuance. No store call. + - Once coverage has ended, the service asks the store, unless a check of this subscription was attempted within the check interval (one hour). Every attempt stamps `subscription_checked_at`. + - Inside the interval it answers from the stored state without calling the store. A failed check answers `provider_unavailable` with `retryAfter` of the whole interval. + - A grantable charge: the planner's rows from the tip, sign, then one transaction that re-reads the tip and writes charge, rows, issuance and state. If the tip moved, nothing is written and the answer is `internal`, never a reply that looks credited. + - No grantable charge: record the charge (if any) and the state, log a charge that is not grantable, write no ledger row. + - A charge whose period does not end after its start is a verifier failure (`internal`, logged). +- Answers: every `BSPBadgeCredential` for a subscription purchase is built by `issueResponse` (the statement after the asserted entry and the current issuance) plus `subscription`. Purchase replays answered from the record carry it too (`credentialResponse` takes it). + +### Client core (`src/Simplex/Chat/`) + +- `Store/Badges.hs`: `UserBadgePurchase` gains a named kind (suggested `PurchaseKind = PKPrepaid | PKSubscription`; `BadgeFunding` is taken), read in `getBadgePurchase` as `CASE WHEN EXISTS (a charge credit in this purchase's badge_ledger) THEN 1 ELSE 0 END`. A purchase is a subscription exactly when its months came from charges. The replica already says so, so there is no column and no client migration. +- `Library/Commands.hs`, in the pass (`updateUserBadge`): + - A prepaid purchase is retired before the request, as today. + - A subscription purchase is not retired at paidThrough. It is retired after a request whose answer is `SSEnded`, through the same `retireExpiredBadge`, whose own `paidThrough <= now` guard still applies. +- `badgeRequestDue`: a subscription purchase is due at zero months on the same condition as a prepaid one with months left: the latest credential is the shown one and it expires within a day. A request that issued nothing re-arms a day later (`stalledAt` in `updateUserBadge`), which is the retry cadence while the store has not charged. +- `requestBadgeIssue` returns the answer's `subscription` beside the balance. Its "issued no credential" check fires only while the ledger still owes a month (`paidThrough > now`), instead of while `balanceMonths > 0`. Every prepaid path gives the same answer, since the service lapses before it answers; a subscription tip whose months elapsed unlapsed (the service writes nothing while waiting) owes nothing. +- `derivedBadgeAlert` raises no `BASupportEnded` for a subscription purchase that is still shown: it is waiting for its renewal, not ended. + +### What gets removed or simplified + +- The premise "no months after paidThrough" becomes one rule per purchase kind, stated where the pass branches. +- `testStoreUnknownProduct`'s subscription case, and the catalog comment "subscriptions wait until renewals add months". +- The service's three never-written subscription columns on `@payments`. +- The draft charge schema in the released client migration's comment (the parts the service migration now implements). +- Not added: a client column or client migration. The purchase kind is derived from the replica, so there is nothing to keep in sync. + +### Alternatives considered, and why each was rejected + +- **Date the grant at request time** (as prepaid grants are): coverage drifts by the 0-7 days between paidThrough and the weekly request, every month, and the app's "Renews" date stops matching the store's. +- **Add the period start to the charge entry** instead of using `createdAt`: a new `StatementEntry` field is dropped by the client replica, which stores fixed columns, and needs a new client check, while `createdAt` already is the operation's time. +- **Ask the store from a background lane or notifications only**, keeping the issue path DB-only: a second ledger writer that needs a Postgres lock, and notifications need external setup. Left for later work, which can feed the same charges table. +- **The app hands renewals over** (a new purchase per renewal, or a top-up of the shown badge): an Apple renewal would get fresh keys under whichever profile is active, and the top-up machinery would be needed. +- **Keep a subscription flag or state on the client purchase**: a client migration now, and two sources of the same fact. The app screens can add stored state if they need it. +- **Request at the store's renewal time** (model §2.9, "after `renews_at`"): a per-user request time links requests (objectives 3 and 4 in `docs/protocol/badges-overview.md`). The shared weekly moment already falls after paidThrough. +- **Retire at paidThrough and re-show on renewal**: contacts see the badge removed and re-added every month, and a retired purchase is never asked about again. +- **Remove the badge when recipients' grace ends**, while the store still retries: a payment the store recovers afterwards could not re-activate it without resubscription work, so the buyer would pay for a month they never see. Contacts already see it faded as expired by then. +- **Write the lapse at `now` when no charge is grantable** (as prepaid does): it is dated after a charge that may later be granted at an earlier period start, and it moves the client's tip so the pass counts as issued and stops retrying. +- **Read the Apple subscription from the JWS the app hands over**, without the App Store Server API: the app is not involved in renewals, so only the service's own read sees them. + +## 5. Compatibility and security + +- **Wire:** one optional response field, `badgeCredential.subscription`, version unchanged. + - An older client ignores the field (aeson `rejectUnknownFields = False`). + - A newer client reading an older service gets `Nothing`, and an older service never writes a charge, so nothing is derived as a subscription. + - A state added later decodes as `SSUnknown` on this client and does not fail the response. + - Apple claim refs change only for auto-renewable transactions, which no client hands over today (the apps finish them uncredited). + - `docs/protocol/badges-rpc.md` and `badges-rpc.schema.json` gain the field, the `charge` credit's dating, `issueBadge`'s renewal behaviour, and the subscription answers of `purchaseBadge`. The doc's sentence that "each renewal credits months of the charged badge type" changes to say a charge for another tier is recorded and not granted until tier changes are supported. +- **Database:** one new service migration with up and down, SQLite and Postgres, covered by the service schema down/up cycle. No client migration. The client query in `getBadgePurchase` changes, so `chat_query_plans.txt` changes; only the full local run regenerates it, since it is rewritten from every query a full run records. +- **Config:** new optional keys in the `[apple]` section for the App Store Server API key and host. A deployment without them keeps verifying Apple one-time purchases. +- **Older and newer clients:** an older client never holds a subscription purchase, since no app sells one. An app downgraded to an older core retires a subscription badge at paidThrough and never asks again, so it loses every renewal the store keeps charging. +- **Adversary:** + - Quota exhaustion: a client holding a subscription key repeats issue requests to make the service hammer a store's API quota, which every subscriber shares. For an existing purchase the store is asked only once coverage has ended, and at most once per subscription per hour, failures included; requests in between are answered from the stored state. On the purchase path there is no payment row to stamp: an honest client retries a period that has not started, or a pending payment, every 300 s only until it starts or settles, stops on a Play subscription whose state is `ended`, and retries every other case once a day; a client that ignores `retryAfter` can repeat a purchase as it can today (each attempt is one store call, as one-time Play purchases already are). + - Replay: a charge is unique per `(payment, provider charge ref)` and granted only if no ledger row references it, in the transaction that re-reads the tip. A replay inside the issued period gets the cached credential. + - Forged renewal evidence: none is accepted from a client. Renewals come only from the service's own store read, and Apple's answers are verified against the pinned root, so a client cannot name a charge, a period or a tier. + - Another key presenting the same subscription: the claim is the subscription's identity, so another key gets `receipt_used`, as today. + - Store data out of range (period end not after start, unknown plan, another tier, an offer): not granted; a malformed period is a verifier failure, logged. + - A dishonest service dating grants: grants are legitimately dated up to about a month back, so the client's check accepts any date between the previous row's and now; it still flags a row dated before its predecessor. Accepted: the verdict is stored and nothing reads it. + - Metadata: the zero-month request goes out at the same shared weekly moment as every renewal, so it adds no per-user timing. The service links a subscription's renewals to its purchase key, as it already links monthly issuance. + - Secrets at rest: the Play token and the Apple API private key are usable only with our store accounts. Neither is logged or returned; the Apple key file is read like the Play service-account file. + - Resource use: each check is one store call of at most the verifier timeout on the serial loop, bounded per subscription here. The aggregate load at the weekly moment is a known limit (§8). + +## 6. Slices + +Before slice 1, the user creates the branch on the host, after `f/badge-store-verifiers` is merged into `badge-in-app-checkout`: `git switch -c f/badge-subscriptions badge-in-app-checkout`. + +The implementer runs `cabal build` and the tests in every slice. Every slice runs `cabal build` (project default flags, never `-O0`), then the scoped suites named in it, from `/workspace/simplex-chat-2`. Check the example counts, not only the exit status. hspec `-m` matches a substring of the example path, and several `-m` are alternatives. The new service and client subscription tests go under a `describe "subscriptions"` inside `badgeServiceTests` (`tests/Bots/BadgeService/BotTests.hs`), so `-m "SimpleX badge service e2e/subscriptions"` runs exactly them. + +### Slice 1: name Apple claims by reference (structural) + +- Files: `src/Simplex/Chat/PaymentService.hs`, `src/Simplex/Chat/Library/Commands.hs`, `BadgeService/StoreReceipts.hs`, `BadgeService/StoreReceipts/Mock.hs`, and any test that calls the function. +- Change: rename `appleTransactionId` to `appleTransactionRef`, matching `googlePurchaseRef` and `StoreTransactionRef`; no behaviour change. Slice 3a makes it return a subscription's original id, which the old name would misstate. +- Verify: `cabal test simplex-chat-test --test-options='-m "Supporter badges"'`. +- Done when: it builds and the suite passes with the same example count. + +### Slice 2: charge storage and the response field (structural) + +- Files: `BadgeService/Store/{SQLite,Postgres}/Migrations.hs`, `BadgeService/{Store,Service}.hs`, `src/Simplex/Chat/Badges/{Ledger,Service}.hs`, `src/Simplex/Chat/Store/Badges.hs` (the fallback `entryTypeFromColumns` call), the client migration `M20260915_user_badges` in both backends (draft comment only), `tests/BadgeTests.hs`, `tests/Bots/BadgeService/WebTests.hs`. +- Change: + - The `20261012_badge_subscriptions` migration (§4). + - `appendLedgerPlan` writes `charge_id` for an `SCCharge` row; the ledger tip and statement reads select it; `entryTypeFromColumns` gains the charge id; the client's fallback call passes `Nothing`. + - `SubscriptionState` and the `subscription` field; `credentialResponse` takes it, and every caller passes `Nothing`. + - Nothing writes a charge or a state yet, so no behaviour changes. +- Tests: `testEntryTypeColumns` asserts the `SCCharge` round trip with a charge id and `Nothing` without one; JSON tests for the field (present, absent, unknown state); `testServiceColumns` covers the new and dropped columns; the down/up cycle covers the migration. +- Verify: `cabal test simplex-chat-test --test-options='-m "Supporter badges" -m "SimpleX badge service"'` (this also runs the e2e suite). +- Done when: the suites pass, the down/up cycle includes the new migration, and AC11 and AC13 hold. + +### Slice 3a: the charge planner and Apple subscription claims (behavioural, pure) + +- Files: `src/Simplex/Chat/Badges/Ledger.hs`, `src/Simplex/Chat/PaymentService.hs`, `BadgeService/StoreReceipts/Apple.hs`, `tests/BadgeTests.hs`, `tests/Bots/BadgeService/StoreVerifierTests.hs`; the comments on `VerifiedStoreTransaction.transactionRef` and `OpenStorePurchase` that call Apple's ref the transaction id. +- Change: the planner; `appleTransactionRef` returns the original id for auto-renewable transactions, and the Apple verifier's `transaction` vouches with that same ref. +- Tests: `testChargeRowsVerify` (AC10, every case listed there); `testAppleTransactionRef` (subscription, consumable, missing type); a `StoreVerifierTests` case with a renewal payload (`transactionId` different from `originalTransactionId`, type auto-renewable) asserting the verified ref equals `appleTransactionRef` of the JWS. +- Verify: `cabal test simplex-chat-test --test-options='-m "Supporter badges" -m "badge store verifiers"'`. +- Done when: AC10, the AC12 unit test and the new renewal-payload case hold, and the existing Apple verifier cases still pass. This slice tests the riskiest assumption (§8) before any service code depends on it. + +### Slice 3b: credit a subscription purchase, against the fake store (behavioural) + +- Files: `BadgeService/{Catalog,StoreReceipts,Service,Store}.hs`, `tests/Bots/BadgeService/{FakeStore,BotTests}.hs`, `tests/BadgeTests.hs` (its `uncalledVerifier` builds `StoreVerifier` directly and needs the new field), `docs/protocol/badges-rpc.md`, `docs/protocol/badges-rpc.schema.json`. +- Change: catalog `StoreProduct` sum and `storePlan`; `checkSubscription`, `SubscriptionRef`, `SubscriptionCheck`, `StoreCharge`; the grant rule; the purchase branch for subscription products, with its no-grantable-charge answers; replays carry the state. +- FakeStore gains subscriptions: Google keyed by token and Apple keyed by original id, each with a mutable state and latest charge, and a check counter. Its Apple subscription fixtures (`unsignedJWS` with `type` and `originalTransactionId`) vouch with the original id, which is the claim `appleTransactionRef` reads. +- Tests: `testSubscriptionPurchase` (AC1), `testSubscriptionPurchaseNotGrantable` (AC9: a period not started, `payment_pending` 300 s; a Play subscription whose state is `ended`, `receipt_invalid`; a Play revoked or offer charge while renewing and an Apple subscription whose state is `ended`, `payment_pending` with a day; each writing nothing); `testStoreUnknownProduct` and `testStoreAcknowledgesBeforeCrediting` keep their unpriced cases with a product no catalog maps instead of `subscr_badge_supporter_01`. FakeStore vouches for that product with its own token, so the request reaches `storeProduct` and is answered `product_unavailable` rather than refused by the fake verifier. +- Verify: `cabal test simplex-chat-test --test-options='-m "SimpleX badge service e2e/store purchases" -m "SimpleX badge service e2e/subscriptions"'`. +- Done when: AC1 and AC9 hold and the store purchase examples still pass. + +### Slice 4: renew once coverage ends, on the service (behavioural) + +- Files: `BadgeService/{Service,Store}.hs`, `tests/Bots/BadgeService/{BotTests,FakeStore}.hs`, `docs/protocol/badges-rpc.md`. +- Change: the renewal step in `issueBadgeCmd`, the check interval stamped on every attempt, the one-transaction write that answers `internal` when the tip moved, answers through `issueResponse`. +- Tests (service, through `serviceCmd` with the shared test clock): `testSubscriptionChargeGrantedOnce` (AC5, with the replay inside the issued period), `testSubscriptionStoreChecksBounded` (AC6: coverage running makes no check; two requests after coverage ended within the hour make one; a failed check also holds the next for the hour), `testSubscriptionChargeNotGranted` (AC7: revoked, other tier, offer, period not started, period ended), `testSubscriptionStoreUnreachable` (AC8). +- Verify: `cabal test simplex-chat-test --test-options='-m "SimpleX badge service e2e"'`. +- Done when: AC5-AC8 hold and the existing e2e examples still pass. + +### Slice 5: keep asking at zero months, on the client (behavioural) + +- Files: `src/Simplex/Chat/Store/Badges.hs`, `src/Simplex/Chat/Library/Commands.hs`, `tests/Bots/BadgeService/BotTests.hs`. +- Change: the purchase kind on `UserBadgePurchase`; `badgeRequestDue` at zero months for a subscription; retirement on `SSEnded` after the request; the "issued no credential" check on owed months; no support-ended alert while a subscription is shown. +- Tests (real client and service, test clock, `renewalMoments`/`armWakeAt`): `testSubscriptionRenews` (AC2, including that the new credential is presented at the old one's expiry with no gap), `testSubscriptionAwaitsCharge` (AC3), `testSubscriptionEnds` (AC4), `testAppleRenewalResolvesToPurchase` (AC12: two Apple JWS with one original id and two transaction ids resolve to one receipt record and one purchase; and a held record whose first attempt is answered `payment_pending`, then adopts the renewal JWS, is credited at its next attempt). `testNoCredentialMonthsRanOut` and `testNoCredentialMonthsLeft` stay green under the changed check. +- Verify: `cabal test simplex-chat-test --test-options='-m "SimpleX badge service e2e"'`, then `--test-options='-m "Supporter badges"'`. `chat_query_plans.txt` is left to the full run (§7); a scoped run must not be used to regenerate it. +- Done when: AC2-AC4, AC12 and AC17 hold. + +### Slice 6: Apple subscription check (behavioural) + +- Files: `BadgeService/StoreReceipts/Apple.hs`, `BadgeService/{Config,Service}.hs`, `apps/simplex-badge-service/badge_service.ini.example`, `scripts/badge-service/badge_service.ini.example`, `apps/simplex-badge-service/README.md`, `tests/Bots/BadgeService/{StoreVerifierTests,ConfigTests}.hs`, a new fake App Store API (for example `tests/Bots/BadgeService/FakeAppStore.hs`, a Warp server like `FakePlay`). +- Change: + - Separate the pinned-root chain check from the transaction parser in `Apple.hs`, so the API's signed transactions and renewal info reuse it; parse the subscription fields (§4). + - The API key config, the ES256 JWT with reuse, "Get All Subscription Statuses", the status mapping, the HTTP classes, and `FAMILY_SHARED` refusal. + - `configureStoreVerifier` sets `checkSubscription` for Apple when the key is configured. +- Tests: Apple subscription cases in `StoreVerifierTests` against the fake API, signed with the test chain the existing Apple cases build: each status and its mapping, cancelled versus renewing by `autoRenewStatus`, revoked, offer, family-shared, a body signed by another root and a chain the check throws on, and a signed transaction or renewal info whose leaf the intermediate did not sign or whose signature does not verify (the check's `Left`), all verifier failures, never a refusal, 401/403 (internal), 404/429/5xx/timeout (unreachable), the JWT's claims and header checked by the fake. A service test: an Apple Sandbox subscription JWS is answered `test_purchase` without any call to the fake API. The API-side Sandbox case is a defence only a Sandbox host would exercise. `ConfigTests`: the key keys, an incomplete key, the default host. +- Verify: `cabal test simplex-chat-test --test-options='-m "SimpleX badge service"'`. +- Done when: AC14 and AC16's Apple part hold, and the existing Apple verifier cases pass. + +### Slice 7: Play subscription check and acknowledgement (behavioural) + +- Files: `BadgeService/StoreReceipts/Google.hs`, `BadgeService/{StoreReceipts,Service}.hs`, `tests/Bots/BadgeService/{StoreVerifierTests,FakePlay}.hs`. +- Change: `subscriptionsv2.get` through `playCall`; the state, plan, period, order and offer mapping; test purchases; the subscription acknowledgement, called before a subscription is credited and skipped when Play reports it acknowledged; `configureStoreVerifier` sets `checkSubscription` for Play; a Play subscription receipt is verified by `checkSubscription` alone. +- FakePlay gains the `purchases/subscriptionsv2/tokens/{token}` and `purchases/subscriptions/{productId}/tokens/{token}:acknowledge` routes; its existing `PlayRecord` answers serve subscription bodies. +- Tests: Play subscription cases in `StoreVerifierTests`: each state and its mapping, cancelled versus renewing, the derived period start for monthly and annual plans, an unknown base plan, a line item in a free-trial or introductory `offerPhase` (offer) and one bought on an offer now in its base-price phase (not an offer), a test purchase, a pending subscription, acknowledgement made once and skipped when already acknowledged, a failed acknowledgement crediting nothing, and the existing HTTP classes. A service test buys a Play subscription through FakePlay end to end. +- Verify: `cabal test simplex-chat-test --test-options='-m "SimpleX badge service"'`. +- Done when: AC15 and AC16's Play part hold, and the existing Play verifier cases pass. + +## 7. Test plan + +- New tests and what each proves: listed per acceptance criterion in §1 and per slice in §6. The pure planner test (AC10) proves the dating choice against the client's own check. The service tests prove grant-once, replays, bounded store use and the refusal classes. The client tests prove the badge stays on across paidThrough with no gap and comes off only on `ended`. The store tests prove each store's answers map to the right state and refusal class, and that Apple's answers are trusted only through the pinned root. +- Existing tests that must stay green: "Supporter badges" (ledger transitions, statement checks, JSON, store purchases, request loop), "SimpleX badge service" (config, web, catalog, schema cycle, store verifiers), "SimpleX badge service e2e" (renewals, lapses, retirement, alerts, store purchases, receipt worker). `testStoreUnknownProduct` changes as described. +- Full-suite check, at the end: + - `cabal test simplex-chat-test`, which also rewrites `src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt` for the changed purchase query. + - On the host, a Postgres build of the service, `cabal build --flags=+client_postgres exe:simplex-badge-service`, and a Postgres run of `cabal test --flags=+client_postgres simplex-chat-test --test-options='-m "badge service schema"'`, which executes the new migration's Postgres SQL. The deployed service is Postgres-only, and CI tests SQLite only. + +## 8. Risks + +- **Riskiest assumption, and how the first slice tests it early:** that dating every charge grant to the store's period start keeps `createdAt` non-decreasing and every row verifiable on all renewal paths, including annual renewals and recovery after billing retry or grace. Slice 3a's `testChargeRowsVerify` runs the planner's rows through the client's own `balanceChecked` before any service code depends on them. A failure there changes the design (to an explicit period field) before slices 3b-7 are built. +- **Store behaviour assumed from documentation, confirmed only with real store accounts** (Sandbox and licence testers, after this run): that Apple renewals keep `originalTransactionId` and a resubscription in the same group keeps it too; that Apple's "Get All Subscription Statuses" answers by original transaction id with the latest signed transaction; that Play keeps the token across renewals and issues a new one on resubscription after the subscription ended; that Play's period start is the period end minus the plan's length. The fake stores follow the documentation, so tests cannot catch a mismatch. If one is wrong, `SubscriptionCheck` mapping changes; the planner, the ledger and the client do not. The Play terminal answer for an ended subscription depends on the new-token premise; if Play reused the token, it becomes the day-long `payment_pending`. +- **Load:** every subscriber renewing in a week asks at the same instant (the TODO above `badgeBoundary`), and each check is up to the verifier timeout on the serial loop against a 30 s client deadline. A large renewal cohort times out and retries until requests are spread across the lead day and store calls leave the serial loop. +- **The derived purchase kind:** a purchase is a subscription exactly when its replica holds a charge credit. A future re-bind, transfer or `opening` restatement (which the protocol specifies for new devices) must keep a charge credit in the replica, or the client would retire a live subscription at paidThrough. +- **A badge waiting for a charge blocks new purchases:** while a subscription badge waits for its store to charge, the profile cannot buy or redeem another badge, for up to the store's retry window, or indefinitely if a store never reports `ended` (for example after an Apple tier change, which this run does not grant). +- **One `ended` answer is final:** the client retires on the first `ended` at or after paidThrough, and a retired purchase is not asked about again. If a store briefly reports a subscription as expired around a renewal, the renewals after it are lost until resubscription support exists. +- **Before subscriptions can be sold** (not this run, each a pay-and-get-nothing or more-than-paid case): spreading the weekly requests and moving store calls off the serial loop; resubscription after the badge was removed and restore on a new device (an Apple resubscription after retirement resolves to the old, credited receipt record); tier changes (iOS lets a user switch tier from Settings, and the new tier's charges are recorded and never granted); refund handling (a refunded annual keeps issuing until its months run out); the app screens and hand-over. + +## 9. Open questions + +None. The timing and grace behaviour, the branch, who runs builds, tier changes, Family Sharing, the first-purchase answers and the store connections are decided in §2, §4 and §6. + +## 10. Out of scope (noticed, not doing) + +- `writeIssued` answers a skipped issuance as success. Harmless for issuance; only the charge path changes here. +- The prepaid-ending alert TODO above `derivedBadgeAlert`. +- Unused drafts: `PaymentTerm`/`PTSubscription` (`PaymentService/Types.hs`), `BadgeCharge` and `BadgePlan` (`Badges/Types.hs`), `BSCUpgradeBadgeSubscription`'s `badgeRequest` (`Badges/Service.hs`). +- The Apple verifier checks no certificate validity dates or revocation. +- A Play subscription purchase is offered by the first offer on its base plan, not a named offer (`PlayStore.kt`). +- Retirement does not signal the receipt worker. +- After a renewal that lands once the shown credential has expired (billing-retry recovery), the request and the profile update happen in the same pass, because `presentIssuedBadge` presents as soon as the shown credential has expired. This already happens to late prepaid renewals; it weakens objective 3 for recovered subscriptions. + +## 11. Follow-up runs + +Each item marked **(blocks sale)** must land before subscriptions are offered in either app. + +- **(blocks sale)** Load and timing: spread requests across the lead day, drop queued requests older than the client deadline, take store calls off the serial loop behind a Postgres row lock on the purchase. +- **(blocks sale)** Resubscription and restore: a resubscription after the badge was removed and a new device's restore re-bind the subscription to the presenting key and show it again (model § recovery, "re-bind, capped per payment and period"). +- **(blocks sale)** Tier changes: a tier-changing grant for Apple upgrades and downgrades and Play replacements, and the client updating `current_badge_type`. +- **(blocks sale)** Refunds: a per-charge refund debit, so a refunded annual stops issuing; Play's Voided Purchases API. +- Store notifications: App Store Server Notifications V2 and Play real-time notifications updating charges and state between requests (a second writer, after the row lock). +- **(blocks sale)** iOS app: sell monthly and annual, hand subscription transactions over instead of finishing them, "Renews"/"Ends"/"Payment problem" on Your badge, the manage-subscriptions sheet, Restore purchases, the subscription alerts, disclosures, dev mock subscriptions. A subscription receipt must not wait behind a shown badge while the store charges (refuse code redemption while the profile has an open store purchase, or hand subscription receipts over regardless). A held subscription record adopts the newest JWS; a revoked renewal's terminal refusal must not refuse the whole subscription, and adopting a newer transaction should make the record due at once. While a subscription badge waits for its store to charge, the profile cannot buy or redeem another badge; the app should say so. +- **(blocks sale)** Kotlin port of the iOS run, plus the Play sweep querying subscriptions and offer selection by base plan. + +External waits before sale (not runs): + +- The App Store Connect subscription group and products, with Billing Grace Period off; Play subscription products with the grace period at its minimum, account hold, and subscription pausing off (§4 Timing). +- No trial or introductory offers on any subscription plan. +- Family Sharing off for the subscription products; it cannot be undone once on. +- The subscription products stay unbuyable (not live, or not offered by any app build) until sale: today's apps finish a subscription transaction without crediting it, so a buyer would pay and get nothing. +- An App Store Server API key; a Play service account with order permissions. +- Store API quotas for the expected renewal checks, raised if needed. +- Store review of the subscription screens, including how review purchases are handled: Sandbox and licence-tester purchases are answered `test_purchase`, the same open question as the one-time lane's. +- Terms and privacy links; translations. diff --git a/plans/2026-10-13-block-markdown-rendering.md b/plans/2026-10-13-block-markdown-rendering.md new file mode 100644 index 0000000000..fd5a3be739 --- /dev/null +++ b/plans/2026-10-13-block-markdown-rendering.md @@ -0,0 +1,415 @@ +# Block Markdown: rendering in the apps and on the web + +Date: 2026-10-13. Revised: 2026-10-16, for master. Revised: 2026-10-17, drawing from markdownBlocks. Revised: 2026-10-18, drawing by index ranges (S5). Revised: 2026-10-18, a column of block views (approved mockup https://claude.ai/artifact/7Twe2qJcdULBbcqhSPuh6k, v3). + +Where a whole message is shown, the apps draw `formattedText` with `markdownBlocks` (`2026-10-13-block-markdown.md`, sections 2 and 4) as a column of block views; each text view is the segment loop of master over the index range of one block, list entry or table cell (section 3). Previews, quotes, the compose context, the profile teaser and notifications stay one text. A message without blocks is drawn as now. On the web, HTML blocks are drawn by the page script. + +References are to master at 382dc7480. + +``` +K/ apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ +I/ apps/ios/Shared/Views/ +S/ apps/ios/SimpleXChat/ +W/ website/src/js/ +``` + +## 1. Scope + +``` +blocks drawn every list drawn by MarkdownText (K/views/chat/item/TextItemView.kt:125) + every list drawn by messageText (I/Chat/ChatItem/MsgContentView.swift:396) + desktop selection (K/views/chat/TextSelection.kt:264-305) + copied text of a selection: as drawn, by the same block drawing + the Copy action of an item: its source text, as now +tokens by kind notification texts (K/platform/NtfManager.kt:134-148, S/Notifications.swift:200-210) +token form compose state: parsedMessage, links, mentions, link sanitizing, as now + K/views/chat/ComposeView.kt:111, 130, 1024-1070 + I/Chat/ComposeMessage/ComposeView.swift:560, 1240, 1990 +HTML blocks renderMarkdown (W/simplex-lib.jsc:57-128) +``` + +- Per text view, as in one text now: + - taps + - long presses + - secrets + - mentions + - commands +- Per message: + - the typing indicator and the meta reserve: at the end of the last view when it is text, otherwise in a line below + - the meta reserve after text: a line break when the text direction differs from the bubble's, as now; spaces in the line below (iOS `reserveSpaceForMeta(mt, newLine: false)`) + - text direction: the column in the text direction, text aligned to its start +- Code blocks and tables scroll sideways; a drag that starts in them does not swipe the item. + +## 2. Types + +Kotlin, `K/model/ChatModel.kt`: + +``` +Format (:5177) @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() (:5178) + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() + style of Escape and Markup: SpanStyle() +BlockToken @Serializable sealed class, next to Format + @SerialName("lineBreak") class LineBreak + @SerialName("softBreak") class SoftBreak + @SerialName("hardBreak") class HardBreak + @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int) + @SerialName("indent") class Indent + @SerialName("headingMark") class HeadingMark(val level: Int) + @SerialName("headingClose") class HeadingClose + @SerialName("headingUnderline") class HeadingUnderline(val level: Int) + @SerialName("listMark") class ListMark(val number: String? = null) + @SerialName("quoteMark") class QuoteMark + @SerialName("fenceMark") class FenceMark(val language: String? = null) + @SerialName("rowMark") class RowMark + @SerialName("cellMark") class CellMark + @SerialName("delimiterRow") class DelimiterRow(val columns: List) + @SerialName("ruleMark") class RuleMark +MarkdownBlock @Serializable sealed class + @SerialName("paragraph") class Paragraph(val content: SegmentRange) + @SerialName("heading") class Heading(val level: Int, val content: SegmentRange) + @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange) + @SerialName("quote") class Quote(val content: SegmentRange) + @SerialName("list") class ItemList(val items: List) + @SerialName("table") class Table(val columns: List, val header: List, val rows: List>) + @SerialName("rule") class Rule(val mark: SegmentRange) +ListEntry @Serializable class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) +SegmentRange @Serializable class SegmentRange(val from: Int, val to: Int) +TableAlign @Serializable enum class: none, left, center, right +BlockToken.drawnText the text of the token in a block view (section 3) +BlockToken.previewText the text of the token in one text (section 3) +BlockTextRange class BlockTextRange(val range: SegmentRange, val inRow: Boolean) +MarkdownBlock.textRanges the ranges of the text views of a block, in source order + Paragraph, Heading, CodeBlock, Quote: content + ItemList: the contents of the entries, a parent before its children + Table: the cells of the header, then of each row; inRow for each cell after the first of a row + Rule: none +listEntries (items, level) -> List>: the entries with their levels, a parent before its children +blockTextRanges (blocks) -> the textRanges of all blocks +headingSegments (size, blocks) -> BooleanArray: the segments in heading contents +segmentDisplayText (ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode) -> String + the body of itemSegmentDisplayText (K/views/chat/item/TextItemView.kt:88-108), with mentions instead of ci + Escape and Markup: the text drawn for the token in a block view + replaces itemSegmentDisplayText +itemDisplay (ci: ChatItem, linkMode: SimplexLinkMode) -> ItemDisplay?, null without formattedText + parts: per segment the displayed and the copied text (Markup: its drawn text both); + between text views a part "\t" (between cells of a row) or "\n" + bases: per text view the offset of its first character in the selection offsets of the item; 0 for the first + without markdownBlocks: the segments, bases [0] +MarkdownBlocks composable: the column of block views (section 3) +``` + +- Kotlin name `ItemList`: `List` is `kotlin.collections.List` in the scope of `MarkdownBlock`; JSON tag "list". + +Kotlin records, field `markdownBlocks: List? = null` after `formattedText`: + +``` +ChatItem K/model/ChatModel.kt:3409 +CIQuote K/model/ChatModel.kt:4415 +ChatItemVersion K/model/ChatModel.kt:5716 +ParsedFormattedText K/views/helpers/TextEditor.kt:134-136 +``` + +iOS, `S/ChatTypes.swift`: + +``` +Format (:5681) case bold(singleStar: Bool) + case escape + case markup(token: BlockToken) +BlockToken enum: Decodable, Equatable, Hashable + cases as the Kotlin classes, associated values as their fields +MarkdownBlock enum: Decodable, Equatable, Hashable + cases as the Kotlin classes, list as `list` +ListEntry struct: Decodable, Equatable, Hashable +SegmentRange struct: Decodable, Equatable, Hashable +TableAlign enum: String, Decodable, Hashable +drawnText, previewText, BlockTextRange, textRanges, listEntries, blockTextRanges, headingSegments: as Kotlin +MarkdownBlocksView SwiftUI view, generic over the block text view, in I/Chat/ChatItem/MsgContentView.swift (section 3) +MarkdownView SwiftUI view: one text, or MarkdownBlocksView when blocks are present +``` + +- The `case .bold:` pattern (I/Chat/ChatItem/MsgContentView.swift:450) binds `singleStar`. + +iOS records, field `markdownBlocks: [MarkdownBlock]?` after `formattedText`: + +``` +ChatItem S/ChatTypes.swift:3810 +CIQuote S/ChatTypes.swift:4861 +ChatItemVersion S/ChatTypes.swift:6400 +ParsedMarkdown S/API.swift:179-181 +``` + +- `FormattedText` is unchanged. +- With an unknown block or token tag, the item is invalid, as with an unknown `Format` tag. + +## 3. Drawing + +Segment loop: + +``` +segment loop master's loop over formattedText + Kotlin: MarkdownText (K/views/chat/item/TextItemView.kt) + Swift: messageText (I/Chat/ChatItem/MsgContentView.swift), the loop in a nested function appendSegments +range a block view: the range of its block, entry or cell; otherwise all indices +font Kotlin: the style passed by the column + Swift: parameter baseFont; the fonts of formats and links derived from it +Bold singleStar italic, when markdownBlocks is present +HyperLink linkUri "mailto:" (e-mail autolink): opened as an e-mail link + Kotlin: annotation OTHER_URL; Swift: without webLinkAttrKey + FormattedText.linkUri: null for it, so no link preview +``` + +Modes of the segment loop (Kotlin `MarkdownText`, Swift `messageText`): + +``` +block view parameter range (a text view of the column) + tokens by drawnText: SoftBreak " ", LineBreak and HardBreak "\n", ParagraphBreak n × "\n", other markup nothing +one text markdownBlocks present, no range (previews, quotes, compose context, profile teaser) + tokens by previewText: SoftBreak, HardBreak and CellMark " "; LineBreak and ParagraphBreak "\n"; + ListMark the number or "•" and " "; other markup nothing + segments in heading contents (headingSegments) bold +no blocks markup drawn as its text; Escape nothing +``` + +The column (Kotlin `MarkdownBlocks`, `blockLayout = true` of `MarkdownText`; Swift `MarkdownBlocksView`, `blockLayout` of `MsgContentView`, and `MarkdownView`): + +``` +order blocks in source order; text view index i = the i-th of blockTextRanges +spacing a ParagraphBreak between two blocks: half a line; otherwise none +Paragraph text view, body font +Heading text view, bold; size × 1.5 (level 1), 1.3 (2), 1.15 (3), 1 (4-6); line height scaled alike +List per entry (listEntries): a row of the marker ("•" or the number) and the text view; indent 20 per level +Quote a 4 wide bar (text colour at 16%), 10 gap, the text view at 70-72% opacity +CodeBlock card in the bubble's quote colour, corners 12 × the bubble roundness setting, the bubble's full width + the text view: monospace, size × 0.875, no wrapping, scrolls sideways + copy icon at the top right: on hover (desktop) or after a tap (mobile); copies the drawn text of the range +Table rounded frame (12 × roundness, 1 line), scrolls sideways + columns as wide as their widest cell; header row bold on the quote colour; alternate body rows tinted + lines between rows and columns; a cell aligned by its column + a missing cell at a row end: an empty cell (GFM 0.29 example 204) + Kotlin: a Layout measuring cells by max intrinsic width, composing only the present cells + iOS 16+: Grid, columns × rows at most 1000 (maxGridCells); otherwise, and on iOS 15: the table as typed in a code card +Rule 1 line, 6 above and below +first view gets the sender and the report prefix +last view gets the meta reserve and the typing indicator when it is text; otherwise a line with them below the column +ranges limited to the length of fts +``` + +- Quote colour: `sentQuote` in a sent bubble, `receivedQuote` otherwise (Kotlin `AppColors`, Swift `theme.appColors`). +- Swipe to reply: Kotlin: the horizontal scroll consumes the drag before `swipeable` of the item. iOS: scrolling blocks report their frames (`SwipeExclusionFrames`) in the item's coordinate space; the item's drag gesture ignores drags that start in them. +- iOS: a horizontal scroll only when the content is wider than the bubble (`ViewThatFits` on iOS 16+). + +Secrets (Kotlin, as iOS `hiddenSecretsView`): a hidden secret is drawn transparent, and its text path is painted with the tile `vertical_logo` (8 dp, rotated 45°, saturation 0.65, alpha 0.35), added to `moko-resources/images` from the iOS asset. + +Desktop selection (K/views/chat/TextSelection.kt): + +``` +setupItemSelection (selectionManager, selectionIndex, isLive, bases) +text views each registers its bounds and layout with its base (ItemSelection.text(index)) +pointer offset the view under the pointer, or the nearest; base + its local offset +highlight per view, the selected offsets of the item within the view, shifted by its base +copy, snap itemDisplay(ci).parts: copied text as before, with "\t" and "\n" between views +``` + +Notifications, `hideSecrets` (K/platform/NtfManager.kt, S/Notifications.swift): Secret "...", markup by previewText, Escape nothing, other segments their text. + +## 4. Call sites + +Kotlin: + +``` +MarkdownText (K/views/chat/item/TextItemView.kt) parameters markdownBlocks, blockLayout, range, softWrap, cardColor, selection, textIndex, parentDirection (the bubble's direction, for the meta reserve of block views); section 3 +CIMarkdownText (K/views/chat/item/FramedItemView.kt:445) ci.markdownBlocks; column; quote colour by direction; selection with itemDisplay bases +ciQuotedMsgTextView (K/views/chat/item/FramedItemView.kt:61) qi.markdownBlocks; one text +item preview (K/views/chatlist/ChatPreviewView.kt:261-265, 277) null where formattedText is null, otherwise ci.markdownBlocks; one text +TextBubble (K/views/chat/ChatItemInfoView.kt:55-57) the markdownBlocks of the item or the version; column +ContextItemView (K/views/chat/ContextItemView.kt:59) contextItem.markdownBlocks; one text +ProfileDescriptionText (K/views/chat/ChatInfoView.kt:794-795, 804-805) blocks of parseToMarkdownText; one text +showFullProfileDescription (K/views/chat/item/TextItemView.kt:56-58) blocks of parseToMarkdownText; column +WelcomeMessageView (K/views/chat/group/WelcomeMessageView.kt:149-151) blocks of parseToMarkdownText; column +desktop selection (K/views/chat/TextSelection.kt:264-305) section 3; itemDisplay +notifications (K/platform/NtfManager.kt:134-148) section 3 +parseToMarkdownText (K/views/helpers/TextEditor.kt) new: (text: String) -> ParsedFormattedText? +parseToMarkdown (K/views/helpers/TextEditor.kt:138-146) the formattedText of parseToMarkdownText +``` + +iOS: + +``` +messageText (I/Chat/ChatItem/MsgContentView.swift) parameters markdownBlocks, range, baseFont, secretStart; section 3 +MsgContentView (I/Chat/ChatItem/MsgContentView.swift) vars markdownBlocks, blockLayout, cardColor; MarkdownBlocksView rightToLeft from its rightToLeft, MarkdownView from isRightToLeft(text) +item content (I/Chat/ChatItem/MsgContentView.swift:624) chatItem.markdownBlocks +quote (I/Chat/ChatItem/FramedItemView.swift:337) qi.markdownBlocks; one text +FramedItemView (I/Chat/ChatItem/FramedItemView.swift:366) the markdownBlocks of the item; column; quote colour by direction +chatItemPreviewText (I/ChatList/ChatPreviewView.swift:305-316, 302) nil where formattedText is nil, otherwise ci.markdownBlocks; one text +TextBubble (I/Chat/ChatItemInfoView.swift:298-319) the markdownBlocks of the item or the version; MarkdownView +ContextItemView (I/Chat/ComposeMessage/ContextItemView.swift:74) contextItem.markdownBlocks; one text +markdownText (I/Chat/ChatItem/MsgContentView.swift:331-352) blocks of parseSimpleXMarkdownText; one text +FullProfileDescriptionView, GroupWelcomeView textPreview MarkdownView with blocks of parseSimpleXMarkdownText +item swipe (I/Chat/ChatView.swift chatItemWithMenu) ignores drags starting in SwipeExclusionFrames +notifications (S/Notifications.swift:200-210) section 3 +parseSimpleXMarkdownText (S/API.swift) new: (_ s: String) -> ParsedMarkdown? +parseSimpleXMarkdown (S/API.swift:151-162) the formattedText of parseSimpleXMarkdownText +``` + +Without blocks: + +``` +K/views/chatlist/ChatPreviewView.kt:233 draft: formattedText null +K/views/chat/ChatInfoView.kt:811-813 teaser with Format.Modal appended +I/Chat/ChatItem/MsgContentView.swift:380-383 teaser with Format.modal appended +``` + +Unchanged: + +``` +K/views/chat/item/FramedItemView.kt:349 short emoji test: formattedText == null +I/Chat/ChatItem/FramedItemView.swift:145 short emoji test: formattedText == nil +K/views/chat/ComposeView.kt:130 edit of a sent item: the token form, for links and mentions +K/views/usersettings/MarkdownHelpView.kt inline formats +I/UserSettings/MarkdownHelp.swift inline formats +``` + +## 5. Web + +`renderMarkdown(fts, blocks)` (`W/simplex-lib.jsc:57-128`): + +``` +blocks absent as now; escape: nothing +blocks invalid as now; escape: nothing + invalid: not an array + an unknown type + a range not of integers with 0 <= from <= to <= fts.length + a range starting before the end of the previous range, in source order + a list deeper than 7 levels +blocks in source order, each block one element below +range each segment drawn as now + SoftBreak: " " + LineBreak, HardBreak:
+ other markup, escape: nothing + Bold, singleStar: +gaps a ParagraphBreak between two blocks:
; otherwise nothing +Paragraph
: the range +Heading
: the range; N: the level 1-3, otherwise 4 +CodeBlock
: the range, then a copy button (class md-copy, icons md-icon-copy and md-icon-done)
+Quote            
: the range +List each entry, in source order:
: "•" or the number; : the range + N: 1.25 × the level below the top, at most 6 levels +Table
: the header row in ' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + ''; + gap(b.header.length > 0 ? b.header[0].from : pos); + html += '
, the body rows in + each cell: style="text-align: A;"; A: the column alignment, none as left, any other value as left + rows padded with empty cells to the column count when columns × rows <= 1000 (MAX_TABLE_CELLS) +Rule
+number escaped text +copy handleCodeClick: a click on the button copies the text of the
 (innerText); a click elsewhere in the card toggles the class tapped
+```
+
+Styles, as the apps (section 3), in each page: `.simplex-preview-text .md-*` in W/channel-preview.jsc (colours from its `--sp-*` variables, card `--sp-quote`, corners 10px for its 18px bubbles), `#directory .entry .md-*` in website/src/directory.html (light and `.dark` values, corners 4px as its entries). The flex items around the text get `min-width: 0` (`.simplex-preview-msg-col`, `#directory .entry .text-container`), so that wide blocks scroll inside the message. `copyToClipboard` moved from W/channel-preview.jsc to W/simplex-lib.jsc.
+
+Call sites:
+
+```
+W/channel-preview.jsc:880-887       the welcome message object: markdownBlocks: data.welcomeMessageBlocks
+W/channel-preview.jsc:990           renderMarkdown(shortDescr, data.shortDescriptionBlocks)
+W/channel-preview.jsc:1522          renderMarkdown(msg.formattedText, msg.markdownBlocks)
+W/directory.jsc:232                 shortDescrBlocks, welcomeMessageBlocks from the entry
+W/directory.jsc:251-255             renderMarkdown with the blocks of each list
+W/directory.jsc:254, 260            

kept: set by innerHTML, the block elements become children of the

+``` + +- The meta span after a message with a block at its end (`W/channel-preview.jsc:1522`) is drawn on its own line. +- Click handlers: W/channel-preview.jsc `setupSecretToggles` calls `handleCodeClick`; W/directory.jsc `initDirectory` adds it on `#directory`. + +## 6. Docs + +``` +apps/multiplatform/spec/client/chat-view.md TextItemView.kt: blocks of markdownBlocks drawn as a column (MarkdownBlocks.kt) +apps/ios/spec/client/chat-view.md MsgContentView: the same (MarkdownBlocksView) +``` + +## 7. Tests + +`apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt`: + +``` +decoding each block token in the JSON of the core + each block, a list with nested entries + each TableAlign value + with and without language + with and without number + Bold with and without singleStar; Escape +text ranges blockTextRanges for each block kind: order, inRow of table cells +list entries listEntries: order and levels +headings headingSegments, also with a shortened list +token texts drawnText and previewText of each token kind; Escape; one-text previews of a heading, paragraph and list +selection itemDisplay: displayed and copied texts and bases of a quote and a table; Escape without blocks +``` + +Checks on each platform of the apps: + +1. A sent message with each block kind: drawn by section 3. +2. A received message with each block kind: drawn by section 3. +3. A text without blocks: drawn as before. +4. Inline parts in each block with content: actions as in a paragraph. + +``` +inline parts a link + a mention + a secret + a command +blocks a heading + a list item + a quote + a table cell +``` + +5. A message with blocks in a quote, the chat list preview, the compose context, item info and a notification. +6. Edit of a sent message with blocks: the compose field has the source text. +7. Desktop: selection inside a block, across blocks, across items, and its copy. +8. Live message with an open code fence. +9. Live message with a growing table. +10. Chat link item with text ending with a heading: no empty line at the end of the text. +11. A profile description and a welcome message with a list. +12. iOS: the cases of `MarkdownBlocksTest.kt` drawn equal to Android. + +Web: + +13. A channel page with a message of each block kind. +14. A directory entry with a welcome message with a list. +15. Relay JSON with hostile values: `number` with markup, `columns` with markup, a range out of bounds, a range not of integers, an unknown block type. + +Not automated: + +``` +iOS the Swift views: check 12 only +web script checks 13-15 only +``` + +## Interfaces + +``` +app types Format: Markup, Escape, Bold singleStar + BlockToken + MarkdownBlock + ListEntry + SegmentRange + TableAlign + BlockTextRange +records markdownBlocks: ChatItem, CIQuote, ChatItemVersion, ParsedFormattedText (Kotlin), ParsedMarkdown (Swift) +functions textRanges, blockTextRanges, listEntries, headingSegments + segmentDisplayText, itemDisplay (Kotlin) + parseToMarkdownText (Kotlin), parseSimpleXMarkdownText (Swift) + the segment loop: range; Swift: baseFont, secretStart, nested function appendSegments +views MarkdownBlocks (Kotlin), MarkdownBlocksView and MarkdownView (Swift) +selection ItemSelection per text view (Kotlin) +resources vertical_logo@1x, @2x, @3x.png (Kotlin, from the iOS asset) +files MarkdownBlocksTest.kt, MarkdownBlocks.kt +``` + +## Depends on + +``` +block Markdown plan, "Types" constructors, fields, JSON tags +block Markdown plan, "Pass 2: blocks" ranges +block Markdown plan, "Records and parse sites" fields +release both plans together +``` diff --git a/plans/2026-10-13-block-markdown.md b/plans/2026-10-13-block-markdown.md new file mode 100644 index 0000000000..d5a39caf36 --- /dev/null +++ b/plans/2026-10-13-block-markdown.md @@ -0,0 +1,867 @@ +# Block Markdown: core + +Date: 2026-10-13. Revised: 2026-10-16, for master. Revised: 2026-10-17, two passes over the whole text. Revised: 2026-10-18, CommonMark syntax (S4, S6), soft breaks (S5), block lines then inline content. + +Each message text is parsed in two passes. In pass 1, the lines of the text are grouped into blocks. The content of the lines of a block is joined with "\n" and parsed by the inline parser of master; each "\n" is then replaced by the break token and the syntax tokens of the next line. Pass 2 divides the tokens at the breaks between blocks and builds the block structure. `formattedText` is the joined token list; the text of each token is its source. `markdownBlocks` is the block structure: index ranges into `formattedText`. + +References are to master at 382dc7480. Haskell paths without a directory are in `src/Simplex/Chat/`. + +## 1. Syntax + +### Lines + +``` +line break "\n", with an optional "\r" before it +final one line break at the text end is removed before pass 1, as by T.lines (Markdown.hs:162) +line the text between two line breaks, or between a line break and the text start or end +hard break two or more spaces, or an odd number of backslashes, before a line break, when the next line is a continuation of the block (section 3) + CommonMark 0.31.2 section 6.7, examples 633, 634 +soft break a line break before a line that is a continuation of a paragraph, a list entry or a quote, without a hard break + CommonMark 0.31.2, section "Soft line breaks" +indentation spaces and tabs at a line start + tab stops every 4 columns +blank line white space only +``` + +- Drawing of line breaks in messages with blocks: rendering plan, section 3. + +### Line kinds + +Tested in this order at a line start, at any indentation: + +``` +code line a line inside a code block, until its closing fence +table start a line with "|", when the next line is a delimiter row with the same number of cells +fence 3 or more "`", then an info string without "`" + or 3 or more "~", then an info string + when a line follows it; a fence line at the text end is text +heading 1 to 6 "#", a space, then text with a character other than white space + closing sequence: a space or a tab, one or more "#", then white space only, at the line end +underline "=" only, or "-" only, then white space only, when the open block is a paragraph + CommonMark 0.31.2 section 4.3 (setext heading underline) +rule 3 or more of one of "-", "*", "_", with spaces and tabs between and after them +list item a marker, a space, then text with a character other than white space + marker: "-", "*", "+", or 1 to 9 digits and "." or ")" +quote ">", then text with a character other than white space + or blank quote lines before such a line + after a paragraph line: such a line, or one blank quote line before it +table row a line with "|", while a table is open +blank white space only +paragraph any other line +``` + +- Delimiter row: a line with "|" where every cell, trimmed, is `:?-+:?`. +- The line after a closing fence is tested anew. + +### Blocks + +``` +CodeBlock a fence line, the code lines after it, and the closing fence line + closing fence: at least as many of the character of the opening fence, then white space only, at any indentation + without a closing fence the block is closed at the end of the text + in a code line the white space up to the column of the opening fence is syntax + a tab that crosses that column is syntax + language: the first word of the info string + language is absent for an empty info string +Heading one heading line + level: the number of "#" + the closing sequence is syntax + or: the lines of a paragraph and the underline after them + level: 1 for "=", 2 for "-" +Rule one rule line +List consecutive list items, blank lines between them included +ListEntry a list item line and its continuation lines (section 3) + content: the text after the marker and the space, then the continuation lines without their indentation + number: the digits and the delimiter, as written + number is absent for "-", "*" and "+" +Quote consecutive quote lines, and the paragraph lines that are continuations of them (section 3) + CommonMark 0.31.2 section 5.1, example 247 + text of a quote line: after ">" and one optional space or tab + a text of white space only is syntax + the lines with texts of white space only at its start and at its end are outside its content +Table a table start line, its delimiter row, and the table rows after them + columns: one per delimiter cell + "-" below: one or more "-" + ":-:" TACenter + "-:" TARight + ":-" TALeft + "-" TANone + header: the cells of the table start line + rows: the cells of each table row + cells are divided at each "|" + the "|" at the line start and at the line end are optional + in a row longer than the header, the last cell is the text from the start of its column to the end of the row, without a "|" at the line end + the white space around the text of a cell is syntax +Paragraph consecutive paragraph lines (section 3) + a paragraph line is text as written, indentation included, as now +``` + +List nesting: + +``` +parent the nearest entry before it with a smaller column, in the same List +depth at most 7 (maxListDepth) + an entry below depth 7: added at depth 7 +blank line the List continues +other block the List ends +``` + +Content: + +``` +inline the lines of a paragraph, of a list entry, of a quote: one text (section 3, inlineLines) + the text of a heading line + each cell +code each code line after its syntax: one segment without format +``` + +### Inline formats across line breaks + +Decided 2026-10-17. + +``` +formats Bold, Italic, StrikeThrough, Snippet, Secret, Small, Colored +across a format is continued into the next line when that line is a continuation of the block (section 3) + one segment of the format per line + the line break and the syntax of the next line between them + never across a blank line + "*", "_", "~": across lines when the content does not start or end with white space; otherwise as now, within the line +within headings and cells: within one line + links, SimpleX links, names, mentions, commands, e-mails, phones: within one line +``` + +Bold, decided 2026-10-18: + +``` +*x* Bold, singleStar = True +**x** Bold, singleStar = False +``` + +- `*x*` is drawn as italic in messages with blocks (rendering plan, section 3). + +### Inline syntax of GFM + +Decided 2026-10-18 (S4, S6). In all messages, with and without blocks: + +``` +~~x~~ StrikeThrough GFM example 491 +__x__ Bold, singleStar = False GFM example 391 +`x` Snippet CommonMark section 6.1, GFM example 339 +`` x ` y `` a run of backticks, the content, a run of the same length + one space removed at each end when the content starts and ends with a space and is not spaces only + a space at one end only: kept (CommonMark example 332); spaces only: kept (example 334) + master's check of spaces at the ends (Markdown.hs:231-235): not applied to Snippet + as [x](x) (sowLinkP, Markdown.hs:281-293), its text "" GFM example 603 + x an e-mail address: HyperLink {showText = Just x, linkUri = "mailto:" <> x} +\c c an ASCII punctuation character: Escape "\", then c without format CommonMark examples 12, 14 + not in code blocks and snippets + "\" before a line break: a hard break (section 1, Lines) +``` + +### Text, as now + +``` +raw HTML +images ![alt](uri) +indented code +a list marker inside a quote +a fence inside a quote +a quote marker inside a quote +nested inline formats +``` + +### Incomplete syntax in a live message + +``` +fence line at the text end paragraph line + a code block once a line follows +open code fence code to the end of the text +table header row paragraph line + a table once the delimiter row is complete +rule characters at the text end rule + text once another character is added to the line +block marker before its text text + a block once text is added +open format at the text end text + a format once it is closed +``` + +- Each version of a live message is parsed from its whole text (`Store/Messages.hs:2583`). + +## 2. Types + +`Markdown.hs`: + +```haskell +data Format + = Bold {singleStar :: BoolDef} + | ... -- present constructors, unchanged + | Phone + | Escape + | Markup {token :: BlockToken} + | Unknown {json :: J.Value} + +data BlockToken + = LineBreak + | SoftBreak + | HardBreak + | ParagraphBreak {lineBreaks :: Int} + | Indent + | HeadingMark {level :: Int} + | HeadingClose + | HeadingUnderline {level :: Int} + | ListMark {number :: Maybe Text} + | QuoteMark + | FenceMark {language :: Maybe Text} + | RowMark + | CellMark + | DelimiterRow {columns :: [TableAlign]} + | RuleMark + deriving (Eq, Show) + +data MarkdownBlock + = Paragraph {content :: SegmentRange} + | Heading {level :: Int, content :: SegmentRange} + | CodeBlock {language :: Maybe Text, content :: SegmentRange} + | Quote {content :: SegmentRange} + | List {items :: NonEmpty ListEntry} + | Table {columns :: NonEmpty TableAlign, header :: [SegmentRange], rows :: [[SegmentRange]]} + | Rule {mark :: SegmentRange} + | UnknownBlock {json :: J.Value} + deriving (Eq, Show) + +data ListEntry = ListEntry {number :: Maybe Text, content :: SegmentRange, items :: [ListEntry]} + deriving (Eq, Show) + +data SegmentRange = SegmentRange {from :: Int, to :: Int} + deriving (Eq, Show) + +data TableAlign = TANone | TALeft | TACenter | TARight + deriving (Eq, Show) + +data ParsedMarkdown = ParsedMarkdown + { formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock) + } +``` + +- `FormattedText` is unchanged. +- `MarkdownList` is unchanged: `[FormattedText]`. +- Markup token: a segment with format `Markup`. +- `Escape`: the backslash of a backslash escape; text `"\"`. +- `markdownText` (`Markdown.hs:472-491`): + - a markup token, `Escape`: its text + - `Bold`: `*x*` when `singleStar`, `**x**` otherwise (`Markdown.hs:476`) + - `Snippet`: delimited by a backtick run longer than any backtick run in its text, with a space inside each delimiter when the text starts or ends with a backtick, or starts and ends with a space and is not spaces only (`Markdown.hs:479`) +- `SegmentRange`: `[from, to)` into `formattedText`. +- JSON of `BlockToken` and `MarkdownBlock`: `sumTypeJSON fstToLower`, as `Format` (`Markdown.hs:536`). +- An unknown block tag is decoded as `UnknownBlock`, as in `Format` (`Markdown.hs:538-539`). +- An unknown token tag: the format is decoded as `Unknown`. +- `Bold` without `singleStar` in JSON is decoded with `singleStar = False`: the field type is `BoolDef` (`Types.hs:2313-2318`). +- JSON of `ListEntry`, `SegmentRange` and `ParsedMarkdown`: `defaultJSON`. +- JSON of `TableAlign`: `enumJSON (dropPrefix "TA")`. +- Field names equal to local names in importing modules (`-Werror=name-shadowing`, simplex-chat.cabal:362) are hidden in those imports. + +Values: + +``` +bold {"type":"bold","singleStar":true} +escape {"type":"escape"} +lineBreak {"type":"markup","token":{"type":"lineBreak"}} +softBreak {"type":"markup","token":{"type":"softBreak"}} +hardBreak {"type":"markup","token":{"type":"hardBreak"}} +headingClose {"type":"markup","token":{"type":"headingClose"}} +headingUnderline {"type":"markup","token":{"type":"headingUnderline","level":1}} +paragraphBreak {"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}} +headingMark {"type":"markup","token":{"type":"headingMark","level":2}} +listMark {"type":"markup","token":{"type":"listMark","number":"2."}} +fenceMark {"type":"markup","token":{"type":"fenceMark","language":"bash"}} +delimiterRow {"type":"markup","token":{"type":"delimiterRow","columns":["none","center"]}} +paragraph {"type":"paragraph","content":{"from":1,"to":4}} +codeBlock {"type":"codeBlock","language":"bash","content":{"from":2,"to":5}} +list {"type":"list","items":[{"number":"1.","content":{"from":4,"to":9},"items":[]}]} +table {"type":"table","columns":["none"],"header":[{"from":1,"to":2}],"rows":[[{"from":6,"to":7}]]} +rule {"type":"rule","mark":{"from":2,"to":3}} +``` + +- `language` and `number` are omitted when absent. + +## 3. Pass 1: tokens + +``` +input the text without its final line break +output segments in source order + the concatenation of their texts is the input +``` + +Block parsers, tested in this order at a line start: + +``` +tableP fenceP headingP ruleP listItemP quoteP paragraphP +``` + +- Block start (`blockStartP`): a table start, a fence, a heading line, a rule, a list item line, a quote start. +- Between blocks: `LineBreak`, or `ParagraphBreak` with the blank lines. + +Continuation lines: + +``` +block next line its syntax tokens +paragraph text, not a block start, not an underline none: the indentation is content +list item text, not a block start Indent, when indented +quote a quote line with text QuoteMark + a blank quote line QuoteMark: the whole line + after a line with text: a line with text, none + without ">", not a block start +table a line with "|", not a block start the row tokens +code each line before the closing fence Indent, then the code +``` + +- An unindented line after a list item is a continuation of it (B11; CommonMark 0.31.2 section 5.2, rule 5). +- A paragraph line after a quote line is a continuation of the quote (CommonMark 0.31.2 section 5.1, example 247). + +Inline content of a paragraph, a list item, a quote (`inlineLines`): + +``` +lines the text of each line after its syntax tokens +hard break two or more spaces, or an odd number of "\", at the end of a line with a next line: removed from the line +runs consecutive lines with text; a blank line is a run alone +parse each run joined with "\n", parsed by markdownP; the runs joined with "\n" +breaks each "\n" replaced by the break token and the syntax tokens of the next line + a segment with "\n" is divided there; empty parts without a format are dropped +break HardBreak: after a hard break, or with a blank line before or after it + SoftBreak: otherwise +``` + +- `markdownP` keeps each "\n" of its input in the texts of its segments. + +Texts of the markup tokens: + +``` +LineBreak a line break between two blocks +SoftBreak a soft break (section 1) +HardBreak a hard break (section 1): the spaces or the backslash, and the line break + the line break before and after a blank quote line inside a quote + the line break between the lines of a code block, between the lines of a table, before a setext underline +ParagraphBreak n a line break and the blank lines after it, each with its line break + the blank lines at the text start, with their line breaks + n: the number of "\n" +Indent the indentation of a continuation line of a list item + in a code line: the white space up to the column of the opening fence +HeadingMark the indentation, the "#" run, the space +HeadingClose the closing sequence of a heading line (section 1) +HeadingUnderline the underline line (section 1); level 1 for "=", 2 for "-" +ListMark the indentation, the marker, the space +QuoteMark the indentation, ">", the optional space or tab + in a quote line with a text of white space only: also that text +FenceMark the opening or the closing fence line +RowMark the indentation, the "|" at the line start, the white space before the first cell + empty in a row without them +CellMark after a cell before the last: its white space, the "|", the white space before the next cell + after the last cell: its white space, the "|" at the line end, the white space after it + empty after a last cell without them +DelimiterRow the delimiter row +RuleMark the rule line +``` + +- Inside a code block the lines are divided by `HardBreak` only, blank code lines and spaces at line ends included. +- Spaces at a line end before a line break other than a hard break: content, as now. + +Tokens of a line, in order: + +``` +paragraph line inline content, the indentation included +heading HeadingMark, inline content, HeadingClose when present +underline HeadingUnderline +list item ListMark, inline content +continuation Indent when indented, inline content +quote line QuoteMark, inline content +fence line FenceMark +code line Indent when indented, one segment without format when not empty +table row RowMark, then for each cell: inline content, CellMark +delimiter row DelimiterRow +rule RuleMark +``` + +Changes to `Markdown.hs`: + +``` +parseMaybeMarkdownList (148-167) formattedText of parsedMarkdown +parsedMarkdown new: the command rule (149-157), as now + T.lines (162), intercalate (163): replaced by pass 1 + the join (164-167): joinSegments, markup tokens not joined +documentP, block parsers new (above) +inlineLines new (above) +fragmentP (209) '\n': "\n" without format +'*' case (214) doubleP '*' (Bold False); then formattedP '*' (Bold True) +'_' case (215) doubleP '_' (Bold False); then formattedP '_' Italic +'~' case (216) doubleP '~' StrikeThrough; then formattedP '~' StrikeThrough +'`' case (217) code span (section 1), in place of formattedP '`' Snippet +'<' case new: autolink (section 1); then wordP +'\' case new: escape (section 1); then wordP +formattedP (228-230) content to the delimiter within the line, as now + without the delimiter in the line: across lines (section 1) +boldP (236-241) doubleP: "**", "__", "~~" + white space at the content ends by isSpace +secret (245-249) white space at the content ends by isSpace +styledP (267-272) the space after the style: not "\n" + white space at the content ends by isSpace +sowLinkP (281-293) the format of the link: hyperLinkFormat, shared with autolinks +wordP (320) to a space, "\n", or a backslash before an ASCII punctuation character +secretFallback (251) to a space or "\n" +nameRefP (253) to a space or "\n" +inParens (294) within one line +displayNameTextP_ (512-517) a quoted name: within one line +parseMarkdown (176-177) unchanged: the inline parser, used by msgPlain (View.hs:2474) +``` + +## 4. Pass 2: blocks + +``` +blocks the tokens divided at LineBreak and ParagraphBreak; empty parts dropped +kind by the first token of a block + HeadingMark Heading + ListMark list entry: the column of its indentation, its number + QuoteMark Quote + FenceMark CodeBlock + RowMark Table + RuleMark Rule: mark, the range of the RuleMark token + other Paragraph; Heading when the last token is HeadingUnderline +lists consecutive list entries: one List, nested by column (section 1) +``` + +Ranges: + +``` +content from the first segment without Markup to the last + the breaks and the markup between them included +code from the token after the line break of the opening fence to the line break before the closing fence, excluded + without a closing fence: to the end +cell from the token after its RowMark or CellMark to its CellMark + empty for an empty cell +row from a RowMark to the next RowMark +columns the DelimiterRow +``` + +### Texts without blocks + +Decided 2026-10-17. + +``` +condition pass 1 yields no markup other than LineBreak, SoftBreak, HardBreak and ParagraphBreak + or more than 2000 markup tokens (maxMarkupTokens; decided 2026-10-18) +result each markup token a segment without format, joined by joinSegments + Nothing when no segment has a format, as now + markdownBlocks: Nothing +``` + +- `Escape` is an inline format: kept in texts without blocks. + +### Example + +Text: + +``` +# Plan +- buy *milk* + and eggs + - nested + +> a +> b +``` + +`formattedText`: + +``` +0 headingMark 1 "# " +1 "Plan" +2 lineBreak "\n" +3 listMark "- " +4 "buy " +5 bold "milk" +6 softBreak "\n" +7 indent " " +8 "and eggs" +9 lineBreak "\n" +10 listMark " - " +11 "nested" +12 paragraphBreak 2 "\n\n" +13 quoteMark "> " +14 "a" +15 softBreak "\n" +16 quoteMark "> " +17 "b" +``` + +`markdownBlocks`: + +``` +Heading 1 [1, 2) +List + ListEntry [4, 9) + ListEntry [11, 12) +Quote [14, 18) +``` + +Format across a line break, `"> *a\n> b*"`: + +``` +0 quoteMark "> " +1 bold "a" +2 softBreak "\n" +3 quoteMark "> " +4 bold "b" +Quote [1, 5) +``` + +## 5. Records and parse sites + +`Markdown.hs`: + +```haskell +parsedMarkdown :: Text -> ParsedMarkdown +parseMaybeMarkdownList :: Text -> Maybe MarkdownList -- formattedText of parsedMarkdown +``` + +Field `markdownBlocks :: Maybe (NonEmpty MarkdownBlock)` after `formattedText`: + +``` +ChatItem Messages.hs:265 +CIQuote Messages.hs:614 +ChatItemVersion Messages.hs:1362 +ParsedMarkdown Markdown.hs:143 +WebMessage Web.hs:103 +``` + +Other new fields, of the same type: + +``` +WebChannelPreview Web.hs:114-115 shortDescriptionBlocks, welcomeMessageBlocks +DirectoryEntry Directory/Listing.hs:94-95 shortDescrBlocks, welcomeMessageBlocks +``` + +`(Text, Maybe MarkdownList)` becomes `(Text, ParsedMarkdown)`: + +``` +Library/Internal.hs:365, 368 msgContentTexts, ciContentTexts +Library/Internal.hs:2993 itemTexts +Library/Internal.hs:3030, 3034, 3037 saveRcvChatItem, ciContentNoParse, saveRcvChatItem' +Library/Internal.hs:3088 mkChatItem_ +Library/Internal.hs:3406, 3416 createLocalChatItems +Library/Commands.hs:5155, 5263 ComposedMessageReq and its list +``` + +- `ciContentNoParse` (`Library/Internal.hs:3034-3035`): `ParsedMarkdown Nothing Nothing`. + +Parse sites: + +``` +Store/Messages.hs:693, 2305 CIQuote: parsedMarkdown of the quoted text +Store/Messages.hs:1122, 2334, 2426, 2583 ChatItem: parsedMarkdown of the item text +Store/Messages.hs:3373 ChatItemVersion +Store/Messages.hs:1284, 1630, 1870, 2864, 2936 markdownBlocks = Nothing, with formattedText = Nothing +Library/Internal.hs:226 CIQuote: markdownBlocks of the quoted item +Library/Commands.hs:4931 CIQuote: markdownBlocks of the quoted item +Messages.hs:1369-1379 mkItemVersion: markdownBlocks of the item +Web.hs:252-253, 325 parsedMarkdown of the description texts; its two fields set the two sibling fields of the record + toFormattedText (426-431) removed: equal to parseMaybeMarkdownList +Directory/Listing.hs:143-144, 197-198 toFormattedText returns the list and the blocks; they set the two sibling fields of the record +Store/Messages.hs:693, 2305 mkCIQuote (Messages.hs): CIQuote with the parsed text of its content +Mobile.hs:396 chatParseMarkdown: J.encode . parsedMarkdown . safeDecodeUtf8 +``` + +- In `updatedMentionNames` (`Library/Internal.hs:291-322`), segments are mapped one to one, so the ranges of `markdownBlocks` are valid for its result. + +## 6. Consumers + +Unchanged: + +``` +mentionedNames (Markdown.hs:71-76) a mention in code is text +hasLinks (Markdown.hs:192-193) a link in code is text +styleMarkdownList, styleFormat (Styled.hs:38-47) markup: its text without style (sgr, Styled.hs:71-79) +updatedMentionNames (Library/Internal.hs:291-322) segments mapped one to one +prohibitedSimplexLinks (Library/Internal.hs:426-429) +removeSimplexLink (Library/Internal.hs:1369-1380) the kept text includes the markup texts +hasObfuscatedSimplexLink (Markdown.hs:195-203) a SimpleX link in code is found in the whole text +msgText (Terminal/Output.hs:231-237) +Directory/Listing.hs:52-60 +Directory/Service.hs:599-600 +Directory/Events.hs:106 +msgPlain (View.hs:2474) parseMarkdownList per line, as now +``` + +`Bold {singleStar}` sites: + +``` +sgr (Styled.hs:73) Bold {}: bold, as now +bold' (View.hs:2172) styled (Bold False) +Help.hs:312 markdown (Bold True) "bold text" +``` + +## 6a. Directory service + +``` +spam filter reply (Directory/Service.hs:1160-1163) each command line ends with two spaces: hard breaks between the command lines +tests/Bots/DirectoryTests.hs unchanged: the terminal output of the tests has no trailing spaces +``` + +## 7. Docs and libraries + +`bots/src/API/Docs/Types.hs`: + +``` +chatTypesDocsData, next to :288-289 (sti @BlockToken, STUnion, "", [], "", "") + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", "") + (sti @ListEntry, STRecord, "", [], "", "") + (sti @SegmentRange, STRecord, "", [], "", "") + (sti @TableAlign, STEnum, "TA", [], "", "") +the Format entry (:288) description: markup tokens, their text as source syntax; Escape; Bold singleStar +next to :521-522 deriving instance Generic for BlockToken, MarkdownBlock, ListEntry, SegmentRange, TableAlign +``` + +Written by the "Bot API docs" test (`tests/APIDocs.hs`): + +``` +bots/api/TYPES.md +the type files of the TypeScript library +the type files of the Python library +``` + +## 8. Compatibility + +- Protocol: unchanged. +- Storage: unchanged. +- A peer with an older core shows the source text with inline formats. +- Items stored earlier are parsed when read (`Store/Messages.hs:1122, 2334, 2426`). Texts drawn as blocks after the change: + +``` +text now after +# of users: 5 text heading +8) nice text list item "8)" +- a text list item ++ a text list item +* a text list item +> cmd text quote +--- text rule +*** text rule +___ text rule +a line with "|" above "-|-" text table +*a text bold, across lines +b* +Title text heading 2 +--- +Title text heading 1 +=== +~~~ fences text code block +\# a text "# a", the "\" not drawn +~~x~~ text strike-through +__x__ text bold +``x`` text code +` a ` text code "a" + text link +``` + +- A quote continued by a line without ">": the line is drawn in the quote. + +- In texts with blocks, after the change: `*x*` drawn as italic; single line breaks in paragraphs, list items and quotes drawn as spaces (rendering plan, section 3). +- `has_link` of earlier items is unchanged. +- A link in code is text. +- A SimpleX link in code is found by `hasObfuscatedSimplexLink` (`Library/Internal.hs:429, 1375, 1380`). +- A SimpleX link in code of a compose field: not found by the app checks of the segments (`ComposeView.kt:435-445`, `ShareListView.kt:81-84`, `ChatUtils.swift:104-113`); the core rejects the message where SimpleX links are prohibited (`Library/Internal.hs:429`), accepted. +- A group link in code of a directory group description: not found by `descriptionContainsLink` (`Directory/Listing.hs:60`); the link line is added to the listing (`Directory/Listing.hs:129-134`), accepted. +- A format across lines, rewritten by `updatedMentionNames`: delimiters on each line, `"> *a\n> b*"` written as `"> *a*\n> *b*"`. + +Costs: + +``` +JSON each line break and each block syntax: one markup segment, in texts with blocks + markdownBlocks: the ranges of the blocks +parsing one parse of the whole text, on each read of an item +``` + +Consumers of earlier releases: + +``` +web pages a markup token: its text, by the default case (website/src/js/simplex-lib.jsc:119-120) + new fields ignored +bots the text of each segment, as now + the Format union has one more type; records have new fields +apps remote desktop only: minRemoteCtrlVersion and minRemoteHostVersion (Remote.hs:77-82) are the version of the release, set at its version change +``` + +## 9. Tests + +`tests/MarkdownTests.hs`: + +``` +operators ==>> (:48-49), <<== (:51-52), <<==>> (:54-55): unchanged + multilineMarkdownList (:463-481): unchanged +new operator ===> for parsedMarkdown: formattedText and markdownBlocks +new spec blockMarkdown +``` + +`blockMarkdown`, the tokens and the blocks of each text: + +``` +heading each level + "#", "# ", "#x", "#\tx#": no blocks + closing sequence: "# a #", "# a ## "; "# a#": "a#" as content +setext "a\n===": heading 1; "a\n---": heading 2; "a\nb\n=": heading 1 of two lines + "- - -" under a text line: rule + "===" under a list item, a quote, a heading, at the text start: text +list each bullet marker + numbers as written, with each delimiter + nesting by 2, 3 and 4 columns + tab indentation + depth above 7 + an indented continuation line + an unindented continuation line + a blank line between items: one list + a paragraph after a list + a list item after a heading +quote ">" with and without a space after it + ">" alone: no blocks + "> a\n>\n> b": one quote with an empty line + ">\n> a\n>": the empty lines outside the content + "> > b": "> b" is text + "> a\nb": one quote, SoftBreak + "> a\nb\n> c": one quote + "> a\n\nb": a quote and a paragraph +code closed + "~~~" fences; "```" not closed by "~~~"; an info string with "`" after "~~~" + open at the end of the text + an empty code line, blank code lines at its end + an info string of two words + indentation of the fence + a tab across the column of the fence + a closing fence with white space after it + inline syntax in code: text + "```", "```\n", "a\n```": no blocks + "```\n```", "```\n\n```", "```\n \n```", "```\n```\nx": code blocks +rule each character + "- - -" + "---" under a text line +table each alignment + a row shorter than the header + a row longer than the header, with and without "|" at its end + an empty cell, a cell of white space only, "||" between cells + rows without "|" at the line start: empty RowMark + indented rows + "a | b\n- | -": a table + a header row without a delimiter row: no blocks + a table ended by a line without "|" +paragraph a paragraph line with indentation: Indent as text + blank lines before the first block and after the last block +across bold, italic, strike-through, snippet, secret, colored across a line break: in a paragraph, a quote, a list item + not across a blank line, a heading line, a cell + an unclosed format: the delimiter and the rest of the line as text; formats on the next lines kept + a delimiter with white space at the content end across lines: as now, within the line +breaks ParagraphBreak counts + CR before each line break, in each block kind + final line break removed + hard break: two and three spaces, and a backslash, before a line break in a paragraph, a list item, a quote + one space before a line break: SoftBreak, the space as content + two spaces or a backslash before a blank line, at the text end, in code: content + SoftBreak in a paragraph, a list entry, a quote; HardBreak between table rows and code lines; LineBreak between blocks +escapes "C:\dir\file.txt": text, one word +bold "*x*": singleStar True + "**x**", "__x__": singleStar False +gfm "~~x~~": StrikeThrough + "`` x ` y ``": Snippet "x ` y"; "```a```" in a line: Snippet "a"; "`` a`": text + "` a `": Snippet "a"; "` a`": Snippet " a"; "` `": Snippet " "; "` a `": Snippet " a " + "": HyperLink, text ""; "": HyperLink to "mailto:a@b.c"; "": text +escapes "\*a*": Escape, "*a*" as text; "\# a" at a line start: no heading + "a\_b": Escape inside a word; "\a": text + in code and snippets: text + in a text without blocks: kept +command "/link 1": as now +live "```", then "```\nls": no blocks, then a code block + "---", then "---x" + an opening fence, then three backticks in the same line + a delimiter row, then more text in it +``` + +Properties, for each text above: + +``` +source T.concat (map markdownText formattedText) is the text without its final line break, except: + colour names written as digits, as now (Markdown.hs:295-309, 494-503) + formats across lines, delimited on each line; an empty part at a line break written as empty delimiters + ~~x~~ written ~x~ + __x__ written **x** + code spans written with the delimiters of markdownText (section 2) +ranges within formattedText, in source order, not overlapping +content each segment without markup is in one content range or one cell +``` + +Consumers: + +``` +mentionedNames a mention in a list item, a quote, a heading, a table cell is found + a mention in code and in a snippet is not found +hasLinks a URI in a list item and a table cell is found; in code not found +SimpleX links in a list item and a table cell: a SimplexLink segment + in code: no segment; found by hasObfuscatedSimplexLink + the text without SimpleX link segments, as removeSimplexLink (Library/Internal.hs:1369-1380) +markdownText each markup token +``` + +- The consumers above are tested on parsed text in `tests/MarkdownTests.hs`; chat tests are not added. + +Other modules: + +``` +tests/ChatTests/Groups.hs:8427 testUpdatedMentionNames + text: a list, a table, a code block + a renamed mention outside code + the same mention in code + the new text: the source with the new name outside code +tests/JSONFixtures.hs a message with a heading, a list and a bold segment, in both encodings +tests/JSONTests.hs the fixtures, for owsf2tagged + a round trip of each block and each markup token + an unknown block tag is UnknownBlock +``` + +Linear time: + +``` +phone numbers the scan ends after 22 characters (master: to the end of the digit groups) +link text "[x]" the scan ends at "[" or a line break +link target "(x)" the scan ends at white space +autolink "" the scan ends at "<" or white space +segment joining each run of equal formats joined once (T.concat) +block starts a table start: two lines; a fence: one line and a line break +table cells one pass over the row and cell marks +``` + +- Tests: the bounded scans keep master's results; texts of 4000 repetitions of each adversarial pattern are parsed; 1000 list items have blocks, 1001 have none. + +## Interfaces + +``` +types Format: Markup, Escape, Bold {singleStar} + BlockToken + MarkdownBlock + ListEntry + SegmentRange + TableAlign + ParsedMarkdown: markdownBlocks +records markdownBlocks: ChatItem, CIQuote, ChatItemVersion, WebMessage + shortDescriptionBlocks, welcomeMessageBlocks: WebChannelPreview + shortDescrBlocks, welcomeMessageBlocks: DirectoryEntry +functions parsedMarkdown + parseMaybeMarkdownList: formattedText of parsedMarkdown +constants maxListDepth 7 + maxMarkupTokens 2000 +``` + +`2026-10-13-block-markdown-rendering.md`: + +``` +the Kotlin types +the iOS types +the web page script +the drawing of the blocks +``` diff --git a/plans/2026-10-15-ci-build-cache.md b/plans/2026-10-15-ci-build-cache.md new file mode 100644 index 0000000000..b6b6d34d17 --- /dev/null +++ b/plans/2026-10-15-ci-build-cache.md @@ -0,0 +1,243 @@ +# CI build cache + +Repository: simplex-chat/simplex-chat. +Branch: `ep/ci-build-cache`. +Model: simplexmq at `e91711c7`: +- `.github/workflows/build.yml` +- `scripts/ci/source-timestamps.sh` +- `scripts/ci/prune-old-builds.sh` + +Repository cache limit: 80 GB. + +## Files + +- `.github/workflows/build.yml` +- `.github/actions/prepare-build/action.yml` +- `scripts/ci/source-timestamps.sh`: copied from simplexmq. +- `scripts/ci/prune-old-builds.sh`: new. +- `scripts/desktop/build-lib-linux.sh`: build directory from `DIST_DIR`, default `dist-newstyle`. + +## Job environment + +Jobs: build-linux, build-linux-postgres, build-macos, build-windows. + +opt +- pull request: `O0` +- other events: `O2` + +ghc_options +- pull request: `--ghc-options=-O0` +- other events: empty + +release +- `v*` tag: `true` + +branch +- pull request: base branch +- other events: ref name + +cache_o0 +- push to master or stable: `true` + +ZSTD_CLEVEL +- `9`: zstd level of cache archives + +## Cache entries + +deps +- path: cabal store + - build-linux, build-linux-postgres: `~/.cabal/store` + - build-macos, build-windows: output `cabal-store` of prepare-build +- key: `deps---` +- restore key 1: `deps---` +- restore key 2: `deps--` +- saved when the store hash differs from the matched key + +toolchain +- job: build-macos +- path: + - `~/.ghcup/ghc/` + - `~/.ghcup/bin/*-` + - `~/.ghcup/bin/cabal-3.10.2.0` +- key: `ghcup---ghc-cabal3.10.2.0-` +- restored before "Prepare build" +- saved after "Prepare build" when the key is not matched + +src +- jobs: build-linux, build-macos, build-windows +- path: `dist-newstyle/src` +- key: `src--` +- restore key: `src--` +- saved when the matched key differs from the key, by: + - build-linux: ubuntu-22.04 x86_64, GHC 9.6.3 + - build-macos: x86_64 + - build-windows + +dist +- jobs: build-linux, build-macos, build-windows +- path: + - `dist-newstyle/*` + - `!dist-newstyle/src` +- key: `dist----` +- restore key 1: `dist----` +- restore key 2: `dist---` +- restored except on libs release pushes in build-macos and build-windows +- saved when the matched key differs from the key + +dist-lib +- job: build-linux +- path: + - `dist-lib/*` + - `!dist-lib/src` +- key: `dist-lib---` +- restore key 1: `dist-lib---` +- restore key 2: `dist-lib--` +- restored on pull requests that change the chat core, `libs` matrix entries, x86_64 +- saved when the library build succeeds and the matched key differs from the key + +store hash +- first 16 hex characters of sha256 of the store's package directory listing + +target +- build-linux: `ubuntu---ghc-` +- build-linux-postgres: `ubuntu-22.04-x86_64-postgres-ghc-` +- build-macos: `--ghc-` +- build-windows: `windows-x86_64-ghc-` + +toolchain +- Linux: first 16 hex characters of sha256 of the installed `binutils*`, `cpp*`, `gcc*` and `*-dev` package versions in the container +- macOS: first 16 hex characters of sha256 of `sw_vers -productVersion` and `clang --version` +- Windows: first 16 hex characters of sha256 of MSYS2 `gcc --version` + +Release tags: caches are neither restored nor saved. +Each missed restore: `::warning title=Cache miss::` annotation. + +## build-linux + +Job permissions +- `contents: write` +- `packages: write` + +Build image +- registry cache: `ghcr.io/simplex-chat/simplex-chat-build-cache:--ghc-` +- read: every build except release tags +- written: pushes to master, `mode=max`, `ignore-error=true` +- login: `docker login ghcr.io` with `GITHUB_TOKEN`, every build except release tags + +Container +- volume: `~/.cabal:/home/builder/.cabal` +- environment: `CABAL_DIR=/home/builder/.cabal` + +Steps after "Start container": +1. Compute cache keys. +2. Restore deps. +3. Restore src. +4. Restore dist. +5. Restore dist-lib. +6. GHC 8.10.7, dist restored: `scripts/ci/source-timestamps.sh restore dist-newstyle/sources.sha256`. +7. Prepare cabal.project.local: unchanged. +8. Build CLI: + - `scripts/ci/prune-old-builds.sh dist-newstyle` + - `cabal update` + - `cabal build -j --enable-tests ` + - binaries copied to `/out` + - `cabal clean` removed +9. Build library: + - `scripts/ci/prune-old-builds.sh dist-lib` + - `dist-lib/src`: symlink to `../dist-newstyle/src` + - `DIST_DIR=dist-lib` +10. GHC 8.10.7: `scripts/ci/source-timestamps.sh record dist-newstyle/sources.sha256`. +11. Save deps. +12. Save src. +13. Save dist. +14. Save dist-lib. +15. Release and test steps: unchanged. +16. -O0 refresh, when `cache_o0`, the run is not cancelled and "Build CLI" succeeded: + - remove `dist-newstyle` contents except `src` + - restore dist with opt `O0` + - GHC 8.10.7: restore source timestamps + - `scripts/ci/prune-old-builds.sh dist-newstyle` + - `cabal build -j --enable-tests --ghc-options=-O0` + - GHC 8.10.7: record source hashes + - save dist with opt `O0` + +"Fix permissions for cache": removed. + +## build-linux-postgres + +- condition: unchanged +- caches: deps +- container: volume and environment as in build-linux +- `cabal clean` removed +- "Fix permissions for cache": removed + +## build-macos and build-windows + +prepare-build action +- inputs `os` and `cache_path`: removed +- "Restore cached build": removed +- output `cabal-store`: output `cabal-store` of setup-haskell-action + +Steps: +1. build-macos: restore toolchain. +2. Prepare build. +3. build-macos: save toolchain. +4. Compute cache keys. +5. Restore deps. +6. Restore src. +7. Restore dist. +8. Build CLI: + - `scripts/ci/prune-old-builds.sh dist-newstyle` + - `` +9. Save deps. +10. Save src. +11. Save dist. +12. build-macos "Run tests": aarch64 only, `cabal test` with ``. +13. Other steps: unchanged. +14. -O0 refresh as in build-linux, after all other steps. + +build-macos +- step before "Prepare build": `/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk` linked to `xcrun --sdk macosx --show-sdk-path` +- Build CLI: `bin_path` from the last line of `cabal list-bin simplex-chat` + +build-windows +- cache key and store hash steps: `msys2` shell +- store hash: store path converted with `cygpath -u` +- keeps the `dist-newstyle/src` removals +- -O0 refresh: + - skipped on libs release pushes + - runs `scripts/desktop/prepare-openssl-windows.sh` before the build + +## prune-old-builds.sh + +Arguments: build directories. +In each `

/build/*/*/`: +- `simplex-chat-`: deleted unless the version equals `simplex-chat.cabal` + +Version: carriage returns removed. + +## Verification + +Baseline, run 38730229359: +- Linux "Restore cached build": 0-4 s +- Linux "Build and cache Docker image": 3.5-5 min +- Linux "Build CLI", GHC 9.6.3: 23-30 min +- Linux "Build CLI", GHC 8.10.7: 80 min +- macOS "Build CLI": 1.5-30 min +- Windows "Build CLI": 23 min + +Test pull request: `ep/ci-build-cache` into master. + +On the second run of the test pull request: +1. build-linux, build-macos and build-windows restore deps and dist. +2. "Build CLI" compiles only modules changed since the first run. +3. GHC 8.10.7 "Build CLI" compiles only changed modules. +4. Tests pass. + +After merge, on the first push to master: +1. build-linux, build-macos and build-windows save deps, dist with opt `O2` and dist with opt `O0`. +2. build-linux writes the image cache. + +On the next pull request: +1. build-linux, build-macos and build-windows restore dist with opt `O0` from master. +2. build-linux reads the image cache. diff --git a/plans/2026-10-18-package-releases.md b/plans/2026-10-18-package-releases.md new file mode 100644 index 0000000000..286ab2cf26 --- /dev/null +++ b/plans/2026-10-18-package-releases.md @@ -0,0 +1,364 @@ +# Package releases + +Repository: simplex-chat/simplex-chat. +Prerequisite: PR #7750 merged; master runs are not cancelled. +Scope: releases in simplex-chat-libs, npm and PyPI. +App releases (`v*` tags in simplex-chat): unchanged. + +## Releases + +| Release | Repository | Name | Released when | Assets | +|---|---|---|---|---| +| libsimplex | simplex-chat-libs | `core-` | push to master or stable claims the version for its branch | `LIBS_ASSETS` | +| `@simplex-chat/types` | npm | version of its `package.json` | library release push, version absent from npm | | +| add-on | simplex-chat-libs | `nodejs-addon-<7 characters of the pushed commit sha>` | push to master or stable, published release of the add-on hash absent | `ADDON_ASSETS` | +| `simplex-chat` (npm) | npm | version of its `package.json` | push to master or stable, version absent from npm | | +| `simplex-chat` (PyPI) | PyPI | `__version__` of `_version.py` | push to master or stable, version absent from PyPI | | + +Release states in simplex-chat-libs: + +``` +absent +draft created before the build jobs; assets uploaded by the build jobs +published every asset present; immutable +``` + +Add-on hash: output of the current `addon_tag` function without its prefix. +Add-on release notes: `sources: `. + +Types source: `packages/simplex-chat-client/types/typescript/src`. +Python types source: `packages/simplex-chat-python/src/simplex_chat/types`. + +## Library claim + +Claim: entry `core..` = simplex-chat commit in `releases.toml` of simplex-chat-libs. +Branches: master, stable. +Written through the contents API with the blob sha; a concurrent writer re-reads after 409. + +Rows in order of evaluation: + +| `core..` | `claim-core` result | +|---|---| +| version lower than the highest version recorded for the branch | exit 1 | +| version recorded on any branch for this commit | `resume` | +| version recorded on any branch for another commit | `skip` | +| absent, types check failed | exit 1 | +| absent, types check passed | `new`: entry written | + +Previous core release: commit of the highest version recorded for the branch. + +Types check: + +``` +types changed types source differs from the previous core release +passed types unchanged + or types version unrecorded, absent from npm, simplexChatCore = +``` + +## Types versions + +``` +scheme ., as simplex-chat (npm) +first 7.1.0-beta.6.1, from core-release --types-version +next bump_npm_version of the types version at the previous core release +npm tag dist_tag: beta for a prerelease version, latest otherwise +``` + +## Manual settings + +1. simplex-chat-libs: immutable releases enabled. +2. npm trusted publisher of `simplex-chat`: workflow `build.yml`. +3. npm trusted publisher of `@simplex-chat/types`: workflow `build.yml`. +4. PyPI trusted publisher of `simplex-chat`: workflow `build.yml`. +5. Secret `RELEASE_PR_TOKEN`: deleted. +6. PR #7732: closed; branch `packages-release/master`: deleted. +7. `releases.toml` entry added: `core.master."7.1.0.11"` = `9711dcfc9c39ba125c1fb1674a3e039b1b9c0e81`. +8. Notes of release `nodejs-addon-308c0960c707`: `sources: 308c0960c707`. + +## Files + +- `scripts/ci/packages_release.py` +- `scripts/ci/test_packages_release.py` +- `scripts/ci/package-tests.sh`: new. +- `.github/workflows/build.yml` +- `.github/workflows/packages.yml` +- `packages/simplex-chat-client/types/typescript/package.json`: field `simplexChatCore`, `7.1.0.11`. +- `packages/simplex-chat-client/types/typescript/README.md`: line `SimpleX Chat core: 7.1.0.11`. + +## packages_release.py + +Asset set by tag prefix: + +``` +core- LIBS_ASSETS +nodejs-addon- ADDON_ASSETS +``` + +Commands: + +``` +core-release [--branch ] [--types-version ] + run locally + branch: the checked out branch by default + version not higher than the highest version recorded for the branch: exit 1 + version: of simplex-chat.cabal written + types changed or --types-version: types version written, as in Types versions + types version written: simplexChatCore and the README line written +claim-core + run in maybe-release + Library claim; prints claim=new, claim=resume or claim=skip + new: also prints commit= +release-status + prints status=published, status=draft or status=absent + drafts: from the authenticated release list +addon-release + releases nodejs-addon-* with notes "sources: " + published one: prints tag= and status=published + draft: prints tag= and status=draft + neither: prints status=absent +latest-libs [--branch ] + prints tag= + order: numeric components of the version +publish-release + draft with every asset of its set: published + missing assets: listed, exit 1 +check-pins + pins of release.ts and _version.py compared with the digests of published releases + mismatch, missing asset or unpublished release: listed, exit 1 +download + asset saved to / + SHA-256 compared with the API digest; mismatch: exit 1 +download-pinned + tag and SHA-256 from release.ts + asset saved to /; mismatch: exit 1 +prepare [nodejs] [python] [--branch ] + run locally + branch: the checked out branch by default + nodejs: simplex-chat (npm); python: simplex-chat (PyPI) + packages: the listed ones; all when none is listed + other package: pins and version unchanged + library pin: latest-libs of the branch + warning: core- newer than the pinned release + nodejs add-on pin: addon-release; exit 1 unless published with every asset + nodejs types dependency: ^; exit 1 unless published on npm + python: exit 1 when the Python types source differs from the commit recorded for the pinned core version + every pinned asset: download, SHA-256 compared with the API digest + changed package: directory without tests differs from the commit that set its version + versions: bumped as now; --nodejs-version, --python-version override + written: release.ts, _version.py, package.json of simplex-chat (npm) + summary: printed; --summary and --types-version removed +build-plan + pull requests only + --event and --release-missing removed +``` + +Commands removed: `addon-tag`. +`record`: option `--branch` of `--core`, default master; replaced by `claim-core` in maybe-release. +Unchanged: `changes`, `publish-plan`, `test-names`. + +`build_plan(core_changed, addon_changed)`: + +``` +core changed, add-on changed platforms: all; test: false +core changed, add-on unchanged platforms: none; test: false +core unchanged platforms: all; test: true +``` + +## package-tests.sh + +Commands from the build steps of `packages.yml` (lines 147-166). + +``` +build + types, npm package and add-on built + add-on copied to packages/simplex-chat-nodejs/build/simplex-chat-nodejs-.node +test + jest, as in packages.yml; SIMPLEX_LIBS_DIR and SIMPLEX_ADDON_PATH from the environment + pytest +addon + build + simplex-chat-libs-.zip of $LIBS_RELEASE: packages_release.py download, unpack to $RUNNER_TEMP/libs + jest with SIMPLEX_LIBS_DIR=$RUNNER_TEMP/libs/libs + add-on uploaded to draft $ADDON_TAG of $LIBS_REPOSITORY: gh release upload --clobber, token $GH_TOKEN +publish [nodejs] [python] + nodejs: types and npm package built + download-pinned simplex-chat-nodejs-.node to $RUNNER_TEMP/addon + add-on copied to packages/simplex-chat-nodejs/build/Release/simplex.node, the path of the tests + jest + python: pytest + run in CI and locally +``` + +## build.yml + +### maybe-release + +Outputs: + +``` +libs_tag core-, when the claim is new or resume +addon_tag tag of the add-on draft +addon_draft true when the add-on release is a draft after this job +libs_latest output tag of latest-libs +publish_types libs_tag set, types version printed by publish-plan +publish_nodejs nodejs version printed by publish-plan +publish_python python version printed by publish-plan +publish publish_nodejs or publish_python +``` + +Steps after "Create release": + +| Step | Condition | Action | +|---|---|---| +| Library release | push to master or stable | `claim-core `; new → `gh release create core- --draft --target `; resume → existing draft, `libs_tag` empty when published; skip → notice annotation | +| Add-on release | push to master or stable | `addon-release`: absent → `gh release create nodejs-addon-<7 characters of github.sha> --draft --notes "sources: "`; draft → reused; output `addon_draft` true when absent or draft | +| Latest library | `addon_draft` | `latest-libs --branch ` | +| Versions to publish | push to master or stable | `publish-plan`; environment `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` as in `packages.yml` | + +Steps removed: "Check version change", "Record libs release", "Create libs release". +"Clone project": `fetch-depth: 2`, unchanged; the types check fetches the commit of the recorded types version. +Token of the release steps: `NODEJS_REPO_TOKEN`. +Library release jobs: concurrency groups with `libs_tag`, unchanged. + +### Library uploads + +Steps "Upload library" (build-linux, build-macos, build-windows) and "Upload postgres library": + +``` +action gh release upload --clobber --repo -libs +token NODEJS_REPO_TOKEN +conditions unchanged +``` + +### Package steps in build jobs + +Platform jobs: + +``` +build-linux matrix.libs entry linux-x86_64 +build-linux-push matrix.libs entry linux-aarch64 +build-macos both entries macos- +build-windows windows-latest windows-x86_64 +``` + +Steps, after "Run tests" (build-linux, build-macos) and after "Upload library" (build-windows): + +| Step | Condition | +|---|---| +| Setup Node.js 22 | `!cancelled()`; core pull request condition, `addon_draft` or `publish` | +| Setup Python 3.12 | `!cancelled()`, Setup Node.js succeeded | +| Package tests (pull request) | build-linux only, as now; `package-tests.sh build linux-x86_64`, then `test` with SIMPLEX_LIBS_DIR | +| Add-on | `!cancelled()`, `addon_draft`, Setup Python succeeded; `package-tests.sh addon ` | +| Package tests | `!cancelled()`, `publish`, Setup Python succeeded; `package-tests.sh publish `, with `nodejs` when `publish_nodejs`, with `python` when `publish_python` | +| Package tests result | Package tests succeeded; artifact `package-tests-` | + +Linux steps: on the host. + +Environment of "Add-on": + +``` +LIBS_RELEASE libs_latest +ADDON_TAG addon_tag +LIBS_REPOSITORY -libs +GH_TOKEN NODEJS_REPO_TOKEN +``` + +### Final jobs + +``` +releases + needs maybe-release, build-linux, build-linux-push, build-linux-postgres, build-macos, build-windows + if !cancelled(), libs_tag or addon_draft + step 1 publish-release ; libs_tag + step 2 publish-release ; !cancelled(), addon_draft + token NODEJS_REPO_TOKEN +publish + needs maybe-release, build-linux, build-linux-push, build-macos, build-windows, releases + if !cancelled(), publish or publish_types + permissions contents: read; id-token: write + env as in packages.yml: NODE_AUTH_TOKEN, NPM_SCOPE, PYPI_PROJECT, PYPI_URL + step 1 when publish: artifacts package-tests-*, 5 required + step 2 when publish: check-pins + steps "publish" job of packages.yml, from "Versions to publish" + types step publish_types and needs.releases.result == 'success' +record + as in packages.yml; needs publish +``` + +Comments of the `publish` job of `packages.yml`: moved with it. +Comment of the `record` job: removed. +Trusted publishing comment (`packages.yml` lines 12-15): moved above `publish`. + +Job removed: `packages`, with its header comment. + +## packages.yml + +``` +triggers pull_request, paths unchanged + push, workflow_dispatch, workflow_call: removed +addon job steps "Add-on release tag", "Release branch", "Check the add-on release": removed + outputs tag, release_branch, missing: removed + Build plan: --event and --release-missing removed +build job Checkout, Setup Node.js, Setup Python + package-tests.sh build + package-tests.sh test, when planned + "Upload add-on artifact": removed +jobs removed publish, record, release +header lines 3-10 and 17-28: removed +``` + +## Release procedure + +Library and types: + +1. `python3 scripts/ci/packages_release.py core-release ` on master or stable, with `--types-version` for a new app version. +2. Commit of the written files; push to the same branch. +3. `build.yml`: claim, draft, uploads by the build jobs, `releases`, types publication. +4. Failed claim: fix commit; the next push claims the version. +5. Incomplete draft: re-run of the failed build jobs, then of `releases` and `publish`. + +Add-on: + +1. Push to master or stable changes the add-on sources. +2. Incomplete draft: completed by a re-run or by the next push. + +Packages, published by GitHub: + +1. `git pull` on master or stable. +2. `python3 scripts/ci/packages_release.py prepare`, with package names and version overrides when needed. +3. Commit of the written files; push to the same branch. +4. Versions absent from the registries: published by `build.yml`. + +Packages, published locally: + +1. Steps 1 and 2 above. +2. `scripts/ci/package-tests.sh publish [nodejs] [python]`. +3. `npm publish` of `simplex-chat`; `python -m build` and `twine upload` of the Python package. +4. Step 3 above; `build.yml` skips the published versions. + +Published npm package: `files` of `package.json` and the pins; add-on and libsimplex downloaded at run time. + +## Verification + +1. Unit tests: + - `claim-core`: every row of the Library claim table + - types check: changed and unchanged types, recorded and unrecorded types version + - `core-release` with and without types changes + - `release-status` + - `addon-release` notes lookup + - `prepare` with one package listed + - `prepare python` with changed Python types + - `latest-libs` order + - `publish-release` with missing assets + - `check-pins` mismatch + - `download` digest mismatch + - `build_plan` cases +2. `gh release upload` to a draft by tag: `shared.FetchRelease` of gh looks up the draft by tag (`fetchDraftRelease`). +3. First library release after the merge: assets uploaded to the draft, release published by `publish-release`. +4. First master push after merge: claim `skip`, as `core-7.1.0.11` is recorded for `9711dcfc`; `addon-release` finds `nodejs-addon-308c0960c707`; package steps skipped. +5. Local `prepare`: pins `core-7.1.0.11` and `nodejs-addon-308c0960c707`; every download matches. +6. `core-release 7.1.0.12 --types-version 7.1.0-beta.6.1`, commit, push: claim `new`, draft, 6 assets uploaded, release published, types 7.1.0-beta.6.1 published with tag beta. +7. Master push with an add-on source change: draft created, 5 assets uploaded, release published. +8. Master push with versions from `prepare`: 5 artifacts `package-tests-*`, pins checked, versions published, versions recorded. +9. Second push during the run of step 6: claim `skip`. +10. Stable push after master is merged into stable, with a stable core version: claim `new` for `core.stable`. diff --git a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw new file mode 100644 index 0000000000..3e48751677 --- /dev/null +++ b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw @@ -0,0 +1,250 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw-jetbrains-plugin", + "elements": [ + {"id":"hdr-bg","type":"rectangle","x":0,"y":0,"width":2500,"height":96,"strokeColor":"transparent","backgroundColor":"#1c1e26","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["header"],"index":"a000"}, + {"id":"hdr-title","type":"text","x":40,"y":24,"width":620,"height":28,"strokeColor":"#ffffff","roughness":0,"groupIds":["header"],"text":"Buying a badge - hidden codes","fontSize":22,"fontFamily":2,"index":"a001","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"hdr-sub","type":"text","x":40,"y":58,"width":1500,"height":15,"strokeColor":"#a0a6b0","roughness":0,"groupIds":["header"],"text":"the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not","fontSize":12,"fontFamily":2,"index":"a002","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"hdr-r1","type":"text","x":2260,"y":26,"width":200,"height":15,"strokeColor":"#a0a6b0","roughness":0,"groupIds":["header"],"text":"in-app checkout","fontSize":12,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a003","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"hdr-r2","type":"text","x":2260,"y":48,"width":200,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["header"],"text":"wireframes","fontSize":11,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a004","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"l1-band","type":"rectangle","x":20,"y":150,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#eef5ff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane1"],"index":"a010"}, + {"id":"l1-head","type":"text","x":60,"y":178,"width":900,"height":22,"strokeColor":"#0053d0","roughness":0,"groupIds":["lane1"],"text":"Non-store builds - the browser does all of it","fontSize":17,"fontFamily":2,"index":"a011","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l1-sub","type":"text","x":60,"y":206,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane1"],"text":"no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home","fontSize":11,"fontFamily":2,"index":"a012","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-panel","type":"rectangle","x":60,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"boundElements":[{"id":"ar-a1a2","type":"arrow"}],"index":"a020"}, + {"id":"a1-title","type":"text","x":80,"y":306,"width":260,"height":24,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"Support SimpleX","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a021","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a1-body","type":"text","x":76,"y":348,"width":268,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A1","lane1"],"text":"Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a022","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-info","type":"text","x":80,"y":404,"width":260,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"(i) How badges protect your privacy","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a023","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-hero","type":"rectangle","x":132,"y":448,"width":156,"height":156,"strokeColor":"transparent","backgroundColor":"#e8eef7","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"index":"a024"}, + {"id":"a1-herot","type":"text","x":132,"y":519,"width":156,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A1","lane1"],"text":"phone-supporter","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a025","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a1-btn","type":"rectangle","x":90,"y":730,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"boundElements":[{"id":"a1-btnt","type":"text"}],"index":"a026"}, + {"id":"a1-btnt","type":"text","x":100,"y":742,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A1","lane1"],"text":"Buy in browser","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a1-btn","index":"a027","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-sec","type":"text","x":80,"y":786,"width":260,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"Redeem code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a028","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-secn","type":"text","x":80,"y":806,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A1","lane1"],"text":"the manual path, for a code from elsewhere","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a029","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a1-capt","type":"text","x":60,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A1","lane1"],"text":"A1. BadgesSupportSimplexView","fontSize":11,"fontFamily":2,"index":"a02a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-capb","type":"text","x":60,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A1","lane1"],"text":"Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.","fontSize":9,"fontFamily":2,"index":"a02b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a2-panel","type":"rectangle","x":480,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"boundElements":[{"id":"ar-a1a2","type":"arrow"},{"id":"ar-a2a3","type":"arrow"}],"index":"a030"}, + {"id":"a2-url","type":"rectangle","x":480,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a031"}, + {"id":"a2-urlt","type":"text","x":496,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A2","lane1"],"text":"badges.simplex.chat/#/tier?app=true","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a032","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a2-title","type":"text","x":496,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Choose your badge","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a033","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a2-lede","type":"text","x":496,"y":342,"width":268,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A2","lane1"],"text":"Larger files that stay available longer.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a034","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a2-c1","type":"rectangle","x":496,"y":378,"width":130,"height":150,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a035"}, + {"id":"a2-c1i","type":"rectangle","x":545,"y":398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a036"}, + {"id":"a2-c1t","type":"text","x":504,"y":442,"width":114,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Supporter\n2GB files\n\n$7/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a037","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a2-c2","type":"rectangle","x":634,"y":378,"width":130,"height":150,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a038"}, + {"id":"a2-c2i","type":"rectangle","x":683,"y":398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a039"}, + {"id":"a2-c2t","type":"text","x":642,"y":442,"width":114,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Legend\n5GB files\n\n$70/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a03a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a2-btn","type":"rectangle","x":496,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"boundElements":[{"id":"a2-btnt","type":"text"}],"index":"a03b"}, + {"id":"a2-btnt","type":"text","x":506,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A2","lane1"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a2-btn","index":"a03c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a2-capt","type":"text","x":480,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"A2. Where the link lands","fontSize":11,"fontFamily":2,"index":"a03d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a2-capb","type":"text","x":480,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A2","lane1"],"text":"The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.","fontSize":9,"fontFamily":2,"index":"a03e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a3-panel","type":"rectangle","x":900,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"boundElements":[{"id":"ar-a2a3","type":"arrow"},{"id":"ar-a3a4","type":"arrow"}],"index":"a040"}, + {"id":"a3-url","type":"rectangle","x":900,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a041"}, + {"id":"a3-urlt","type":"text","x":916,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A3","lane1"],"text":"badges.simplex.chat/#/tier?app=true","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a042","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a3-title","type":"text","x":916,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"How long?","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a043","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a3-lede","type":"text","x":916,"y":342,"width":268,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"Legend - 5GB files, 21 days","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a044","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a3-c1","type":"rectangle","x":916,"y":378,"width":84,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a045"}, + {"id":"a3-c1t","type":"text","x":920,"y":418,"width":76,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"1 month\n\n$70","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a046","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-c2","type":"rectangle","x":1008,"y":378,"width":84,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a047"}, + {"id":"a3-c2t","type":"text","x":1012,"y":412,"width":76,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"3 months\n\n$140\none month free","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a048","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-c3","type":"rectangle","x":1100,"y":378,"width":84,"height":124,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a049"}, + {"id":"a3-c3t","type":"text","x":1104,"y":412,"width":76,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"12 months\n\n$420\nsave 50%","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a04a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-note","type":"text","x":916,"y":520,"width":268,"height":24,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"Paid once, for the months you choose.\nNo subscription.","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a04b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a3-btn","type":"rectangle","x":916,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"boundElements":[{"id":"a3-btnt","type":"text"}],"index":"a04c"}, + {"id":"a3-btnt","type":"text","x":926,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A3","lane1"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a3-btn","index":"a04d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a3-capt","type":"text","x":900,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"A3. Unchanged","fontSize":11,"fontFamily":2,"index":"a04e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a3-capb","type":"text","x":900,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.","fontSize":9,"fontFamily":2,"index":"a04f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"a4-panel","type":"rectangle","x":1320,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"boundElements":[{"id":"ar-a3a4","type":"arrow"},{"id":"ar-a4a5","type":"arrow"}],"index":"a050"}, + {"id":"a4-url","type":"rectangle","x":1320,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a051"}, + {"id":"a4-urlt","type":"text","x":1336,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A4","lane1"],"text":"badges.simplex.chat/?order=8f3a...","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a052","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a4-title","type":"text","x":1336,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Check your order","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a053","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a4-rowsL","type":"text","x":1336,"y":352,"width":160,"height":48,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Your badge\nDuration\nLaunch discount: 50% off\nTotal","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a054","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a4-rowsR","type":"text","x":1508,"y":352,"width":96,"height":48,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Legend\n12 months\n-$420.00\n$420.00","fontSize":9.5,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a055","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a4-label","type":"text","x":1336,"y":420,"width":100,"height":12,"strokeColor":"#6b7280","roughness":0,"groupIds":["A4","lane1"],"text":"PAY WITH","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a056","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a4-m1","type":"rectangle","x":1336,"y":438,"width":84,"height":60,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a057"}, + {"id":"a4-m1t","type":"text","x":1340,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Bitcoin","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a058","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-m2","type":"rectangle","x":1428,"y":438,"width":84,"height":60,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a059"}, + {"id":"a4-m2t","type":"text","x":1432,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Monero","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a05a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-m3","type":"rectangle","x":1520,"y":438,"width":84,"height":60,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a05b"}, + {"id":"a4-m3t","type":"text","x":1524,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Card","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a05c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-btn","type":"rectangle","x":1336,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"boundElements":[{"id":"a4-btnt","type":"text"}],"index":"a05d"}, + {"id":"a4-btnt","type":"text","x":1346,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A4","lane1"],"text":"Pay $420.00 with Monero","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a4-btn","index":"a05e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a4-capt","type":"text","x":1320,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"A4. Unchanged, and the pay screens after it","fontSize":11,"fontFamily":2,"index":"a05f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a4-capb","type":"text","x":1320,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A4","lane1"],"text":"The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.","fontSize":9,"fontFamily":2,"index":"a05g","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"a5-panel","type":"rectangle","x":1740,"y":260,"width":300,"height":620,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"boundElements":[{"id":"ar-a4a5","type":"arrow"},{"id":"ar-a5a6","type":"arrow"}],"index":"a060"}, + {"id":"a5-url","type":"rectangle","x":1740,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"index":"a061"}, + {"id":"a5-urlt","type":"text","x":1756,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A5","lane1"],"text":"badges.simplex.chat/?order=8f3a...","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a062","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a5-title","type":"text","x":1756,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A5","lane1"],"text":"Paid","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a063","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a5-lede","type":"text","x":1756,"y":344,"width":268,"height":26,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A5","lane1"],"text":"Opening SimpleX to add your badge.\nIf nothing happens, use the button.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a064","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a5-btn","type":"rectangle","x":1756,"y":396,"width":268,"height":38,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"boundElements":[{"id":"a5-btnt","type":"text"}],"index":"a065"}, + {"id":"a5-btnt","type":"text","x":1766,"y":408,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A5","lane1"],"text":"Return to SimpleX","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a5-btn","index":"a066","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a5-sec","type":"text","x":1756,"y":452,"width":268,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A5","lane1"],"text":"Show code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a067","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a5-link","type":"rectangle","x":1756,"y":496,"width":268,"height":34,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"index":"a068"}, + {"id":"a5-linkt","type":"text","x":1762,"y":507,"width":256,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A5","lane1"],"text":"simplexchat:/badge/code/SB-4K2M9-...","fontSize":9,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a069","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"a5-note","type":"text","x":1756,"y":544,"width":268,"height":33,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A5","lane1"],"text":"the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a06a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a5-capt","type":"text","x":1740,"y":900,"width":300,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["A5","lane1"],"text":"A5. New: the code leaves by link, not by eye","fontSize":11,"fontFamily":2,"index":"a06b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a5-capb","type":"text","x":1740,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A5","lane1"],"text":"Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.","fontSize":9,"fontFamily":2,"index":"a06c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a6-panel","type":"rectangle","x":2160,"y":260,"width":300,"height":620,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"boundElements":[{"id":"ar-a5a6","type":"arrow"}],"index":"a070"}, + {"id":"a6-title","type":"text","x":2180,"y":306,"width":260,"height":24,"strokeColor":"#0053d0","roughness":0,"groupIds":["A6","lane1"],"text":"Badge is being issued","fontSize":18,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a071","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a6-spin","type":"ellipse","x":2288,"y":380,"width":44,"height":44,"strokeColor":"#0053d0","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["A6","lane1"],"index":"a072"}, + {"id":"a6-spint","type":"text","x":2180,"y":440,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A6","lane1"],"text":"progress","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a073","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a6-body","type":"text","x":2186,"y":478,"width":248,"height":39,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"This takes a moment. You do not\nneed to do anything, and you can\nclose the app.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a074","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a6-then","type":"rectangle","x":2176,"y":600,"width":268,"height":96,"strokeColor":"#8f97a3","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a075"}, + {"id":"a6-theni","type":"rectangle","x":2190,"y":612,"width":240,"height":72,"strokeColor":"transparent","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a076"}, + {"id":"a6-theng","type":"rectangle","x":2298,"y":620,"width":24,"height":24,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a077"}, + {"id":"a6-thent","type":"text","x":2180,"y":650,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A6","lane1"],"text":"Legend","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a078","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a6-thens","type":"text","x":2180,"y":666,"width":260,"height":11,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"shown on your profile","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a079","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a6-thenn","type":"text","x":2160,"y":708,"width":300,"height":11,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A6","lane1"],"text":"then BadgesYourBadgeView, unchanged","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a07a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a6-capt","type":"text","x":2160,"y":900,"width":300,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["A6","lane1"],"text":"A6. New screen, and the only new app state","fontSize":11,"fontFamily":2,"index":"a07b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a6-capb","type":"text","x":2160,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.","fontSize":9,"fontFamily":2,"index":"a07c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"ar-a1a2","type":"arrow","x":368,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a1a2-t","type":"text"}],"index":"a080","fillStyle":"solid"}, + {"id":"ar-a1a2-t","type":"text","x":365.810546875,"y":563.5,"width":108.37890625,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Buy in browser - no alert","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a1a2","index":"a081","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a2a3","type":"arrow","x":788,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a2a3-t","type":"text"}],"index":"a082","fillStyle":"solid"}, + {"id":"ar-a2a3-t","type":"text","x":800,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a2a3","index":"a083","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a3a4","type":"arrow","x":1208,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a3a4-t","type":"text"}],"index":"a084","fillStyle":"solid"}, + {"id":"ar-a3a4-t","type":"text","x":1220,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a3a4","index":"a085","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a4a5","type":"arrow","x":1628,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a4a5-t","type":"text"}],"index":"a086","fillStyle":"solid"}, + {"id":"ar-a4a5-t","type":"text","x":1640,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"it settles","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a4a5","index":"a087","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a5a6","type":"arrow","x":2048,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a5a6-t","type":"text"}],"index":"a088","fillStyle":"solid"}, + {"id":"ar-a5a6-t","type":"text","x":2056.93115234375,"y":557,"width":86.1376953125,"height":26,"strokeColor":"#0053d0","roughness":0,"text":"simplexchat: carries\nthe code","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a5a6","index":"a089","fillStyle":"solid","strokeWidth":2,"originalText":"simplexchat: carries the code","lineHeight":1.3}, + {"id":"l2-band","type":"rectangle","x":20,"y":1040,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#f5f1ff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane2"],"index":"a100"}, + {"id":"l2-head","type":"text","x":60,"y":1068,"width":900,"height":22,"strokeColor":"#5b21b6","roughness":0,"groupIds":["lane2"],"text":"Store builds - the store path in the app, the browser alongside it","fontSize":17,"fontFamily":2,"index":"a101","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l2-sub","type":"text","x":60,"y":1096,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane2"],"text":"Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here","fontSize":11,"fontFamily":2,"index":"a102","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-panel","type":"rectangle","x":60,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"boundElements":[{"id":"ar-b1b2","type":"arrow"}],"index":"a110"}, + {"id":"b1-title","type":"text","x":80,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"Support SimpleX","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a111","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b1-body","type":"text","x":76,"y":1238,"width":268,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B1","lane2"],"text":"Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a112","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-info","type":"text","x":80,"y":1294,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"(i) How badges protect your privacy","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a113","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-hero","type":"rectangle","x":132,"y":1338,"width":156,"height":156,"strokeColor":"transparent","backgroundColor":"#efeaf9","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"index":"a114"}, + {"id":"b1-herot","type":"text","x":132,"y":1409,"width":156,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B1","lane2"],"text":"phone-supporter","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a115","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b1-btn","type":"rectangle","x":90,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"boundElements":[{"id":"b1-btnt","type":"text"}],"index":"a116"}, + {"id":"b1-btnt","type":"text","x":100,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B1","lane2"],"text":"Choose your badge","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b1-btn","index":"a117","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-sec","type":"text","x":80,"y":1676,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"Buy in browser","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a118","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-secn","type":"text","x":80,"y":1696,"width":260,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B1","lane2"],"text":"where the browser is not allowed,\nthis slot reads Redeem code instead","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a119","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b1-capt","type":"text","x":60,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B1","lane2"],"text":"B1. Two buttons, one of them gated","fontSize":11,"fontFamily":2,"index":"a11a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-capb","type":"text","x":60,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B1","lane2"],"text":"Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.","fontSize":9,"fontFamily":2,"index":"a11b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"b2-panel","type":"rectangle","x":480,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"boundElements":[{"id":"ar-b1b2","type":"arrow"},{"id":"ar-b2b3","type":"arrow"}],"index":"a120"}, + {"id":"b2-back","type":"text","x":496,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a121","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-title","type":"text","x":500,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"Choose your badge","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a122","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b2-lede","type":"text","x":500,"y":1236,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B2","lane2"],"text":"Larger files that stay available longer.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a123","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b2-avatar","type":"ellipse","x":598,"y":1268,"width":56,"height":56,"strokeColor":"transparent","backgroundColor":"#e3e6ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B2","lane2"],"index":"a124"}, + {"id":"b2-name","type":"text","x":500,"y":1334,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"My nickname [badge] v","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a125","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"b2-c1","type":"rectangle","x":492,"y":1378,"width":132,"height":150,"strokeColor":"#5b21b6","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a126"}, + {"id":"b2-c1i","type":"rectangle","x":542,"y":1398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a127"}, + {"id":"b2-c1t","type":"text","x":500,"y":1442,"width":116,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"Supporter\nSend 2GB files\n\n$6.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a128","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-c2","type":"rectangle","x":636,"y":1378,"width":132,"height":150,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a129"}, + {"id":"b2-c2i","type":"rectangle","x":686,"y":1398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a12a"}, + {"id":"b2-c2t","type":"text","x":644,"y":1442,"width":116,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"Legend\nSend 5GB files\n\n$69.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-btn","type":"rectangle","x":510,"y":1596,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"boundElements":[{"id":"b2-btnt","type":"text"}],"index":"a12c"}, + {"id":"b2-btnt","type":"text","x":520,"y":1608,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B2","lane2"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b2-btn","index":"a12d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-sec","type":"text","x":500,"y":1652,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"Redeem code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-infon","type":"text","x":500,"y":1676,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B2","lane2"],"text":"here only when B1 offers the browser;\nthe privacy link always stays on B1","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b2-capt","type":"text","x":480,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"B2. BadgesChooseBadgeView","fontSize":11,"fontFamily":2,"index":"a130","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-capb","type":"text","x":480,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B2","lane2"],"text":"Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.","fontSize":9,"fontFamily":2,"index":"a131","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"b3-panel","type":"rectangle","x":900,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"boundElements":[{"id":"ar-b2b3","type":"arrow"},{"id":"ar-b3b4","type":"arrow"}],"index":"a132"}, + {"id":"b3-back","type":"text","x":916,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B3","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a133","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-title","type":"text","x":920,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B3","lane2"],"text":"How long?","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a134","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b3-lede","type":"text","x":920,"y":1236,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"Legend - 5GB files, 21 days","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a135","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b3-avatar","type":"ellipse","x":1022,"y":1268,"width":56,"height":56,"strokeColor":"transparent","backgroundColor":"#e3e6ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B3","lane2"],"index":"a136"}, + {"id":"b3-name","type":"text","x":920,"y":1334,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"My nickname [badge]","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a137","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"b3-c1","type":"rectangle","x":912,"y":1386,"width":85,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a138"}, + {"id":"b3-c1t","type":"text","x":917,"y":1426,"width":75,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"1 month\n\n$69.99","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a139","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-c2","type":"rectangle","x":1007,"y":1386,"width":85,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a13a"}, + {"id":"b3-c2t","type":"text","x":1010,"y":1426,"width":79,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"Monthly\n\n$69.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a13b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-c3","type":"rectangle","x":1102,"y":1386,"width":85,"height":124,"strokeColor":"#5b21b6","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a13c"}, + {"id":"b3-c3t","type":"text","x":1105,"y":1420,"width":79,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"Annual\n\n$699.99/year\nSave 17%","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a13d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-btn","type":"rectangle","x":930,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"boundElements":[{"id":"b3-btnt","type":"text"}],"index":"a13e"}, + {"id":"b3-btnt","type":"text","x":940,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B3","lane2"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b3-btn","index":"a13f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-foot","type":"text","x":920,"y":1676,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"Renews on 24 August 2027. Cancel anytime.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a140","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b3-capt","type":"text","x":900,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"B3. BadgesHowLongView, disarmed","fontSize":11,"fontFamily":2,"index":"a141","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-capb","type":"text","x":900,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.","fontSize":9,"fontFamily":2,"index":"a142","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"b4-panel","type":"rectangle","x":1320,"y":1150,"width":300,"height":620,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["B4","lane2"],"boundElements":[{"id":"ar-b3b4","type":"arrow"},{"id":"ar-b4o1","type":"arrow"},{"id":"ar-b4o2","type":"arrow"},{"id":"ar-b4o3","type":"arrow"}],"index":"a143"}, + {"id":"b4-back","type":"text","x":1336,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a144","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-title","type":"text","x":1340,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"Check your order","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a145","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-rowsL","type":"text","x":1344,"y":1270,"width":160,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B4","lane2"],"text":"Your badge\nDuration\nTotal","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a146","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-rowsR","type":"text","x":1500,"y":1270,"width":96,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B4","lane2"],"text":"Legend\nAnnual\n$699.99/year","fontSize":9.5,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a147","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-div","type":"rectangle","x":1344,"y":1292,"width":252,"height":1,"strokeColor":"#dfe4ea","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B4","lane2"],"index":"a148"}, + {"id":"b4-note","type":"text","x":1344,"y":1336,"width":252,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"Charged by the App Store to the\naccount on this device.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a149","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b4-btn","type":"rectangle","x":1350,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B4","lane2"],"boundElements":[{"id":"b4-btnt","type":"text"}],"index":"a14a"}, + {"id":"b4-btnt","type":"text","x":1360,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B4","lane2"],"text":"Pay $699.99/year","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b4-btn","index":"a14b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-foot","type":"text","x":1340,"y":1676,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"Renews on 24 August 2027. Cancel anytime.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a14c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b4-capt","type":"text","x":1320,"y":1790,"width":300,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"B4. New screen, one for every market","fontSize":11,"fontFamily":2,"index":"a14d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-capb","type":"text","x":1320,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.","fontSize":9,"fontFamily":2,"index":"a14e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"o1-card","type":"rectangle","x":1740,"y":1160,"width":300,"height":120,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["O1","lane2"],"boundElements":[{"id":"ar-b4o1","type":"arrow"}],"index":"a150"}, + {"id":"o1-t","type":"text","x":1760,"y":1174,"width":260,"height":17,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O1","lane2"],"text":"Paid","fontSize":13,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a151","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3076923076923077}, + {"id":"o1-s","type":"text","x":1760,"y":1198,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O1","lane2"],"text":"o Issuing your badge","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a152","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"o1-b","type":"text","x":1766,"y":1222,"width":248,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["O1","lane2"],"text":"The store has taken the payment.\nKeeps trying if you close the app.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a153","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o2-card","type":"rectangle","x":1740,"y":1300,"width":300,"height":96,"strokeColor":"#8f97a3","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a154"}, + {"id":"o2-i","type":"rectangle","x":1756,"y":1312,"width":268,"height":72,"strokeColor":"transparent","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a155"}, + {"id":"o2-g","type":"rectangle","x":1878,"y":1320,"width":24,"height":24,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a156"}, + {"id":"o2-t","type":"text","x":1760,"y":1350,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O2","lane2"],"text":"Legend","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a157","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"o2-s","type":"text","x":1760,"y":1366,"width":260,"height":11,"strokeColor":"#6b7280","roughness":0,"groupIds":["O2","lane2"],"text":"shown on your profile","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a158","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"o2-n","type":"text","x":1740,"y":1402,"width":300,"height":11,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["O2","lane2"],"text":"BadgesYourBadgeView, unchanged","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a159","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"o3-card","type":"rectangle","x":1740,"y":1424,"width":300,"height":130,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["O3","lane2"],"boundElements":[{"id":"ar-b4o2","type":"arrow"}],"index":"a15a"}, + {"id":"o3-t","type":"text","x":1760,"y":1438,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O3","lane2"],"text":"Waiting for approval","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"o3-s","type":"text","x":1760,"y":1460,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O3","lane2"],"text":"o Nothing has been charged","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"o3-b","type":"text","x":1746,"y":1484,"width":288,"height":39,"strokeColor":"#6b7280","roughness":0,"groupIds":["O3","lane2"],"text":"Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o4-card","type":"rectangle","x":1760,"y":1584,"width":260,"height":120,"strokeColor":"#d6dae0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O4","lane2"],"boundElements":[{"id":"ar-b4o3","type":"arrow"}],"index":"a15e"}, + {"id":"o4-t","type":"text","x":1776,"y":1598,"width":228,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O4","lane2"],"text":"Cannot verify this purchase","fontSize":11,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o4-b","type":"text","x":1776,"y":1620,"width":228,"height":39,"strokeColor":"#4b5563","roughness":0,"groupIds":["O4","lane2"],"text":"The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15g","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o4-d","type":"rectangle","x":1760,"y":1668,"width":260,"height":1,"strokeColor":"#dfe4ea","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["O4","lane2"],"index":"a15h"}, + {"id":"o4-o","type":"text","x":1760,"y":1678,"width":260,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["O4","lane2"],"text":"Ok","fontSize":11,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15i","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o-note","type":"text","x":1740,"y":1712,"width":300,"height":44,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["O","lane2"],"text":"iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15j","fillStyle":"solid","strokeWidth":2,"lineHeight":1.7254901960784312}, + {"id":"o-capt","type":"text","x":1740,"y":1790,"width":300,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O","lane2"],"text":"B5. What the sheet returns","fontSize":11,"fontFamily":2,"index":"a15k","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o-capb","type":"text","x":1740,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["O","lane2"],"text":"Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.","fontSize":9,"fontFamily":2,"index":"a15l","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"ar-b1b2","type":"arrow","x":368,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b1b2-t","type":"text"}],"index":"a180","fillStyle":"solid"}, + {"id":"ar-b1b2-t","type":"text","x":380,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Choose your badge","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b1b2","index":"a181","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b2b3","type":"arrow","x":788,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b2b3-t","type":"text"}],"index":"a182","fillStyle":"solid"}, + {"id":"ar-b2b3-t","type":"text","x":800,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b2b3","index":"a183","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b3b4","type":"arrow","x":1208,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b3b4-t","type":"text"}],"index":"a184","fillStyle":"solid"}, + {"id":"ar-b3b4-t","type":"text","x":1220,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b3b4","index":"a185","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o1","type":"arrow","x":1628,"y":1220,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o1-t","type":"text"}],"index":"a186","fillStyle":"solid"}, + {"id":"ar-b4o1-t","type":"text","x":1640,"y":1206,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"verified","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o1","index":"a187","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o2","type":"arrow","x":1628,"y":1489,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o2-t","type":"text"}],"index":"a188","fillStyle":"solid"}, + {"id":"ar-b4o2-t","type":"text","x":1640,"y":1475,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"pending","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o2","index":"a189","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o3","type":"arrow","x":1628,"y":1644,"width":124,"height":0,"strokeColor":"#9aa1ab","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"elbowed":true,"points":[[0,0],[124,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o3-t","type":"text"}],"index":"a18a","fillStyle":"solid"}, + {"id":"ar-b4o3-t","type":"text","x":1640,"y":1630,"width":100,"height":13,"strokeColor":"#6b7280","roughness":0,"text":"unverified","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o3","index":"a18b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"l3-band","type":"rectangle","x":20,"y":1930,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#fff6ef","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane3"],"index":"a200"}, + {"id":"l3-head","type":"text","x":60,"y":1958,"width":900,"height":22,"strokeColor":"#a15c1e","roughness":0,"groupIds":["lane3"],"text":"Desktop - where the deep link may not exist","fontSize":17,"fontFamily":2,"index":"a201","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l3-sub","type":"text","x":60,"y":1986,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane3"],"text":"the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there","fontSize":11,"fontFamily":2,"index":"a202","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-panel","type":"rectangle","x":60,"y":2040,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"boundElements":[{"id":"ar-d1d2","type":"arrow"}],"index":"a210"}, + {"id":"d1-back","type":"text","x":76,"y":2058,"width":20,"height":14,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a211","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-title","type":"text","x":80,"y":2086,"width":260,"height":24,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"Redeem code","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a212","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"d1-body","type":"text","x":80,"y":2128,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D1","lane3"],"text":"Paste the code you received.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a213","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-field","type":"rectangle","x":76,"y":2160,"width":268,"height":40,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"index":"a214"}, + {"id":"d1-fieldt","type":"text","x":80,"y":2174,"width":260,"height":13,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"SB-XXXXX-XXXXX-XXXXX-XXXXX","fontSize":10,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a215","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-paste","type":"text","x":80,"y":2216,"width":260,"height":13,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"Paste","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a216","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-scan","type":"rectangle","x":126,"y":2254,"width":168,"height":168,"strokeColor":"#d6dae0","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"index":"a217"}, + {"id":"d1-scant","type":"text","x":126,"y":2332,"width":168,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"QR scanner","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a218","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d1-btn","type":"rectangle","x":90,"y":2510,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#dcc9b4","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"boundElements":[{"id":"d1-btnt","type":"text"}],"index":"a219"}, + {"id":"d1-btnt","type":"text","x":100,"y":2522,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["D1","lane3"],"text":"Redeem","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"d1-btn","index":"a21a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-note","type":"text","x":80,"y":2566,"width":260,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"opened by Buy in browser at the same\nmoment as the browser, and waiting","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a21b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d1-capt","type":"text","x":60,"y":2680,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D1","lane3"],"text":"D1. Both halves at once","fontSize":11,"fontFamily":2,"index":"a21c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-capb","type":"text","x":60,"y":2700,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["D1","lane3"],"text":"On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.","fontSize":9,"fontFamily":2,"index":"a21d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"d2-panel","type":"rectangle","x":480,"y":2040,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"boundElements":[{"id":"ar-d1d2","type":"arrow"}],"index":"a220"}, + {"id":"d2-url","type":"rectangle","x":480,"y":2040,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a221"}, + {"id":"d2-urlt","type":"text","x":496,"y":2048,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D2","lane3"],"text":"badges.simplex.chat/#/tier?app=desktop","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a222","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d2-title","type":"text","x":496,"y":2090,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"Paid. Here is your code.","fontSize":16,"fontFamily":2,"autoResize":false,"index":"a223","fillStyle":"solid","strokeWidth":2,"lineHeight":1.375}, + {"id":"d2-code","type":"rectangle","x":496,"y":2128,"width":268,"height":36,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a224"}, + {"id":"d2-codet","type":"text","x":500,"y":2140,"width":260,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"SB-4K2M9-XQ7VT-H3BNP-R8WLC","fontSize":10,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a225","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4}, + {"id":"d2-btn","type":"rectangle","x":496,"y":2180,"width":268,"height":34,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"boundElements":[{"id":"d2-btnt","type":"text"}],"index":"a226"}, + {"id":"d2-btnt","type":"text","x":506,"y":2190,"width":248,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["D2","lane3"],"text":"Copy code","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"d2-btn","index":"a227","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d2-qr","type":"rectangle","x":496,"y":2236,"width":110,"height":110,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["D2","lane3"],"index":"a228"}, + {"id":"d2-qrt","type":"text","x":496,"y":2352,"width":110,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D2","lane3"],"text":"scan to carry it\nto your phone","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a229","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d2-redeem","type":"text","x":622,"y":2236,"width":142,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.","fontSize":8.5,"fontFamily":2,"autoResize":false,"index":"a22a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.411764705882353}, + {"id":"d2-warn","type":"rectangle","x":622,"y":2288,"width":142,"height":58,"strokeColor":"transparent","backgroundColor":"#fdf3e7","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a22b"}, + {"id":"d2-warnt","type":"text","x":632,"y":2298,"width":122,"height":38,"strokeColor":"#8a5a1e","roughness":0,"groupIds":["D2","lane3"],"text":"This is the only copy.\nSaved in this browser\nand nowhere else.","fontSize":8.5,"fontFamily":2,"autoResize":false,"index":"a22c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4901960784313726}, + {"id":"d2-capt","type":"text","x":480,"y":2680,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"D2. The page, told which app it is","fontSize":11,"fontFamily":2,"index":"a22d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d2-capb","type":"text","x":480,"y":2700,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["D2","lane3"],"text":"codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.","fontSize":9,"fontFamily":2,"index":"a22e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"q-box","type":"rectangle","x":900,"y":2040,"width":1540,"height":260,"strokeColor":"#e0a672","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["Q","lane3"],"index":"a230"}, + {"id":"q-head","type":"text","x":924,"y":2064,"width":900,"height":20,"strokeColor":"#a15c1e","roughness":0,"groupIds":["Q","lane3"],"text":"QUESTIONS - deep links, and whether the desktop can have them at all","fontSize":15,"fontFamily":2,"autoResize":false,"index":"a231","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"q-body","type":"text","x":924,"y":2098,"width":1490,"height":130,"strokeColor":"#4b5563","roughness":0,"groupIds":["Q","lane3"],"text":"1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.","fontSize":10,"fontFamily":2,"autoResize":false,"index":"a232","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"n-box","type":"rectangle","x":900,"y":2340,"width":1540,"height":300,"strokeColor":"#c9ced6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["N","lane3"],"index":"a233"}, + {"id":"n-head","type":"text","x":924,"y":2364,"width":900,"height":20,"strokeColor":"#1c1e26","roughness":0,"groupIds":["N","lane3"],"text":"What has to be built, and what it costs","fontSize":15,"fontFamily":2,"autoResize":false,"index":"a234","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"n-body","type":"text","x":924,"y":2398,"width":1490,"height":220,"strokeColor":"#4b5563","roughness":0,"groupIds":["N","lane3"],"text":"The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.","fontSize":10,"fontFamily":2,"autoResize":false,"index":"a235","fillStyle":"solid","strokeWidth":2,"lineHeight":2}, + {"id":"ar-d1d2","type":"arrow","x":368,"y":2350,"width":104,"height":0,"strokeColor":"#a15c1e","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"startArrowhead":"arrow","endArrowhead":"arrow","boundElements":[{"id":"ar-d1d2-t","type":"text"}],"index":"a250","fillStyle":"solid"}, + {"id":"ar-d1d2-t","type":"text","x":370.23193359375,"y":2337,"width":99.5361328125,"height":26,"strokeColor":"#a15c1e","roughness":0,"text":"opened together, code\ncarried by hand","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-d1d2","index":"a251","fillStyle":"solid","strokeWidth":2,"originalText":"opened together, code carried by hand","lineHeight":1.3}, + {"id":"ft-note","type":"text","x":60,"y":2830,"width":2400,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["footer"],"text":"Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.","fontSize":10,"fontFamily":2,"index":"a300","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3} + ], + "appState": {"gridSize": 20, "gridStep": 5, "gridModeEnabled": false, "viewBackgroundColor": "#ffffff"}, + "files": {} +} diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 267db9f243..7542ed3205 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -105,14 +105,9 @@ build() { sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties" for arch in $arches; do - if [ "$arch" = "armv7a" ]; then - android_simplex_lib="${folder}#hydraJobs.${arch}-android:lib:simplex-chat.x86_64-linux" - android_support_lib="${folder}#hydraJobs.${arch}-android:lib:support.x86_64-linux" - else - android_simplex_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:simplex-chat" - android_support_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:support" - fi - + android_simplex_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:simplex-chat" + android_support_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:support" + android_simplex_lib_output="${PWD}/result/pkg-${arch}-android-libsimplex.zip" android_support_lib_output="${PWD}/result/pkg-${arch}-android-libsupport.zip" diff --git a/scripts/badge-service/.gitignore b/scripts/badge-service/.gitignore new file mode 100644 index 0000000000..78d3246083 --- /dev/null +++ b/scripts/badge-service/.gitignore @@ -0,0 +1,4 @@ +.env +config/ +data/ +web/ diff --git a/scripts/badge-service/badge_service.ini.example b/scripts/badge-service/badge_service.ini.example index dcc9364d62..f0dea005ac 100644 --- a/scripts/badge-service/badge_service.ini.example +++ b/scripts/badge-service/badge_service.ini.example @@ -36,6 +36,23 @@ session_minutes = 60 waiting_seconds = 3 idle_seconds = 60 +; optional; without it every App Store receipt is answered provider_not_configured, which the app retries later. +; root_certificates lists, comma-separated, the roots trusted to sign receipts, each in DER and placed +; in config/. Today that is https://www.apple.com/certificateauthority/AppleRootCA-G3.cer. +; When Apple adds a root, list it beside the old one rather than in its place. +;[apple] +;bundle_id = chat.simplex.app +;root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer + +; optional; without it every Play receipt is answered provider_not_configured, which the app retries later. +; service_account_file is the JSON key of a service account with the Play Console permissions "View +; financial data, orders, and cancellation survey responses" and "Manage orders and subscriptions", to +; read and acknowledge purchases. Without the second, a purchase the app has not already acknowledged +; itself is answered internal until the permission is granted. Place it in config/; the file must be readable by the service's user alone. +;[google] +;package_name = chat.simplex.app +;service_account_file = /etc/simplex-badge-service/play-service-account.json + ; index is the one clients verify against; private_key is from `simplex-chat badge keygen`. ; To rotate, change both together, once clients trust the new index. [issuer] @@ -52,3 +69,9 @@ private_key = replace-me ;[group] ;display_name = SimpleX Badges ;description = Welcome to the badges group + +; local testing only, in both run modes: accepts any well-formed App Store or Play receipt without +; verifying it, so anyone who can reach this service can mint badges. The service refuses to start +; with this on beside an [apple] or [google] section. +[dev] +accept_unverified_store_receipts = off diff --git a/scripts/badge-service/compose.yml b/scripts/badge-service/compose.yml index 1c7e485e08..6a81ac8200 100644 --- a/scripts/badge-service/compose.yml +++ b/scripts/badge-service/compose.yml @@ -19,6 +19,11 @@ services: - "--service-config" - "/etc/simplex-badge-service/badge_service.ini" volumes: - - ./badge_service.ini:/etc/simplex-badge-service/badge_service.ini:ro + - type: bind + source: ./config + target: /etc/simplex-badge-service + read_only: true + bind: + create_host_path: false - ./web:/srv/webapp stop_grace_period: 10s diff --git a/scripts/ci/README.md b/scripts/ci/README.md new file mode 100644 index 0000000000..edc3569c38 --- /dev/null +++ b/scripts/ci/README.md @@ -0,0 +1,37 @@ +# App tests on GitHub's runners + +Two workflows build the apps from a branch's own code and test them, only when asked: `ios-sim` (the iOS app in a simulator on a macOS runner) and `desktop-linux` (the desktop app on a Linux runner). Each runs a scripted smoke test and, on request, a Claude session that follows a test plan, and a test backend (relay, directory, names server, peers). Details: [iOS](ios-sim/README.md), [desktop](desktop-linux/README.md). + +## Run tags + +Put a tag in the message of the commit you push. Tags combine: each workflow runs everything its tags ask for. + +| Tag | iOS | Desktop | +|---|---|---| +| `[app-ci]` | scripted test | scripted test | +| `[app-ci:ios]` | scripted test | — | +| `[app-ci:desktop]` | — | scripted test | +| `[claude-test]` | scripted test, then Claude | scripted test, then Claude | +| `[claude-test:ios]` | scripted test, then Claude | — | +| `[claude-test:desktop]` | — | scripted test, then Claude | +| `[ci-backend]`, with one of the above | also the test backend | also the test backend | +| `[ci-release]`, with one of the above | — | also the latest release's onboarding, to tell a branch's failure from the runner's | + +Only the pushed head commit counts, and a tag counts anywhere in its message, body included: never write one in a message unless that push should run it. A push that asks for nothing starts no job and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Pushes to master and stable never start a run, because a squash merge's message lists the merged commits' subjects with their tags; run it by hand there. + +## Running a workflow by hand + +Actions → `ios-sim` or `desktop-linux` → Run workflow, on any branch, with checkboxes that are off by default: `claude` (the Claude session), `backend` (the test backend) and, on desktop, `release` (the latest release's onboarding). + +## How long a run takes + +| Run | iOS | Desktop | +|---|---|---| +| Scripted test, builds reused | about 25 min, plus the wait for a macOS runner, which the team's builds share (minutes to an hour) | about 10 min | +| The core rebuilt, after Haskell changes | 30–45 min more | about 25 min more | +| With the test backend | 20–45 min more when it is rebuilt, then about 10 min to start | about 30 min more when it is rebuilt, then a few minutes to start | +| With Claude on the smoke plan | 10–15 min more | about 4 min more | + +## The Claude job + +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. The environment's branch rule alone decides which branches may use it: on a branch it doesn't allow, a run that asks for Claude fails at the Claude job's environment check, and the scripted test runs as usual. Each run spends the subscription's usage limits; the job summary gives its tokens and cost estimated at API prices. diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index 50a2d91cf6..653ac78cb3 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -4,42 +4,29 @@ ## Running it -On any branch, in either of two ways: +The workflow runs only when asked, on any branch, by a run tag in the pushed commit's message or by hand from the Actions tab: [App tests on GitHub's runners](../README.md) lists the tags and inputs. -- **Dispatch:** Actions → desktop-linux → Run workflow, with the `claude` and `backend` checkboxes (both off by default). -- **Push** with a tag in the head commit's message, which is how agents in push sessions ask for a run: - -| Tag | Runs | -|---|---| -| `[app-ci]` | the scripted test, here and in the iOS app's workflow where the branch has it | -| `[app-ci:desktop]` | the scripted test, here only | -| `[claude-test]` | the scripted test and the Claude job, here and in the iOS app's workflow where the branch has it | -| `[claude-test:desktop]` | the scripted test and the Claude job, here only | -| `[ci-backend]` | with one of the above: also the test backend and the release job | - -A push without these tags (or with only iOS tags) starts a run whose jobs are all skipped, and it doesn't cancel a run in progress. A newer run that was asked for cancels an older one on the same branch. Pushes to master and stable never start it, because a squash merge's message lists the merged commits' subjects with their tags; dispatch it there instead. - -Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged: the core by a cache keyed on `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md` and the build scripts; the backend by an artifact of the same branch named after a hash of its sources (30 days). So a branch that changes core and app code together tests both. +Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged, as an artifact of the same branch named after a hash of its sources (30 days): for the core `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md` and the build scripts, for the backend `src/**`, the CLI's and the directory's sources, `cabal.project`, `simplex-chat.cabal` and its build script. So a branch that changes core and app code together tests both. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`. ## Jobs | Job | Does | Time | |---|---|---| -| `plan` | chooses the jobs, finds an earlier backend build | 10 s | -| `core` | `scripts/desktop/build-lib-linux.sh` via `build-core.sh` | 15 s cached, about 23 min not | -| `app` | the app (`:desktop:createDistributable`), desktopTest, `smoke-test.sh` | about 10 min: build 4.5, desktopTest 0.5, smoke test 3.5 (about 25 with the backend's searches) | -| `claude` (opt-in) | the app at its first screen, or with the backend set up at the chat list, then Claude Code drives it | about 7 min, Claude 2.5 | -| `backend` (opt-in) | `build-backend.sh`: `simplex-chat`, `simplex-directory-service`, and `smp-server` from the simplexmq commit `cabal.project` pins | about 27 min, skipped when reused | +| `plan` | chooses the jobs, finds earlier core and backend builds | 10 s | +| `core` | `scripts/desktop/build-lib-linux.sh` via `build-core.sh` | skipped when reused, about 24 min not | +| `app` | the app (`:desktop:createDistributable`), desktopTest, `smoke-test.sh` | about 12 min: build 4.5, desktopTest 0.5, smoke test 3.5 (about 25 with the backend's searches) | +| `claude` (opt-in) | after `app` passed: its distributable at the first screen, or with the backend set up at the chat list, then Claude Code drives it | about 4 min, Claude 3 (about 15 with the backend's search test) | +| `backend` (opt-in) | `build-backend.sh`: `simplex-chat`, `simplex-directory-service`, and `smp-server` from the simplexmq commit `cabal.project` pins | about 30 min, skipped when reused | | `release` (opt-in) | the latest release's .deb for this Ubuntu, onboarding only, to tell a branch's failure from the runner's | 1.5 min | -Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-claude-test`, and the backend's logs. The job summaries list the checks, the desktopTest results and the Claude usage. +Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-claude-test`, and the backend's logs; `desktop-app`, the distributable for the Claude job, is kept a day. The job summaries list the checks, the desktopTest results and the Claude usage. ## Scripts - `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers. - `smoke-test.sh [start|onboarding|setup|backend|search]`: the scripted cases; the default is onboarding and both layouts. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts; `search`, which the workflow runs when the backend started, also searches in both layouts: "Search in Directory" for "ci seed", and the full search for the name `seeded` with Enter (the local chat, the Names part, the Directory part, in order), and with the toolbar at the bottom the full search in the New chat sheet, checking headers, rows, avatars, counts and the cards hidden while results show. It then joins `ci_channel` from its Directory row and checks, in both layouts, the note "All results on this page are already in your chats" when the typed `ci_channel` finds only the listed chat, and, with "seeded notes" open and `ci seed` typed, `ci_channel` as its chat's row in the Directory part (OCR reads no `#` before its name and no "Join channel" next to it; `#ci_group` keeps "Join group") that opens the channel without an alert; and the same row in the New chat sheet. The search case makes 129 checks (106 before the known-chat ones). - `backend.sh start ` / `stop `: the test backend, below. -- `claude-test.sh`, `claude-test.md` (the prompt), `claude-search.md` (the prompt when the backend runs), `claude-guard.py`, `claude-usage.py`: the Claude job. +- `claude-test.sh`, `claude-test.md` (the prompt), `claude-search.md` (the prompt when the backend runs), `claude-scope` (whether the prompt uses the test backend), `claude-guard.py`, `claude-usage.py`: the Claude job. - `build-core.sh`, `install-core.sh`, `build-backend.sh`, `earlier-run.sh`, `release-app.sh`, `test-summary.py`: builds, reuse and reports. ## The test backend @@ -54,8 +41,8 @@ It writes the directory's address to `/directory-link` and the SMP server's ## The Claude job -It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. The token goes only to the step that runs `claude`, and every `ui` call unsets it. With `[ci-backend]` the job also starts the test backend and waits for its build, sets the app up at the chat list (`smoke-test.sh … setup`) and gives Claude `claude-search.md`, the online search test, in place of the onboarding path in `claude-test.md`: the directory and full searches in both layouts (parts A and B), then part C, which joins `ci_channel` from its Directory row and checks that later searches show it as a chat (the note for a page of chats already listed, its chat row in the Directory part of the chat list and of New chat, opening without an alert). The job installs Claude Code 2.1.296 and runs `claude-haiku-5-5` with `--max-turns 120` and `--max-budget-usd 3`. Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`, as it does on this branch; a run that asks for Claude must then ask for the backend too, or the plan job fails. With the backend it sets the app up at the chat list (`smoke-test.sh … setup`) and gives Claude `claude-search.md`, the online search test, in place of the onboarding path in `claude-test.md`: the directory and full searches in both layouts (parts A and B), then part C, which joins `ci_channel` from its Directory row and checks that later searches show it as a chat (the note for a page of chats already listed, its chat row in the Directory part of the chat list and of New chat, opening without an alert). The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 120`, `--max-budget-usd 3` and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`). Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. ## Cost -The runners are free for this public repository. A Claude run spends the subscription's usage limits, which interactive sessions share: Haiku 5.5 does this smoke path in 15 to 17 turns, about 0.008 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription). +The runners are free for this public repository. A Claude run spends the subscription's usage limits, which interactive sessions share: Haiku 5.5 does this smoke path in 15 to 30 turns, 0.01 to 0.02 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription). diff --git a/scripts/ci/desktop-linux/backend.sh b/scripts/ci/desktop-linux/backend.sh index 527a476d84..d9675a3d4e 100755 --- a/scripts/ci/desktop-linux/backend.sh +++ b/scripts/ci/desktop-linux/backend.sh @@ -124,21 +124,27 @@ create_directory() { } start_directory() { + local pid deadline=$((SECONDS + 60)) # -y: the service adds its own tables to the database the CLI created, and would otherwise ask on stdin nohup "$bin/simplex-directory-service" -d "$work/directory" -y --super-users "$1" < /dev/null >> "$work/directory.log" 2>&1 & - echo $! > "$work/directory.pid" - sleep 5 - if ! kill -0 "$(cat "$work/directory.pid")" 2> /dev/null; then - echo "error: the directory service exited" - cat "$work/directory.log" - return 1 - fi + pid=$! + echo "$pid" > "$work/directory.pid" + # its output is block-buffered, so it shows that its chat has started by connecting to its servers + until grep -q "pid=$pid," <<< "$(ss -Htnp state established)"; do + if ! kill -0 "$pid" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the directory service did not start" + cat "$work/directory.log" + return 1 + fi + sleep 1 + done } -stop_directory() { +# Waits for the process to exit, which completes its output, and kills it after 30 s. +stop_process() { local pid deadline - pid=$(cat "$work/directory.pid") - kill -INT "$pid" 2> /dev/null || true + pid=$(cat "$work/$1.pid") + kill "-$2" "$pid" 2> /dev/null || true deadline=$((SECONDS + 30)) while kill -0 "$pid" 2> /dev/null; do if [ "$SECONDS" -ge "$deadline" ]; then @@ -156,7 +162,7 @@ connect_admin() { admin_until "Welcome to $directory_name" 180 "/tail @'$directory_name' 10" local id id=$(db_query directory "SELECT contact_id FROM contacts WHERE local_display_name = '$admin'") - stop_directory + stop_process directory INT start_directory "$id:$admin" echo "directory restarted with super user $id:$admin" } @@ -367,12 +373,15 @@ start() { stop() { local p - for p in directory relay smp-server resolver; do + for p in directory relay smp-server; do if [ -f "$work/$p.pid" ]; then - kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true + stop_process "$p" INT fi done - sleep 3 + # Python, started in the background by a script, ignores SIGINT + if [ -f "$work/resolver.pid" ]; then + stop_process resolver TERM + fi for p in directory relay smp-server resolver; do echo "::group::$p log" cat "$work/$p.log" 2> /dev/null || true diff --git a/scripts/ci/desktop-linux/build-backend.sh b/scripts/ci/desktop-linux/build-backend.sh index adda90dcfe..fbe20bad8e 100755 --- a/scripts/ci/desktop-linux/build-backend.sh +++ b/scripts/ci/desktop-linux/build-backend.sh @@ -13,7 +13,6 @@ out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" ghc --version cabal --version -du -sh ~/.cabal/store dist-newstyle 2> /dev/null || true cat > cabal.project.local << EOF ignore-project: False diff --git a/scripts/ci/desktop-linux/build-core.sh b/scripts/ci/desktop-linux/build-core.sh index 13ab52eb30..cda413e9f1 100755 --- a/scripts/ci/desktop-linux/build-core.sh +++ b/scripts/ci/desktop-linux/build-core.sh @@ -8,13 +8,10 @@ set -euo pipefail mkdir -p "$(dirname "$1")" out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" arch=$(uname -m) -ghc_ver=$(ghc --numeric-version) -version=$(sed -n 's/^version: *//p' simplex-chat.cabal) libs_dir=apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-$arch ghc --version cabal --version -du -sh ~/.local/state/cabal/store ~/.cabal/store dist-newstyle 2> /dev/null || true cat > cabal.project.local << EOF ignore-project: False @@ -24,9 +21,6 @@ package direct-sqlcipher flags: +openssl EOF -# a restored dist-newstyle can hold an older version's build directory, which breaks the script's simplex-chat-* glob -find "dist-newstyle/build/$arch-linux/ghc-$ghc_ver" -maxdepth 1 -name 'simplex-chat-*' ! -name "simplex-chat-$version" -exec rm -rf {} + 2> /dev/null || true - SKIP_VLC=1 scripts/desktop/build-lib-linux.sh ls -la "$libs_dir" diff --git a/scripts/ci/desktop-linux/claude-scope b/scripts/ci/desktop-linux/claude-scope new file mode 100644 index 0000000000..4a6033862b --- /dev/null +++ b/scripts/ci/desktop-linux/claude-scope @@ -0,0 +1,3 @@ +# What the Claude job needs for its session: claude-search.md with the test backend, claude-test.md without. +# backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too. +backend: yes diff --git a/scripts/ci/desktop-linux/claude-test.sh b/scripts/ci/desktop-linux/claude-test.sh index aac8e85889..bcaeb8674f 100755 --- a/scripts/ci/desktop-linux/claude-test.sh +++ b/scripts/ci/desktop-linux/claude-test.sh @@ -12,7 +12,9 @@ set -euo pipefail -: "${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set}" +token=${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set} +# only the claude process gets the token, nothing else this script starts +unset CLAUDE_CODE_OAUTH_TOKEN here=$(cd "$(dirname "$0")" && pwd) out=$(cd "$1" && pwd) transcript=$2 @@ -37,26 +39,28 @@ EOF claude --version cd "$out" status=0 -claude -p "$(cat prompt.md)" \ +CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \ --model "$CLAUDE_MODEL" \ + --effort high \ + --restricted \ + --tools Bash,Read,Write,Edit \ + --allowedTools "Bash(ui)" "Bash(ui *)" "Read(/$out/**)" "Edit(/$out/**)" \ + --permission-mode dontAsk \ + --permission-prompts none \ + --strict-mcp-config \ --max-turns 120 \ --max-budget-usd 3 \ - --output-format stream-json \ - --verbose \ - --tools Bash,Read,Write \ - --permission-mode dontAsk \ - --allowedTools "Bash(ui *)" "Read(/$out/**)" "Edit(/$out/**)" \ - --disallowedTools "mcp__*" \ - --strict-mcp-config \ - --disable-slash-commands \ + --autocompact 200k \ --no-session-persistence \ --settings "$settings" \ - < /dev/null > "$transcript" 2> "$stderr" || status=$? + --output-format stream-json \ + --verbose \ + < prompt.md > "$transcript" 2> "$stderr" || status=$? # A second safeguard to the file tools' limits: what the session wrote is printed and uploaded only when it is known # not to hold the token. grep reads the token from a pipe, so it is in no process's arguments. leak=0 -printf '%s\n' "$CLAUDE_CODE_OAUTH_TOKEN" | grep -rqF -f - -- "$out" "$transcript" "$stderr" "$CLAUDE_GUARD_LOG" || leak=$? +printf '%s\n' "$token" | grep -rqF -f - -- "$out" "$transcript" "$stderr" "$CLAUDE_GUARD_LOG" || leak=$? if [ "$leak" -ne 1 ]; then rm -rf -- "$out" "$transcript" "$stderr" "$CLAUDE_GUARD_LOG" echo "error: the token is in the Claude session's output, or checking for it failed (grep exited $leak); the output, transcript, stderr and guard log are deleted" @@ -80,4 +84,4 @@ if [ "$status" -ne 0 ]; then exit 1 fi last=$(grep -v '^[[:space:]]*$' <<< "$result" | tail -n 1 || true) -grep -qxE '[*`]*CLAUDE-TEST: PASS[*`]*' <<< "$last" +grep -qxE '[*`]*CLAUDE-TEST: PASS[*`.]*[[:space:]]*' <<< "$last" diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index 14a0af51a1..0bb1498c8a 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -4,33 +4,33 @@ ## Running it -The workflow runs only when asked, on any branch: +The workflow runs only when asked, on any branch, by a run tag in the pushed commit's message or by hand from the Actions tab: [App tests on GitHub's runners](../README.md) lists the tags and inputs. -- **Dispatch** it from the Actions tab (workflow "ios-sim", "Run workflow"), with the inputs `claude` (also run the Claude session) and `backend` (also start the test backend), both off by default. -- **Push** a commit whose message carries a tag: `[app-ci]` runs the scripted tests of both apps (iOS and desktop), `[app-ci:ios]` only this one; `[claude-test]` (both apps) or `[claude-test:ios]` runs the scripted test and then the Claude session; add `[ci-backend]` to build and start the test backend. Only the pushed head commit counts, and a tag matches anywhere in its message, body included, so don't quote a tag in a message that shouldn't run it. - -A push without a tag starts nothing and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Pushes to master and stable never start it, because a squash merge's message lists the merged commits' subjects with their tags; dispatch it there instead. Which branches may run the Claude session is decided only by the `app-sim-claude` environment's branch rule in the repository settings: on a branch it doesn't allow, a run that asks for Claude fails at the environment check, and because the app job holds the environment, its scripted test fails with it. Ask for Claude only on branches the rule allows. - -Results: the job log, the job summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. +Results: the job logs, the Claude job's summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (the Claude job's smoke test in `smoke-test/`, and per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. ## How long it takes, what it costs -- `plan` (ubuntu, 15 s) names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change. -- `core` (macOS, 35–45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`). -- `backend` (macOS, 20–30 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`. -- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min (about 20 with the backend's searches), the backend start about 10 min when asked, Claude as long as its plan needs. +- `plan` (ubuntu-24.04, 10 s) reads what the run asks for, names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change. +- `core` (macOS, 30–45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`). +- `backend` (macOS, 20–45 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`. +- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min (about 20 with the backend's searches), the backend start about 10 min when asked. +- `claude` (macOS, only when asked, after `app` passed): a new simulator with the app job's build and the smoke test, about 7 min (about 20 with the backend), then the sessions (the smoke plan's took 2.6 min). -A run with reused builds and no extras takes about 26 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. Caches are evicted within hours (the repository is over its cache limit); the artifacts are what make runs fast. +A run with reused builds and no extras takes about 25 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`: builds are reused only as the artifacts above, and a core or backend rebuild compiles its dependencies too. + +The macOS jobs run on `macos-26`, the image with Xcode 26 and the iPhone 17 simulator; `macos-latest` moves to each new macOS and its Xcode. ## The Claude session -It runs after the smoke test, in the same job, on the simulator the smoke test leaves running, once per layout listed in `claude-scope`, with Claude Code installed from npm at a pinned version. The job then references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; jobs without Claude reference no environment. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it later holds the Claude token. +It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept a day) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. -- `claude-scope`: the model, the layouts, the cases per layout, optional entries per layout, `max-turns` and `max-budget-usd` per session. +Each session's final message ends with `CLAUDE-TEST: PASS`, or `CLAUDE-TEST: FAIL` and the failed cases; the step fails unless every session passes. + +- `claude-scope`: whether the cases use the test backend, the model, the layouts, the cases per layout, optional entries per layout, `max-turns` and `max-budget-usd` per session. - `device-test-plan.md`: the plan (`| ID | Do | Expect |` tables): here, online search (directory and SimpleX names, chat list and New chat), about 50 cases. A feature branch replaces it with its own and names its cases in `claude-scope`. - `claude-prompt.md`: what any session needs to know about the app, the `ui` helper and the test backend; `claude-test.sh` adds the `ui` help, the session's cases, layout, start state and limits, the seeded backend and the plan. -- `claude-guard.py`: a PreToolUse hook that lets through only one plain `ui` command per Bash call, since read-only commands run without asking in every permission mode and Bash inherits the token. Read, Write and Edit are allowed only in the session's own directory, so a session can't change `ui` or the scripts it runs. After each session `claude-test.sh` deletes the session's files if the token appears in them. -- `claude-usage.sh`: each session's turns, duration, tokens and cost "estimated at API prices" (Claude Code's own estimate, not a bill) to the job summary and `claude-usage.json`. +- `claude-guard.py`: a PreToolUse hook that lets through only one plain `ui` command per Bash call, since read-only commands run without asking in every permission mode and Bash inherits the token. Read, Write and Edit are allowed only in the session's own directory, so a session can't change `ui` or the scripts it runs. After each session `claude-test.sh` deletes the session's files, and fails, if the token appears in them or the check for it fails. +- `claude-usage.sh`: each session's turns, duration, tokens and cost "estimated at API prices" (Claude Code's own estimate, not a bill) to the job summary and `claude-usage.json`, or why they are unknown (a session cut short before its token check is not uploaded). Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; a 50-case feature plan took 300–450 turns, 30–60 minutes and $1.1–1.7 per session, estimated at API prices. Every run spends the subscription's usage limits, which interactive sessions share. @@ -53,5 +53,5 @@ It writes what it seeded to `/seed.md` (the Claude sessions get it) an ## Other files -- `use-macos26-sdk.sh`: runner images from 20261013 add Command Line Tools with a macOS 27 SDK that Xcode 26.6's linker can't read; every macOS job points the default SDK back at macOS 26.5. +- `use-macos26-sdk.sh`: runner images from 20261013 add Command Line Tools with a macOS 27 SDK that Xcode 26.6's linker can't read; every macOS job points the default SDK back at macOS 26.5. The pinned `macos-26` label runs such an image. - `earlier-run.sh `: finds the newest run of this branch in this repository with that artifact; artifacts from forks and other branches are never used. diff --git a/scripts/ci/ios-sim/backend.sh b/scripts/ci/ios-sim/backend.sh index 484d88b0cc..42f1a89692 100755 --- a/scripts/ci/ios-sim/backend.sh +++ b/scripts/ci/ios-sim/backend.sh @@ -60,13 +60,6 @@ expect_out() { fi } -expect_no_error() { - if grep -qiE "error" "$work/out"; then - echo "error: the command failed" - return 1 - fi -} - # Each run keeps the client online for a few seconds, which also lets the handshakes it waits for progress. admin_until() { local pattern=$1 secs=$2 cmd=$3 @@ -117,22 +110,28 @@ create_directory() { } start_directory() { + local pid deadline=$((SECONDS + 60)) # -y: the service adds its own tables to the database the CLI created, and would otherwise ask on stdin nohup "$bin/simplex-directory-service" -d "$work/directory" -y --super-users "$1" < /dev/null >> "$work/directory.log" 2>&1 & - echo $! > "$work/directory.pid" + pid=$! + echo "$pid" > "$work/directory.pid" echo "$1" > "$work/super-user" - sleep 5 - if ! kill -0 "$(cat "$work/directory.pid")" 2> /dev/null; then - echo "error: the directory service exited" - cat "$work/directory.log" - return 1 - fi + # its output is block-buffered, so it shows that its chat has started by connecting to its servers + until lsof -a -p "$pid" -iTCP -sTCP:ESTABLISHED > /dev/null 2>&1; do + if ! kill -0 "$pid" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the directory service did not start" + cat "$work/directory.log" + return 1 + fi + sleep 1 + done } -stop_directory() { +# Waits for the process to exit, which completes its output, and kills it after 30 s. +stop_process() { local pid deadline - pid=$(cat "$work/directory.pid") - kill -INT "$pid" 2> /dev/null || true + pid=$(cat "$work/$1.pid") + kill "-$2" "$pid" 2> /dev/null || true deadline=$((SECONDS + 30)) while kill -0 "$pid" 2> /dev/null; do if [ "$SECONDS" -ge "$deadline" ]; then @@ -150,7 +149,7 @@ connect_admin() { admin_until "Welcome to $directory_name" 180 "/tail @'$directory_name' 10" local id id=$(db_query directory "SELECT contact_id FROM contacts WHERE local_display_name = '$admin'") - stop_directory + stop_process directory INT start_directory "$id:$admin" echo "directory restarted with super user $id:$admin" } @@ -302,7 +301,7 @@ start_contact() { expect_out "^auto_accept on" register_name "$name" "$address" "" named "$db" "$display_name" 5 "/_set domain 1 $name" - expect_no_error + expect_out "^new contact address set$" # --headless is for relays only; a command with a long -t keeps the client online, accepting contacts, for 6 hours nohup "$bin/simplex-chat" -d "$work/$db" -e "/auto_accept on" -t 21600 --user-display-name "$display_name" < /dev/null > "$work/$db.log" 2>&1 & echo $! > "$work/$db.pid" @@ -326,7 +325,7 @@ seed_names() { link=$(channel_link ciboth) register_name ciboth.testing "$address" "$link" named "$admin" "$admin" 5 "/_set domain 1 ciboth.testing" - expect_no_error + expect_out "^new contact address set$" named "$admin" "$admin" 5 "/public group access #ciboth domain=ciboth.testing" expect_out "updated public group access: domain=ciboth.testing" @@ -337,8 +336,8 @@ seed_names() { # What the app will resolve, from a client that knows none of the names' owners. probe_names() { local target pattern - # -s gives its server the names role only to users that exist when the client starts - chat probe 1 "/u" --user-display-name ci_probe + # -s gives its server the names role only for users that exist when the client starts + chat probe 1 "/user" --user-display-name ci_probe for target in "#cinews.testing|ok to connect via relays" "@cialice.testing|contact address: ok to connect" "@cibobby.testing|contact address: ok to connect" "ciboth.testing|You can also connect to @ciboth"; do pattern=${target#*|} target=${target%%|*} @@ -496,14 +495,17 @@ start() { } stop() { - local p processes="directory relay ci_alice ci_robert smp-server names-resolver" + local p processes="directory relay ci_alice ci_robert smp-server" for p in $processes; do if [ -f "$work/$p.pid" ]; then - kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true + stop_process "$p" INT fi done - sleep 3 - for p in $processes; do + # Python, started in the background by a script, ignores SIGINT + if [ -f "$work/names-resolver.pid" ]; then + stop_process names-resolver TERM + fi + for p in $processes names-resolver; do echo "::group::$p log" cat "$work/$p.log" 2> /dev/null || true echo "::endgroup::" @@ -522,7 +524,7 @@ case "${1:-}" in ;; directory-stop) work=$2 - stop_directory + stop_process directory INT ;; directory-start) work=$2 diff --git a/scripts/ci/ios-sim/build-backend.sh b/scripts/ci/ios-sim/build-backend.sh index 44b6c4c0c0..cf319a95dd 100755 --- a/scripts/ci/ios-sim/build-backend.sh +++ b/scripts/ci/ios-sim/build-backend.sh @@ -12,7 +12,6 @@ openssl_dir=$(brew --prefix openssl@3) ghc --version cabal --version -du -sh ~/.cabal/store dist-newstyle || true cat > cabal.project.local < cabal.project.local < (optional) tells that session which directory entries and names its cases use, so none meets an entry already known. +# backend: yes when the cases use the test backend; a run that asks for Claude must then start it too. +backend: yes model: claude-haiku-5-5 layouts: reachable top cases-reachable: U1, U2, U4 and W12 diff --git a/scripts/ci/ios-sim/claude-test.sh b/scripts/ci/ios-sim/claude-test.sh index 377f9d407d..e96d651cc6 100755 --- a/scripts/ci/ios-sim/claude-test.sh +++ b/scripts/ci/ios-sim/claude-test.sh @@ -2,7 +2,8 @@ # Runs the device test plan with Claude Code on this runner after the smoke test, one session per layout, # as claude-scope says. A session drives the app only through the ui helper, and reads and writes only its -# own directory /: prompt.md, report.md, shots/, transcript.jsonl (stream-json). +# own directory, which ends up in /: prompt.md, report.md, shots/, transcript.jsonl (stream-json). +# It passes when every session's final message ends with "CLAUDE-TEST: PASS". # Usage: scripts/ci/ios-sim/claude-test.sh # Needs CLAUDE_CODE_OAUTH_TOKEN, SIM_UDID (the booted simulator with the app) and, when the test backend runs, BACKEND_DIR. @@ -14,6 +15,9 @@ unset CLAUDE_CODE_OAUTH_TOKEN mkdir -p "$1" out=$(cd "$1" && pwd) +# sessions run here and move to the output dir, which is uploaded, only after their files are checked for the token +staging=$out.sessions +mkdir -p "$staging" dir=$(cd "$(dirname "$0")" && pwd) : "${SIM_UDID:?SIM_UDID must be the simulator UDID}" BACKEND_DIR=${BACKEND_DIR:-} @@ -91,14 +95,15 @@ write_prompt() { status=0 previous="" for layout in $layouts; do - session=$out/$layout + session=$staging/$layout mkdir -p "$session/shots" + echo "$layout" >> "$out/sessions" ui layout "$layout" write_prompt "$layout" "$previous" > "$session/prompt.md" echo "::group::Claude session: $layout, $model, $max_turns turns, \$$max_budget" session_status=0 # outside the session's directory, so the session cannot change it - export CLAUDE_GUARD_LOG=$out/$layout-guard.log + export CLAUDE_GUARD_LOG=$staging/$layout-guard.log : > "$CLAUDE_GUARD_LOG" ( cd "$session" @@ -122,11 +127,16 @@ for layout in $layouts; do ) || session_status=$? echo "claude exited with $session_status" # the token must never be printed or uploaded, whatever the session did; grep reads it from a pipe, not from its arguments - if grep -rqF -f <(printf '%s\n' "$token") "$session" "$CLAUDE_GUARD_LOG"; then + leak=0 + grep -rqF -f <(printf '%s\n' "$token") "$session" "$CLAUDE_GUARD_LOG" || leak=$? + if [ "$leak" -ne 1 ]; then rm -rf "$session" "$CLAUDE_GUARD_LOG" - echo "::error::the $layout session's files contained the token, so they were deleted before the upload" + echo "::error::the $layout session's files contain the token, or checking for it failed (grep exited $leak), so they were deleted before the upload" session_status=1 else + mv "$session" "$CLAUDE_GUARD_LOG" "$out/" + session=$out/$layout + CLAUDE_GUARD_LOG=$out/$layout-guard.log tail -n 20 "$session/stderr.log" if [ -f "$session/report.md" ]; then cat "$session/report.md" @@ -135,6 +145,14 @@ for layout in $layouts; do fi echo "guard hook: $(grep -c '^allow' "$CLAUDE_GUARD_LOG" || true) allowed, $(grep -c '^refuse' "$CLAUDE_GUARD_LOG" || true) refused" grep '^refuse' "$CLAUDE_GUARD_LOG" || true + result=$(jq -r -R 'fromjson? | select(.type == "result") | .result // empty' "$session/transcript.jsonl" | tail -n 20 || true) + echo "final message, last lines:" + echo "$result" + last=$(grep -v '^[[:space:]]*$' <<< "$result" | tail -n 1 || true) + if ! grep -qxE '[*`]*CLAUDE-TEST: PASS[*`.]*[[:space:]]*' <<< "$last"; then + echo "::error::the $layout session's final message does not end with CLAUDE-TEST: PASS" + session_status=1 + fi fi echo "::endgroup::" if [ "$session_status" -ne 0 ]; then diff --git a/scripts/ci/ios-sim/claude-usage.sh b/scripts/ci/ios-sim/claude-usage.sh index 79132707cb..ee63e32ef4 100755 --- a/scripts/ci/ios-sim/claude-usage.sh +++ b/scripts/ci/ios-sim/claude-usage.sh @@ -19,10 +19,22 @@ mkdir -p "$out" echo "|---|---|---|---|---|---|---|---|---|---|---|" } >> "$summary" -for transcript in "$out"/*/transcript.jsonl; do - [ -f "$transcript" ] || continue - session=$(basename "$(dirname "$transcript")") +# claude-test.sh lists each session it starts in /sessions; a session's files reach only once +# they are checked for the token +while read -r session; do sessions=$((sessions + 1)) + transcript=$out/$session/transcript.jsonl + if [ ! -f "$transcript" ]; then + if [ -d "$out.sessions/$session" ]; then + reason="cut short before its files were checked for the token, so they are not uploaded" + else + reason="its files held the token or could not be checked, so they were deleted" + fi + echo "| $session | $reason: usage unknown | | | | | | | | | |" >> "$summary" + mkdir -p "$out/$session" + jq -n --arg s "$session" '{session: $s, result: "missing"}' > "$out/$session/usage.json" + continue + fi result=$(jq -c -R 'fromjson? | select(.type == "result")' "$transcript" | tail -n 1) if [ -z "$result" ]; then echo "| $session | no result event: the session timed out or crashed | | | | | | | | | |" >> "$summary" @@ -31,7 +43,7 @@ for transcript in "$out"/*/transcript.jsonl; do fi jq --arg s "$session" '{session: $s, subtype, is_error, num_turns, duration_ms, usage: (.usage | {input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}), modelUsage, total_cost_usd, permission_denials: (.permission_denials // [] | length)}' <<< "$result" > "$out/$session/usage.json" jq -r --arg s "$session" '"| \($s) | \(.subtype)\(if .is_error then " (error)" else "" end) | \(.num_turns) | \(.duration_ms / 60000 | floor) min | \(.usage.input_tokens) | \(.usage.output_tokens) | \(.usage.cache_read_input_tokens) | \(.usage.cache_creation_input_tokens) | \([.modelUsage // {} | to_entries[] | "\(.key) $\(.value.costUSD // 0 | . * 100 | round / 100)"] | join(", ")) | $\(.total_cost_usd // 0 | . * 100 | round / 100) | \(.permission_denials // [] | length) |"' <<< "$result" >> "$summary" -done +done < <(cat "$out/sessions" 2> /dev/null) if [ "$sessions" -eq 0 ]; then echo "No Claude session ran." >> "$summary" diff --git a/scripts/ci/package-tests.sh b/scripts/ci/package-tests.sh new file mode 100755 index 0000000000..33d83c4986 --- /dev/null +++ b/scripts/ci/package-tests.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +set -euo pipefail + +cd "$(dirname "$0")/../.." +nodejs=packages/simplex-chat-nodejs +python_package=packages/simplex-chat-python +python=$(command -v python3 || command -v python) +temp=${RUNNER_TEMP:-$(mktemp -d)} + +usage() { + echo "usage: $0 build | test | addon | publish [nodejs] [python]" >&2 + exit 1 +} + +build_package() { + (cd packages/simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + (cd "$nodejs" && npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript) + if [ -n "${1:-}" ]; then + (cd "$nodejs" && npx --yes node-gyp rebuild) + fi + (cd "$nodejs" && npx tsc && cp src/simplex.* dist) + if [ -n "${1:-}" ]; then + cp "$nodejs/build/Release/simplex.node" "$nodejs/build/simplex-chat-nodejs-$1.node" + fi +} + +test_nodejs() { + (cd "$nodejs" && npx jest --forceExit --testNamePattern '^(?!.*use preset servers)') +} + +test_python() { + (unset SIMPLEX_LIBS_DIR && cd "$python_package" && "$python" -m pip install -e ".[test]" && "$python" -m pytest -q) +} + +case "${1:-}" in + build) + [ $# -eq 2 ] || usage + build_package "$2" + ;; + test) + [ $# -eq 1 ] || usage + test_nodejs + test_python + ;; + addon) + [ $# -eq 2 ] || usage + build_package "$2" + zip=simplex-chat-libs-$2.zip + "$python" scripts/ci/packages_release.py download "$LIBS_RELEASE" "$zip" "$temp/libs" + "$python" -m zipfile -e "$temp/libs/$zip" "$temp/libs" + SIMPLEX_LIBS_DIR=$temp/libs/libs test_nodejs + gh release upload "$ADDON_TAG" "$nodejs/build/simplex-chat-nodejs-$2.node" --clobber --repo "$LIBS_REPOSITORY" + ;; + publish) + [ $# -ge 2 ] || usage + platform=$2 + shift 2 + for suite in "$@"; do + case "$suite" in + nodejs) + build_package + addon=simplex-chat-nodejs-$platform.node + "$python" scripts/ci/packages_release.py download-pinned "$addon" "$temp/addon" + mkdir -p "$nodejs/build/Release" + cp "$temp/addon/$addon" "$nodejs/build/Release/simplex.node" + test_nodejs + ;; + python) + test_python + ;; + *) + usage + ;; + esac + done + ;; + *) + usage + ;; +esac diff --git a/scripts/ci/packages_release.py b/scripts/ci/packages_release.py index 488d35bb8c..fdfd7c3b68 100644 --- a/scripts/ci/packages_release.py +++ b/scripts/ci/packages_release.py @@ -1,13 +1,21 @@ #!/usr/bin/env python3 """Release automation for simplex-chat and @simplex-chat/types on npm and simplex-chat on PyPI. -addon-tag print the add-on release tag for the current sources -changes print whether the changes since a commit touch the chat core and the add-on -build-plan print the platforms to build the add-on on and whether to run the package tests -prepare update pins and versions for a release PR -publish-plan print the package versions that are not published yet -record record releases and published versions in releases.toml of simplex-chat-libs -test-names rename the packages to NPM_SCOPE and PYPI_PROJECT (test repositories) +core-release set the core version and the types version for a library release +claim-core claim the library release of the core version for a commit +release-status print whether a release of simplex-chat-libs is published, a draft or absent +addon-release print the add-on release of the current add-on sources +latest-libs print the newest published library release +publish-release publish a draft release that has every asset +check-pins check the pins of the packages against the published releases +download download a release asset and check its digest +download-pinned download a pinned asset and check its pin +changes print whether the changes since a commit touch the chat core and the add-on +build-plan print the platforms to build the add-on on and whether to run the package tests +prepare update pins and versions of the packages +publish-plan print the package versions that are not published yet +record record releases and published versions in releases.toml of simplex-chat-libs +test-names rename the packages to NPM_SCOPE and PYPI_PROJECT (test repositories) """ from __future__ import annotations @@ -19,12 +27,14 @@ import hashlib import json import os import re +import shlex import subprocess import sys +import tempfile import tomllib import urllib.error import urllib.request -from collections.abc import Callable +from collections.abc import Callable, Collection from dataclasses import dataclass, field, replace from typing import Any from pathlib import Path @@ -37,7 +47,15 @@ RELEASE_FILE = f"{NODEJS_DIR}/src/release.ts" PYTHON_VERSION_FILE = f"{PYTHON_DIR}/src/simplex_chat/_version.py" ADDON_SOURCES = [f"{NODEJS_DIR}/binding.gyp", f"{NODEJS_DIR}/cpp"] CORE_SOURCES = ["src", "simplex-chat.cabal", "cabal.project"] -ADDON_TAG_HASH_LENGTH = 12 +CABAL_FILE = "simplex-chat.cabal" +CABAL_VERSION = re.compile(r"^(version:\s*)(\S+)", re.M) +TYPES_SOURCES = [f"{TYPES_DIR}/src"] +TYPES_README = f"{TYPES_DIR}/README.md" +TYPES_README_CORE = re.compile(r"^SimpleX Chat core: .*$", re.M) +PYTHON_TYPES_SOURCES = [f"{PYTHON_DIR}/src/simplex_chat/types"] +ADDON_HASH_LENGTH = 12 +CORE_TAG = re.compile(r"^core-(\d+(?:\.\d+)*)$") +ADDON_TAG_PREFIX = "nodejs-addon-" LIBS_ASSETS = [ "simplex-chat-libs-linux-aarch64.zip", "simplex-chat-libs-linux-x86_64-postgres.zip", @@ -54,24 +72,31 @@ ADDON_RUNNERS = { "windows-x86_64": "windows-latest", } ADDON_PLATFORMS = list(ADDON_RUNNERS) -# build.yml builds libsimplex of pull requests on this platform only -CORE_TEST_PLATFORM = "linux-x86_64" ADDON_ASSETS = [f"simplex-chat-nodejs-{platform}.node" for platform in ADDON_PLATFORMS] SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") RELEASES_FILE = "releases.toml" RECORD_ATTEMPTS = 5 +DOWNLOAD_CHUNK = 1 << 20 TOML_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$") # Pure functions -def addon_tag(files: dict[str, bytes], node_addon_api: str) -> str: +def addon_hash(files: dict[str, bytes], node_addon_api: str) -> str: digest = hashlib.sha256() for name in sorted(files): digest.update(name.encode() + b"\0" + files[name] + b"\0") digest.update(f"node-addon-api@{node_addon_api}".encode()) - return f"nodejs-addon-{digest.hexdigest()[:ADDON_TAG_HASH_LENGTH]}" + return digest.hexdigest()[:ADDON_HASH_LENGTH] + + +def addon_notes(sources_hash: str) -> str: + return f"sources: {sources_hash}" + + +def version_key(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) def bump_npm_version(version: str) -> str: @@ -143,7 +168,7 @@ def render_python_version(version: str, pins: Pins) -> str: return ( '"""Package version and the simplex-chat-libs release it loads.\n' "\n" - "Written by scripts/ci/packages_release.py: the release PR bumps the version\n" + "Written by scripts/ci/packages_release.py prepare: it bumps the version\n" "and pins new library releases with the SHA-256 of their assets.\n" '"""\n' "\n" @@ -154,16 +179,84 @@ def render_python_version(version: str, pins: Pins) -> str: ) +def asset_digests(release: dict) -> dict[str, str]: + return {a["name"]: (a.get("digest") or "").removeprefix("sha256:") for a in release["assets"]} + + def release_digests(release: dict | None, assets: list[str]) -> dict[str, str] | None: """Asset digests of a GitHub release, or None when the release is missing or incomplete.""" if release is None: return None - digests = {a["name"]: (a.get("digest") or "").removeprefix("sha256:") for a in release["assets"]} + digests = asset_digests(release) if not all(SHA256_HEX.match(digests.get(asset, "")) for asset in assets): return None return {asset: digests[asset] for asset in assets} +def release_assets(tag: str) -> list[str]: + if CORE_TAG.match(tag): + return LIBS_ASSETS + if tag.startswith(ADDON_TAG_PREFIX): + return ADDON_ASSETS + raise ValueError(f"no asset set for release {tag}") + + +def preferred_release(releases: list[dict]) -> dict | None: + """The published release among them, otherwise the first draft.""" + return next((r for r in releases if not r["draft"]), next(iter(releases), None)) + + +def release_status(release: dict | None) -> str: + if release is None: + return "absent" + return "draft" if release["draft"] else "published" + + +def tag_release(releases: list[dict], tag: str) -> dict | None: + return preferred_release([r for r in releases if r["tag_name"] == tag]) + + +def addon_release(releases: list[dict], sources_hash: str) -> dict | None: + notes = addon_notes(sources_hash) + return preferred_release( + [r for r in releases if r["tag_name"].startswith(ADDON_TAG_PREFIX) and r.get("body") == notes] + ) + + +def core_version(tag: str) -> str | None: + match = CORE_TAG.match(tag) + return match[1] if match else None + + +def latest_libs(releases: list[dict], versions: Collection[str]) -> str | None: + """The newest published release of the core versions that has every library asset.""" + tags = [ + r["tag_name"] + for r in releases + if not r["draft"] and core_version(r["tag_name"]) in versions and release_digests(r, LIBS_ASSETS) + ] + return max(tags, key=lambda tag: version_key(core_version(tag)), default=None) + + +def missing_assets(release: dict) -> list[str]: + present = {a["name"] for a in release["assets"]} + return [asset for asset in release_assets(release["tag_name"]) if asset not in present] + + +def pin_problems(pins: Pins, published: dict[str, dict | None]) -> list[str]: + """Differences between the pins and the published releases of the pinned tags.""" + problems = [] + for tag, release in published.items(): + if release is None: + problems.append(f"{tag} is not published") + continue + digests = asset_digests(release) + for asset in release_assets(tag): + if pins.sha256.get(asset) != digests.get(asset): + problems.append(f"{asset} of {tag}: pinned {pins.sha256.get(asset)}, published {digests.get(asset)}") + return problems + + @dataclass(frozen=True) class Package: version: str @@ -179,10 +272,11 @@ class State: addon_digests: dict[str, str] | None nodejs_pins: Pins python_pins: Pins - types: Package + types_dependency: str + nodejs_types_dependency: str nodejs: Package python: Package - types_version: str | None = None + packages: frozenset[str] = frozenset({"nodejs", "python"}) nodejs_version: str | None = None python_version: str | None = None @@ -191,23 +285,16 @@ class State: class Plan: nodejs_pins: Pins python_pins: Pins - types_version: str - types_bumped: bool + types_dependency: str | None nodejs_version: str nodejs_changed: bool python_version: str python_changed: bool notes: list[str] = field(default_factory=list) - @property - def types_dependency(self) -> str | None: - return f"^{self.types_version}" if self.types_bumped else None - @property def title(self) -> str: parts = [] - if self.types_bumped: - parts.append(f"types {self.types_version}") if self.nodejs_changed: parts.append(f"nodejs {self.nodejs_version}") if self.python_changed: @@ -216,7 +303,7 @@ class Plan: def plan(state: State) -> Plan: - """What a release PR changes, from the state of the branch and the releases.""" + """What a package release changes, from the state of the branch and the releases.""" notes: list[str] = [] def pin(pins: Pins, field_name: str, tag: str, digests: dict[str, str] | None, what: str) -> tuple[Pins, bool]: @@ -229,27 +316,30 @@ def plan(state: State) -> Plan: notes.append(f"{what}: {tag}") return replace(pins, **{field_name: tag, "sha256": {**pins.sha256, **digests}}), True - nodejs_pins, nodejs_libs = pin( - state.nodejs_pins, "libs_release", state.libs_tag, state.libs_digests, "Node.js libsimplex" - ) - nodejs_pins, addon = pin(nodejs_pins, "addon_release", state.addon_tag, state.addon_digests, "Node.js add-on") - python_pins, python_libs = pin( - state.python_pins, "libs_release", state.libs_tag, state.libs_digests, "Python libsimplex" - ) - def next_version(pkg: Package, changed: bool, override: str | None, bump) -> str: if override: return override return bump(pkg.version) if changed and pkg.published else pkg.version - types_version = next_version(state.types, state.types.changed, state.types_version, bump_npm_version) - types_bumped = types_version != state.types.version - nodejs_changed = nodejs_libs or addon or types_bumped or state.nodejs.changed - nodejs_version = next_version(state.nodejs, nodejs_changed, state.nodejs_version, bump_npm_version) - python_changed = python_libs or state.python.changed - python_version = next_version(state.python, python_changed, state.python_version, bump_python_version) + nodejs_pins, nodejs_version, nodejs_changed, types_dependency = state.nodejs_pins, state.nodejs.version, False, None + if "nodejs" in state.packages: + nodejs_pins, nodejs_libs = pin( + state.nodejs_pins, "libs_release", state.libs_tag, state.libs_digests, "Node.js libsimplex" + ) + nodejs_pins, addon = pin(nodejs_pins, "addon_release", state.addon_tag, state.addon_digests, "Node.js add-on") + if state.types_dependency != state.nodejs_types_dependency: + types_dependency = state.types_dependency + notes.append(f"@simplex-chat/types: {types_dependency}") + nodejs_changed = nodejs_libs or addon or types_dependency is not None or state.nodejs.changed + nodejs_version = next_version(state.nodejs, nodejs_changed, state.nodejs_version, bump_npm_version) + python_pins, python_version, python_changed = state.python_pins, state.python.version, False + if "python" in state.packages: + python_pins, python_libs = pin( + state.python_pins, "libs_release", state.libs_tag, state.libs_digests, "Python libsimplex" + ) + python_changed = python_libs or state.python.changed + python_version = next_version(state.python, python_changed, state.python_version, bump_python_version) for name, old, new in [ - ("@simplex-chat/types", state.types.version, types_version), ("simplex-chat (npm)", state.nodejs.version, nodejs_version), ("simplex-chat (PyPI)", state.python.version, python_version), ]: @@ -258,8 +348,7 @@ def plan(state: State) -> Plan: return Plan( nodejs_pins=nodejs_pins, python_pins=python_pins, - types_version=types_version, - types_bumped=types_bumped, + types_dependency=types_dependency, nodejs_version=nodejs_version, nodejs_changed=nodejs_changed or nodejs_version != state.nodejs.version, python_version=python_version, @@ -274,33 +363,49 @@ class BuildPlan: test: bool -def build_plan( - event: str, release_missing: bool, libs_artifact: bool, libs_release: str, core_changed: bool, addon_changed: bool -) -> BuildPlan: - """Each package test runs once, against the libsimplex of the change: a pull request that changes the core - is tested by build.yml with its own libsimplex (libs_artifact), a new library release with that release.""" - if libs_artifact: - return BuildPlan([CORE_TEST_PLATFORM], test=True) - if libs_release: - return BuildPlan(ADDON_PLATFORMS, test=True) - if event == "pull_request": - if core_changed: - return BuildPlan(ADDON_PLATFORMS if addon_changed else [], test=False) - return BuildPlan(ADDON_PLATFORMS, test=True) - return BuildPlan(ADDON_PLATFORMS if release_missing else [], test=True) +def build_plan(core_changed: bool, addon_changed: bool) -> BuildPlan: + """The package tests of a pull request that changes the core run in build.yml, with its own libsimplex.""" + if core_changed: + return BuildPlan(ADDON_PLATFORMS if addon_changed else [], test=False) + return BuildPlan(ADDON_PLATFORMS, test=True) def build_matrix(platforms: list[str]) -> str: return json.dumps([{"platform": platform, "os": ADDON_RUNNERS[platform]} for platform in platforms]) -def recorded_commit(releases: dict, key: str, version: str) -> str | None: - """The simplex-chat commit recorded for a version; key is "core", "npm/" or "pypi/".""" +def core_key(branch: str) -> str: + return f"core/{branch}" + + +def recorded_versions(releases: dict, key: str) -> dict[str, str]: + """The recorded versions and their simplex-chat commits; key is "core/", "npm/" or "pypi/".""" table = releases for part in key.split("/", 1): table = table.get(part, {}) - commit = table.get(version) - return commit if isinstance(commit, str) else None + return {version: commit for version, commit in table.items() if isinstance(commit, str)} + + +def recorded_commit(releases: dict, key: str, version: str) -> str | None: + return recorded_versions(releases, key).get(version) + + +def highest_recorded(releases: dict, key: str) -> str | None: + return max(recorded_versions(releases, key), key=version_key, default=None) + + +def recorded_core_commit(releases: dict, version: str) -> str | None: + """The commit recorded for the core version on any branch: a release tag is shared by the branches.""" + for branch in releases.get("core", {}): + if commit := recorded_commit(releases, core_key(branch), version): + return commit + return None + + +def previous_core_commit(releases: dict, branch: str) -> str | None: + """The commit of the highest core version recorded for the branch.""" + previous = highest_recorded(releases, core_key(branch)) + return recorded_commit(releases, core_key(branch), previous) if previous else None def add_releases(releases: dict, entries: list[tuple[str, str]], commit: str) -> dict: @@ -361,14 +466,18 @@ def write_json(file: str, value: dict) -> None: (ROOT / file).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n") -def current_addon_tag() -> str: +def current_addon_hash() -> str: files = { path.relative_to(ROOT).as_posix(): path.read_bytes() for source in ADDON_SOURCES for path in ([ROOT / source] if (ROOT / source).is_file() else sorted((ROOT / source).rglob("*"))) if path.is_file() } - return addon_tag(files, read_json(f"{NODEJS_DIR}/package.json")["devDependencies"]["node-addon-api"]) + return addon_hash(files, read_json(f"{NODEJS_DIR}/package.json")["devDependencies"]["node-addon-api"]) + + +def cabal_version() -> str: + return CABAL_VERSION.search(read_text(CABAL_FILE))[2] def changed_since(base: str, sources: list[str]) -> bool: @@ -382,14 +491,9 @@ def changes(base: str) -> None: def print_build_plan(args: argparse.Namespace) -> None: - pull_request = args.event == "pull_request" and not args.libs_artifact result = build_plan( - args.event, - release_missing=args.release_missing, - libs_artifact=args.libs_artifact, - libs_release=args.libs_release, - core_changed=pull_request and changed_since(args.base, CORE_SOURCES), - addon_changed=pull_request and changed_since(args.base, ADDON_SOURCES), + core_changed=changed_since(args.base, CORE_SOURCES), + addon_changed=changed_since(args.base, ADDON_SOURCES), ) print(f"platforms={build_matrix(result.platforms)}") print(f"test={'true' if result.test else ''}") @@ -434,10 +538,13 @@ def http_json(url: str, headers: dict[str, str] | None = None, method: str = "GE # Workflows pass unset repository variables as empty strings. -def npm_published(name: str, version: str) -> bool: +def npm_package_url(name: str) -> str: registry = os.environ.get("NPM_REGISTRY") or "https://registry.npmjs.org" - package = registry_name(name, os.environ.get("NPM_SCOPE")).replace("/", "%2F") - return http_json(f"{registry}/{package}/{version}") is not None + return f"{registry}/{registry_name(name, os.environ.get('NPM_SCOPE')).replace('/', '%2F')}" + + +def npm_published(name: str, version: str) -> bool: + return http_json(f"{npm_package_url(name)}/{version}") is not None def python_project() -> str: @@ -450,19 +557,50 @@ def pypi_published(version: str) -> bool: return http_json(f"{index}/pypi/{project}/{version}/json") is not None +def libs_repository() -> str: + return os.environ.get("LIBS_REPOSITORY") or "simplex-chat/simplex-chat-libs" + + def libs_api(path: str, method: str = "GET", body: dict | None = None) -> Any: """GitHub API of the simplex-chat-libs repository.""" - repo = os.environ.get("LIBS_REPOSITORY") or "simplex-chat/simplex-chat-libs" headers = {"Accept": "application/vnd.github+json"} if token := os.environ.get("GH_TOKEN"): headers["Authorization"] = f"Bearer {token}" - return http_json(f"https://api.github.com/repos/{repo}/{path}", headers, method, body) + return http_json(f"https://api.github.com/repos/{libs_repository()}/{path}", headers, method, body) def github_release(tag: str) -> dict | None: + """The published release of the tag.""" return libs_api(f"releases/tags/{tag}") +def libs_releases() -> list[dict]: + """The releases of simplex-chat-libs; drafts are listed only with a token that can push to it.""" + releases: list[dict] = [] + page = 1 + while batch := libs_api(f"releases?per_page=100&page={page}"): + releases += batch + page += 1 + return releases + + +def download(tag: str, asset: str, directory: str, sha256: str) -> Path: + """Saves the asset of the release to the directory; exits when its SHA-256 differs.""" + url = f"https://github.com/{libs_repository()}/releases/download/{tag}/{asset}" + target = Path(directory) / asset + target.parent.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256() + request = urllib.request.Request(url, headers={"User-Agent": "simplex-chat-release"}) + with urllib.request.urlopen(request, timeout=600) as response, target.open("wb") as file: + while chunk := response.read(DOWNLOAD_CHUNK): + file.write(chunk) + digest.update(chunk) + if digest.hexdigest() != sha256: + target.unlink() + sys.exit(f"SHA-256 of {asset} of {tag} is {digest.hexdigest()}, expected {sha256}") + return target + + def read_releases() -> tuple[dict, str | None]: """The releases and the blob of the file, which a change must name.""" file = libs_api(f"contents/{RELEASES_FILE}") @@ -500,6 +638,13 @@ def commit_exists(commit: str) -> bool: return found.returncode == 0 +def fetch_commit(commit: str) -> str: + """The commit, fetched from origin when the checkout lacks it.""" + if not commit_exists(commit): + git("fetch", "-q", "--depth=1", "origin", commit) + return commit + + def npm_key(directory: str) -> str: return f"npm/{read_json(f'{directory}/package.json')['name']}" @@ -522,21 +667,113 @@ def npm_package(directory: str, releases: dict) -> Package: ) -def prepare(args: argparse.Namespace) -> None: - cabal_version = read_field("simplex-chat.cabal", r"^version:\s*(\S+)") - libs_tag = f"core-{cabal_version}" - addon = current_addon_tag() - python_version, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) +def types_changed(releases: dict, branch: str) -> bool: + """Whether the types sources differ from those of the previous core release on the branch.""" + previous = previous_core_commit(releases, branch) + return previous is None or changed_since(fetch_commit(previous), TYPES_SOURCES) + + +def types_problem(version: str, releases: dict, branch: str) -> str | None: + """Why the types can't be released with core , or None when they can.""" + if not types_changed(releases, branch): + return None + types = read_json(f"{TYPES_DIR}/package.json") + if recorded_commit(releases, npm_key(TYPES_DIR), types["version"]) or npm_published(types["name"], types["version"]): + return f"{types['name']} changed, and its version {types['version']} is published" + if types.get("simplexChatCore") != version: + return f"simplexChatCore of {types['name']} is {types.get('simplexChatCore')}, expected {version}" + return None + + +def claim_core(version: str, commit: str, branch: str) -> None: releases, _ = read_releases() + highest = highest_recorded(releases, core_key(branch)) + if highest and version_key(version) < version_key(highest): + sys.exit(f"core {version} is lower than core {highest} recorded for {branch}") + if recorded := recorded_core_commit(releases, version): + print(f"claim={'resume' if recorded == commit else 'skip'}") + return + if problem := types_problem(version, releases, branch): + sys.exit(problem) + libs_commit = record_releases([(core_key(branch), version)], commit) + releases, _ = read_releases() + if recorded_core_commit(releases, version) == commit: + print("claim=new") + print(f"commit={libs_commit}") + else: + print("claim=skip") + + +def current_branch() -> str: + return git("rev-parse", "--abbrev-ref", "HEAD") + + +def core_release(version: str, branch: str, types_version: str | None) -> None: + releases, _ = read_releases() + highest = highest_recorded(releases, core_key(branch)) + if highest and version_key(version) <= version_key(highest): + sys.exit(f"core {version} is not higher than core {highest} recorded for {branch}") + (ROOT / CABAL_FILE).write_text(CABAL_VERSION.sub(rf"\g<1>{version}", read_text(CABAL_FILE), count=1)) + print(f"{CABAL_FILE}: {version}") + if not types_version and not types_changed(releases, branch): + return + types = read_json(f"{TYPES_DIR}/package.json") + if not types_version: + previous = previous_core_commit(releases, branch) + released = json.loads(git("show", f"{previous}:{TYPES_DIR}/package.json")) if previous else types + types_version = bump_npm_version(released["version"]) + readme, found = TYPES_README_CORE.subn(f"SimpleX Chat core: {version}", read_text(TYPES_README), count=1) + if not found: + sys.exit(f"{TYPES_README} has no line 'SimpleX Chat core: '") + write_json(f"{TYPES_DIR}/package.json", {**types, "version": types_version, "simplexChatCore": version}) + (ROOT / TYPES_README).write_text(readme) + print(f"{types['name']}: {types_version}") + + +def pinned_tags(pins: Pins) -> list[str]: + return [tag for tag in (pins.libs_release, pins.addon_release) if tag] + + +def published_pin_problems(pins: Pins) -> list[str]: + return pin_problems(pins, {tag: github_release(tag) for tag in pinned_tags(pins)}) + + +def prepare(args: argparse.Namespace) -> None: + packages = frozenset(args.packages or ["nodejs", "python"]) + branch = args.branch or current_branch() + releases, _ = read_releases() + libs = libs_releases() + libs_tag = latest_libs(libs, recorded_versions(releases, core_key(branch))) + if libs_tag is None: + sys.exit(f"no published library release recorded for {branch}") + core = core_version(libs_tag) + if version_key(cabal_version()) > version_key(core): + print(f"warning: core-{cabal_version()} is not published, {libs_tag} is pinned", file=sys.stderr) + sources_hash = current_addon_hash() + addon = addon_release(libs, sources_hash) + addon_digests = release_digests(addon, ADDON_ASSETS) if release_status(addon) == "published" else None + if "nodejs" in packages and addon_digests is None: + sys.exit(f"the add-on release of sources {sources_hash} is not published with every asset") + types = read_json(f"{TYPES_DIR}/package.json") + types_name = types["name"] + if "nodejs" in packages and not npm_published(types_name, types["version"]): + sys.exit(f"{types_name} {types['version']} is not published") + if "python" in packages: + core_commit = recorded_commit(releases, core_key(branch), core) + if changed_since(core_commit, PYTHON_TYPES_SOURCES): + sys.exit(f"the Python types differ from those of {libs_tag}") + nodejs = read_json(f"{NODEJS_DIR}/package.json") + python_version, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) result = plan( State( libs_tag=libs_tag, - libs_digests=release_digests(github_release(libs_tag), LIBS_ASSETS), - addon_tag=addon, - addon_digests=release_digests(github_release(addon), ADDON_ASSETS), + libs_digests=release_digests(tag_release(libs, libs_tag), LIBS_ASSETS), + addon_tag=addon["tag_name"] if addon else "", + addon_digests=addon_digests, nodejs_pins=parse_release(read_text(RELEASE_FILE)), python_pins=python_pins, - types=npm_package(TYPES_DIR, releases), + types_dependency=f"^{types['version']}", + nodejs_types_dependency=nodejs["dependencies"][types_name], nodejs=npm_package(NODEJS_DIR, releases), python=package_state( pypi_key(), @@ -547,24 +784,35 @@ def prepare(args: argparse.Namespace) -> None: releases, lambda: pypi_published(python_version), ), - types_version=args.types_version, + packages=packages, nodejs_version=args.nodejs_version, python_version=args.python_version, ) ) - (ROOT / RELEASE_FILE).write_text(render_release(result.nodejs_pins)) - (ROOT / PYTHON_VERSION_FILE).write_text(render_python_version(result.python_version, result.python_pins)) - types = read_json(f"{TYPES_DIR}/package.json") - write_json(f"{TYPES_DIR}/package.json", {**types, "version": result.types_version}) - nodejs = read_json(f"{NODEJS_DIR}/package.json") - dependencies = nodejs["dependencies"] - if result.types_dependency: - dependencies = {**dependencies, "@simplex-chat/types": result.types_dependency} - write_json(f"{NODEJS_DIR}/package.json", {**nodejs, "version": result.nodejs_version, "dependencies": dependencies}) - summary = result.title + "\n\n" + "".join(f"- {note}\n" for note in result.notes) - if args.summary: - Path(args.summary).write_text(summary) - print(summary, end="") + selected_pins = [ + pins for name, pins in [("nodejs", result.nodejs_pins), ("python", result.python_pins)] if name in packages + ] + if problems := [problem for pins in selected_pins for problem in published_pin_problems(pins)]: + sys.exit("\n".join(problems)) + assets = { + (tag, asset, pins.sha256[asset]) + for pins in selected_pins + for tag in pinned_tags(pins) + for asset in release_assets(tag) + } + with tempfile.TemporaryDirectory() as directory: + for tag, asset, sha256 in sorted(assets): + download(tag, asset, directory, sha256) + print(f"checked {asset} of {tag}", file=sys.stderr) + if "nodejs" in packages: + (ROOT / RELEASE_FILE).write_text(render_release(result.nodejs_pins)) + dependencies = nodejs["dependencies"] + if result.types_dependency: + dependencies = {**dependencies, types_name: result.types_dependency} + write_json(f"{NODEJS_DIR}/package.json", {**nodejs, "version": result.nodejs_version, "dependencies": dependencies}) + if "python" in packages: + (ROOT / PYTHON_VERSION_FILE).write_text(render_python_version(result.python_version, result.python_pins)) + print(result.title + "\n\n" + "".join(f"- {note}\n" for note in result.notes), end="") def publish_plan() -> None: @@ -584,7 +832,7 @@ def publish_plan() -> None: def record(args: argparse.Namespace) -> None: versions = [ - ("core", args.core), + (core_key(args.branch), args.core), (npm_key(TYPES_DIR), args.types), (npm_key(NODEJS_DIR), args.nodejs), (pypi_key(), args.python), @@ -622,36 +870,118 @@ def test_names() -> None: ) -def github_bool(value: str) -> bool: - """GitHub expressions pass booleans to the shell as "true", "false" or "".""" - return value == "true" +def print_release_status(tag: str) -> None: + print(f"status={release_status(tag_release(libs_releases(), tag))}") + + +def print_addon_release() -> None: + sources_hash = current_addon_hash() + release = addon_release(libs_releases(), sources_hash) + print(f"notes={shlex.quote(addon_notes(sources_hash))}") + print(f"status={release_status(release)}") + print(f"tag={release['tag_name'] if release else ''}") + + +def publish_release(tag: str) -> None: + release = tag_release(libs_releases(), tag) + if release is None: + sys.exit(f"{tag}: no release") + if not release["draft"]: + print(f"{tag} is published") + return + if missing := missing_assets(release): + sys.exit(f"{tag}: missing {', '.join(missing)}") + libs_api(f"releases/{release['id']}", "PATCH", {"draft": False}) + print(f"{tag} published") + + +def check_pins() -> None: + _, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) + problems = published_pin_problems(parse_release(read_text(RELEASE_FILE))) + published_pin_problems(python_pins) + if problems: + sys.exit("\n".join(problems)) + print("the pins match the published releases") + + +def download_asset(tag: str, asset: str, directory: str) -> None: + digests = release_digests(github_release(tag), [asset]) + if digests is None: + sys.exit(f"{asset} of {tag} is not published with a digest") + download(tag, asset, directory, digests[asset]) + + +def download_pinned(asset: str, directory: str) -> None: + pins = parse_release(read_text(RELEASE_FILE)) + tag = next((tag for tag in pinned_tags(pins) if asset in release_assets(tag)), None) + if tag is None or asset not in pins.sha256: + sys.exit(f"{asset} is not pinned in {RELEASE_FILE}") + download(tag, asset, directory, pins.sha256[asset]) def main(argv: list[str]) -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("addon-tag") + branch_help = "the branch of the core versions, the checked out branch by default" + core_release_parser = commands.add_parser("core-release") + core_release_parser.add_argument("version", help="the new core version") + core_release_parser.add_argument("--branch", help=branch_help) + core_release_parser.add_argument("--types-version", help="this types version instead of a bump when needed") + claim_parser = commands.add_parser("claim-core") + claim_parser.add_argument("version", help="the core version of simplex-chat.cabal") + claim_parser.add_argument("commit", help="the simplex-chat commit to release it from") + claim_parser.add_argument("branch", help="the branch of the commit") + status_parser = commands.add_parser("release-status") + status_parser.add_argument("tag") + commands.add_parser("addon-release") + latest_libs_parser = commands.add_parser("latest-libs") + latest_libs_parser.add_argument("--branch", help=branch_help) + publish_release_parser = commands.add_parser("publish-release") + publish_release_parser.add_argument("tag") + commands.add_parser("check-pins") + download_parser = commands.add_parser("download") + download_parser.add_argument("tag") + download_parser.add_argument("asset") + download_parser.add_argument("directory") + download_pinned_parser = commands.add_parser("download-pinned") + download_pinned_parser.add_argument("asset") + download_pinned_parser.add_argument("directory") changes_parser = commands.add_parser("changes") changes_parser.add_argument("base", help="the commit to compare with") build_parser = commands.add_parser("build-plan") - build_parser.add_argument("--event", required=True, help="github.event_name") - build_parser.add_argument("--base", default="", help="the base commit of a pull request") - build_parser.add_argument("--release-missing", type=github_bool, default=False) - build_parser.add_argument("--libs-artifact", type=github_bool, default=False) - build_parser.add_argument("--libs-release", default="") + build_parser.add_argument("--base", required=True, help="the base commit of the pull request") prepare_parser = commands.add_parser("prepare") - for package in ["nodejs", "types", "python"]: + prepare_parser.add_argument("packages", nargs="*", choices=["nodejs", "python"], help="all when none is listed") + prepare_parser.add_argument("--branch", help=branch_help) + for package in ["nodejs", "python"]: prepare_parser.add_argument(f"--{package}-version", help="this version instead of a bump when needed") - prepare_parser.add_argument("--summary", help="file for the PR title and body") commands.add_parser("publish-plan") record_parser = commands.add_parser("record") record_parser.add_argument("--commit", required=True, help="the simplex-chat commit released or published from") + record_parser.add_argument("--branch", default="master", help="the branch of the core version") for name in ["core", "types", "nodejs", "python"]: record_parser.add_argument(f"--{name}", default="", help=f"{name} version, recorded when not empty") commands.add_parser("test-names") args = parser.parse_args(argv) - if args.command == "addon-tag": - print(current_addon_tag()) + if args.command == "core-release": + core_release(args.version, args.branch or current_branch(), args.types_version) + elif args.command == "claim-core": + claim_core(args.version, args.commit, args.branch) + elif args.command == "release-status": + print_release_status(args.tag) + elif args.command == "addon-release": + print_addon_release() + elif args.command == "latest-libs": + releases, _ = read_releases() + versions = recorded_versions(releases, core_key(args.branch or current_branch())) + print(f"tag={latest_libs(libs_releases(), versions) or ''}") + elif args.command == "publish-release": + publish_release(args.tag) + elif args.command == "check-pins": + check_pins() + elif args.command == "download": + download_asset(args.tag, args.asset, args.directory) + elif args.command == "download-pinned": + download_pinned(args.asset, args.directory) elif args.command == "changes": changes(args.base) elif args.command == "build-plan": diff --git a/scripts/ci/prune-old-builds.sh b/scripts/ci/prune-old-builds.sh new file mode 100755 index 0000000000..520fb6bf45 --- /dev/null +++ b/scripts/ci/prune-old-builds.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +version=$(awk '/^version:/ {print $2}' simplex-chat.cabal | tr -d '\r') +for dir in "$@"; do + if [ -d "$dir/build" ]; then + find "$dir/build" -mindepth 3 -maxdepth 3 -type d -name 'simplex-chat-*' ! -name "simplex-chat-$version" -exec rm -rf {} + + fi +done diff --git a/scripts/ci/source-timestamps.sh b/scripts/ci/source-timestamps.sh new file mode 100755 index 0000000000..e8bc8b1cef --- /dev/null +++ b/scripts/ci/source-timestamps.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +command=$1 +hashes=$2 + +sources() { + git ls-files -z '*.hs' '*.hsc' +} + +case "$command" in + record) + sources | xargs -0 sha256sum > "$hashes" + ;; + restore) + sources | xargs -0 touch + while read -r hash file; do + if [ -f "$file" ] && [ "$(sha256sum "$file" | cut -d' ' -f1)" = "$hash" ]; then + touch -d 2000-01-01 "$file" + fi + done < "$hashes" + ;; + *) + echo "usage: $0 record|restore " >&2 + exit 1 + ;; +esac diff --git a/scripts/ci/test_packages_release.py b/scripts/ci/test_packages_release.py index 634260cabc..994e678690 100644 --- a/scripts/ci/test_packages_release.py +++ b/scripts/ci/test_packages_release.py @@ -1,7 +1,10 @@ +import argparse import base64 import contextlib import email.message +import hashlib import io +import json import subprocess import tomllib import urllib.error @@ -14,26 +17,41 @@ from unittest import mock import packages_release from packages_release import ( ADDON_ASSETS, + CABAL_FILE, NODEJS_DIR, + PYTHON_DIR, PYTHON_VERSION_FILE, + RELEASE_FILE, TYPES_DIR, + TYPES_README, ADDON_PLATFORMS, BuildPlan, LIBS_ASSETS, Package, Pins, State, - addon_tag, + addon_hash, + addon_notes, + addon_release, build_matrix, build_plan, bump_npm_version, bump_python_version, changed_since, add_releases, + claim_core, + core_release, + download, + highest_recorded, + latest_libs, + missing_assets, package_state, + pin_problems, + prepare, publish_plan, record_releases, recorded_commit, + release_status, render_releases, dist_tag, npm_published, @@ -45,6 +63,8 @@ from packages_release import ( release_digests, render_python_version, render_release, + tag_release, + types_problem, ) @@ -60,6 +80,10 @@ def github_release(assets: list[str], n: int) -> dict: return {"assets": [{"name": name, "digest": f"sha256:{sha(n)}"} for name in assets]} +def listed_release(tag: str, assets: list[str], n: int, draft: bool = False, body: str | None = None) -> dict: + return {"id": n, "tag_name": tag, "draft": draft, "body": body, **github_release(assets, n)} + + def state(**overrides) -> State: current = State( libs_tag="core-1.0.0.1", @@ -68,7 +92,8 @@ def state(**overrides) -> State: addon_digests=digests(ADDON_ASSETS, 2), nodejs_pins=Pins("core-1.0.0.1", {**digests(LIBS_ASSETS, 1), **digests(ADDON_ASSETS, 2)}, "nodejs-addon-aaa"), python_pins=Pins("core-1.0.0.1", digests(LIBS_ASSETS, 1)), - types=Package("0.12.1", changed=False, published=True), + types_dependency="^0.12.1", + nodejs_types_dependency="^0.12.1", nodejs=Package("7.1.0-beta.6.1", changed=False, published=True), python=Package("7.1.0b6", changed=False, published=True), ) @@ -101,15 +126,15 @@ class VersionTest(unittest.TestCase): self.assertEqual(registry_name("simplex-chat", None), "simplex-chat") -class AddonTagTest(unittest.TestCase): +class AddonHashTest(unittest.TestCase): def test_depends_on_sources_and_node_addon_api(self): files = {"a.cc": b"1", "b.h": b"2"} - tag = addon_tag(files, "^8.5.0") - self.assertRegex(tag, r"^nodejs-addon-[0-9a-f]{12}$") - self.assertEqual(addon_tag(dict(reversed(files.items())), "^8.5.0"), tag) - self.assertNotEqual(addon_tag({**files, "b.h": b"3"}, "^8.5.0"), tag) - self.assertNotEqual(addon_tag({"a.cc": b"1", "c.h": b"2"}, "^8.5.0"), tag) - self.assertNotEqual(addon_tag(files, "^8.6.0"), tag) + sources_hash = addon_hash(files, "^8.5.0") + self.assertRegex(sources_hash, r"^[0-9a-f]{12}$") + self.assertEqual(addon_hash(dict(reversed(files.items())), "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash({**files, "b.h": b"3"}, "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash({"a.cc": b"1", "c.h": b"2"}, "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash(files, "^8.6.0"), sources_hash) class PinFilesTest(unittest.TestCase): @@ -135,7 +160,7 @@ class ReleaseDigestsTest(unittest.TestCase): class PlanTest(unittest.TestCase): def test_nothing_changes_when_current(self): result = plan(state()) - self.assertEqual((result.nodejs_changed, result.python_changed, result.types_bumped), (False, False, False)) + self.assertEqual((result.nodejs_changed, result.python_changed, result.types_dependency), (False, False, None)) self.assertEqual(result.notes, []) def test_new_libs_release_updates_both_libraries(self): @@ -170,17 +195,17 @@ class PlanTest(unittest.TestCase): plan(state(python=Package("7.1.0b6", True, True))).title, "packages: release python 7.1.0b6.post1" ) - def test_types_bump_updates_the_nodejs_dependency(self): - result = plan(state(types=Package("0.12.1", True, True))) + def test_new_types_version_updates_the_nodejs_dependency(self): + result = plan(state(types_dependency="^0.12.2")) self.assertEqual(result.types_dependency, "^0.12.2") - self.assertEqual(result.title, "packages: release types 0.12.2, nodejs 7.1.0-beta.6.2") + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2") def test_all_changes_combine_into_one_release(self): result = plan( state( **NEW_LIBS, **NEW_ADDON, - types=Package("0.12.1", True, True), + types_dependency="^0.12.2", nodejs=Package("7.1.0-beta.6.1", True, True), python=Package("7.1.0b6", True, True), ) @@ -188,7 +213,16 @@ class PlanTest(unittest.TestCase): self.assertEqual( (result.nodejs_pins.libs_release, result.nodejs_pins.addon_release), ("core-1.0.0.2", "nodejs-addon-bbb") ) - self.assertEqual(result.title, "packages: release types 0.12.2, nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + + def test_unlisted_package_keeps_its_pins_and_version(self): + result = plan(state(**NEW_LIBS, types_dependency="^0.12.2", packages=frozenset({"python"}))) + self.assertEqual(result.nodejs_pins, state().nodejs_pins) + self.assertEqual( + (result.nodejs_version, result.nodejs_changed, result.types_dependency), ("7.1.0-beta.6.1", False, None) + ) + self.assertEqual(result.python_pins.libs_release, "core-1.0.0.2") + self.assertEqual(result.title, "packages: release python 7.1.0b6.post1") def test_unpublished_versions_are_not_bumped_again(self): result = plan( @@ -199,34 +233,18 @@ class PlanTest(unittest.TestCase): self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") def test_requested_versions_win(self): - result = plan(state(nodejs_version="7.2.0", types_version="0.13.0", python_version="7.2.0")) - self.assertEqual(result.types_dependency, "^0.13.0") - self.assertEqual(result.title, "packages: release types 0.13.0, nodejs 7.2.0, python 7.2.0") + result = plan(state(nodejs_version="7.2.0", python_version="7.2.0")) + self.assertEqual(result.title, "packages: release nodejs 7.2.0, python 7.2.0") class BuildPlanTest(unittest.TestCase): - def plan(self, event="push", release_missing=False, libs_artifact=False, libs_release="", core=False, addon=False): - return build_plan(event, release_missing, libs_artifact, libs_release, core, addon) - - def test_push_builds_a_missing_addon_release_and_tests_it(self): - self.assertEqual(self.plan(release_missing=True), BuildPlan(ADDON_PLATFORMS, test=True)) - self.assertEqual(self.plan(), BuildPlan([], test=True)) - def test_pull_request_tests_with_the_pinned_libs(self): - self.assertEqual(self.plan("pull_request"), BuildPlan(ADDON_PLATFORMS, test=True)) - self.assertEqual(self.plan("pull_request", addon=True), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=False, addon_changed=False), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=False, addon_changed=True), BuildPlan(ADDON_PLATFORMS, test=True)) def test_core_change_leaves_tests_to_build_yml(self): - self.assertEqual(self.plan("pull_request", core=True), BuildPlan([], test=False)) - self.assertEqual(self.plan("pull_request", core=True, addon=True), BuildPlan(ADDON_PLATFORMS, test=False)) - - def test_libsimplex_of_the_pull_request_is_tested_on_its_platform(self): - self.assertEqual( - self.plan("pull_request", libs_artifact=True, core=True), BuildPlan(["linux-x86_64"], test=True) - ) - - def test_new_library_release_is_tested_on_every_platform(self): - self.assertEqual(self.plan(libs_release="core-1.0.0.2"), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=True, addon_changed=False), BuildPlan([], test=False)) + self.assertEqual(build_plan(core_changed=True, addon_changed=True), BuildPlan(ADDON_PLATFORMS, test=False)) def test_matrix_names_the_runner_of_each_platform(self): self.assertEqual( @@ -302,6 +320,12 @@ class ReleasesFileTest(unittest.TestCase): self.assertIsNone(recorded_commit(self.RELEASES, "npm/simplex-chat", "7.1.0-beta.6.4")) self.assertIsNone(recorded_commit({}, "pypi/simplex-chat", "7.1.0b6")) + def test_highest_recorded_version_is_compared_numerically(self): + releases = {"core": {"7.1.0.9": "a", "7.1.0.11": "b"}, "npm": {"@simplex-chat/types": {"0.9.0": "c"}}} + self.assertEqual(highest_recorded(releases, "core"), "7.1.0.11") + self.assertEqual(highest_recorded(releases, "npm/@simplex-chat/types"), "0.9.0") + self.assertIsNone(highest_recorded(releases, "npm/simplex-chat")) + def test_adds_entries_and_keeps_recorded_commits(self): updated = add_releases(self.RELEASES, [("npm/simplex-chat", "7.1.0-beta.6.4"), ("core", "7.1.0.11")], "x") self.assertEqual(updated["npm"]["simplex-chat"], {"7.1.0-beta.6.3": "n1", "7.1.0-beta.6.4": "x"}) @@ -360,13 +384,10 @@ class RecordReleasesTest(unittest.TestCase): self.assertEqual(record_releases([("core", "1")], "other"), "libs0") -class PackageStateTest(unittest.TestCase): +class GitRepoTest(unittest.TestCase): def setUp(self): self.repo = Path(tempfile.mkdtemp()) self.git("init", "-q") - self.write("pkg/version.txt", 'version = "1"\n') - self.write("pkg/src.txt", "a") - self.commit("set version") def git(self, *args): subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=self.repo, check=True) @@ -379,6 +400,19 @@ class PackageStateTest(unittest.TestCase): self.git("add", "-A") self.git("commit", "-q", "-m", message) + def head(self): + return subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, text=True + ).stdout.strip() + + +class PackageStateTest(GitRepoTest): + def setUp(self): + super().setUp() + self.write("pkg/version.txt", 'version = "1"\n') + self.write("pkg/src.txt", "a") + self.commit("set version") + def state(self, releases=None, registry_published=False): with mock.patch.object(packages_release, "ROOT", self.repo): return package_state( @@ -388,11 +422,6 @@ class PackageStateTest(unittest.TestCase): def changed(self): return self.state().changed - def head(self): - return subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, text=True - ).stdout.strip() - def test_recorded_version_is_published_and_compared_with_its_commit(self): self.write("pkg/src.txt", "b") self.commit("change published with version 1") @@ -472,5 +501,303 @@ class PackageStateTest(unittest.TestCase): self.assertFalse(self.changed()) +class ReleasesTest(unittest.TestCase): + def test_latest_libs_is_the_highest_complete_published_version(self): + releases = [ + listed_release("core-7.1.0.9", LIBS_ASSETS, 1), + listed_release("core-7.1.0.11", LIBS_ASSETS, 2), + listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 3), + listed_release("core-7.1.0.13", LIBS_ASSETS, 4, draft=True), + listed_release("core-test-7.1.0.14", LIBS_ASSETS, 5), + listed_release("nodejs-addon-1111111", ADDON_ASSETS, 6), + ] + versions = {"7.1.0.9", "7.1.0.11", "7.1.0.12", "7.1.0.13", "test-7.1.0.14"} + self.assertEqual(latest_libs(releases, versions), "core-7.1.0.11") + self.assertEqual(latest_libs(releases, {"7.1.0.9"}), "core-7.1.0.9") + self.assertIsNone(latest_libs(releases, {"7.0.0.12"})) + + def test_published_release_is_preferred_to_a_draft(self): + draft = listed_release("core-7.1.0.12", LIBS_ASSETS, 1, draft=True) + published = listed_release("core-7.1.0.12", LIBS_ASSETS, 2) + self.assertEqual(tag_release([draft, published], "core-7.1.0.12"), published) + self.assertEqual(tag_release([draft], "core-7.1.0.12"), draft) + statuses = [ + release_status(tag_release(releases, "core-7.1.0.12")) for releases in ([draft, published], [draft], []) + ] + self.assertEqual(statuses, ["published", "draft", "absent"]) + + def test_addon_release_is_found_by_its_notes(self): + other = listed_release("nodejs-addon-1111111", ADDON_ASSETS, 1, body=addon_notes("bbb")) + draft = listed_release("nodejs-addon-2222222", ADDON_ASSETS, 2, draft=True, body=addon_notes("aaa")) + published = listed_release("nodejs-addon-3333333", ADDON_ASSETS, 3, body=addon_notes("aaa")) + self.assertEqual(addon_release([other, draft, published], "aaa"), published) + self.assertEqual(addon_release([other, draft], "aaa"), draft) + self.assertIsNone(addon_release([other], "aaa")) + + def test_missing_assets_of_a_draft(self): + self.assertEqual(missing_assets(listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 1)), LIBS_ASSETS[:1]) + self.assertEqual(missing_assets(listed_release("nodejs-addon-1111111", ADDON_ASSETS, 1)), []) + + def test_pin_problems_list_mismatches_and_unpublished_releases(self): + pins = Pins("core-7.1.0.11", {**digests(LIBS_ASSETS, 1), **digests(ADDON_ASSETS, 2)}, "nodejs-addon-1111111") + published = { + "core-7.1.0.11": github_release(LIBS_ASSETS, 1), + "nodejs-addon-1111111": github_release(ADDON_ASSETS, 2), + } + self.assertEqual(pin_problems(pins, published), []) + self.assertEqual( + len(pin_problems(pins, {**published, "core-7.1.0.11": github_release(LIBS_ASSETS, 3)})), len(LIBS_ASSETS) + ) + self.assertEqual( + pin_problems(pins, {**published, "nodejs-addon-1111111": None}), ["nodejs-addon-1111111 is not published"] + ) + + +class PublishReleaseTest(unittest.TestCase): + def publish(self, releases): + api = mock.Mock() + with ( + mock.patch.object(packages_release, "libs_releases", return_value=releases), + mock.patch.object(packages_release, "libs_api", api), + contextlib.redirect_stdout(io.StringIO()), + ): + packages_release.publish_release("core-7.1.0.12") + return api + + def test_complete_draft_is_published(self): + api = self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS, 7, draft=True)]) + api.assert_called_once_with("releases/7", "PATCH", {"draft": False}) + + def test_draft_with_missing_assets_stays_a_draft(self): + with self.assertRaises(SystemExit): + self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 7, draft=True)]) + + def test_published_release_is_left_unchanged(self): + self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS, 7)]).assert_not_called() + + +class ClaimCoreTest(unittest.TestCase): + def claim(self, recorded, after_record=None, problem=None, version="7.1.0.12", branch="master"): + reads = iter([(recorded, "blob"), (after_record or recorded, "blob")]) + self.record = mock.Mock(return_value="libs1") + output = io.StringIO() + with ( + mock.patch.object(packages_release, "read_releases", lambda: next(reads)), + mock.patch.object(packages_release, "record_releases", self.record), + mock.patch.object(packages_release, "types_problem", return_value=problem), + contextlib.redirect_stdout(output), + ): + claim_core(version, "sx1", branch) + return output.getvalue().splitlines() + + def test_lower_version_fails(self): + with self.assertRaises(SystemExit): + self.claim({"core": {"master": {"7.1.0.13": "sx0"}}}) + + def test_version_recorded_for_this_commit_resumes(self): + self.assertEqual(self.claim({"core": {"master": {"7.1.0.12": "sx1"}}}), ["claim=resume"]) + + def test_version_recorded_for_another_commit_is_skipped(self): + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx0"}}}), ["claim=skip"]) + + def test_version_recorded_on_another_branch_is_skipped(self): + recorded = {"core": {"master": {"7.1.0.11": "sx0"}, "stable": {"7.1.0.12": "sx2"}}} + self.assertEqual(self.claim(recorded), ["claim=skip"]) + + def test_failed_types_check_fails_before_recording(self): + with self.assertRaises(SystemExit): + self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, problem="types changed") + self.record.assert_not_called() + + def test_unrecorded_version_is_claimed(self): + claimed = {"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx1"}}} + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, claimed), ["claim=new", "commit=libs1"]) + self.record.assert_called_once_with([("core/master", "7.1.0.12")], "sx1") + + def test_stable_versions_are_compared_with_stable_only(self): + recorded = {"core": {"master": {"7.1.0.11": "sx0"}, "stable": {"7.0.0.12": "sx3"}}} + claimed = {"core": {**recorded["core"], "stable": {"7.0.0.12": "sx3", "7.0.0.13": "sx1"}}} + self.assertEqual( + self.claim(recorded, claimed, version="7.0.0.13", branch="stable"), ["claim=new", "commit=libs1"] + ) + self.record.assert_called_once_with([("core/stable", "7.0.0.13")], "sx1") + with self.assertRaises(SystemExit): + self.claim(recorded, version="7.0.0.11", branch="stable") + + def test_concurrent_claim_of_another_commit_is_skipped(self): + claimed = {"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx2"}}} + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, claimed), ["claim=skip"]) + + +class TypesProblemTest(unittest.TestCase): + def problem(self, types, changed=True, published=False, releases=None): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + (root / TYPES_DIR).mkdir(parents=True) + (root / TYPES_DIR / "package.json").write_text(json.dumps({"name": "@simplex-chat/types", **types})) + with ( + mock.patch.object(packages_release, "ROOT", root), + mock.patch.object(packages_release, "types_changed", return_value=changed), + mock.patch.object(packages_release, "npm_published", return_value=published), + ): + return types_problem("7.1.0.12", releases or {}, "master") + + def test_unchanged_types_pass(self): + self.assertIsNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.11"}, changed=False)) + + def test_changed_types_with_a_new_version_for_the_core_pass(self): + self.assertIsNone(self.problem({"version": "0.12.2", "simplexChatCore": "7.1.0.12"})) + + def test_changed_types_with_a_published_version_fail(self): + self.assertIsNotNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.12"}, published=True)) + recorded = {"npm": {"@simplex-chat/types": {"0.12.1": "c"}}} + self.assertIsNotNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.12"}, releases=recorded)) + + def test_changed_types_for_another_core_fail(self): + self.assertIsNotNone(self.problem({"version": "0.12.2", "simplexChatCore": "7.1.0.11"})) + + +class CoreReleaseTest(GitRepoTest): + def setUp(self): + super().setUp() + self.write(CABAL_FILE, "name: simplex-chat\nversion: 7.1.0.11\n") + types = {"name": "@simplex-chat/types", "version": "0.12.1", "simplexChatCore": "7.1.0.11"} + self.write(f"{TYPES_DIR}/package.json", json.dumps(types, indent=2) + "\n") + self.write(TYPES_README, "# Types\n\nSimpleX Chat core: 7.1.0.11\n") + self.write(f"{TYPES_DIR}/src/types.ts", "a") + self.commit("types 0.12.1") + self.releases = {"core": {"master": {"7.1.0.11": self.head()}}} + + def release(self, version, types_version=None): + with ( + mock.patch.object(packages_release, "ROOT", self.repo), + mock.patch.object(packages_release, "read_releases", return_value=(self.releases, "blob")), + contextlib.redirect_stdout(io.StringIO()), + ): + core_release(version, "master", types_version) + + def read(self, file): + return (self.repo / file).read_text() + + def types(self): + return json.loads(self.read(f"{TYPES_DIR}/package.json")) + + def test_changed_types_get_a_new_version_for_the_core(self): + self.write(f"{TYPES_DIR}/src/types.ts", "b") + self.commit("types change") + self.release("7.1.0.12") + self.assertIn("version: 7.1.0.12\n", self.read(CABAL_FILE)) + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("0.12.2", "7.1.0.12")) + self.assertIn("SimpleX Chat core: 7.1.0.12\n", self.read(TYPES_README)) + + def test_unchanged_types_keep_their_version(self): + self.release("7.1.0.12") + self.assertIn("version: 7.1.0.12\n", self.read(CABAL_FILE)) + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("0.12.1", "7.1.0.11")) + + def test_requested_types_version_wins(self): + self.release("7.1.0.12", "7.1.0-beta.6.1") + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("7.1.0-beta.6.1", "7.1.0.12")) + + def test_types_version_is_bumped_from_the_previous_core_release(self): + self.write(f"{TYPES_DIR}/package.json", json.dumps({**self.types(), "version": "0.12.5"})) + self.write(f"{TYPES_DIR}/src/types.ts", "b") + self.commit("types change and a manual version") + self.release("7.1.0.12") + self.assertEqual(self.types()["version"], "0.12.2") + + def test_version_must_be_higher_than_the_recorded_one(self): + with self.assertRaises(SystemExit): + self.release("7.1.0.11") + self.assertIn("version: 7.1.0.11\n", self.read(CABAL_FILE)) + + +class PrepareTest(GitRepoTest): + def setUp(self): + super().setUp() + nodejs = { + "name": "simplex-chat", + "version": "7.1.0-beta.6.1", + "dependencies": {"@simplex-chat/types": "^0.12.1"}, + "devDependencies": {"node-addon-api": "^8.5.0"}, + } + self.write(CABAL_FILE, "version: 1.0.0.2\n") + self.write(f"{NODEJS_DIR}/binding.gyp", "{}") + self.write(f"{NODEJS_DIR}/cpp/simplex.cc", "add-on") + self.write(f"{NODEJS_DIR}/package.json", json.dumps(nodejs)) + self.write(RELEASE_FILE, render_release(state().nodejs_pins)) + self.write(f"{TYPES_DIR}/package.json", json.dumps({"name": "@simplex-chat/types", "version": "0.12.1"})) + self.write(PYTHON_VERSION_FILE, render_python_version("7.1.0b6", state().python_pins)) + self.write(f"{PYTHON_DIR}/src/simplex_chat/types/_types.py", "a") + self.write(f"{PYTHON_DIR}/pyproject.toml", 'name = "simplex-chat"\n') + self.commit("packages") + with mock.patch.object(packages_release, "ROOT", self.repo): + sources_hash = packages_release.current_addon_hash() + self.libs = [ + listed_release("core-1.0.0.2", LIBS_ASSETS, 3), + listed_release("nodejs-addon-1111111", ADDON_ASSETS, 4, body=addon_notes(sources_hash)), + ] + self.releases = { + "core": {"master": {"1.0.0.2": self.head()}}, + "npm": {"simplex-chat": {"7.1.0-beta.6.1": self.head()}}, + "pypi": {"simplex-chat": {"7.1.0b6": self.head()}}, + } + + def prepare(self, *packages): + published = {release["tag_name"]: release for release in self.libs} + download = mock.Mock() + with ( + mock.patch.object(packages_release, "ROOT", self.repo), + mock.patch.object(packages_release, "read_releases", return_value=(self.releases, "blob")), + mock.patch.object(packages_release, "libs_releases", return_value=self.libs), + mock.patch.object(packages_release, "github_release", published.get), + mock.patch.object(packages_release, "npm_published", return_value=True), + mock.patch.object(packages_release, "pypi_published", return_value=True), + mock.patch.object(packages_release, "download", download), + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + prepare( + argparse.Namespace(packages=list(packages), branch="master", nodejs_version=None, python_version=None) + ) + return download + + def test_listed_package_is_released_alone(self): + download = self.prepare("nodejs") + pins = parse_release((self.repo / RELEASE_FILE).read_text()) + self.assertEqual((pins.libs_release, pins.addon_release), ("core-1.0.0.2", "nodejs-addon-1111111")) + self.assertEqual(json.loads((self.repo / NODEJS_DIR / "package.json").read_text())["version"], "7.1.0-beta.6.2") + python = parse_python_version((self.repo / PYTHON_VERSION_FILE).read_text()) + self.assertEqual(python, ("7.1.0b6", state().python_pins)) + self.assertEqual(download.call_count, len(LIBS_ASSETS) + len(ADDON_ASSETS)) + + def test_python_types_newer_than_the_pinned_core_stop_the_release(self): + self.write(f"{PYTHON_DIR}/src/simplex_chat/types/_types.py", "b") + self.commit("python types change") + with self.assertRaises(SystemExit): + self.prepare("python") + self.assertEqual(parse_python_version((self.repo / PYTHON_VERSION_FILE).read_text())[0], "7.1.0b6") + + +class DownloadTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = directory.name + + def download(self, sha256): + with mock.patch.object(packages_release.urllib.request, "urlopen", return_value=io.BytesIO(b"data")): + return download("core-1.0.0.1", "a.zip", self.directory, sha256) + + def test_matching_digest_keeps_the_file(self): + self.assertEqual(self.download(hashlib.sha256(b"data").hexdigest()).read_bytes(), b"data") + + def test_mismatching_digest_removes_the_file(self): + with self.assertRaises(SystemExit): + self.download(sha(1)) + self.assertFalse((Path(self.directory) / "a.zip").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index 6ee419c5a8..3006f5aad0 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -19,19 +19,20 @@ fi root_dir="$(dirname "$(dirname "$(readlink "$0")")")" cd $root_dir -BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* +DIST_DIR="${DIST_DIR:-dist-newstyle}" +BUILD_DIR=$DIST_DIR/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done #rm -rf $BUILD_DIR if [[ "$DATABASE_BACKEND" == "postgres" ]]; then echo "Building with postgres backend..." - cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library +client_postgres' --constraint 'simplex-chat +client_library +client_postgres' + cabal build --builddir="$DIST_DIR" lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library +client_postgres' --constraint 'simplex-chat +client_library +client_postgres' else echo "Building with sqlite backend..." - cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' + cabal build --builddir="$DIST_DIR" lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' fi cd $BUILD_DIR/build mv libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so libsimplex.so 2> /dev/null || true diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 5563086c29..e69aacd422 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -22,7 +22,7 @@ BUILD_DIR=dist-newstyle/build/$ARCH-*/ghc-*/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done rm -rf $BUILD_DIR diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index 15e021adf7..ad62b2d209 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -19,7 +19,7 @@ BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done # IMPORTANT: in order to get a working build you should use x86_64 MinGW with make, cmake, gcc. # 100% working MinGW is https://github.com/brechtsanders/winlibs_mingw/releases/download/13.1.0-16.0.5-11.0.0-ucrt-r5/winlibs-x86_64-posix-seh-gcc-13.1.0-mingw-w64ucrt-11.0.0-r5.zip diff --git a/scripts/desktop/make-appimage-linux.sh b/scripts/desktop/make-appimage-linux.sh index 650c98d522..ae10710174 100755 --- a/scripts/desktop/make-appimage-linux.sh +++ b/scripts/desktop/make-appimage-linux.sh @@ -39,7 +39,7 @@ cp usr/lib/simplex.png usr/share/icons ln -s usr/bin/*imple* AppRun cp $multiplatform_dir/desktop/src/jvmMain/resources/distribute/*imple*.desktop chat.simplex.app.desktop -sed -i 's|Exec=.*|Exec=simplex|g' *imple*.desktop +sed -i 's|^Exec=[^ ]*|Exec=simplex|' *imple*.desktop sed -i 's|Icon=.*|Icon=simplex|g' *imple*.desktop cp *imple*.desktop usr/share/applications/ cp $multiplatform_dir/desktop/src/jvmMain/resources/distribute/*.appdata.xml usr/share/metainfo diff --git a/scripts/desktop/make-deb-linux.sh b/scripts/desktop/make-deb-linux.sh index 0fa543a81d..5231dea0dd 100755 --- a/scripts/desktop/make-deb-linux.sh +++ b/scripts/desktop/make-deb-linux.sh @@ -48,6 +48,10 @@ dpkg-deb -R ./release/main/deb/simplex*.deb ./extracted rm -f ./extracted/opt/*imple*/lib/app/*skiko-awt-runtime-linux* sed -i -e '/skiko-awt-runtime-linux/d' ./extracted/opt/*imple*/lib/app/simplex.cfg sed -i "/Version/ s/\$/~$VERSION_CODENAME/" ./extracted/DEBIAN/control +# jpackage's desktop entry passes no URL and handles no MIME type; this registers the simplexchat: and simplex: schemes +SCHEME_MIME_TYPE_LINE='MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;' +sed -i -e '/%[uU]/!s|^Exec=.*|& %u|' -e "s|^MimeType=.*|$SCHEME_MIME_TYPE_LINE|" ./extracted/opt/*imple*/lib/*.desktop +grep -qxF "$SCHEME_MIME_TYPE_LINE" ./extracted/opt/*imple*/lib/*.desktop || { echo "desktop entry does not register simplexchat: and simplex:" >&2; exit 1; } find ./extracted/ -exec touch -d "@$SOURCE_DATE_EPOCH" {} + dpkg-deb --build --root-owner-group --uniform-compression ./extracted ./release/main/deb/simplex_${ARCH}.deb diff --git a/scripts/flatpak/chat.simplex.simplex.desktop b/scripts/flatpak/chat.simplex.simplex.desktop index 4f4cd4eece..d18285b278 100644 --- a/scripts/flatpak/chat.simplex.simplex.desktop +++ b/scripts/flatpak/chat.simplex.simplex.desktop @@ -8,3 +8,4 @@ Categories=Utility;Chat;InstantMessaging; Exec=simplex %U Icon=chat.simplex.simplex StartupWMClass=simplex-chat +MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex; diff --git a/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index 4bd7262d17..45bf2a6936 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -246,7 +246,12 @@ Continue?' for arch in $ARCHES; do case "$arch" in armv7a) - build_tag="${tag}-armv7a" + # Releases made before the master-android branch was merged have armv7a in a separate tag. + if git -C "$build_directory" rev-parse -q --verify "refs/tags/${tag}-armv7a" > /dev/null; then + build_tag="${tag}-armv7a" + else + build_tag="${tag}" + fi ;; aarch64) build_tag="${tag}" diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ded7e0aa80..e4d805d99e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.1.0.11 +version: 7.1.0.12 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -171,6 +171,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification + Simplex.Chat.Store.Postgres.Migrations.M20261016_badge_store_receipts else exposed-modules: Simplex.Chat.Archive @@ -351,6 +352,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification + Simplex.Chat.Store.SQLite.Migrations.M20261016_badge_store_receipts other-modules: Paths_simplex_chat hs-source-dirs: @@ -450,6 +452,10 @@ executable simplex-badge-service BadgeService.Store BadgeService.Store.Invoices BadgeService.Store.Migrate + BadgeService.StoreReceipts + BadgeService.StoreReceipts.Apple + BadgeService.StoreReceipts.Google + BadgeService.StoreReceipts.Mock BadgeService.Waiters BadgeService.Web.Server Paths_simplex_chat @@ -461,6 +467,9 @@ executable simplex-badge-service , base64-bytestring >=1.0 && <1.3 , containers ==0.6.* , crypton ==0.34.* + , crypton-x509 ==1.7.* + , crypton-x509-store ==1.6.* + , crypton-x509-validation ==1.6.* , directory ==1.3.* , filepath ==1.4.* , http-client ==0.7.* @@ -742,6 +751,10 @@ test-suite simplex-chat-test BadgeService.Store BadgeService.Store.Invoices BadgeService.Store.Migrate + BadgeService.StoreReceipts + BadgeService.StoreReceipts.Apple + BadgeService.StoreReceipts.Google + BadgeService.StoreReceipts.Mock BadgeService.Waiters BadgeService.Web.Server Bots.BadgeService.BTCPayTests @@ -749,9 +762,12 @@ test-suite simplex-chat-test Bots.BadgeService.CatalogTests Bots.BadgeService.ConfigTests Bots.BadgeService.FakeBTCPay + Bots.BadgeService.FakePlay + Bots.BadgeService.FakeStore Bots.BadgeService.FakeStripe Bots.BadgeService.GroupIntegrationTests Bots.BadgeService.GroupTests + Bots.BadgeService.StoreVerifierTests Bots.BadgeService.StripeTests Bots.BadgeService.WaitersTests Bots.BadgeService.WebTests @@ -799,10 +815,14 @@ test-suite simplex-chat-test , composition ==1.0.* , containers ==0.6.* , crypton ==0.34.* + , crypton-x509 ==1.7.* + , crypton-x509-store ==1.6.* + , crypton-x509-validation ==1.6.* , deepseq ==1.4.* , directory ==1.3.* , filepath ==1.4.* , generic-random ==1.5.* + , hourglass ==0.2.* , http-client ==0.7.* , http-client-tls ==0.3.* , http-types ==0.12.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 0522e3c8a6..61146c0afa 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -74,6 +74,7 @@ defaultChatConfig = badgeServiceAddress = Just $ either error id $ strDecode "https://smp5.simplex.im/a#ooSNWlEZTO2RPE0Ff5ZoybAs5zEhWLMlQrXesnhaZHM", badgeCurrentTime = getCurrentTime, badgeRetryInterval = RetryInterval {initialInterval = 30_000000, increaseAfter = 0, maxInterval = 3600_000000}, + badgeConsecutiveRetries = 3, confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options @@ -198,6 +199,7 @@ newChatController deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO badgeWorkers <- TM.emptyIO + storeReceiptWorkers <- TM.emptyIO badgeSeq <- newTVarIO 0 relayGroupLinkChecksAsync <- newTVarIO Nothing webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState @@ -246,6 +248,7 @@ newChatController deliveryJobWorkers, relayRequestWorkers, badgeWorkers, + storeReceiptWorkers, badgeSeq, relayGroupLinkChecksAsync, webPreviewState, diff --git a/src/Simplex/Chat/Badges/Ledger.hs b/src/Simplex/Chat/Badges/Ledger.hs index 2f31773d2d..870820d7c8 100644 --- a/src/Simplex/Chat/Badges/Ledger.hs +++ b/src/Simplex/Chat/Badges/Ledger.hs @@ -26,6 +26,7 @@ import Data.Time.Calendar.WeekDate (toWeekDate) import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime) import Simplex.Chat.Badges (BadgeType) import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId) -- The calendar difference overshoots by at most one month, so one comparison settles it. monthsBetween :: UTCTime -> UTCTime -> Integer @@ -204,13 +205,15 @@ debitTypeTag = \case SDLapse -> "lapse" SDUnknown {tag} -> tag --- | Only the types a tag alone rebuilds, which is those whose constructor has no fields; the rest --- answer Nothing rather than a type with an invented payload. The client also stores each type's --- JSON and reads that first, so this is its fallback; the service has no such column. --- TODO [badges] take the reference columns and rebuild payment, charge, transferIn, upgrade and --- transferOut, without which the service cannot re-emit a statement carrying one. -entryTypeFromColumns :: Text -> Maybe Text -> Maybe Text -> Maybe StatementEntryType -entryTypeFromColumns entryType credit_ debit_ = case (entryType, credit_, debit_) of +-- | Rebuilds only what the columns determine: constructors with no fields, and a payment credit. +-- payment_ is Nothing when the row references no payment, and Just Nothing when that payment had no +-- invoice, as a store purchase has none. The rest answer Nothing rather than invent a payload. +-- The client stores each type's JSON and reads that first, so this is its fallback; the service has none. +-- TODO [badges] rebuild charge, transferIn, upgrade and transferOut from their reference columns, +-- without which the service cannot re-emit a statement carrying one. +entryTypeFromColumns :: Maybe (Maybe InvoiceId) -> Text -> Maybe Text -> Maybe Text -> Maybe StatementEntryType +entryTypeFromColumns payment_ entryType credit_ debit_ = case (entryType, credit_, debit_) of + ("credit", Just "payment", _) -> SECredit . SCPayment <$> payment_ ("credit", Just t, _) -> SECredit <$> creditType t ("debit", _, Just t) -> SEDebit <$> debitType t _ -> Nothing diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index 725009d839..80ee632c25 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -87,12 +87,12 @@ data BadgeServiceCommand code :: Text -- no badgeRequest: a code carries no tier for the client to state } | BSCPurchaseBadge - { badgeRequest :: BadgeRequest, + { masterKey :: BadgeMasterKey, -- no badgeRequest: the tier is the product's, the expiry the one every credential of that week shares payment :: ServicePayment, upgrade :: Maybe BadgeUpgrade -- upgrade store 1-time badge } | BSCUpgradeBadgeSubscription - { badgeRequest :: BadgeRequest, + { badgeRequest :: BadgeRequest, -- TODO [badges] drop when implemented: a client-stated expiry leaves the week's anonymity set payment :: ServicePayment, -- store payments only balance :: BadgeBalance } diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 1038b2b64f..f4949848f1 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -30,6 +30,7 @@ module Simplex.Chat.Badges.Types BadgeAlert (..), BadgeAlertPrice (..), BadgeState (..), + OpenStorePurchase (..), ) where import Control.Applicative ((<|>)) @@ -43,7 +44,7 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) -import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId, StoredPayment) +import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId) import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.DB (fromTextField_) @@ -236,12 +237,14 @@ data BadgeServiceErrorCode | BSEPaymentNotEntitled | BSEPaymentPending | BSEProviderUnavailable + | BSEProviderNotConfigured -- no verifier deployed: a dev service, not the store failing to answer | BSERateLimited | BSECodeInvalid | BSECodeUsed | BSECodeExpired | BSEReceiptInvalid | BSEReceiptUsed + | BSETestPurchase -- the store vouched for a purchase that cost nothing: Apple's sandbox, a Play license tester | BSEInternal | BSEUnknown Text -- forwards-compatible: service is deployed ahead of clients deriving (Eq, Show) @@ -281,6 +284,17 @@ data BadgeState = BadgeState } deriving (Show) +-- | One of a profile's store purchases that no receipt has reached yet, or whose receipt is held until +-- the service credits it; transactionRef is set once a receipt arrived. Neither reference is a secret: +-- Apple's is the transaction id, Google's is a hash of the token. creditError is set only while the +-- held receipt's last attempt failed in a way that cannot clear on its own. +data OpenStorePurchase = OpenStorePurchase + { invoiceId :: Maybe Text, + transactionRef :: Maybe Text, + creditError :: Maybe BadgeIssueFailure + } + deriving (Show) + instance TextEncoding BadgePurchaseStatus where textEncode = \case PSAcquiring -> "acquiring" @@ -325,12 +339,14 @@ instance TextEncoding BadgeServiceErrorCode where BSEPaymentNotEntitled -> "payment_not_entitled" BSEPaymentPending -> "payment_pending" BSEProviderUnavailable -> "provider_unavailable" + BSEProviderNotConfigured -> "provider_not_configured" BSERateLimited -> "rate_limited" BSECodeInvalid -> "code_invalid" BSECodeUsed -> "code_used" BSECodeExpired -> "code_expired" BSEReceiptInvalid -> "receipt_invalid" BSEReceiptUsed -> "receipt_used" + BSETestPurchase -> "test_purchase" BSEInternal -> "internal" BSEUnknown t -> t textDecode s = Just $ case s of @@ -344,12 +360,14 @@ instance TextEncoding BadgeServiceErrorCode where "payment_not_entitled" -> BSEPaymentNotEntitled "payment_pending" -> BSEPaymentPending "provider_unavailable" -> BSEProviderUnavailable + "provider_not_configured" -> BSEProviderNotConfigured "rate_limited" -> BSERateLimited "code_invalid" -> BSECodeInvalid "code_used" -> BSECodeUsed "code_expired" -> BSECodeExpired "receipt_invalid" -> BSEReceiptInvalid "receipt_used" -> BSEReceiptUsed + "test_purchase" -> BSETestPurchase "internal" -> BSEInternal t -> BSEUnknown t @@ -410,3 +428,5 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BIF") ''BadgeIssueFailure) $(JQ.deriveJSON defaultJSON ''BadgeIssueError) $(JQ.deriveJSON defaultJSON ''BadgeState) + +$(JQ.deriveJSON defaultJSON ''OpenStorePurchase) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fc4dcc5f6b..6fd2e5578f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -85,7 +85,8 @@ import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyM import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeIssueFailure, BadgeState (..), OpenStorePurchase (..)) +import Simplex.Chat.PaymentService (ServicePayment) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -153,6 +154,8 @@ data ChatConfig = ChatConfig badgeCurrentTime :: IO UTCTime, -- how long a badge worker waits before repeating a renewal that failed for a passing reason badgeRetryInterval :: RetryInterval, + -- attempts a store receipt gets in one turn before its next one waits for the stored schedule + badgeConsecutiveRetries :: Int, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -329,6 +332,7 @@ data ChatController = ChatController relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework -- one badge worker per user: badge state is per profile, and one profile must not stall another badgeWorkers :: TMap UserId (SessionVar BadgeWorker), + storeReceiptWorkers :: TMap UserId Worker, badgeSeq :: TVar Int, relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), webPreviewState :: Maybe WebPreviewState, @@ -663,6 +667,8 @@ data ChatCommand | UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service + | APIPurchaseBadge {userId :: UserId, echoedInvoiceId :: Maybe Text, payment :: ServicePayment} -- hand over an App Store or Google Play receipt, held until a worker credits it; answers from the record and sends nothing + | APICreateBadgeInvoice {userId :: UserId} -- the record of a store purchase, created before the store charges; answers the id the store echoes | APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert | APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first -- episode is last because it is free text: it is the value that makes one occurrence of an @@ -873,7 +879,8 @@ data ChatResponse | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} - | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} + | CRBadgeInvoice {user :: User, invoiceId :: Text} + | CRBadgeState {user :: User, badgeState :: Maybe BadgeState, storePurchases :: [OpenStorePurchase]} | CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} @@ -995,6 +1002,8 @@ data ChatEvent | CEvtServiceReplySent {connectionId :: AgentConnId} | CEvtBadgeChanged {user :: User, badgeState :: Maybe BadgeState} -- badge state changed, including a renewal that arrived without a command | CEvtBadgeAlert {user :: User, badgeAlert :: BadgeAlert} + | CEvtStorePurchaseCredited {user :: User, invoiceId :: Text} -- the held store receipt of this invoice was credited, so its store transaction can be finished + | CEvtStorePurchaseRefused {user :: User, invoiceId :: Text, refusal :: BadgeIssueFailure} -- the held store receipt of this invoice was refused | CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason} | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response | CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} @@ -1486,8 +1495,10 @@ data SimplexDomainError | SDEUnknownDomain -- the resolved link's profile has no name, or a different name deriving (Eq, Show) +-- | Why acquiring a badge failed, for a code and for a store purchase alike. data BadgeRedeemError = BREInvalidCode -- format or check character + | BREInvalidReceipt -- names no transaction, or is not a store payment | BREServiceNotConfigured | BREBadgeActive | BREServiceError {serviceError :: BadgeServiceErrorCode} diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 56ed65fb4b..5b0b5b1b3a 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -22,7 +22,7 @@ import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Markdown import Simplex.Chat.Styled -import Simplex.Chat.Types (LocalProfile (..), User (..)) +import Simplex.Chat.Types (BoolDef (..), LocalProfile (..), User (..)) import System.Console.ANSI.Types highlight :: Text -> Markdown @@ -309,7 +309,7 @@ markdownInfo = map styleMarkdown [ green "Markdown:", - indent <> highlight "*bold* " <> " - " <> markdown Bold "bold text", + indent <> highlight "*bold* " <> " - " <> markdown (Bold $ BoolDef True) "bold text", indent <> highlight "_italic_ " <> " - " <> markdown Italic "italic text" <> " (shown as underlined)", indent <> highlight "~strikethrough~" <> " - " <> markdown StrikeThrough "strikethrough text" <> " (shown as inverse)", indent <> highlight "`code snippet` " <> " - " <> markdown Snippet "a + b // no *markdown* here", diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 014dc8f6c2..f4a5eb509e 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -61,7 +61,7 @@ import Crypto.Random (ChaChaDRG) import Simplex.Messaging.Session (SessionVar (..), withGetSessVar') import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), ProofPresHeader (..), badgeServerCredential, mkBadgeStatus, maxSndXFTPFileSize, verifyCredential) import qualified Simplex.Chat.Badges.Ledger as L -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIssueError (..), BadgeIssueFailure (..), BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIssueError (..), BadgeIssueFailure (..), BadgeState (..), OpenStorePurchase (..)) import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) @@ -76,6 +76,8 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators import Simplex.Chat.Options +import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionId, googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (PaymentProvider (..), StoreTransactionRef (..)) import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Remote @@ -103,9 +105,10 @@ import qualified Simplex.Chat.Util as U import Simplex.Chat.Web (webPreviewWorker) import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHard) import Simplex.Messaging.Agent -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Client (getAgentWorker, waitForUserNetwork, waitForWork, waitWhileSuspended, withWork_) +import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), Worker (..), allRoles) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), withRetryInterval) +import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay, withRetryInterval, withRetryIntervalCount) import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) @@ -263,6 +266,7 @@ startChatController mainApp enableSndFiles serviceRequests = do startRelayRequestWorker_ startCleanupManager mapM_ startBadgeWork users + startStoreReceiptWorkers users void $ forkIO $ mapM_ startExpireCIs users startRelayChecks users startWebPreview users @@ -281,6 +285,10 @@ startChatController mainApp enableSndFiles serviceRequests = do runExceptT startRelayRequestWorker >>= \case Left e -> liftIO $ putStrLn $ "Error starting relay request worker: " <> show e Right _ -> pure () + startStoreReceiptWorkers users = + runExceptT (mapM_ startStoreReceiptWork users) >>= \case + Left e -> liftIO $ putStrLn $ "Error starting store receipt workers: " <> show e + Right _ -> pure () startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case @@ -361,7 +369,7 @@ restoreCalls = do atomically $ writeTVar calls callsMap stopChatController :: ChatController -> IO () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, cleanupManagerAsync, relayGroupLinkChecksAsync, webPreviewState, expireCIThreads, timedItemThreads, deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, badgeWorkers} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, cleanupManagerAsync, relayGroupLinkChecksAsync, webPreviewState, expireCIThreads, timedItemThreads, deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, badgeWorkers, storeReceiptWorkers} = do readTVarIO remoteHostSessions >>= mapM_ (cancelRemoteHost False . snd) atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (cancelRemoteCtrl False . snd) disconnectAgentClient smpAgent @@ -375,6 +383,7 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, clearMap deliveryJobWorkers >>= mapM_ cancelWorker clearMap relayRequestWorkers >>= mapM_ cancelWorker stopBadgeWorkers badgeWorkers + clearMap storeReceiptWorkers >>= mapM_ cancelWorker closeFiles sndFiles closeFiles rcvFiles atomically $ do @@ -453,7 +462,7 @@ processChatCommand cxt nm = \case checkValidName displayName checkProfileImageSize image checkProfileSize p - p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile + p@Profile {displayName} <- liftIO $ maybe (generateRandomProfile Nothing) pure profile u <- asks currentUser users <- withFastStore' getUsers forM_ users $ \User {profile = LocalProfile {displayName = n}, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do @@ -607,6 +616,7 @@ processChatCommand cxt nm = \case void . forkIO $ startFilesToReceive users setAllExpireCIFlags True mapM_ startBadgeWork users + mapM_ startStoreReceiptWork users ok_ APISuspendChat t -> do chatWriteVar chatActivated False @@ -824,7 +834,7 @@ processChatCommand cxt nm = \case CTGroup -> withGroupLock "updateChatItem" chatId $ do g@(GIK gInfo@GroupInfo {groupId, membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user chatId when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor - let (_, ft_) = msgContentTexts mc + let (_, ParsedMarkdown ft_ _) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership mc ft_ then throwCmdError ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do @@ -2128,7 +2138,7 @@ processChatCommand cxt nm = \case Welcome -> withUser $ pure . CRWelcome APIAddContact userId incognito -> withUserId userId $ \user -> do -- [incognito] generate profile for connection - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing subMode <- chatReadVar subscriptionMode (preparedReq, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareInvitationLink a (aUserId user) IKUsePQ Nothing linkProfile <- presentUserBadge user incognitoProfile (Just $ PHLink linkKey) $ userProfileDirect user incognitoProfile Nothing True @@ -2146,7 +2156,7 @@ processChatCommand cxt nm = \case let PendingContactConnection {pccConnStatus, customUserProfileId, connLinkInv} = conn case (pccConnStatus, customUserProfileId, incognito) of (ConnNew, Nothing, True) -> do - incognitoProfile <- liftIO generateRandomProfile + incognitoProfile <- liftIO $ generateIncognitoProfile user sLnk <- updatePCCShortLinkData conn $ userProfileDirect user (Just incognitoProfile) Nothing True conn' <- withFastStore' $ \db -> do pId <- createIncognitoProfile db user incognitoProfile @@ -2325,7 +2335,7 @@ processChatCommand cxt nm = \case publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ -- Prepare group record once before connecting to relays (updatePreparedRelayedGroup): -- set group link info and incognito profile, generate and store membership keys - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} e2e g'@(GIK gInfo' _) <- withFastStore $ \db -> do g'@(GIK gInfo' _) <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey publicMemberCount_ @@ -3616,10 +3626,19 @@ processChatCommand cxt nm = \case ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText + APIPurchaseBadge userId echoedInvoiceId payment -> withUserId userId $ \user -> handOverStoreReceipt user echoedInvoiceId payment + APICreateBadgeInvoice userId -> withUserId userId $ \user -> do + void requireBadgeService + refuseWhileBadgeHeld user + g <- asks random + now <- badgeNow + invoiceId <- UUID.toText <$> liftIO V4.nextRandom + withStore' $ \db -> createBadgeStoreReceipt db g user invoiceId now + pure $ CRBadgeInvoice user invoiceId APIGetBadgeState userId -> withUserId' userId $ \user -> do -- the read also signals the worker, whose results follow as CEvtBadgeChanged lift $ startBadgeWork user - CRBadgeState user <$> getUserBadgeState user + badgeStateResponse user APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user -> CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId) APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do @@ -3628,7 +3647,7 @@ processChatCommand cxt nm = \case withStore' $ \db -> setBadgeAlertAcked db user badgePurchaseId alertKind episode snoozeUntil -- after the write, so the pass it signals arms a wake for the snooze rather than raising again lift $ startBadgeWork user - CRBadgeState user <$> getUserBadgeState user + badgeStateResponse user SetBotCommands commands -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -3873,7 +3892,7 @@ processChatCommand cxt nm = \case pqSup' = PQSupportOn joinNewConn chatV = do -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' let ccLink = CCLink cReq $ serverShortLink <$> sLnk_ conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' @@ -3945,7 +3964,7 @@ processChatCommand cxt nm = \case -- [incognito] generate profile to send, or use membership profile for relay groups incognitoProfile_ <- case gInfo_ of Just (Just (GIK gInfo _)) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo - _ -> if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + _ -> if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing let incognitoProfile = fromIncognitoProfile <$> incognitoProfile_ subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk @@ -3961,7 +3980,7 @@ processChatCommand cxt nm = \case (connId, chatV, reqHeader) <- prepareContact user cReq pqSup newXContactId <- XContactId <$> drgRandomBytes 16 -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing subMode <- chatReadVar subscriptionMode let cReqHash = contactCReqHash cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq reqHeader cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup @@ -4332,7 +4351,7 @@ processChatCommand cxt nm = \case when (isPublicGroup groupKeys && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels" when (not (isPublicGroup groupKeys) && isJust publicGroup) $ throwCmdError "publicGroup is not allowed in groups" -- [incognito] generate incognito profile for group membership - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile memberId groupKeys publicMemberCount_ createNewGroupItems :: User -> GroupInfo -> CM () createNewGroupItems user gInfo = do @@ -4910,12 +4929,12 @@ processChatCommand cxt nm = \case fl_ <- liftIO $ ciffForwardLink db ciff pure (mcForward fl_ mc, Nothing) (Just qiId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, markdownBlocks, file} <- getDirectChatItem db user contactId qiId (origQmc, qd, sent) <- quoteData qci let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent mc origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText, markdownBlocks} pure (mcQuote QuotedMsg {msgRef, content = qmc} mc, Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote pure (mc' {file = fInv_, ttl = ttl' <$> timed_, live = justTrue live}, quotedItem_) @@ -4957,7 +4976,7 @@ processChatCommand cxt nm = \case findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ParsedMarkdown ft _), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do @@ -5139,7 +5158,7 @@ processChatCommand cxt nm = \case prepareLocalItemsData :: NonEmpty ComposedMessageReq -> NonEmpty (Maybe (CIFile 'MDSnd)) -> - NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) prepareLocalItemsData = L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts, _) f -> (CISndMsgContent mc, f, itemForwarded, ts) @@ -5247,7 +5266,7 @@ updatedRelays relays UserOperatorServers {operator, smpServers, xftpServers, cha disableRelay relay@UserChatRelay {preset} = AUCR SDBStored $ if preset then relay {enabled = False} else relay {deleted = True} -type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, ParsedMarkdown), Map MemberName CIMention) composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} @@ -5257,7 +5276,7 @@ composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgConte composedMessageReqMentions :: DB.Connection -> User -> GroupInfo -> ComposedMessage -> ExceptT StoreError IO ComposedMessageReq composedMessageReqMentions db user g cm@ComposedMessage {msgContent = mc, mentions} = do - let ts@(_, ft_) = msgContentTexts mc + let ts@(_, ParsedMarkdown ft_ _) = msgContentTexts mc (cm,Nothing,ts,) <$> getCIMentions db user g ft_ mentions data ChangedProfileContact = ChangedProfileContact @@ -5318,30 +5337,20 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse redeemBadgeCode nm user@User {userId} codeText = do code <- maybe (throwRedeemError BREInvalidCode) pure $ parseBadgeCode codeText - sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure + sendTarget <- requireBadgeService g <- asks random now <- liftIO getCurrentTime let codeSent = badgeCodeText code -- the guard, the request and the write are one section: without it two codes redeemed at once -- both pass the guard and are both spent, for one badge (present_, redeemed) <- withEntityLock "badgeRedeem" (CLBadgeUser userId) $ do - redemption_ <- withStore' $ \db -> getBadgeCodeRedemption db user codeSent - -- a code already redeemed here is allowed through: re-sending it returns the badge it bought - -- and adds nothing. Refused before its keys are stashed and before the request, so it stays unspent - replaying <- maybe (pure False) (\r -> withStore' $ \db -> isJust <$> getCodeBadgePurchase db r) redemption_ - unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive - redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <- - maybe (withStore' $ \db -> createBadgeCodeRedemption db g user codeSent now) pure redemption_ - let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}} - respBytes <- sendServiceRequestBytes nm user sendTarget Nothing (Just purchasePrivKey) req - respData <- either (const $ throwRedeemError $ BREInvalidResponse "not JSON") pure $ parseServiceBody respBytes - case J.fromJSON (J.Object respData) of - J.Error _ -> throwRedeemError $ BREInvalidResponse "not a badge service response" - J.Success BSPError {code = errCode} -> do - when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption) + stash_ <- withStore' $ \db -> getBadgeCodeRedemption db user codeSent + stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeCodeRedemption db g user codeSent now + requestBadgeStash nm user sendTarget stash BSCRedeemBadgeCode {masterKey, code = codeSent} >>= \case + Left (errCode, _) -> do + when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db user codeSent throwRedeemError $ BREServiceError errCode - J.Success BSPBadgeCredential {credential = Just cred, statement} -> storeRedeemedBadge user redemption cred statement - J.Success _ -> throwRedeemError $ BREInvalidResponse "unexpected response type" + Right redeemed -> pure redeemed -- outside the badge lock: the chat lock must not be taken under it mapM_ presentUserBadgeToContacts present_ pure redeemed @@ -5353,6 +5362,70 @@ redeemBadgeCode nm user@User {userId} codeText = do BSECodeExpired -> True _ -> False +-- | The app hands a receipt over until it is answered credited or refused, under whichever profile is +-- active; the answer is the owner's, read from the record. Nothing is sent: the owner's receipt worker is. +handOverStoreReceipt :: User -> Maybe Text -> ServicePayment -> CM ChatResponse +handOverStoreReceipt presentingUser echoedInvoiceId payment = do + txRef <- maybe (throwRedeemError BREInvalidReceipt) pure $ storeTransactionRef payment + void requireBadgeService + g <- asks random + now <- badgeNow + BadgeReceiptRecord {ownerId, status} <- + withStore' (\db -> holdStoreReceipt db g presentingUser echoedInvoiceId txRef payment now) + >>= maybe (throwChatError $ CEInternalError "store receipt was not recorded") pure + owner <- withStore $ \db -> getUser db ownerId + case status of + RSHeld {} -> lift (resumeStoreReceiptWork ownerId) >> badgeStateResponse owner + RSCredited {badgePurchaseId} -> do + cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId) + cred@(BadgeCredential _ _ _ info) <- maybe (throwChatError $ CEInternalError "credited store purchase has no credential") pure cred_ + CRBadgeRedeemed owner (OwnBadge cred (mkBadgeStatus now (Just True) info)) False <$> getUserBadgeState owner + RSRefused {refusal = Just BIFServiceError {code}} -> throwRedeemError $ BREServiceError code + RSRefused {} -> throwChatError $ CEInternalError "store purchase refused for no recorded reason" + +-- | The receipt will never be credited to its key; any other refusal may pass on a retry. +storeReceiptRefused :: BadgeServiceErrorCode -> Bool +storeReceiptRefused = \case + BSEReceiptInvalid -> True + BSEReceiptUsed -> True + BSETestPurchase -> True + _ -> False + +-- | The same reference the service claims a transaction by, read without verifying anything. +storeTransactionRef :: ServicePayment -> Maybe StoreTransactionRef +storeTransactionRef = \case + SPApple {jws} -> StoreTransactionRef PPApple <$> appleTransactionId jws + SPGoogle {token} -> Just $ StoreTransactionRef PPGoogle $ googlePurchaseRef token + SPInvoice {} -> Nothing + SPReceipt {} -> Nothing + +-- | A stash that already bought a badge here passes, as re-sending it adds nothing; any other is +-- refused while a badge is held, before its keys are stashed or sent, so the funding stays unspent. +stashBadgeKeys :: User -> Maybe BadgeStash -> (DB.Connection -> IO BadgeStash) -> CM BadgeStash +stashBadgeKeys user stash_ createStash = do + replaying <- maybe (pure False) (\s -> withStore' $ \db -> isJust <$> getStashBadgePurchase db s) stash_ + unless replaying $ refuseWhileBadgeHeld user + maybe (withStore' createStash) pure stash_ + +requireBadgeService :: CM (ConnectTarget 'CMContact) +requireBadgeService = asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure + +refuseWhileBadgeHeld :: User -> CM () +refuseWhileBadgeHeld user = whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive + +-- | One attempt to turn a stash into a badge. A refusal is answered rather than thrown, with the +-- service's retryAfter. +requestBadgeStash :: NetworkRequestMode -> User -> ConnectTarget 'CMContact -> BadgeStash -> BadgeServiceCommand -> CM (Either (BadgeServiceErrorCode, Maybe Word32) (Maybe User, ChatResponse)) +requestBadgeStash nm user sendTarget stash@BadgeStash {purchaseKey, purchasePrivKey} request = do + let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} + respBytes <- sendServiceRequestBytes nm user sendTarget Nothing (Just purchasePrivKey) req + respData <- either (const $ throwRedeemError $ BREInvalidResponse "not JSON") pure $ parseServiceBody respBytes + case J.fromJSON (J.Object respData) of + J.Error _ -> throwRedeemError $ BREInvalidResponse "not a badge service response" + J.Success BSPError {code, retryAfter} -> pure $ Left (code, retryAfter) + J.Success BSPBadgeCredential {credential = Just cred, statement} -> Right <$> storeRedeemedBadge user stash cred statement + J.Success _ -> throwRedeemError $ BREInvalidResponse "unexpected response type" + throwRedeemError :: BadgeRedeemError -> CM a throwRedeemError = throwChatError . CEBadgeRedeemError @@ -5461,6 +5534,94 @@ waitBadgeWake badgeWork now = \case badgeMaxWake :: NominalDiffTime badgeMaxWake = 100 * 365 * nominalDay +startStoreReceiptWork :: User -> CM () +startStoreReceiptWork User {userId} = do + held <- withStore' $ \db -> getNextHeldStoreReceipt db userId + when (either (const False) isJust held) $ lift $ resumeStoreReceiptWork userId + +resumeStoreReceiptWork :: UserId -> CM' () +resumeStoreReceiptWork userId = do + ws <- asks storeReceiptWorkers + a <- asks smpAgent + void $ getAgentWorker "store_receipt" True a userId ws $ runStoreReceiptWorker a userId + +runStoreReceiptWorker :: AgentClient -> UserId -> Worker -> CM () +runStoreReceiptWorker a userId Worker {doWork} = do + scheduled <- newTVarIO S.empty + forever $ do + lift $ waitForWork doWork + withWork_ a doWork (getDueReceipt scheduled) $ \case + BadgeReceiptRecord {receiptId, status = RSHeld {stash, invoiceId, payment, retryDelay}} -> creditStoreReceipt a userId receiptId stash invoiceId payment retryDelay + BadgeReceiptRecord {receiptId} -> notHeld receiptId + where + getDueReceipt scheduled = + withStore' (`getNextHeldStoreReceipt` userId) >>= \case + Right (Just r@BadgeReceiptRecord {receiptId, status = RSHeld {nextAttemptAt}}) -> do + now <- badgeNow + if nextAttemptAt <= now + then pure $ Right (Just r) + else Right Nothing <$ scheduleReceipt scheduled receiptId (diffUTCTime nextAttemptAt now) + Right (Just BadgeReceiptRecord {receiptId}) -> Right Nothing <$ notHeld receiptId + r -> pure r + -- getNextHeldStoreReceipt reads held rows only, so this says its query and its result type disagree + notHeld receiptId = eToView $ ChatError $ CEInternalError $ "store receipt " <> show receiptId <> " is not held" + scheduleReceipt scheduled receiptId delay = do + new <- atomically $ stateTVar scheduled $ \ids -> (S.notMember receiptId ids, S.insert receiptId ids) + when new . void . liftIO . forkIO $ do + threadDelay' $ diffToMicroseconds delay + atomically $ modifyTVar' scheduled $ S.delete receiptId + void $ atomically $ tryPutTMVar doWork () + +data StoreReceiptOutcome = SROCredited (Maybe User) | SRORefused BadgeIssueFailure | SRORetry | SRODeferred + +-- | Under the owner's badge lock, which keeps a profile's requests to the service serial. +creditStoreReceipt :: AgentClient -> UserId -> Int64 -> BadgeStash -> Maybe Text -> Text -> Maybe Int64 -> CM () +creditStoreReceipt a userId receiptId stash@BadgeStash {masterKey} invoiceId_ payment retryDelay = do + ChatConfig {badgeRetryInterval = ri, badgeConsecutiveRetries} <- asks config + withRetryIntervalCount (maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) retryDelay) $ \n delay loop -> do + liftIO $ waitWhileSuspended a + liftIO $ waitForUserNetwork a + -- decided under the lock and acted on outside it: the retry sleeps, and announcing takes the chat lock + outcome <- withEntityLock "badgePurchase" (CLBadgeUser userId) $ + tryAllErrors attempt >>= \case + Right (Right (present_, _)) -> pure $ SROCredited present_ + Right (Left (code, retryAfter)) + | storeReceiptRefused code -> do + let failure = serviceFailure code retryAfter + SRORefused failure <$ withStore' (\db -> refuseStoreReceipt db receiptId failure) + | otherwise -> failed ri delay (serviceFailure code retryAfter) retryAfter + Left e -> failed ri delay (badgeIssueFailure e) Nothing + -- the invoice id is the app's only key to the store transaction, so without one nothing is emitted and its sweep finishes it + case outcome of + SROCredited present_ -> do + user <- withStore $ \db -> getUser db userId + toView . CEvtBadgeChanged user =<< getUserBadgeState user + forM_ invoiceId_ $ toView . CEvtStorePurchaseCredited user + -- last, so a failed broadcast cannot lose the settlement + mapM_ presentUserBadgeToContacts present_ + SRORefused failure -> forM_ invoiceId_ $ \invoiceId -> do + user <- withStore (`getUser` userId) + toView $ CEvtStorePurchaseRefused user invoiceId failure + SRORetry | n + 1 < badgeConsecutiveRetries -> loop + _ -> pure () + where + attempt = do + user <- withStore $ \db -> getUser db userId + refuseWhileBadgeHeld user + sendTarget <- requireBadgeService + payment' <- maybe (throwChatError $ CEInternalError "held store payment does not decode") pure $ decodeJSON payment + requestBadgeStash NRMBackground user sendTarget stash BSCPurchaseBadge {masterKey, payment = payment', upgrade = Nothing} + serviceFailure code retryAfter = BIFServiceError {code = boundedServiceErrorCode code, retryable = isJust retryAfter} + failed ri delay failure retryAfter = do + now <- badgeNow + let (retryIn, outcome) + | isJust retryAfter = (badgeRetryAfter ri retryAfter, SRODeferred) + -- a buyer is waiting, so unlike a renewal a failure that can clear on its own never waits the day + | badgeFailureTransient failure = (fromIntegral delay / 1000000, SRORetry) + | otherwise = (badgeStalledInterval, SRODeferred) + withStore' $ \db -> recordStoreReceiptFailure db receiptId (nextRetryDelay 0 delay ri) (retryIn `addUTCTime` now) failure + pure outcome + -- | Retire what has ended, renew what is due, then report the next wake. Waking early, late or not -- at all changes only timing: each run reads stored state and works out what to do. updateUserBadge :: UserId -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime) @@ -5538,6 +5699,15 @@ emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} shownCred now raised <- atomically $ stateTVar emitted (,occurrence) when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert +-- | Scoped to one profile, so another profile's store purchase, which may be hidden, never shows under it. +badgeStateResponse :: User -> CM ChatResponse +badgeStateResponse user = do + badgeState <- getUserBadgeState user + CRBadgeState user badgeState . map finalError <$> withStore' (`getOpenStorePurchases` user) + where + -- like a renewal's, only a failure that cannot clear on its own is the user's to know about + finalError p@OpenStorePurchase {creditError} = p {creditError = find (not . badgeFailureTransient) creditError} + -- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged. getUserBadgeState :: User -> CM (Maybe BadgeState) getUserBadgeState user = do @@ -5571,9 +5741,10 @@ badgeStalledInterval = nominalDay -- | The wait after a service refusal, floored at initialInterval so answering 0 cannot spin the -- worker, and uncapped above it. badgeRetryAfter :: RetryInterval -> Maybe Word32 -> NominalDiffTime -badgeRetryAfter RetryInterval {initialInterval} = maybe badgeStalledInterval (max floorWait . fromIntegral) - where - floorWait = fromIntegral initialInterval / 1000000 +badgeRetryAfter ri = maybe badgeStalledInterval (max (retryFloor ri) . fromIntegral) + +retryFloor :: RetryInterval -> NominalDiffTime +retryFloor RetryInterval {initialInterval} = fromIntegral initialInterval / 1000000 -- | How far ahead of the shown credential's expiry the renewal is requested - a day, so a failure -- has that long to retry. The wake and the due check both derive from it and have to agree. @@ -5630,6 +5801,8 @@ badgeIssueFailure e = case e of | otherwise -> BIFUnexpected {message = tshow agentError} ChatError (CECommandError m) -> BIFUnexpected {message = T.pack m} ChatError (CEInternalError m) -> BIFUnexpected {message = T.pack m} + ChatError (CEBadgeRedeemError BREUnknownKeyIndex) -> BIFInvalidCredential + ChatError (CEBadgeRedeemError BRECredentialNotVerified) -> BIFInvalidCredential _ -> BIFUnexpected {message = tshow e} -- | Whether a failure can clear on its own, which decides whether the alert waits for the shown @@ -5753,8 +5926,8 @@ stopBadgeWorkers workers = -- | Verify the credential before writing anything; the purchase, the statement's rows, the -- issuance and the profile's badge go in one transaction. Answers the user to tell contacts about, -- which the caller does once the badge lock is released. -storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse) -storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeType}) statement = +storeRedeemedBadge :: User -> BadgeStash -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse) +storeRedeemedBadge user@User {userId} stash@BadgeStash {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeType}) statement = verifyOwnBadge cred >>= \case Nothing -> throwRedeemError BREUnknownKeyIndex Just False -> throwRedeemError BRECredentialNotVerified @@ -5767,7 +5940,7 @@ storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} let badge = OwnBadge cred (mkBadgeStatus now (Just True) info) -- TODO [badges] retire a previously held badge (user', newBadge, applied) <- withStore $ \db -> do - (purchaseId, newBadge) <- liftIO $ createCodeBadgePurchase db user redemption cred now + (purchaseId, newBadge) <- liftIO $ createStashBadgePurchase db user stash cred now applied <- liftIO $ applyBadgeStatement db g purchaseId badgeType statement (Just cred) now -- a replay must not put a superseded badge back, or tell every contact again user' <- if newBadge then setUserBadge db user (Just badge) else getUser db userId @@ -5918,6 +6091,7 @@ cleanupManager = do cleanupDeliveryJobs `catchAllErrors` eToView -- TODO possibly, also cleanup async commands cleanupProbes `catchAllErrors` eToView + cleanupStoreReceipts `catchAllErrors` eToView liftIO $ threadDelay' $ diffToMicroseconds interval where runWithoutInitialDelay cleanupInterval = flip catchAllErrors eToView $ do @@ -5987,6 +6161,11 @@ cleanupManager = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(14 * nominalDay)) ts withStore' (`deleteOldProbes` cutoffTs) + -- the badge clock, which the records are written with + cleanupStoreReceipts = do + ts <- badgeNow + let cutoffTs = addUTCTime (-(30 * nominalDay)) ts + withStore' (`deleteUnfundedStoreReceipts` cutoffTs) deleteInProgressGroup :: User -> GroupInfo -> CM () deleteInProgressGroup user gInfo = do @@ -6209,6 +6388,8 @@ chatCommandP = "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP), + "/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <*> optional (" invoice=" *> (safeDecodeUtf8 <$> A.takeTill (== ' '))) <* A.space <*> jsonP), + "/_badge invoice " *> (APICreateBadgeInvoice <$> A.decimal), "/_badge state " *> (APIGetBadgeState <$> A.decimal), "/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal), "/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 2bc6918c6b..66604f7ebc 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -217,13 +217,13 @@ prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc m fl_ <- liftIO $ ciffForwardLink db ciff pure (mcForward fl_ mc, Nothing) (Just quotedItemId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, markdownBlocks, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId (origQmc, qd, sent, member_) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = memberId' <$> member_} qmc = quoteContent mc origQmc file (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft', markdownBlocks} pure (mcQuote QuotedMsg {msgRef, content = qmc'} mc, Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote let mc'' = mc' {mentions = MsgMentions mentions, file = fInv_, ttl = ttl' <$> timed_, live = justTrue live, scope = msgScope, asGroup = justTrue showGroupAsSender} @@ -362,11 +362,11 @@ getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of fmap (name,) . eitherToMaybe <$> runExceptT (getGroupMemberIdByName db user gId name) -msgContentTexts :: MsgContent -> (Text, Maybe MarkdownList) -msgContentTexts mc = let t = msgContentText mc in (t, parseMaybeMarkdownList t) +msgContentTexts :: MsgContent -> (Text, ParsedMarkdown) +msgContentTexts mc = let t = msgContentText mc in (t, parsedMarkdown t) -ciContentTexts :: CIContent d -> (Text, Maybe MarkdownList) -ciContentTexts content = let t = ciContentToText content in (t, parseMaybeMarkdownList t) +ciContentTexts :: CIContent d -> (Text, ParsedMarkdown) +ciContentTexts content = let t = ciContentToText content in (t, parsedMarkdown t) quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent mc qmc ciFile_ @@ -994,7 +994,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (ct, conn, incognitoProfile, getPresHeader) <- case contactId_ of Nothing -> do - incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' (ct, conn) <- withStore' $ \db -> createContactFromRequest db user userContactLinkId_ connId chatV cReqChatVRange cName profileId cp xContactId incognitoProfile subMode pqSup' False @@ -1003,7 +1003,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConn ct of Nothing -> do - incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' currentTs <- liftIO getCurrentTime conn <- withStore' $ \db -> do @@ -1336,6 +1336,9 @@ introduceInChannel cxt user g@(GIK gInfo _) subscriber@GroupMember {activeConn = sendIntros ms = forM_ (L.nonEmpty $ map (memberIntroEvt gInfo) ms) $ \evts -> sendGroupMemberMessages user g conn evts +generateIncognitoProfile :: User -> IO Profile +generateIncognitoProfile User {profile = LocalProfile {peerType}} = generateRandomProfile peerType + userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user g = userProfileInGroup' user (Just g) {-# INLINE userProfileInGroup #-} @@ -2982,7 +2985,7 @@ saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed l data NewSndChatItemData c = NewSndChatItemData { msg :: SndMessage, content :: CIContent 'MDSnd, - itemTexts :: (Text, Maybe MarkdownList), + itemTexts :: (Text, ParsedMarkdown), itemMentions :: Map MemberName CIMention, ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), @@ -3007,8 +3010,8 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) - createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do - let hasLink_ = ciContentHasLink content (snd itemTexts) + createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts = itemTexts@(_, ParsedMarkdown ft_ _), itemMentions, ciFile, quotedItem, itemForwarded} = do + let hasLink_ = ciContentHasLink content ft_ ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (toMsgVerified (signMessagesRequired cd) (MSSVerified <$ signedMsg_)) createdAt @@ -3019,15 +3022,15 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse -saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty -ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -ciContentNoParse content = (content, (ciContentToText content, Nothing)) +ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) +ciContentNoParse content = (content, (ciContentToText content, ParsedMarkdown Nothing Nothing)) -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) -saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, ts@(_, ParsedMarkdown ft_ _)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime cxt <- chatStoreCxt withStore' $ \db -> do @@ -3043,7 +3046,7 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem itemForwarded <- rcvForwardedFrom db user cd msg (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemForwarded itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt + let ci = mkChatItem_ cd showAsGroup ciId content ts ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt ci' <- case toChatInfo cd of GroupChat g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci @@ -3073,15 +3076,15 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem -- TODO [mentions] optimize by avoiding unnecessary parsing mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgVerified currentTs = - let ts@(_, ft_) = ciContentTexts content + let ts@(_, ParsedMarkdown ft_ _) = ciContentTexts content hasLink_ = ciContentHasLink content ft_ in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs -mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d -mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, ParsedMarkdown) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d +mkChatItem_ cd showGroupAsSender ciId content (itemText, ParsedMarkdown formattedText markdownBlocks) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = let itemStatus = ciCreateStatus content meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgVerified currentTs currentTs - in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} + in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem, reactions = [], file} ciContentHasLink :: CIContent d -> Maybe MarkdownList -> Bool ciContentHasLink content ft_ = case ciMsgContent content of @@ -3395,7 +3398,7 @@ memberAttentionChange unread brokerTs_ rcvMem_ = \case createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> - NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do @@ -3405,8 +3408,8 @@ createLocalChatItems user cd itemsData createdAt = do unless (null errs) $ toView $ CEvtChatErrors errs pure items where - createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) - createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do + createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> IO (ChatItem 'CTLocal 'MDSnd) + createItem db (content, ciFile, itemForwarded, ts@(_, ParsedMarkdown ft_ _)) = do let hasLink_ = ciContentHasLink content ft_ ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing Nothing Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index d1a6cddb76..2711956e04 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -52,12 +52,12 @@ import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Files (getChatTempDirectory, safeFileNameStr) import Simplex.Chat.Library.Internal +import Simplex.Chat.Markdown (ParsedMarkdown (..)) import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events -import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -1535,7 +1535,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag Just AutoAccept {acceptIncognito} -> do incognitoProfile <- if not shortLinkDataSet && acceptIncognito - then Just . NewIncognito <$> liftIO generateRandomProfile + then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing ct' <- acceptContactRequestAsync user uclId ct ucr incognitoProfile toView $ CEvtAcceptingContactRequest user ct' @@ -2246,7 +2246,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | otherwise = (\GroupMember {memberId} -> encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))) <$> m_ GroupInfo {groupProfile = GroupProfile {publicGroup}} = gInfo sentAsGroup = asGroup_ == Just True - ts@(_, ft_) = msgContentTexts content + ts@(_, ParsedMarkdown ft_ _) = msgContentTexts content -- m' is Maybe GroupMember saveRcvCI gInfo' m' scopeInfo = let itemMember_ = if sentAsGroup then Nothing else m' @@ -2335,7 +2335,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag pure $ Just $ infoToDeliveryContext gInfo' scopeInfo showGroupAsSender where content = CIRcvMsgContent mc - ts@(_, ft_) = msgContentTexts mc + ts@(_, ParsedMarkdown ft_ _) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do (cci, scopeInfo) <- withStore $ \db -> do diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index c90cd54fab..996a9449cd 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -19,17 +19,19 @@ import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as AB +import Data.Attoparsec.Combinator (lookAhead) import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace) -import Data.Either (fromRight) +import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace, isSymbol) +import Data.Either (fromRight, isLeft, lefts) import Data.Functor (($>)) -import Data.List (foldl', intercalate) -import Data.List.NonEmpty (NonEmpty) +import Data.List (intercalate) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe, isNothing, mapMaybe) +import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) import Data.Semigroup (sconcat) import Data.String import Data.Text (Text) @@ -49,7 +51,7 @@ data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown deriving (Eq, Show) data Format - = Bold + = Bold {singleStar :: BoolDef} | Italic | StrikeThrough | Snippet @@ -65,9 +67,49 @@ data Format | Mention {memberName :: Text} | Email | Phone + | Escape + | Markup {token :: BlockToken} | Unknown {json :: J.Value} deriving (Eq, Show) +data BlockToken + = LineBreak + | SoftBreak + | HardBreak + | ParagraphBreak {lineBreaks :: Int} + | Indent + | HeadingMark {level :: Int} + | HeadingClose + | HeadingUnderline {level :: Int} + | ListMark {number :: Maybe Text} + | QuoteMark + | FenceMark {language :: Maybe Text} + | RowMark + | CellMark + | DelimiterRow {columns :: [TableAlign]} + | RuleMark + deriving (Eq, Show) + +data MarkdownBlock + = Paragraph {content :: SegmentRange} + | Heading {level :: Int, content :: SegmentRange} + | CodeBlock {language :: Maybe Text, content :: SegmentRange} + | Quote {content :: SegmentRange} + | List {items :: NonEmpty ListEntry} + | Table {columns :: NonEmpty TableAlign, header :: [SegmentRange], rows :: [[SegmentRange]]} + | Rule {mark :: SegmentRange} + | UnknownBlock {json :: J.Value} + deriving (Eq, Show) + +data ListEntry = ListEntry {number :: Maybe Text, content :: SegmentRange, items :: [ListEntry]} + deriving (Eq, Show) + +data SegmentRange = SegmentRange {from :: Int, to :: Int} + deriving (Eq, Show) + +data TableAlign = TANone | TALeft | TACenter | TARight + deriving (Eq, Show) + mentionedNames :: MarkdownList -> [Text] mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) where @@ -140,31 +182,120 @@ instance IsString FormattedText where type MarkdownList = [FormattedText] -data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList} +data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList, markdownBlocks :: Maybe (NonEmpty MarkdownBlock)} unmarked :: Text -> Markdown unmarked = Markdown Nothing parseMaybeMarkdownList :: Text -> Maybe MarkdownList -parseMaybeMarkdownList s = case ls of - [] -> Nothing +parseMaybeMarkdownList s = let ParsedMarkdown {formattedText} = parsedMarkdown s in formattedText + +parsedMarkdown :: Text -> ParsedMarkdown +parsedMarkdown s = case T.lines s of + [] -> ParsedMarkdown Nothing Nothing [l] - | T.null cmd -> Nothing - | isCmd -> Just [FormattedText (Just $ Command cmd) l] + | T.null cmd -> ParsedMarkdown Nothing Nothing + | isCmd -> ParsedMarkdown (Just [FormattedText (Just $ Command cmd) l]) Nothing where (isCmd, cmd) = case T.uncons $ T.dropWhile (== ' ') l of Just (c, rest) -> (c == '/', rest) Nothing -> (False, "") _ - | all (isNothing . format) ml -> Nothing - | otherwise -> Just . reverse $ foldl' acc [] ml + | any isBlockSyntax tokens && length [f | FormattedText f _ <- tokens, isMarkup f] <= maxMarkupTokens -> ParsedMarkdown (Just tokens) (L.nonEmpty $ markdownBlocksOf tokens) + | all (isNothing . format) inline -> ParsedMarkdown Nothing Nothing + | otherwise -> ParsedMarkdown (Just inline) Nothing where - ls = T.lines s - ml = intercalate ["\n"] $ map (markdownToList . parseMarkdown) ls - acc [] m = [m] - acc ms@(FormattedText f t : ms') ft@(FormattedText f' t') - | f == f' = FormattedText f (t <> t') : ms' - | otherwise = ft : ms + s' = fromMaybe s $ T.stripSuffix "\r\n" s <|> T.stripSuffix "\n" s + tokens = joinSegments $ fromRight [FormattedText Nothing s'] $ A.parseOnly (documentP <* A.endOfInput) s' + inline = joinSegments $ map breakAsText tokens + breakAsText ft@(FormattedText f t) = if isMarkup f then FormattedText Nothing t else ft + +isBlockSyntax :: FormattedText -> Bool +isBlockSyntax = \case + FormattedText (Just (Markup t)) _ -> case t of + LineBreak -> False + SoftBreak -> False + HardBreak -> False + ParagraphBreak _ -> False + _ -> True + _ -> False + +joinSegments :: MarkdownList -> MarkdownList +joinSegments = map joinGroup . L.groupBy (\(FormattedText f _) (FormattedText f' _) -> f == f' && not (isMarkup f)) + +joinGroup :: NonEmpty FormattedText -> FormattedText +joinGroup g@(FormattedText f _ :| _) = FormattedText f $ T.concat [t | FormattedText _ t <- L.toList g] + +isMarkup :: Maybe Format -> Bool +isMarkup = \case + Just (Markup _) -> True + _ -> False + +markdownBlocksOf :: MarkdownList -> [MarkdownBlock] +markdownBlocksOf = groupLists . mapMaybe (blockOf <=< L.nonEmpty) . splitBlocks . zip [0 ..] + where + splitBlocks ts = case break (isBlockBreak . snd) ts of + (b, _ : rest) -> b : splitBlocks rest + (b, []) -> [b] + isBlockBreak = \case + FormattedText (Just (Markup LineBreak)) _ -> True + FormattedText (Just (Markup (ParagraphBreak _))) _ -> True + _ -> False + +blockOf :: NonEmpty (Int, FormattedText) -> Maybe (Either (Int, Maybe Text, SegmentRange) MarkdownBlock) +blockOf ts@((i, FormattedText f t) :| _) = case f of + Just (Markup tkn) -> case tkn of + HeadingMark lvl -> Right . Heading lvl <$> contentRange + ListMark num -> (\r -> Left (indentColumn $ T.takeWhile isIndentChar t, num, r)) <$> contentRange + QuoteMark -> Right . Quote <$> contentRange + FenceMark lang -> Just $ Right $ CodeBlock lang $ SegmentRange (i + 2) (max (i + 2) codeEnd) + RowMark -> Right <$> tableBlock (L.toList ts) + RuleMark -> Just $ Right $ Rule $ SegmentRange i (i + 1) + _ -> paragraph + _ -> paragraph + where + contentRange = (\is -> SegmentRange (L.head is) (L.last is + 1)) <$> L.nonEmpty [j | (j, FormattedText f' _) <- L.toList ts, not (isMarkup f')] + codeEnd = case L.last ts of + (j, FormattedText (Just (Markup (FenceMark _))) _) -> j - 1 + (j, _) -> j + 1 + paragraph = case L.last ts of + (_, FormattedText (Just (Markup (HeadingUnderline lvl))) _) -> Right . Heading lvl <$> contentRange + _ -> Right . Paragraph <$> contentRange + +tableBlock :: [(Int, FormattedText)] -> Maybe MarkdownBlock +tableBlock ts = do + cols <- L.nonEmpty $ concat [aligns | (_, FormattedText (Just (Markup (DelimiterRow aligns))) _) <- ts] + headerRow : bodyRows <- pure $ tableRows [(i, tkn) | (i, FormattedText (Just (Markup tkn)) _) <- ts, tkn == RowMark || tkn == CellMark] + pure $ Table cols headerRow bodyRows + where + tableRows = \case + (i, RowMark) : marks -> + let (cellMarks, marks') = span ((== CellMark) . snd) marks + js = map fst cellMarks + in zipWith (\a b -> SegmentRange (a + 1) b) (i : js) js : tableRows marks' + _ : marks -> tableRows marks + [] -> [] + +groupLists :: [Either (Int, Maybe Text, SegmentRange) MarkdownBlock] -> [MarkdownBlock] +groupLists = \case + Right b : bs -> b : groupLists bs + bs@(Left _ : _) -> + let (entries, rest) = span isLeft bs + in maybe id ((:) . List) (L.nonEmpty $ nestEntries 1 $ lefts entries) (groupLists rest) + [] -> [] + +nestEntries :: Int -> [(Int, Maybe Text, SegmentRange)] -> [ListEntry] +nestEntries depth = \case + (column, num, r) : rest -> + let (children, siblings) = if depth < maxListDepth then span (\(c, _, _) -> c > column) rest else ([], rest) + in ListEntry num r (nestEntries (depth + 1) children) : nestEntries depth siblings + [] -> [] + +maxListDepth :: Int +maxListDepth = 7 + +maxMarkupTokens :: Int +maxMarkupTokens = 2000 parseMarkdownList :: Text -> MarkdownList parseMarkdownList = markdownToList . parseMarkdown @@ -173,6 +304,13 @@ markdownToList :: Markdown -> MarkdownList markdownToList (Markdown f s) = [FormattedText f s] markdownToList (m1 :|: m2) = markdownToList m1 <> markdownToList m2 +joinMarkdown :: [Markdown] -> Markdown +joinMarkdown ms = case map joinGroup $ L.groupBy sameFormat $ filter (\(FormattedText _ t) -> not $ T.null t) $ concatMap markdownToList ms of + [] -> mempty + fts -> foldr1 (:|:) [Markdown f t | FormattedText f t <- fts] + where + sameFormat (FormattedText f _) (FormattedText f' _) = f == f' + parseMarkdown :: Text -> Markdown parseMarkdown s = fromRight (unmarked s) $ A.parseOnly (markdownP <* A.endOfInput) s @@ -203,54 +341,76 @@ hasObfuscatedSimplexLink t = <|> pure False markdownP :: Parser Markdown -markdownP = mconcat <$> A.many' fragmentP +markdownP = joinMarkdown <$> A.many' fragmentP where fragmentP :: Parser Markdown fragmentP = A.peekChar >>= \case Just c -> case c of ' ' -> unmarked <$> A.takeWhile (== ' ') + '\n' -> unmarked <$> A.string "\n" '+' -> phoneP <|> wordP - '*' -> boldP <|> formattedP '*' Bold - '_' -> formattedP '_' Italic - '~' -> formattedP '~' StrikeThrough - '`' -> formattedP '`' Snippet + '*' -> doubleP '*' (Bold $ BoolDef False) <|> formattedP '*' (Bold $ BoolDef True) + '_' -> doubleP '_' (Bold $ BoolDef False) <|> formattedP '_' Italic + '~' -> doubleP '~' StrikeThrough <|> formattedP '~' StrikeThrough + '`' -> codeSpanP '#' -> A.char '#' *> (secretP <|> nameRefP '#' <|> secretFallback) '!' -> styledP <|> wordP '@' -> (A.char '@' *> nameRefP '@') <|> mentionP <|> wordP '/' -> commandP <|> wordP '[' -> sowLinkP <|> wordP + '<' -> autolinkP <|> wordP + '\\' -> escapeP <|> wordP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP Nothing -> fail "" formattedP :: Char -> Format -> Parser Markdown formattedP c f = do - s <- A.char c *> A.takeTill (== c) - (A.char c $> md c f s) <|> noFormat (c `T.cons` s) + s <- A.char c *> A.takeTill (\x -> x == c || x == '\n') + (A.char c $> md c f s) <|> (markdown f <$> acrossLinesP s) <|> noFormat (c `T.cons` s) + where + acrossLinesP s = do + s' <- ((s <> "\n") <>) <$> (A.char '\n' *> A.takeTill (== c) <* A.char c) + if T.null s || isSpace (T.head s) || isSpace (T.last s') then fail "not formatted" else pure s' md :: Char -> Format -> Text -> Markdown md c f s | T.null s || T.head s == ' ' || T.last s == ' ' = unmarked $ c `T.cons` s `T.snoc` c | otherwise = markdown f s - boldP :: Parser Markdown - boldP = do - s <- A.string "**" *> A.takeTill (== '*') <* A.string "**" - if T.null s || T.head s == ' ' || T.last s == ' ' - then fail "not bold" - else pure $ markdown Bold s + doubleP :: Char -> Format -> Parser Markdown + doubleP c f = do + s <- A.string cc *> A.takeTill (== c) <* A.string cc + if T.null s || isSpace (T.head s) || isSpace (T.last s) + then fail "not formatted" + else pure $ markdown f s + where + cc = T.pack [c, c] + codeSpanP :: Parser Markdown + codeSpanP = do + ticks <- A.takeWhile1 (== '`') + let pieceP = A.takeWhile1 (/= '`') <|> (A.takeWhile1 (== '`') >>= \r -> r <$ guard (r /= ticks)) + (markdown Snippet . stripSpaces . T.concat <$> A.many' pieceP <* A.string ticks) <|> pure (unmarked ticks) + where + stripSpaces s + | T.isPrefixOf " " s && T.isSuffixOf " " s && T.any (/= ' ') s = T.drop 1 $ T.dropEnd 1 s + | otherwise = s + escapeP :: Parser Markdown + escapeP = do + c <- A.char '\\' *> A.satisfy (\x -> isAscii x && (isPunctuation x || isSymbol x)) + pure $ markdown Escape "\\" <> unmarked (T.singleton c) secretP :: Parser Markdown secretP = secret <$?> ((,,) <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile1 (== '#')) secret :: (Text, Text, Text) -> Either String Markdown secret (b, s, a) - | T.null s || T.head s == ' ' || T.last s == ' ' = Left "not secret" + | T.null s || isSpace (T.head s) || isSpace (T.last s) = Left "not secret" | otherwise = Right $ markdown Secret $ T.init ss where ss = b <> s <> a secretFallback :: Parser Markdown - secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (== ' ') + secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (\c -> c == ' ' || c == '\n') nameRefP :: Char -> Parser Markdown - nameRefP pfx = nameRef <$?> A.takeTill (== ' ') + nameRefP pfx = nameRef <$?> A.takeTill (\c -> c == ' ' || c == '\n') where nameRef word | pfx == '@' && T.all (/= '.') name = Left "not a name" @@ -265,9 +425,9 @@ markdownP = mconcat <$> A.many' fragmentP md' = markdown (SimplexName ni) full styledP :: Parser Markdown styledP = do - f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.space - s <- ((<>) <$> A.takeWhile1 (\c -> c /= ' ' && c /= '!') <*> A.takeTill (== '!')) <* A.char '!' - if T.null s || T.last s == ' ' + f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.satisfy (\c -> isSpace c && c /= '\n') + s <- ((<>) <$> A.takeWhile1 (\c -> not (isSpace c) && c /= '!') <*> A.takeTill (== '!')) <* A.char '!' + if T.null s || isSpace (T.last s) then fail "not styled" else pure $ markdown f s mentionP = prefixedStringP '@' displayNameTextP_ Mention @@ -279,19 +439,24 @@ markdownP = mconcat <$> A.many' fragmentP res = markdown (format str) (pfx `T.cons` origStr) pure $ if T.null punct then res else res :|: unmarked punct sowLinkP = do - t <- '[' `inParens` ']' - l <- '(' `inParens` ')' + t <- enclosedP '[' ']' (\c -> c == '[' || c == '\n') + l <- enclosedP '(' ')' isSpace let hasPunct = T.any (\c -> isPunctuation c && c /= '-' && c /= '_') t when (hasPunct && t /= l && ("https://" <> t) /= l) $ fail "punctuation in hyperlink text" - f <- case strDecode $ encodeUtf8 l of - Right lnk@(ACL _ cLink) -> case cLink of - CLShort _ -> pure $ simplexUriFormat (Just t) lnk - CLFull _ -> fail "full SimpleX link in hyperlink" - Left _ -> case parseUri $ encodeUtf8 l of - Right _ -> pure $ HyperLink (Just t) l - Left e -> fail $ "not uri: " <> T.unpack e + f <- hyperLinkFormat t l pure $ markdown f $ T.concat ["[", t, "](", l, ")"] - inParens open close = A.char open *> A.takeWhile1 (/= close) <* A.char close + autolinkP = do + l <- enclosedP '<' '>' (\c -> c == '<' || isSpace c) + f <-if isEmail l then pure (HyperLink (Just l) ("mailto:" <> l)) else hyperLinkFormat l l + pure $ markdown f $ "<" <> l <> ">" + hyperLinkFormat t l = case strDecode $ encodeUtf8 l of + Right lnk@(ACL _ cLink) -> case cLink of + CLShort _ -> pure $ simplexUriFormat (Just t) lnk + CLFull _ -> fail "full SimpleX link in hyperlink" + Left _ -> case parseUri $ encodeUtf8 l of + Right _ -> pure $ HyperLink (Just t) l + Left e -> fail $ "not uri: " <> T.unpack e + enclosedP open close stop = A.char open *> A.takeWhile1 (\c -> c /= close && not (stop c)) <* A.char close colorP = A.anyChar >>= \case 'r' -> optional "ed" $> Red @@ -310,14 +475,16 @@ markdownP = mconcat <$> A.many' fragmentP phoneP = do country <- optional $ T.cons <$> A.char '+' <*> A.takeWhile1 isDigit code <- optional $ conc4 <$> phoneSep <*> "(" <*> A.takeWhile1 isDigit <*> ")" - segments <- mconcat <$> A.many' ((<>) <$> phoneSep <*> A.takeWhile1 isDigit) - let s = fromMaybe "" country <> fromMaybe "" code <> segments - len = T.length s + s <- segmentsP $ fromMaybe "" country <> fromMaybe "" code + let len = T.length s if 7 <= len && len <= 22 then pure $ markdown Phone s else fail "not phone" + segmentsP s + | T.length s > 22 = fail "not phone" + | otherwise = optional ((<>) <$> phoneSep <*> A.takeWhile1 isDigit) >>= maybe (pure s) (segmentsP . (s <>)) conc4 s1 s2 s3 s4 = s1 <> s2 <> s3 <> s4 phoneSep = " " <|> "-" <|> "." <|> "" wordP :: Parser Markdown - wordP = wordMD <$> A.takeTill (== ' ') + wordP = wordMD . T.concat <$> A.many' (A.takeWhile1 (\c -> c /= ' ' && c /= '\n' && c /= '\\') <|> (notP escapeP *> A.string "\\")) wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s @@ -377,6 +544,307 @@ markdownP = mconcat <$> A.many' fragmentP strEncodeText :: StrEncoding a => a -> Text strEncodeText = safeDecodeUtf8 . strEncode +documentP :: Parser MarkdownList +documentP = do + blanks <- blankLinesP + ([paragraphBreak blanks | not (T.null blanks)] <>) <$> blocksP + where + blocksP = (<>) <$> blockP <*> A.option [] ((:) <$> blockBreakP <*> blocksP) + +blockBreakP :: Parser FormattedText +blockBreakP = do + br <- lineEndP + blanks <- blankLinesP + pure $ if T.null blanks then markupText LineBreak br else paragraphBreak (br <> blanks) + +blankLinesP :: Parser Text +blankLinesP = (<>) <$> (T.concat <$> A.many' ((<>) <$> A.takeWhile isIndentChar <*> lineEndP)) <*> A.option "" (A.takeWhile1 isIndentChar <* A.endOfInput) + +paragraphBreak :: Text -> FormattedText +paragraphBreak t = markupText (ParagraphBreak $ T.count "\n" t) t + +markupText :: BlockToken -> Text -> FormattedText +markupText = FormattedText . Just . Markup + +blockP :: Parser MarkdownList +blockP = tableP <|> fenceP <|> headingP <|> ruleP <|> listItemP <|> quoteP <|> paragraphP + +blockStartP :: Parser () +blockStartP = void tableStartP <|> void fenceStartP <|> void headingMarkP <|> void ruleP <|> void listMarkP <|> quoteStartP + +paragraphLineP :: Parser () +paragraphLineP = hasTextP *> notP blockStartP + +hasTextP :: Parser () +hasTextP = lookAhead lineTextP >>= guard . not . isBlank + +notP :: Parser a -> Parser () +notP p = ((True <$ lookAhead p) <|> pure False) >>= guard . not + +tableP :: Parser MarkdownList +tableP = do + (headerLine, br, delimiter, aligns) <- tableStartP + let row = tableRowTokens $ length aligns + bodyRows <- A.many' $ (\br' l -> markupText HardBreak br' : row l) <$> lineEndP <* notP blockStartP <*> (lineTextP >>= \l -> l <$ guard (T.any (== '|') l)) + pure $ row headerLine <> [markupText HardBreak br, markupText (DelimiterRow aligns) delimiter] <> concat bodyRows + +tableStartP :: Parser (Text, Text, Text, [TableAlign]) +tableStartP = do + headerLine <- lineTextP + br <- lineEndP + delimiter <- lineTextP + aligns <- maybe mzero pure $ tableStart headerLine delimiter + pure (headerLine, br, delimiter, aligns) + +tableRowTokens :: Int -> Text -> MarkdownList +tableRowTokens n t = case map cellParts cellTexts of + (before, c, after) : more -> markupText RowMark (lead <> before) : cellTokens c after more + [] -> [markupText RowMark lead, markupText CellMark trail] + where + (lead, allCells, trail) = tableLine t + cellTexts = case splitAt (n - 1) allCells of + (ts, rest@(_ : _ : _)) -> ts <> [T.intercalate "|" rest] + _ -> allCells + cellTokens c after = \case + (before', c', after') : more -> cellText c <> [markupText CellMark (after <> "|" <> before')] <> cellTokens c' after' more + [] -> cellText c <> [markupText CellMark (after <> trail)] + cellText c = if T.null c then [] else parseMarkdownList c + cellParts cell = + let before = T.takeWhile isSpace cell + rest = T.drop (T.length before) cell + after = T.takeWhileEnd isSpace rest + in (before, T.dropEnd (T.length after) rest, after) + +fenceP :: Parser MarkdownList +fenceP = do + (l, (col, n, ch, lang)) <- fenceStartP + codeLines <- A.many' $ (,) <$> lineEndP <*> (lineTextP >>= \cl -> cl <$ guard (not $ closingFence n ch cl)) + closing <- optional $ (,) <$> lineEndP <*> lineTextP + pure $ markupText (FenceMark lang) l : concatMap (codeLine col) codeLines <> maybe [] (\(br, cl) -> [markupText HardBreak br, markupText (FenceMark Nothing) cl]) closing + where + codeLine col (br, cl) = + let (ind, code) = dropColumns col cl + in markupText HardBreak br : [markupText Indent ind | not (T.null ind)] <> [FormattedText Nothing code | not (T.null code)] + +fenceStartP :: Parser (Text, (Int, Int, Char, Maybe Text)) +fenceStartP = do + l <- lineTextP + fence <- maybe mzero pure $ fenceLine l + (l, fence) <$ lookAhead lineEndP + +headingP :: Parser MarkdownList +headingP = do + (lvl, syntax) <- headingMarkP + (headingText, closing) <- headingClosing <$> lineTextP + pure $ markupText (HeadingMark lvl) syntax : parseMarkdownList headingText <> [markupText HeadingClose closing | not (T.null closing)] + +headingMarkP :: Parser (Int, Text) +headingMarkP = do + ind <- A.takeWhile isIndentChar + hashes <- A.takeWhile1 (== '#') + guard $ T.length hashes <= 6 + sp <- A.string " " + hasTextP + pure (T.length hashes, ind <> hashes <> sp) + +ruleP :: Parser MarkdownList +ruleP = lineTextP >>= \l -> [markupText RuleMark l] <$ guard (isRule l) + +listItemP :: Parser MarkdownList +listItemP = do + (num, syntax) <- listMarkP + l <- lineTextP + ls <- A.many' $ (\br ind l' -> (br, [markupText Indent ind | not (T.null ind)], l')) <$> lineEndP <* paragraphLineP <*> A.takeWhile isIndentChar <*> lineTextP + pure $ markupText (ListMark num) syntax : inlineLines l ls + +listMarkP :: Parser (Maybe Text, Text) +listMarkP = do + ind <- A.takeWhile isIndentChar + (num, marker) <- ((Nothing,) . T.singleton <$> A.satisfy (`elem` ("-*+" :: String))) <|> numberedP + sp <- A.string " " + hasTextP + pure (num, ind <> marker <> sp) + where + numberedP = do + digits <- A.takeWhile1 isDigit + guard $ T.length digits <= 9 + marker <- T.snoc digits <$> A.satisfy (\c -> c == '.' || c == ')') + pure (Just marker, marker) + +quoteP :: Parser MarkdownList +quoteP = do + leading <- A.many' $ (\l br -> [markupText QuoteMark l, markupText HardBreak br]) <$> blankQuoteLineP <*> lineEndP + (mark, l) <- quoteTextP + ls <- nextLinesP True + pure $ concat leading <> (markupText QuoteMark mark : inlineLines l ls) + where + quoteTextP = (,) <$> quoteMarkP <* hasTextP <*> lineTextP + nextLinesP afterText = A.option [] $ do + br <- lineEndP + (syntax, l, isText) <- quoteLineP <|> blankLineP <|> (if afterText then lazyLineP else mzero) + ((br, syntax, l) :) <$> nextLinesP isText + quoteLineP = (\(m, l) -> ([markupText QuoteMark m], l, True)) <$> quoteTextP + blankLineP = (\l -> ([markupText QuoteMark l], "", False)) <$> blankQuoteLineP + lazyLineP = ([],,True) <$> (notP quoteMarkP *> paragraphLineP *> lineTextP) + +quoteMarkP :: Parser Text +quoteMarkP = do + ind <- A.takeWhile isIndentChar + sp <- A.char '>' *> A.option "" (T.singleton <$> A.satisfy isIndentChar) + pure $ ind <> ">" <> sp + +blankQuoteLineP :: Parser Text +blankQuoteLineP = (<>) <$> quoteMarkP <*> A.takeWhile isIndentChar <* lookAhead (void lineEndP <|> A.endOfInput) + +quoteStartP :: Parser () +quoteStartP = optional (blankQuoteLineP *> lineEndP) *> quoteMarkP *> hasTextP + +paragraphP :: Parser MarkdownList +paragraphP = do + l <- lineTextP + ls <- A.many' $ (,[],) <$> lineEndP <* notP underlineP <* paragraphLineP <*> lineTextP + underline <- A.option [] $ (\br (lvl, u) -> [markupText HardBreak br, markupText (HeadingUnderline lvl) u]) <$> lineEndP <*> underlineP + pure $ inlineLines l ls <> underline + +underlineP :: Parser (Int, Text) +underlineP = lineTextP >>= \l -> maybe mzero (pure . (,l)) (underlineLevel l) + +inlineLines :: Text -> [(Text, MarkdownList, Text)] -> MarkdownList +inlineLines l0 nextLines = insertBreaks breaks $ intercalate ["\n"] $ map (parseMarkdownList . T.intercalate "\n") $ runs lineTexts + where + (lineTexts, breaks) = go l0 nextLines + go l = \case + (br, syntax, l') : more -> + let (l1, brk) = if isBlank l || isBlank l' then (l, markupText HardBreak br) else breakAfter l br + (ls, bs) = go l' more + in (l1 : ls, (brk : syntax) : bs) + [] -> ([l], []) + runs = \case + l : ls | not (isBlank l) -> let (run, ls') = break isBlank ls in (l : run) : runs ls' + l : ls -> [l] : runs ls + [] -> [] + +breakAfter :: Text -> Text -> (Text, FormattedText) +breakAfter l br + | T.length spaces >= 2 = (T.dropEnd (T.length spaces) l, markupText HardBreak (spaces <> br)) + | odd (T.length $ T.takeWhileEnd (== '\\') l) = (T.dropEnd 1 l, markupText HardBreak ("\\" <> br)) + | otherwise = (l, markupText SoftBreak br) + where + spaces = T.takeWhileEnd (== ' ') l + +insertBreaks :: [MarkdownList] -> MarkdownList -> MarkdownList +insertBreaks breaks = \case + FormattedText f t : fts -> case (T.breakOn "\n" t, breaks) of + ((t1, t2), brk : breaks') | not (T.null t2) -> segment f t1 <> brk <> insertBreaks breaks' (FormattedText f (T.drop 1 t2) : fts) + _ -> segment f t <> insertBreaks breaks fts + [] -> [] + where + segment f t = [FormattedText f t | isJust f || not (T.null t)] + +lineTextP :: Parser Text +lineTextP = T.concat <$> A.many' (A.takeWhile1 (\c -> c /= '\n' && c /= '\r') <|> (A.string "\r" <* (A.peekChar >>= guard . (/= Just '\n')))) + +lineEndP :: Parser Text +lineEndP = A.string "\r\n" <|> A.string "\n" + +isIndentChar :: Char -> Bool +isIndentChar c = c == ' ' || c == '\t' + +isBlank :: Text -> Bool +isBlank = T.all isSpace + +indentColumn :: Text -> Int +indentColumn = T.foldl' (\col c -> if c == '\t' then nextTabStop col else col + 1) 0 + +nextTabStop :: Int -> Int +nextTabStop col = (col `div` 4 + 1) * 4 + +dropColumns :: Int -> Text -> (Text, Text) +dropColumns n = go 0 + where + go col s + | col >= n = ("", s) + | otherwise = case T.uncons s of + Just (' ', s') -> first (T.cons ' ') $ go (col + 1) s' + Just ('\t', s') -> first (T.cons '\t') $ go (nextTabStop col) s' + _ -> ("", s) + +fenceLine :: Text -> Maybe (Int, Int, Char, Maybe Text) +fenceLine t = do + let (ind, s) = T.span isIndentChar t + ch <- T.find (\c -> c == '`' || c == '~') (T.take 1 s) + let ticks = T.length $ T.takeWhile (== ch) s + info = T.drop ticks s + guard $ ticks >= 3 && (ch == '~' || T.all (/= '`') info) + pure (indentColumn ind, ticks, ch, listToMaybe $ T.words info) + +closingFence :: Int -> Char -> Text -> Bool +closingFence n ch t = + let s = T.dropWhile isIndentChar t + ticks = T.length $ T.takeWhile (== ch) s + in ticks >= n && T.all isSpace (T.drop ticks s) + +headingClosing :: Text -> (Text, Text) +headingClosing c = + let trimmed = T.dropWhileEnd isIndentChar c + hashes = T.takeWhileEnd (== '#') trimmed + before = T.dropEnd (T.length hashes) trimmed + c' = T.dropWhileEnd isIndentChar before + in if not (T.null hashes) && T.any (not . isSpace) c' && T.any isIndentChar (T.takeEnd 1 before) + then (c', T.drop (T.length c') c) + else (c, "") + +underlineLevel :: Text -> Maybe Int +underlineLevel t = case T.uncons s of + Just ('=', _) | T.all (== '=') s -> Just 1 + Just ('-', _) | T.all (== '-') s -> Just 2 + _ -> Nothing + where + s = T.dropWhileEnd isSpace $ T.dropWhile isIndentChar t + +isRule :: Text -> Bool +isRule t = case T.uncons s of + Just (c, _) -> c `elem` ("-*_" :: String) && T.length s >= 3 && T.all (== c) s + Nothing -> False + where + s = T.filter (not . isIndentChar) t + +tableStart :: Text -> Text -> Maybe [TableAlign] +tableStart t d = do + guard $ T.any (== '|') t + aligns <- delimiterRow d + guard $ length (tableCells t) == length aligns + pure aligns + +delimiterRow :: Text -> Maybe [TableAlign] +delimiterRow t + | T.any (== '|') t = mapM cellAlign $ tableCells t + | otherwise = Nothing + where + cellAlign cell = + let s = T.strip cell + (left, s') = maybe (False, s) (True,) $ T.stripPrefix ":" s + (right, dashes) = maybe (False, s') (True,) $ T.stripSuffix ":" s' + in if not (T.null dashes) && T.all (== '-') dashes + then Just $ case (left, right) of + (True, True) -> TACenter + (False, True) -> TARight + (True, False) -> TALeft + (False, False) -> TANone + else Nothing + +tableCells :: Text -> [Text] +tableCells t = let (_, cells, _) = tableLine t in cells + +tableLine :: Text -> (Text, [Text], Text) +tableLine t = (ind <> lead, T.splitOn "|" body, trail) + where + (ind, s) = T.span isIndentChar t + (lead, s') = maybe ("", s) ("|",) $ T.stripPrefix "|" s + ws = T.takeWhileEnd isSpace s' + s'' = T.dropEnd (T.length ws) s' + (body, trail) = maybe (s'', ws) (,"|" <> ws) $ T.stripSuffix "|" s'' + parseUri :: ByteString -> Either Text U.URI parseUri s = case U.parseURI U.laxURIParserOptions s of Left e -> Left $ "Invalid URI: " <> tshow e @@ -473,10 +941,10 @@ markdownText :: FormattedText -> Text markdownText (FormattedText f_ t) = case f_ of Nothing -> t Just f -> case f of - Bold -> around '*' + Bold (BoolDef singleStar) -> if singleStar then around '*' else "**" <> t <> "**" Italic -> around '_' StrikeThrough -> around '~' - Snippet -> around '`' + Snippet -> codeSpan Secret -> around '#' Small -> "!- " <> t <> "!" Colored (FormatColor c) -> color c @@ -488,9 +956,16 @@ markdownText (FormattedText f_ t) = case f_ of Command _ -> t Email -> t Phone -> t + Escape -> t + Markup _ -> t Unknown _ -> t where around c = c `T.cons` t `T.snoc` c + codeSpan = + let ticks = T.replicate (1 + maximum (0 : map T.length (filter (T.isPrefixOf "`") $ T.group t))) "`" + pad = T.isPrefixOf "`" t || T.isSuffixOf "`" t || (T.isPrefixOf " " t && T.isSuffixOf " " t && not (T.all (== ' ') t)) + sp = if pad then " " else "" + in ticks <> sp <> t <> sp <> ticks color c = case colorStr c of Just cStr -> cStr <> t `T.snoc` '!' Nothing -> t @@ -514,7 +989,7 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill takeNameTill p = A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted c = A.char c *> takeNameTill (== c) <* A.char c + quoted c = A.char c *> takeNameTill (\x -> x == c || x == '\n') <* A.char c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' commandTextP :: Parser (Text, Text) @@ -533,11 +1008,24 @@ viewName s = if T.any isSpace s || maybe False (isPunctuation . snd) (T.unsnoc s $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) +$(JQ.deriveJSON (enumJSON $ dropPrefix "TA") ''TableAlign) + +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''BlockToken) + $(JQ.deriveToJSON (sumTypeJSON fstToLower) ''Format) instance FromJSON Format where parseJSON v = $(JQ.mkParseJSON (sumTypeJSON fstToLower) ''Format) v <|> pure (Unknown v) +$(JQ.deriveJSON defaultJSON ''SegmentRange) + +$(JQ.deriveJSON defaultJSON ''ListEntry) + +$(JQ.deriveToJSON (sumTypeJSON fstToLower) ''MarkdownBlock) + +instance FromJSON MarkdownBlock where + parseJSON v = $(JQ.mkParseJSON (sumTypeJSON fstToLower) ''MarkdownBlock) v <|> pure (UnknownBlock v) + $(JQ.deriveJSON defaultJSON ''FormattedText) $(JQ.deriveToJSON defaultJSON ''ParsedMarkdown) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 50035b55be..d298a1d3c1 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -263,6 +263,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem -- The key is a name used in the message text, used to look up CIMention. mentions :: Map MemberName CIMention, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], file :: Maybe (CIFile d) @@ -611,10 +612,16 @@ data CIQuote (c :: ChatType) = CIQuote sharedMsgId :: Maybe SharedMsgId, -- Nothing for the messages from the old clients sentAt :: UTCTime, content :: MsgContent, - formattedText :: Maybe MarkdownList + formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock) } deriving (Show) +mkCIQuote :: CIQDirection c -> Maybe ChatItemId -> Maybe SharedMsgId -> UTCTime -> MsgContent -> CIQuote c +mkCIQuote chatDir itemId sharedMsgId sentAt content = CIQuote {chatDir, itemId, sharedMsgId, sentAt, content, formattedText, markdownBlocks} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown $ msgContentText content + quoteItemId :: CIQuote c -> Maybe ChatItemId quoteItemId CIQuote {itemId} = itemId @@ -1360,13 +1367,14 @@ data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, msgContent :: MsgContent, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), itemVersionTs :: UTCTime, createdAt :: UTCTime } deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion -mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent content +mkItemVersion ChatItem {content, formattedText, markdownBlocks, meta} = version <$> ciMsgContent content where CIMeta {itemId, itemTs, createdAt} = meta version mc = @@ -1374,6 +1382,7 @@ mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent { chatItemVersionId = itemId, msgContent = mc, formattedText, + markdownBlocks, itemVersionTs = itemTs, createdAt = createdAt } diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 807152f826..6de6d2c206 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -41,7 +41,7 @@ import Simplex.Chat import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands -import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Markdown (parseUri, parsedMarkdown, sanitizeUri) import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -394,7 +394,7 @@ chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) chatParseMarkdown :: ByteString -> JSONByteString -chatParseMarkdown = J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 +chatParseMarkdown = J.encode . parsedMarkdown . safeDecodeUtf8 {-# INLINE chatParseMarkdown #-} chatParseServer :: ByteString -> JSONByteString diff --git a/src/Simplex/Chat/PaymentService.hs b/src/Simplex/Chat/PaymentService.hs index 38f94b2118..91b7a9fde1 100644 --- a/src/Simplex/Chat/PaymentService.hs +++ b/src/Simplex/Chat/PaymentService.hs @@ -1,17 +1,28 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.PaymentService ( ServiceInvoice (..), ServicePayment (..), + appleTransactionId, + googlePurchaseRef, module Simplex.Chat.PaymentService.Types, ) where +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString.Base64.URL as B64U import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Simplex.Chat.PaymentService.Types +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) data ServiceInvoice = ServiceInvoice { invoiceId :: InvoiceId, @@ -27,11 +38,24 @@ data ServiceInvoice = ServiceInvoice data ServicePayment = SPApple {jws :: Text} - | SPGoogle {token :: Text} + | SPGoogle {productId :: Text, token :: Text} -- the Publisher API finds a purchase by product and token | SPInvoice {invoiceId :: InvoiceId} | SPReceipt {receipt :: Text} -- transfer of unissued months deriving (Show) +-- | Read without verifying the signature: it names the transaction, and proves nothing about it. +appleTransactionId :: Text -> Maybe Text +appleTransactionId signed = case T.splitOn "." signed of + [_, payload, _] -> do + J.Object o <- J.decodeStrict' =<< eitherToMaybe (B64U.decodeUnpadded $ encodeUtf8 payload) + J.String txId <- JM.lookup "transactionId" o + pure txId + _ -> Nothing + +-- | A purchase token is a bearer secret, so a purchase is named by its hash. +googlePurchaseRef :: Text -> Text +googlePurchaseRef = safeDecodeUtf8 . strEncode . C.sha256Hash . encodeUtf8 + $(JQ.deriveJSON defaultJSON ''ServiceInvoice) $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SP") ''ServicePayment) diff --git a/src/Simplex/Chat/PaymentService/Types.hs b/src/Simplex/Chat/PaymentService/Types.hs index 067e170e90..fe05ecbca4 100644 --- a/src/Simplex/Chat/PaymentService/Types.hs +++ b/src/Simplex/Chat/PaymentService/Types.hs @@ -1,6 +1,9 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.PaymentService.Types @@ -8,6 +11,7 @@ module Simplex.Chat.PaymentService.Types InvoiceId (..), PaymentId (..), PaymentProvider (..), + StoreTransactionRef (..), CardProvider (..), CryptoCurrency (..), ServicePaymentMethod (..), @@ -26,7 +30,16 @@ import Data.ByteString.Char8 (ByteString) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word32) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Encoding.String (TextEncoding (..)) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif -- USD etc. are in minor units, following Stripe etc. convention newtype CurrencyAmount = CurrencyAmount Word32 @@ -45,6 +58,31 @@ newtype PaymentId = PaymentId Text data PaymentProvider = PPApple | PPGoogle | PPStripe | PPCrypto | PPCode | PPReceipt deriving (Eq, Show) +instance TextEncoding PaymentProvider where + textEncode = \case + PPApple -> "apple" + PPGoogle -> "google" + PPStripe -> "stripe" + PPCrypto -> "crypto" + PPCode -> "code" + PPReceipt -> "receipt" + textDecode = \case + "apple" -> Just PPApple + "google" -> Just PPGoogle + "stripe" -> Just PPStripe + "crypto" -> Just PPCrypto + "code" -> Just PPCode + "receipt" -> Just PPReceipt + _ -> Nothing + +instance FromField PaymentProvider where fromField = fromTextField_ textDecode + +instance ToField PaymentProvider where toField = toField . textEncode + +-- | The stable name of one store transaction, by which a retry resolves to the same row. +data StoreTransactionRef = StoreTransactionRef {provider :: PaymentProvider, transactionRef :: Text} + deriving (Eq, Show) + data CardProvider = CPStripe deriving (Eq, Show) diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index b460a73d3d..bc1d069540 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -1,16 +1,17 @@ +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.ProfileGenerator where import Data.Text (Text) -import Simplex.Chat.Types (Profile (..)) +import Simplex.Chat.Types (ChatPeerType, Profile (..)) import System.Random (randomRIO) -generateRandomProfile :: IO Profile -generateRandomProfile = do +generateRandomProfile :: Maybe ChatPeerType -> IO Profile +generateRandomProfile peerType = do adjective <- pick adjectives noun <- pickNoun adjective 2 - pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing} + pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType, preferences = Nothing, badge = Nothing, contactDomain = Nothing} where pick :: [a] -> IO a pick xs = (xs !!) <$> randomRIO (0, length xs - 1) diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index 19d6d2efef..12a914d35f 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -7,7 +7,8 @@ {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Badges - ( BadgeCodeRedemption (..), + ( BadgeStash (..), + BadgeStashRef (..), UserBadgePurchase (..), getUserBadgePurchase, getBadgePurchase, @@ -18,9 +19,18 @@ module Simplex.Chat.Store.Badges clearShownBadge, getBadgeCodeRedemption, createBadgeCodeRedemption, + BadgeReceiptRecord (..), + BadgeReceiptStatus (..), + holdStoreReceipt, + createBadgeStoreReceipt, + getOpenStorePurchases, + getNextHeldStoreReceipt, + recordStoreReceiptFailure, + refuseStoreReceipt, + deleteUnfundedStoreReceipts, deleteBadgeCodeRedemption, - createCodeBadgePurchase, - getCodeBadgePurchase, + createStashBadgePurchase, + getStashBadgePurchase, storeBadgeIssuance, getLatestIssuedCredential, storeBadgeStatement, @@ -31,8 +41,10 @@ module Simplex.Chat.Store.Badges where import Control.Concurrent.STM (TVar, atomically) +import Control.Monad (forM, forM_, join) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Maybe (isJust, mapMaybe) @@ -41,9 +53,12 @@ import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) -import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..)) -import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..), OpenStorePurchase (..)) +import Simplex.Chat.PaymentService (ServicePayment) +import Simplex.Chat.PaymentService.Types (StoreTransactionRef (..)) +import Simplex.Chat.Store.Shared (StoreError, insertedRowId) import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C @@ -58,18 +73,20 @@ import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif --- | The keys one redemption attempt is signed with, stashed before the request is sent so that a --- retry reaches the service as the same signer and is answered with the credential already issued. -data BadgeCodeRedemption = BadgeCodeRedemption - { redemptionId :: Int64, +-- | The keys one attempt to fund a badge is signed with, stashed before the request is sent so that +-- a retry reaches the service as the same signer and is answered with the credential already issued. +data BadgeStash = BadgeStash + { stashRef :: BadgeStashRef, purchaseKey :: C.PublicKeyEd25519, purchasePrivKey :: C.PrivateKeyEd25519, masterKey :: BadgeMasterKey } -getBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO (Maybe BadgeCodeRedemption) +data BadgeStashRef = BSRCodeRedemption Int64 | BSRStoreReceipt Int64 + +getBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO (Maybe BadgeStash) getBadgeCodeRedemption db User {userId} code = - maybeFirstRow toRedemption $ + maybeFirstRow (toBadgeStash BSRCodeRedemption) $ DB.query db [sql| @@ -78,11 +95,8 @@ getBadgeCodeRedemption db User {userId} code = WHERE user_id = ? AND code = ? |] (userId, code) - where - toRedemption (redemptionId, purchaseKey, purchasePrivKey, Binary mk) = - BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} -createBadgeCodeRedemption :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO BadgeCodeRedemption +createBadgeCodeRedemption :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO BadgeStash createBadgeCodeRedemption db g User {userId} code now = do (purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g masterKey@(BadgeMasterKey mk) <- generateMasterKey g @@ -94,42 +108,206 @@ createBadgeCodeRedemption db g User {userId} code now = do |] (userId, code, purchaseKey, purchasePrivKey, Binary mk, now) redemptionId <- insertedRowId db - pure BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey} + pure BadgeStash {stashRef = BSRCodeRedemption redemptionId, purchaseKey, purchasePrivKey, masterKey} --- | Drop a stashed attempt whose code the service refused for good, unless a purchase already --- came from it - badge_purchases references this row. -deleteBadgeCodeRedemption :: DB.Connection -> Int64 -> IO () -deleteBadgeCodeRedemption db redemptionId = +-- | Our record of a store receipt, as against the receipt itself: held until the service credits or refuses it. +data BadgeReceiptRecord = BadgeReceiptRecord + { receiptId :: Int64, + ownerId :: UserId, + status :: BadgeReceiptStatus + } + +data BadgeReceiptStatus + = RSHeld {stash :: BadgeStash, invoiceId :: Maybe Text, payment :: Text, nextAttemptAt :: UTCTime, retryDelay :: Maybe Int64} + | RSCredited {badgePurchaseId :: Int64} + | RSRefused {refusal :: Maybe BadgeIssueFailure} + +-- | Resolves a store transaction to the record that owns it, looking up by its reference again after each step +-- rather than trusting the write: finding none still leaves the echoed invoice's record to attach it to, and a +-- concurrent hand-over of the same transaction may win either write. It stays with the record that already has +-- it, whose keys the service may have credited; failing that it joins the record Buy created; and when no record +-- can take it - the store echoed no invoice, or the one it echoed is already held by another transaction - the +-- presenting profile gets a new one, nothing else saying who paid. +holdStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Maybe Text -> StoreTransactionRef -> ServicePayment -> UTCTime -> IO (Maybe BadgeReceiptRecord) +holdStoreReceipt db g User {userId} invoiceId_ txRef@StoreTransactionRef {provider, transactionRef} payment now = + getReceiptRecord db txRef >>= \case + Just r@BadgeReceiptRecord {receiptId} -> do + -- a resolved record stays resolved, so a hand-over landing just after its credit cannot hold it again + DB.execute db "UPDATE badge_store_receipts SET payment = ? WHERE badge_store_receipt_id = ? AND payment IS NOT NULL" (paymentJSON, receiptId) + pure $ Just r + Nothing -> do + forM_ invoiceId_ $ \invoiceId -> + DB.execute + db + "UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, payment = ?, next_attempt_at = ? WHERE invoice_id = ? AND transaction_ref IS NULL" + (provider, transactionRef, paymentJSON, now, invoiceId) + getReceiptRecord db txRef >>= \case + Just r -> pure $ Just r + Nothing -> do + insertReceipt + getReceiptRecord db txRef + where + paymentJSON = safeDecodeUtf8 . LB.toStrict $ J.encode payment + insertReceipt = do + (purchaseKey, purchasePrivKey) <- atomically (C.generateKeyPair g) :: IO C.KeyPairEd25519 + BadgeMasterKey mk <- generateMasterKey g + -- an invoice id another record holds is not repeated: this record is then keyed by its transaction alone + invoiceId' <- fmap join . forM invoiceId_ $ \invoiceId -> do + taken <- DB.query db "SELECT badge_store_receipt_id FROM badge_store_receipts WHERE invoice_id = ?" (Only invoiceId) :: IO [Only Int64] + pure $ if null taken then Just invoiceId else Nothing + DB.execute + db + [sql| + INSERT INTO badge_store_receipts + (user_id, invoice_id, provider, transaction_ref, payment, next_attempt_at, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT DO NOTHING + |] + ((userId, invoiceId', provider, transactionRef, paymentJSON, now) :. (purchaseKey, purchasePrivKey, Binary mk, now)) + +getReceiptRecord :: DB.Connection -> StoreTransactionRef -> IO (Maybe BadgeReceiptRecord) +getReceiptRecord db StoreTransactionRef {provider, transactionRef} = + maybeFirstRow toReceiptRecord $ + DB.query + db + [sql| + SELECT r.badge_store_receipt_id, r.user_id, r.purchase_key, r.purchase_priv_key, r.master_key, + r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error + FROM badge_store_receipts r + LEFT JOIN badge_purchases p ON p.badge_store_receipt_id = r.badge_store_receipt_id + WHERE r.provider = ? AND r.transaction_ref = ? + |] + (provider, transactionRef) + where + toReceiptRecord ((receiptId, ownerId, purchaseKey, purchasePrivKey, mk) :. (invoiceId, payment_, nextAttemptAt_, retryDelay, purchaseId_, refusal)) = + BadgeReceiptRecord {receiptId, ownerId, status} + where + status = case (payment_, nextAttemptAt_) of + (Just payment, Just nextAttemptAt) -> RSHeld {stash = toBadgeStash BSRStoreReceipt (receiptId, purchaseKey, purchasePrivKey, mk), invoiceId, payment, nextAttemptAt, retryDelay} + _ -> maybe (RSRefused refusal) RSCredited purchaseId_ + +-- | The record made when Buy is tapped, before any receipt: the store echoes its invoice id. +createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO () +createBadgeStoreReceipt db g User {userId} invoiceId now = do + (purchaseKey, purchasePrivKey) <- atomically (C.generateKeyPair g) :: IO C.KeyPairEd25519 + BadgeMasterKey mk <- generateMasterKey g + DB.execute + db + [sql| + INSERT INTO badge_store_receipts (user_id, invoice_id, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?) + |] + (userId, invoiceId, purchaseKey, purchasePrivKey, Binary mk, now) + +-- | No receipt yet, or one held: a credited or refused record is resolved and not listed. +getOpenStorePurchases :: DB.Connection -> User -> IO [OpenStorePurchase] +getOpenStorePurchases db User {userId} = + map toOpenStorePurchase + <$> DB.query + db + [sql| + SELECT invoice_id, transaction_ref, credit_error + FROM badge_store_receipts + WHERE user_id = ? AND (transaction_ref IS NULL OR payment IS NOT NULL) + ORDER BY badge_store_receipt_id + |] + (Only userId) + where + toOpenStorePurchase (invoiceId, transactionRef, creditError) = OpenStorePurchase {invoiceId, transactionRef, creditError} + +getNextHeldStoreReceipt :: DB.Connection -> UserId -> IO (Either StoreError (Maybe BadgeReceiptRecord)) +getNextHeldStoreReceipt db userId = + fmap Right . maybeFirstRow toHeld $ + DB.query + db + [sql| + SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay + FROM badge_store_receipts r + JOIN users u ON u.user_id = r.user_id + WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL + ORDER BY r.next_attempt_at, r.retry_count, r.badge_store_receipt_id + LIMIT 1 + |] + (Only userId) + where + toHeld (stashRow@(receiptId, _, _, _) :. (invoiceId, payment, nextAttemptAt, retryDelay)) = + BadgeReceiptRecord {receiptId, ownerId = userId, status = RSHeld {stash = toBadgeStash BSRStoreReceipt stashRow, invoiceId, payment, nextAttemptAt, retryDelay}} + +recordStoreReceiptFailure :: DB.Connection -> Int64 -> Int64 -> UTCTime -> BadgeIssueFailure -> IO () +recordStoreReceiptFailure db receiptId retryDelay nextAttemptAt failure = + DB.execute + db + [sql| + UPDATE badge_store_receipts + SET retry_delay = ?, next_attempt_at = ?, retry_count = retry_count + 1, credit_error = ? + WHERE badge_store_receipt_id = ? AND payment IS NOT NULL + |] + (retryDelay, nextAttemptAt, failure, receiptId) + +-- | Kept rather than deleted, so that a later hand-over of the same transaction is answered from the record. +refuseStoreReceipt :: DB.Connection -> Int64 -> BadgeIssueFailure -> IO () +refuseStoreReceipt db receiptId refusal = + DB.execute + db + "UPDATE badge_store_receipts SET payment = NULL, next_attempt_at = NULL, retry_delay = NULL, retry_count = 0, credit_error = ? WHERE badge_store_receipt_id = ?" + (refusal, receiptId) + +-- | A held record is paid for and a credited one is referenced by its purchase, so neither is deleted. +deleteUnfundedStoreReceipts :: DB.Connection -> UTCTime -> IO () +deleteUnfundedStoreReceipts db createdBefore = + DB.execute + db + [sql| + DELETE FROM badge_store_receipts + WHERE payment IS NULL AND created_at < ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_store_receipt_id = badge_store_receipts.badge_store_receipt_id) + |] + (Only createdBefore) + +toBadgeStash :: (Int64 -> BadgeStashRef) -> (Int64, C.PublicKeyEd25519, C.PrivateKeyEd25519, Binary ByteString) -> BadgeStash +toBadgeStash ref (stashId, purchaseKey, purchasePrivKey, Binary mk) = + BadgeStash {stashRef = ref stashId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} + +-- | Drop the keys stashed for a code the service refused for good, unless a purchase already came +-- from them - badge_purchases references their row. +deleteBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO () +deleteBadgeCodeRedemption db User {userId} code = DB.execute db [sql| DELETE FROM badge_code_redemptions - WHERE badge_code_redemption_id = ? - AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?) + WHERE user_id = ? AND code = ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_code_redemption_id = badge_code_redemptions.badge_code_redemption_id) |] - (redemptionId, redemptionId) + (userId, code) --- | 'False' when the code was already redeemed here: the service replays the credential it +-- | 'False' when the stash already funded a purchase here: the service replays the credential it -- issued, and that must add no purchase and leave the shown badge alone. -createCodeBadgePurchase :: DB.Connection -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> IO (Int64, Bool) -createCodeBadgePurchase db User {userId} redemption credential now = - getCodeBadgePurchase db redemption >>= \case +createStashBadgePurchase :: DB.Connection -> User -> BadgeStash -> BadgeCredential -> UTCTime -> IO (Int64, Bool) +createStashBadgePurchase db User {userId} stash credential now = do + -- the credit settles a held receipt in the transaction that stores its purchase, a replay included + forM_ storeReceiptId_ $ \storeReceiptId -> DB.execute db "UPDATE badge_store_receipts SET payment = NULL, next_attempt_at = NULL, retry_delay = NULL, retry_count = 0 WHERE badge_store_receipt_id = ?" (Only storeReceiptId) + getStashBadgePurchase db stash >>= \case Just purchaseId -> pure (purchaseId, False) Nothing -> do DB.execute db [sql| INSERT INTO badge_purchases - (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?) + (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, + badge_code_redemption_id, badge_store_receipt_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, PSIssued, redemptionId, now, now) + ((userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType) :. (PSIssued, redemptionId_, storeReceiptId_, now, now)) purchaseId <- insertedRowId db DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId) pure (purchaseId, True) where - BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption + BadgeStash {stashRef, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = stash BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential + (redemptionId_, storeReceiptId_) = case stashRef of + BSRCodeRedemption redemptionId -> (Just redemptionId, Nothing) + BSRStoreReceipt storeReceiptId -> (Nothing, Just storeReceiptId) -- | The period comes from the ledger, the expiry from the credential, which runs a week longer. -- 'False' means no issuance row was written, which the caller reports rather than drop in silence. @@ -194,10 +372,13 @@ getIssuedPeriod db badgePurchaseId entryId = do [(Just periodStart, periodEnd)] -> Just (periodStart, periodEnd) _ -> Nothing -getCodeBadgePurchase :: DB.Connection -> BadgeCodeRedemption -> IO (Maybe Int64) -getCodeBadgePurchase db BadgeCodeRedemption {redemptionId} = - maybeFirstRow fromOnly $ - DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId) +getStashBadgePurchase :: DB.Connection -> BadgeStash -> IO (Maybe Int64) +getStashBadgePurchase db BadgeStash {stashRef} = + maybeFirstRow fromOnly $ case stashRef of + BSRCodeRedemption redemptionId -> + DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId) + BSRStoreReceipt storeReceiptId -> + DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_store_receipt_id = ?" (Only storeReceiptId) data UserBadgePurchase = UserBadgePurchase { badgePurchaseId :: Int64, @@ -370,7 +551,7 @@ getBadgeLedger db User {userId} badgePurchaseId = toStatementEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType) :. (Maybe UTCTime, UTCTime, Text, Maybe Text, Maybe Text, Maybe Text) -> Maybe StatementEntry toStatementEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) = (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}) - <$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ + <$> maybe (entryTypeFromColumns Nothing entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ -- | Decodes the stored JSON rather than rebuilding from the tag, so a version that has since -- learnt the type reads it with its fields, and one that has not still gets it back verbatim. diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 35a5138ed1..7db176bd50 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -76,7 +76,7 @@ createOrUpdateContactRequest invId cReqChatVRange@(VersionRange minV maxV) presHeader_ - profile@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} + profile@Profile {displayName, fullName, shortDescr, description, image, contactLink, peerType, preferences} xContactId_ welcomeMsgId_ requestMsg_ @@ -174,8 +174,8 @@ createOrUpdateContactRequest liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId) :. ("" :: LocalAlias, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, userId) :. ("" :: LocalAlias, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- liftIO $ insertedRowId db liftIO $ DB.execute @@ -247,6 +247,7 @@ createOrUpdateContactRequest description = ?, image = ?, contact_link = ?, + chat_peer_type = ?, updated_at = ?, badge_proof = ?, badge_pres_header = ?, @@ -264,7 +265,7 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - ((displayName, fullName, shortDescr, description, image, contactLink, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId)) + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId)) updateRequest currentTs = if displayName == oldDisplayName then diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 462b3629f1..67ba030ee1 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -752,17 +752,17 @@ updateMemberContactProfileReset_ db userId profileId profile badgeVerified = do updateMemberContactProfileReset_' db userId profileId profile badgeVerified currentTs updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () -updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactDomain, badge} badgeVerified updatedAt = +updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, peerType, contactDomain, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, preferences_json = NULL, updated_at = ?, + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, preferences = NULL, preferences_json = NULL, updated_at = ?, badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? |] - ((displayName, fullName, shortDescr, description, image, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) -- update only member profile fields (when member has associated contact - we keep contactLink and prefs) updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index e9d99fbe89..839a3d5746 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -2127,7 +2127,7 @@ createJoiningMember User {userId, userContactId} GroupInfo {groupId, membership} cReqChatVRange - p@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} + p@Profile {displayName, fullName, shortDescr, description, image, contactLink, peerType, preferences} presHeader_ cReqXContactId_ cReqMemberId_ @@ -2141,8 +2141,8 @@ createJoiningMember liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, userId, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- liftIO $ insertedRowId db r@(groupMemberId, _) <- case cReqMemberId_ of Just memberId -> do @@ -2500,13 +2500,13 @@ createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profil createNewMember_ db user (groupId' gInfo) newMember badgeVerified currentTs createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> Maybe ProofPresHeader -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Profile, Maybe Bool) -createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} presHeader_ createdAt = +createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, shortDescr, description, image, contactLink, peerType, preferences} presHeader_ createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do (p'@Profile {badge}, badgeVerified) <- profileBadgeVerified presHeader_ (badgeKeys cxt) Nothing p DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId, createdAt, createdAt) :. badgeToRow badge badgeVerified :. prefsToRow preferences) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, userId, createdAt, createdAt) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- insertedRowId db pure $ Right (ldn, profileId, p', badgeVerified) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b8909da1b3..5ad9107e6f 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -690,7 +690,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c - ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content + ciQuote itemId dir = mkCIQuote dir itemId msgId sentAt content getDirectChatItemQuote_ :: Int64 -> Bool -> IO (CIQuote 'CTDirect) getDirectChatItemQuote_ contactId userSent = do fmap ciQuoteDirect . maybeFirstRow fromOnly $ @@ -1119,7 +1119,9 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = Nothing, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d ciMeta content status = @@ -1282,6 +1284,7 @@ safeToDirectItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -1628,6 +1631,7 @@ safeToGroupItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -1868,6 +1872,7 @@ safeToLocalItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -2302,7 +2307,7 @@ toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction . unBI <$> qu toQuote :: QuoteRow -> Maybe (CIQDirection c) -> Maybe (CIQuote c) toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir = - CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) + mkCIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) @@ -2331,7 +2336,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = toDirectQuote quoteRow, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = @@ -2423,7 +2430,9 @@ toGroupChatItem _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = @@ -2569,6 +2578,7 @@ updateDirectChatItem' db User {userId} contactId ci newContent edited live timed updatedChatItem :: ChatItem c d -> CIContent d -> Bool -> Bool -> Maybe CITimed -> UTCTime -> ChatItem c d updatedChatItem ci@ChatItem {meta = meta@CIMeta {itemStatus, itemEdited, itemTimed, itemLive}} newContent edited live timed_ currentTs = let newText = ciContentToText newContent + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown newText edited' = itemEdited || edited live' = (live &&) <$> itemLive timed' = case timed_ of @@ -2580,7 +2590,7 @@ updatedChatItem ci@ChatItem {meta = meta@CIMeta {itemStatus, itemEdited, itemTim let deleteAt' = addUTCTime (realToFrac ttl) currentTs in Just CITimed {ttl, deleteAt = Just deleteAt'} _ -> itemTimed - in ci {content = newContent, meta = meta {itemText = newText, itemEdited = edited', itemTimed = timed', itemLive = live'}, formattedText = parseMaybeMarkdownList newText} + in ci {content = newContent, meta = meta {itemText = newText, itemEdited = edited', itemTimed = timed', itemLive = live'}, formattedText, markdownBlocks} -- this function assumes that direct item with correct chat direction already exists, -- it should be checked before calling it @@ -2861,7 +2871,7 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) - pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing} + pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing, markdownBlocks = Nothing} deleteMemberCIs :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO () deleteMemberCIs db User {userId} GroupInfo {groupId, membership} member = do @@ -2933,7 +2943,7 @@ updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (DBCIBlockedByAdmin, deletedTs, currentTs, userId, groupId, itemId) - pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing} + pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing, markdownBlocks = Nothing} pattern DBCINotDeleted :: Int pattern DBCINotDeleted = 0 @@ -3370,8 +3380,8 @@ getChatItemVersions db itemId = do where toChatItemVersion :: (Int64, MsgContent, UTCTime, UTCTime) -> ChatItemVersion toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = - let formattedText = parseMaybeMarkdownList $ msgContentText msgContent - in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt} + let ParsedMarkdown formattedText markdownBlocks = parsedMarkdown $ msgContentText msgContent + in ChatItemVersion {chatItemVersionId, msgContent, formattedText, markdownBlocks, itemVersionTs, createdAt} directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect) directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 7fceb20dfe..ce096b81e0 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -56,6 +56,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group import Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification +import Simplex.Chat.Store.Postgres.Migrations.M20261016_badge_store_receipts import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -111,7 +112,8 @@ schemaMigrations = ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), - ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification) + ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification), + ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs index 98bdd53db7..20fc4fa99d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs @@ -152,6 +152,7 @@ DROP TABLE @badge_prices; |] {- TODO [badges] deferred draft schema for paid purchases, subscriptions, upgrades and transfers. +The service added @badge_purchases.payment_id and @badge_ledger.payment_id in its 20260925_badge_store_receipts, so they left this draft; the client's stay deferred with the rest, as nothing writes its @payments yet. CREATE TABLE @subscription_charges( charge_id TEXT NOT NULL PRIMARY KEY, @@ -165,10 +166,6 @@ CREATE TABLE @subscription_charges( UNIQUE(payment_id, provider_charge_ref) ); -ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; - -CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); - CREATE TABLE @badge_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, @@ -200,16 +197,12 @@ CREATE TABLE @badge_subscription_changes( CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); -ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; - ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; ALTER TABLE @badge_ledger ADD COLUMN from_purchase_id BIGINT REFERENCES @badge_purchases; ALTER TABLE @badge_ledger ADD COLUMN to_purchase_id BIGINT REFERENCES @badge_purchases; -CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); - CREATE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id); CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); @@ -217,11 +210,9 @@ CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); -- down -DROP INDEX @idx_badge_ledger_payment; DROP INDEX @idx_badge_ledger_charge; DROP INDEX @idx_badge_ledger_from_purchase; DROP INDEX @idx_badge_ledger_to_purchase; -ALTER TABLE @badge_ledger DROP COLUMN payment_id; ALTER TABLE @badge_ledger DROP COLUMN charge_id; ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; @@ -231,8 +222,6 @@ DROP INDEX @idx_badge_invoices_purchase; DROP INDEX @idx_badge_invoices_offer; DROP INDEX @idx_badge_invoices_price; DROP TABLE @badge_invoices; -DROP INDEX @idx_badge_purchases_payment; -ALTER TABLE @badge_purchases DROP COLUMN payment_id; DROP TABLE @subscription_charges; -} diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261016_badge_store_receipts.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261016_badge_store_receipts.hs new file mode 100644 index 0000000000..a506e5a06b --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261016_badge_store_receipts.hs @@ -0,0 +1,51 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20261016_badge_store_receipts where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +-- | invoice_id is null for a receipt naming one another row holds: the transaction still has to be +-- creditable, and its reference identifies it. provider and transaction_ref are null until a receipt +-- arrives, and distinct NULLs let several rows await one at once. payment is held from the receipt's +-- arrival until the service credits or refuses it; a refused row keeps its refusal in credit_error. +m20261016_badge_store_receipts :: Text +m20261016_badge_store_receipts = + [r| +CREATE TABLE badge_store_receipts( + badge_store_receipt_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + invoice_id TEXT UNIQUE, + provider TEXT, + transaction_ref TEXT, + purchase_key BYTEA NOT NULL, + purchase_priv_key BYTEA NOT NULL, + master_key BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + payment TEXT, + next_attempt_at TIMESTAMPTZ, + retry_delay BIGINT, + retry_count BIGINT NOT NULL DEFAULT 0, + credit_error TEXT, + UNIQUE(provider, transaction_ref) +); + +CREATE INDEX idx_badge_store_receipts_user ON badge_store_receipts(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_store_receipt_id BIGINT REFERENCES badge_store_receipts; + +CREATE UNIQUE INDEX idx_badge_purchases_store_receipt ON badge_purchases(badge_store_receipt_id); +|] + +down_m20261016_badge_store_receipts :: Text +down_m20261016_badge_store_receipts = + [r| +DROP INDEX idx_badge_purchases_store_receipt; + +ALTER TABLE badge_purchases DROP COLUMN badge_store_receipt_id; + +DROP INDEX idx_badge_store_receipts_user; + +DROP TABLE badge_store_receipts; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 835181199c..d062fc41e2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -279,7 +279,8 @@ CREATE TABLE test_chat_schema.badge_purchases ( issue_failed_since timestamp with time zone, issue_error_at timestamp with time zone, issue_error text, - next_wake_at timestamp with time zone + next_wake_at timestamp with time zone, + badge_store_receipt_id bigint ); @@ -295,6 +296,36 @@ ALTER TABLE test_chat_schema.badge_purchases ALTER COLUMN badge_purchase_id ADD +CREATE TABLE test_chat_schema.badge_store_receipts ( + badge_store_receipt_id bigint NOT NULL, + user_id bigint NOT NULL, + invoice_id text, + provider text, + transaction_ref text, + purchase_key bytea NOT NULL, + purchase_priv_key bytea NOT NULL, + master_key bytea NOT NULL, + created_at timestamp with time zone NOT NULL, + payment text, + next_attempt_at timestamp with time zone, + retry_delay bigint, + retry_count bigint DEFAULT 0 NOT NULL, + credit_error text +); + + + +ALTER TABLE test_chat_schema.badge_store_receipts ALTER COLUMN badge_store_receipt_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.badge_store_receipts_badge_store_receipt_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.calls ( call_id bigint NOT NULL, contact_id bigint NOT NULL, @@ -794,7 +825,8 @@ CREATE TABLE test_chat_schema.delivery_jobs ( failed smallint DEFAULT 0, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, - sender_group_member_ids text + sender_group_member_ids text, + notification smallint DEFAULT 0 NOT NULL ); @@ -1810,6 +1842,21 @@ ALTER TABLE ONLY test_chat_schema.badge_purchases +ALTER TABLE ONLY test_chat_schema.badge_store_receipts + ADD CONSTRAINT badge_store_receipts_invoice_id_key UNIQUE (invoice_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_store_receipts + ADD CONSTRAINT badge_store_receipts_pkey PRIMARY KEY (badge_store_receipt_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_store_receipts + ADD CONSTRAINT badge_store_receipts_provider_transaction_ref_key UNIQUE (provider, transaction_ref); + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_pkey PRIMARY KEY (call_id); @@ -2214,10 +2261,18 @@ CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON test_chat_schema.badg +CREATE UNIQUE INDEX idx_badge_purchases_store_receipt ON test_chat_schema.badge_purchases USING btree (badge_store_receipt_id); + + + CREATE INDEX idx_badge_purchases_user ON test_chat_schema.badge_purchases USING btree (user_id); +CREATE INDEX idx_badge_store_receipts_user ON test_chat_schema.badge_store_receipts USING btree (user_id); + + + CREATE INDEX idx_calls_chat_item_id ON test_chat_schema.calls USING btree (chat_item_id); @@ -3020,11 +3075,21 @@ ALTER TABLE ONLY test_chat_schema.badge_purchases +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_badge_store_receipt_id_fkey FOREIGN KEY (badge_store_receipt_id) REFERENCES test_chat_schema.badge_store_receipts(badge_store_receipt_id); + + + ALTER TABLE ONLY test_chat_schema.badge_purchases ADD CONSTRAINT badge_purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; +ALTER TABLE ONLY test_chat_schema.badge_store_receipts + ADD CONSTRAINT badge_store_receipts_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 6ed3e81d1f..535e5d3864 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -179,6 +179,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group import Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification +import Simplex.Chat.Store.SQLite.Migrations.M20261016_badge_store_receipts import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -357,7 +358,8 @@ schemaMigrations = ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), - ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification) + ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification), + ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs index 3462db9bc1..8646d8b29e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs @@ -153,6 +153,7 @@ DROP TABLE @badge_prices; |] {- TODO [badges] deferred draft schema for paid purchases, subscriptions, upgrades and transfers. +The service added @badge_purchases.payment_id and @badge_ledger.payment_id in its 20260925_badge_store_receipts, so they left this draft; the client's stay deferred with the rest, as nothing writes its @payments yet. CREATE TABLE @subscription_charges( charge_id TEXT NOT NULL PRIMARY KEY, @@ -166,10 +167,6 @@ CREATE TABLE @subscription_charges( UNIQUE(payment_id, provider_charge_ref) ) STRICT; -ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; - -CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); - CREATE TABLE @badge_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, @@ -201,16 +198,12 @@ CREATE TABLE @badge_subscription_changes( CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); -ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; - ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; ALTER TABLE @badge_ledger ADD COLUMN from_purchase_id INTEGER REFERENCES @badge_purchases; ALTER TABLE @badge_ledger ADD COLUMN to_purchase_id INTEGER REFERENCES @badge_purchases; -CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); - CREATE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id); CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); @@ -218,11 +211,9 @@ CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); -- down -DROP INDEX @idx_badge_ledger_payment; DROP INDEX @idx_badge_ledger_charge; DROP INDEX @idx_badge_ledger_from_purchase; DROP INDEX @idx_badge_ledger_to_purchase; -ALTER TABLE @badge_ledger DROP COLUMN payment_id; ALTER TABLE @badge_ledger DROP COLUMN charge_id; ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; @@ -232,8 +223,6 @@ DROP INDEX @idx_badge_invoices_purchase; DROP INDEX @idx_badge_invoices_offer; DROP INDEX @idx_badge_invoices_price; DROP TABLE @badge_invoices; -DROP INDEX @idx_badge_purchases_payment; -ALTER TABLE @badge_purchases DROP COLUMN payment_id; DROP TABLE @subscription_charges; -} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261016_badge_store_receipts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261016_badge_store_receipts.hs new file mode 100644 index 0000000000..25c50ccd8f --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261016_badge_store_receipts.hs @@ -0,0 +1,50 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261016_badge_store_receipts where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- | invoice_id is null for a receipt naming one another row holds: the transaction still has to be +-- creditable, and its reference identifies it. provider and transaction_ref are null until a receipt +-- arrives, and distinct NULLs let several rows await one at once. payment is held from the receipt's +-- arrival until the service credits or refuses it; a refused row keeps its refusal in credit_error. +m20261016_badge_store_receipts :: Query +m20261016_badge_store_receipts = + [sql| +CREATE TABLE badge_store_receipts( + badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + invoice_id TEXT UNIQUE, + provider TEXT, + transaction_ref TEXT, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + created_at TEXT NOT NULL, + payment TEXT, + next_attempt_at TEXT, + retry_delay INTEGER, + retry_count INTEGER NOT NULL DEFAULT 0, + credit_error TEXT, + UNIQUE(provider, transaction_ref) +) STRICT; + +CREATE INDEX idx_badge_store_receipts_user ON badge_store_receipts(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_store_receipt_id INTEGER REFERENCES badge_store_receipts; + +CREATE UNIQUE INDEX idx_badge_purchases_store_receipt ON badge_purchases(badge_store_receipt_id); +|] + +down_m20261016_badge_store_receipts :: Query +down_m20261016_badge_store_receipts = + [sql| +DROP INDEX idx_badge_purchases_store_receipt; + +ALTER TABLE badge_purchases DROP COLUMN badge_store_receipt_id; + +DROP INDEX idx_badge_store_receipts_user; + +DROP TABLE badge_store_receipts; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index dc8ad0f248..d7debec504 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1526,10 +1526,6 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? -Plan: -SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 64aea20620..fefef85c38 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -507,6 +507,7 @@ Query: description = ?, image = ?, contact_link = ?, + chat_peer_type = ?, updated_at = ?, badge_proof = ?, badge_pres_header = ?, @@ -1253,8 +1254,17 @@ Plan: Query: INSERT INTO badge_purchases - (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at) + (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, + badge_code_redemption_id, badge_store_receipt_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO badge_store_receipts + (user_id, invoice_id, provider, transaction_ref, payment, next_attempt_at, purchase_key, purchase_priv_key, master_key, created_at) VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT DO NOTHING Plan: @@ -4077,6 +4087,15 @@ Query: Plan: SCAN group_members +Query: + SELECT invoice_id, transaction_ref, credit_error + FROM badge_store_receipts + WHERE user_id = ? AND (transaction_ref IS NULL OR payment IS NOT NULL) + ORDER BY badge_store_receipt_id + +Plan: +SEARCH badge_store_receipts USING INDEX idx_badge_store_receipts_user (user_id=?) + Query: SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, l.was_paused_since, l.service_created_at, l.entry_type, l.entry_credit_type, l.entry_debit_type, l.entry_type_value @@ -4135,6 +4154,30 @@ Query: Plan: SEARCH file_badge_proofs USING INDEX idx_file_badge_proofs_file_id_kind (file_id=?) +Query: + SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay + FROM badge_store_receipts r + JOIN users u ON u.user_id = r.user_id + WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL + ORDER BY r.next_attempt_at, r.retry_count, r.badge_store_receipt_id + LIMIT 1 + +Plan: +SEARCH u USING INTEGER PRIMARY KEY (rowid=?) +SEARCH r USING INDEX idx_badge_store_receipts_user (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT r.badge_store_receipt_id, r.user_id, r.purchase_key, r.purchase_priv_key, r.master_key, + r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error + FROM badge_store_receipts r + LEFT JOIN badge_purchases p ON p.badge_store_receipt_id = r.badge_store_receipt_id + WHERE r.provider = ? AND r.transaction_ref = ? + +Plan: +SEARCH r USING INDEX sqlite_autoindex_badge_store_receipts_2 (provider=? AND transaction_ref=?) +SEARCH p USING COVERING INDEX idx_badge_purchases_store_receipt (badge_store_receipt_id=?) LEFT-JOIN + Query: SELECT r.contact_id, g.group_id, r.group_member_id FROM received_probes r @@ -4461,15 +4504,26 @@ SEARCH ug USING AUTOMATIC COVERING INDEX (group_id=?) Query: DELETE FROM badge_code_redemptions - WHERE badge_code_redemption_id = ? - AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?) + WHERE user_id = ? AND code = ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_code_redemption_id = badge_code_redemptions.badge_code_redemption_id) Plan: -SEARCH badge_code_redemptions USING INTEGER PRIMARY KEY (rowid=?) -SCALAR SUBQUERY 1 -SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) +SEARCH badge_code_redemptions USING INDEX sqlite_autoindex_badge_code_redemptions_1 (user_id=? AND code=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH p USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) +Query: + DELETE FROM badge_store_receipts + WHERE payment IS NULL AND created_at < ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_store_receipt_id = badge_store_receipts.badge_store_receipt_id) + +Plan: +SCAN badge_store_receipts +CORRELATED SCALAR SUBQUERY 1 +SEARCH p USING COVERING INDEX idx_badge_purchases_store_receipt (badge_store_receipt_id=?) +SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_store_receipt (badge_store_receipt_id=?) + Query: DELETE FROM chat_items WHERE group_scope_group_member_id = ? @@ -4962,6 +5016,12 @@ Query: Plan: +Query: + INSERT INTO badge_store_receipts (user_id, invoice_id, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?) + +Plan: + Query: INSERT INTO calls (contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at) @@ -5048,8 +5108,8 @@ Query: Plan: Query: - INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?) + INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, chat_peer_type, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) @@ -5210,6 +5270,14 @@ Query: Plan: SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE badge_store_receipts + SET retry_delay = ?, next_attempt_at = ?, retry_count = retry_count + 1, credit_error = ? + WHERE badge_store_receipt_id = ? AND payment IS NOT NULL + +Plan: +SEARCH badge_store_receipts USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? @@ -5396,7 +5464,7 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, preferences_json = NULL, updated_at = ?, + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, preferences = NULL, preferences_json = NULL, updated_at = ?, badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? @@ -7239,6 +7307,7 @@ SEARCH connections USING COVERING INDEX idx_connections_user_contact_link_id (us Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH badge_store_receipts USING COVERING INDEX idx_badge_store_receipts_user (user_id=?) SEARCH badge_code_redemptions USING COVERING INDEX idx_badge_code_redemptions_user (user_id=?) SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_user (user_id=?) SEARCH chat_relays USING COVERING INDEX idx_chat_relays_user_id (user_id=?) @@ -7290,15 +7359,15 @@ Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, descr Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Plan: +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) + Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) -Plan: -SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) - -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) @@ -7396,6 +7465,14 @@ Query: SELECT 1 FROM settings WHERE user_id = ? LIMIT 1 Plan: SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) +Query: SELECT COUNT(*) FROM badge_store_receipts +Plan: +SCAN badge_store_receipts USING COVERING INDEX idx_badge_store_receipts_user + +Query: SELECT COUNT(*) FROM badge_store_receipts WHERE payment IS NOT NULL +Plan: +SCAN badge_store_receipts + Query: SELECT COUNT(*) FROM groups WHERE relay_own_status IS NOT NULL Plan: SCAN groups @@ -7503,6 +7580,14 @@ Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption Plan: SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) +Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_store_receipt_id = ? +Plan: +SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_store_receipt (badge_store_receipt_id=?) + +Query: SELECT badge_store_receipt_id FROM badge_store_receipts WHERE invoice_id = ? +Plan: +SEARCH badge_store_receipts USING COVERING INDEX sqlite_autoindex_badge_store_receipts_1 (invoice_id=?) + Query: SELECT balance_checked FROM badge_ledger ORDER BY entry_id Plan: SCAN badge_ledger @@ -7648,6 +7733,10 @@ Query: SELECT count(1) FROM pending_group_messages Plan: SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id +Query: SELECT credit_error FROM badge_store_receipts WHERE payment IS NOT NULL AND credit_error IS NOT NULL +Plan: +SCAN badge_store_receipts + Query: SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ? Plan: SEARCH badge_ledger USING INDEX idx_badge_ledger_uuid (entry_uuid=?) @@ -7842,6 +7931,11 @@ Query: SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMI Plan: SCAN delivery_jobs +Query: SELECT p.contact_link, p.chat_peer_type FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id WHERE m.local_display_name = ? +Plan: +SCAN m +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT purchase_key FROM badge_code_redemptions Plan: SCAN badge_code_redemptions @@ -7942,6 +8036,10 @@ Query: SELECT user_id FROM users WHERE local_display_name = ? Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) +Query: SELECT user_id, invoice_id, transaction_ref IS NOT NULL FROM badge_store_receipts ORDER BY badge_store_receipt_id +Plan: +SCAN badge_store_receipts + Query: SELECT via_contact_uri, pres_header FROM connections WHERE connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -7966,6 +8064,26 @@ Query: UPDATE badge_purchases SET next_wake_at = ? WHERE badge_purchase_id = ? Plan: SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE badge_store_receipts SET payment = ? +Plan: +SCAN badge_store_receipts + +Query: UPDATE badge_store_receipts SET payment = ? WHERE badge_store_receipt_id = ? AND payment IS NOT NULL +Plan: +SEARCH badge_store_receipts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE badge_store_receipts SET payment = NULL, next_attempt_at = NULL, retry_delay = NULL, retry_count = 0 WHERE badge_store_receipt_id = ? +Plan: +SEARCH badge_store_receipts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE badge_store_receipts SET payment = NULL, next_attempt_at = NULL, retry_delay = NULL, retry_count = 0, credit_error = ? WHERE badge_store_receipt_id = ? +Plan: +SEARCH badge_store_receipts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, payment = ?, next_attempt_at = ? WHERE invoice_id = ? AND transaction_ref IS NULL +Plan: +SEARCH badge_store_receipts USING INDEX sqlite_autoindex_badge_store_receipts_1 (invoice_id=?) + Query: UPDATE chat_items SET item_content = ?, item_text = ?, updated_at = ? WHERE chat_item_id = ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 71abf5d37f..d3b3a095d4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -942,6 +942,7 @@ CREATE TABLE badge_purchases( issue_error_at TEXT, issue_error TEXT, next_wake_at TEXT, + badge_store_receipt_id INTEGER REFERENCES badge_store_receipts, UNIQUE(purchase_key) ) STRICT; CREATE TABLE badge_ledger( @@ -996,6 +997,23 @@ CREATE TABLE group_member_badge_proofs( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) STRICT; +CREATE TABLE badge_store_receipts( + badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + invoice_id TEXT UNIQUE, + provider TEXT, + transaction_ref TEXT, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + created_at TEXT NOT NULL, + payment TEXT, + next_attempt_at TEXT, + retry_delay INTEGER, + retry_count INTEGER NOT NULL DEFAULT 0, + credit_error TEXT, + UNIQUE(provider, transaction_ref) +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1562,6 +1580,10 @@ CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members( invited_via_group_member_id ); +CREATE INDEX idx_badge_store_receipts_user ON badge_store_receipts(user_id); +CREATE UNIQUE INDEX idx_badge_purchases_store_receipt ON badge_purchases( + badge_store_receipt_id +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 75a49090ed..bf41d2de6e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -323,14 +323,14 @@ createConnection_ db userId connType entityId acId connStatus connChatVersion pe ent ct = if connType == ct then entityId else Nothing createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 -createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, shortDescr, description, image} = do +createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, shortDescr, description, image, peerType} = do DB.execute db [sql| - INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?) + INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, chat_peer_type, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (displayName, fullName, shortDescr, description, image, userId, Just (BI True), createdAt, createdAt) + (displayName, fullName, shortDescr, description, image, peerType, userId, Just (BI True), createdAt, createdAt) insertedRowId db updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> PQEncryption -> IO () diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index 2dd1faa391..e1925ce835 100644 --- a/src/Simplex/Chat/Styled.hs +++ b/src/Simplex/Chat/Styled.hs @@ -70,7 +70,7 @@ sShow = plain . show sgr :: Format -> [SGR] sgr = \case - Bold -> [SetConsoleIntensity BoldIntensity] + Bold {} -> [SetConsoleIntensity BoldIntensity] Italic -> [SetUnderlining SingleUnderline, SetItalicized True] StrikeThrough -> [SetSwapForegroundBackground True] Colored (FormatColor c) -> [SetColor Foreground Vivid c] diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 81ed6ac5a8..94fd5d4fcd 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -749,7 +749,7 @@ data Profile = Profile } deriving (Eq, Show) -data ChatPeerType = CPTHuman | CPTBot | CPTBusiness | CPTUnknown Text +data ChatPeerType = CPTHuman | CPTBot | CPTBusiness | CPTAgent | CPTUnknown Text deriving (Eq, Show) instance FromJSON ChatPeerType where @@ -768,11 +768,13 @@ instance TextEncoding ChatPeerType where "human" -> CPTHuman "bot" -> CPTBot "business" -> CPTBusiness + "agent" -> CPTAgent tag -> CPTUnknown tag textEncode = \case CPTHuman -> "human" CPTBot -> "bot" CPTBusiness -> "business" + CPTAgent -> "agent" CPTUnknown tag -> tag profileFromName :: ContactName -> Profile diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 03b116202b..650209eadd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -46,7 +46,7 @@ import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag) import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..)) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..), OpenStorePurchase (..)) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -193,7 +193,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] - CRBadgeState u st -> ttyUser u $ viewUserBadgeState st + CRBadgeInvoice u invoiceId -> ttyUser u ["badge invoice: " <> plain invoiceId] + CRBadgeState u st storePurchases -> ttyUser u $ viewUserBadgeState st <> map viewOpenStorePurchase storePurchases CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView @@ -485,6 +486,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)] CEvtBadgeChanged u st -> ttyUser u $ viewUserBadgeState st CEvtBadgeAlert u alert -> ttyUser u $ viewBadgeAlert alert + CEvtStorePurchaseCredited u invoiceId -> ttyUser u ["store purchase credited: invoice " <> plain invoiceId] + CEvtStorePurchaseRefused u invoiceId refusal -> ttyUser u ["store purchase refused: invoice " <> plain invoiceId <> ", " <> plain (safeDecodeUtf8 $ strEncode refusal)] CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"] CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci @@ -1864,6 +1867,14 @@ viewBadgeIssueError BadgeIssueError {failedSince, lastAttemptAt, reason} = viewBadgeAlert :: BadgeAlert -> [StyledString] viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date] +viewOpenStorePurchase :: OpenStorePurchase -> StyledString +viewOpenStorePurchase OpenStorePurchase {invoiceId, transactionRef, creditError} = + plain $ + "store purchase open: invoice " + <> fromMaybe "none" invoiceId + <> maybe "" (", transaction " <>) transactionRef + <> maybe "" ((", not credited: " <>) . safeDecodeUtf8 . strEncode) creditError + viewBadgeLedger :: [StatementEntry] -> [StyledString] viewBadgeLedger [] = ["no ledger entries"] viewBadgeLedger entries = map viewEntry entries @@ -2158,7 +2169,7 @@ viewGroupDescription GroupInfo {groupProfile = GroupProfile {description}} = maybe ["No welcome message!"] ((bold' "Welcome message:" :) . map plain . T.lines) description bold' :: String -> StyledString -bold' = styled Bold +bold' = styled (Bold $ BoolDef False) viewContactAliasUpdated :: Contact -> [StyledString] viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}} @@ -2869,13 +2880,14 @@ viewChatError isCmd logLevel testView = \case CEBadgeRedeemError e -> let reason = case e of BREInvalidCode -> "invalid code" + BREInvalidReceipt -> "invalid store receipt" BREServiceNotConfigured -> "badge service not configured" BREBadgeActive -> "badge already active" BREServiceError code -> "badge service error: " <> T.unpack (badgeServiceErrorText code) BREInvalidResponse m -> "invalid service response: " <> m BREUnknownKeyIndex -> "credential names an unknown badge key index" BRECredentialNotVerified -> "credential does not verify against configured key" - in ["cannot redeem badge code: " <> plain reason] + in ["cannot get badge: " <> plain reason] CEAgentCommandError e -> ["agent command error: " <> plain e] CEInvalidFileDescription e -> ["invalid file description: " <> plain e] CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 80f7432da2..db184f5a8a 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -31,6 +31,7 @@ import Control.Monad import Control.Monad.Except (runExceptT) import Data.Either (rights) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B @@ -44,7 +45,7 @@ import qualified Data.Text as T import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime, getCurrentTime) import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), storeCxt) -import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) +import Simplex.Chat.Markdown (MarkdownBlock, MarkdownList, ParsedMarkdown (..), parsedMarkdown) import Simplex.Chat.Messages ( CChatItem (..), CIDirection (..), @@ -101,6 +102,7 @@ data WebMessage = WebMessage ts :: UTCTime, content :: MsgContent, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), file :: Maybe WebFileInfo, quote :: Maybe QuotedMsg, reactions :: [CIReactionCount], @@ -112,7 +114,9 @@ data WebMessage = WebMessage data WebChannelPreview = WebChannelPreview { channel :: GroupProfile, shortDescription :: Maybe MarkdownList, + shortDescriptionBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), members :: [WebMemberProfile], subscribers :: Maybe Int64, messages :: [WebMessage], @@ -247,10 +251,14 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI let rendered = mapMaybe toRenderedItem $ rights items msgs = map fst rendered senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered + ParsedMarkdown shortDescription shortDescriptionBlocks = maybe (ParsedMarkdown Nothing Nothing) parsedMarkdown sd + ParsedMarkdown welcomeMessage welcomeMessageBlocks = maybe (ParsedMarkdown Nothing Nothing) parsedMarkdown wd preview = WebChannelPreview { channel = gp, - shortDescription = toFormattedText =<< sd, - welcomeMessage = toFormattedText =<< wd, + shortDescription, + shortDescriptionBlocks, + welcomeMessage, + welcomeMessageBlocks, members = senders, subscribers, messages = msgs, @@ -307,7 +315,7 @@ channelRemoved cc gId = void $ tryPutTMVar wakeSignal () toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile]) -toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file}) +toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, markdownBlocks, quotedItem, reactions, file}) | isJust itemTimed = Nothing | otherwise = case ciMsgContent content of Just mc | not (isReport mc) -> @@ -323,6 +331,7 @@ toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, ts = itemTs, content = mc, formattedText, + markdownBlocks, file = webFileInfo <$> file, quote = quotedItem >>= ciQuoteToQuotedMsg, reactions, @@ -423,13 +432,6 @@ removeStaleFiles dir activeFiles = do removeFile (dir f) `catchOwn'` \(e :: SomeException) -> logError $ "web preview: error removing stale file " <> T.pack f <> ": " <> tshow e -toFormattedText :: Text -> Maybe MarkdownList -toFormattedText t = case parseMaybeMarkdownList t of - Just fts | any hasFormat fts -> Just fts - _ -> Nothing - where - hasFormat (FormattedText fmt _) = isJust fmt - publicGroupIdFileName :: B64UrlByteString -> String publicGroupIdFileName = B.unpack . strEncode diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index 360c97edf0..45899ac26f 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -9,19 +9,26 @@ module BadgeTests (badgeTests) where -import BadgeService.Service (badgeErrorRetryAfter) +import BadgeService.Service (badgeErrorRetryAfter, shownServiceRequest, survive) +import BadgeService.StoreReceipts (StoreReceipt (..), StoreRefusal (..), StoreVerifier (..), VerifiedStoreTransaction (..), toStoreReceipt) +import BadgeService.StoreReceipts.Mock (mockStoreVerifier) +import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) import Control.Concurrent.STM (atomically) +import Control.Exception (SomeAsyncException, SomeException, catch, fromException, throwIO) import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Base64.URL as B64U import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Data.Time.Calendar (fromGregorian) import Data.Time.Calendar.WeekDate (toWeekDate) import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, nominalDay) import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import qualified Data.Aeson as J import qualified Data.Aeson.KeyMap as KM -import Data.Maybe (fromMaybe, isNothing, maybeToList) +import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges import Simplex.Chat.Badges.Code @@ -29,14 +36,16 @@ import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service import Simplex.Chat.Badges.Types (BadgeIssueFailure (..)) import Simplex.Chat (defaultChatConfig) -import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent) -import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeFailureTransient, badgeIssueFailure, badgeRetryAfter, badgeServiceErrorText, badgeStalledInterval) +import Simplex.Chat.Controller (BadgeRedeemError (..), ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent) +import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeFailureTransient, badgeIssueFailure, badgeRetryAfter, badgeServiceErrorText, badgeStalledInterval, storeTransactionRef) +import Simplex.Chat.PaymentService (ServicePayment (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId (..), PaymentProvider (..), StoreTransactionRef (..)) import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay) import Simplex.Messaging.Crypto.BBS import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BrokerErrorType (..), ErrorType (AUTH), NetworkError (..)) -import Simplex.Messaging.Util (tshow) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import Simplex.Messaging.Version.Internal (Version (..)) import Test.Hspec @@ -94,9 +103,18 @@ badgeTests = do it "bounds and strips a code this version does not know" testServiceErrorCodeBounded describe "service protocol JSON" $ do it "redeemBadgeCode request matches the schema" testRedeemRequestJSON + it "purchaseBadge request matches the schema" testPurchaseRequestJSON it "badgeCredential response matches the schema" testCredentialResponseJSON it "error response matches the schema" testErrorResponseJSON it "statement entries round-trip, unknown entry types verbatim" testStatementJSON + describe "store purchases" $ do + it "keys a purchase by the store's transaction id, not by the evidence signed over it" testStoreTransactionRef + it "shows a service request in the terminal as its type alone" testShownServiceRequest + it "refuses for good a Play product id that could name another purchase, before any verifier" testGoogleProductIdPath + it "does not send a Play token that could name another purchase, and leaves it retryable" testGoogleTokenPath + it "has the dev mock vouch for the transaction its claim names, as a production purchase" testMockVouchesForClaim + describe "badge service request loop" $ do + it "survives a request that throws, and still stops when cancelled" testSurviveRequestFailure proofOf :: BadgeProof -> BBSProof proofOf (BadgeProof _ _ p _) = p @@ -724,7 +742,7 @@ testServiceRetryAfter = do badgeErrorRetryAfter BSEInternal `shouldBe` Nothing mapM_ (\code -> badgeErrorRetryAfter code `shouldBe` Nothing) - [BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEUnknown "future_code"] + [BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEProviderNotConfigured, BSETestPurchase, BSEUnknown "future_code"] -- The app shows the recorded failure in a sentence, so an agent error is stored as the agent -- error and not as the chat error wrapping it, whether or not it can clear on its own. @@ -737,6 +755,8 @@ testIssueFailureClassification = do failureFor timeout `shouldBe` BIFNetwork {agentError = tshow timeout} failureFor auth `shouldBe` BIFUnexpected {message = tshow auth} badgeIssueFailure (ChatError (CECommandError "unexpected badge service response")) `shouldBe` BIFUnexpected {message = "unexpected badge service response"} + badgeIssueFailure (ChatError (CEBadgeRedeemError BREUnknownKeyIndex)) `shouldBe` BIFInvalidCredential + badgeIssueFailure (ChatError (CEBadgeRedeemError BRECredentialNotVerified)) `shouldBe` BIFInvalidCredential -- A refusal the service marks transient is not worth a word before the credential lapses. Internal -- comes without retryAfter so that a failing service is not pressed, not because the fault is @@ -795,12 +815,18 @@ testEntryTypeColumns = do debits = [SDRefund, SDUpgrade k, SDTransferOut k, SDSupport, SDBadge, SDLapse] mapM_ (\c -> wireTag (J.toJSON (SECredit c)) "credit" `shouldBe` Just (creditTypeTag c)) credits mapM_ (\d -> wireTag (J.toJSON (SEDebit d)) "debit" `shouldBe` Just (debitTypeTag d)) debits - -- the three types this version writes survive a round trip through the columns + -- the types this version writes survive a round trip through the columns, a payment credit + -- through the payment its row references mapM_ - (\t -> uncurry3 entryTypeFromColumns (entryTypeColumns t) `shouldSatisfy` sameEntryType t) + (\t -> uncurry3 (entryTypeFromColumns Nothing) (entryTypeColumns t) `shouldSatisfy` sameEntryType t) [SECredit SCCode, SEDebit SDBadge, SEDebit SDLapse] + let storeCredit = SECredit (SCPayment Nothing) + invoiceCredit = SECredit (SCPayment (Just (InvoiceId "inv1"))) + uncurry3 (entryTypeFromColumns (Just Nothing)) (entryTypeColumns storeCredit) `shouldSatisfy` sameEntryType storeCredit + uncurry3 (entryTypeFromColumns (Just (Just (InvoiceId "inv1")))) (entryTypeColumns invoiceCredit) `shouldSatisfy` sameEntryType invoiceCredit -- a type that needs a reference column is not silently read back as something else - uncurry3 entryTypeFromColumns (entryTypeColumns (SECredit (SCCharge "ch1"))) `shouldSatisfy` isNothing + uncurry3 (entryTypeFromColumns Nothing) (entryTypeColumns storeCredit) `shouldSatisfy` isNothing + uncurry3 (entryTypeFromColumns Nothing) (entryTypeColumns (SECredit (SCCharge "ch1"))) `shouldSatisfy` isNothing -- which is why every type is stored as its own JSON as well, and read from that first: the -- columns alone would answer a row naming an invoice or a purchase as no row at all mapM_ roundTrips credits @@ -832,6 +858,106 @@ testRedeemRequestJSON = do `shouldBe` J.object ["version" J..= (1 :: Int), "request" J..= J.object ["type" J..= ("getBadgeCatalog" :: T.Text)]] roundTrips req +testPurchaseRequestJSON :: IO () +testPurchaseRequestJSON = do + drg <- C.newRandom + mk <- generateMasterKey drg + (k, _) <- atomically $ C.generateKeyPair drg :: IO (C.KeyPair 'C.Ed25519) + let payment = SPGoogle {productId = "badge_supporter_01", token = "token"} + req = BadgeServiceRequest {version = Version 1, purchaseKey = Just k, request = BSCPurchaseBadge {masterKey = mk, payment, upgrade = Nothing}} + -- the client states its master key and the store's evidence, and nothing a receipt already decides + J.toJSON req + `shouldBe` J.object + [ "version" J..= (1 :: Int), + "purchaseKey" J..= k, + "request" + J..= J.object + [ "type" J..= ("purchaseBadge" :: T.Text), + "masterKey" J..= mk, + "payment" J..= J.object ["type" J..= ("google" :: T.Text), "productId" J..= ("badge_supporter_01" :: T.Text), "token" J..= ("token" :: T.Text)] + ] + ] + J.toJSON SPApple {jws = "a.b.c"} `shouldBe` J.object ["type" J..= ("apple" :: T.Text), "jws" J..= ("a.b.c" :: T.Text)] + roundTrips req + +testStoreTransactionRef :: IO () +testStoreTransactionRef = do + let jws transactionId signature = T.intercalate "." [part "{\"alg\":\"ES256\"}", part ("{\"transactionId\":\"" <> transactionId <> "\",\"productId\":\"BADGE_SUPPORTER_01\"}"), signature] + part = safeDecodeUtf8 . B64U.encodeUnpadded . encodeUtf8 + apple = storeTransactionRef . SPApple + -- the store may sign the same transaction again, and a retry must find the keys it stashed + apple (jws "2000000812345671" "c2lnbmVkIG9uY2U") `shouldBe` Just (StoreTransactionRef PPApple "2000000812345671") + apple (jws "2000000812345671" "c2lnbmVkIGFnYWlu") `shouldBe` apple (jws "2000000812345671" "c2lnbmVkIG9uY2U") + apple (jws "2000000812345672" "c2lnbmVkIG9uY2U") `shouldNotBe` apple (jws "2000000812345671" "c2lnbmVkIG9uY2U") + apple "not.a-jws" `shouldBe` Nothing + apple (T.intercalate "." [part "{}", part "{\"productId\":\"BADGE_SUPPORTER_01\"}", "sig"]) `shouldBe` Nothing + -- a Play token is a bearer secret, so it is kept only as its hash + let google = storeTransactionRef SPGoogle {productId = "badge_supporter_01", token = "play-token"} + google `shouldSatisfy` maybe False (\(StoreTransactionRef provider ref) -> provider == PPGoogle && ref /= "play-token") + google `shouldBe` storeTransactionRef SPGoogle {productId = "badge_supporter_01", token = "play-token"} + storeTransactionRef SPInvoice {invoiceId = InvoiceId "inv"} `shouldBe` Nothing + +testShownServiceRequest :: IO () +testShownServiceRequest = do + drg <- C.newRandom + mk <- generateMasterKey drg + (k, _) <- atomically $ C.generateKeyPair drg :: IO (C.KeyPair 'C.Ed25519) + let shown request = case J.toJSON BadgeServiceRequest {version = Version 1, purchaseKey = Just k, request} of + J.Object o -> J.Object (shownServiceRequest o) + _ -> J.Null + typeOnly t = J.object ["request" J..= J.object ["type" J..= (t :: T.Text)]] + shown BSCPurchaseBadge {masterKey = mk, payment = SPApple {jws = "a.b.c"}, upgrade = Nothing} `shouldBe` typeOnly "purchaseBadge" + shown BSCRedeemBadgeCode {masterKey = mk, code = "SB-00000-00000-00000-00001"} `shouldBe` typeOnly "redeemBadgeCode" + +testGoogleProductIdPath :: IO () +testGoogleProductIdPath = do + let refused productId = case toStoreReceipt uncalledVerifier SPGoogle {productId, token = validPlayToken} of + Just (Left SRInvalid {}) -> True + _ -> False + mapM_ (\p -> refused p `shouldBe` True) ["badge_legend_01/tokens/other?", "badge_legend_01?x", "badge_legend_01#x", "..", "../badge_legend_01", "Badge_legend_01", ""] + case toStoreReceipt uncalledVerifier SPGoogle {productId = "badge_supporter_01", token = validPlayToken} of + Just (Right StoreReceipt {txRef = StoreTransactionRef {provider}}) -> provider `shouldBe` PPGoogle + _ -> expectationFailure "a valid product id and token were refused" + +testGoogleTokenPath :: IO () +testGoogleTokenPath = do + let unsent token = case toStoreReceipt uncalledVerifier SPGoogle {productId = "badge_supporter_01", token} of + Just (Left SRUnreachable {}) -> True + _ -> False + mapM_ (\t -> unsent t `shouldBe` True) ["a/b", "../x", "t?x", "t#x", "t x", ""] + +uncalledVerifier :: StoreVerifier +uncalledVerifier = StoreVerifier {verifyApple = Nothing, verifyGoogle = Just $ \_ _ -> error "verifier called", acknowledgeGoogle = Nothing, verifyTimeout = 500000} + +validPlayToken :: T.Text +validPlayToken = "fake-play-token.AO-J1Oz9x2kqE7wYt3" + +testMockVouchesForClaim :: IO () +testMockVouchesForClaim = do + let part = safeDecodeUtf8 . B64U.encodeUnpadded . encodeUtf8 + signed = T.intercalate "." [part "{\"alg\":\"ES256\"}", part "{\"transactionId\":\"2000000812345671\",\"productId\":\"BADGE_SUPPORTER_01\"}", "c2lnbmVk"] + vouchesForClaim payment = case toStoreReceipt mockStoreVerifier payment of + Just (Right StoreReceipt {txRef = StoreTransactionRef {transactionRef = providerRef}, verifyReceipt}) -> + verifyReceipt >>= \case + Right VerifiedStoreTransaction {transactionRef, testPurchase} -> (transactionRef, testPurchase) `shouldBe` (providerRef, False) + Left refusal -> expectationFailure ("the mock refused: " <> show refusal) + _ -> expectationFailure "refused before the mock was asked" + vouchesForClaim SPApple {jws = signed} + vouchesForClaim SPGoogle {productId = "badge_supporter_01", token = "fake-play-token.AO-J1Oz9x2kqE7wYt3"} + +testSurviveRequestFailure :: IO () +testSurviveRequestFailure = do + survive "a request" (throwIO $ userError "failed") `shouldReturn` () + started <- newEmptyMVar + stopped <- newEmptyMVar + t <- forkIO $ survive "a request" (putMVar started () >> threadDelay 10000000) `catch` (putMVar stopped . asyncException) + takeMVar started + killThread t + takeMVar stopped `shouldReturn` True + where + asyncException :: SomeException -> Bool + asyncException e = isJust (fromException e :: Maybe SomeAsyncException) + testCredentialResponseJSON :: IO () testCredentialResponseJSON = do Right (_, sk) <- bbsKeyGen diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 129824fed8..dcc53d30b2 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PostfixOperators #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -14,27 +15,31 @@ import BadgeService.Codes (issueOneCode) import BadgeService.Config (BadgeIssuerKey (..), readServiceConfig) import BadgeService.Group (GroupEvent) import Bots.BadgeService.ConfigTests (withIssuer) +import Bots.BadgeService.FakeStore import BadgeService.Options import BadgeService.Service import BadgeService.Store (IssuedCode (..), getBadgeCode) import BadgeService.Store.Invoices (markCodePaid) +import BadgeService.StoreReceipts (StoreRefusal (..), StoreVerifier (..), noStoreVerifier) import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import ChatClient import ChatTests.DBUtils import ChatTests.Utils -import Control.Concurrent (threadDelay) +import Control.Concurrent (newEmptyMVar, putMVar, takeMVar, threadDelay) import Control.Concurrent.Async (async, cancel) -import Control.Concurrent.STM (TQueue, atomically, readTMVar) +import Control.Concurrent.STM (TQueue, atomically, readTMVar, readTQueue) import Control.Monad (forM_, void, when) import Control.Exception (finally) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (toLower) import Data.Either (isLeft, isRight) import Data.Int (Int64) -import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef) +import Data.List (isPrefixOf, stripPrefix) import qualified Data.Map.Strict as M import Data.Maybe (isJust, isNothing) import Data.String (fromString) @@ -43,9 +48,10 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay) import Data.Time.Format (defaultTimeLocale, formatTime) +import Data.Word (Word32) import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType (..), generateMasterKey) import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeHash, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode) -import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, endOfMondayAfter) +import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, emptyEntry, endOfMondayAfter) import Simplex.Chat.Badges.Service import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) import Simplex.Chat.Bot.Store (withDB') @@ -53,6 +59,9 @@ import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatError import Simplex.Chat.Core (sendChatCmdStr) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Chat.PaymentService (ServicePayment (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId (..)) +import Simplex.Chat.Store.Badges (getNextHeldStoreReceipt) import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) @@ -119,6 +128,55 @@ badgeServiceTests = do it "should renew a badge on a profile that is not active, without switching to it" testRenewalKeepsActiveProfile it "should broadcast the current profile when a renewal presents a badge" testRenewalKeepsProfileEdits it "should present the month already issued when a previous pass did not" testPresentationCatchesUp + describe "store purchases" $ do + describe "what the store answers" $ do + it "should credit the months a store receipt paid for and issue the first" testStorePurchase + it "should refuse a receipt another key was credited with" testStoreReceiptUsed + it "should refuse a receipt the store does not vouch for, with no retry" testStoreReceiptInvalid + it "should write nothing for a pending purchase, and credit it once when it settles" testStorePending + it "should acknowledge a Play purchase before crediting it, and credit nothing when acknowledging fails" testStoreAcknowledgesBeforeCrediting + it "should refuse a product that grants no badge" testStoreUnknownProduct + it "should refuse a store purchase that carries an upgrade" testStoreUpgradeRefused + it "should grant the badge of the product the receipt proves" testStoreBadgeTypeFromProduct + it "should keep refusing invoice and receipt funding" testNonStoreFundingRefused + it "should replay a receipt to its own key while the store is down, and to no other" testStoreReplayWhileStoreDown + it "should answer a throwing Apple verifier as internal, and a failing or hanging Google one as retryable" testStoreVerifierFailures + describe "claim races" $ do + it "should answer a request that lost the claim race with the credential the winner was given" testStorePurchaseRace + it "should refuse as receipt_used a key that lost the claim race to another key, crediting nothing" testStorePurchaseRaceOtherKey + describe "the service's own checks" $ do + it "should refuse a store with no verifier with no retry, and the client should hold the receipt, showing why it is not credited" testPurchaseWithNoVerifier + it "should credit nothing when the verified transaction is not the one the evidence names" testStoreVerifiedOtherTransaction + it "should refuse a verified quantity other than one as internal, writing nothing" testStoreQuantityRefused + it "should refuse a store purchase whose purchaseKey is not the verified signer" testStorePurchaseKeyMismatch + it "should refuse a Play product id it will not send to Play, without asking Play" testStoreUnsentProductId + describe "the hand-over" $ do + it "should hold a Play receipt until the worker credits it, and answer it as credited to its owner under any profile" testPurchaseBadge + it "should refuse an App Store receipt that names no transaction, recording nothing" testPurchaseNamesNoTransaction + it "should keep a receipt credited to another key as refused" testPurchaseStashReceiptUsed + it "should hold a Play token the service will not send to Play" testPurchaseUnsentPlayToken + it "should credit a purchase first handed over under another profile to that profile" testPurchaseStrandedUnderOtherProfile + it "should credit a purchase to a hidden profile without naming it" testPurchaseDeliveredToHiddenProfile + describe "invoice first" $ do + it "should credit a receipt to the profile that created its invoice however late it arrives, and answer as that profile" testInvoiceOtherProfile + it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown + it "should record a receipt echoing an invoice another record holds by its transaction alone, and credit it" testInvoiceHeldByAnotherRecord + it "should list a record however old until a receipt reaching it is settled" testInvoiceAgedOut + it "should refuse an invoice with no service configured or while a badge is held, creating no record" testInvoiceRefusedBeforeCharge + it "should hold a receipt for an invoice while a badge is held, without attempting it" testInvoiceWhileBadgeHeld + it "should list only the asking profile's open store purchases" testInvoiceStateOtherProfile + describe "the receipt worker" $ do + it "should retry a receipt the service could not verify, and credit it with no trigger at all" testStoreReceiptRetried + it "should credit a second receipt while an earlier one waits for the store" testStoreReceiptNotBlocked + it "should answer a refused receipt from the record, sending nothing, and not list it" testStoreRefusalKept + it "should refuse a test purchase for good, and answer it from the record" testStoreTestPurchaseRefused + it "should announce a settlement to an owner that is not active, and print nothing for a hidden one" testStoreSettledOtherProfile + it "should resolve one new transaction handed over twice at once to one record" testStoreReceiptTwoAtOnce + it "should delete a month-old record that will fund no badge, and keep held and credited ones" testStoreReceiptCleanup + it "should keep a receipt whose attempt throws" testStoreReceiptAttemptThrows + it "should credit a held receipt after the app restarts" testStoreReceiptAfterRestart + describe "the Play lane" $ do + it "should answer other requests while a Play receipt is with the store, and credit it once Play answers" testPlayHoldsNoOtherRequest badgeBotName :: Text badgeBotName = "SimpleX Badges" @@ -175,7 +233,9 @@ data BadgeServiceEnv = BadgeServiceEnv bsClock :: TestClock, bsClientCfg :: ChatConfig, bsAddress :: String, - bsController :: ChatController + bsController :: ChatController, + bsStore :: FakeStore, + bsVerifier :: StoreVerifier } -- | Stop the service for good: requests sent after it go unanswered until they time out. Stopping @@ -188,14 +248,19 @@ withBadgeService ps test = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsAddress, bsController} -> test bsClientCfg bsAddress bsController withBadgeServiceEnv :: HasCallStack => TestParams -> (BadgeServiceEnv -> IO ()) -> IO () -withBadgeServiceEnv ps test = do +withBadgeServiceEnv ps = withBadgeServiceVerifier ps fakeVerifier + +withBadgeServiceVerifier :: HasCallStack => TestParams -> (FakeStore -> StoreVerifier) -> (BadgeServiceEnv -> IO ()) -> IO () +withBadgeServiceVerifier ps verifierOf test = do Right (pk, sk) <- bbsKeyGen clock <- newTestClock + store <- newFakeStore let opts = mkBadgeServiceOpts ps sk svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk, badgeCurrentTime = testClockTime clock} + verifier = verifierOf store withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () -- First start: badge service takes the CreateMyAddress branch. - runBadgeService ps svcCfg opts $ \_ -> pure () + runBadgeService ps verifier svcCfg opts $ \_ -> pure () -- Reopen the DB to read the link the service created. bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do bs <## "subscribed 1 connections on server localhost" @@ -208,9 +273,9 @@ withBadgeServiceEnv ps test = do let clientCfg = svcCfg {badgeServiceAddress = Just $ either (error . ("bad badge service address: " <>)) id $ strDecode (B.pack bsLink)} -- Second start: badge service takes the ShowMyAddress branch, then serves the test body. - runBadgeService ps svcCfg opts $ \env -> do + runBadgeService ps verifier svcCfg opts $ \env -> do cc <- atomically $ readTMVar $ serviceCC env - test BadgeServiceEnv {bsIssuerKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}, bsClock = clock, bsClientCfg = clientCfg, bsAddress = bsLink, bsController = cc} + test BadgeServiceEnv {bsIssuerKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}, bsClock = clock, bsClientCfg = clientCfg, bsAddress = bsLink, bsController = cc, bsStore = store, bsVerifier = verifier} issueCode :: HasCallStack => ChatController -> BadgeType -> Int -> IO BadgeCode issueCode cc badgeType months = issueCodeAs cc badgeType months "free" @@ -237,9 +302,9 @@ issueCodeAs cc badgeType months status = -- | The post-start hook fills serviceCC once the address exists, so waiting on it is the service -- being ready. A fixed delay here raced with startup and left the address output of one start -- arriving during the next test. -runBadgeService :: TestParams -> ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () -runBadgeService ps cfg opts action = do - env <- newServiceState +runBadgeService :: TestParams -> StoreVerifier -> ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () +runBadgeService ps verifier cfg opts action = do + env <- (\s -> s {storeVerifier = verifier}) <$> newServiceState t <- async $ badgeService opts (fst $ testPortsCfg ps cfg testOpts) env ready <- timeout 30000000 $ atomically $ readTMVar $ serviceCC env when (isNothing ready) $ cancel t >> error "badge service did not start" @@ -305,10 +370,10 @@ testRedeemUnknownCode ps = g <- C.newRandom unknown <- randomBadgeCode g alice ##> ("/_redeem_badge_code 1 " <> codeArg unknown) - alice <## "cannot redeem badge code: badge service error: code_invalid" + alice <## "cannot get badge: badge service error: code_invalid" -- a failed check character is refused before anything leaves the device alice ##> "/_redeem_badge_code 1 SB-00000-00000-00000-00001" - alice <## "cannot redeem badge code: invalid code" + alice <## "cannot get badge: invalid code" -- sent straight to the service, past the client's own check, the two are one answer (_, redeemPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) redeemDirect alice bsLink redeemPriv (T.unpack $ badgeCodeText unknown) @@ -361,7 +426,7 @@ testRedeemSecondCode ps = alice <## "supporter badge - active" alice <##. "expires " alice ##> ("/_redeem_badge_code 1 " <> codeArg legend) - alice <## "cannot redeem badge code: badge already active" + alice <## "cannot get badge: badge already active" alice ##> "/p" showActiveUser alice "alice (Alice, * supporter)" alice ##> "/create user alisa" @@ -383,18 +448,19 @@ testRedeemSameCodeOtherProfile ps = alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> ("/_redeem_badge_code 2 " <> codeArg code) - alice <## "cannot redeem badge code: badge service error: code_used" + alice <## "cannot get badge: badge service error: code_used" alice ##> "/p" showActiveUser alice "alisa" alice ##> "/user alice" showActiveUser alice "alice (Alice, * supporter)" serviceCmd :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse -serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} = serviceCmdWith bsIssuerKey bsController Nothing +serviceCmd BadgeServiceEnv {bsIssuerKey, bsController, bsVerifier} purchaseKey request = + badgeServiceResponse bsIssuerKey bsVerifier bsController Nothing (Just purchaseKey) (requestObject purchaseKey request) serviceCmdWith :: HasCallStack => BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse serviceCmdWith key cc trackerQ_ purchaseKey request = - badgeServiceResponse key cc trackerQ_ (Just purchaseKey) (requestObject purchaseKey request) + badgeServiceResponse key noStoreVerifier cc trackerQ_ (Just purchaseKey) (requestObject purchaseKey request) requestObject :: HasCallStack => C.PublicKeyEd25519 -> BadgeServiceCommand -> J.Object requestObject purchaseKey request = case J.toJSON BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} of @@ -1498,7 +1564,7 @@ testRedeemUnpaidCode ps = withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do unpaid <- issueCodeAs cc BTSupporter 1 "unpaid" alice ##> ("/_redeem_badge_code 1 " <> codeArg unpaid) - alice <## "cannot redeem badge code: badge service error: payment_pending" + alice <## "cannot get badge: badge service error: payment_pending" paid <- issueCodeAs cc BTSupporter 1 "paid" alice ##> ("/_redeem_badge_code 1 " <> codeArg paid) alice <## "badge redeemed" @@ -1515,7 +1581,7 @@ testExpiredCode ps = withDB' "markCodePaid" cc (\db -> markCodePaid db (badgeCodeHash code) (addUTCTime (-60) now)) `shouldReturn` Right () alice ##> ("/_redeem_badge_code 1 " <> codeArg code) - alice <## "cannot redeem badge code: badge service error: code_expired" + alice <## "cannot get badge: badge service error: code_expired" testRedeemedBeforeTheDeadline :: HasCallStack => TestParams -> IO () testRedeemedBeforeTheDeadline ps = @@ -1569,7 +1635,7 @@ testRevokedCode ps = paid <- issueCodeAs cc BTSupporter 1 "paid" revokeCodeAs cc paid `shouldReturn` Right "Revoked." alice ##> ("/_redeem_badge_code 1 " <> codeArg paid) - alice <## "cannot redeem badge code: badge service error: code_invalid" + alice <## "cannot get badge: badge service error: code_invalid" revokeCodeAs cc paid `shouldReturn` Right "Already revoked." testRevokedUnpaidCode :: HasCallStack => TestParams -> IO () @@ -1591,3 +1657,798 @@ testRevokeRejectsTrailingInput ps = -- The text is spelled out rather than taken from the service, so a change there fails here. badgeCmdUsage :: Text badgeCmdUsage = "Usage: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], or //revoke " + +purchaseCmd :: BadgeMasterKey -> ServicePayment -> BadgeServiceCommand +purchaseCmd masterKey payment = BSCPurchaseBadge {masterKey, payment, upgrade = Nothing} + +supporterPlay :: ServicePayment +supporterPlay = googlePayment "badge_supporter_01" googleSupporterToken + +paymentArg :: ServicePayment -> String +paymentArg = LB.unpack . J.encode + +refusalOf :: HasCallStack => BadgeServiceResponse -> (BadgeServiceErrorCode, Maybe Word32) +refusalOf = \case + BSPError {code, retryAfter} -> (code, retryAfter) + r -> error $ "expected an error, got " <> show (J.toJSON r) + +rowCount :: ChatController -> String -> IO Int +rowCount ChatController {chatStore} table = + withTransaction chatStore $ \db -> do + [Only n] <- DB.query_ db $ fromString $ "SELECT COUNT(*) FROM " <> table + pure n + +-- | Each store payment's provider, amount and currency, and the ledger rows that name it. +storePayments :: ChatController -> IO [(Text, Maybe Int, Maybe Text, Int)] +storePayments ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.query_ db . fromString $ + "SELECT pay.provider, pay.amount, pay.currency, COUNT(l.entry_id) FROM sx_badge_service_payments pay " + <> "LEFT JOIN sx_badge_service_badge_ledger l ON l.payment_id = pay.payment_id " + <> "GROUP BY pay.payment_id, pay.provider, pay.amount, pay.currency ORDER BY pay.provider" + +nothingPurchased :: HasCallStack => ChatController -> IO () +nothingPurchased cc = do + rowCount cc "sx_badge_service_payments" `shouldReturn` 0 + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 0 + +testStorePurchase :: HasCallStack => TestParams -> IO () +testStorePurchase ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + purchased <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + let (entries, previousEntryId) = statementOf purchased + previousEntryId `shouldBe` Nothing + map entryTag entries `shouldBe` ["payment", "badge"] + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries `shouldBe` [(1, 1), (-1, 0)] + badgeTypeOf purchased `shouldBe` Just BTSupporter + masterKeyOf purchased `shouldBe` Just masterKey + map (\(_, c, m, _, _, t) -> (c, m, t)) <$> ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` [(1, 1, Just "payment"), (-1, 0, Just "badge")] + -- Play reports no price, and only the credit names the payment + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + +testStoreReceiptUsed :: HasCallStack => TestParams -> IO () +testStoreReceiptUsed ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (firstKey, firstMasterKey) <- newPurchaseKeys + _ <- serviceCmd env firstKey $ purchaseCmd firstMasterKey supporterPlay + ledger <- ledgerRows cc "sx_badge_service_badge_ledger" + (otherKey, otherMasterKey) <- newPurchaseKeys + used <- serviceCmd env otherKey $ purchaseCmd otherMasterKey supporterPlay + refusalOf used `shouldBe` (BSEReceiptUsed, Nothing) + ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStoreReceiptInvalid :: HasCallStack => TestParams -> IO () +testStoreReceiptInvalid ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleSandboxJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + let refused payment = refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey payment) + refused (googlePayment "badge_supporter_01" "not-a-purchase") `shouldReturn` (BSEReceiptInvalid, Nothing) + refused SPApple {jws = "not.a.jws"} `shouldReturn` (BSEReceiptInvalid, Nothing) + refused SPApple {jws = unsignedJWS "{\"transactionId\":\"2000000812345699\",\"productId\":\"BADGE_SUPPORTER_01\"}"} `shouldReturn` (BSEReceiptInvalid, Nothing) + -- named rather than lumped in with a forgery, since the device that bought it already knows + refused SPApple {jws = appleSandboxJWS} `shouldReturn` (BSETestPurchase, Nothing) + nothingPurchased cc + +testStorePending :: HasCallStack => TestParams -> IO () +testStorePending ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = store} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + let unsettled = purchaseCmd masterKey $ googlePayment "badge_supporter_01" googlePendingToken + waiting <- serviceCmd env purchaseKey unsettled + refusalOf waiting `shouldSatisfy` \(code, retryAfter) -> code == BSEPaymentPending && isJust retryAfter + nothingPurchased cc + settlePending store + settled <- serviceCmd env purchaseKey unsettled + badgeTypeOf settled `shouldBe` Just BTSupporter + again <- serviceCmd env purchaseKey unsettled + credentialOf again `shouldBe` credentialOf settled + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + +testStoreAcknowledgesBeforeCrediting :: HasCallStack => TestParams -> IO () +testStoreAcknowledgesBeforeCrediting ps = do + acknowledged <- newIORef [] + acknowledgement <- newIORef $ Right () + let acknowledging store = (fakeVerifier store) {acknowledgeGoogle = Just $ \_ token -> modifyIORef' acknowledged (token :) >> readIORef acknowledgement} + withBadgeServiceVerifier ps acknowledging $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + let purchase payment = serviceCmd env purchaseKey $ purchaseCmd masterKey payment + unsettled <- purchase $ googlePayment "badge_supporter_01" googlePendingToken + fst (refusalOf unsettled) `shouldBe` BSEPaymentPending + unpriced <- purchase $ googlePayment "subscr_badge_supporter_01" googleSubscriptionToken + refusalOf unpriced `shouldBe` (BSEProductUnavailable, Nothing) + readIORef acknowledged `shouldReturn` [] + writeIORef acknowledgement $ Left $ SRUnreachable "acknowledging: fake store is down" + unacknowledged <- purchase supporterPlay + refusalOf unacknowledged `shouldSatisfy` \(code, retryAfter) -> code == BSEProviderUnavailable && isJust retryAfter + writeIORef acknowledgement $ Left $ SRVerifierFailed "acknowledging: the service account lacks the Play Console permission for this call" + forbidden <- purchase supporterPlay + refusalOf forbidden `shouldBe` (BSEInternal, Nothing) + nothingPurchased cc + writeIORef acknowledgement $ Right () + purchased <- purchase supporterPlay + badgeTypeOf purchased `shouldBe` Just BTSupporter + replayed <- purchase supporterPlay + credentialOf replayed `shouldBe` credentialOf purchased + readIORef acknowledged `shouldReturn` replicate 3 googleSupporterToken + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStoreUnknownProduct :: HasCallStack => TestParams -> IO () +testStoreUnknownProduct ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + -- a real purchase of a subscription, whose renewals nothing adds to a badge yet + subscription <- serviceCmd env purchaseKey $ purchaseCmd masterKey $ googlePayment "subscr_badge_supporter_01" googleSubscriptionToken + refusalOf subscription `shouldBe` (BSEProductUnavailable, Nothing) + nothingPurchased cc + +testStoreUpgradeRefused :: HasCallStack => TestParams -> IO () +testStoreUpgradeRefused ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + g <- C.newRandom + (fromPurchaseKey, fromPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + now <- getCurrentTime + let upgrade = BadgeUpgrade {fromPurchaseKey, receipt = "receipt", receiptSignature = C.sign' fromPriv "receipt", balance = BadgeBalance {lastEntry = emptyEntry now BTSupporter}} + upgraded <- serviceCmd env purchaseKey BSCPurchaseBadge {masterKey, payment = supporterPlay, upgrade = Just upgrade} + refusalOf upgraded `shouldBe` (BSEBadRequest, Nothing) + nothingPurchased cc + +testStoreBadgeTypeFromProduct :: HasCallStack => TestParams -> IO () +testStoreBadgeTypeFromProduct ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleLegendJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + legend <- serviceCmd env purchaseKey $ purchaseCmd masterKey SPApple {jws = appleLegendJWS} + badgeTypeOf legend `shouldBe` Just BTLegend + map (\StatementEntry {balanceBadgeType} -> balanceBadgeType) (fst $ statementOf legend) `shouldBe` [BTLegend, BTLegend] + storePayments cc `shouldReturn` [("apple", Just 7000, Just "USD", 1)] + +testNonStoreFundingRefused :: HasCallStack => TestParams -> IO () +testNonStoreFundingRefused ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + invoice <- serviceCmd env purchaseKey $ purchaseCmd masterKey SPInvoice {invoiceId = InvoiceId "inv"} + refusalOf invoice `shouldBe` (BSEUnknownPurchaseKey, Nothing) + transfer <- serviceCmd env purchaseKey $ purchaseCmd masterKey SPReceipt {receipt = "receipt"} + refusalOf transfer `shouldBe` (BSEUnknownPurchaseKey, Nothing) + nothingPurchased cc + +testStoreReplayWhileStoreDown :: HasCallStack => TestParams -> IO () +testStoreReplayWhileStoreDown ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = store} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + purchased <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + ledger <- ledgerRows cc "sx_badge_service_badge_ledger" + setGoogleDown store True + -- the key it was credited to is answered from the record + replayed <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + credentialOf replayed `shouldBe` credentialOf purchased + map entryTag (fst $ statementOf replayed) `shouldBe` map entryTag (fst $ statementOf purchased) + -- any other key waits for the store, or the answer would tell it which purchases were credited + (otherKey, otherMasterKey) <- newPurchaseKeys + other <- serviceCmd env otherKey $ purchaseCmd otherMasterKey supporterPlay + fst (refusalOf other) `shouldBe` BSEProviderUnavailable + ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStoreVerifierFailures :: HasCallStack => TestParams -> IO () +testStoreVerifierFailures ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleThrowingJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + let answer payment = refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey payment) + -- Apple is checked offline, so a verifier that throws was answered by nothing but its own bug + answer SPApple {jws = appleThrowingJWS} `shouldReturn` (BSEInternal, Nothing) + answer (googlePayment "badge_supporter_01" googleThrowingToken) >>= (`shouldSatisfy` \(code, retryAfter) -> code == BSEProviderUnavailable && isJust retryAfter) + answer (googlePayment "badge_supporter_01" googleHangingToken) >>= (`shouldSatisfy` \(code, retryAfter) -> code == BSEProviderUnavailable && isJust retryAfter) + nothingPurchased cc + +testStoreVerifiedOtherTransaction :: HasCallStack => TestParams -> IO () +testStoreVerifiedOtherTransaction ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleMisnamedJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey SPApple {jws = appleMisnamedJWS}) `shouldReturn` (BSEInternal, Nothing) + nothingPurchased cc + +testStoreQuantityRefused :: HasCallStack => TestParams -> IO () +testStoreQuantityRefused ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleQuantityJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey SPApple {jws = appleQuantityJWS}) `shouldReturn` (BSEInternal, Nothing) + nothingPurchased cc + +testPurchaseWithNoVerifier :: HasCallStack => TestParams -> IO () +testPurchaseWithNoVerifier ps = + withBadgeServiceVerifier ps (const noStoreVerifier) $ \env@BadgeServiceEnv {bsClientCfg, bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey supporterPlay) `shouldReturn` (BSEProviderNotConfigured, Nothing) + nothingPurchased cc + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + storePurchaseOpen alice "" + -- not yet rather than never, so the receipt stays held for the retry once a verifier is deployed + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final provider_not_configured"] + heldStoreReceipts (chatController alice) `shouldReturn` 1 + alice ##> "/_badge state 1" + alice .<## ", not credited: service_error final provider_not_configured" + +testStorePurchaseRace :: HasCallStack => TestParams -> IO () +testStorePurchaseRace ps = do + hook <- newIORef (pure ()) + withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + winner <- newIORef Nothing + -- the same purchase is presented again while the first is with the store, and credited first + writeIORef hook $ serviceCmd env purchaseKey (purchaseCmd masterKey supporterPlay) >>= writeIORef winner . Just + raced <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + Just credited <- readIORef winner + credentialOf credited `shouldSatisfy` isJust + credentialOf raced `shouldBe` credentialOf credited + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStorePurchaseRaceOtherKey :: HasCallStack => TestParams -> IO () +testStorePurchaseRaceOtherKey ps = do + hook <- newIORef (pure ()) + withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + (otherKey, otherMasterKey) <- newPurchaseKeys + winner <- newIORef Nothing + -- another key presents the same purchase while the first is with the store, and is credited first + writeIORef hook $ serviceCmd env otherKey (purchaseCmd otherMasterKey supporterPlay) >>= writeIORef winner . Just + raced <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + refusalOf raced `shouldBe` (BSEReceiptUsed, Nothing) + Just credited <- readIORef winner + masterKeyOf credited `shouldBe` Just otherMasterKey + ledger <- ledgerRows cc "sx_badge_service_badge_ledger" + replayed <- serviceCmd env otherKey $ purchaseCmd otherMasterKey supporterPlay + credentialOf replayed `shouldBe` credentialOf credited + ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStorePurchaseKeyMismatch :: HasCallStack => TestParams -> IO () +testStorePurchaseKeyMismatch ps = + withBadgeService ps $ \clientCfg bsLink cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + g <- C.newRandom + (_, signPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + (claimedPub, _) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + let signKey = B.unpack $ strEncode (C.StoredPrivateKey signPriv) + claimed = B.unpack $ strEncode claimedPub + req = + "{\"version\":1,\"purchaseKey\":\"" <> claimed + <> "\",\"request\":{\"type\":\"purchaseBadge\",\"masterKey\":\"" <> testMasterKeyB64 + <> "\",\"payment\":" <> paymentArg supporterPlay <> "}}" + alice ##> ("/_service_request 1 " <> bsLink <> " sign_key=" <> signKey <> " " <> req) + alice <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}" + nothingPurchased cc + +testStoreUnsentProductId :: HasCallStack => TestParams -> IO () +testStoreUnsentProductId ps = do + hook <- newIORef (pure ()) + verifications <- countGoogleVerifications hook + withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey $ googlePayment "badge_supporter_01/../other" googleSupporterToken) `shouldReturn` (BSEReceiptInvalid, Nothing) + verifications `shouldReturn` 0 + nothingPurchased cc + +testPurchaseBadge :: HasCallStack => TestParams -> IO () +testPurchaseBadge ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- the app hands a purchase over until it is answered credited, and one already credited adds nothing + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay + alice <## "[user: alice] badge already redeemed" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + map (\(_, c, m, _, _, t) -> (c, m, t)) <$> ledgerRows (chatController alice) "badge_ledger" `shouldReturn` [(1, 1, Just "payment"), (-1, 0, Just "badge")] + alice ##> "/p" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice, * supporter)" + +testPurchaseNamesNoTransaction :: HasCallStack => TestParams -> IO () +testPurchaseNamesNoTransaction ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = "not.a-jws"} + alice <## "cannot get badge: invalid store receipt" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), False)] + nothingPurchased cc + +testPurchaseStashReceiptUsed :: HasCallStack => TestParams -> IO () +testPurchaseStashReceiptUsed ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + invoiceId <- createInvoice alice 1 + -- the store echoes the buyer's invoice on any device the purchase reaches + let purchase = purchaseWithInvoice 1 invoiceId supporterPlay + alice ##> purchase + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] + bob ##> purchase + bob <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final receipt_used")] + bob ##> purchase + bob <## "cannot get badge: badge service error: receipt_used" + storeReceiptRows (chatController bob) `shouldReturn` [(1, Just (T.pack invoiceId), True)] + heldStoreReceipts (chatController bob) `shouldReturn` 0 + rowCount (chatController alice) "badge_store_receipts" `shouldReturn` 1 + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + bob ##> "/p" + showActiveUser bob "bob (Bob)" + +testPurchaseUnsentPlayToken :: HasCallStack => TestParams -> IO () +testPurchaseUnsentPlayToken ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "token/other")) + storePurchaseOpen alice "" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry provider_unavailable"] + heldStoreReceipts (chatController alice) `shouldReturn` 1 + nothingPurchased cc + +testPurchaseStrandedUnderOtherProfile :: HasCallStack => TestParams -> IO () +testPurchaseStrandedUnderOtherProfile ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + let purchase userId = purchaseWithInvoice userId invoiceId supporterPlay + alice ##> purchase 1 + alice <##. invoicePurchase "" invoiceId + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- handed over again under whichever profile is active, the purchase stays with the keys alice holds + alice ##> purchase 2 + alice <##. invoicePurchase "[user: alice] " invoiceId + writeIORef broken False + mapM_ (alice <##.) $ creditedLines "[user: alice] " invoiceId "1: supporter" + (alice "/user alice" + showActiveUser alice "alice (Alice, * supporter)" + +testPurchaseDeliveredToHiddenProfile :: HasCallStack => TestParams -> IO () +testPurchaseDeliveredToHiddenProfile ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do + let purchase userId = "/_badge purchase " <> show (userId :: Int) <> " " <> paymentArg supporterPlay + alice ##> purchase 1 + storePurchaseOpen alice "" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_hide user 1 \"password\"" + alice <## "user alice:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + -- the answer and the credit are the hidden owner's, so the view prints none of them + alice ##> purchase 2 + (alice "/user alice password" + showActiveUser alice "alice (Alice, * supporter)" + +testInvoiceOtherProfile :: HasCallStack => TestParams -> IO () +testInvoiceOtherProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock = clock} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay + inAnyOrder alice [[invoicePurchase "[user: alice] " invoiceId], creditedLines "[user: alice] " invoiceId "1: supporter"] + (alice "/user alice" + showActiveUser alice "alice (Alice, * supporter)" + +testInvoiceUnknown :: HasCallStack => TestParams -> IO () +testInvoiceUnknown ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- a reinstall or a second device: the store echoes an invoice this database never created + let unknown = "0b6a5e6c-4f3e-4d51-9f55-3a0f7c2f9e11" + alice ##> purchaseWithInvoice 2 unknown supporterPlay + inAnyOrder alice [[invoicePurchase "" unknown], creditedLines "" unknown "1: supporter"] + storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True)] + +testInvoiceHeldByAnotherRecord :: HasCallStack => TestParams -> IO () +testInvoiceHeldByAnotherRecord ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId (googlePayment "badge_supporter_01" "not-a-purchase") + alice <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final receipt_invalid")] + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + inAnyOrder alice [[openPurchase ""], ["1: supporter"]] + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True), (1, Nothing, True)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testInvoiceAgedOut :: HasCallStack => TestParams -> IO () +testInvoiceAgedOut ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock = clock} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + unpaid <- createInvoice alice 1 + presented <- createInvoice alice 1 + refused <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 refused (googlePayment "badge_supporter_01" "not-a-purchase") + alice + <### [ ConsoleString ("store purchase open: invoice " <> unpaid), + ConsoleString ("store purchase open: invoice " <> presented), + StartsWith (invoicePurchase "" refused), + ConsoleString (refusedLine "" refused "service_error final receipt_invalid") + ] + alice ##> purchaseWithInvoice 1 presented (googlePayment "badge_supporter_01" googlePendingToken) + alice <## ("store purchase open: invoice " <> unpaid) + alice <##. ("store purchase open: invoice " <> presented <> ", transaction ") + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry payment_pending"] + -- no age hides a record: one with no receipt and one held stay listed, and only a settled one goes + getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) + alice ##> "/_badge state 1" + alice <## ("store purchase open: invoice " <> unpaid) + alice <##. ("store purchase open: invoice " <> presented <> ", transaction ") + (alice TestParams -> IO () +testInvoiceRefusedBeforeCharge ps = do + withNewTestChatCfg ps testCfg {badgeServiceAddress = Nothing} "bob" bobProfile $ \bob -> do + bob ##> "/_badge invoice 1" + bob <## "cannot get badge: badge service not configured" + storeReceiptRows (chatController bob) `shouldReturn` [] + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice ##> "/_badge invoice 1" + alice <## "cannot get badge: badge already active" + storeReceiptRows (chatController alice) `shouldReturn` [] + +testInvoiceWhileBadgeHeld :: HasCallStack => TestParams -> IO () +testInvoiceWhileBadgeHeld ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <##. "1: supporter" + alice <##. ("store purchase open: invoice " <> invoiceId <> ", transaction ") + storeReceiptDue (chatController alice) 1 `shouldReturn` False + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] + heldStoreReceipts (chatController alice) `shouldReturn` 1 + rowCount cc "sx_badge_service_payments" `shouldReturn` 0 + +testInvoiceStateOtherProfile :: HasCallStack => TestParams -> IO () +testInvoiceStateOtherProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + _ <- createInvoice alice 1 + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_hide user 1 \"password\"" + alice <## "user alice:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + invoiceId <- createInvoice alice 2 + alice ##> "/_badge state 2" + alice <## ("store purchase open: invoice " <> invoiceId) + (alice TestParams -> IO () +testStoreReceiptRetried ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <##. invoicePurchase "" invoiceId + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] + writeIORef broken False + mapM_ (alice <##.) $ creditedLines "" invoiceId "1: supporter" + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStoreReceiptNotBlocked :: HasCallStack => TestParams -> IO () +testStoreReceiptNotBlocked ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsStore = store@FakeStore {appleSupporterJWS}} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + setGoogleDown store True + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + storePurchaseOpen alice "" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry provider_unavailable"] + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = appleSupporterJWS} + inAnyOrder alice [[openPurchase "", invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] + heldStoreReceipts (chatController alice) `shouldReturn` 1 + +testStoreRefusalKept :: HasCallStack => TestParams -> IO () +testStoreRefusalKept ps = do + hook <- newIORef (pure ()) + verifications <- countGoogleVerifications hook + withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + let refused = "/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase") + alice ##> refused + storePurchaseOpen alice "" + -- with no invoice id the app has no key to the store transaction, so the refusal is not announced + heldStoreReceipts (chatController alice) `shouldEventuallyReturn` 0 + (alice refused + alice <## "cannot get badge: badge service error: receipt_invalid" + alice ##> "/_badge state 1" + (alice TestParams -> IO () +testStoreTestPurchaseRefused ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsStore = FakeStore {appleSandboxJWS}} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + let purchase = purchaseWithInvoice 1 invoiceId SPApple {jws = appleSandboxJWS} + alice ##> purchase + alice <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final test_purchase")] + heldStoreReceipts (chatController alice) `shouldReturn` 0 + alice ##> purchase + alice <## "cannot get badge: badge service error: test_purchase" + +testStoreSettledOtherProfile :: HasCallStack => TestParams -> IO () +testStoreSettledOtherProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + shown <- createInvoice alice 1 + hidden <- createInvoice alice 1 + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- the invoice makes alice the owner, so the hand-over's answer and the settlement are hers + alice ##> purchaseWithInvoice 2 shown (googlePayment "badge_supporter_01" "not-a-purchase") + alice + <### [ StartsWith ("[user: alice] store purchase open: invoice " <> shown <> ", transaction "), + ConsoleString ("store purchase open: invoice " <> hidden), + ConsoleString (refusedLine "[user: alice] " shown "service_error final receipt_invalid") + ] + alice ##> "/_hide user 1 \"password\"" + alice <## "user alice:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + alice ##> purchaseWithInvoice 2 hidden (googlePayment "badge_supporter_01" "not-a-purchase-either") + (alice TestParams -> IO () +testStoreReceiptTwoAtOnce ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + let handOver = void $ sendChatCmdStr (chatController alice) (purchaseWithInvoice 1 invoiceId supporterPlay) + concurrentlyN_ [handOver, handOver] + mapM_ (alice <##.) $ creditedLines "" invoiceId "1: supporter" + (alice TestParams -> IO () +testStoreReceiptCleanup ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock, bsStore = FakeStore {appleSupporterJWS}} -> do + let cfg = bsClientCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + unpaid <- createInvoice alice 1 + alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase")) + alice <## ("store purchase open: invoice " <> unpaid) + storePurchaseOpen alice "" + heldStoreReceipts (chatController alice) `shouldEventuallyReturn` 0 + alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) + alice <### [ConsoleString ("store purchase open: invoice " <> unpaid), StartsWith (openPurchase ""), StartsWith "1: supporter"] + alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googleUnreachableToken)) + alice <##. "1: supporter" + alice <## ("store purchase open: invoice " <> unpaid) + storePurchaseOpen alice "" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack unpaid), False), (1, Nothing, True), (1, Nothing, True), (1, Nothing, True)] + -- a month on, the record no receipt reached and the refusal go; the credited and the held one stay + testClockTime bsClock >>= setClockAt bsClock . addUTCTime (31 * nominalDay) + -- the move also ends the badge credited above; whether its worker wakes alone or on this signal, its lines are read here + alice `send` "/_app activate" + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "badge alert: support_ended " + ] + storeReceiptRows (chatController alice) `shouldEventuallyReturn` [(1, Nothing, True), (1, Nothing, True)] + heldStoreReceipts (chatController alice) `shouldReturn` 1 + +testStoreReceiptAttemptThrows :: HasCallStack => TestParams -> IO () +testStoreReceiptAttemptThrows ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + storePurchaseOpen alice "" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] + -- a stored payment this version cannot read makes the attempt throw before anything is sent + setStoreReceiptPayment (chatController alice) "not a payment" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["unexpected held store payment does not decode"] + (alice TestParams -> IO () +testStoreReceiptAfterRestart ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg} -> do + let cfg = fastRetryCfg bsClientCfg + invoiceId <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <##. invoicePurchase "" invoiceId + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] + pure invoiceId + writeIORef broken False + withTestChatCfg ps cfg "alice" $ \alice -> do + expectLinesWithin 60 alice (creditedLines "" invoiceId "1: supporter") ["subscribed 1 connections on server localhost"] + heldStoreReceipts (chatController alice) `shouldReturn` 0 + +testPlayHoldsNoOtherRequest :: HasCallStack => TestParams -> IO () +testPlayHoldsNoOtherRequest ps = do + entered <- newEmptyMVar + release <- newEmptyMVar + hook <- newIORef $ putMVar entered () >> takeMVar release + -- past the 20s line wait, so a request queued behind the held call cannot be answered in time + let holding store = (googleVerifierWithHook hook store) {verifyTimeout = 60000000} + withBadgeServiceVerifier ps holding $ \BadgeServiceEnv {bsClientCfg, bsAddress, bsController = cc, bsStore = FakeStore {appleSupporterJWS}} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> + withNewTestChatCfg ps bsClientCfg "cath" cathProfile $ \cath -> do + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + storePurchaseOpen alice "" + timeout 30000000 (takeMVar entered) `shouldReturn` Just () + code <- issueCode cc BTSupporter 1 + bob ##> ("/_redeem_badge_code 1 " <> codeArg code) + bob <## "badge redeemed" + bob <## "supporter badge - active" + bob <##. "expires " + cath ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) + cath <### [StartsWith (openPurchase ""), StartsWith "1: supporter"] + g <- C.newRandom + (upgradeKey, upgradePriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + (fromPurchaseKey, fromPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + masterKey <- generateMasterKey g + now <- getCurrentTime + let upgrade = BadgeUpgrade {fromPurchaseKey, receipt = "receipt", receiptSignature = C.sign' fromPriv "receipt", balance = BadgeBalance {lastEntry = emptyEntry now BTSupporter}} + upgradeReq = LB.unpack $ J.encode $ requestObject upgradeKey BSCPurchaseBadge {masterKey, payment = supporterPlay, upgrade = Just upgrade} + bob ##> ("/_service_request 1 " <> bsAddress <> " sign_key=" <> B.unpack (strEncode $ C.StoredPrivateKey upgradePriv) <> " " <> upgradeReq) + bob <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}" + putMVar release () + expectLinesWithin 60 alice ["1: supporter"] [] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 3 + +createInvoice :: HasCallStack => TestCC -> Int -> IO String +createInvoice cc userId = do + cc ##> ("/_badge invoice " <> show userId) + line <- getTermLine cc + maybe (error $ "expected an invoice, got " <> line) pure $ stripPrefix "badge invoice: " line + +purchaseWithInvoice :: Int -> String -> ServicePayment -> String +purchaseWithInvoice userId invoiceId payment = "/_badge purchase " <> show userId <> " invoice=" <> invoiceId <> " " <> paymentArg payment + +-- | Each store purchase record's profile, invoice id, and whether a receipt reached it. +storeReceiptRows :: ChatController -> IO [(Int64, Maybe Text, Bool)] +storeReceiptRows ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.query_ db $ + "SELECT user_id, invoice_id, transaction_ref IS NOT NULL " + <> "FROM badge_store_receipts ORDER BY badge_store_receipt_id" + +storeReceiptDue :: ChatController -> Int64 -> IO Bool +storeReceiptDue ChatController {chatStore} userId = + withTransaction chatStore $ \db -> + getNextHeldStoreReceipt db userId >>= either (error . show) (pure . isJust) + +heldStoreReceipts :: ChatController -> IO Int +heldStoreReceipts ChatController {chatStore} = + withTransaction chatStore $ \db -> do + [Only n] <- DB.query_ db "SELECT COUNT(*) FROM badge_store_receipts WHERE payment IS NOT NULL" + pure n + +heldStoreReceiptErrors :: ChatController -> IO [Text] +heldStoreReceiptErrors ChatController {chatStore} = + withTransaction chatStore $ \db -> + map fromOnly <$> DB.query_ db "SELECT credit_error FROM badge_store_receipts WHERE payment IS NOT NULL AND credit_error IS NOT NULL" + +setStoreReceiptPayment :: ChatController -> Text -> IO () +setStoreReceiptPayment ChatController {chatStore} payment = + withTransaction chatStore $ \db -> DB.execute db "UPDATE badge_store_receipts SET payment = ?" (Only payment) + +fastRetryCfg :: ChatConfig -> ChatConfig +fastRetryCfg cfg = cfg {badgeRetryInterval = RetryInterval {initialInterval = 100000, increaseAfter = 0, maxInterval = 500000}} + +-- | The fake store's Google verdicts, failing as a verifier bug while broken is set: internal, with no retryAfter. +brokenGoogle :: IORef Bool -> FakeStore -> StoreVerifier +brokenGoogle broken FakeStore {fakeVerifier = v@StoreVerifier {verifyGoogle}} = + v {verifyGoogle = unlessBroken <$> verifyGoogle} + where + unlessBroken verify productId token = + readIORef broken >>= \case + True -> pure $ Left $ SRVerifierFailed "fake verifier bug" + False -> verify productId token + +-- | Each block's lines in order, the blocks in any order: the worker is signalled before the command that +-- signalled it has answered, so its events can print first. Every line is matched as a prefix. +inAnyOrder :: HasCallStack => TestCC -> [[String]] -> IO () +inAnyOrder _ [] = pure () +inAnyOrder cc blocks = do + line <- getTermLine cc + case break (startsWith line) blocks of + (earlier, (_ : rest) : others) -> do + mapM_ (cc <##.) rest + inAnyOrder cc (earlier <> others) + _ -> error $ "expected a line starting one of " <> show blocks <> ", got " <> show line + where + startsWith l = \case + start : _ -> start `isPrefixOf` l + [] -> False + +-- | The required lines under one budget in any order, and the tolerated ones only if they come at +-- all: the agent announces UP for a subscription that was still pending in the session answering it +-- (simplexmq processSubOk), so "subscribed N connections" may never be printed, and when it is it +-- races a settlement that arrived in the same batch. getTermLine' would bound each line at 20s. +expectLinesWithin :: HasCallStack => Int -> TestCC -> [String] -> [String] -> IO () +expectLinesWithin seconds cc@TestCC {termQ, printOutput} required tolerated = go (seconds * 10) required + where + go n outstanding = + 100000 `timeout` atomically (readTQueue termQ) >>= \case + Just line -> do + when printOutput $ userName cc >>= \name -> putStrLn $ name <> ": " <> line + case break (`isPrefixOf` line) outstanding of + (earlier, _ : others) -> go n (earlier <> others) + _ + | any (`isPrefixOf` line) tolerated -> go n outstanding + | otherwise -> error $ "unexpected " <> show line <> ", awaiting " <> show outstanding + Nothing + | null outstanding -> pure () + | n > 0 -> go (n - 1) outstanding + | otherwise -> error $ "no line starting one of " <> show outstanding <> " in " <> show seconds <> " seconds" + +-- | Counts the Google verifications the service makes, re-arming the one-shot hook each time. +countGoogleVerifications :: IORef (IO ()) -> IO (IO Int) +countGoogleVerifications hook = do + n <- newIORef 0 + let count = modifyIORef' n (+ 1) >> writeIORef hook count + writeIORef hook count + pure $ readIORef n + +storePurchaseOpen :: HasCallStack => TestCC -> String -> IO () +storePurchaseOpen cc userPrefix = cc <##. openPurchase userPrefix + +openPurchase :: String -> String +openPurchase userPrefix = userPrefix <> "store purchase open: invoice none, transaction " + +invoicePurchase :: String -> String -> String +invoicePurchase userPrefix invoiceId = userPrefix <> "store purchase open: invoice " <> invoiceId <> ", transaction " + +-- | The worker's credit as the owner's terminal shows it: the badge, then the settlement. +creditedLines :: String -> String -> String -> [String] +creditedLines userPrefix invoiceId badge = [userPrefix <> badge, userPrefix <> "store purchase credited: invoice " <> invoiceId] + +refusedLine :: String -> String -> String -> String +refusedLine userPrefix invoiceId failure = userPrefix <> "store purchase refused: invoice " <> invoiceId <> ", " <> failure diff --git a/tests/Bots/BadgeService/ConfigTests.hs b/tests/Bots/BadgeService/ConfigTests.hs index 7d5c5c9a7f..ce38929725 100644 --- a/tests/Bots/BadgeService/ConfigTests.hs +++ b/tests/Bots/BadgeService/ConfigTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -7,6 +8,7 @@ import BadgeService.Config import qualified Data.ByteString.Char8 as B import Data.Either (isLeft) import Data.Ini (readIniFile) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Text as T import qualified Data.Text.IO as T import Simplex.Messaging.Encoding.String (strDecode) @@ -47,6 +49,14 @@ badgeConfigTests = describe "badge service config" $ do it "refuses an index that is not a positive whole number" testIssuerIndexInvalid it "refuses a private key that is not a valid issuer secret" testIssuerBadSecret it "names the old default and key_ settings, then refuses the boot" testIssuerOldFormat + it "verifies store receipts when the dev section is absent" testDevUnverifiedReceiptsAbsent + it "reads accept_unverified_store_receipts = on" testDevUnverifiedReceiptsOn + it "reads accept_unverified_store_receipts = off" testDevUnverifiedReceiptsOff + it "refuses an accept_unverified_store_receipts that is not on or off" testDevUnverifiedReceiptsNotBoolean + it "configures no store verifier when [apple] and [google] are absent" testStoresAbsent + it "reads [apple] and [google], defaulting Google's endpoints" testStoresRead + it "refuses an incomplete [apple] or [google], naming the key" testStoresIncomplete + it "refuses accept_unverified_store_receipts beside a real store verifier" testDevUnverifiedBesideVerifier groupConfigTests fullIni :: T.Text @@ -353,6 +363,74 @@ testIssuerOldFormat = do unknownKeys ini `shouldMatchList` ["issuer.default", "issuer.key_1"] issuerRefusal old `shouldReturn` "issuer.index is required" +testDevUnverifiedReceiptsAbsent :: IO () +testDevUnverifiedReceiptsAbsent = withIni fullIni $ \p -> do + Right cfg <- readServiceConfig p + devAcceptUnverifiedStoreReceipts cfg `shouldBe` False + +testDevUnverifiedReceiptsOn :: IO () +testDevUnverifiedReceiptsOn = withDev "accept_unverified_store_receipts = on\n" $ \r -> case r of + Right cfg -> devAcceptUnverifiedStoreReceipts cfg `shouldBe` True + Left e -> expectationFailure ("[dev] accept_unverified_store_receipts = on is legal: " <> e) + +testDevUnverifiedReceiptsOff :: IO () +testDevUnverifiedReceiptsOff = withDev "accept_unverified_store_receipts = off\n" $ \r -> case r of + Right cfg -> devAcceptUnverifiedStoreReceipts cfg `shouldBe` False + Left e -> expectationFailure ("[dev] accept_unverified_store_receipts = off is legal: " <> e) + +testDevUnverifiedReceiptsNotBoolean :: IO () +testDevUnverifiedReceiptsNotBoolean = withDev "accept_unverified_store_receipts = yes\n" $ \r -> case r of + Left e -> e `shouldContain` "accept_unverified_store_receipts" + Right _ -> expectationFailure "only on and off are accepted, so a typo cannot silently arm the mock" + +withDev :: T.Text -> (Either String ServiceConfig -> IO a) -> IO a +withDev keys act = withIni (fullIni <> "[dev]\n" <> keys) $ \p -> readServiceConfig p >>= act + +appleIni, googleIni :: T.Text +appleIni = "[apple]\nbundle_id = chat.simplex.app\nroot_certificates = /etc/badges/AppleRootCA-G3.cer ,/etc/badges/AppleRootCA-G4.cer,\n" +googleIni = "[google]\npackage_name = chat.simplex.app\nservice_account_file = /etc/badges/play.json\n" + +testStoresAbsent :: IO () +testStoresAbsent = withIni fullIni $ \p -> do + Right cfg <- readServiceConfig p + appleStore cfg `shouldBe` Nothing + playStore cfg `shouldBe` Nothing + +testStoresRead :: IO () +testStoresRead = withIni (fullIni <> appleIni <> googleIni) $ \p -> do + Right cfg <- readServiceConfig p + appleStore cfg `shouldBe` Just AppleStoreConfig {aBundleId = "chat.simplex.app", aRootCertificates = "/etc/badges/AppleRootCA-G3.cer" :| ["/etc/badges/AppleRootCA-G4.cer"]} + playStore cfg + `shouldBe` Just + PlayStoreConfig + { gPackageName = "chat.simplex.app", + gServiceAccountFile = "/etc/badges/play.json", + gApiHost = "https://androidpublisher.googleapis.com", + gTokenUrl = "https://oauth2.googleapis.com/token" + } + +testStoresIncomplete :: IO () +testStoresIncomplete = do + refused (T.replace "bundle_id = chat.simplex.app\n" "" appleIni) "apple.bundle_id" + refused (T.replace "root_certificates = /etc/badges/AppleRootCA-G3.cer ,/etc/badges/AppleRootCA-G4.cer,\n" "" appleIni) "apple.root_certificates" + refused (T.replace "package_name = chat.simplex.app\n" "" googleIni) "google.package_name" + refused (T.replace "service_account_file = /etc/badges/play.json\n" "" googleIni) "google.service_account_file" + where + refused section key = + withIni (fullIni <> section) readServiceConfig >>= \case + Left e -> e `shouldContain` key + Right _ -> expectationFailure ("an incomplete section must fail at boot, naming " <> key) + +testDevUnverifiedBesideVerifier :: IO () +testDevUnverifiedBesideVerifier = + mapM_ + ( \stores -> + withIni (fullIni <> stores <> "[dev]\naccept_unverified_store_receipts = on\n") readServiceConfig >>= \case + Left e -> e `shouldContain` "accept_unverified_store_receipts" + Right _ -> expectationFailure "a service that verifies store receipts must not also accept unverified ones" + ) + [appleIni, googleIni] + parseIni :: T.Text -> IO (Either String ServiceConfig) parseIni t = withIni t readServiceConfig diff --git a/tests/Bots/BadgeService/FakePlay.hs b/tests/Bots/BadgeService/FakePlay.hs new file mode 100644 index 0000000000..f5b346a5e3 --- /dev/null +++ b/tests/Bots/BadgeService/FakePlay.hs @@ -0,0 +1,184 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Bots.BadgeService.FakePlay + ( FakePlay (..), + PlayAnswer (..), + withFakePlay, + answerPurchase, + answerAcknowledgements, + answerTokenRequests, + grantTokensFor, + fakePackageName, + ) +where + +import BadgeService.Config (PlayStoreConfig (..)) +import Control.Concurrent (threadDelay) +import Control.Concurrent.STM +import Control.Monad (forever) +import Crypto.Hash.Algorithms (SHA256 (..)) +import Crypto.Number.Serialize (i2osp) +import qualified Crypto.PubKey.RSA as RSA +import qualified Crypto.PubKey.RSA.PKCS15 as RSA +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import qualified Data.ByteString as B +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Char8 as B8 +import qualified Data.ByteString.Lazy as LB +import qualified Data.Map.Strict as M +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (decodeUtf8) +import Network.HTTP.Types (Status, badRequest400, hAuthorization, hContentType, methodGet, methodPost, mkStatus, notFound404, ok200, parseSimpleQuery, unauthorized401) +import Network.Wai (Application, Response, pathInfo, requestHeaders, requestMethod, responseLBS, strictRequestBody) +import qualified Network.Wai.Handler.Warp as Warp +import System.Directory (createDirectoryIfMissing) +import System.FilePath (()) +import UnliftIO.Temporary (withTempDirectory) + +data PlayAnswer = PlayRecord J.Value | PlayBody LB.ByteString | PlayStatus Int | PlayHang + +data FakePlay = FakePlay + { fpConfig :: PlayStoreConfig, + fpAnswers :: TVar (M.Map Text PlayAnswer), + -- a status the token endpoint answers instead of granting + fpTokenRefusal :: TVar (Maybe Int), + fpTokenSeconds :: TVar Int, + fpGrantedTokens :: TVar [B.ByteString], + fpPurchasePaths :: TVar [[Text]], + -- what the acknowledge endpoint answers instead of acknowledging + fpAcknowledgeRefusal :: TVar (Maybe PlayAnswer), + fpAcknowledged :: TVar [Text] + } + +fakePackageName :: Text +fakePackageName = "chat.simplex.app" + +fakeClientEmail :: Text +fakeClientEmail = "badge-verifier@simplex-badges-test.iam.gserviceaccount.com" + +-- | Warp chooses the port, so a server left over from an earlier run cannot answer instead. +-- The service account key is made per run, since a committed one trips secret scanning. +withFakePlay :: (FakePlay -> IO a) -> IO a +withFakePlay action = do + (key, privateKey) <- RSA.generate 128 65537 + fpAnswers <- newTVarIO M.empty + fpTokenRefusal <- newTVarIO Nothing + fpTokenSeconds <- newTVarIO 3600 + fpGrantedTokens <- newTVarIO [] + fpPurchasePaths <- newTVarIO [] + fpAcknowledgeRefusal <- newTVarIO Nothing + fpAcknowledged <- newTVarIO [] + createDirectoryIfMissing True "tests/tmp" + withTempDirectory "tests/tmp" "fake-play" $ \d -> do + let accountFile = d "service-account.json" + fake base = + FakePlay + { fpConfig = PlayStoreConfig {gPackageName = fakePackageName, gServiceAccountFile = accountFile, gApiHost = base, gTokenUrl = base <> "/token"}, + fpAnswers, + fpTokenRefusal, + fpTokenSeconds, + fpGrantedTokens, + fpPurchasePaths, + fpAcknowledgeRefusal, + fpAcknowledged + } + LB.writeFile accountFile $ J.encode $ J.object ["client_email" J..= fakeClientEmail, "private_key" J..= decodeUtf8 (pkcs8Pem privateKey)] + Warp.testWithApplication (pure $ \req respond -> fakeApp key (fake $ origin req) req respond) $ \prt -> + action (fake $ "http://127.0.0.1:" <> T.pack (show prt)) + where + -- the app needs the base URL for the audience it checks, and the request names the port it reached + origin req = maybe "" (("http://" <>) . T.pack . B8.unpack) (lookup "Host" (requestHeaders req)) + +-- | The PKCS#8 PEM Google puts in a service account key, so the service's own parser reads it. +pkcs8Pem :: RSA.PrivateKey -> B.ByteString +pkcs8Pem k = + "-----BEGIN PRIVATE KEY-----\n" <> B8.unlines (chunks $ B64.encode pkcs8) <> "-----END PRIVATE KEY-----\n" + where + RSA.PublicKey {RSA.public_n, RSA.public_e} = RSA.private_pub k + pkcs8 = der 0x30 $ derInteger 0 <> der 0x30 (rsaEncryption <> "\x05\x00") <> der 0x04 pkcs1 + pkcs1 = der 0x30 $ foldMap derInteger [0, public_n, public_e, RSA.private_d k, RSA.private_p k, RSA.private_q k, RSA.private_dP k, RSA.private_dQ k, RSA.private_qinv k] + rsaEncryption = der 0x06 "\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01" + derInteger n = der 0x02 $ case i2osp n of + bs | B.null bs || B.head bs >= 0x80 -> B.cons 0 bs + bs -> bs + der tag content = B.pack (tag : derLength (B.length content)) <> content + derLength n + | n < 0x80 = [fromIntegral n] + | otherwise = let bs = B.unpack (i2osp $ toInteger n) in 0x80 + fromIntegral (length bs) : bs + chunks bs + | B.null bs = [] + | otherwise = let (line, rest) = B.splitAt 64 bs in line : chunks rest + +answerPurchase :: FakePlay -> Text -> PlayAnswer -> IO () +answerPurchase FakePlay {fpAnswers} token answer = atomically $ modifyTVar' fpAnswers $ M.insert token answer + +answerTokenRequests :: FakePlay -> Maybe Int -> IO () +answerTokenRequests FakePlay {fpTokenRefusal} = atomically . writeTVar fpTokenRefusal + +grantTokensFor :: FakePlay -> Int -> IO () +grantTokensFor FakePlay {fpTokenSeconds} = atomically . writeTVar fpTokenSeconds + +answerAcknowledgements :: FakePlay -> Maybe PlayAnswer -> IO () +answerAcknowledgements FakePlay {fpAcknowledgeRefusal} = atomically . writeTVar fpAcknowledgeRefusal + +fakeApp :: RSA.PublicKey -> FakePlay -> Application +fakeApp key FakePlay {fpConfig = PlayStoreConfig {gTokenUrl}, fpAnswers, fpTokenRefusal, fpTokenSeconds, fpGrantedTokens, fpPurchasePaths, fpAcknowledgeRefusal, fpAcknowledged} req respond = + case (requestMethod req, pathInfo req) of + (m, ["token"]) | m == methodPost -> strictRequestBody req >>= grant + (m, path@["androidpublisher", "v3", "applications", pkg, "purchases", "products", _, "tokens", token]) + | m == methodGet && pkg == fakePackageName -> authorized $ do + atomically $ modifyTVar' fpPurchasePaths (<> [path]) + purchase token + | m == methodPost && pkg == fakePackageName, Just acknowledgedToken <- T.stripSuffix ":acknowledge" token -> authorized $ acknowledge acknowledgedToken + _ -> respond $ status notFound404 + where + authorized act = do + granted <- readTVarIO fpGrantedTokens + case lookup hAuthorization (requestHeaders req) of + Just auth | Just bearer <- B.stripPrefix "Bearer " auth, take 1 (reverse granted) == [bearer] -> act + _ -> respond $ status unauthorized401 + acknowledge token = + readTVarIO fpAcknowledgeRefusal >>= \case + Just answer -> answerWith answer + Nothing -> do + atomically $ modifyTVar' fpAcknowledged (<> [token]) + respond $ responseLBS ok200 [] "" + grant body = + readTVarIO fpTokenRefusal >>= \case + Just code -> respond $ status (mkStatus code "Refused") + Nothing + | validAssertion (parseSimpleQuery $ LB.toStrict body) -> do + bearer <- atomically $ stateTVar fpGrantedTokens $ \ts -> let t = "fake-access-" <> B8.pack (show (length ts)) in (t, ts <> [t]) + seconds <- readTVarIO fpTokenSeconds + respond $ json ok200 $ J.object ["access_token" J..= B8.unpack bearer, "expires_in" J..= seconds, "token_type" J..= ("Bearer" :: Text)] + | otherwise -> respond $ json badRequest400 $ J.object ["error" J..= ("invalid_grant" :: Text)] + validAssertion form = case (lookup "grant_type" form, B8.split '.' <$> lookup "assertion" form) of + (Just "urn:ietf:params:oauth:grant-type:jwt-bearer", Just [header, claims, sig]) + | Right sigBytes <- B64U.decodeUnpadded sig, + RSA.verify (Just SHA256) key (header <> "." <> claims) sigBytes, + Right claimBytes <- B64U.decodeUnpadded claims, + Just (J.Object c) <- J.decodeStrict' claimBytes -> + KM.lookup "aud" c == Just (J.String gTokenUrl) + && KM.lookup "scope" c == Just (J.String "https://www.googleapis.com/auth/androidpublisher") + && KM.lookup "iss" c == Just (J.String fakeClientEmail) + _ -> False + purchase token = + M.lookup token <$> readTVarIO fpAnswers >>= \case + Just answer -> answerWith answer + Nothing -> respond $ status notFound404 + answerWith = \case + PlayRecord v -> respond $ json ok200 v + PlayBody b -> respond $ responseLBS ok200 [(hContentType, "application/json")] b + PlayStatus code -> respond $ status (mkStatus code "Answer") + PlayHang -> forever $ threadDelay 1000000 + +json :: Status -> J.Value -> Response +json st = responseLBS st [(hContentType, "application/json")] . J.encode + +status :: Status -> Response +status st = json st $ J.object ["error" J..= J.object ["code" J..= show st]] diff --git a/tests/Bots/BadgeService/FakeStore.hs b/tests/Bots/BadgeService/FakeStore.hs new file mode 100644 index 0000000000..c8c7048a48 --- /dev/null +++ b/tests/Bots/BadgeService/FakeStore.hs @@ -0,0 +1,141 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} + +module Bots.BadgeService.FakeStore + ( FakeStore (..), + newFakeStore, + googleVerifierWithHook, + settlePending, + setGoogleDown, + googleSupporterToken, + googlePendingToken, + googleUnreachableToken, + googleThrowingToken, + googleHangingToken, + googleSubscriptionToken, + googlePayment, + unsignedJWS, + ) +where + +import BadgeService.StoreReceipts +import Control.Concurrent (threadDelay) +import Control.Monad (forever) +import qualified Data.Aeson as J +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef) +import Data.Text (Text) +import Simplex.Chat.PaymentService (ServicePayment (..), googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) +import Simplex.Messaging.Util (safeDecodeUtf8) +import System.FilePath (()) + +fixtureDir :: FilePath +fixtureDir = "apps" "simplex-badge-service" "test-fixtures" "apple" + +-- | The Apple JWS are the fixtures under no real signature, so they are accepted as these exact +-- strings only, never because a payload decodes - which a real verifier must refuse to do. +data FakeStore = FakeStore + { appleSupporterJWS :: Text, + appleLegendJWS :: Text, + appleSandboxJWS :: Text, + appleThrowingJWS :: Text, + appleMisnamedJWS :: Text, + appleQuantityJWS :: Text, + pendingSettled :: IORef Bool, + googleDown :: IORef Bool, + fakeVerifier :: StoreVerifier + } + +newFakeStore :: IO FakeStore +newFakeStore = do + appleSupporterJWS <- fixtureJWS "transaction-supporter.json" + appleLegendJWS <- fixtureJWS "transaction-legend.json" + appleSandboxJWS <- fixtureJWS "transaction-sandbox.json" + let appleThrowingJWS = unsignedJWS "{\"transactionId\":\"2000000812345679\",\"productId\":\"BADGE_SUPPORTER_01\"}" + appleMisnamedJWS = unsignedJWS "{\"transactionId\":\"2000000812345698\",\"productId\":\"BADGE_SUPPORTER_01\"}" + appleQuantityJWS = unsignedJWS "{\"transactionId\":\"2000000812345697\",\"productId\":\"BADGE_SUPPORTER_01\"}" + pendingSettled <- newIORef False + googleDown <- newIORef False + let appleReceipts = + [ (appleSupporterJWS, appleTransaction "2000000812345671" "BADGE_SUPPORTER_01" False 700), + (appleLegendJWS, appleTransaction "2000000812345672" "BADGE_LEGEND_01" False 7000), + (appleSandboxJWS, appleTransaction "2000000812345673" "BADGE_LEGEND_01" True 7000), + -- a verifier vouching for another transaction than the one the evidence names + (appleMisnamedJWS, appleTransaction "2000000812345671" "BADGE_SUPPORTER_01" False 700), + (appleQuantityJWS, (appleTransaction "2000000812345697" "BADGE_SUPPORTER_01" False 1400) {quantity = 2}) + ] + verifyApple jws + | jws == appleThrowingJWS = error "fake verifier bug" + | otherwise = maybe (Left $ SRInvalid "not a fake receipt") Right $ lookup jws appleReceipts + verifyGoogle = googleVerdict pendingSettled googleDown + pure + FakeStore + { appleSupporterJWS, + appleLegendJWS, + appleSandboxJWS, + appleThrowingJWS, + appleMisnamedJWS, + appleQuantityJWS, + pendingSettled, + googleDown, + fakeVerifier = StoreVerifier {verifyApple = Just verifyApple, verifyGoogle = Just verifyGoogle, acknowledgeGoogle = Nothing, verifyTimeout = 500000} + } + where + fixtureJWS name = unsignedJWS <$> B.readFile (fixtureDir name) + appleTransaction transactionRef productId testPurchase cents = + VerifiedStoreTransaction {transactionRef, productId, quantity = 1, testPurchase, paid = Just (CurrencyAmount cents, "USD"), alreadyAcknowledged = False} + +googleVerdict :: IORef Bool -> IORef Bool -> Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +googleVerdict pendingSettled googleDown productId token = + readIORef googleDown >>= \case + True -> pure $ Left $ SRUnreachable "fake store is down" + False + | (productId, token) == ("badge_supporter_01", googleSupporterToken) -> pure $ Right purchased + | (productId, token) == ("subscr_badge_supporter_01", googleSubscriptionToken) -> pure $ Right purchased + | (productId, token) == ("badge_supporter_01", googlePendingToken) -> do + settled <- readIORef pendingSettled + pure $ if settled then Right purchased else Left SRPending + | token == googleUnreachableToken -> pure $ Left $ SRUnreachable "fake store is down" + | token == googleThrowingToken -> ioError $ userError "fake connection reset" + | token == googleHangingToken -> forever $ threadDelay 1000000 + | otherwise -> pure $ Left $ SRInvalid "not a fake purchase" + where + purchased = VerifiedStoreTransaction {transactionRef = googlePurchaseRef token, productId, quantity = 1, testPurchase = False, paid = Nothing, alreadyAcknowledged = False} + +-- | The fake store, running the hook's action once, inside the first Google verification, before it answers. +googleVerifierWithHook :: IORef (IO ()) -> FakeStore -> StoreVerifier +googleVerifierWithHook hook FakeStore {fakeVerifier = v@StoreVerifier {verifyGoogle}} = + v {verifyGoogle = hooked <$> verifyGoogle, verifyTimeout = 10000000} + where + hooked verify productId token = do + action <- atomicModifyIORef' hook (pure (),) + action + verify productId token + +settlePending :: FakeStore -> IO () +settlePending FakeStore {pendingSettled} = writeIORef pendingSettled True + +setGoogleDown :: FakeStore -> Bool -> IO () +setGoogleDown FakeStore {googleDown} = writeIORef googleDown + +googleSupporterToken, googlePendingToken, googleUnreachableToken, googleThrowingToken, googleHangingToken, googleSubscriptionToken :: Text +googleSupporterToken = "fake-play-token-supporter.AO-J1Oz9x2kqE7wYt3" +googlePendingToken = "fake-play-token-pending.AO-J1Oy8w1jpD6vXs2" +googleUnreachableToken = "fake-play-token-unreachable.AO-J1Ox7v0ioC5uWr1" +googleThrowingToken = "fake-play-token-throwing.AO-J1Ov5t8gmA3sUp9" +googleHangingToken = "fake-play-token-hanging.AO-J1Ou4s7flZ2rTo8" +googleSubscriptionToken = "fake-play-token-subscription.AO-J1Ow6u9hnB4tVq0" + +googlePayment :: Text -> Text -> ServicePayment +googlePayment productId token = SPGoogle {productId, token} + +unsignedJWS :: B.ByteString -> Text +unsignedJWS payload = safeDecodeUtf8 $ B.intercalate "." $ map B64U.encodeUnpadded [header, payload, "not a signature"] + where + header = LB.toStrict $ J.encode $ J.object ["alg" J..= ("ES256" :: Text), "x5c" J..= (["leaf", "intermediate", "root"] :: [Text])] diff --git a/tests/Bots/BadgeService/StoreVerifierTests.hs b/tests/Bots/BadgeService/StoreVerifierTests.hs new file mode 100644 index 0000000000..27e73c7154 --- /dev/null +++ b/tests/Bots/BadgeService/StoreVerifierTests.hs @@ -0,0 +1,592 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Bots.BadgeService.StoreVerifierTests (badgeStoreVerifierTests) where + +import BadgeService.Config (PlayStoreConfig (..), ServiceConfig (..), readServiceConfig) +import BadgeService.Service (ServiceState (..), configureStoreVerifier, newServiceState) +import BadgeService.StoreReceipts +import BadgeService.StoreReceipts.Apple (readAppleRoot, verifyAppleTransaction) +import BadgeService.StoreReceipts.Google (playStoreVerifier) +import Bots.BadgeService.FakePlay +import Control.Concurrent.STM (readTVarIO) +import Control.Monad (forM_) +import Crypto.Hash.Algorithms (SHA256 (..)) +import Crypto.Number.Serialize (i2osp, i2ospOf_) +import qualified Crypto.PubKey.ECC.ECDSA as ECDSA +import qualified Crypto.PubKey.ECC.Generate as ECC +import qualified Crypto.PubKey.ECC.Types as ECC +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Lazy as LB +import Data.Either (isRight) +import Data.Hourglass (Date (..), DateTime (..), Month (..), TimeOfDay (..)) +import Data.List (isInfixOf) +import Data.List.NonEmpty (NonEmpty (..)) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (decodeUtf8) +import Data.X509 +import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionId, googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) +import System.Directory (createDirectoryIfMissing) +import System.FilePath (takeDirectory, ()) +import Test.Hspec +import UnliftIO.Temporary (withTempDirectory) + +badgeStoreVerifierTests :: Spec +badgeStoreVerifierTests = describe "badge store verifiers" $ do + describe "App Store signed transactions" $ do + it "vouches for a transaction a chain to the trusted root signed, naming the transaction it was claimed by" testAppleVerdict + it "verifies a transaction under any of the trusted roots" testAppleAnyTrustedRoot + it "fails, rather than refuses, an intermediate the trusted root did not sign, since the root may be misconfigured" testAppleRootNotTrusted + it "refuses a leaf the intermediate did not sign, though the intermediate is genuine" testAppleForgedLeaf + it "fails, rather than refuses, a leaf signature the library cannot check" testAppleLeafUncheckable + it "refuses a tampered payload" testAppleTamperedPayload + it "fails, rather than refuses, a chain that is not exactly leaf, intermediate and root, a shape Apple may yet adopt" testAppleChainLength + it "fails, rather than refuses, a header that names another algorithm, a shape Apple may yet adopt" testAppleOtherAlgorithm + it "fails, rather than refuses, another app's bundle id, since ours may be misconfigured" testAppleOtherBundle + it "refuses a refunded transaction" testAppleRevoked + it "reports a Sandbox transaction as a test purchase" testAppleSandbox + it "fails, rather than refuses, a chain Apple did not mark for signing receipts" testAppleUnmarkedChain + it "fails, rather than refuses, a payload Apple signed in a shape it does not know" testAppleUnknownShape + it "fails, rather than refuses, a leaf holding a key it cannot read" testAppleLeafKey + it "records the price in the currency's minor units, or not at all" testApplePrice + it "reads the trusted root from a DER file, and reports one it cannot read or that is not a root" testReadAppleRoot + it "has nothing to acknowledge" testAppleNoAcknowledgement + describe "Google Play purchases" $ do + it "vouches for a purchased token, asking Play about that token in this app" testPlayPurchased + it "reports a license tester's purchase as a test purchase, and a promo code's as paid" testPlayTestAndPromo + it "refuses a canceled purchase" testPlayCanceled + it "answers a pending purchase as pending" testPlayPending + it "answers 404, 5xx and every other error but 401 and 403 as unreachable" testPlayErrorStatuses + it "fails on 401 and 403, and asks for a new access token after a 401" testPlayCredentialsRefused + it "fails when the token endpoint refuses the assertion, and is unreachable when it is down" testPlayTokenEndpoint + it "fails on a body it cannot read, another product, or an unknown state" testPlayUnreadable + it "answers a Play that does not answer, or cannot be reached, as unreachable" testPlayHangsOrIsGone + it "reuses its access token while it is valid" testPlayTokenCached + it "asks for a new access token once the one it holds is about to expire" testPlayTokenExpires + it "sends no token that is only dots, and describes a token outside the grammar without quoting it" testPlayTokensNotSent + it "acknowledges a purchase, unless it is acknowledged or consumed already" testPlayAcknowledges + it "reads a purchase once to verify and acknowledge it, and acknowledges only what that read found unacknowledged" testPlayReadsPurchaseOnce + it "reports an acknowledgement Play refuses or does not answer" testPlayAcknowledgementFails + it "acknowledges through the verifier a [google] section configures" testPlayConfiguredAcknowledges + +-- * App Store + +-- TestPurchase is the service's own refusal after a verifier vouched, so no verifier returns it here +data Outcome = Invalid | TestPurchase | Pending | Unreachable | Failed | NotConfigured | Verdict + deriving (Eq, Show) + +outcome :: Either StoreRefusal a -> Outcome +outcome = \case + Left (SRInvalid _) -> Invalid + Left SRTestPurchase -> TestPurchase + Left SRPending -> Pending + Left (SRUnreachable _) -> Unreachable + Left (SRVerifierFailed _) -> Failed + Left SRNotConfigured -> NotConfigured + Right _ -> Verdict + +reason :: Either StoreRefusal a -> Text +reason = \case + Left (SRInvalid r) -> r + Left (SRUnreachable r) -> r + Left (SRVerifierFailed r) -> r + _ -> "" + +seamVerdict :: StoreVerifier -> ServicePayment -> IO (Either StoreRefusal VerifiedStoreTransaction) +seamVerdict verifier payment = case toStoreReceipt verifier payment of + Just (Right StoreReceipt {verifyReceipt}) -> verifyReceipt + Just (Left refusal) -> pure $ Left refusal + Nothing -> expectationFailure "not a store payment" >> undefined + +ourBundleId :: Text +ourBundleId = "chat.simplex.app" + +appleVerdict :: SignedCertificate -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +appleVerdict root = appleVerdictUnder (root :| []) + +appleVerdictUnder :: NonEmpty SignedCertificate -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +appleVerdictUnder roots jws = seamVerdict noStoreVerifier {verifyApple = Just (verifyAppleTransaction roots ourBundleId)} SPApple {jws} + +data TestChain = TestChain + { rootCert :: SignedCertificate, + intermediateCert :: SignedCertificate, + leafCert :: SignedCertificate, + intermediateKey :: ECDSA.PrivateKey, + leafKey :: ECDSA.PrivateKey + } + +appleRootName, otherRootName, intermediateName, leafName :: DistinguishedName +appleRootName = DistinguishedName [([2, 5, 4, 3], "Apple Root CA - G3")] +otherRootName = DistinguishedName [([2, 5, 4, 3], "Some Other Root")] +intermediateName = DistinguishedName [([2, 5, 4, 3], "Apple Worldwide Developer Relations Certification Authority")] +leafName = DistinguishedName [([2, 5, 4, 3], "Prod ECC Mac App Store and iTunes Store Receipt Signing")] + +receiptLeafMarker, intermediateMarker :: [Integer] +receiptLeafMarker = [1, 2, 840, 113635, 100, 6, 11, 1] +intermediateMarker = [1, 2, 840, 113635, 100, 6, 2, 1] + +-- | Shaped as Apple's chain, under a root of the given name; the intermediate and the leaf carry the given markers. +newChain :: DistinguishedName -> [[Integer]] -> [[Integer]] -> IO TestChain +newChain rootName intermediateMarkers leafMarkers = do + (rootKey, rootPub) <- newKeyPair + (intermediateKey, intermediatePub) <- newKeyPair + (leafKey, leafPub) <- newKeyPair + rootCert <- certify rootKey rootName rootName rootPub [] + intermediateCert <- certify rootKey rootName intermediateName intermediatePub intermediateMarkers + leafCert <- certify intermediateKey intermediateName leafName leafPub leafMarkers + pure TestChain {rootCert, intermediateCert, leafCert, intermediateKey, leafKey} + +trustedChain :: IO TestChain +trustedChain = newChain appleRootName [intermediateMarker] [receiptLeafMarker] + +newKeyPair :: IO (ECDSA.PrivateKey, PubKey) +newKeyPair = do + (ECDSA.PublicKey _ q, k) <- ECC.generate p256 + case q of + ECC.Point x y -> pure (k, PubKeyEC $ PubKeyEC_Named ECC.SEC_p256r1 $ SerializedPoint $ B.cons 4 $ i2ospOf_ 32 x <> i2ospOf_ 32 y) + ECC.PointO -> newKeyPair + +p256 :: ECC.Curve +p256 = ECC.getCurveByName ECC.SEC_p256r1 + +certify :: ECDSA.PrivateKey -> DistinguishedName -> DistinguishedName -> PubKey -> [[Integer]] -> IO SignedCertificate +certify = certifyWith $ SignatureALG HashSHA256 PubKeyALG_EC + +certifyWith :: SignatureALG -> ECDSA.PrivateKey -> DistinguishedName -> DistinguishedName -> PubKey -> [[Integer]] -> IO SignedCertificate +certifyWith alg issuerKey issuerName subjectName subjectKey markers = + objectToSignedExactF sign certificate + where + certificate = + Certificate + { certVersion = 2, + certSerial = 1, + certSignatureAlg = alg, + certIssuerDN = issuerName, + certValidity = (DateTime (Date 2020 January 1) (TimeOfDay 0 0 0 0), DateTime (Date 2040 January 1) (TimeOfDay 0 0 0 0)), + certSubjectDN = subjectName, + certPubKey = subjectKey, + certExtensions = Extensions $ Just [ExtensionRaw oid False "\x05\x00" | oid <- markers] + } + sign tbs = (\(ECDSA.Signature r s) -> (derSignature r s, alg)) <$> ECDSA.sign issuerKey SHA256 tbs + derSignature r s = der 0x30 (der 0x02 (unsigned r) <> der 0x02 (unsigned s)) + der tag content = B.pack [tag, fromIntegral (B.length content)] <> content + unsigned n = let bs = i2osp n in if B.head bs >= 0x80 then B.cons 0 bs else bs + +transactionPayload :: [(J.Key, J.Value)] -> [J.Key] -> J.Value +transactionPayload overrides removed = + J.Object $ foldr KM.delete (KM.fromList overrides `KM.union` base) removed + where + base = + KM.fromList + [ ("transactionId", "2000000900000001"), + ("originalTransactionId", "2000000900000001"), + ("bundleId", J.String ourBundleId), + ("productId", "BADGE_SUPPORTER_01"), + ("purchaseDate", J.toJSON (1790000000000 :: Int)), + ("quantity", J.toJSON (1 :: Int)), + ("type", "Consumable"), + ("inAppOwnershipType", "PURCHASED"), + ("signedDate", J.toJSON (1790000001000 :: Int)), + ("environment", "Production"), + ("transactionReason", "PURCHASE"), + ("storefront", "USA"), + ("price", J.toJSON (7000 :: Int)), + ("currency", "USD") + ] + +signedTransaction :: TestChain -> [SignedCertificate] -> J.Value -> J.Value -> IO Text +signedTransaction TestChain {leafKey} x5c header payload = do + let signingInput = segment header' <> "." <> segment payload + header' = case header of + J.Object h -> J.Object $ KM.insert "x5c" (J.toJSON $ map (decodeUtf8 . B64.encode . encodeSignedObject) x5c) h + v -> v + ECDSA.Signature r s <- ECDSA.sign leafKey SHA256 signingInput + pure $ decodeUtf8 $ signingInput <> "." <> B64U.encodeUnpadded (i2ospOf_ 32 r <> i2ospOf_ 32 s) + +segment :: J.Value -> ByteString +segment = B64U.encodeUnpadded . LB.toStrict . J.encode + +es256 :: J.Value +es256 = J.object ["alg" J..= ("ES256" :: Text)] + +signedBy :: TestChain -> J.Value -> IO Text +signedBy chain@TestChain {rootCert, intermediateCert, leafCert} = signedTransaction chain [leafCert, intermediateCert, rootCert] es256 + +testAppleVerdict :: IO () +testAppleVerdict = do + chain <- trustedChain + jws <- signedBy chain $ transactionPayload [] [] + appleVerdict (rootCert chain) jws + `shouldReturn` Right VerifiedStoreTransaction {transactionRef = "2000000900000001", productId = "BADGE_SUPPORTER_01", quantity = 1, testPurchase = False, paid = Just (CurrencyAmount 700, "USD"), alreadyAcknowledged = False} + appleTransactionId jws `shouldBe` Just "2000000900000001" + +testAppleAnyTrustedRoot :: IO () +testAppleAnyTrustedRoot = do + old <- trustedChain + new <- newChain otherRootName [intermediateMarker] [receiptLeafMarker] + forM_ [old, new] $ \chain -> do + jws <- signedBy chain $ transactionPayload [] [] + fmap transactionRef <$> appleVerdictUnder (rootCert old :| [rootCert new]) jws `shouldReturn` Right "2000000900000001" + +testAppleRootNotTrusted :: IO () +testAppleRootNotTrusted = do + trusted <- trustedChain + forM_ [appleRootName, otherRootName] $ \rootName -> do + untrusted <- newChain rootName [intermediateMarker] [receiptLeafMarker] + jws <- signedBy untrusted $ transactionPayload [] [] + r <- appleVerdict (rootCert trusted) jws + outcome r `shouldBe` Failed + reason r `shouldSatisfy` ("configured root" `T.isInfixOf`) + +testAppleForgedLeaf :: IO () +testAppleForgedLeaf = do + chain@TestChain {rootCert, intermediateCert} <- trustedChain + (forgedKey, forgedPub) <- newKeyPair + forgedLeaf <- certify forgedKey intermediateName leafName forgedPub [receiptLeafMarker] + jws <- signedTransaction chain {leafKey = forgedKey} [forgedLeaf, intermediateCert, rootCert] es256 $ transactionPayload [] [] + outcome <$> appleVerdict rootCert jws `shouldReturn` Invalid + +testAppleLeafUncheckable :: IO () +testAppleLeafUncheckable = do + chain@TestChain {rootCert, intermediateCert} <- trustedChain + (otherKey, otherPub) <- newKeyPair + rsaLabelled <- certifyWith (SignatureALG HashSHA256 PubKeyALG_RSA) otherKey intermediateName leafName otherPub [receiptLeafMarker] + jws <- signedTransaction chain {leafKey = otherKey} [rsaLabelled, intermediateCert, rootCert] es256 $ transactionPayload [] [] + r <- appleVerdict rootCert jws + outcome r `shouldBe` Failed + reason r `shouldSatisfy` ("cannot check" `T.isInfixOf`) + +testAppleTamperedPayload :: IO () +testAppleTamperedPayload = do + chain <- trustedChain + jws <- signedBy chain $ transactionPayload [] [] + case T.splitOn "." jws of + [header, _, signature] -> do + let tampered = T.intercalate "." [header, decodeUtf8 $ segment $ transactionPayload [("productId", "BADGE_LEGEND_01")] [], signature] + outcome <$> appleVerdict (rootCert chain) tampered `shouldReturn` Invalid + _ -> expectationFailure "not a JWS" + +testAppleChainLength :: IO () +testAppleChainLength = do + chain@TestChain {rootCert, intermediateCert, leafCert} <- trustedChain + forM_ [[leafCert, rootCert], [leafCert, intermediateCert], [leafCert, intermediateCert, rootCert, rootCert]] $ \x5c -> do + jws <- signedTransaction chain x5c es256 $ transactionPayload [] [] + outcome <$> appleVerdict rootCert jws `shouldReturn` Failed + +testAppleOtherAlgorithm :: IO () +testAppleOtherAlgorithm = do + chain@TestChain {rootCert, intermediateCert, leafCert} <- trustedChain + let x5c = [leafCert, intermediateCert, rootCert] + forM_ ["none", "HS256", "ES384"] $ \alg -> do + jws <- signedTransaction chain x5c (J.object ["alg" J..= (alg :: Text)]) $ transactionPayload [] [] + outcome <$> appleVerdict rootCert jws `shouldReturn` Failed + +testAppleOtherBundle :: IO () +testAppleOtherBundle = do + chain <- trustedChain + jws <- signedBy chain $ transactionPayload [("bundleId", "com.example.other")] [] + r <- appleVerdict (rootCert chain) jws + outcome r `shouldBe` Failed + reason r `shouldSatisfy` ("bundle id" `T.isInfixOf`) + +testAppleRevoked :: IO () +testAppleRevoked = do + chain <- trustedChain + jws <- signedBy chain $ transactionPayload [("revocationDate", J.toJSON (1790000500000 :: Int)), ("revocationReason", J.toJSON (0 :: Int))] [] + outcome <$> appleVerdict (rootCert chain) jws `shouldReturn` Invalid + +testAppleSandbox :: IO () +testAppleSandbox = do + chain <- trustedChain + jws <- signedBy chain $ transactionPayload [("environment", "Sandbox")] [] + fmap testPurchase <$> appleVerdict (rootCert chain) jws `shouldReturn` Right True + +testAppleUnmarkedChain :: IO () +testAppleUnmarkedChain = + forM_ [([intermediateMarker], []), ([], [receiptLeafMarker])] $ \(intermediateMarkers, leafMarkers) -> do + unmarked <- newChain appleRootName intermediateMarkers leafMarkers + jws <- signedBy unmarked $ transactionPayload [] [] + outcome <$> appleVerdict (rootCert unmarked) jws `shouldReturn` Failed + +testAppleUnknownShape :: IO () +testAppleUnknownShape = do + chain <- trustedChain + let failsOn payload = do + jws <- signedBy chain payload + outcome <$> appleVerdict (rootCert chain) jws `shouldReturn` Failed + failsOn $ transactionPayload [("environment", "Staging")] [] + failsOn $ transactionPayload [("environment", "Xcode")] [] + failsOn $ transactionPayload [("environment", "LocalTesting")] [] + failsOn $ transactionPayload [] ["productId"] + failsOn $ transactionPayload [("quantity", "1")] [] + +-- | The key is read before the signature it would check, so the chain and the payload are genuine. +testAppleLeafKey :: IO () +testAppleLeafKey = do + chain@TestChain {rootCert, intermediateCert, intermediateKey} <- trustedChain + let failsOnLeafKey pub = do + leaf <- certify intermediateKey intermediateName leafName pub [receiptLeafMarker] + jws <- signedTransaction chain [leaf, intermediateCert, rootCert] es256 $ transactionPayload [] [] + outcome <$> appleVerdict rootCert jws `shouldReturn` Failed + failsOnLeafKey $ PubKeyEC $ PubKeyEC_Named ECC.SEC_p384r1 $ SerializedPoint $ B.cons 4 $ B.replicate 64 1 + failsOnLeafKey $ PubKeyEC $ PubKeyEC_Named ECC.SEC_p256r1 $ SerializedPoint "\x04" + +testApplePrice :: IO () +testApplePrice = do + chain <- trustedChain + let paidFor fields removed = do + jws <- signedBy chain $ transactionPayload fields removed + fmap paid <$> appleVerdict (rootCert chain) jws + priced price currency = paidFor [("price", J.toJSON (price :: Int)), ("currency", currency)] [] + -- Apple's own examples: $1.99, JPY 300 and KRW 3300 + priced 1990 "USD" `shouldReturn` Right (Just (CurrencyAmount 199, "USD")) + priced 300000 "JPY" `shouldReturn` Right (Just (CurrencyAmount 300, "JPY")) + priced 3300000 "KRW" `shouldReturn` Right (Just (CurrencyAmount 3300, "KRW")) + priced 1234 "KWD" `shouldReturn` Right (Just (CurrencyAmount 1234, "KWD")) + priced 1995 "USD" `shouldReturn` Right Nothing + priced 1000 "CLF" `shouldReturn` Right Nothing + priced 1000 "usd" `shouldReturn` Right Nothing + paidFor [] ["price", "currency"] `shouldReturn` Right Nothing + +testReadAppleRoot :: IO () +testReadAppleRoot = do + TestChain {rootCert, intermediateCert} <- trustedChain + readAppleRoot ("apps" "simplex-badge-service" "test-fixtures" "apple" "AppleRootCA-G3.cer") >>= (`shouldSatisfy` isRight) + createDirectoryIfMissing True "tests/tmp" + withTempDirectory "tests/tmp" "apple-root" $ \d -> do + B.writeFile (d "root.cer") (encodeSignedObject rootCert) + readAppleRoot (d "root.cer") `shouldReturn` Right rootCert + B.writeFile (d "root.pem") "-----BEGIN CERTIFICATE-----\n" + readAppleRoot (d "root.pem") >>= (`shouldSatisfy` either ("not a DER certificate" `isInfixOf`) (const False)) + readAppleRoot (d "absent.cer") >>= (`shouldSatisfy` either ("could not be read" `isInfixOf`) (const False)) + B.writeFile (d "intermediate.cer") (encodeSignedObject intermediateCert) + readAppleRoot (d "intermediate.cer") >>= (`shouldSatisfy` either ("not a self-signed root certificate" `isInfixOf`) (const False)) + +testAppleNoAcknowledgement :: IO () +testAppleNoAcknowledgement = do + chain <- trustedChain + jws <- signedBy chain $ transactionPayload [] [] + withFakePlay $ \fake -> do + verifier <- playVerifier fake + acknowledgement verifier {verifyApple = Just (verifyAppleTransaction (rootCert chain :| []) ourBundleId)} SPApple {jws} `shouldReturn` Nothing + +-- * Google Play + +playVerifier :: FakePlay -> IO StoreVerifier +playVerifier fake = + playStoreVerifier (fpConfig fake) >>= \case + Right (verify, acknowledge) -> pure noStoreVerifier {verifyGoogle = Just verify, acknowledgeGoogle = Just acknowledge, verifyTimeout = 2000000} + Left e -> expectationFailure e >> undefined + +acknowledgement :: StoreVerifier -> ServicePayment -> IO (Maybe (Either StoreRefusal ())) +acknowledgement verifier payment = case toStoreReceipt verifier payment of + Just (Right StoreReceipt {verifyReceipt, acknowledgeReceipt}) -> + verifyReceipt >>= \case + Right tx -> traverse ($ tx) acknowledgeReceipt + Left refusal -> expectationFailure ("not verified: " <> show refusal) >> undefined + _ -> expectationFailure "not a store receipt" >> undefined + +playVerdict :: StoreVerifier -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +playVerdict verifier token = seamVerdict verifier SPGoogle {productId = "badge_supporter_01", token} + +purchaseRecord :: Int -> [(J.Key, J.Value)] -> J.Value +purchaseRecord state fields = + J.Object $ + KM.fromList fields + `KM.union` KM.fromList + [ ("kind", "androidpublisher#productPurchase"), + ("purchaseTimeMillis", "1790000000000"), + ("purchaseState", J.toJSON state), + ("consumptionState", J.toJSON (0 :: Int)), + ("orderId", "GPA.3312-4521-8899-01234"), + ("acknowledgementState", J.toJSON (0 :: Int)), + ("regionCode", "US") + ] + +testToken :: Text +testToken = "fake-play-token.AO-J1Oz9x2kqE7wYt3" + +-- | Every reason reaches the service's log, which must never hold the token. +answeredAs :: StoreVerifier -> Text -> Outcome -> IO () +answeredAs verifier token expected = do + r <- playVerdict verifier token + outcome r `shouldBe` expected + reason r `shouldNotSatisfy` (token `T.isInfixOf`) + +testPlayPurchased :: IO () +testPlayPurchased = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [] + playVerdict verifier testToken + `shouldReturn` Right VerifiedStoreTransaction {transactionRef = googlePurchaseRef testToken, productId = "badge_supporter_01", quantity = 1, testPurchase = False, paid = Nothing, alreadyAcknowledged = False} + readTVarIO (fpPurchasePaths fake) + `shouldReturn` [["androidpublisher", "v3", "applications", fakePackageName, "purchases", "products", "badge_supporter_01", "tokens", testToken]] + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [("quantity", J.toJSON (2 :: Int)), ("productId", "badge_supporter_01")] + fmap quantity <$> playVerdict verifier testToken `shouldReturn` Right 2 + +testPlayTestAndPromo :: IO () +testPlayTestAndPromo = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [("purchaseType", J.toJSON (0 :: Int))] + fmap testPurchase <$> playVerdict verifier testToken `shouldReturn` Right True + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [("purchaseType", J.toJSON (1 :: Int))] + fmap testPurchase <$> playVerdict verifier testToken `shouldReturn` Right False + +testPlayCanceled :: IO () +testPlayCanceled = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 1 [] + answeredAs verifier testToken Invalid + +testPlayPending :: IO () +testPlayPending = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 2 [] + answeredAs verifier testToken Pending + +testPlayErrorStatuses :: IO () +testPlayErrorStatuses = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answeredAs verifier testToken Unreachable + forM_ [400, 404, 410, 429, 500, 503] $ \code -> do + answerPurchase fake testToken $ PlayStatus code + answeredAs verifier testToken Unreachable + +testPlayCredentialsRefused :: IO () +testPlayCredentialsRefused = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayStatus 403 + answeredAs verifier testToken Failed + answerPurchase fake testToken $ PlayStatus 401 + answeredAs verifier testToken Failed + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [] + answeredAs verifier testToken Verdict + length <$> readTVarIO (fpGrantedTokens fake) `shouldReturn` 2 + +testPlayTokenEndpoint :: IO () +testPlayTokenEndpoint = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [] + forM_ [400, 401] $ \code -> do + answerTokenRequests fake $ Just code + answeredAs verifier testToken Failed + forM_ [429, 503] $ \code -> do + answerTokenRequests fake $ Just code + answeredAs verifier testToken Unreachable + answerTokenRequests fake Nothing + answeredAs verifier testToken Verdict + readTVarIO (fpPurchasePaths fake) >>= (`shouldSatisfy` ((== 1) . length)) + +testPlayUnreadable :: IO () +testPlayUnreadable = withFakePlay $ \fake -> do + verifier <- playVerifier fake + forM_ + [ PlayBody "not json", + PlayBody "{\"purchaseState\":\"0\"}", + PlayRecord $ J.object ["kind" J..= ("androidpublisher#productPurchase" :: Text)], + PlayRecord $ purchaseRecord 0 [("productId", "badge_legend_01")], + PlayRecord $ purchaseRecord 3 [], + PlayRecord $ purchaseRecord 0 [("purchaseType", J.toJSON (2 :: Int))], + PlayBody $ J.encode (purchaseRecord 0 []) <> LB.replicate (1024 * 1024) 32 + ] + $ \answer -> do + answerPurchase fake testToken answer + answeredAs verifier testToken Failed + +testPlayHangsOrIsGone :: IO () +testPlayHangsOrIsGone = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken PlayHang + r <- playVerdict verifier testToken + (outcome r, reason r) `shouldBe` (Unreachable, "google verifier timed out") + -- not a stopped stub's port, which a test running alongside may already have been given + gone <- playVerifier fake {fpConfig = (fpConfig fake) {gApiHost = "http://127.0.0.1:1"}} + answeredAs gone testToken Unreachable + +testPlayTokenCached :: IO () +testPlayTokenCached = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [] + answeredAs verifier testToken Verdict + answeredAs verifier testToken Verdict + length <$> readTVarIO (fpGrantedTokens fake) `shouldReturn` 1 + +testPlayTokenExpires :: IO () +testPlayTokenExpires = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [] + grantTokensFor fake 300 + answeredAs verifier testToken Verdict + answeredAs verifier testToken Verdict + length <$> readTVarIO (fpGrantedTokens fake) `shouldReturn` 2 + +testPlayTokensNotSent :: IO () +testPlayTokensNotSent = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answeredAs verifier ".." Unreachable + let outside = "fake/play+token=AO-J1Oz9x2kqE7wYt3" + r <- playVerdict verifier outside + outcome r `shouldBe` Unreachable + reason r `shouldBe` "a Play token this service will not send: 34 characters: lowercase, uppercase, digits, '-', other printable ASCII" + readTVarIO (fpPurchasePaths fake) `shouldReturn` [] + +testPlayAcknowledges :: IO () +testPlayAcknowledges = withFakePlay $ \fake -> do + verifier <- playVerifier fake + let acknowledging = acknowledgement verifier SPGoogle {productId = "badge_supporter_01", token = testToken} + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [] + acknowledging `shouldReturn` Just (Right ()) + readTVarIO (fpAcknowledged fake) `shouldReturn` [testToken] + forM_ ["acknowledgementState", "consumptionState"] $ \state -> do + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [(state, J.toJSON (1 :: Int))] + acknowledging `shouldReturn` Just (Right ()) + readTVarIO (fpAcknowledged fake) `shouldReturn` [testToken] + +testPlayReadsPurchaseOnce :: IO () +testPlayReadsPurchaseOnce = do + requestsFor [] `shouldReturn` (1, [testToken]) + forM_ ["acknowledgementState", "consumptionState"] $ \state -> + requestsFor [(state, J.toJSON (1 :: Int))] `shouldReturn` (1, []) + where + requestsFor fields = withFakePlay $ \fake -> do + verifier <- playVerifier fake + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 fields + acknowledgement verifier SPGoogle {productId = "badge_supporter_01", token = testToken} `shouldReturn` Just (Right ()) + (,) <$> (length <$> readTVarIO (fpPurchasePaths fake)) <*> readTVarIO (fpAcknowledged fake) + +testPlayAcknowledgementFails :: IO () +testPlayAcknowledgementFails = withFakePlay $ \fake -> do + verifier <- playVerifier fake + let acknowledgedAs expected = do + r <- acknowledgement verifier SPGoogle {productId = "badge_supporter_01", token = testToken} + outcome <$> r `shouldBe` Just expected + maybe "" reason r `shouldSatisfy` ("acknowledging: " `T.isPrefixOf`) + maybe "" reason r `shouldNotSatisfy` (testToken `T.isInfixOf`) + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [] + answerAcknowledgements fake $ Just $ PlayStatus 403 + acknowledgedAs Failed + answerAcknowledgements fake $ Just $ PlayStatus 503 + acknowledgedAs Unreachable + answerAcknowledgements fake $ Just PlayHang + acknowledgedAs Unreachable + readTVarIO (fpAcknowledged fake) `shouldReturn` [] + +testPlayConfiguredAcknowledges :: IO () +testPlayConfiguredAcknowledges = withFakePlay $ \fake@FakePlay {fpConfig} -> do + let ini = takeDirectory (gServiceAccountFile fpConfig) "badge_service.ini" + writeFile ini "[listener]\nstatic_dir = /srv/badges\n" + Right cfg <- readServiceConfig ini + ServiceState {storeVerifier} <- newServiceState >>= configureStoreVerifier (Just cfg {playStore = Just fpConfig}) + answerPurchase fake testToken $ PlayRecord $ purchaseRecord 0 [] + acknowledgement storeVerifier SPGoogle {productId = "badge_supporter_01", token = testToken} `shouldReturn` Just (Right ()) + readTVarIO (fpAcknowledged fake) `shouldReturn` [testToken] + diff --git a/tests/Bots/BadgeService/WebTests.hs b/tests/Bots/BadgeService/WebTests.hs index 15203c75ac..65c5cc3017 100644 --- a/tests/Bots/BadgeService/WebTests.hs +++ b/tests/Bots/BadgeService/WebTests.hs @@ -978,8 +978,11 @@ testServiceConfig staticDir trustForwarded = btcpay = Nothing, stripe = Nothing, poll = PollConfig {pWaitingSeconds = 3, pIdleSeconds = 60}, + appleStore = Nothing, + playStore = Nothing, issuer = Nothing, - group = Nothing + group = Nothing, + devAcceptUnverifiedStoreReceipts = False } testServeWebappOff :: IO () diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 58c1c9c51c..dcb3321702 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -305,6 +305,7 @@ chatGroupTests = do it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles it "overflow profile batch is sent without notification flag" testChannelOverflowProfileNotification it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend + it "owner, relay and subscriber store address and peer type of promoted member" testChannelMemberAddressPeerType describe "channel operations" $ do it "should update channel profile (signed)" testChannelUpdateProfileSigned it "should preserve working link after profile update" testChannelLinkAfterProfileUpdate @@ -6306,6 +6307,8 @@ testGroupHistoryGroupLink = bob #> "#team hey!" alice <# "#team bob> hey!" + threadDelay 1000000 + alice ##> "/create link #team" gLink <- getGroupLink alice "team" GRMember True @@ -8439,6 +8442,12 @@ testUpdatedMentionNames = do `shouldBe` "hello @'Alice Jones' @robert" test (mm [("alice", Just "alice"), ("cath", Just "alice")]) "hello @alice @cath" `shouldBe` "hello @alice @alice_1" + it "replace changed names outside code" $ \_ -> do + let t = "- hello\n| x | @bob |\n|---|---|\n```\n@bob\n```" + t' = "- hello\n| x | @robert |\n|---|---|\n```\n@bob\n```" + (mc', ft', _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) (mm [("bob", Just "robert")]) + msgContentText mc' `shouldBe` t' + ft' `shouldBe` parseMaybeMarkdownList t' where test mentionsMap t = let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentionsMap @@ -10232,6 +10241,53 @@ testChannelProfileUpdateNoRePrepend ps = threadDelay 500000 memberIntroducedTo bob "kate" "dan" +testChannelMemberAddressPeerType :: HasCallStack => TestParams -> IO () +testChannelMemberAddressPeerType ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathBotProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + _ <- setupRelay alice bob + alice ##> "/_public group 1 1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"directMessages\": {\"enable\": \"on\"}, \"support\": {\"enable\": \"off\"}}}" + alice <## "group #team is created" + alice <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + alice <## "#team: group link relays updated, current relays:" + alice <## " - relay id 1: active" + alice <## "group link:" + void $ getTermLine alice, + bob <## "#team: you joined the group as relay" + ] + alice ##> "/show link #team" + (shortLink, fullLink) <- getGroupLinks alice "team" GRObserver False + + cath ##> "/ad" + _ <- getContactLinks cath True + cath ##> "/pa on" + cath <## "new contact address set" + + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] + + cath #> "#team hi from cath" + bob <# "#team cath> hi from cath" + alice <# "#team cath> hi from cath [>>]" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi from cath [>>]" + + Just cathAddress <- withCCUser cath $ \User {profile = LocalProfile {contactLink}} -> pure contactLink + mapM (`memberAddressPeerType` "cath") [alice, bob, dan] `shouldReturn` replicate 3 [(Just cathAddress, Just CPTBot)] + where + cathBotProfile = (cathProfile :: Profile) {peerType = Just CPTBot} + +memberAddressPeerType :: TestCC -> T.Text -> IO [(Maybe ConnLinkContact, Maybe ChatPeerType)] +memberAddressPeerType cc name = + withCCTransaction cc $ \db -> + DB.query db "SELECT p.contact_link, p.chat_peer_type FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id WHERE m.local_display_name = ?" (Only name) + testChannelMultiSendersIndependent :: HasCallStack => TestParams -> IO () testChannelMultiSendersIndependent ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 3779006d8d..91ec9a54da 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -32,7 +32,7 @@ import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (ChatBinding (..), ChatMsgEvent (XInfo), ContactShortLinkData (ContactShortLinkData), LinkOwnerSig, MsgChatLink (..), MsgContent (..), encodeChatBinding) import Simplex.Chat.Store.Groups (getGroupMembers) import Simplex.Chat.Store.Shared (createContact, getGroupInfoKeys) -import Simplex.Chat.Types (AgentConnId (..), ConnStatus (..), GroupId, GroupInfo (..), GroupInfoKeys (..), GroupMember (..), Profile (..), GroupRejectionReason (..), fromLocalProfile, profileFromName, userProfileDirect, pattern VersionChat) +import Simplex.Chat.Types (AgentConnId (..), ChatPeerType (..), ConnStatus (..), GroupId, GroupInfo (..), GroupInfoKeys (..), GroupMember (..), Profile (..), GroupRejectionReason (..), fromLocalProfile, profileFromName, userProfileDirect, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) @@ -133,6 +133,8 @@ chatProfileTests = do it "connection incognito unchanged errors" testConnectionIncognitoUnchangedErrors it "set, reset, set connection incognito" testSetResetSetConnectionIncognito it "join group incognito" testJoinGroupIncognito + it "join group incognito as bot" $ testJoinGroupIncognitoPeerType (Just CPTBot) + it "join group incognito as human" $ testJoinGroupIncognitoPeerType Nothing it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito it "deleting contact first, group second deletes incognito profile" testDeleteContactThenGroupDeletesIncognitoProfile @@ -4905,6 +4907,36 @@ testShortLinkConnectPreparedGroupIncognito = testChat3 aliceProfile bobProfile c alice <# "#team cath> 3" bob ?<# "#team cath> 3" +testJoinGroupIncognitoPeerType :: HasCallStack => Maybe ChatPeerType -> TestParams -> IO () +testJoinGroupIncognitoPeerType peerType_ = testChat3 aliceProfile bobProfile {peerType = peerType_} cathProfile test + where + test alice bob cath = do + createGroup2 "team" alice cath + alice ##> "/create link #team" + (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect directly" + groupSLinkData <- getTermLine bob + bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) + bob <## "#team: group is prepared" + bob ##> "/_connect group #1 incognito=on" + bobIncognito <- getTermLine bob + bob <## "#team: connection started incognito" + alice <## (bobIncognito <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), + do + bob <## "#team: joining the group..." + bob <## ("#team: you joined the group incognito as " <> bobIncognito) + bob <## "#team: member cath (Catherine) is connected", + do + cath <## ("#team: alice added " <> bobIncognito <> " to the group (connecting...)") + cath <## ("#team: new member " <> bobIncognito <> " is connected") + ] + forM_ [alice, cath] $ \cc -> do + GroupMember {memberProfile} <- getTestMember cc "team" (T.pack bobIncognito) + peerType (fromLocalProfile memberProfile) `shouldBe` peerType_ + testShortLinkChangePreparedGroupUser :: HasCallStack => TestParams -> IO () testShortLinkChangePreparedGroupUser = testChat3 aliceProfile bobProfile cathProfile test where diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index bbdf8c14b0..e7948ac190 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -38,7 +38,13 @@ userJSON :: LB.ByteString userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false}" parsedMarkdownSwift :: LB.ByteString -parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{\"singleStar\":true}},\"text\":\"hello\"}]}" parsedMarkdownTagged :: LB.ByteString -parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\",\"singleStar\":true},\"text\":\"hello\"}]}" + +markdownBlocksSwift :: LB.ByteString +markdownBlocksSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"headingMark\":{\"level\":2}}}},\"text\":\"## \"},{\"text\":\"a\"},{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"lineBreak\":{}}}},\"text\":\"\\n\"},{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"listMark\":{}}}},\"text\":\"- \"},{\"format\":{\"_owsf\":true,\"bold\":{\"singleStar\":true}},\"text\":\"b\"}],\"markdownBlocks\":[{\"_owsf\":true,\"heading\":{\"level\":2,\"content\":{\"from\":1,\"to\":2}}},{\"_owsf\":true,\"list\":{\"items\":[{\"content\":{\"from\":4,\"to\":5},\"items\":[]}]}}]}" + +markdownBlocksTagged :: LB.ByteString +markdownBlocksTagged = "{\"formattedText\":[{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"headingMark\",\"level\":2}},\"text\":\"## \"},{\"text\":\"a\"},{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"lineBreak\"}},\"text\":\"\\n\"},{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"listMark\"}},\"text\":\"- \"},{\"format\":{\"type\":\"bold\",\"singleStar\":true},\"text\":\"b\"}],\"markdownBlocks\":[{\"type\":\"heading\",\"level\":2,\"content\":{\"from\":1,\"to\":2}},{\"type\":\"list\",\"items\":[{\"content\":{\"from\":4,\"to\":5},\"items\":[]}]}]}" diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 52d722d26a..4f37e507f7 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} module JSONTests where @@ -13,7 +14,9 @@ import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import JSONFixtures +import Simplex.Chat.Markdown (Format (Bold), MarkdownBlock (UnknownBlock), ParsedMarkdown (..), parsedMarkdown) import Simplex.Chat.Remote.Protocol (owsf2tagged) +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Parsers import Test.Hspec import Test.Hspec.QuickCheck (modifyMaxSuccess) @@ -27,6 +30,7 @@ owsf2TaggedJSONTest = do chatStartedSwift `to` chatStartedTagged connectionsDiffSwift `to` connectionsDiffTagged parsedMarkdownSwift `to` parsedMarkdownTagged + markdownBlocksSwift `to` markdownBlocksTagged where to :: LB.ByteString -> LB.ByteString -> IO () owsf `to` tagged = @@ -77,7 +81,23 @@ owsf2TaggedSomeTypeTests = modifyMaxSuccess (const 10000) $ do it "should convert to tagged via TH encoding" $ property $ \x -> (join . fmap (JT.parseMaybe thParseJSON . owsf2tagged) . J.decode . toLazyByteString . J.fromEncoding . thToEncoding) x == Just (x :: SomeType) +markdownBlocksJSONTests :: Spec +markdownBlocksJSONTests = do + it "should encode tokens and blocks" $ + J.encode (parsedMarkdown "## a\n- *b*") `shouldBe` markdownBlocksTagged + it "should decode tokens and blocks of each kind" $ do + let ParsedMarkdown ft blocks = parsedMarkdown "# a #\nb\n===\n- *c*\n 1. d\n> e\n\n| x | y |\n|:-|-:|\n| 1 |\n```bash\ncode\n```\n---\ne \nf\\\ng\n- \\*h\n i" + J.eitherDecode (J.encode ft) `shouldBe` Right ft + J.eitherDecode (J.encode blocks) `shouldBe` Right blocks + it "should decode an unknown block" $ + J.eitherDecode "{\"type\":\"future\"}" `shouldBe` Right (UnknownBlock $ J.object ["type" J..= ("future" :: String)]) + it "should decode bold without singleStar" $ do + J.eitherDecode "{\"type\":\"bold\"}" `shouldBe` Right (Bold $ BoolDef False) + (JT.parseEither J.parseJSON . owsf2tagged =<< J.eitherDecode "{\"_owsf\":true,\"bold\":{}}") `shouldBe` Right (Bold $ BoolDef False) + jsonTests :: Spec -jsonTests = describe "owsf2tagged" $ do - it "should convert chat types" owsf2TaggedJSONTest - describe "SomeType" owsf2TaggedSomeTypeTests +jsonTests = do + describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests + describe "Markdown blocks" markdownBlocksJSONTests diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 903e3b3773..9c0de7e6e2 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -1,15 +1,22 @@ {-# LANGUAGE BlockArguments #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} module MarkdownTests where +import Control.Applicative ((<|>)) +import Control.Monad (forM_) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Agent.Protocol (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$$>)) @@ -32,6 +39,21 @@ markdownTests = do textWithCommands textWithSimplexNames multilineMarkdownList + inlineMarkdownGFM + backslashEscapes + textsWithoutBlocks + blockMarkdown + breaksInBlocks + headings + thematicBreaks + lists + quotes + codeBlocks + tables + formatsAcrossLines + liveMessages + markdownConsumers + linearTimeParsing testSanitizeUri infixr 1 ==>, <==, <==>, ==>>, <<==, <<==>> @@ -54,8 +76,17 @@ s <<== ft = T.concat (map markdownText ft) `shouldBe` s (<<==>>) :: Text -> MarkdownList -> Expectation s <<==>> ft = (s ==>> ft) >> (s <<== ft) +boldF :: Format +boldF = Bold $ BoolDef True + +bold2F :: Format +bold2F = Bold $ BoolDef False + bold :: Text -> Markdown -bold = markdown Bold +bold = markdown boldF + +bold2 :: Text -> Markdown +bold2 = markdown bold2F textFormat :: Spec textFormat = describe "text format (bold)" do @@ -76,17 +107,17 @@ textFormat = describe "text format (bold)" do <==> "this is " <> bold "bold" <> " " it "correct markdown with double asterisk" do "this is **bold formatted** text" - ==> "this is " <> bold "bold formatted" <> " text" + <==> "this is " <> bold2 "bold formatted" <> " text" "**bold formatted** text" - ==> bold "bold formatted" <> " text" + <==> bold2 "bold formatted" <> " text" "this is **bold**" - ==> "this is " <> bold "bold" + <==> "this is " <> bold2 "bold" " **bold** text" - ==> " " <> bold "bold" <> " text" + <==> " " <> bold2 "bold" <> " text" "this is **bold** " - ==> "this is " <> bold "bold" <> " " + <==> "this is " <> bold2 "bold" <> " " "this is **bold** " - ==> "this is " <> bold "bold" <> " " + <==> "this is " <> bold2 "bold" <> " " it "ignored as markdown" do "this is * unformatted * text" <==> "this is * unformatted * text" @@ -478,7 +509,576 @@ multilineMarkdownList = describe "multiline markdown" do it "command markdown" do "/link 1" <<==>> [command' "link 1" "/link 1"] " /link 1" <<==>> [command' "link 1" " /link 1"] - "*0*\n/7 /+" <<==>> [FormattedText (Just Bold) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] + "*0*\n/7 /+" <<==>> [FormattedText (Just boldF) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] + +inlineMarkdownGFM :: Spec +inlineMarkdownGFM = describe "inline markdown of GFM" do + it "strike-through and bold with double delimiters" do + "~~struck~~ text" ==> markdown StrikeThrough "struck" <> " text" + "~struck~ text" <==> markdown StrikeThrough "struck" <> " text" + "__bold__ text" ==> bold2 "bold" <> " text" + "**a**b" <==> bold2 "a" <> "b" + "~~ a~~" ==> "~~ a~~" + "__ a__" ==> "__ a__" + "a__b__" ==> "a__b__" + markdownText (fmt boldF "a") `shouldBe` "*a*" + markdownText (fmt bold2F "a") `shouldBe` "**a**" + it "code spans" do + "`` x ` y ``" ==> markdown Snippet "x ` y" + "``x ` y``" <== markdown Snippet "x ` y" + "` a `" ==> markdown Snippet "a" + "` a`" <==> markdown Snippet " a" + "` `" <==> markdown Snippet " " + "` a `" <==> markdown Snippet " a " + "``a``" ==> markdown Snippet "a" + "```a```" ==> markdown Snippet "a" + "``` a ```" ==> markdown Snippet "a" + "`` a`" <==> "`` a`" + "`a``b`" ==> markdown Snippet "a``b" + "```a``b```" <== markdown Snippet "a``b" + markdownText (fmt Snippet "`a") `shouldBe` "`` `a ``" + it "autolinks" do + "" <==> web "https://simplex.chat" "https://simplex.chat" "" + "mail " <==> "mail " <> web "chat@simplex.chat" "mailto:chat@simplex.chat" "" + "" <==> "" + " "" <==> "" + +backslashEscapes :: Spec +backslashEscapes = describe "backslash escapes" do + it "an escaped ASCII punctuation character is text" do + "\\*not bold*" <==> markdown Escape "\\" <> "*not bold*" + "a\\_b_" <==> "a" <> markdown Escape "\\" <> "_b_" + "\\\\" <==> markdown Escape "\\" <> "\\" + "\\`a`" <==> markdown Escape "\\" <> "`a`" + it "a backslash before other characters is text" do + "\\a" <==> "\\a" + "\\ a" <==> "\\ a" + "a\\" <==> "a\\" + "C:\\dir\\file.txt" <==> "C:\\dir\\file.txt" + it "escapes are text in code" do + "`\\*`" <==> markdown Snippet "\\*" + "```\n\\*a\n```" <===> ([fence "```", hardBr, "\\*a", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + it "escaped block syntax is text" do + "\\# a\n- b" <===> ([fmt Escape "\\", "# a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "\\- a\n- b" <===> ([fmt Escape "\\", "- a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "\\> a\n- b" <===> ([fmt Escape "\\", "> a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "1\\. a\n- b" <===> (["1", fmt Escape "\\", ". a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + it "escapes are kept in texts without blocks" do + "\\*a*" <<==>> [fmt Escape "\\", "*a*"] + "\\*a*\nb" <<==>> [fmt Escape "\\", "*a*\nb"] + +textsWithoutBlocks :: Spec +textsWithoutBlocks = describe "texts without blocks, as on master" do + it "no format" do + parseMaybeMarkdownList "a\nb" `shouldBe` Nothing + parseMaybeMarkdownList "a\n\nb" `shouldBe` Nothing + parseMaybeMarkdownList "a\n" `shouldBe` Nothing + parseMaybeMarkdownList "a \nb" `shouldBe` Nothing + parseMaybeMarkdownList "\n\na" `shouldBe` Nothing + it "line breaks are text" do + "*a*\n\nb" <<==>> [fmt boldF "a", "\n\nb"] + "*a*\nb" <<==>> [fmt boldF "a", "\nb"] + "*a*\r\nb" <<==>> [fmt boldF "a", "\r\nb"] + "*a* \nb" <<==>> [fmt boldF "a", " \nb"] + "*a*\\\nb" <<==>> [fmt boldF "a", "\\\nb"] + " a\n*b*" <<==>> [" a\n", fmt boldF "b"] + "\n\n*a*" <<==>> ["\n\n", fmt boldF "a"] + it "the final line break is removed" do + "*a*\n" ==>> [fmt boldF "a"] + "*a*\r\n" ==>> [fmt boldF "a"] + "*a*\n\n" ==>> [fmt boldF "a", "\n"] + +infix 1 ===>, <===> + +(===>) :: Text -> (MarkdownList, [MarkdownBlock]) -> Expectation +s ===> (ft, blocks) = do + let ParsedMarkdown ft' blocks' = parsedMarkdown s + (ft', blocks') `shouldBe` (Just ft, L.nonEmpty blocks) + rangesCoverContent ft blocks + +(<===>) :: Text -> (MarkdownList, [MarkdownBlock]) -> Expectation +s <===> parsed@(ft, _) = (s ===> parsed) >> (withoutFinalBreak <<== ft) + where + withoutFinalBreak = fromMaybe s $ T.stripSuffix "\r\n" s <|> T.stripSuffix "\n" s + +noBlocks :: Text -> Expectation +noBlocks s = let ParsedMarkdown _ blocks = parsedMarkdown s in blocks `shouldBe` Nothing + +rangesCoverContent :: MarkdownList -> [MarkdownBlock] -> Expectation +rangesCoverContent ft blocks = do + ranges `shouldSatisfy` all (\(SegmentRange a b) -> 0 <= a && a <= b && b <= length ft) + zip ranges (drop 1 ranges) `shouldSatisfy` all (\(SegmentRange _ b, SegmentRange a _) -> b <= a) + [i | SegmentRange a b <- ranges, i <- [a .. b - 1], i `elem` contentIndices] `shouldBe` contentIndices + where + ranges = concatMap blockRanges blocks + contentIndices = [i | (i, FormattedText f _) <- zip [0 ..] ft, not (isMarkup f)] + blockRanges = \case + Paragraph r -> [r] + Heading _ r -> [r] + CodeBlock _ r -> [r] + Quote r -> [r] + List entries -> concatMap entryRanges entries + Table _ headerCells bodyRows -> headerCells <> concat bodyRows + Rule r -> [r] + UnknownBlock _ -> [] + entryRanges (ListEntry _ r children) = r : concatMap entryRanges children + +fmt :: Format -> Text -> FormattedText +fmt = FormattedText . Just + +mk :: BlockToken -> Text -> FormattedText +mk = markupText + +lineBr :: FormattedText +lineBr = mk LineBreak "\n" + +softBr :: FormattedText +softBr = mk SoftBreak "\n" + +hardBr :: FormattedText +hardBr = mk HardBreak "\n" + +heading1 :: FormattedText +heading1 = mk (HeadingMark 1) "# " + +bullet :: Text -> FormattedText +bullet = mk (ListMark Nothing) + +numbered :: Text -> FormattedText +numbered n = mk (ListMark $ Just n) (n <> " ") + +quoteMark :: Text -> FormattedText +quoteMark = mk QuoteMark + +fence :: Text -> FormattedText +fence = mk (FenceMark Nothing) + +rng :: Int -> Int -> SegmentRange +rng = SegmentRange + +entry :: Int -> Int -> [ListEntry] -> ListEntry +entry a b = ListEntry Nothing (rng a b) + +blockMarkdown :: Spec +blockMarkdown = describe "block markdown" do + it "blocks and their ranges" do + "# Plan\n- buy *milk*\n and eggs\n - nested\n\n> a\n> b" + <===> ( [ mk (HeadingMark 1) "# ", + "Plan", + mk LineBreak "\n", + mk (ListMark Nothing) "- ", + "buy ", + FormattedText (Just boldF) "milk", + mk SoftBreak "\n", + mk Indent " ", + "and eggs", + mk LineBreak "\n", + mk (ListMark Nothing) " - ", + "nested", + mk (ParagraphBreak 2) "\n\n", + mk QuoteMark "> ", + "a", + mk SoftBreak "\n", + mk QuoteMark "> ", + "b" + ], + [Heading 1 (rng 1 2), List [entry 4 9 [entry 11 12 []]], Quote (rng 14 18)] + ) + +breaksInBlocks :: Spec +breaksInBlocks = describe "line breaks in texts with blocks" do + it "soft breaks in paragraphs, list items and quotes" do + "a\nb\nc\n# d" <===> (["a", softBr, "b", softBr, "c", lineBr, heading1, "d"], [Paragraph (rng 0 5), Heading 1 (rng 7 8)]) + " a\n b\n# c" <===> ([" a", softBr, " b", lineBr, heading1, "c"], [Paragraph (rng 0 3), Heading 1 (rng 5 6)]) + "- a\nb" <===> ([bullet "- ", "a", softBr, "b"], [List [entry 1 4 []]]) + "> a\nb" <===> ([quoteMark "> ", "a", softBr, "b"], [Quote (rng 1 4)]) + it "hard breaks by two or more spaces and by a backslash" do + "a \nb\n- c" <===> (["a", mk HardBreak " \n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a \nb\n- c" <===> (["a", mk HardBreak " \n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\nb\n- c" <===> (["a", mk HardBreak "\\\n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\\\\\nb\n- c" <===> (["a", fmt Escape "\\", "\\", mk HardBreak "\\\n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 5), List [entry 7 8 []]]) + "- a \n b" <===> ([bullet "- ", "a", mk HardBreak " \n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\\\n b" <===> ([bullet "- ", "a", mk HardBreak "\\\n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "> a \n> b" <===> ([quoteMark "> ", "a", mk HardBreak " \n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + "> a\\\n> b" <===> ([quoteMark "> ", "a", mk HardBreak "\\\n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + it "spaces and backslashes that are content" do + "a \nb\n- c" <===> (["a ", softBr, "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\\\nb\n- c" <===> (["a", fmt Escape "\\", "\\", softBr, "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 5), List [entry 7 8 []]]) + "a \n\n- c" <===> (["a ", mk (ParagraphBreak 2) "\n\n", bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "a\\\n\n- c" <===> (["a\\", mk (ParagraphBreak 2) "\n\n", bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "a \n- c" <===> (["a ", lineBr, bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "# h\na " <===> ([heading1, "h", lineBr, "a "], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# h\na\\" <===> ([heading1, "h", lineBr, "a\\"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + it "line and paragraph breaks between blocks" do + "## a\nb" <===> ([mk (HeadingMark 2) "## ", "a", lineBr, "b"], [Heading 2 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n\nb" <===> ([heading1, "a", mk (ParagraphBreak 2) "\n\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n\n\nb" <===> ([heading1, "a", mk (ParagraphBreak 3) "\n\n\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n \n\t\nb" <===> ([heading1, "a", mk (ParagraphBreak 3) "\n \n\t\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + it "blank lines at the text start and end" do + "\n\n# a" <===> ([mk (ParagraphBreak 2) "\n\n", heading1, "a"], [Heading 1 (rng 2 3)]) + "\n \n# a" <===> ([mk (ParagraphBreak 2) "\n \n", heading1, "a"], [Heading 1 (rng 2 3)]) + "# a\n\n" <===> ([heading1, "a", lineBr], [Heading 1 (rng 1 2)]) + "# a\n\n\n" <===> ([heading1, "a", mk (ParagraphBreak 2) "\n\n"], [Heading 1 (rng 1 2)]) + "# a\n " <===> ([heading1, "a", mk (ParagraphBreak 1) "\n "], [Heading 1 (rng 1 2)]) + it "CR before line breaks" do + "a\r\nb\r\n- c" <===> (["a", mk SoftBreak "\r\n", "b", mk LineBreak "\r\n", bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a \r\nb\r\n- c" <===> (["a", mk HardBreak " \r\n", "b", mk LineBreak "\r\n", bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "# a\r\n- b" <===> ([heading1, "a", mk LineBreak "\r\n", bullet "- ", "b"], [Heading 1 (rng 1 2), List [entry 4 5 []]]) + "# a\r\n\r\nb" <===> ([heading1, "a", mk (ParagraphBreak 2) "\r\n\r\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "- a\r\n b" <===> ([bullet "- ", "a", mk SoftBreak "\r\n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "> a\r\n> b" <===> ([quoteMark "> ", "a", mk SoftBreak "\r\n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + "a\r\n===" <===> (["a", mk HardBreak "\r\n", mk (HeadingUnderline 1) "==="], [Heading 1 (rng 0 1)]) + "```\r\na\r\n```" <===> ([fence "```", mk HardBreak "\r\n", "a", mk HardBreak "\r\n", fence "```"], [CodeBlock Nothing (rng 2 3)]) + "|a|\r\n|-|\r\n|b|" <===> ([mk RowMark "|", "a", mk CellMark "|", mk HardBreak "\r\n", mk (DelimiterRow [TANone]) "|-|", mk HardBreak "\r\n", mk RowMark "|", "b", mk CellMark "|"], [Table [TANone] [rng 1 2] [[rng 7 8]]]) + +headings :: Spec +headings = describe "headings" do + it "levels 1 to 6" do + forM_ ([1 .. 6] :: [Int]) $ \n -> do + let hashes = T.replicate n "#" + (hashes <> " a") <===> ([mk (HeadingMark n) (hashes <> " "), "a"], [Heading n (rng 1 2)]) + noBlocks "####### a" + it "a space and text after the marker" do + noBlocks "#" + noBlocks "# " + noBlocks "#a" + noBlocks "#\ta" + noBlocks "#\tx#" + "#\n- b" <===> (["#", lineBr, bullet "- ", "b"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + it "indentation and inline content" do + " # a" <===> ([mk (HeadingMark 1) " # ", "a"], [Heading 1 (rng 1 2)]) + "# *a* b" <===> ([heading1, fmt boldF "a", " b"], [Heading 1 (rng 1 3)]) + it "closing sequences" do + "# a #" <===> ([heading1, "a", mk HeadingClose " #"], [Heading 1 (rng 1 2)]) + "# a ## " <===> ([heading1, "a", mk HeadingClose " ## "], [Heading 1 (rng 1 2)]) + "# a #\nb" <===> ([heading1, "a", mk HeadingClose " #", lineBr, "b"], [Heading 1 (rng 1 2), Paragraph (rng 4 5)]) + "# a#" <===> ([heading1, "a#"], [Heading 1 (rng 1 2)]) + "# a \\#" <===> ([heading1, "a ", fmt Escape "\\", "#"], [Heading 1 (rng 1 4)]) + it "one line, after and before other blocks" do + "a\n# b" <===> (["a", lineBr, heading1, "b"], [Paragraph (rng 0 1), Heading 1 (rng 3 4)]) + "# a\n# b" <===> ([heading1, "a", lineBr, heading1, "b"], [Heading 1 (rng 1 2), Heading 1 (rng 4 5)]) + it "setext headings" do + "a\n===" <===> (["a", hardBr, mk (HeadingUnderline 1) "==="], [Heading 1 (rng 0 1)]) + "a\n---" <===> (["a", hardBr, mk (HeadingUnderline 2) "---"], [Heading 2 (rng 0 1)]) + "a\n-" <===> (["a", hardBr, mk (HeadingUnderline 2) "-"], [Heading 2 (rng 0 1)]) + "a\nb\n=" <===> (["a", softBr, "b", hardBr, mk (HeadingUnderline 1) "="], [Heading 1 (rng 0 3)]) + "a\nb\n---" <===> (["a", softBr, "b", hardBr, mk (HeadingUnderline 2) "---"], [Heading 2 (rng 0 3)]) + "a\n === " <===> (["a", hardBr, mk (HeadingUnderline 1) " === "], [Heading 1 (rng 0 1)]) + "a\n---\nb" <===> (["a", hardBr, mk (HeadingUnderline 2) "---", lineBr, "b"], [Heading 2 (rng 0 1), Paragraph (rng 4 5)]) + it "underlines only under paragraphs" do + "a\n- - -" <===> (["a", lineBr, mk RuleMark "- - -"], [Paragraph (rng 0 1), Rule (rng 2 3)]) + "a\n***" <===> (["a", lineBr, mk RuleMark "***"], [Paragraph (rng 0 1), Rule (rng 2 3)]) + "# a\n===" <===> ([heading1, "a", lineBr, "==="], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "> a\n===" <===> ([quoteMark "> ", "a", softBr, "==="], [Quote (rng 1 4)]) + "- a\n===" <===> ([bullet "- ", "a", softBr, "==="], [List [entry 1 4 []]]) + noBlocks "a\n= =" + noBlocks "a\n\n===" + noBlocks "===" + noBlocks "===\na" + +thematicBreaks :: Spec +thematicBreaks = describe "thematic breaks" do + it "3 or more of one character, with spaces between and around" do + forM_ (["---", "***", "___", "- - -", "* * *", "_ _ _", "-- -", " ***", "*** ", "----------"] :: [Text]) $ \r -> + r <===> ([mk RuleMark r], [Rule (rng 0 1)]) + it "other lines are text" do + noBlocks "--" + noBlocks "*-*" + noBlocks "---x" + noBlocks "-- a" + it "between other blocks" do + "a\n***\nb" <===> (["a", lineBr, mk RuleMark "***", lineBr, "b"], [Paragraph (rng 0 1), Rule (rng 2 3), Paragraph (rng 4 5)]) + "- a\n---" <===> ([bullet "- ", "a", lineBr, mk RuleMark "---"], [List [entry 1 2 []], Rule (rng 3 4)]) + +lists :: Spec +lists = describe "lists" do + it "bullet markers" do + forM_ (["- ", "* ", "+ "] :: [Text]) $ \m -> (m <> "a") <===> ([bullet m, "a"], [List [entry 1 2 []]]) + "- a\n* b\n+ c" <===> ([bullet "- ", "a", lineBr, bullet "* ", "b", lineBr, bullet "+ ", "c"], [List [entry 1 2 [], entry 4 5 [], entry 7 8 []]]) + it "numbers as written" do + "1. a\n2) b" <===> ([numbered "1.", "a", lineBr, numbered "2)", "b"], [List [ListEntry (Just "1.") (rng 1 2) [], ListEntry (Just "2)") (rng 4 5) []]]) + "3. a\n7. b" <===> ([numbered "3.", "a", lineBr, numbered "7.", "b"], [List [ListEntry (Just "3.") (rng 1 2) [], ListEntry (Just "7.") (rng 4 5) []]]) + "123456789. a" <===> ([numbered "123456789.", "a"], [List [ListEntry (Just "123456789.") (rng 1 2) []]]) + it "markers without a space and text are text" do + noBlocks "-a" + noBlocks "- " + noBlocks "1.a" + noBlocks "1. " + noBlocks "1234567890. a" + noBlocks "a. b" + it "nesting by the column of the marker" do + "- a\n - b" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "1. a\n - b" <===> ([numbered "1.", "a", lineBr, bullet " - ", "b"], [List [ListEntry (Just "1.") (rng 1 2) [entry 4 5 []]]]) + "- a\n - b" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "- a\n\t- b" <===> ([bullet "- ", "a", lineBr, bullet "\t- ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "- a\n - b\n- c" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b", lineBr, bullet "- ", "c"], [List [entry 1 2 [entry 4 5 []], entry 7 8 []]]) + "- a\n - b\n - c" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b", lineBr, bullet " - ", "c"], [List [entry 1 2 [entry 4 5 [], entry 7 8 []]]]) + " - a\n- b" <===> ([bullet " - ", "a", lineBr, bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + it "depth at most 7" do + let ks = [1 .. 8] :: [Int] + marker k = T.replicate (2 * (k - 1)) " " <> "- " + e k = entry (3 * k - 2) (3 * k - 1) + T.intercalate "\n" [marker k <> T.pack (show k) | k <- ks] + <===> ( intercalate [lineBr] [[bullet (marker k), FormattedText Nothing (T.pack $ show k)] | k <- ks], + [List [e 1 [e 2 [e 3 [e 4 [e 5 [e 6 [e 7 [], e 8 []]]]]]]]] + ) + it "continuation lines" do + "- a\n b" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\n b" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\n\tb" <===> ([bullet "- ", "a", softBr, mk Indent "\t", "b"], [List [entry 1 5 []]]) + "- a\nb" <===> ([bullet "- ", "a", softBr, "b"], [List [entry 1 4 []]]) + "- a\n b\n - c" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b", lineBr, bullet " - ", "c"], [List [entry 1 5 [entry 7 8 []]]]) + "- *a* b" <===> ([bullet "- ", fmt boldF "a", " b"], [List [entry 1 3 []]]) + it "blank lines between items" do + "- a\n\n- b" <===> ([bullet "- ", "a", mk (ParagraphBreak 2) "\n\n", bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + "- a\n\n\n- b" <===> ([bullet "- ", "a", mk (ParagraphBreak 3) "\n\n\n", bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + it "lists and other blocks" do + "- a\n\nb" <===> ([bullet "- ", "a", mk (ParagraphBreak 2) "\n\n", "b"], [List [entry 1 2 []], Paragraph (rng 3 4)]) + "a\n- b" <===> (["a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "# a\n- b" <===> ([heading1, "a", lineBr, bullet "- ", "b"], [Heading 1 (rng 1 2), List [entry 4 5 []]]) + "- a\n# b" <===> ([bullet "- ", "a", lineBr, heading1, "b"], [List [entry 1 2 []], Heading 1 (rng 4 5)]) + "- a\n> b" <===> ([bullet "- ", "a", lineBr, quoteMark "> ", "b"], [List [entry 1 2 []], Quote (rng 4 5)]) + "- a\n# h\n- b" <===> ([bullet "- ", "a", lineBr, heading1, "h", lineBr, bullet "- ", "b"], [List [entry 1 2 []], Heading 1 (rng 4 5), List [entry 7 8 []]]) + +quotes :: Spec +quotes = describe "quotes" do + it "quote marks" do + "> a" <===> ([quoteMark "> ", "a"], [Quote (rng 1 2)]) + ">a" <===> ([quoteMark ">", "a"], [Quote (rng 1 2)]) + ">\ta" <===> ([quoteMark ">\t", "a"], [Quote (rng 1 2)]) + "> a" <===> ([quoteMark "> ", " a"], [Quote (rng 1 2)]) + " > a" <===> ([quoteMark " > ", "a"], [Quote (rng 1 2)]) + ">a\n> b" <===> ([quoteMark ">", "a", softBr, quoteMark "> ", "b"], [Quote (rng 1 5)]) + it "quote marks without text are text" do + noBlocks ">" + noBlocks "> " + noBlocks ">\n>" + it "block syntax inside a quote is text" do + "> > b\nc" <===> ([quoteMark "> ", "> b", softBr, "c"], [Quote (rng 1 4)]) + "> # a" <===> ([quoteMark "> ", "# a"], [Quote (rng 1 2)]) + "> - a" <===> ([quoteMark "> ", "- a"], [Quote (rng 1 2)]) + it "blank quote lines" do + "> a\n>\n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Quote (rng 1 7)]) + "> a\n>\n>\n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Quote (rng 1 9)]) + "> a\n> \n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark "> ", hardBr, quoteMark "> ", "b"], [Quote (rng 1 7)]) + ">\n> a\n>" <===> ([quoteMark ">", hardBr, quoteMark "> ", "a", hardBr, quoteMark ">"], [Quote (rng 3 4)]) + it "lines without quote marks after a line with text" do + "> a\nb" <===> ([quoteMark "> ", "a", softBr, "b"], [Quote (rng 1 4)]) + "> a\nb\n> c" <===> ([quoteMark "> ", "a", softBr, "b", softBr, quoteMark "> ", "c"], [Quote (rng 1 7)]) + "> a\nb\n>\n> c" <===> ([quoteMark "> ", "a", softBr, "b", hardBr, quoteMark ">", hardBr, quoteMark "> ", "c"], [Quote (rng 1 9)]) + "> a\n>\nb" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", lineBr, "b"], [Quote (rng 1 2), Paragraph (rng 5 6)]) + it "quotes and other blocks" do + "> a\n\nb" <===> ([quoteMark "> ", "a", mk (ParagraphBreak 2) "\n\n", "b"], [Quote (rng 1 2), Paragraph (rng 3 4)]) + "> a\n\n> b" <===> ([quoteMark "> ", "a", mk (ParagraphBreak 2) "\n\n", quoteMark "> ", "b"], [Quote (rng 1 2), Quote (rng 4 5)]) + "> a\n- b" <===> ([quoteMark "> ", "a", lineBr, bullet "- ", "b"], [Quote (rng 1 2), List [entry 4 5 []]]) + "> a\n# b" <===> ([quoteMark "> ", "a", lineBr, heading1, "b"], [Quote (rng 1 2), Heading 1 (rng 4 5)]) + "> a\nb | c\n-|-" + <===> ( [quoteMark "> ", "a", lineBr, mk RowMark "", "b", mk CellMark " | ", "c", mk CellMark "", hardBr, mk (DelimiterRow [TANone, TANone]) "-|-"], + [Quote (rng 1 2), Table [TANone, TANone] [rng 4 5, rng 6 7] []] + ) + it "quotes after paragraph lines" do + "a\n> b" <===> (["a", lineBr, quoteMark "> ", "b"], [Paragraph (rng 0 1), Quote (rng 3 4)]) + "a\n>\n> b" <===> (["a", lineBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Paragraph (rng 0 1), Quote (rng 5 6)]) + "a\n>\n>\n> b" <===> (["a", softBr, ">", lineBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Paragraph (rng 0 3), Quote (rng 7 8)]) + parseMaybeMarkdownList (T.intercalate "\n" $ "a" : replicate 7000 ">") `shouldBe` Nothing + +codeBlocks :: Spec +codeBlocks = describe "code blocks" do + it "fences and languages" do + "```\nx *y*\n```" <===> ([fence "```", hardBr, "x *y*", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "~~~\na\n\nb\n~~~" <===> ([fence "~~~", hardBr, "a", hardBr, hardBr, "b", hardBr, fence "~~~"], [CodeBlock Nothing (rng 2 6)]) + "```bash\necho 1" <===> ([mk (FenceMark $ Just "bash") "```bash", hardBr, "echo 1"], [CodeBlock (Just "bash") (rng 2 3)]) + "```bash script\nx\n```" <===> ([mk (FenceMark $ Just "bash") "```bash script", hardBr, "x", hardBr, fence "```"], [CodeBlock (Just "bash") (rng 2 3)]) + "``` bash\nx\n```" <===> ([mk (FenceMark $ Just "bash") "``` bash", hardBr, "x", hardBr, fence "```"], [CodeBlock (Just "bash") (rng 2 3)]) + "~~~ a`b\nc\n~~~" <===> ([mk (FenceMark $ Just "a`b") "~~~ a`b", hardBr, "c", hardBr, fence "~~~"], [CodeBlock (Just "a`b") (rng 2 3)]) + noBlocks "``` a`b\nc\n```" + it "closing fences" do + "```\na\n~~~\nb\n```" <===> ([fence "```", hardBr, "a", hardBr, "~~~", hardBr, "b", hardBr, fence "```"], [CodeBlock Nothing (rng 2 7)]) + "```\na\n``` " <===> ([fence "```", hardBr, "a", hardBr, fence "``` "], [CodeBlock Nothing (rng 2 3)]) + "```\na\n`````" <===> ([fence "```", hardBr, "a", hardBr, fence "`````"], [CodeBlock Nothing (rng 2 3)]) + "````\na\n```\n````" <===> ([fence "````", hardBr, "a", hardBr, "```", hardBr, fence "````"], [CodeBlock Nothing (rng 2 5)]) + "```\na\n``` b" <===> ([fence "```", hardBr, "a", hardBr, "``` b"], [CodeBlock Nothing (rng 2 5)]) + it "blank and indented code lines" do + "```\n\na\n\n```" <===> ([fence "```", hardBr, hardBr, "a", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 5)]) + "```\na\n\n```" <===> ([fence "```", hardBr, "a", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n a\n ```" <===> ([fence " ```", hardBr, mk Indent " ", " a", hardBr, fence " ```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n\ta\n ```" <===> ([fence " ```", hardBr, mk Indent "\t", "a", hardBr, fence " ```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n a\n```" <===> ([fence " ```", hardBr, mk Indent " ", "a", hardBr, fence "```"], [CodeBlock Nothing (rng 2 4)]) + it "code is text" do + "```\n*a* `b` @c https://simplex.chat\n```" <===> ([fence "```", hardBr, "*a* `b` @c https://simplex.chat", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "```\na \nb\\\n```" <===> ([fence "```", hardBr, "a ", hardBr, "b\\", hardBr, fence "```"], [CodeBlock Nothing (rng 2 5)]) + it "a fence followed by a line is a code block" do + "```\n```" <===> ([fence "```", hardBr, fence "```"], [CodeBlock Nothing (rng 2 2)]) + "```\n```\nx" <===> ([fence "```", hardBr, fence "```", lineBr, "x"], [CodeBlock Nothing (rng 2 2), Paragraph (rng 4 5)]) + "```\n\n```" <===> ([fence "```", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 2)]) + "```\n \n```" <===> ([fence "```", hardBr, " ", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "```\n\n" <===> ([fence "```", hardBr], [CodeBlock Nothing (rng 2 2)]) + it "a fence line at the text end is text" do + noBlocks "```" + noBlocks "```\n" + noBlocks "a\n```" + "a\n```\nb" <===> (["a", lineBr, fence "```", hardBr, "b"], [Paragraph (rng 0 1), CodeBlock Nothing (rng 4 5)]) + it "code blocks and other blocks" do + "a\n```\nb\n```" <===> (["a", lineBr, fence "```", hardBr, "b", hardBr, fence "```"], [Paragraph (rng 0 1), CodeBlock Nothing (rng 4 5)]) + "```\na\n```\nb" <===> ([fence "```", hardBr, "a", hardBr, fence "```", lineBr, "b"], [CodeBlock Nothing (rng 2 3), Paragraph (rng 6 7)]) + "- a\n```\nb\n```" <===> ([bullet "- ", "a", lineBr, fence "```", hardBr, "b", hardBr, fence "```"], [List [entry 1 2 []], CodeBlock Nothing (rng 5 6)]) + +tables :: Spec +tables = describe "tables" do + it "alignments" do + "| a | b | c | d |\n|---|:--|--:|:-:|" + <===> ( [mk RowMark "| ", "a", mk CellMark " | ", "b", mk CellMark " | ", "c", mk CellMark " | ", "d", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TALeft, TARight, TACenter]) "|---|:--|--:|:-:|"], + [Table [TANone, TALeft, TARight, TACenter] [rng 1 2, rng 3 4, rng 5 6, rng 7 8] []] + ) + "a | b\n:- | -:" <===> ([mk RowMark "", "a", mk CellMark " | ", "b", mk CellMark "", hardBr, mk (DelimiterRow [TALeft, TARight]) ":- | -:"], [Table [TALeft, TARight] [rng 1 2, rng 3 4] []]) + it "rows and cells" do + "| a | b |\n|---|:-:|\n| 1 | 2 |" + <===> ( [mk RowMark "| ", "a", mk CellMark " | ", "b", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TACenter]) "|---|:-:|", hardBr, mk RowMark "| ", "1", mk CellMark " | ", "2", mk CellMark " |"], + [Table [TANone, TACenter] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]] + ) + "|a|b|\n|-|-|\n|c|" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10]]]) + "|a|b|\n|-|-|\n|c|d|e|" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|", "d|e", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]]) + "|a|b|\n|-|-|\n|c|d|e" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|", "d|e", mk CellMark ""], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]]) + "|a|b|\n|-|-|\n||c|" <===> (header2 <> [hardBr, mk RowMark "|", mk CellMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 9, rng 10 11]]]) + "|a|b|\n|-|-|\n| |c|" <===> (header2 <> [hardBr, mk RowMark "| ", mk CellMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 9, rng 10 11]]]) + "|a|\n|-|\n||" <===> ([mk RowMark "|", "a", mk CellMark "|", hardBr, mk (DelimiterRow [TANone]) "|-|", hardBr, mk RowMark "|", mk CellMark "|"], [Table [TANone] [rng 1 2] [[rng 7 7]]]) + " | a |\n |---|\n | b |" <===> ([mk RowMark " | ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) " |---|", hardBr, mk RowMark " | ", "b", mk CellMark " |"], [Table [TANone] [rng 1 2] [[rng 7 8]]]) + it "inline content in cells" do + "| *a* | @b |\n|---|---|" <===> ([mk RowMark "| ", fmt boldF "a", mk CellMark " | ", fmt (Mention "b") "@b", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TANone]) "|---|---|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] []]) + it "lines that are not tables" do + noBlocks "| a |\n| b |" + noBlocks "| a | b |\n|---|" + noBlocks "| a |\n|---|x" + noBlocks "a\n-|-" + it "tables and other blocks" do + "a | b\n- | -\nc" <===> ([mk RowMark "", "a", mk CellMark " | ", "b", mk CellMark "", hardBr, mk (DelimiterRow [TANone, TANone]) "- | -", lineBr, "c"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [], Paragraph (rng 8 9)]) + "| a |\n|---|\n- b | c" <===> ([mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|---|", lineBr, bullet "- ", "b | c"], [Table [TANone] [rng 1 2] [], List [entry 7 8 []]]) + "x\n| a |\n|---|" <===> (["x", lineBr, mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|---|"], [Paragraph (rng 0 1), Table [TANone] [rng 3 4] []]) + where + header2 = [mk RowMark "|", "a", mk CellMark "|", "b", mk CellMark "|", hardBr, mk (DelimiterRow [TANone, TANone]) "|-|-|"] + +formatsAcrossLines :: Spec +formatsAcrossLines = describe "formats across line breaks" do + it "each delimited format, in a paragraph" do + "*a\nb*" ==>> [fmt boldF "a", "\n", fmt boldF "b"] + "**a\nb**" ==>> [fmt bold2F "a", "\n", fmt bold2F "b"] + "__a\nb__" ==>> [fmt bold2F "a", "\n", fmt bold2F "b"] + "_a\nb_" ==>> [fmt Italic "a", "\n", fmt Italic "b"] + "~a\nb~" ==>> [fmt StrikeThrough "a", "\n", fmt StrikeThrough "b"] + "~~a\nb~~" ==>> [fmt StrikeThrough "a", "\n", fmt StrikeThrough "b"] + "`a\nb`" ==>> [fmt Snippet "a", "\n", fmt Snippet "b"] + "#a\nb#" ==>> [fmt Secret "a", "\n", fmt Secret "b"] + "!1 a\nb!" ==>> [fmt (colored Red) "a", "\n", fmt (colored Red) "b"] + "!- a\nb!" ==>> [fmt Small "a", "\n", fmt Small "b"] + "*a\nb\nc*" ==>> [fmt boldF "a", "\n", fmt boldF "b", "\n", fmt boldF "c"] + "*a \nb*" ==>> [fmt boldF "a", " \n", fmt boldF "b"] + "# h\n*a\nb*" ===> ([heading1, "h", lineBr, fmt boldF "a", softBr, fmt boldF "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + it "in a list item and a quote" do + "- *a\n b*" ===> ([bullet "- ", fmt boldF "a", softBr, mk Indent " ", fmt boldF "b"], [List [entry 1 5 []]]) + "- *a\nb*" ===> ([bullet "- ", fmt boldF "a", softBr, fmt boldF "b"], [List [entry 1 4 []]]) + "> *a\n> b*" ===> (quoted, [Quote (rng 1 5)]) + "> *a*\n> *b*" <===> (quoted, [Quote (rng 1 5)]) + "> *a\nb*" ===> ([quoteMark "> ", fmt boldF "a", softBr, fmt boldF "b"], [Quote (rng 1 4)]) + it "not across blocks, blank lines and cells" do + parseMaybeMarkdownList "*a\n\nb*" `shouldBe` Nothing + "# *a\nb*" <===> ([heading1, "*a", lineBr, "b*"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "- *a\n- b*" <===> ([bullet "- ", "*a", lineBr, bullet "- ", "b*"], [List [entry 1 2 [], entry 4 5 []]]) + "> *a\n>\n> b*" <===> ([quoteMark "> ", "*a", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b*"], [Quote (rng 1 7)]) + "| *a | b* |\n|---|---|" <===> ([mk RowMark "| ", "*a", mk CellMark " | ", "b*", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TANone]) "|---|---|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] []]) + it "a format starting or ending at a line break keeps its empty part" do + "# h\n`\na`" ===> ([heading1, "h", lineBr, fmt Snippet "", softBr, fmt Snippet "a"], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + "# h\n``\n``" <===> ([heading1, "h", lineBr, fmt Snippet "", softBr, fmt Snippet ""], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + T.concat (map markdownText $ fromMaybe [] $ parseMaybeMarkdownList "# h\n`\na`") `shouldBe` "# h\n``\n`a`" + it "an unclosed delimiter is text to the line end" do + "*a\n_b_" ==>> ["*a\n", fmt Italic "b"] + parseMaybeMarkdownList "*a\nb" `shouldBe` Nothing + it "white space at the content ends: as on master, within the line" do + parseMaybeMarkdownList "*a\n*" `shouldBe` Nothing + parseMaybeMarkdownList "*a\nb *" `shouldBe` Nothing + parseMaybeMarkdownList "_ a\nb_" `shouldBe` Nothing + parseMaybeMarkdownList "**a\n**" `shouldBe` Nothing + "5 * 3\n*b*" ==>> ["5 * 3\n", fmt boldF "b"] + it "links, mentions and names within one line" do + parseMaybeMarkdownList "[a\nb](https://simplex.chat)" `shouldBe` Nothing + parseMaybeMarkdownList "@'a\nb'" `shouldBe` Nothing + "https://simplex.chat\nb" ==>> [uri' "https://simplex.chat", "\nb"] + "@alice\nb" ==>> [fmt (Mention "alice") "@alice", "\nb"] + where + quoted = [quoteMark "> ", fmt boldF "a", softBr, quoteMark "> ", fmt boldF "b"] + +liveMessages :: Spec +liveMessages = describe "incomplete syntax of live messages" do + it "a code block once a line follows the fence" do + noBlocks "```" + noBlocks "```js" + "```\nls" <===> ([fence "```", hardBr, "ls"], [CodeBlock Nothing (rng 2 3)]) + "``` a ```" ==>> [fmt Snippet "a"] + it "a rule until another character is added" do + "---" <===> ([mk RuleMark "---"], [Rule (rng 0 1)]) + noBlocks "---x" + it "a table once the delimiter row has a cell" do + noBlocks "| a |" + noBlocks "| a |\n|" + "| a |\n|-" <===> ([mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|-"], [Table [TANone] [rng 1 2] []]) + noBlocks "| a |\n|---|x" + it "block marks before their text" do + noBlocks "-" + noBlocks "1." + noBlocks ">" + "# a" <===> ([heading1, "a"], [Heading 1 (rng 1 2)]) + "- a" <===> ([bullet "- ", "a"], [List [entry 1 2 []]]) + "1. a" <===> ([numbered "1.", "a"], [List [ListEntry (Just "1.") (rng 1 2) []]]) + "> a" <===> ([quoteMark "> ", "a"], [Quote (rng 1 2)]) + +linearTimeParsing :: Spec +linearTimeParsing = describe "bounded scans for linear-time parsing" do + it "phone numbers: at most 22 characters, as on master" do + "1 1 1 1 1 1 1 1 1 1 1 1" <==> "1 " <> phone "1 1 1 1 1 1 1 1 1 1 1" + it "link texts stop at \"[\", link targets and autolinks at white space" do + "[a [b](https://simplex.chat)" <==> "[a " <> web "b" "https://simplex.chat" "[b](https://simplex.chat)" + "[a](https://simplex.chat x)" <==> "[a](https://simplex.chat x)" + "" <==> "" + it "a text with more than 2000 markup tokens has no blocks" do + let ParsedMarkdown _ blocks = parsedMarkdown $ T.replicate 1000 "- a\n" + length <$> blocks `shouldBe` Just 1 + noBlocks $ T.replicate 1001 "- a\n" + length . filter (\(FormattedText f _) -> f == Just boldF) <$> parseMaybeMarkdownList (T.replicate 1001 "- *a*\n") `shouldBe` Just 1001 + it "large adversarial texts" do + forM_ (["1 ", "[ ", "[a]( ", "< ", "a\n", ">\n", "- a\n", "|\n", "|-|\n", "*a\n"] :: [Text]) $ \p -> + length (show $ parseMaybeMarkdownList $ "|a|\n|-|\n" <> T.replicate 4000 p) `shouldSatisfy` (> 0) + +markdownConsumers :: Spec +markdownConsumers = describe "consumers of parsed Markdown" do + it "mentionedNames: mentions in blocks, not in code" do + mentions "- @alice" `shouldBe` ["alice"] + mentions "> @alice" `shouldBe` ["alice"] + mentions "# @alice" `shouldBe` ["alice"] + mentions "| @alice |\n|---|" `shouldBe` ["alice"] + mentions "```\n@alice\n```" `shouldBe` [] + mentions "`@alice`" `shouldBe` [] + it "hasLinks: links in blocks, not in code" do + links "- https://simplex.chat" `shouldBe` True + links "| https://simplex.chat |\n|---|" `shouldBe` True + links "```\nhttps://simplex.chat\n```" `shouldBe` False + it "SimpleX links in blocks and in code" do + simplexLinks ("- " <> addr) `shouldBe` True + simplexLinks ("| " <> addr <> " |\n|---|") `shouldBe` True + simplexLinks ("```\n" <> addr <> "\n```") `shouldBe` False + hasObfuscatedSimplexLink ("```\n" <> addr <> "\n```") `shouldBe` True + textWithoutSimplexLinks ("- check [this link](" <> addr <> ") out") `shouldBe` "- check out" + it "markdownText of markup tokens is their text" do + forM_ ([LineBreak, SoftBreak, HardBreak, ParagraphBreak 2, Indent, HeadingMark 1, HeadingClose, HeadingUnderline 1, ListMark Nothing, QuoteMark, FenceMark Nothing, RowMark, CellMark, DelimiterRow [TANone], RuleMark] :: [BlockToken]) $ \tkn -> + markdownText (mk tkn "x") `shouldBe` "x" + markdownText (fmt Escape "\\") `shouldBe` "\\" + where + parsed = fromMaybe [] . parseMaybeMarkdownList + mentions = mentionedNames . parsed + links = hasLinks . parsed + isSimplexLinkFormat (FormattedText f _) = maybe False isSimplexLink f + simplexLinks = any isSimplexLinkFormat . parsed + textWithoutSimplexLinks = T.concat . map (\(FormattedText _ t) -> t) . filter (not . isSimplexLinkFormat) . parsed + addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw" testSanitizeUri :: Spec testSanitizeUri = describe "sanitizeUri" $ do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2834445af8..6b1a64055f 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -366,6 +366,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.info with empty full name" $ "{\"v\":\"9\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} Nothing + it "x.info with agent peer type" $ + "{\"v\":\"9\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"peerType\":\"agent\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTAgent, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} Nothing it "x.contact with xContactId" $ "{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile Nothing (Just $ XContactId "\1\2\3\4") Nothing Nothing diff --git a/tests/Test.hs b/tests/Test.hs index 42adbfda52..8874e08211 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -9,6 +9,7 @@ import Bots.BadgeService.CatalogTests import Bots.BadgeService.ConfigTests import Bots.BadgeService.GroupIntegrationTests import Bots.BadgeService.GroupTests +import Bots.BadgeService.StoreVerifierTests import Bots.BadgeService.StripeTests import Bots.BadgeService.WaitersTests import Bots.BadgeService.WebTests @@ -83,6 +84,7 @@ main = do badgeWaitersTests badgeBTCPayTests badgeStripeTests + badgeStoreVerifierTests describe "SimpleX chat markdown" markdownTests describe "JSON Tests" jsonTests describe "Member relations" memberRelationsTests diff --git a/website/src/directory.html b/website/src/directory.html index 3505cccc4b..41dea04862 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -123,6 +123,57 @@ active_directory: true color: magenta; } + #directory .entry { + --md-card: rgba(139, 135, 134, 0.12); + --md-line: rgba(139, 135, 134, 0.35); + --md-bar: rgba(0, 0, 0, 0.16); + --md-stripe: rgba(139, 135, 134, 0.08); + --md-link: #0053D0; + } + + .dark #directory .entry { + --md-card: rgba(255, 255, 255, 0.08); + --md-line: rgba(139, 135, 134, 0.45); + --md-bar: rgba(255, 255, 255, 0.22); + --md-stripe: rgba(139, 135, 134, 0.12); + --md-link: #70F0F9; + } + + #directory .entry .text-container { min-width: 0; } + #directory .entry .md-gap { height: 0.7em; } + #directory .entry .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h4 { font-weight: 700; } + #directory .entry .md-entry { display: flex; gap: 6px; } + #directory .entry .md-mark { flex: none; min-width: 1.1em; } + #directory .entry .md-text { flex: 1; min-width: 0; } + #directory .entry .md-quote { display: flex; gap: 10px; } + #directory .entry .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--md-bar); } + #directory .entry .md-quote > .md-text { opacity: 0.7; } + #directory .entry .md-code { position: relative; background: var(--md-card); border-radius: 4px; overflow: hidden; } + #directory .entry .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } + #directory .entry .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 4px; background: var(--md-card); color: var(--md-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } + #directory .entry .md-code:hover .md-copy, + #directory .entry .md-code.tapped .md-copy, + #directory .entry .md-copy:focus-visible { opacity: 1; pointer-events: auto; } + #directory .entry .md-copy svg { width: 18px; height: 18px; } + #directory .entry .md-icon-done, + #directory .entry .md-copy.copied .md-icon-copy { display: none; } + #directory .entry .md-copy.copied .md-icon-done { display: block; } + #directory .entry .md-table { overflow-x: auto; } + #directory .entry .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--md-line); border-radius: 4px; overflow: hidden; } + #directory .entry .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } + #directory .entry .md-frame th, + #directory .entry .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--md-line); border-bottom: 1px solid var(--md-line); } + #directory .entry .md-frame th:last-child, + #directory .entry .md-frame td:last-child { border-right: 0; } + #directory .entry .md-frame tr:last-child th, + #directory .entry .md-frame tr:last-child td { border-bottom: 0; } + #directory .entry .md-frame th { font-weight: 700; background: var(--md-card); } + #directory .entry .md-frame tr:nth-child(odd) td { background: var(--md-stripe); } + #directory .entry .md-rule { border: 0; border-top: 1px solid var(--md-line); margin: 6px 0; } + .pagination { display: flex; justify-content: center; diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index b0a1696010..d754d36b9c 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -23,6 +23,9 @@ const STYLE = ` --sp-color-white: #000; --sp-qr-fg: #062D56; --sp-qr-bg: #ffffff; + --sp-md-line: rgba(139, 135, 134, 0.35); + --sp-md-bar: rgba(0, 0, 0, 0.16); + --sp-md-stripe: rgba(139, 135, 134, 0.08); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 1.4; @@ -58,6 +61,9 @@ const STYLE = ` --sp-color-white: #fff; --sp-qr-fg: #FFFBFA; --sp-qr-bg: transparent; + --sp-md-line: rgba(139, 135, 134, 0.45); + --sp-md-bar: rgba(255, 255, 255, 0.22); + --sp-md-stripe: rgba(139, 135, 134, 0.12); } .simplex-preview-header { @@ -497,6 +503,44 @@ const STYLE = ` .simplex-preview-text .black { color: var(--sp-color-black); } .simplex-preview-text .white { color: var(--sp-color-white); } +.simplex-preview-msg-col { + min-width: 0; +} + +.simplex-preview-text .md-gap { height: 0.7em; } +.simplex-preview-text .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h4 { font-weight: 700; } +.simplex-preview-text .md-entry { display: flex; gap: 6px; } +.simplex-preview-text .md-mark { flex: none; min-width: 1.1em; } +.simplex-preview-text .md-text { flex: 1; min-width: 0; } +.simplex-preview-text .md-quote { display: flex; gap: 10px; } +.simplex-preview-text .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--sp-md-bar); } +.simplex-preview-text .md-quote > .md-text { opacity: 0.7; } +.simplex-preview-text .md-code { position: relative; background: var(--sp-quote); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } +.simplex-preview-text .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 7px; background: var(--sp-quote); color: var(--sp-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } +.simplex-preview-text .md-code:hover .md-copy, +.simplex-preview-text .md-code.tapped .md-copy, +.simplex-preview-text .md-copy:focus-visible { opacity: 1; pointer-events: auto; } +.simplex-preview-text .md-copy svg { width: 18px; height: 18px; } +.simplex-preview-text .md-icon-done, +.simplex-preview-text .md-copy.copied .md-icon-copy { display: none; } +.simplex-preview-text .md-copy.copied .md-icon-done { display: block; } +.simplex-preview-text .md-table { overflow-x: auto; } +.simplex-preview-text .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--sp-md-line); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } +.simplex-preview-text .md-frame th, +.simplex-preview-text .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--sp-md-line); border-bottom: 1px solid var(--sp-md-line); } +.simplex-preview-text .md-frame th:last-child, +.simplex-preview-text .md-frame td:last-child { border-right: 0; } +.simplex-preview-text .md-frame tr:last-child th, +.simplex-preview-text .md-frame tr:last-child td { border-bottom: 0; } +.simplex-preview-text .md-frame th { font-weight: 700; background: var(--sp-quote); } +.simplex-preview-text .md-frame tr:nth-child(odd) td { background: var(--sp-md-stripe); } +.simplex-preview-text .md-rule { border: 0; border-top: 1px solid var(--sp-md-line); margin: 6px 0; } + .simplex-preview-main { flex: 1; min-width: 0; @@ -885,6 +929,7 @@ function render(container, data, channelLink, showAppBadges) { ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(), content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' }, formattedText: Array.isArray(welcome) ? welcome : null, + markdownBlocks: data.welcomeMessageBlocks, reactions: [] }; allMessages = [welcomeMsg].concat(messages); @@ -987,7 +1032,7 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp if (shortDescr) { const descrDiv = document.createElement('div'); descrDiv.className = 'simplex-preview-info-descr'; - descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr); + descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr, data.shortDescriptionBlocks) : escapeHtml(shortDescr); container.appendChild(descrDiv); } @@ -1068,25 +1113,6 @@ function qrColor(value, fallback) { return value === 'transparent' ? '#0000' : value; } -// navigator.clipboard is undefined outside secure contexts; fall back to execCommand -function copyToClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(text); - } - return new Promise(function(resolve, reject) { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.top = '-9999px'; - document.body.appendChild(ta); - ta.select(); - let ok = false; - try { ok = document.execCommand('copy'); } catch (e) {} - document.body.removeChild(ta); - ok ? resolve() : reject(new Error('copy failed')); - }); -} - function renderDesktopConversion(container, channelLink, showAppBadges) { if (showAppBadges) { renderAppBadges(container); @@ -1190,6 +1216,7 @@ function setupSecretToggles(container) { container.addEventListener('click', (e) => { const secret = e.target.closest('.secret'); if (secret) secret.classList.toggle('visible'); + handleCodeClick(e); }); } @@ -1246,6 +1273,7 @@ function renderMessages(container, messages, membersMap, channel) { } const col = document.createElement('div'); + col.className = 'simplex-preview-msg-col'; const bubble = renderBubble(msg, member, showTail, membersMap, channel); col.appendChild(bubble); @@ -1519,7 +1547,7 @@ function appendTextBlock(bubble, msg) { textDiv.className = 'simplex-preview-text'; const meta = renderMetaHTML(msg); if (msg.formattedText && msg.formattedText.length > 0) { - textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta; + textDiv.innerHTML = renderMarkdown(msg.formattedText, msg.markdownBlocks) + meta; } else { textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta; } diff --git a/website/src/js/directory.jsc b/website/src/js/directory.jsc index 5ca3b7fbcf..dd07735260 100644 --- a/website/src/js/directory.jsc +++ b/website/src/js/directory.jsc @@ -32,6 +32,7 @@ async function initDirectory() { liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn)); newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn)); topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn)); + document.getElementById('directory').addEventListener('click', handleCodeClick); window.addEventListener('popstate', applyHash); function applyHash() { @@ -229,7 +230,7 @@ function displayEntries(entries) { for (let entry of entries) { try { - const { entryType, displayName, simplexName, groupLink, shortDescr, welcomeMessage, imageFile } = entry; + const { entryType, displayName, simplexName, groupLink, shortDescr, shortDescrBlocks, welcomeMessage, welcomeMessageBlocks, imageFile } = entry; const entryDiv = document.createElement('div'); entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8'; @@ -248,11 +249,11 @@ function displayEntries(entries) { textContainer.appendChild(simplexNameElement); } - const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; - const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; + const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage, welcomeMessageBlocks) : undefined; + const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr, shortDescrBlocks) : undefined; if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { const descrElement = document.createElement('p'); - descrElement.innerHTML = renderMarkdown(shortDescr); + descrElement.innerHTML = shortDescrHTML; textContainer.appendChild(descrElement); } diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc index d05dd6a931..73e304f9dd 100644 --- a/website/src/js/simplex-lib.jsc +++ b/website/src/js/simplex-lib.jsc @@ -54,7 +54,157 @@ function targetBlank(uri) { return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } -function renderMarkdown(fts) { +const MAX_LIST_DEPTH = 7; + +const MAX_TABLE_CELLS = 1000; + +const COPY_BUTTON = + ''; + +// navigator.clipboard is undefined outside secure contexts; fall back to execCommand +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(new Error('copy failed')); + }); +} + +function handleCodeClick(e) { + const card = e.target.closest('.md-code'); + if (!card) return; + const button = e.target.closest('.md-copy'); + if (!button) { + card.classList.toggle('tapped'); + return; + } + const pre = card.querySelector('pre'); + if (!pre) return; + copyToClipboard(pre.innerText).then(() => { + button.classList.add('copied'); + setTimeout(() => button.classList.remove('copied'), 1500); + }).catch(() => {}); +} + +function validBlocks(fts, blocks) { + let pos = 0; + const range = (r) => { + const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length; + if (valid) pos = r.to; + return valid; + }; + const entries = (items, depth) => + Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1)); + const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range)); + return Array.isArray(blocks) && blocks.every((b) => { + switch (b && b.type) { + case 'paragraph': + case 'heading': + case 'codeBlock': + case 'quote': + return range(b.content); + case 'list': + return entries(b.items, 1); + case 'table': + return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows); + case 'rule': + return range(b.mark); + default: + return false; + } + }); +} + +function renderBlocks(fts, blocks) { + let html = ''; + let pos = 0; + const segments = (r) => renderSegments(fts.slice(r.from, r.to), true); + const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak'; + const gap = (to) => { + if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '
'; + pos = Math.max(pos, to); + }; + const block = (r, open, close) => { + gap(r.from); + html += open + segments(r) + close; + pos = r.to; + }; + const entry = (e, level) => { + html += `
${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`; + pos = e.content.to; + e.items.forEach((item) => entry(item, level + 1)); + }; + const table = (b) => { + const rows = [b.header].concat(b.rows); + const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0; + const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left'); + const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}`; + const row = (cells, tag) => + '
' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
'; + const last = rows[rows.length - 1]; + pos = last.length > 0 ? last[last.length - 1].to : pos; + }; + for (const b of blocks) { + switch (b.type) { + case 'paragraph': + block(b.content, '
', '
'); + break; + case 'heading': + block(b.content, `
`, '
'); + break; + case 'codeBlock': + block(b.content, '
', '
' + COPY_BUTTON + '
'); + break; + case 'quote': + block(b.content, '
', '
'); + break; + case 'list': + if (b.items.length > 0) gap(b.items[0].content.from); + b.items.forEach((item) => entry(item, 0)); + break; + case 'table': + table(b); + break; + case 'rule': + block(b.mark, '
', ''); + break; + } + } + return html; +} + +function markupHtml(token) { + switch (token && token.type) { + case 'softBreak': + return ' '; + case 'lineBreak': + case 'hardBreak': + return '
'; + default: + return ''; + } +} + +function renderMarkdown(fts, blocks) { + return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false); +} + +function renderSegments(fts, withBlocks) { let html = ''; for (const ft of fts) { const { format, text } = ft; @@ -65,7 +215,7 @@ function renderMarkdown(fts) { try { switch (format.type) { case 'bold': - html += `${escapeHtml(text)}`; + html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`; break; case 'italic': html += `${escapeHtml(text)}`; @@ -113,6 +263,11 @@ function renderMarkdown(fts) { case 'phone': html += `${escapeHtml(text)}`; break; + case 'escape': + break; + case 'markup': + html += withBlocks ? markupHtml(format.token) : escapeHtml(text); + break; case 'unknown': html += escapeHtml(text); break;