diff --git a/.github/workflows/android-emu.yml b/.github/workflows/android-emu.yml new file mode 100644 index 0000000000..1d195d653b --- /dev/null +++ b/.github/workflows/android-emu.yml @@ -0,0 +1,30 @@ +name: android-emu + +# A placeholder, so that the Android app test workflow can be started by hand on a branch that has its full +# version: GitHub dispatches only workflows whose file is on the default branch. On master it does nothing. +on: + workflow_dispatch: + inputs: + claude: + description: Also run the Claude job + type: boolean + default: false + backend: + description: Also start the test backend + type: boolean + default: false + release: + description: Also test the latest release's onboarding + type: boolean + default: false + +permissions: + contents: read + +jobs: + placeholder: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Nothing to run here + run: echo "This branch has only the placeholder of android-emu; start it on a branch with the full workflow." diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 074a6feb48..68737fdb3f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1119,11 +1119,16 @@ jobs: id: mac_lib_build if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag && steps.mac_cli_build.outcome == 'success' }} shell: bash + env: + APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} + APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }} + APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }} run: | SKIP_VLC=1 scripts/desktop/build-lib-mac.sh name=simplex-chat-libs-macos-${{ matrix.arch }} mkdir -p release-libs/$name/libs find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + scripts/ci/sign-libs-mac.sh release-libs/$name/libs (cd release-libs/$name && zip -r ../$name.zip libs) - name: Upload library diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index f826ccbbfc..d1186018aa 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -4,7 +4,8 @@ name: desktop-linux on: push: branches: - - "**" + # only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand + - "f/*" # a squash merge's message lists the merged commits' subjects, with their tags - "!master" - "!stable" @@ -15,7 +16,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 +29,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 +43,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 +55,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 +67,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 +123,7 @@ jobs: timeout-minutes: 150 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Show runner run: | @@ -96,26 +133,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 +146,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 +158,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 +172,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 +224,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 +237,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 @@ -281,7 +257,6 @@ jobs: with: distribution: corretto java-version: ${{ env.JAVA_VER }} - cache: gradle - name: Build app id: build @@ -294,6 +269,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 @@ -320,7 +310,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 @@ -329,7 +319,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: | @@ -340,7 +330,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 @@ -352,8 +342,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: @@ -363,14 +353,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 @@ -379,18 +368,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 @@ -398,21 +389,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: 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: | @@ -422,7 +403,7 @@ jobs: sudo chmod +x /usr/local/bin/ui - name: Start the app - run: scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/claude-test/screens" start + run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/app/simplex" "$RUNNER_TEMP/claude-test/screens" 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. @@ -438,12 +419,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: | @@ -456,8 +437,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 @@ -468,12 +449,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 @@ -492,7 +473,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 5d44162369..a275bb82d6 100644 --- a/.github/workflows/ios-sim.yml +++ b/.github/workflows/ios-sim.yml @@ -3,7 +3,8 @@ name: ios-sim on: push: branches: - - '**' + # only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand + - 'f/*' # a squash merge's message lists the merged commits' subjects, with their tags - '!master' - '!stable' @@ -39,25 +40,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 +105,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 +114,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 +132,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 +145,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 +160,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 +177,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 +200,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 +221,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 +235,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 @@ -290,37 +264,34 @@ 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 - name: Run smoke test 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 @@ -329,7 +300,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: | @@ -338,11 +309,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/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 992cf367fc..64e3add943 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1850,8 +1850,8 @@ func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandida try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer)) } -func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws { - let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates) +func apiSendCallExtraInfo(_ contact: Contact, _ rtcSession: String?, _ rtcIceCandidates: String) async throws { + let extraInfo = WebRTCExtraInfo(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo)) } @@ -2987,7 +2987,7 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .callExtraInfo(_, contact, extraInfo): await withCall(contact) { _ in - await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates)) + await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates, session: extraInfo.rtcSession)) } case let .callEnded(_, contact): if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 754bcb2715..a2e65dcf71 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -161,10 +161,10 @@ struct ActiveCallView: View { CallSoundsPlayer.shared.stop() } } - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): Task { do { - try await apiSendCallExtraInfo(call.contact, iceCandidates) + try await apiSendCallExtraInfo(call.contact, session, iceCandidates) } catch { logger.error("apiSendCallExtraInfo \(responseError(error))") } @@ -172,15 +172,24 @@ struct ActiveCallView: View { case let .connection(state): if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), case .connected = callStatus { - call.direction == .outgoing - ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) - : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + // connectedAt is kept on reconnection, so that call duration is not reset + if call.connectedAt == nil { + call.direction == .outgoing + ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) + : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + call.connectedAt = .now + } call.callState = .connected - call.connectedAt = .now + CallSoundsPlayer.shared.stop() if !wasConnected { CallSoundsPlayer.shared.vibrate(long: false) wasConnected = true } + } else if state.connectionState == "reconnecting" { + if call.callState != .reconnecting { + CallSoundsPlayer.shared.startConnectingCallSound() + } + call.callState = .reconnecting } if state.connectionState == "closed" { closeCallView(client) @@ -190,17 +199,22 @@ struct ActiveCallView: View { m.activeCall = nil m.activeCallViewIsCollapsed = false } - Task { - do { - try await apiCallStatus(call.contact, state.connectionState) - } catch { - logger.error("apiCallStatus \(responseError(error))") + // "reconnecting" is not a core call status + if state.connectionState != "reconnecting" { + Task { + do { + try await apiCallStatus(call.contact, state.connectionState) + } catch { + logger.error("apiCallStatus \(responseError(error))") + } } } case let .connected(connectionInfo): call.callState = .connected call.connectionInfo = connectionInfo - call.connectedAt = .now + if call.connectedAt == nil { + call.connectedAt = .now + } if !wasConnected { CallSoundsPlayer.shared.vibrate(long: false) wasConnected = true diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index ef9135761c..6f7a0efa38 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -77,6 +77,7 @@ enum CallState { case answerReceived // incoming - webrtc answer received via API case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed case connected + case reconnecting case ended var text: LocalizedStringKey { @@ -89,6 +90,7 @@ enum CallState { case .answerReceived: return "received confirmation…" case .negotiated: return "connecting…" case .connected: return "connected" + case .reconnecting: return "reconnecting…" case .ended: return "ended" } } @@ -119,7 +121,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case answer(answer: String, iceCandidates: String) - case ice(iceCandidates: String) + case ice(iceCandidates: String, session: String? = nil) case media(source: CallMediaSource, enable: Bool) case end @@ -131,6 +133,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case offer case answer case iceCandidates + case session case enable case iceServers case relay @@ -174,9 +177,10 @@ enum WCallCommand: Equatable, Encodable, Decodable { try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encodeIfPresent(session, forKey: .session) case let .media(source, enable): try container.encode("media", forKey: .type) try container.encode(source, forKey: .media) @@ -213,7 +217,8 @@ enum WCallCommand: Equatable, Encodable, Decodable { self = .answer(answer: answer, iceCandidates: iceCandidates) case "ice": let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) - self = .ice(iceCandidates: iceCandidates) + let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session) + self = .ice(iceCandidates: iceCandidates, session: session) case "media": let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source) let enable = try container.decode(Bool.self, forKey: CodingKeys.enable) @@ -231,7 +236,7 @@ enum WCallResponse: Equatable, Decodable { case capabilities(capabilities: CallCapabilities) case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities) case answer(answer: String, iceCandidates: String) - case ice(iceCandidates: String) + case ice(iceCandidates: String, session: String? = nil) case connection(state: ConnectionState) case connected(connectionInfo: ConnectionInfo) case peerMedia(source: CallMediaSource, enabled: Bool) @@ -246,6 +251,7 @@ enum WCallResponse: Equatable, Decodable { case offer case answer case iceCandidates + case session case state case connectionInfo case message @@ -290,7 +296,8 @@ enum WCallResponse: Equatable, Decodable { self = .answer(answer: answer, iceCandidates: iceCandidates) case "ice": let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) - self = .ice(iceCandidates: iceCandidates) + let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session) + self = .ice(iceCandidates: iceCandidates, session: session) case "connection": let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state) self = .connection(state: state) @@ -333,9 +340,10 @@ extension WCallResponse: Encodable { try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encodeIfPresent(session, forKey: .session) case let .connection(state): try container.encode("connection", forKey: .type) try container.encode(state, forKey: .state) diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index c7d53d3c69..15d36f7931 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -20,6 +20,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg }() private static let ivTagBytes: Int = 28 private static let enableEncryption: Bool = true + private static let reconnectTimeout: UInt64 = 30_000_000_000 + private static let iceRestartInterval: UInt64 = 10_000_000_000 private var chat_ctrl = getChatCtrl() struct Call { @@ -37,6 +39,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg var frameEncryptor: RTCFrameEncryptor? var frameDecryptor: RTCFrameDecryptor? var peerHasOldVersion: Bool + var wasConnected = false + var reconnection: Task? = nil + var iceRestarts: Task? = nil } struct NotConnectedCall { @@ -257,11 +262,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg resp = .error(message: "answer: remote description is not set") } } - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): if let pc = pc, let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { - addIceCandidates(pc, remoteIceCandidates) - resp = .ok + if let session { + resp = await receiveRestartSession(pc, session, remoteIceCandidates) + } else { + addIceCandidates(pc, remoteIceCandidates) + resp = .ok + } } else { resp = .error(message: "ice: call not started") } @@ -301,6 +310,101 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + private func startReconnecting(_ connection: RTCPeerConnection) async { + activeCall?.reconnection = Task { + _ = try? await Task.sleep(nanoseconds: WebRTCClient.reconnectTimeout) + if Task.isCancelled { return } + await sendConnectionState(connection, "disconnected") + endCall() + } + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + if connection.localDescription?.type == .offer { + activeCall?.iceRestarts = Task { + while !Task.isCancelled { + await restartIce(connection) + _ = try? await Task.sleep(nanoseconds: WebRTCClient.iceRestartInterval) + } + } + } + await sendConnectionState(connection, "reconnecting") + } + + private func stopReconnecting() { + activeCall?.reconnection?.cancel() + activeCall?.reconnection = nil + activeCall?.iceRestarts?.cancel() + activeCall?.iceRestarts = nil + } + + private func restartIce(_ connection: RTCPeerConnection) async { + // the previous restart offer is not answered yet + if connection.signalingState != .stable { return } + let (offer, error) = await connection.offer(iceRestart: true) + if let offer { + await sendRestartSession(offer) + } else { + logger.error("WebRTCClient: ICE restart error: \(error?.localizedDescription ?? "unknown error")") + } + } + + private func receiveRestartSession(_ pc: RTCPeerConnection, _ session: String, _ remoteIceCandidates: [RTCIceCandidate]) async -> WCallResponse { + guard let desc: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: session)), + let type = desc.type, let sdp = desc.sdp else { + return .error(message: "ice: invalid session") + } + let remoteDesc = RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp) + if type == .offer && pc.localDescription?.type != .offer { + if (try? await pc.setRemoteDescription(remoteDesc)) == nil { + return .error(message: "ice: remote offer is not set") + } + let (answer, error) = await pc.answer() + guard let answer else { + return .error(message: "ice: answer error: \(error?.localizedDescription ?? "unknown error")") + } + addIceCandidates(pc, remoteIceCandidates) + await sendRestartSession(answer) + } else if type == .answer && pc.signalingState == .haveLocalOffer { + if (try? await pc.setRemoteDescription(remoteDesc)) == nil { + return .error(message: "ice: remote answer is not set") + } + addIceCandidates(pc, remoteIceCandidates) + } else { + return .error(message: "ice: unexpected ICE restart \(type.rawValue)") + } + return .ok + } + + private func sendRestartSession(_ desc: RTCSessionDescription) async { + await sendCallResponse(.init( + corrId: nil, + resp: .ice( + iceCandidates: compressToBase64(input: encodeJSON(await getInitialIceCandidates())), + session: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: desc.type.toSdpType(), sdp: desc.sdp))) + ), + command: nil) + ) + waitForMoreIceCandidates() + } + + private func sendConnectionState(_ connection: RTCPeerConnection, _ connectionState: String) async { + guard let iceConnectionState = connection.iceConnectionState.toString(), + let iceGatheringState = connection.iceGatheringState.toString(), + let signalingState = connection.signalingState.toString() + else { + return + } + await sendCallResponse(.init( + corrId: nil, + resp: .connection(state: ConnectionState( + connectionState: connectionState, + iceConnectionState: iceConnectionState, + iceGatheringState: iceGatheringState, + signalingState: signalingState) + ), + command: nil) + ) + } + // Spec: spec/services/calls.md#sendIceCandidates func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { await self.sendCallResponse(.init( @@ -657,6 +761,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg (notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture() guard let call = activeCall else { return } logger.debug("WebRTCClient: ending the call") + call.reconnection?.cancel() + call.iceRestarts?.cancel() call.connection.close() call.connection.delegate = nil call.frameEncryptor?.delegate = nil @@ -678,16 +784,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg extension WebRTC.RTCPeerConnection { - func mediaConstraints() -> RTCMediaConstraints { - RTCMediaConstraints( - mandatoryConstraints: [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, - kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue], - optionalConstraints: nil) + func mediaConstraints(iceRestart: Bool = false) -> RTCMediaConstraints { + var constraints = [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, + kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue] + if iceRestart { + constraints[kRTCMediaConstraintsIceRestart] = kRTCMediaConstraintsValueTrue + } + return RTCMediaConstraints(mandatoryConstraints: constraints, optionalConstraints: nil) } - func offer() async -> (RTCSessionDescription?, Error?) { + func offer(iceRestart: Bool = false) async -> (RTCSessionDescription?, Error?) { await withCheckedContinuation { cont in - offer(for: mediaConstraints()) { (sdp, error) in + offer(for: mediaConstraints(iceRestart: iceRestart)) { (sdp, error) in self.processSDP(cont, sdp, error) } } @@ -766,24 +874,20 @@ extension WebRTCClient: RTCPeerConnectionDelegate { func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)") - guard let connectionStateString = newState.toString(), - let iceConnectionStateString = connection.iceConnectionState.toString(), - let iceGatheringStateString = connection.iceGatheringState.toString(), - let signalingStateString = connection.signalingState.toString() - else { - return - } + guard let connectionStateString = newState.toString() else { return } Task { - await sendCallResponse(.init( - corrId: nil, - resp: .connection(state: ConnectionState( - connectionState: connectionStateString, - iceConnectionState: iceConnectionStateString, - iceGatheringState: iceGatheringStateString, - signalingState: signalingStateString) - ), - command: nil) - ) + if activeCall?.wasConnected == true { + switch newState { + case .disconnected, .failed: + if activeCall?.reconnection == nil { + await startReconnecting(connection) + } + return + case .new, .checking: return + default: () + } + } + await sendConnectionState(connection, connectionStateString) switch newState { case .checking: @@ -792,7 +896,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate { } let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true setSpeakerEnabledAndConfigureSession(enableSpeaker) - case .connected: sendConnectedEvent(connection) + case .connected: + stopReconnecting() + activeCall?.wasConnected = true + sendConnectedEvent(connection) case .disconnected, .failed: endCall() default: () } diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index ece65130e6..53830bc68f 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -34,10 +34,12 @@ public struct WebRTCSession: Codable { // Spec: spec/services/calls.md#WebRTCExtraInfo public struct WebRTCExtraInfo: Codable { - public init(rtcIceCandidates: String) { + public init(rtcSession: String?, rtcIceCandidates: String) { + self.rtcSession = rtcSession self.rtcIceCandidates = rtcIceCandidates } + public var rtcSession: String? public var rtcIceCandidates: String } diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md index 86cb026625..e656a2932a 100644 --- a/apps/ios/product/flows/calling.md +++ b/apps/ios/product/flows/calling.md @@ -103,6 +103,14 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central 5. `CallViewRenderers` manages WebRTC video rendering surfaces. 6. Call status updates are sent via `apiCallStatus(contact:status:)`. +### 6.1 Reconnection + +1. A connected call loses its connection, e.g. when the device switches between Wi-Fi and mobile network. +2. The call shows "reconnecting…" and plays the connecting sound. Call duration continues, as this state is not reported to the core. +3. The callee (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer via `apiSendCallExtraInfo` with `rtcSession`; the caller answers the same way. +4. When the connection is restored, the call returns to connected state. +5. If the connection is not restored in 30 seconds, the call ends. Clients without ICE restart support ignore the restart offer, so the call ends in this case. + ### 7. Audio Routing 1. `CallAudioDeviceManager` handles audio device selection. @@ -141,7 +149,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central | `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs | | `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data | | `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) | -| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer | +| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer, and ICE restart offer/answer | | `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting | | `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` | | `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera | @@ -152,7 +160,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central |-------|-------|----------| | Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout | | Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry | -| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status | +| WebRTC peer connection failure | NAT traversal, network issues | Before connection: call ends with error status; after connection: reconnection with ICE restart, call ends if not reconnected in 30 seconds | | CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up | | No camera/mic permission | User denied permissions | Permission request dialog shown | diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index 2e86db6802..c3354c9959 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -179,7 +179,7 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw | `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) | | `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) | | `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) | -| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates or ICE restart offer/answer | [L171](../Shared/Model/AppAPITypes.swift#L171) | | `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) | | `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) | | `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) | diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md index 6a1d89f6a3..b3fb83d151 100644 --- a/apps/ios/spec/services/calls.md +++ b/apps/ios/spec/services/calls.md @@ -116,7 +116,10 @@ Manages the WebRTC peer connection, media streams, and data channels. | [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 | | [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 | | [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 | -| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 | +| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L409) | Sends gathered ICE candidates | L409 | +| [`startReconnecting`](../../Shared/Views/Call/WebRTCClient.swift#L313) | Reports "reconnecting", starts reconnection timeout and, for the party that sent the initial offer, periodic ICE restarts | L313 | +| [`restartIce`](../../Shared/Views/Call/WebRTCClient.swift#L339) | Creates ICE restart offer and sends it with candidates in `.ice(iceCandidates:session:)` | L339 | +| [`receiveRestartSession`](../../Shared/Views/Call/WebRTCClient.swift#L350) | Applies ICE restart offer (and sends the answer) or answer received in `.ice` command | L350 | | [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 | | [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 | | [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 | @@ -179,6 +182,10 @@ All call signaling travels through the same encrypted SMP message channels used 5. P2P connection established ``` +### Reconnection + +When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call. + ### API Commands | Command | Direction | Purpose | @@ -187,7 +194,7 @@ All call signaling travels through the same encrypted SMP message channels used | `apiRejectCall(contact:)` | Callee -> Caller | Reject call | | `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer | | `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer | -| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates | +| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates, or ICE restart offer/answer in `rtcSession` | | `apiEndCall(contact:)` | Either | End call | | `apiGetCallInvitations` | -- | Get pending invitations | | `apiCallStatus(contact:, callStatus:)` | -- | Report status change | @@ -317,10 +324,11 @@ struct WebRTCSession { } ``` -### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42) +### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L45) ```swift struct WebRTCExtraInfo { + var rtcSession: String? // ICE restart offer or answer var rtcIceCandidates: String // Additional ICE candidates } ``` @@ -330,7 +338,7 @@ struct WebRTCExtraInfo { Stored in `ChatModel.activeCall`: - Contact reference - Call UUID -- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.) +- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, `.reconnecting`, etc.) - Media type - WebRTCClient reference diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index dfe38f197a..d4c533fd4e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -52,8 +52,7 @@ import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import java.io.Closeable @@ -168,13 +167,24 @@ actual fun ActiveCallView() { CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates) } is WCallResponse.Connection -> - try { + // the call is restored and NOT sent to the core + // Makes sure the core DOES NOT record the duration wrong if a reconnect happens + if (r.state.connectionState == "reconnecting") { + if (call.callState != CallState.Reconnecting) { + CallSoundsPlayer.startConnectingCallSound(scope) + } + updateActiveCall(call) { it.copy(callState = CallState.Reconnecting) } + } else try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) } + if (call.callState == CallState.Reconnecting) { + CallSoundsPlayer.stop() + } + // connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting + updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = it.connectedAt ?: Clock.System.now()) } } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -705,18 +715,17 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM val wv = webView.value if (wv != null) { LaunchedEffect(Unit) { - snapshotFlow { callCommand.firstOrNull() } - .distinctUntilChanged() - .filterNotNull() - .collect { - while (callCommand.isNotEmpty()) { - val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") - if (cmd != null) { - processCommand(wv, cmd) - } + while (isActive) { + // waiting for the first command would miss a command equal to the previous one + snapshotFlow { callCommand.isNotEmpty() }.first { it } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirstOrNull() + Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") + if (cmd != null) { + processCommand(wv, cmd) } } + } } } val assetLoader = WebViewAssetLoader.Builder() 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 b8c5238105..c5e23628a5 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 @@ -2185,8 +2185,8 @@ object ChatController { return r.result is CR.CmdOk } - suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { - val extraInfo = WebRTCExtraInfo(rtcIceCandidates) + suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String): Boolean { + val extraInfo = WebRTCExtraInfo(rtcSession, rtcIceCandidates) val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) return r.result is CR.CmdOk } @@ -3436,7 +3436,7 @@ object ChatController { } is CR.CallExtraInfo -> { withCall(r, r.contact) { _ -> - chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)) + chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates, session = r.extraInfo.rtcSession)) } } is CR.CallEnded -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index b761218fdc..86e826e946 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -56,6 +56,7 @@ enum class CallState { AnswerReceived, Negotiated, Connected, + Reconnecting, Ended; val text: String get() = when(this) { @@ -67,6 +68,7 @@ enum class CallState { AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation) Negotiated -> generalGetString(MR.strings.callstate_connecting) Connected -> generalGetString(MR.strings.callstate_connected) + Reconnecting -> generalGetString(MR.strings.callstate_reconnecting) Ended -> generalGetString(MR.strings.callstate_ended) } } @@ -91,7 +93,7 @@ sealed class WCallCommand { @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() - @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() + @Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallCommand() @Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @@ -104,7 +106,7 @@ sealed class WCallResponse { @Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse() @Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse() - @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() + @Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() @Serializable @SerialName("peerMedia") data class PeerMedia(val source: CallMediaSource, val enabled: Boolean): WCallResponse() @@ -116,7 +118,7 @@ sealed class WCallResponse { @Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) @Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) -@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) +@Serializable data class WebRTCExtraInfo(val rtcSession: String? = null, val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) @Serializable data class RcvCallInvitation( val remoteHostId: Long?, 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 48c844acce..cd80492607 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1301,6 +1301,7 @@ received answer… received confirmation… connecting… + reconnecting… connected ended diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index 7ab8d6fdd6..6fb3677a2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -54,6 +54,8 @@ let inactiveCallMediaSources = { let activeCall; let notConnectedCall; let answerTimeout = 30000; +let reconnectTimeout = 30000; +let iceRestartInterval = 10000; var useWorker = false; var isDesktop = false; var localizedState = ""; @@ -75,7 +77,8 @@ const processCommand = (function () { return { peerConnectionConfig: { iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers, - iceCandidatePoolSize: 10, + // Chrome gathers no candidates on ICE restart when candidates pool is used + iceCandidatePoolSize: 0, encodedInsertableStreams, iceTransportPolicy: relay ? "relay" : "all", // needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers @@ -202,10 +205,14 @@ const processCommand = (function () { layout: (_c = notConnectedCall === null || notConnectedCall === void 0 ? void 0 : notConnectedCall.layout) !== null && _c !== void 0 ? _c : LayoutType.Default, cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0, peerHasOldVersion: false, + receiveRestartSession, }; localOrPeerMediaSourcesChanged(call); await setupMediaStreams(call); let connectionTimeout = setTimeout(connectionHandler, answerTimeout); + let reconnectingTimeout; + let iceRestartTimeout; + let wasConnected = false; if (pc.connectionState) { pc.addEventListener("connectionstatechange", connectionStateChange); } @@ -216,30 +223,49 @@ const processCommand = (function () { async function connectionStateChange() { // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView) // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70) - if (pc.connectionState !== "failed") + if (pc.connectionState !== "failed" || wasConnected) connectionHandler(); } - async function connectionHandler() { + async function connectionHandler(reconnectTimedOut = false) { var _a; + let state = (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" + ? pc.iceConnectionState + : pc.iceConnectionState == "completed" + ? "connected" + : "connecting"); /* webView 69-70 doesn't have connectionState yet */ + if (wasConnected) { + if (reconnectTimedOut) { + reconnectingTimeout = undefined; + if (activeCall !== call || state == "connected") + return; + state = "disconnected"; + } + else if (state == "disconnected" || state == "failed") { + if (reconnectingTimeout !== undefined) + return; + reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout); + if (isIceRestarter()) + restartIce(); + state = "reconnecting"; + } + else if (state == "connecting" || state == "new") { + return; + } + } sendMessageToNative({ resp: { type: "connection", state: { - connectionState: (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" - ? pc.iceConnectionState - : pc.iceConnectionState == "completed" - ? "connected" - : "connecting") /* webView 69-70 doesn't have connectionState yet */, + connectionState: state, iceConnectionState: pc.iceConnectionState, iceGatheringState: pc.iceGatheringState, signalingState: pc.signalingState, }, }, }); - if (pc.connectionState == "disconnected" || - pc.connectionState == "failed" || - (!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed"))) { + if (state == "disconnected" || state == "failed") { clearConnectionTimeout(); + clearReconnecting(); if (pc.connectionState) { pc.removeEventListener("connectionstatechange", connectionStateChange); } @@ -251,8 +277,10 @@ const processCommand = (function () { } endCall(); } - else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) { + else if (state == "connected") { clearConnectionTimeout(); + clearReconnecting(); + wasConnected = true; const stats = (await pc.getStats()); for (const stat of stats.values()) { const { type, state } = stat; @@ -278,6 +306,53 @@ const processCommand = (function () { connectionTimeout = undefined; } } + function clearReconnecting() { + clearTimeout(reconnectingTimeout); + reconnectingTimeout = undefined; + clearTimeout(iceRestartTimeout); + iceRestartTimeout = undefined; + } + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + function isIceRestarter() { + var _a; + return ((_a = pc.localDescription) === null || _a === void 0 ? void 0 : _a.type) == "offer"; + } + async function restartIce() { + if (activeCall !== call || reconnectingTimeout === undefined) + return; + iceRestartTimeout = setTimeout(restartIce, iceRestartInterval); + // the previous restart offer is not answered yet + if (pc.signalingState != "stable") + return; + try { + const iceCandidates = getIceCandidates(pc, config); + const offer = await pc.createOffer({ iceRestart: true }); + await pc.setLocalDescription(offer); + sendMessageToNative({ resp: { type: "ice", session: serialize(offer), iceCandidates: await iceCandidates } }); + } + catch (e) { + console.log("ICE restart error", e); + } + } + async function receiveRestartSession(session, iceCandidates) { + const desc = parse(session); + const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc)); + if (desc.type == "offer" && !isIceRestarter()) { + await pc.setRemoteDescription(remoteDesc); + const localIceCandidates = getIceCandidates(pc, config); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + addIceCandidates(pc, iceCandidates); + sendMessageToNative({ resp: { type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates } }); + } + else if (desc.type == "answer" && pc.signalingState == "have-local-offer") { + await pc.setRemoteDescription(remoteDesc); + addIceCandidates(pc, iceCandidates); + } + else { + throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`); + } + } } function serialize(x) { return LZString.compressToBase64(JSON.stringify(x)); @@ -296,6 +371,8 @@ const processCommand = (function () { console.log("starting outgoing call - capabilities"); if (activeCall) endCall(); + // Stop a preview stream from an earlier pre-connect outgoing call being replaced (activeCall may be null here) + stopNotConnectedCall(); let localStream = null; try { localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User); @@ -332,7 +409,8 @@ const processCommand = (function () { if (activeCall) endCall(); // It can be already defined on Android when switching calls (if the previous call was outgoing) - notConnectedCall = undefined; + // Stop its preview tracks before clearing, otherwise camera/mic stay live + stopNotConnectedCall(); inactiveCallMediaSources.mic = true; inactiveCallMediaSources.camera = command.media == CallMediaType.Video; inactiveCallMediaSourcesChanged(inactiveCallMediaSources); @@ -436,7 +514,11 @@ const processCommand = (function () { break; case "ice": const remoteIceCandidates = parse(command.iceCandidates); - if (pc) { + if (activeCall && command.session) { + await activeCall.receiveRestartSession(command.session, remoteIceCandidates); + resp = { type: "ok" }; + } + else if (pc) { addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; } @@ -1131,6 +1213,13 @@ const processCommand = (function () { activeCall.localScreenStream.getTracks().forEach((track) => track.stop()); } } + // Call on any path that abandons notConnectedCall, otherwise its preview camera/mic tracks stay live. + function stopNotConnectedCall() { + if (notConnectedCall) { + notConnectedCall.localStream.getTracks().forEach((track) => track.stop()); + notConnectedCall = undefined; + } + } function resetVideoElements() { const videos = getVideoElements(); if (!videos) diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js index e6828817ee..be1c820b4e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -2,7 +2,7 @@ // Override defaults to enable worker on Chrome and Safari useWorker = typeof window.Worker !== "undefined"; isDesktop = true; -// Create WebSocket connection. +// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server. const socket = new WebSocket(`ws://${location.host}${location.search}`); socket.addEventListener("open", (_event) => { console.log("Opened socket"); @@ -192,4 +192,4 @@ function updateCallInfoView(state, description) { document.getElementById("state").innerText = state; document.getElementById("description").innerText = description; } -//# sourceMappingURL=ui.js.map +//# sourceMappingURL=ui.js.map \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index f22ed52357..e5542d5da1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -313,7 +313,9 @@ actual object CallSoundsPlayer: CallSoundsPlayerInterface { override fun startConnectingCallSound(scope: CoroutineScope) { // Taken from https://github.com/TelegramOrg/Telegram-Android // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE - start("/media/connecting_call.mp3", 3000, scope) + // the delay is the length of the track exactly, so it repeats with no delay, which already happens on ios and android + // Android (isLooping) and iOS (which sleeps for the track duration) + start("/media/connecting_call.mp3", 3536, scope) } override fun startInCallSound(scope: CoroutineScope) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 881b9ee168..616f46dab6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -8,6 +8,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import chat.simplex.res.* import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString @@ -55,13 +56,24 @@ actual fun ActiveCallView() { CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates) } is WCallResponse.Connection -> - try { + // the call is restored and NOT sent to the core + // Makes sure the core DOES NOT record the duration wrong if a reconnect happens + if (r.state.connectionState == "reconnecting") { + if (call.callState != CallState.Reconnecting) { + CallSoundsPlayer.startConnectingCallSound(scope) + } + chatModel.activeCall.value = call.copy(callState = CallState.Reconnecting) + } else try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) + if (call.callState == CallState.Reconnecting) { + CallSoundsPlayer.stop() + } + // connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = call.connectedAt ?: Clock.System.now()) } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -194,21 +206,20 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } LaunchedEffect(Unit) { - snapshotFlow { callCommand.firstOrNull() } - .distinctUntilChanged() - .filterNotNull() - .collect { - while (connections.isEmpty()) { - delay(100) - } - while (callCommand.isNotEmpty()) { - val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") - if (cmd != null) { - processCommand(cmd) - } + while (isActive) { + // waiting for the first command would miss a command equal to the previous one + snapshotFlow { callCommand.isNotEmpty() }.first { it } + while (connections.isEmpty()) { + delay(100) + } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirstOrNull() + Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") + if (cmd != null) { + processCommand(cmd) } } + } } } diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md index fae7f42031..d29c1d4478 100644 --- a/apps/multiplatform/product/flows/calling.md +++ b/apps/multiplatform/product/flows/calling.md @@ -99,6 +99,7 @@ Both platforms share a common signaling flow through the Haskell core API. ``` Outgoing: WaitCapabilities -> InvitationSent -> OfferSent -> AnswerReceived -> Negotiated -> Connected -> Ended Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ended +Reconnection: Connected -> Reconnecting -> Connected | Ended ``` | State | Description | @@ -111,8 +112,17 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende | `AnswerReceived` | Caller received SDP answer | | `Negotiated` | ICE negotiation complete | | `Connected` | Media flowing | +| `Reconnecting` | Connection lost, waiting for ICE restart | | `Ended` | Call terminated | +### 3.1 Reconnection + +1. A connected call loses its WebRTC connection, e.g. when the device switches between Wi-Fi and mobile network. +2. The WebRTC engine reports `"reconnecting"` connection state; the app shows "reconnecting…" and plays the connecting sound. This state is not reported to the core, and `connectedAt` is kept, so call duration continues. +3. The callee's WebRTC engine (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer in `WCallResponse.Ice` with `session` via `apiSendCallExtraInfo`. The caller answers the same way. +4. When the connection is restored, the call returns to `Connected` and the sound stops. +5. If the connection is not restored in 30 seconds, the call ends as disconnected. Clients without ICE restart support ignore the restart offer, so the call ends in this case. + --- ## 4. Ending a Call @@ -184,7 +194,7 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende | `apiRejectCall(rh, contact)` | Reject incoming call | | `apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` | Send SDP offer | | `apiSendCallAnswer(rh, contact, rtcSession, rtcIceCandidates)` | Send SDP answer | -| `apiSendCallExtraInfo(rh, contact, rtcIceCandidates)` | Send additional ICE candidates | +| `apiSendCallExtraInfo(rh, contact, rtcSession, rtcIceCandidates)` | Send additional ICE candidates, or ICE restart offer/answer | | `apiEndCall(rh, contact)` | End active call | | `apiCallStatus(rh, contact, status)` | Report WebRTC connection status | diff --git a/apps/multiplatform/product/views/call.md b/apps/multiplatform/product/views/call.md index 51d323874c..1d4f3b881b 100644 --- a/apps/multiplatform/product/views/call.md +++ b/apps/multiplatform/product/views/call.md @@ -71,8 +71,8 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | 1. Capabilities | `WCallResponse.Capabilities` | Local capabilities reported; `apiSendCallInvitation()` called | | 2. Offer | `WCallResponse.Offer` | SDP offer + ICE candidates sent via `apiSendCallOffer()` | | 3. Answer | `WCallResponse.Answer` | SDP answer + ICE candidates sent via `apiSendCallAnswer()` | -| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates exchanged via `apiSendCallExtraInfo()` | -| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success | +| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates, and ICE restart offer/answer in `session`, exchanged via `apiSendCallExtraInfo()` | +| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success, `CallState.Reconnecting` when a connected call loses connection | | 6. Connected | `WCallResponse.Connected` | Connection info (relay/direct) stored in `call.connectionInfo` | | 7. PeerMedia | `WCallResponse.PeerMedia` | Remote party media source changes (mic, camera, screen) | | 8. Media control | `WCallCommand.Media` | Toggle local media sources (mic, camera, screen audio/video) | @@ -90,14 +90,15 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | `OfferReceived` | Callee received SDP offer | | `AnswerReceived` | Caller received SDP answer | | `Negotiated` | ICE negotiation complete | -| `Connected` | WebRTC media flowing; `connectedAt` timestamp set | +| `Connected` | WebRTC media flowing; `connectedAt` timestamp set on the first connection | +| `Reconnecting` | Connection lost; "reconnecting…" shown while ICE restarts, call ends after 30 seconds without connection | | `Ended` | Call terminated | ### Call Sounds | Sound | Trigger | |---|---| -| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent | +| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent, and while reconnecting | | In-call sound | `CallSoundsPlayer.startInCallSound()` when delivery receipt received | | Ringtone | `SoundPlayer.start()` for incoming calls | | End vibration | `CallSoundsPlayer.vibrate()` on call end (if was connected) | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md index fdc072f080..e404c02b05 100644 --- a/apps/multiplatform/spec/api.md +++ b/apps/multiplatform/spec/api.md @@ -225,7 +225,7 @@ All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.k | `apiRejectCall` | `rh: Long?, contact: Contact` | Reject an incoming call | [L1854](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | | `apiSendCallOffer` | `rh, contact, rtcSession, rtcIceCandidates, media, capabilities` | Send a WebRTC call offer | [L1859](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | | `apiSendCallAnswer` | `rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String` | Send a WebRTC call answer | [L1866](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | -| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcIceCandidates: String` | Send additional ICE candidates during a call | [L1872](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | +| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String` | Send additional ICE candidates or ICE restart offer/answer during a call | [L2156](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | | `apiEndCall` | `rh: Long?, contact: Contact` | End an active call | [L1878](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | | `apiCallStatus` | `rh: Long?, contact: Contact, status: WebRTCCallStatus` | Report call status updates to the core | [L1883](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index 85303e1dad..a46de4f45c 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -43,6 +43,7 @@ enum class CallState { AnswerReceived, // SDP answer received from peer Negotiated, // ICE negotiation in progress Connected, // Media flowing + Reconnecting, // Connection lost, waiting for ICE to recover Ended; // Call terminated } ``` @@ -53,6 +54,8 @@ enum class CallState { State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`). +**Reconnection**: `Connected` -> `Reconnecting` -> `Connected` or `Ended`. When a connected call loses its WebRTC connection, `call.js` reports `"reconnecting"` connection state instead of ending the call. The app sets `CallState.Reconnecting` and plays the connecting sound; this state is not sent to the core with `apiCallStatus`, so the call item duration is not affected, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds until reconnected: the restart offer and the answer are sent as `WCallResponse.Ice` / `WCallCommand.Ice` with `session`, via `apiSendCallExtraInfo` (`x.call.extra` with `rtcSession`). If the connection is not restored in 30 seconds, `call.js` reports `"disconnected"` and ends the call. + --- @@ -140,7 +143,7 @@ Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/com | `apiRejectCall` | [L1854](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | Reject an incoming call | | `apiSendCallOffer` | [L1859](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | Send SDP offer with ICE candidates and capabilities | | `apiSendCallAnswer` | [L1866](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | Send SDP answer with ICE candidates | -| `apiSendCallExtraInfo` | [L1872](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | Send additional ICE candidates discovered after initial exchange | +| `apiSendCallExtraInfo` | [L2156](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | Send additional ICE candidates discovered after initial exchange, or ICE restart offer/answer in `rtcSession` | | `apiEndCall` | [L1878](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | Terminate a call | | `apiCallStatus` | [L1883](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | Report WebRTC connection status to the backend | diff --git a/cabal.project b/cabal.project index fea595d685..d8868c31c1 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: cba13823567d7cd564d8a1f354046174fba375d8 + tag: aabf4cdab98f9fb4c92dc6d9ec661a5f08f0e80f source-repository-package type: git diff --git a/docs/contributing/RELEASES.md b/docs/contributing/RELEASES.md index ea6123e4c8..e0b80fda5b 100644 --- a/docs/contributing/RELEASES.md +++ b/docs/contributing/RELEASES.md @@ -54,7 +54,7 @@ pypi.. commit of the publication 3. Run of `build.yml`: - `maybe-release`: version claimed for the commit in `releases.toml`, draft `core-` created - - build jobs: 6 library zips uploaded + - build jobs: 6 library zips uploaded; macOS libraries signed with the Developer ID and notarized - `releases`: release published - `publish`: `@simplex-chat/types` published, when its version is unpublished - `record`: types version recorded @@ -141,6 +141,7 @@ Versions unpublished after a failure: published by the next push to the branch. - simplex-chat-libs: release immutability enabled - secret `NODEJS_REPO_TOKEN`: contents of simplex-chat-libs, read and write +- secrets `APPLE_SIMPLEX_SIGNING_KEYCHAIN`, `APPLE_SIMPLEX_NOTARIZATION_APPLE_ID` and `APPLE_SIMPLEX_NOTARIZATION_PASSWORD`: signing and notarization of the macOS libraries, shared with the desktop app build - 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 diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 8cd655d0c0..a95e7ee75f 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -286,7 +286,9 @@ These message are used for WebRTC calls: 3. `x.call.answer`: to continue with call connection the initiating clients must reply with `x.call.answer` message. This message contains WebRTC answer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message. -4. `x.call.end` message is sent to notify the other party that the call is terminated. +4. `x.call.extra` with `rtcSession` property: when an established connection is lost, the client that sent `x.call.offer` restarts ICE and sends the new offer in `rtcSession` of `x.call.extra`. The other client replies with the answer in `rtcSession` of `x.call.extra`. Clients that do not support it ignore this property, and the call ends when reconnection times out. + +5. `x.call.end` message is sent to notify the other party that the call is terminated. Call messages have their own version, independent of the chat protocol version. The initiating client sends the range of call versions it supports in `callVRange` property of `x.call.inv`. The receiving client chooses the highest version supported by both clients and sends it in `callVersion` property of `x.call.offer`. The initiating client rejects the offer if this version is not in the range it sent. A missing range or version means version 1. diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 5fb4794094..a9bb2d8735 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -364,6 +364,9 @@ "rtcExtraInfo": { "properties": { "rtcIceCandidates": {"type": "string"} + }, + "optionalProperties": { + "rtcSession": {"type": "string"} } } } diff --git a/packages/simplex-chat-webrtc/README.md b/packages/simplex-chat-webrtc/README.md index b8db78dbb1..087a7e6fef 100644 --- a/packages/simplex-chat-webrtc/README.md +++ b/packages/simplex-chat-webrtc/README.md @@ -4,3 +4,20 @@ npm i npm run build ``` + +## Tests + +End-to-end tests run two headless Chromium peers with `call.js` and simulate network outages by relaying media via UDP sockets controlled by the test: + +``` +npx playwright-core install chromium +npm test +``` + +Set `CHROME` to use another Chromium executable, and `VERBOSE=1` to log signaling and connection states. + +Full-stack tests in `test/fullstack.test.js` also pass call signaling via two chat cores and a local SMP server. They run when `SIMPLEX_CHAT` and `SMP_SERVER` are set to `simplex-chat` and `smp-server` executables: + +``` +SIMPLEX_CHAT=path/to/simplex-chat SMP_SERVER=path/to/smp-server npm test +``` diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index a6598b6ce7..e49a1484c2 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -8,7 +8,7 @@ "dist" ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "tsc && node --test test/*.test.js", "build": "prettier --write --ignore-unknown . && tsc && ./copy" }, "repository": { @@ -31,6 +31,7 @@ "husky": "^7.0.4", "isomorphic-webcrypto": "^2.3.8", "lint-staged": "^12.4.1", + "playwright-core": "^1.64.0", "prettier": "^2.6.2", "typescript": "^4.6.4" }, diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 8441560013..aa207939e8 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -142,6 +142,7 @@ interface WCallAnswer extends IWCallCommand, IWCallResponse { interface WCallIceCandidates extends IWCallCommand, IWCallResponse { type: "ice" iceCandidates: string // JSON strings for RTCIceCandidateInit[] + session?: string // JSON string for RTCSessionDescriptionInit of ICE restart offer or answer } interface WCEnableMedia extends IWCallCommand { @@ -281,6 +282,7 @@ interface Call { // controls whether transceiver's track was replaced already or set initially (in video call) cameraTrackWasSetBefore: boolean peerHasOldVersion: boolean + receiveRestartSession: (session: string, iceCandidates: RTCIceCandidateInit[]) => Promise } interface NotConnectedCall { @@ -306,6 +308,8 @@ let inactiveCallMediaSources: CallMediaSources = { let activeCall: Call | undefined let notConnectedCall: NotConnectedCall | undefined let answerTimeout = 30_000 +let reconnectTimeout = 30_000 +let iceRestartInterval = 10_000 var useWorker = false var isDesktop = false var localizedState = "" @@ -357,7 +361,8 @@ const processCommand = (function () { return { peerConnectionConfig: { iceServers: iceServers ?? defaultIceServers, - iceCandidatePoolSize: 10, + // Chrome gathers no candidates on ICE restart when candidates pool is used + iceCandidatePoolSize: 0, encodedInsertableStreams, iceTransportPolicy: relay ? "relay" : "all", // needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers @@ -486,10 +491,14 @@ const processCommand = (function () { layout: notConnectedCall?.layout ?? LayoutType.Default, cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0, peerHasOldVersion: false, + receiveRestartSession, } localOrPeerMediaSourcesChanged(call) await setupMediaStreams(call) let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) + let reconnectingTimeout: number | undefined + let iceRestartTimeout: number | undefined + let wasConnected = false if (pc.connectionState) { pc.addEventListener("connectionstatechange", connectionStateChange) } else { @@ -500,33 +509,45 @@ const processCommand = (function () { async function connectionStateChange() { // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView) // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70) - if (pc.connectionState !== "failed") connectionHandler() + if (pc.connectionState !== "failed" || wasConnected) connectionHandler() } - async function connectionHandler() { + async function connectionHandler(reconnectTimedOut = false) { + let state: string = + pc.connectionState ?? + (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" + ? pc.iceConnectionState + : pc.iceConnectionState == "completed" + ? "connected" + : "connecting") /* webView 69-70 doesn't have connectionState yet */ + if (wasConnected) { + if (reconnectTimedOut) { + reconnectingTimeout = undefined + if (activeCall !== call || state == "connected") return + state = "disconnected" + } else if (state == "disconnected" || state == "failed") { + if (reconnectingTimeout !== undefined) return + reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout) + if (isIceRestarter()) restartIce() + state = "reconnecting" + } else if (state == "connecting" || state == "new") { + return + } + } sendMessageToNative({ resp: { type: "connection", state: { - connectionState: - pc.connectionState ?? - (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" - ? pc.iceConnectionState - : pc.iceConnectionState == "completed" - ? "connected" - : "connecting") /* webView 69-70 doesn't have connectionState yet */, + connectionState: state, iceConnectionState: pc.iceConnectionState, iceGatheringState: pc.iceGatheringState, signalingState: pc.signalingState, }, }, }) - if ( - pc.connectionState == "disconnected" || - pc.connectionState == "failed" || - (!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed")) - ) { + if (state == "disconnected" || state == "failed") { clearConnectionTimeout() + clearReconnecting() if (pc.connectionState) { pc.removeEventListener("connectionstatechange", connectionStateChange) } else { @@ -536,8 +557,10 @@ const processCommand = (function () { setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) } endCall() - } else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) { + } else if (state == "connected") { clearConnectionTimeout() + clearReconnecting() + wasConnected = true const stats = (await pc.getStats()) as Map for (const stat of stats.values()) { const {type, state} = stat @@ -564,6 +587,51 @@ const processCommand = (function () { connectionTimeout = undefined } } + + function clearReconnecting() { + clearTimeout(reconnectingTimeout) + reconnectingTimeout = undefined + clearTimeout(iceRestartTimeout) + iceRestartTimeout = undefined + } + + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + function isIceRestarter(): boolean { + return pc.localDescription?.type == "offer" + } + + async function restartIce() { + if (activeCall !== call || reconnectingTimeout === undefined) return + iceRestartTimeout = setTimeout(restartIce, iceRestartInterval) + // the previous restart offer is not answered yet + if (pc.signalingState != "stable") return + try { + const iceCandidates = getIceCandidates(pc, config) + const offer = await pc.createOffer({iceRestart: true}) + await pc.setLocalDescription(offer) + sendMessageToNative({resp: {type: "ice", session: serialize(offer), iceCandidates: await iceCandidates}}) + } catch (e) { + console.log("ICE restart error", e) + } + } + + async function receiveRestartSession(session: string, iceCandidates: RTCIceCandidateInit[]) { + const desc: RTCSessionDescriptionInit = parse(session) + const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc)) + if (desc.type == "offer" && !isIceRestarter()) { + await pc.setRemoteDescription(remoteDesc) + const localIceCandidates = getIceCandidates(pc, config) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + addIceCandidates(pc, iceCandidates) + sendMessageToNative({resp: {type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates}}) + } else if (desc.type == "answer" && pc.signalingState == "have-local-offer") { + await pc.setRemoteDescription(remoteDesc) + addIceCandidates(pc, iceCandidates) + } else { + throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`) + } + } } function serialize(x: T): string { @@ -729,7 +797,10 @@ const processCommand = (function () { break case "ice": const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - if (pc) { + if (activeCall && command.session) { + await activeCall.receiveRestartSession(command.session, remoteIceCandidates) + resp = {type: "ok"} + } else if (pc) { addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} } else { diff --git a/packages/simplex-chat-webrtc/test/chat.js b/packages/simplex-chat-webrtc/test/chat.js new file mode 100644 index 0000000000..bf35f775d7 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/chat.js @@ -0,0 +1,147 @@ +// Runs a local SMP server and chat cores with WebSocket API, so that call signaling goes via the chat protocol. +const {spawn, execFileSync} = require("child_process") +const fs = require("fs") +const net = require("net") +const path = require("path") +const {sleep, log} = require("./harness") + +const START_TIMEOUT = 30000 + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const {port} = server.address() + server.close(() => resolve(port)) + }) + }) +} + +async function waitUntil(what, check) { + const deadline = Date.now() + START_TIMEOUT + while (!(await check())) { + if (Date.now() > deadline) throw new Error(`${what} did not start in ${START_TIMEOUT}ms`) + await sleep(200) + } +} + +function canConnect(port) { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => resolve(false)) + }) +} + +function spawnLogged(name, bin, args, env) { + const proc = spawn(bin, args, {env, stdio: ["ignore", "pipe", "pipe"]}) + const output = (data) => log(`${name}:`, data.toString().trimEnd()) + proc.stdout.on("data", output) + proc.stderr.on("data", output) + return proc +} + +async function startSmpServer(bin, dir) { + const env = {...process.env, SMP_SERVER_CFG_PATH: path.join(dir, "cfg"), SMP_SERVER_LOG_PATH: path.join(dir, "log")} + const init = ["init", "--ip", "127.0.0.1", "--no-password", "--disable-web", "--disable-store-log", "-y"] + const fingerprint = execFileSync(bin, init, {env, stdio: ["ignore", "pipe", "pipe"]}) + .toString() + .match(/Fingerprint: (\S+)/)[1] + const port = await freePort() + const iniFile = path.join(dir, "cfg", "smp-server.ini") + const ini = fs + .readFileSync(iniFile, "utf8") + .replace(/^port = .*$/m, `port = ${port}`) + .replace(/^host = .*$/m, "host = localhost") + fs.writeFileSync(iniFile, ini) + const proc = spawnLogged("smp-server", bin, ["start"], env) + await waitUntil("SMP server", () => canConnect(port)) + return {address: `smp://${fingerprint}@localhost:${port}`, stop: () => proc.kill()} +} + +class ChatClient { + constructor(name, proc, ws) { + this.name = name + this.proc = proc + this.ws = ws + this.corrId = 0 + this.pending = new Map() + this.events = [] + this.waiters = [] + this.onEvent = async () => {} + ws.addEventListener("message", (e) => this.receive(JSON.parse(e.data))) + } + + static async start(bin, dir, name, smpServer) { + const port = await freePort() + const args = ["-d", path.join(dir, name), "-s", smpServer, "-p", String(port), "--user-display-name", name] + const proc = spawnLogged(`chat ${name}`, bin, args, process.env) + await waitUntil(`chat ${name}`, () => canConnect(port)) + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + await new Promise((resolve, reject) => { + ws.addEventListener("open", resolve, {once: true}) + ws.addEventListener("error", reject, {once: true}) + }) + return new ChatClient(name, proc, ws) + } + + receive({corrId, resp}) { + if (corrId) { + this.pending.get(corrId)?.(resp) + this.pending.delete(corrId) + return + } + log(`${this.name} event:`, resp.type) + this.events.push(resp) + this.onEvent(resp).catch((e) => log(`${this.name} event error`, e)) + this.waiters = this.waiters.filter((w) => !(w.pred(resp) && (w.resolve(resp), true))) + } + + async cmd(cmd) { + const corrId = String(++this.corrId) + log(`${this.name} cmd:`, cmd.slice(0, 100)) + const resp = await new Promise((resolve) => { + this.pending.set(corrId, resolve) + this.ws.send(JSON.stringify({corrId, cmd})) + }) + if (resp.type === "chatCmdError") throw new Error(`${this.name} ${cmd.split(" ", 2).join(" ")}: ${JSON.stringify(resp)}`) + return resp + } + + waitEvent(pred, timeout, what) { + const found = this.events.find(pred) + if (found) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const waiter = { + pred, + resolve: (resp) => { + clearTimeout(timer) + resolve(resp) + }, + } + this.waiters.push(waiter) + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w !== waiter) + reject(new Error(`${this.name}: no ${what} event in ${timeout}ms`)) + }, timeout) + }) + } + + stop() { + this.ws.close() + this.proc.kill() + } +} + +async function connectContacts(alice, bob) { + const inv = await alice.cmd("/c") + await bob.cmd(`/c ${inv.connLinkInvitation.connFullLink}`) + const connected = (c) => c.waitEvent((e) => e.type === "contactConnected", START_TIMEOUT, "contactConnected") + const [a, b] = await Promise.all([connected(alice), connected(bob)]) + return [a.contact.contactId, b.contact.contactId] +} + +module.exports = {startSmpServer, ChatClient, connectContacts} diff --git a/packages/simplex-chat-webrtc/test/fullstack.test.js b/packages/simplex-chat-webrtc/test/fullstack.test.js new file mode 100644 index 0000000000..06711845a3 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/fullstack.test.js @@ -0,0 +1,188 @@ +// Calls between two chat cores connected via a local SMP server, with the test playing the native app: +// it passes messages between call.js and the core API as the Android and desktop apps do. +const {test} = require("node:test") +const assert = require("node:assert/strict") +const fs = require("fs") +const os = require("os") +const path = require("path") +const {openPeers, sleep} = require("./harness") +const {startSmpServer, ChatClient, connectContacts} = require("./chat") + +const {SIMPLEX_CHAT, SMP_SERVER} = process.env +const MEDIA = "audio" +const DISCONNECT_DETECTION = 10000 +const RECONNECT = 20000 +const SIGNALING = 15000 +const CORE_CALL_STATUSES = ["connecting", "connected", "disconnected", "failed"] + +async function withChatCall({useWorker = false}, run) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "simplex-call-test-")) + const stops = [] + try { + const smp = await startSmpServer(SMP_SERVER, path.join(dir, "smp")) + stops.push(smp.stop) + const alice = await ChatClient.start(SIMPLEX_CHAT, dir, "alice", smp.address) + stops.push(() => alice.stop()) + const bob = await ChatClient.start(SIMPLEX_CHAT, dir, "bob", smp.address) + stops.push(() => bob.stop()) + const [aliceContact, bobContact] = await connectContacts(alice, bob) + const peers = await openPeers({useWorker}) + stops.push(peers.close) + // alice calls bob: a is alice's call.js, b is bob's call.js + const call = {...peers, alice, bob, aliceContact, bobContact} + connectApp(call.a, alice, aliceContact, peers) + connectApp(call.b, bob, bobContact, peers) + await startCall(call) + await run(call) + } finally { + for (const stop of stops.reverse()) await stop() + fs.rmSync(dir, {recursive: true, force: true}) + } +} + +function connectApp(peer, chat, contactId, {network, signaling}) { + peer.onResponse = async (resp) => { + switch (resp.type) { + case "ice": { + const {session, iceCandidates} = await network.routeSignal(peer.name, resp.session, resp.iceCandidates) + const extraInfo = JSON.stringify({rtcSession: session, rtcIceCandidates: iceCandidates}) + signaling.send(() => chat.cmd(`/_call extra @${contactId} ${extraInfo}`)) + break + } + case "connection": + if (CORE_CALL_STATUSES.includes(resp.state.connectionState)) { + await chat.cmd(`/_call status @${contactId} ${resp.state.connectionState}`) + } + break + case "ended": + await chat.cmd(`/_call end @${contactId}`).catch(() => {}) + break + } + } + chat.onEvent = async (event) => { + switch (event.type) { + case "callExtraInfo": + await peer.command({type: "ice", iceCandidates: event.extraInfo.rtcIceCandidates, session: event.extraInfo.rtcSession}) + break + case "callEnded": + await peer.command({type: "end"}) + break + } + } +} + +async function startCall({a, b, network, alice, bob, aliceContact, bobContact}) { + const capabilities = await a.command({type: "capabilities", media: MEDIA}) + assert.equal(capabilities.resp.type, "capabilities") + const callType = {media: MEDIA, capabilities: capabilities.resp.capabilities} + await alice.cmd(`/_call invite @${aliceContact} ${JSON.stringify(callType)}`) + + const {callInvitation} = await bob.waitEvent((e) => e.type === "callInvitation", SIGNALING, "callInvitation") + const offer = await b.command({type: "start", media: MEDIA, aesKey: callInvitation.sharedKey, iceServers: []}) + assert.equal(offer.resp.type, "offer", JSON.stringify(offer.resp)) + const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) + const callOffer = { + callType: {media: MEDIA, capabilities: offer.resp.capabilities}, + rtcSession: {rtcSession: offerSignal.session, rtcIceCandidates: offerSignal.iceCandidates}, + } + await bob.cmd(`/_call offer @${bobContact} ${JSON.stringify(callOffer)}`) + + const offered = await alice.waitEvent((e) => e.type === "callOffer", SIGNALING, "callOffer") + const answer = await a.command({ + type: "offer", + offer: offered.offer.rtcSession, + iceCandidates: offered.offer.rtcIceCandidates, + media: MEDIA, + aesKey: offered.sharedKey, + iceServers: [], + }) + assert.equal(answer.resp.type, "answer", JSON.stringify(answer.resp)) + const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) + const callAnswer = {rtcSession: answerSignal.session, rtcIceCandidates: answerSignal.iceCandidates} + await alice.cmd(`/_call answer @${aliceContact} ${JSON.stringify(callAnswer)}`) + + const answered = await bob.waitEvent((e) => e.type === "callAnswer", SIGNALING, "callAnswer") + const ok = await b.command({type: "answer", answer: answered.answer.rtcSession, iceCandidates: answered.answer.rtcIceCandidates}) + assert.equal(ok.resp.type, "ok", JSON.stringify(ok.resp)) + await Promise.all([a.waitForState("connected", RECONNECT), b.waitForState("connected", RECONNECT)]) +} + +async function assertAudioDecoded(a, b) { + const before = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] + await sleep(2000) + const after = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] + assert.ok(after[0] > before[0], `A decoded no audio: ${before[0]} -> ${after[0]}`) + assert.ok(after[1] > before[1], `B decoded no audio: ${before[1]} -> ${after[1]}`) +} + +async function callItem(chat, contactId) { + const r = await chat.cmd(`/_get chat @${contactId} count=1`) + return r.chat.chatItems[0].content +} + +function restartSessionsReceived(chat) { + return chat.events.filter((e) => e.type === "callExtraInfo" && e.extraInfo.rtcSession).length +} + +const skip = !(SIMPLEX_CHAT && SMP_SERVER) && "set SIMPLEX_CHAT and SMP_SERVER to chat CLI and SMP server executables" + +test("calls via chat core", {skip}, async (t) => { + for (const [app, useWorker] of [ + ["android", false], + ["desktop", true], + ]) { + await t.test(`${app}: encrypted call reconnects with ICE restart after network path change`, () => + withChatCall({useWorker}, async ({a, b, network, alice, bob, aliceContact, bobContact}) => { + assert.ok(a.messages.find((m) => m.resp.type === "capabilities").resp.capabilities.encryption, "call is not encrypted") + assert.equal(await a.page.evaluate(() => !!activeCall.worker), useWorker, "encryption worker") + await assertAudioDecoded(a, b) + assert.deepEqual(await callItem(alice, aliceContact), {type: "sndCall", status: "progress", duration: 0}) + const connectedAt = Date.now() + + const from = [a.messages.length, b.messages.length] + network.down = true + network.changePath() + await Promise.all([ + a.waitForState("reconnecting", DISCONNECT_DETECTION, from[0]), + b.waitForState("reconnecting", DISCONNECT_DETECTION, from[1]), + ]) + await sleep(3000) + network.down = false + await Promise.all([a.waitForState("connected", RECONNECT, from[0]), b.waitForState("connected", RECONNECT, from[1])]) + await assertAudioDecoded(a, b) + assert.ok(restartSessionsReceived(alice) > 0, "alice received no ICE restart offer") + assert.ok(restartSessionsReceived(bob) > 0, "bob received no ICE restart answer") + assert.equal((await callItem(alice, aliceContact)).status, "progress") + assert.equal((await callItem(bob, bobContact)).status, "progress") + + await alice.cmd(`/_call end @${aliceContact}`) + await a.command({type: "end"}) + await bob.waitEvent((e) => e.type === "callEnded", SIGNALING, "callEnded") + const minDuration = Math.floor((Date.now() - connectedAt) / 1000) - 1 + const aliceItem = await callItem(alice, aliceContact) + assert.equal(aliceItem.status, "ended") + assert.ok(aliceItem.duration >= minDuration, `call duration ${aliceItem.duration} < ${minDuration}`) + }) + ) + } + + await t.test("call ends in both chats when reconnection times out", () => + withChatCall({}, async ({a, b, network, alice, bob, aliceContact, bobContact}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 5000}), b.setTimeouts({reconnectTimeout: 5000})]) + network.down = true + // the first party to time out ends the call, and the other party ends it on "callEnded" event + const endedBy = (peer, other) => peer.waitForEnded(DISCONNECT_DETECTION + 10000).then(() => other) + const other = await Promise.any([endedBy(a, bob), endedBy(b, alice)]) + await other.waitEvent((e) => e.type === "callEnded", SIGNALING, "callEnded") + network.down = false + for (const [chat, contactId] of [ + [alice, aliceContact], + [bob, bobContact], + ]) { + const item = await callItem(chat, contactId) + assert.equal(item.status, "ended", `${chat.name} call item: ${JSON.stringify(item)}`) + assert.ok(item.duration >= 5, `${chat.name} call duration ${item.duration}`) + } + }) + ) +}) diff --git a/packages/simplex-chat-webrtc/test/harness.js b/packages/simplex-chat-webrtc/test/harness.js new file mode 100644 index 0000000000..d8e1668ce1 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/harness.js @@ -0,0 +1,317 @@ +// Runs two headless Chromium peers with call.js; the test plays the native app and the chat core. +// Media can only flow through UDP relays controlled by the test, so outages and path changes are simulated +// by dropping packets in the relays. +const http = require("http") +const fs = require("fs") +const path = require("path") +const dgram = require("dgram") +const {chromium} = require("playwright-core") +const LZString = require("lz-string") + +const ROOT = path.join(__dirname, "..") +const PAGE_FILES = { + "/android/call.html": path.join(ROOT, "src/android/call.html"), + "/android/style.css": path.join(ROOT, "src/android/style.css"), + "/call.js": path.join(ROOT, "dist/call.js"), + "/lz-string.min.js": path.join(ROOT, "node_modules/lz-string/libs/lz-string.min.js"), +} +const CONTENT_TYPES = {".html": "text/html", ".js": "text/javascript", ".css": "text/css"} + +const startTime = Date.now() +function log(...args) { + if (process.env.VERBOSE) console.log(((Date.now() - startTime) / 1000).toFixed(1).padStart(6), ...args) +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const decode = (s) => JSON.parse(LZString.decompressFromBase64(s)) +const encode = (x) => LZString.compressToBase64(JSON.stringify(x)) + +function servePage() { + const server = http.createServer((req, res) => { + const file = PAGE_FILES[new URL(req.url, "http://localhost").pathname] + if (!file) return res.writeHead(404).end() + res.writeHead(200, {"Content-Type": CONTENT_TYPES[path.extname(file)]}).end(fs.readFileSync(file)) + }) + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))) +} + +function bindUdp(ip) { + return new Promise((resolve) => { + const socket = dgram.createSocket("udp4") + socket.bind(0, ip, () => resolve(socket)) + }) +} + +// Relays one ICE generation: each peer is told that the other peer is at the relay socket. +class Relay { + constructor(network, ip) { + this.network = network + this.ip = ip + this.ufrag = {A: undefined, B: undefined} + this.real = {A: undefined, B: undefined} + this.dead = false + this.forwarded = 0 + } + + async open() { + this.sockets = {A: await bindUdp(this.ip), B: await bindUdp(this.ip)} + this.sockets.A.on("message", (msg, from) => this.forward(msg, from, "B", "A")) + this.sockets.B.on("message", (msg, from) => this.forward(msg, from, "A", "B")) + } + + // socket A is advertised to B as A's address, so packets on it come from B and go to A via socket B + forward(msg, from, sender, receiver) { + if (this.dead || this.network.down) return + this.real[sender] = from + const to = this.real[receiver] + if (!to) return + this.forwarded++ + this.sockets[sender].send(msg, to.port, to.address) + } + + port(side) { + return this.sockets[side].address().port + } + + close() { + this.sockets.A.close() + this.sockets.B.close() + } +} + +class Network { + constructor() { + this.down = false + this.relays = [] + } + + // Returns the rewritten candidate, or undefined if the candidate cannot be relayed. + async route(side, candidate) { + const [, component, protocol, , ip, port, , type] = candidate.candidate.split(" ") + if (protocol.toLowerCase() !== "udp" || type !== "host" || ip.includes(":") || component !== "1") return + const ufrag = candidate.usernameFragment + if (this.relays.some((r) => r.ufrag[side] === ufrag)) return + let relay = this.relays.find((r) => !r.dead && r.ufrag[side] === undefined) + if (!relay) { + relay = new Relay(this, ip) + await relay.open() + this.relays.push(relay) + } + relay.ufrag[side] = ufrag + relay.real[side] = {address: ip, port: +port} + log(`relay ${this.relays.indexOf(relay)}: ${side} ${ip}:${port} -> ${relay.ip}:${relay.port(side)}`) + const parts = candidate.candidate.split(" ") + parts[4] = relay.ip + parts[5] = String(relay.port(side)) + return {...candidate, candidate: parts.join(" ")} + } + + // Takes and returns compressed session description and candidates, as in signaling messages + async routeSignal(side, session, iceCandidates) { + // candidates in SDP would connect the peers directly, bypassing the relays + if (session && decode(session).sdp.includes("a=candidate:")) throw new Error(`${side}: SDP has ICE candidates`) + const routed = [] + for (const c of decode(iceCandidates)) { + const r = c?.candidate && (await this.route(side, c)) + if (r) routed.push(r) + } + return {session, iceCandidates: encode(routed)} + } + + // Addresses of all current ICE generations become unreachable, as when a device switches networks. + changePath() { + for (const relay of this.relays) relay.dead = true + } + + close() { + for (const relay of this.relays) relay.close() + } +} + +class Peer { + constructor(name, page, {ignoresIceRestart = false} = {}) { + this.name = name + this.page = page + this.ignoresIceRestart = ignoresIceRestart + this.messages = [] + this.waiters = [] + this.onResponse = async () => {} + } + + async open(url) { + await this.page.exposeFunction("sendToTest", (json) => this.receive(JSON.parse(json))) + this.page.on("console", (m) => log(`${this.name} console:`, m.text().slice(0, 200))) + this.page.on("pageerror", (e) => log(`${this.name} page error:`, e.message)) + await this.page.goto(url) + await this.page.evaluate(() => { + sendMessageToNative = (msg) => window.sendToTest(JSON.stringify(msg)) + }) + } + + receive(msg) { + const resp = msg.resp + log(`${this.name} <-`, resp.type, resp.state?.connectionState ?? "", resp.session ? "with session" : "") + this.messages.push(msg) + if (!msg.command) this.onResponse(resp).catch((e) => log(`${this.name} response error`, e)) + this.waiters = this.waiters.filter((w) => !(w.pred(msg) && (w.resolve(msg), true))) + } + + connectionStates() { + return this.messages.filter((m) => m.resp.type === "connection").map((m) => m.resp.state.connectionState) + } + + sessionsSent() { + return this.messages.filter((m) => m.resp.type === "ice" && !m.command && m.resp.session).length + } + + ended() { + return this.messages.some((m) => m.resp.type === "ended") + } + + waitFor(pred, timeout, what) { + const found = this.messages.find(pred) + if (found) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const waiter = { + pred, + resolve: (msg) => { + clearTimeout(timer) + resolve(msg) + }, + } + this.waiters.push(waiter) + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w !== waiter) + reject(new Error(`${this.name}: no ${what} in ${timeout}ms, connection states: ${this.connectionStates()}`)) + }, timeout) + }) + } + + waitForState(state, timeout, after = 0) { + const pred = (m) => m.resp.type === "connection" && m.resp.state.connectionState === state && this.messages.indexOf(m) >= after + return this.waitFor(pred, timeout, `"${state}" state`) + } + + waitForEnded(timeout) { + return this.waitFor((m) => m.resp.type === "ended", timeout, "ended") + } + + command(command) { + log(`${this.name} ->`, command.type, command.session ? "with session" : "") + return this.page.evaluate((c) => processCommand({corrId: 0, command: c}), command) + } + + async setTimeouts(timeouts) { + await this.page.evaluate((t) => { + if (t.reconnectTimeout) reconnectTimeout = t.reconnectTimeout + if (t.iceRestartInterval) iceRestartInterval = t.iceRestartInterval + }, timeouts) + } + + async receivedBytes() { + return (await this.inboundAudio()).bytesReceived + } + + // energy grows only when received audio is decoded, i.e. it is decrypted with the right key + async receivedAudioEnergy() { + return (await this.inboundAudio()).totalAudioEnergy + } + + inboundAudio() { + return this.page.evaluate(async () => { + let audio + ;(await activeCall.connection.getStats()).forEach((s) => { + if (s.type === "inbound-rtp" && s.kind === "audio" && (!audio || s.bytesReceived > audio.bytesReceived)) audio = s + }) + return {bytesReceived: audio?.bytesReceived ?? 0, totalAudioEnergy: audio?.totalAudioEnergy ?? 0} + }) + } +} + +// Signaling stops while the network is down and resumes in order, as messages queued in SMP servers. +class Signaling { + constructor(network) { + this.network = network + this.queue = Promise.resolve() + this.closed = false + } + + send(deliver) { + this.queue = this.queue.then(async () => { + while (this.network.down && !this.closed) await sleep(100) + if (!this.closed) await deliver().catch((e) => log("signaling error", e.message)) + }) + } +} + +// useWorker encrypts media in a worker with RTCRtpScriptTransform, as the desktop app does +async function openPeers({answerer = {}, useWorker = false} = {}) { + const server = await servePage() + const url = `http://localhost:${server.address().port}/android/call.html` + const browser = await chromium.launch({ + executablePath: process.env.CHROME, + args: [ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + "--disable-features=WebRtcHideLocalIpsWithMdns", + "--autoplay-policy=no-user-gesture-required", + ], + }) + const newPeer = async (name, opts) => { + const context = await browser.newContext({permissions: ["camera", "microphone"]}) + const peer = new Peer(name, await context.newPage(), opts) + await peer.open(url) + if (useWorker) await peer.page.evaluate(() => (useWorker = true)) + return peer + } + // A sends the call invitation and B accepts it, so B sends the WebRTC offer, as "start" command in the apps + const a = await newPeer("A", answerer) + const b = await newPeer("B") + const network = new Network() + const signaling = new Signaling(network) + const close = async () => { + signaling.closed = true + await browser.close() + network.close() + server.close() + } + return {a, b, network, signaling, close} +} + +// The test relays signaling between the peers directly, as the apps and the chat core would do. +async function startCall({media = "audio", answerer = {}} = {}) { + const peers = await openPeers({answerer}) + const {a, b, network, signaling, close} = peers + const relayIce = (from, to) => async (resp) => { + if (resp.type !== "ice") return + const {session, iceCandidates} = await network.routeSignal(from.name, resp.session, resp.iceCandidates) + signaling.send(() => to.command({type: "ice", iceCandidates, session: to.ignoresIceRestart ? undefined : session})) + } + a.onResponse = relayIce(a, b) + b.onResponse = relayIce(b, a) + + try { + await a.command({type: "capabilities", media}) + const offer = await b.command({type: "start", media, iceServers: []}) + if (offer.resp.type !== "offer") throw new Error(`B start: ${JSON.stringify(offer.resp)}`) + const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) + const answer = await a.command({ + type: "offer", + offer: offerSignal.session, + iceCandidates: offerSignal.iceCandidates, + media, + iceServers: [], + }) + if (answer.resp.type !== "answer") throw new Error(`A offer: ${JSON.stringify(answer.resp)}`) + const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) + const ok = await b.command({type: "answer", answer: answerSignal.session, iceCandidates: answerSignal.iceCandidates}) + if (ok.resp.type !== "ok") throw new Error(`B answer: ${JSON.stringify(ok.resp)}`) + await Promise.all([a.waitForState("connected", 20000), b.waitForState("connected", 20000)]) + } catch (e) { + await close() + throw e + } + return peers +} + +module.exports = {openPeers, startCall, sleep, log} diff --git a/packages/simplex-chat-webrtc/test/reconnect.test.js b/packages/simplex-chat-webrtc/test/reconnect.test.js new file mode 100644 index 0000000000..d7eb9f861e --- /dev/null +++ b/packages/simplex-chat-webrtc/test/reconnect.test.js @@ -0,0 +1,95 @@ +const {test} = require("node:test") +const assert = require("node:assert/strict") +const {startCall, sleep} = require("./harness") + +// Chrome reports "disconnected" about 6 seconds after packets stop +const DISCONNECT_DETECTION = 10000 +const RECONNECT = 20000 + +async function assertMediaFlows(a, b) { + const before = [await a.receivedBytes(), await b.receivedBytes()] + await sleep(2000) + const after = [await a.receivedBytes(), await b.receivedBytes()] + assert.ok(after[0] > before[0], `A received no media: ${before[0]} -> ${after[0]}`) + assert.ok(after[1] > before[1], `B received no media: ${before[1]} -> ${after[1]}`) +} + +async function assertReconnects(a, b, outage) { + const from = [a.messages.length, b.messages.length] + await Promise.all([ + a.waitForState("reconnecting", DISCONNECT_DETECTION, from[0]), + b.waitForState("reconnecting", DISCONNECT_DETECTION, from[1]), + ]) + await outage() + await Promise.all([a.waitForState("connected", RECONNECT, from[0]), b.waitForState("connected", RECONNECT, from[1])]) + await assertMediaFlows(a, b) + assert.ok(!a.ended() && !b.ended(), "call ended") + assert.ok(!a.connectionStates().includes("disconnected"), `A states: ${a.connectionStates()}`) + assert.ok(!b.connectionStates().includes("disconnected"), `B states: ${b.connectionStates()}`) +} + +async function withCall(opts, run) { + const call = await startCall(opts) + try { + await run(call) + } finally { + await call.close() + } +} + +test("call reconnection", {concurrency: true}, async (t) => { + await t.test("recovers after a brief outage", () => + withCall({}, async ({a, b, network}) => { + await assertMediaFlows(a, b) + network.down = true + await assertReconnects(a, b, async () => { + network.down = false + }) + }) + ) + + await t.test("recovers after an outage longer than ICE consent timeout", () => + withCall({}, async ({a, b, network}) => { + network.down = true + await assertReconnects(a, b, async () => { + await sleep(15000) + network.down = false + }) + assert.ok(b.sessionsSent() > 0, "offerer did not restart ICE") + assert.ok(a.sessionsSent() > 0, "answerer did not answer ICE restart") + }) + ) + + await t.test("recovers when the network path changes", () => + withCall({}, async ({a, b, network}) => { + network.down = true + network.changePath() + await assertReconnects(a, b, async () => { + network.down = false + }) + const relay = network.relays[network.relays.length - 1] + assert.ok(!relay.dead && relay.forwarded > 0, "media does not use the new path") + }) + ) + + await t.test("ends the call when reconnection times out", () => + withCall({}, async ({a, b, network}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 5000}), b.setTimeouts({reconnectTimeout: 5000})]) + network.down = true + await Promise.all([a.waitForState("reconnecting", DISCONNECT_DETECTION), b.waitForState("reconnecting", DISCONNECT_DETECTION)]) + await Promise.all([a.waitForEnded(10000), b.waitForEnded(10000)]) + assert.deepEqual(a.connectionStates().slice(-2), ["reconnecting", "disconnected"]) + assert.deepEqual(b.connectionStates().slice(-2), ["reconnecting", "disconnected"]) + }) + ) + + await t.test("ends the call when the peer does not support ICE restart", () => + withCall({answerer: {ignoresIceRestart: true}}, async ({a, b, network}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 10000}), b.setTimeouts({reconnectTimeout: 10000})]) + network.changePath() + await Promise.all([a.waitForEnded(DISCONNECT_DETECTION + 15000), b.waitForEnded(DISCONNECT_DETECTION + 15000)]) + assert.ok(b.sessionsSent() > 0, "offerer did not restart ICE") + assert.equal(a.sessionsSent(), 0) + }) + ) +}) diff --git a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw index 25caf53711..3e48751677 100644 --- a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw +++ b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw @@ -3,9350 +3,248 @@ "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, - "roundness": null, - "groupIds": [ - "header" - ], - "seed": 1001, - "index": "a000", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "hdr-title", - "type": "text", - "x": 40, - "y": 24, - "width": 620, - "height": 28, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1002, - "text": "Buying a badge - hidden codes", - "fontSize": 22, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a001", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buying a badge - hidden codes", - "lineHeight": 1.2727272727272727 - }, - { - "id": "hdr-sub", - "type": "text", - "x": 40, - "y": 58, - "width": 1500, - "height": 15, - "strokeColor": "#a0a6b0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1003, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a002", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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", - "lineHeight": 1.25 - }, - { - "id": "hdr-r1", - "type": "text", - "x": 2260, - "y": 26, - "width": 200, - "height": 15, - "strokeColor": "#a0a6b0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1004, - "text": "in-app checkout", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a003", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "in-app checkout", - "lineHeight": 1.25 - }, - { - "id": "hdr-r2", - "type": "text", - "x": 2260, - "y": 48, - "width": 200, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1005, - "text": "wireframes", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a004", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "wireframes", - "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" - ], - "seed": 1010, - "index": "a010", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "l1-head", - "type": "text", - "x": 60, - "y": 178, - "width": 900, - "height": 22, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane1" - ], - "seed": 1011, - "text": "Non-store builds - the browser does all of it", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a011", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Non-store builds - the browser does all of it", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l1-sub", - "type": "text", - "x": 60, - "y": 206, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane1" - ], - "seed": 1012, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a012", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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", - "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" - ], - "seed": 1101, - "boundElements": [ - { - "id": "ar-a1a2", - "type": "arrow" - } - ], - "index": "a020", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-title", - "type": "text", - "x": 80, - "y": 306, - "width": 260, - "height": 24, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1102, - "text": "Support SimpleX", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a021", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Support SimpleX", - "lineHeight": 1.263157894736842 - }, - { - "id": "a1-body", - "type": "text", - "x": 76, - "y": 348, - "width": 268, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1103, - "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", - "verticalAlign": "top", - "autoResize": false, - "index": "a022", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "lineHeight": 1.3 - }, - { - "id": "a1-info", - "type": "text", - "x": 80, - "y": 404, - "width": 260, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1104, - "text": "(i) How badges protect your privacy", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a023", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "(i) How badges protect your privacy", - "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" - ], - "seed": 1105, - "index": "a024", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-herot", - "type": "text", - "x": 132, - "y": 519, - "width": 156, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1106, - "text": "phone-supporter", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a025", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "phone-supporter", - "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" - ], - "seed": 1107, - "boundElements": [ - { - "id": "a1-btnt", - "type": "text" - } - ], - "index": "a026", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-btnt", - "type": "text", - "x": 100, - "y": 742, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1108, - "text": "Buy in browser", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a1-btn", - "index": "a027", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Buy in browser", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a1-sec", - "type": "text", - "x": 80, - "y": 786, - "width": 260, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1109, - "text": "Redeem code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a028", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.3 - }, - { - "id": "a1-secn", - "type": "text", - "x": 80, - "y": 806, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1112, - "text": "the manual path, for a code from elsewhere", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a029", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the manual path, for a code from elsewhere", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a1-capt", - "type": "text", - "x": 60, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1110, - "text": "A1. BadgesSupportSimplexView", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a02a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A1. BadgesSupportSimplexView", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a1-capb", - "type": "text", - "x": 60, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1111, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a02b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 1201, - "boundElements": [ - { - "id": "ar-a1a2", - "type": "arrow" - }, - { - "id": "ar-a2a3", - "type": "arrow" - } - ], - "index": "a030", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 1202, - "index": "a031", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-urlt", - "type": "text", - "x": 496, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1203, - "text": "badges.simplex.chat/#/tier?app=true", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a032", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=true", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a2-title", - "type": "text", - "x": 496, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1204, - "text": "Choose your badge", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a033", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Choose your badge", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a2-lede", - "type": "text", - "x": 496, - "y": 342, - "width": 268, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1205, - "text": "Larger files that stay available longer.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a034", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Larger files that stay available longer.", - "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" - ], - "seed": 1206, - "index": "a035", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 1207, - "index": "a036", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c1t", - "type": "text", - "x": 504, - "y": 442, - "width": 114, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1208, - "text": "Supporter\n2GB files\n\n$7/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a037", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Supporter\n2GB files\n\n$7/month", - "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" - ], - "seed": 1209, - "index": "a038", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 1210, - "index": "a039", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c2t", - "type": "text", - "x": 642, - "y": 442, - "width": 114, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1211, - "text": "Legend\n5GB files\n\n$70/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a03a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\n5GB files\n\n$70/month", - "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" - ], - "seed": 1212, - "boundElements": [ - { - "id": "a2-btnt", - "type": "text" - } - ], - "index": "a03b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-btnt", - "type": "text", - "x": 506, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1213, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a2-btn", - "index": "a03c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a2-capt", - "type": "text", - "x": 480, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1215, - "text": "A2. Where the link lands", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a03d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A2. Where the link lands", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a2-capb", - "type": "text", - "x": 480, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1216, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a03e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 1301, - "boundElements": [ - { - "id": "ar-a2a3", - "type": "arrow" - }, - { - "id": "ar-a3a4", - "type": "arrow" - } - ], - "index": "a040", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 1302, - "index": "a041", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-urlt", - "type": "text", - "x": 916, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1303, - "text": "badges.simplex.chat/#/tier?app=true", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a042", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=true", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a3-title", - "type": "text", - "x": 916, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1304, - "text": "How long?", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a043", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "How long?", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a3-lede", - "type": "text", - "x": 916, - "y": 342, - "width": 268, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1305, - "text": "Legend - 5GB files, 21 days", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a044", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend - 5GB files, 21 days", - "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" - ], - "seed": 1306, - "index": "a045", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c1t", - "type": "text", - "x": 920, - "y": 418, - "width": 76, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1307, - "text": "1 month\n\n$70", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a046", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "1 month\n\n$70", - "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" - ], - "seed": 1308, - "index": "a047", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c2t", - "type": "text", - "x": 1012, - "y": 412, - "width": 76, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1309, - "text": "3 months\n\n$140\none month free", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a048", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "3 months\n\n$140\none month free", - "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" - ], - "seed": 1310, - "index": "a049", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c3t", - "type": "text", - "x": 1104, - "y": 412, - "width": 76, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1311, - "text": "12 months\n\n$420\nsave 50%", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a04a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "12 months\n\n$420\nsave 50%", - "lineHeight": 1.3 - }, - { - "id": "a3-note", - "type": "text", - "x": 916, - "y": 520, - "width": 268, - "height": 24, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1312, - "text": "Paid once, for the months you choose.\nNo subscription.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a04b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid once, for the months you choose.\nNo subscription.", - "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" - ], - "seed": 1313, - "boundElements": [ - { - "id": "a3-btnt", - "type": "text" - } - ], - "index": "a04c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-btnt", - "type": "text", - "x": 926, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1314, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a3-btn", - "index": "a04d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a3-capt", - "type": "text", - "x": 900, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1315, - "text": "A3. Unchanged", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a04e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A3. Unchanged", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a3-capb", - "type": "text", - "x": 900, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1316, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a04f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 1401, - "boundElements": [ - { - "id": "ar-a3a4", - "type": "arrow" - }, - { - "id": "ar-a4a5", - "type": "arrow" - } - ], - "index": "a050", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 1402, - "index": "a051", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-urlt", - "type": "text", - "x": 1336, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1403, - "text": "badges.simplex.chat/?order=8f3a...", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a052", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/?order=8f3a...", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a4-title", - "type": "text", - "x": 1336, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1404, - "text": "Check your order", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a053", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Check your order", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a4-rowsL", - "type": "text", - "x": 1336, - "y": 352, - "width": 160, - "height": 48, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1405, - "text": "Your badge\nDuration\nLaunch discount: 50% off\nTotal", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a054", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Your badge\nDuration\nLaunch discount: 50% off\nTotal", - "lineHeight": 1.263157894736842 - }, - { - "id": "a4-rowsR", - "type": "text", - "x": 1508, - "y": 352, - "width": 96, - "height": 48, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1406, - "text": "Legend\n12 months\n-$420.00\n$420.00", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a055", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\n12 months\n-$420.00\n$420.00", - "lineHeight": 1.263157894736842 - }, - { - "id": "a4-label", - "type": "text", - "x": 1336, - "y": 420, - "width": 100, - "height": 12, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1407, - "text": "PAY WITH", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a056", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "PAY WITH", - "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" - ], - "seed": 1408, - "index": "a057", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m1t", - "type": "text", - "x": 1340, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1409, - "text": "Bitcoin", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a058", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Bitcoin", - "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" - ], - "seed": 1410, - "index": "a059", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m2t", - "type": "text", - "x": 1432, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1411, - "text": "Monero", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a05a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Monero", - "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" - ], - "seed": 1412, - "index": "a05b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m3t", - "type": "text", - "x": 1524, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1413, - "text": "Card", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a05c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Card", - "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" - ], - "seed": 1414, - "boundElements": [ - { - "id": "a4-btnt", - "type": "text" - } - ], - "index": "a05d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-btnt", - "type": "text", - "x": 1346, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1415, - "text": "Pay $420.00 with Monero", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a4-btn", - "index": "a05e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Pay $420.00 with Monero", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a4-capt", - "type": "text", - "x": 1320, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1416, - "text": "A4. Unchanged, and the pay screens after it", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a05f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A4. Unchanged, and the pay screens after it", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a4-capb", - "type": "text", - "x": 1320, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1417, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a05g", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 1501, - "boundElements": [ - { - "id": "ar-a4a5", - "type": "arrow" - }, - { - "id": "ar-a5a6", - "type": "arrow" - } - ], - "index": "a060", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 1502, - "index": "a061", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-urlt", - "type": "text", - "x": 1756, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1503, - "text": "badges.simplex.chat/?order=8f3a...", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a062", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/?order=8f3a...", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a5-title", - "type": "text", - "x": 1756, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1504, - "text": "Paid", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a063", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a5-lede", - "type": "text", - "x": 1756, - "y": 344, - "width": 268, - "height": 26, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1505, - "text": "Opening SimpleX to add your badge.\nIf nothing happens, use the button.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a064", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Opening SimpleX to add your badge.\nIf nothing happens, use the button.", - "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" - ], - "seed": 1506, - "boundElements": [ - { - "id": "a5-btnt", - "type": "text" - } - ], - "index": "a065", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-btnt", - "type": "text", - "x": 1766, - "y": 408, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1507, - "text": "Return to SimpleX", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a5-btn", - "index": "a066", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Return to SimpleX", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a5-sec", - "type": "text", - "x": 1756, - "y": 452, - "width": 268, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1508, - "text": "Show code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a067", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Show code", - "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" - ], - "seed": 1509, - "index": "a068", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-linkt", - "type": "text", - "x": 1762, - "y": 507, - "width": 256, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1510, - "text": "simplexchat:/badge/code/SB-4K2M9-...", - "fontSize": 9, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a069", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "simplexchat:/badge/code/SB-4K2M9-...", - "lineHeight": 1.4444444444444444 - }, - { - "id": "a5-note", - "type": "text", - "x": 1756, - "y": 544, - "width": 268, - "height": 33, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1511, - "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", - "verticalAlign": "top", - "autoResize": false, - "index": "a06a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a5-capt", - "type": "text", - "x": 1740, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1512, - "text": "A5. New: the code leaves by link, not by eye", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a06b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A5. New: the code leaves by link, not by eye", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a5-capb", - "type": "text", - "x": 1740, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1513, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a06c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 1601, - "boundElements": [ - { - "id": "ar-a5a6", - "type": "arrow" - } - ], - "index": "a070", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-title", - "type": "text", - "x": 2180, - "y": 306, - "width": 260, - "height": 24, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1602, - "text": "Badge is being issued", - "fontSize": 18, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a071", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Badge is being issued", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a6-spin", - "type": "ellipse", - "x": 2288, - "y": 380, - "width": 44, - "height": 44, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1603, - "index": "a072", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-spint", - "type": "text", - "x": 2180, - "y": 440, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1604, - "text": "progress", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a073", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "progress", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a6-body", - "type": "text", - "x": 2186, - "y": 478, - "width": 248, - "height": 39, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1605, - "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", - "verticalAlign": "top", - "autoResize": false, - "index": "a074", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "This takes a moment. You do not\nneed to do anything, and you can\nclose the app.", - "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" - ], - "seed": 1606, - "index": "a075", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 1607, - "index": "a076", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 1608, - "index": "a077", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-thent", - "type": "text", - "x": 2180, - "y": 650, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1609, - "text": "Legend", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a078", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend", - "lineHeight": 1.3 - }, - { - "id": "a6-thens", - "type": "text", - "x": 2180, - "y": 666, - "width": 260, - "height": 11, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1610, - "text": "shown on your profile", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a079", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "shown on your profile", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a6-thenn", - "type": "text", - "x": 2160, - "y": 708, - "width": 300, - "height": 11, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1611, - "text": "then BadgesYourBadgeView, unchanged", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a07a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "then BadgesYourBadgeView, unchanged", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a6-capt", - "type": "text", - "x": 2160, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1612, - "text": "A6. New screen, and the only new app state", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a07b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A6. New screen, and the only new app state", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a6-capb", - "type": "text", - "x": 2160, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1613, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a07c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "ar-a1a2", - "type": "arrow", - "x": 368, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1701, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a1a2-t", - "type": "text" - } - ], - "index": "a080", - "version": 2, - "versionNonce": 1538965901, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251194517, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a1a2-t", - "type": "text", - "x": 365.810546875, - "y": 563.5, - "width": 108.37890625, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1702, - "text": "Buy in browser - no alert", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a1a2", - "index": "a081", - "version": 3, - "versionNonce": 709045891, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251191288, - "link": null, - "locked": false, - "originalText": "Buy in browser - no alert", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a2a3", - "type": "arrow", - "x": 788, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1703, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a2a3-t", - "type": "text" - } - ], - "index": "a082", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a2a3-t", - "type": "text", - "x": 800, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1704, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a2a3", - "index": "a083", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a3a4", - "type": "arrow", - "x": 1208, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1705, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a3a4-t", - "type": "text" - } - ], - "index": "a084", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a3a4-t", - "type": "text", - "x": 1220, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1706, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a3a4", - "index": "a085", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a4a5", - "type": "arrow", - "x": 1628, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1707, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a4a5-t", - "type": "text" - } - ], - "index": "a086", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a4a5-t", - "type": "text", - "x": 1640, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1708, - "text": "it settles", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a4a5", - "index": "a087", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "it settles", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a5a6", - "type": "arrow", - "x": 2048, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1709, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a5a6-t", - "type": "text" - } - ], - "index": "a088", - "version": 2, - "versionNonce": 737650275, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251382068, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a5a6-t", - "type": "text", - "x": 2056.93115234375, - "y": 557, - "width": 86.1376953125, - "height": 26, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1710, - "text": "simplexchat: carries\nthe code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a5a6", - "index": "a089", - "version": 4, - "versionNonce": 1743168653, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251380246, - "link": null, - "locked": false, - "originalText": "simplexchat: carries the code", - "autoResize": true, - "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" - ], - "seed": 2010, - "index": "a100", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "l2-head", - "type": "text", - "x": 60, - "y": 1068, - "width": 900, - "height": 22, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane2" - ], - "seed": 2011, - "text": "Store builds - the store path in the app, the browser alongside it", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a101", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Store builds - the store path in the app, the browser alongside it", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l2-sub", - "type": "text", - "x": 60, - "y": 1096, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane2" - ], - "seed": 2012, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a102", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here", - "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" - ], - "seed": 2101, - "boundElements": [ - { - "id": "ar-b1b2", - "type": "arrow" - } - ], - "index": "a110", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-title", - "type": "text", - "x": 80, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2102, - "text": "Support SimpleX", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a111", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Support SimpleX", - "lineHeight": 1.263157894736842 - }, - { - "id": "b1-body", - "type": "text", - "x": 76, - "y": 1238, - "width": 268, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2103, - "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", - "verticalAlign": "top", - "autoResize": false, - "index": "a112", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "lineHeight": 1.3 - }, - { - "id": "b1-info", - "type": "text", - "x": 80, - "y": 1294, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2104, - "text": "(i) How badges protect your privacy", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a113", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "(i) How badges protect your privacy", - "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" - ], - "seed": 2105, - "index": "a114", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-herot", - "type": "text", - "x": 132, - "y": 1409, - "width": 156, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2106, - "text": "phone-supporter", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a115", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "phone-supporter", - "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" - ], - "seed": 2107, - "boundElements": [ - { - "id": "b1-btnt", - "type": "text" - } - ], - "index": "a116", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-btnt", - "type": "text", - "x": 100, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2108, - "text": "Choose your badge", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b1-btn", - "index": "a117", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Choose your badge", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b1-sec", - "type": "text", - "x": 80, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2109, - "text": "Buy in browser", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a118", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buy in browser", - "lineHeight": 1.3 - }, - { - "id": "b1-secn", - "type": "text", - "x": 80, - "y": 1696, - "width": 260, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2110, - "text": "where the browser is not allowed,\nthis slot reads Redeem code instead", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a119", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "where the browser is not allowed,\nthis slot reads Redeem code instead", - "lineHeight": 1.3333333333333333 - }, - { - "id": "b1-capt", - "type": "text", - "x": 60, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2111, - "text": "B1. Two buttons, one of them gated", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a11a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B1. Two buttons, one of them gated", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b1-capb", - "type": "text", - "x": 60, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2112, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a11b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 2201, - "boundElements": [ - { - "id": "ar-b1b2", - "type": "arrow" - }, - { - "id": "ar-b2b3", - "type": "arrow" - } - ], - "index": "a120", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-back", - "type": "text", - "x": 496, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2202, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a121", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-title", - "type": "text", - "x": 500, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2203, - "text": "Choose your badge", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a122", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Choose your badge", - "lineHeight": 1.263157894736842 - }, - { - "id": "b2-lede", - "type": "text", - "x": 500, - "y": 1236, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2204, - "text": "Larger files that stay available longer.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a123", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Larger files that stay available longer.", - "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" - ], - "seed": 2205, - "index": "a124", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-name", - "type": "text", - "x": 500, - "y": 1334, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2206, - "text": "My nickname [badge] v", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a125", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "My nickname [badge] v", - "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" - ], - "seed": 2207, - "index": "a126", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 2208, - "index": "a127", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c1t", - "type": "text", - "x": 500, - "y": 1442, - "width": 116, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2209, - "text": "Supporter\nSend 2GB files\n\n$6.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a128", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Supporter\nSend 2GB files\n\n$6.99/month", - "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" - ], - "seed": 2210, - "index": "a129", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 2211, - "index": "a12a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c2t", - "type": "text", - "x": 644, - "y": 1442, - "width": 116, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2212, - "text": "Legend\nSend 5GB files\n\n$69.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\nSend 5GB files\n\n$69.99/month", - "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" - ], - "seed": 2213, - "boundElements": [ - { - "id": "b2-btnt", - "type": "text" - } - ], - "index": "a12c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-btnt", - "type": "text", - "x": 520, - "y": 1608, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2214, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b2-btn", - "index": "a12d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-sec", - "type": "text", - "x": 500, - "y": 1652, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2215, - "text": "Redeem code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.3 - }, - { - "id": "b2-infon", - "type": "text", - "x": 500, - "y": 1676, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2216, - "text": "here only when B1 offers the browser;\nthe privacy link always stays on B1", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "here only when B1 offers the browser;\nthe privacy link always stays on B1", - "lineHeight": 1.3333333333333333 - }, - { - "id": "b2-capt", - "type": "text", - "x": 480, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2217, - "text": "B2. BadgesChooseBadgeView", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a130", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B2. BadgesChooseBadgeView", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-capb", - "type": "text", - "x": 480, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2218, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a131", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 2301, - "boundElements": [ - { - "id": "ar-b2b3", - "type": "arrow" - }, - { - "id": "ar-b3b4", - "type": "arrow" - } - ], - "index": "a132", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-back", - "type": "text", - "x": 916, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2302, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a133", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-title", - "type": "text", - "x": 920, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2303, - "text": "How long?", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a134", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "How long?", - "lineHeight": 1.263157894736842 - }, - { - "id": "b3-lede", - "type": "text", - "x": 920, - "y": 1236, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2304, - "text": "Legend - 5GB files, 21 days", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a135", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend - 5GB files, 21 days", - "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" - ], - "seed": 2305, - "index": "a136", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-name", - "type": "text", - "x": 920, - "y": 1334, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2306, - "text": "My nickname [badge]", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a137", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "My nickname [badge]", - "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" - ], - "seed": 2307, - "index": "a138", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c1t", - "type": "text", - "x": 917, - "y": 1426, - "width": 75, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2308, - "text": "1 month\n\n$69.99", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a139", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "1 month\n\n$69.99", - "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" - ], - "seed": 2309, - "index": "a13a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c2t", - "type": "text", - "x": 1010, - "y": 1426, - "width": 79, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2310, - "text": "Monthly\n\n$69.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a13b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Monthly\n\n$69.99/month", - "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" - ], - "seed": 2311, - "index": "a13c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c3t", - "type": "text", - "x": 1105, - "y": 1420, - "width": 79, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2312, - "text": "Annual\n\n$699.99/year\nSave 17%", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a13d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Annual\n\n$699.99/year\nSave 17%", - "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" - ], - "seed": 2313, - "boundElements": [ - { - "id": "b3-btnt", - "type": "text" - } - ], - "index": "a13e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-btnt", - "type": "text", - "x": 940, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2314, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b3-btn", - "index": "a13f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-foot", - "type": "text", - "x": 920, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2315, - "text": "Renews on 24 August 2027. Cancel anytime.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a140", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Renews on 24 August 2027. Cancel anytime.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b3-capt", - "type": "text", - "x": 900, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2316, - "text": "B3. BadgesHowLongView, disarmed", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a141", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B3. BadgesHowLongView, disarmed", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-capb", - "type": "text", - "x": 900, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2317, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a142", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 2401, - "boundElements": [ - { - "id": "ar-b3b4", - "type": "arrow" - }, - { - "id": "ar-b4o1", - "type": "arrow" - }, - { - "id": "ar-b4o2", - "type": "arrow" - }, - { - "id": "ar-b4o3", - "type": "arrow" - } - ], - "index": "a143", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-back", - "type": "text", - "x": 1336, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2402, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a144", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-title", - "type": "text", - "x": 1340, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2403, - "text": "Check your order", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a145", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Check your order", - "lineHeight": 1.263157894736842 - }, - { - "id": "b4-rowsL", - "type": "text", - "x": 1344, - "y": 1270, - "width": 160, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2404, - "text": "Your badge\nDuration\nTotal", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a146", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Your badge\nDuration\nTotal", - "lineHeight": 1.263157894736842 - }, - { - "id": "b4-rowsR", - "type": "text", - "x": 1500, - "y": 1270, - "width": 96, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2405, - "text": "Legend\nAnnual\n$699.99/year", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a147", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\nAnnual\n$699.99/year", - "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, - "roundness": null, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2406, - "index": "a148", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-note", - "type": "text", - "x": 1344, - "y": 1336, - "width": 252, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2407, - "text": "Charged by the App Store to the\naccount on this device.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a149", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Charged by the App Store to the\naccount on this device.", - "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" - ], - "seed": 2408, - "boundElements": [ - { - "id": "b4-btnt", - "type": "text" - } - ], - "index": "a14a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-btnt", - "type": "text", - "x": 1360, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2409, - "text": "Pay $699.99/year", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b4-btn", - "index": "a14b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Pay $699.99/year", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-foot", - "type": "text", - "x": 1340, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2410, - "text": "Renews on 24 August 2027. Cancel anytime.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a14c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Renews on 24 August 2027. Cancel anytime.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b4-capt", - "type": "text", - "x": 1320, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2411, - "text": "B4. New screen, one for every market", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a14d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B4. New screen, one for every market", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-capb", - "type": "text", - "x": 1320, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2412, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a14e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 2601, - "boundElements": [ - { - "id": "ar-b4o1", - "type": "arrow" - } - ], - "index": "a150", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o1-t", - "type": "text", - "x": 1760, - "y": 1174, - "width": 260, - "height": 17, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2602, - "text": "Paid", - "fontSize": 13, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a151", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid", - "lineHeight": 1.3076923076923077 - }, - { - "id": "o1-s", - "type": "text", - "x": 1760, - "y": 1198, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2603, - "text": "o Issuing your badge", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a152", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "o Issuing your badge", - "lineHeight": 1.3 - }, - { - "id": "o1-b", - "type": "text", - "x": 1766, - "y": 1222, - "width": 248, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2604, - "text": "The store has taken the payment.\nKeeps trying if you close the app.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a153", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The store has taken the payment.\nKeeps trying if you close the app.", - "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" - ], - "seed": 2605, - "index": "a154", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 2606, - "index": "a155", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 2607, - "index": "a156", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o2-t", - "type": "text", - "x": 1760, - "y": 1350, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2608, - "text": "Legend", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a157", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend", - "lineHeight": 1.3 - }, - { - "id": "o2-s", - "type": "text", - "x": 1760, - "y": 1366, - "width": 260, - "height": 11, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2609, - "text": "shown on your profile", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a158", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "shown on your profile", - "lineHeight": 1.2941176470588236 - }, - { - "id": "o2-n", - "type": "text", - "x": 1740, - "y": 1402, - "width": 300, - "height": 11, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2610, - "text": "BadgesYourBadgeView, unchanged", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a159", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "BadgesYourBadgeView, unchanged", - "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" - ], - "seed": 2611, - "boundElements": [ - { - "id": "ar-b4o2", - "type": "arrow" - } - ], - "index": "a15a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o3-t", - "type": "text", - "x": 1760, - "y": 1438, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2612, - "text": "Waiting for approval", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Waiting for approval", - "lineHeight": 1.25 - }, - { - "id": "o3-s", - "type": "text", - "x": 1760, - "y": 1460, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2613, - "text": "o Nothing has been charged", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "o Nothing has been charged", - "lineHeight": 1.368421052631579 - }, - { - "id": "o3-b", - "type": "text", - "x": 1746, - "y": 1484, - "width": 288, - "height": 39, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2614, - "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", - "verticalAlign": "top", - "autoResize": false, - "index": "a15d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.", - "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" - ], - "seed": 2615, - "boundElements": [ - { - "id": "ar-b4o3", - "type": "arrow" - } - ], - "index": "a15e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o4-t", - "type": "text", - "x": 1776, - "y": 1598, - "width": 228, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2616, - "text": "Cannot verify this purchase", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Cannot verify this purchase", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o4-b", - "type": "text", - "x": 1776, - "y": 1620, - "width": 228, - "height": 39, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2617, - "text": "The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15g", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.", - "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, - "roundness": null, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2618, - "index": "a15h", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o4-o", - "type": "text", - "x": 1760, - "y": 1678, - "width": 260, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2619, - "text": "Ok", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15i", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Ok", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o-note", - "type": "text", - "x": 1740, - "y": 1712, - "width": 300, - "height": 44, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2620, - "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", - "verticalAlign": "top", - "autoResize": false, - "index": "a15j", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "lineHeight": 1.7254901960784312 - }, - { - "id": "o-capt", - "type": "text", - "x": 1740, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2621, - "text": "B5. What the sheet returns", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a15k", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B5. What the sheet returns", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o-capb", - "type": "text", - "x": 1740, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2622, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a15l", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "ar-b1b2", - "type": "arrow", - "x": 368, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2701, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b1b2-t", - "type": "text" - } - ], - "index": "a180", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b1b2-t", - "type": "text", - "x": 380, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2702, - "text": "Choose your badge", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b1b2", - "index": "a181", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Choose your badge", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b2b3", - "type": "arrow", - "x": 788, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2703, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b2b3-t", - "type": "text" - } - ], - "index": "a182", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b2b3-t", - "type": "text", - "x": 800, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2704, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b2b3", - "index": "a183", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b3b4", - "type": "arrow", - "x": 1208, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2705, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b3b4-t", - "type": "text" - } - ], - "index": "a184", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b3b4-t", - "type": "text", - "x": 1220, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2706, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b3b4", - "index": "a185", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o1", - "type": "arrow", - "x": 1628, - "y": 1220, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2709, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o1-t", - "type": "text" - } - ], - "index": "a186", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o1-t", - "type": "text", - "x": 1640, - "y": 1206, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2710, - "text": "verified", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o1", - "index": "a187", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "verified", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o2", - "type": "arrow", - "x": 1628, - "y": 1489, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2711, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o2-t", - "type": "text" - } - ], - "index": "a188", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o2-t", - "type": "text", - "x": 1640, - "y": 1475, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2712, - "text": "pending", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o2", - "index": "a189", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "pending", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o3", - "type": "arrow", - "x": 1628, - "y": 1644, - "width": 124, - "height": 0, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2713, - "points": [ - [ - 0, - 0 - ], - [ - 124, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o3-t", - "type": "text" - } - ], - "index": "a18a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o3-t", - "type": "text", - "x": 1640, - "y": 1630, - "width": 100, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2714, - "text": "unverified", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o3", - "index": "a18b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "unverified", - "autoResize": true, - "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" - ], - "seed": 3010, - "index": "a200", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "l3-head", - "type": "text", - "x": 60, - "y": 1958, - "width": 900, - "height": 22, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane3" - ], - "seed": 3011, - "text": "Desktop - where the deep link may not exist", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a201", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Desktop - where the deep link may not exist", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l3-sub", - "type": "text", - "x": 60, - "y": 1986, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane3" - ], - "seed": 3012, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a202", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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", - "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" - ], - "seed": 3101, - "boundElements": [ - { - "id": "ar-d1d2", - "type": "arrow" - } - ], - "index": "a210", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-back", - "type": "text", - "x": 76, - "y": 2058, - "width": 20, - "height": 14, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3102, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a211", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-title", - "type": "text", - "x": 80, - "y": 2086, - "width": 260, - "height": 24, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3103, - "text": "Redeem code", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a212", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.263157894736842 - }, - { - "id": "d1-body", - "type": "text", - "x": 80, - "y": 2128, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3104, - "text": "Paste the code you received.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a213", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paste the code you received.", - "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" - ], - "seed": 3105, - "index": "a214", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-fieldt", - "type": "text", - "x": 80, - "y": 2174, - "width": 260, - "height": 13, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3106, - "text": "SB-XXXXX-XXXXX-XXXXX-XXXXX", - "fontSize": 10, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a215", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "SB-XXXXX-XXXXX-XXXXX-XXXXX", - "lineHeight": 1.3 - }, - { - "id": "d1-paste", - "type": "text", - "x": 80, - "y": 2216, - "width": 260, - "height": 13, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3107, - "text": "Paste", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a216", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paste", - "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" - ], - "seed": 3108, - "index": "a217", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-scant", - "type": "text", - "x": 126, - "y": 2332, - "width": 168, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3109, - "text": "QR scanner", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a218", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "QR scanner", - "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" - ], - "seed": 3110, - "boundElements": [ - { - "id": "d1-btnt", - "type": "text" - } - ], - "index": "a219", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-btnt", - "type": "text", - "x": 100, - "y": 2522, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3111, - "text": "Redeem", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "d1-btn", - "index": "a21a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Redeem", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-note", - "type": "text", - "x": 80, - "y": 2566, - "width": 260, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3112, - "text": "opened by Buy in browser at the same\nmoment as the browser, and waiting", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a21b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "opened by Buy in browser at the same\nmoment as the browser, and waiting", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d1-capt", - "type": "text", - "x": 60, - "y": 2680, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3113, - "text": "D1. Both halves at once", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a21c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "D1. Both halves at once", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-capb", - "type": "text", - "x": 60, - "y": 2700, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3114, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a21d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 3201, - "boundElements": [ - { - "id": "ar-d1d2", - "type": "arrow" - } - ], - "index": "a220", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "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" - ], - "seed": 3202, - "index": "a221", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-urlt", - "type": "text", - "x": 496, - "y": 2048, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3203, - "text": "badges.simplex.chat/#/tier?app=desktop", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a222", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=desktop", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d2-title", - "type": "text", - "x": 496, - "y": 2090, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3204, - "text": "Paid. Here is your code.", - "fontSize": 16, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a223", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid. Here is your code.", - "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" - ], - "seed": 3205, - "index": "a224", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-codet", - "type": "text", - "x": 500, - "y": 2140, - "width": 260, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3206, - "text": "SB-4K2M9-XQ7VT-H3BNP-R8WLC", - "fontSize": 10, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a225", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "SB-4K2M9-XQ7VT-H3BNP-R8WLC", - "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" - ], - "seed": 3207, - "boundElements": [ - { - "id": "d2-btnt", - "type": "text" - } - ], - "index": "a226", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-btnt", - "type": "text", - "x": 506, - "y": 2190, - "width": 248, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3208, - "text": "Copy code", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "d2-btn", - "index": "a227", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Copy code", - "autoResize": true, - "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, - "roundness": null, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3209, - "index": "a228", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-qrt", - "type": "text", - "x": 496, - "y": 2352, - "width": 110, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3210, - "text": "scan to carry it\nto your phone", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a229", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "scan to carry it\nto your phone", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d2-redeem", - "type": "text", - "x": 622, - "y": 2236, - "width": 142, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3211, - "text": "REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a22a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.", - "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" - ], - "seed": 3212, - "index": "a22b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-warnt", - "type": "text", - "x": 632, - "y": 2298, - "width": 122, - "height": 38, - "strokeColor": "#8a5a1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3213, - "text": "This is the only copy.\nSaved in this browser\nand nowhere else.", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a22c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "This is the only copy.\nSaved in this browser\nand nowhere else.", - "lineHeight": 1.4901960784313726 - }, - { - "id": "d2-capt", - "type": "text", - "x": 480, - "y": 2680, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3214, - "text": "D2. The page, told which app it is", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a22d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "D2. The page, told which app it is", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d2-capb", - "type": "text", - "x": 480, - "y": 2700, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3215, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a22e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 3301, - "index": "a230", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "q-head", - "type": "text", - "x": 924, - "y": 2064, - "width": 900, - "height": 20, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "Q", - "lane3" - ], - "seed": 3302, - "text": "QUESTIONS - deep links, and whether the desktop can have them at all", - "fontSize": 15, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a231", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "QUESTIONS - deep links, and whether the desktop can have them at all", - "lineHeight": 1.3333333333333333 - }, - { - "id": "q-body", - "type": "text", - "x": 924, - "y": 2098, - "width": 1490, - "height": 130, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "Q", - "lane3" - ], - "seed": 3303, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a232", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "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" - ], - "seed": 3401, - "index": "a233", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "n-head", - "type": "text", - "x": 924, - "y": 2364, - "width": 900, - "height": 20, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "N", - "lane3" - ], - "seed": 3402, - "text": "What has to be built, and what it costs", - "fontSize": 15, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a234", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "What has to be built, and what it costs", - "lineHeight": 1.3333333333333333 - }, - { - "id": "n-body", - "type": "text", - "x": 924, - "y": 2398, - "width": 1490, - "height": 220, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "N", - "lane3" - ], - "seed": 3403, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a235", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "lineHeight": 2 - }, - { - "id": "ar-d1d2", - "type": "arrow", - "x": 368, - "y": 2350, - "width": 104, - "height": 0, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 3501, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": "arrow", - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-d1d2-t", - "type": "text" - } - ], - "index": "a250", - "version": 2, - "versionNonce": 1589412771, - "isDeleted": false, - "fillStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251896821, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-d1d2-t", - "type": "text", - "x": 370.23193359375, - "y": 2337, - "width": 99.5361328125, - "height": 26, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 3502, - "text": "opened together, code\ncarried by hand", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-d1d2", - "index": "a251", - "version": 4, - "versionNonce": 1533799245, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251895408, - "link": null, - "locked": false, - "originalText": "opened together, code carried by hand", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ft-note", - "type": "text", - "x": 60, - "y": 2830, - "width": 2400, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "footer" - ], - "seed": 4005, - "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, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a300", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "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.", - "lineHeight": 1.3 - } + {"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" - }, + "appState": {"gridSize": 20, "gridStep": 5, "gridModeEnabled": false, "viewBackgroundColor": "#ffffff"}, "files": {} -} \ No newline at end of file +} diff --git a/scripts/ci/README.md b/scripts/ci/README.md new file mode 100644 index 0000000000..2f5660dfc1 --- /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 to a branch named `f/…`. 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. Tags start runs only on `f/…` branches, so pull requests from other branches don't list skipped runs; on any other branch, master and stable included, run a workflow by hand. + +## 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 a9080e1c87..be0586558a 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: by a run tag in a commit pushed to an `f/…` branch, or by hand from the Actions tab on any branch. [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 | -| `claude` (opt-in) | the app at its first screen, 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 | +| `claude` (opt-in) | after `app` passed: its distributable at the first screen, then Claude Code drives it | about 4 min, Claude 3 | +| `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]`: 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. - `backend.sh start ` / `stop `: the test backend, below. -- `claude-test.sh`, `claude-test.md` (the prompt), `claude-guard.py`, `claude-usage.py`: the Claude job. +- `claude-test.sh`, `claude-test.md` (the prompt), `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 for prompts that use it, and waits for its build; this smoke path's prompt doesn't use it. The job installs Claude Code 2.1.296 and runs `claude-haiku-5-5` with `--max-turns 60` 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`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 60`, `--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 836f09d275..842b3c890e 100755 --- a/scripts/ci/desktop-linux/backend.sh +++ b/scripts/ci/desktop-linux/backend.sh @@ -120,21 +120,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 @@ -152,7 +158,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" } @@ -311,12 +317,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..ca4c2da0f9 --- /dev/null +++ b/scripts/ci/desktop-linux/claude-scope @@ -0,0 +1,3 @@ +# What the Claude job needs for the session claude-test.md describes. +# backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too. +backend: no diff --git a/scripts/ci/desktop-linux/claude-test.sh b/scripts/ci/desktop-linux/claude-test.sh index d254226b75..12fde73a05 100755 --- a/scripts/ci/desktop-linux/claude-test.sh +++ b/scripts/ci/desktop-linux/claude-test.sh @@ -11,7 +11,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 @@ -35,26 +37,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 60 \ --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" @@ -78,4 +82,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 dd2c37c030..2e98b029a3 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: by a run tag in a commit pushed to an `f/…` branch, or by hand from the Actions tab on any branch. [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, 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, 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, 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). On master it is a three-case smoke plan; 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. @@ -47,11 +47,11 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; - a local SMP server with a `[NAMES]` section and `names-resolver.py`, the names `cinews.testing`, `ciboth.testing`, `cialice.testing` and `cibobby.testing` claimed by their owners, and the contacts cialice and Robert, online and accepting connections; its address goes to `/names-server`; - checks before the app starts: a search sent to the directory as a message must list `ci_group` and `ci_channel`, and `/_connect plan` must resolve each name from a fresh peer. -Not yet verified on master: the backend ran on `f/directory-search`'s code, and its checks were rewritten for master's (the directory answers searches sent as messages there; service requests need the feature branch). +On master's code it starts and passes these checks (the directory answers searches sent as messages); service requests to the directory, which only `f/directory-search` answers, are not checked here. It writes what it seeded to `/seed.md` (the Claude sessions get it) and logs to `/*.log`; `stop`, `directory-stop`, `directory-start` and `listings` are its other commands. The app reaches it through the simulator, which shares the runner's network; pointing the app at the names server is a few steps in Settings, described in `claude-prompt.md`. ## 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 02b293a9f9..3c4c5c4ca6 100755 --- a/scripts/ci/ios-sim/backend.sh +++ b/scripts/ci/ios-sim/backend.sh @@ -57,13 +57,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 @@ -114,22 +107,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 @@ -147,7 +146,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" } @@ -299,7 +298,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" @@ -323,7 +322,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" @@ -334,6 +333,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 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%%|*} @@ -433,14 +434,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::" @@ -459,7 +463,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: no model: claude-haiku-5-5 layouts: reachable cases-reachable: S1-S3 diff --git a/scripts/ci/ios-sim/claude-test.sh b/scripts/ci/ios-sim/claude-test.sh index 5d72d03a9b..e069f33ef3 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:-} @@ -89,14 +93,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" @@ -120,11 +125,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" @@ -133,6 +143,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/ios-sim/earlier-run.sh b/scripts/ci/ios-sim/earlier-run.sh index 752bea6292..7081bd08b1 100755 --- a/scripts/ci/ios-sim/earlier-run.sh +++ b/scripts/ci/ios-sim/earlier-run.sh @@ -8,10 +8,10 @@ set -euo pipefail name=$1 -run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" --jq " +run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" | jq -r --arg branch "$GITHUB_REF_NAME" ' [.artifacts[] - | select((.expired | not) and .workflow_run.head_branch == \"$GITHUB_REF_NAME\" and .workflow_run.head_repository_id == .workflow_run.repository_id)] - | sort_by(.created_at) | last | .workflow_run.id // empty") + | select((.expired | not) and .workflow_run.head_branch == $branch and .workflow_run.head_repository_id == .workflow_run.repository_id)] + | sort_by(.created_at) | last | .workflow_run.id // empty') if [ -n "$run_id" ]; then echo "$name: from run $run_id" echo "run-id=$run_id" >> "$GITHUB_OUTPUT" diff --git a/scripts/ci/sign-libs-mac.sh b/scripts/ci/sign-libs-mac.sh new file mode 100755 index 0000000000..e455404503 --- /dev/null +++ b/scripts/ci/sign-libs-mac.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail + +libs=$1 +team=5NN7GUYB6T +keychain=/tmp/simplex.keychain +temp=$(mktemp -d) +trap 'rm -rf "$keychain" "$temp"' EXIT + +notarytool() { + xcrun notarytool "$@" --apple-id "$APPLE_SIMPLEX_NOTARIZATION_APPLE_ID" --password "$APPLE_SIMPLEX_NOTARIZATION_PASSWORD" --team-id "$team" +} + +echo "$APPLE_SIMPLEX_SIGNING_KEYCHAIN" | base64 --decode -o "$keychain" +security unlock-keychain -p "" "$keychain" +security list-keychains -s $(security list-keychains | xargs) "$keychain" + +for lib in "$libs"/*.dylib; do + codesign --remove-signature "$lib" + codesign --timestamp --options runtime --force --sign "Developer ID Application: SimpleX Chat Ltd ($team)" --keychain "$keychain" "$lib" +done + +ditto -c -k --keepParent "$libs" "$temp/libs.zip" +notarytool submit "$temp/libs.zip" --wait --timeout 1h --output-format json > "$temp/submission.json" || true +cat "$temp/submission.json" +read -r id status < <(python3 -c 'import json, sys; r = json.load(open(sys.argv[1])); print(r["id"], r["status"])' "$temp/submission.json") +notarytool log "$id" +if [ "$status" != Accepted ]; then + exit 1 +fi diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 49ae96bb8f..28b7b78368 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."cba13823567d7cd564d8a1f354046174fba375d8" = "0nhd6zsmqs55b7shh945b0v9pn0qqkpx9djq9dldair82hsmw1if"; + "https://github.com/simplex-chat/simplexmq.git"."aabf4cdab98f9fb4c92dc6d9ec661a5f08f0e80f" = "07w7rlc29gyd5p5yblgscgza1cv7sz01jygvxd6zp59xsygcd6zq"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 747e32db2e..c61978990d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -353,6 +353,7 @@ library 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 + Simplex.Chat.Store.SQLite.Migrations.M20261018_autoincrement_ids other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index f64cd8e736..fec5473996 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -230,7 +230,8 @@ data WebRTCSession = WebRTCSession deriving (Eq, Show) data WebRTCExtraInfo = WebRTCExtraInfo - { rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates + { rtcSession :: Maybe Text, -- LZW compressed JSON encoding of ICE restart offer or answer + rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } deriving (Eq, Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 1ea478ea7e..cca80a98aa 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2688,7 +2688,7 @@ processChatCommand cxt nm = \case combineResults _ _ (Left e) = Left e createCI :: DB.Connection -> User -> Bool -> UTCTime -> (Contact, SndMessage) -> IO () createCI db user hasLink createdAt (ct, sndMsg) = - void $ createNewSndChatItem db user (CDDirectSnd ct) False sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink createdAt + void $ createNewSndChatItem db user (CDDirectSnd ct) False sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink (roundDownToSecond createdAt) createdAt SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index d4e9893bd9..cd89881e4f 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -40,7 +40,7 @@ import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Functor.Identity import Data.Int (Int64) -import Data.List (find, foldl', mapAccumL, partition) +import Data.List (foldl', mapAccumL, partition) import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -3020,13 +3020,17 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) 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 + itemTs = roundDownToSecond createdAt + ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ itemTs 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 + let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ itemTs Nothing (toMsgVerified (signMessagesRequired cd) (MSSVerified <$ signedMsg_)) createdAt Right <$> case cd of CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci +roundDownToSecond :: UTCTime -> UTCTime +roundDownToSecond t@UTCTime {utctDayTime} = t {utctDayTime = fromInteger (floor utctDayTime)} + 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 @@ -3363,7 +3367,7 @@ createChatItems :: CM' [Either ChatError AChatItem] createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime - let itemTs = fromMaybe createdAt itemTs_ + let itemTs = fromMaybe (roundDownToSecond createdAt) itemTs_ cxt <- asks storeCxt void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents @@ -3419,9 +3423,10 @@ createLocalChatItems user cd itemsData createdAt = 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 + itemTs = roundDownToSecond createdAt + ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ itemTs Nothing Nothing Nothing Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing createdAt + pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ itemTs Nothing Nothing createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 5ad9107e6f..ee5a7f1659 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -546,9 +546,9 @@ setSupportChatMemberAttention db cxt user g m memberAttention = do m_ <- runExceptT $ getGroupMemberById db cxt user (groupMemberId' m) pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId -createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, sharedMsgId, signedMsg_} ciContent quotedItem itemForwarded timed live hasLink createdAt = - createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing (toMsgVerified (signMessagesRequired chatDirection) (MSSVerified <$ signedMsg_)) signedMsg_ Nothing createdAt +createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> UTCTime -> IO ChatItemId +createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, sharedMsgId, signedMsg_} ciContent quotedItem itemForwarded timed live hasLink itemTs createdAt = + createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink itemTs Nothing (toMsgVerified (signMessagesRequired chatDirection) (MSSVerified <$ signedMsg_)) signedMsg_ Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 535e5d3864..dd2238adf5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -180,6 +180,7 @@ 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.Chat.Store.SQLite.Migrations.M20261018_autoincrement_ids import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -359,7 +360,8 @@ schemaMigrations = ("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), - ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts) + ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts), + ("20261018_autoincrement_ids", m20261018_autoincrement_ids, Just down_m20261018_autoincrement_ids) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261018_autoincrement_ids.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261018_autoincrement_ids.hs new file mode 100644 index 0000000000..a390e1326f --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261018_autoincrement_ids.hs @@ -0,0 +1,110 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261018_autoincrement_ids where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261018_autoincrement_ids :: Query +m20261018_autoincrement_ids = + [sql| +INSERT INTO sqlite_sequence (name, seq) +SELECT 'users', MAX(ROWID) FROM users; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'contacts', MAX(ROWID) FROM contacts; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'groups', MAX(ROWID) FROM groups; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'group_members', MAX(ROWID) FROM group_members; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'files', MAX(ROWID) FROM files; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'connections', MAX(ROWID) FROM connections; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'contact_requests', MAX(ROWID) FROM contact_requests; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'rcv_roster_transfers', MAX(ROWID) FROM rcv_roster_transfers; + +PRAGMA writable_schema=1; + +UPDATE sqlite_master SET sql = replace(sql, 'user_id INTEGER PRIMARY KEY,', 'user_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'users' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'contact_id INTEGER PRIMARY KEY,', 'contact_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'contacts' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'group_id INTEGER PRIMARY KEY,', 'group_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'groups' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'group_member_id INTEGER PRIMARY KEY,', 'group_member_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'group_members' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'file_id INTEGER PRIMARY KEY,', 'file_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'files' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'connection_id INTEGER PRIMARY KEY,', 'connection_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'connections' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'contact_request_id INTEGER PRIMARY KEY,', 'contact_request_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'contact_requests' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'roster_transfer_id INTEGER PRIMARY KEY,', 'roster_transfer_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'rcv_roster_transfers' AND type = 'table'; + +PRAGMA writable_schema=RESET; +|] + +down_m20261018_autoincrement_ids :: Query +down_m20261018_autoincrement_ids = + [sql| +DELETE FROM sqlite_sequence WHERE name = 'users'; + +DELETE FROM sqlite_sequence WHERE name = 'contacts'; + +DELETE FROM sqlite_sequence WHERE name = 'groups'; + +DELETE FROM sqlite_sequence WHERE name = 'group_members'; + +DELETE FROM sqlite_sequence WHERE name = 'files'; + +DELETE FROM sqlite_sequence WHERE name = 'connections'; + +DELETE FROM sqlite_sequence WHERE name = 'contact_requests'; + +DELETE FROM sqlite_sequence WHERE name = 'rcv_roster_transfers'; + +PRAGMA writable_schema=1; + +UPDATE sqlite_master SET sql = replace(sql, 'user_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'user_id INTEGER PRIMARY KEY,') +WHERE name = 'users' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'contact_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'contact_id INTEGER PRIMARY KEY,') +WHERE name = 'contacts' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'group_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'group_id INTEGER PRIMARY KEY,') +WHERE name = 'groups' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'group_member_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'group_member_id INTEGER PRIMARY KEY,') +WHERE name = 'group_members' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'file_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'file_id INTEGER PRIMARY KEY,') +WHERE name = 'files' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'connection_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'connection_id INTEGER PRIMARY KEY,') +WHERE name = 'connections' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'contact_request_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'contact_request_id INTEGER PRIMARY KEY,') +WHERE name = 'contact_requests' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'roster_transfer_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'roster_transfer_id INTEGER PRIMARY KEY,') +WHERE name = 'rcv_roster_transfers' AND type = 'table'; + +PRAGMA writable_schema=RESET; +|] 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 d7debec504..a02d379f10 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -614,6 +614,15 @@ Query: Plan: SEARCH address_ratchet_keys USING INDEX idx_address_ratchet_keys (conn_id=? AND ratchet_key_id=?) +Query: + UPDATE rcv_messages + SET receive_attempts = receive_attempts + 1 + WHERE conn_id = ? AND internal_id = ? + RETURNING receive_attempts + +Plan: +SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + Query: DELETE FROM address_ratchet_keys WHERE conn_id = ? @@ -1526,6 +1535,10 @@ 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_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index d3b3a095d4..91183ede3a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -36,7 +36,7 @@ CREATE TABLE contact_profiles( preferences_json TEXT ) STRICT; CREATE TABLE users( - user_id INTEGER PRIMARY KEY, + user_id INTEGER PRIMARY KEY AUTOINCREMENT, contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, local_display_name TEXT NOT NULL UNIQUE, @@ -63,6 +63,7 @@ CREATE TABLE users( ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED ) STRICT; +CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE display_names( user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, @@ -74,7 +75,7 @@ CREATE TABLE display_names( UNIQUE(user_id, ldn_base, ldn_suffix) ON CONFLICT FAIL ) WITHOUT ROWID, STRICT; CREATE TABLE contacts( - contact_id INTEGER PRIMARY KEY, + contact_id INTEGER PRIMARY KEY AUTOINCREMENT, contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, @@ -151,7 +152,7 @@ CREATE TABLE group_profiles( preferences_json TEXT ) STRICT; CREATE TABLE groups( - group_id INTEGER PRIMARY KEY, -- local group ID + group_id INTEGER PRIMARY KEY AUTOINCREMENT, -- local group ID user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, -- local group name without spaces group_profile_id INTEGER REFERENCES group_profiles ON DELETE SET NULL, -- shared group profile @@ -222,7 +223,7 @@ CREATE TABLE groups( ) STRICT; CREATE TABLE group_members( -- group members, excluding the local user - group_member_id INTEGER PRIMARY KEY, + group_member_id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, member_id BLOB NOT NULL, -- shared member ID, unique per group member_role TEXT NOT NULL, -- owner, admin, member @@ -280,7 +281,7 @@ CREATE TABLE group_member_intros( UNIQUE(re_group_member_id, to_group_member_id) ) STRICT; CREATE TABLE files( - file_id INTEGER PRIMARY KEY, + file_id INTEGER PRIMARY KEY AUTOINCREMENT, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE, file_name TEXT NOT NULL, @@ -352,7 +353,7 @@ CREATE TABLE rcv_file_chunks( ) WITHOUT ROWID, STRICT; CREATE TABLE connections( -- all SMP agent connections - connection_id INTEGER PRIMARY KEY, + connection_id INTEGER PRIMARY KEY AUTOINCREMENT, agent_conn_id BLOB NOT NULL UNIQUE, conn_level INTEGER NOT NULL DEFAULT 0, via_contact INTEGER REFERENCES contacts(contact_id) ON DELETE SET NULL, @@ -419,7 +420,7 @@ CREATE TABLE user_contact_links( UNIQUE(user_id, local_display_name) ) STRICT; CREATE TABLE contact_requests( - contact_request_id INTEGER PRIMARY KEY, + contact_request_id INTEGER PRIMARY KEY AUTOINCREMENT, user_contact_link_id INTEGER REFERENCES user_contact_links ON UPDATE CASCADE ON DELETE SET NULL, agent_invitation_id BLOB NOT NULL, @@ -530,7 +531,6 @@ CREATE TABLE chat_items( fwd_from_member_id BLOB, fwd_from_shared_msg_id BLOB ) STRICT; -CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, @@ -848,7 +848,7 @@ CREATE TABLE group_relays( base_web_url TEXT ) STRICT; CREATE TABLE rcv_roster_transfers( - roster_transfer_id INTEGER PRIMARY KEY, + roster_transfer_id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, from_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, roster_version INTEGER NOT NULL, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 0593dc3554..086abd8e48 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -60,7 +60,7 @@ import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..)) import Simplex.Messaging.Server (runSMPServerBlocking) -import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) +import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMaxClientCmdWait, defaultMessageExpiration, defaultNtfExpiration, defaultInactiveClientExpiration) import NameResolver (NameRegistry, resolverNamesConfig, withNameResolver) import Simplex.Messaging.Server.MsgStore.STM (STMMsgStore) import Simplex.Messaging.Transport @@ -638,8 +638,6 @@ smpServerCfg p = { transports = [(smpTestPort p, transport @TLS, False)], tbqSize = 4, msgQueueQuota = 16, - maxJournalMsgCount = 24, - maxJournalStateLines = 4, queueIdBytes = 24, msgIdBytes = 24, serverStoreCfg = SSCMemory Nothing, -- $ Just StorePaths {storeLogFile = "tmp/smp-server-store.log", storeMsgsFile = Just "tmp/smp-server-messages.log"}, @@ -653,7 +651,6 @@ smpServerCfg p = messageExpiration = Just defaultMessageExpiration, expireMessagesOnStart = False, expireMessagesOnSend = False, - idleQueueInterval = defaultIdleQueueInterval, notificationExpiration = defaultNtfExpiration, inactiveClientExpiration = Just defaultInactiveClientExpiration, smpCredentials = @@ -680,6 +677,7 @@ smpServerCfg p = allowSMPProxy = True, serverClientConcurrency = 16, serverResolverConcurrency = 1000, + maxClientCmdWait = defaultMaxClientCmdWait, namesConfig = Nothing, information = Nothing, startOptions = StartOptions {maintenance = False, compactLog = False, logLevel = LogError, skipWarnings = False, confirmMigrations = MCYesUp} diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7300fcff22..3ce8f97130 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1362,6 +1362,13 @@ testNegotiateCall = bob <## "ok" threadDelay 100000 bob #$> ("/_get chat @2 count=100", callChat, chatFeatures <> [(0, "incoming call: in progress")]) + -- either party can restart ICE of a connected call + bob ##> "/_call extra @2 {\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"}" + bob <## "ok" + alice <## "call extra info from bob" + alice ##> "/_call extra @2 {\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"}" + alice <## "ok" + bob <## "call extra info from alice" -- either party can end the call bob ##> "/_call end @2" bob <## "ok" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index dcb3321702..63f954454a 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -12644,13 +12644,7 @@ testChannelMessageFile ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve - -- the roster arrives as a file before this one; Postgres assigns it a new id and does not - -- reuse it on delete (SQLite does), so the received message file is id 2 here, 1 on SQLite. -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif -- owner sends file as channel message alice #> "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -12696,11 +12690,7 @@ testChannelMessageFileCancel ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif -- owner sends file as channel message alice #> "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -12876,11 +12866,7 @@ testChannelOwnerFileTransferAsMember ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif -- owner sends file as member (not as channel) alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" @@ -13028,11 +13014,7 @@ testChannelFileBadgeProof ps = do withNewTestChatCfg ps cfg "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve addTestBadge alice =<< issueTestBadge sk futureDate -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -13095,11 +13077,7 @@ testChannelOwnerFileCancelAsMember ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif -- owner sends file as member (not as channel) alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" @@ -13475,17 +13453,9 @@ testChannelSignedFile ps = xftpCLI ["rand", testfile, "1mb"] `shouldReturn` ["File created: " <> testfile] createChannel1Relay "team" alice bob cath dan eve promoteChannelMember "team" alice bob cath [dan, eve] - -- roster serves arrive as files that Postgres deletes without reusing the id (SQLite reuses - -- it), so ids run higher here. cath's send and the subscribers' (dan, eve) receive both - -- follow only their join roster (id 2). The relay (bob) also received the roster re-served - -- on cath's promotion, so its file is id 3. The owner (alice) has no roster file, so id 1. -#if defined(dbPostgres) + -- roster files are deleted after the transfer, so ids run higher: cath, dan and eve use id 2, the relay (bob) id 3, the owner (alice) id 1. let fileId = 2 :: Int relayFileId = 3 :: Int -#else - let fileId = 1 :: Int - relayFileId = 1 :: Int -#endif -- cath's first (signed) message introduces her to dan/eve cath ##> "/_send #1 sign=on text hi" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 4a971a456a..c707512a9a 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -2381,11 +2381,7 @@ testPlanAddressContactViaAddress = bob @@@ [("@alice", "")] -- GUI api -#if defined(dbPostgres) bob ##> "/_connect contact 1 4" -#else - bob ##> "/_connect contact 1 2" -#endif connecting alice bob where connecting alice bob = do @@ -2443,11 +2439,7 @@ testPlanAddressContactViaShortAddress = bob @@@ [("@alice", "")] -- GUI api -#if defined(dbPostgres) bob ##> "/_connect contact 1 4" -#else - bob ##> "/_connect contact 1 2" -#endif connecting alice bob where connecting alice bob = do @@ -3079,14 +3071,8 @@ testChangePCCUser = testChat2 aliceProfile bobProfile $ alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection back to other user -#if defined(dbPostgres) alice ##> "/_set conn user :2 3" alice <## "connection 2 changed from user alisa to user alisa2, new link:" -#else - -- connection ID does not change in SQLite because table has no auto-increment - alice ##> "/_set conn user :1 3" - alice <## "connection 1 changed from user alisa to user alisa2, new link:" -#endif alice <## "" _shortInv <- getTermLine alice alice <## "" @@ -3128,14 +3114,8 @@ testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection back to initial user -#if defined(dbPostgres) alice ##> "/_set conn user :2 1" alice <## "connection 2 changed from user alisa to user alice, new link:" -#else - -- connection ID does not change in SQLite because table has no auto-increment - alice ##> "/_set conn user :1 1" - alice <## "connection 1 changed from user alisa to user alice, new link:" -#endif alice <## "" _shortInv <- getTermLine alice alice <## "" @@ -3173,16 +3153,9 @@ testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection to incognito and make sure it's attached to the newly created user profile -#if defined(dbPostgres) alice ##> "/_set incognito :2 on" _ <- getTermLine alice alice <## "connection 2 changed to incognito" -#else - -- connection ID does not change in SQLite because table has no auto-increment - alice ##> "/_set incognito :1 on" - _ <- getTermLine alice - alice <## "connection 1 changed to incognito" -#endif bob ##> ("/connect " <> inv) bob <## "confirmation sent!" alisaIncognito <- getTermLine alice diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index fb9e8317c3..79415071f7 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -433,5 +433,11 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.call.offer with call version" $ "{\"v\":\"9\",\"event\":\"x.call.offer\",\"params\":{\"callId\":\"AQIDBA==\",\"offer\":{\"callType\":{\"media\":\"video\",\"capabilities\":{\"encryption\":true}},\"rtcSession\":{\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"},\"callVersion\":2}}}" #==# XCallOffer (CallId "\1\2\3\4") CallOffer {callType = defaultCallType, rtcSession = testRTCSession, callDhPubKey = Nothing, callVersion = Just $ VersionCall 2} + it "x.call.extra without session" $ + "{\"v\":\"9\",\"event\":\"x.call.extra\",\"params\":{\"callId\":\"AQIDBA==\",\"extra\":{\"rtcExtraInfo\":{\"rtcIceCandidates\":\"[]\"}}}}" + #==# XCallExtra (CallId "\1\2\3\4") CallExtraInfo {rtcExtraInfo = WebRTCExtraInfo {rtcSession = Nothing, rtcIceCandidates = "[]"}} + it "x.call.extra with ICE restart session" $ + "{\"v\":\"9\",\"event\":\"x.call.extra\",\"params\":{\"callId\":\"AQIDBA==\",\"extra\":{\"rtcExtraInfo\":{\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"}}}}" + #==# XCallExtra (CallId "\1\2\3\4") CallExtraInfo {rtcExtraInfo = WebRTCExtraInfo {rtcSession = Just "{}", rtcIceCandidates = "[]"}} where testRTCSession = WebRTCSession {rtcSession = "{}", rtcIceCandidates = "[]"} diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 0f2cddcc1c..fecebfc8b2 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -146,7 +146,8 @@ skipComparisonForDownMigrations = -- appends; CREATE INDEX appends). "20260529_delivery_job_senders", -- group_domain is removed - "20260603_simplex_name" + "20260603_simplex_name", + "20261018_autoincrement_ids" ] getSchema :: FilePath -> FilePath -> IO String