Merge branch 'master' into nd/new-profile-for-invitation

This commit is contained in:
Narasimha-sc committed 2026-10-19 11:52:32 +00:00
commit 0201c7f229
69 files changed
+2186 -9906

No files matched your search

+30
View File
@@ -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."
+5
View File
@@ -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
+102 -121
View File
@@ -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
+164 -100
View File
@@ -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
+3 -3
View File
@@ -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) }) {
+26 -12
View File
@@ -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
+14 -6
View File
@@ -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)
+135 -28
View File
@@ -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<Void, Never>? = nil
var iceRestarts: Task<Void, Never>? = 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: ()
}
+3 -1
View File
@@ -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
}
+10 -2
View File
@@ -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 |
+1 -1
View File
@@ -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) |
+12 -4
View File
@@ -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
@@ -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<WebRTCCallStatus>("\"${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<WCallCommand>, 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()
@@ -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 -> {
@@ -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<RTCIceServer>? = 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<RTCIceServer>? = 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?,
@@ -1301,6 +1301,7 @@
<string name="callstate_received_answer">received answer…</string>
<string name="callstate_received_confirmation">received confirmation…</string>
<string name="callstate_connecting">connecting…</string>
<string name="callstate_reconnecting">reconnecting…</string>
<string name="callstate_connected">connected</string>
<string name="callstate_ended">ended</string>
@@ -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)
@@ -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
@@ -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) {
@@ -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<WebRTCCallStatus>("\"${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<WCallCommand>, 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)
}
}
}
}
}
+11 -1
View File
@@ -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 |
+5 -4
View File
@@ -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) |
+1 -1
View File
@@ -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) |
+4 -1
View File
@@ -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.
---
<a id="ActiveCallView"></a>
@@ -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 |
+1 -1
View File
@@ -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
+2 -1
View File
@@ -54,7 +54,7 @@ pypi.<package>.<version> commit of the publication
3. Run of `build.yml`:
- `maybe-release`: version claimed for the commit in `releases.toml`, draft `core-<version>` 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
+3 -1
View File
@@ -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.
+3
View File
@@ -364,6 +364,9 @@
"rtcExtraInfo": {
"properties": {
"rtcIceCandidates": {"type": "string"}
},
"optionalProperties": {
"rtcSession": {"type": "string"}
}
}
}
+17
View File
@@ -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
```
+2 -1
View File
@@ -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"
},
+88 -17
View File
@@ -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<void>
}
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<string, any>
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<T>(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 {
+147
View File
@@ -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}
@@ -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}`)
}
})
)
})
@@ -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}
@@ -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)
})
)
})
File diff suppressed because it is too large. Load diff
+37
View File
@@ -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.
+11 -24
View File
@@ -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 <app dir> <out dir> [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 <tar.gz> <dir>` / `stop <dir>`: 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 `<dir>/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).
+23 -14
View File
@@ -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
@@ -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
-6
View File
@@ -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"
+3
View File
@@ -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
+17 -13
View File
@@ -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"
+18 -18
View File
@@ -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 <id> --log` and `gh run download <id> -n <artifact>`.
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 <id> --log` and `gh run download <id> -n <artifact>`.
## 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 `<work dir>/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 `<work dir>/seed.md` (the Claude sessions get it) and logs to `<work dir>/*.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 <artifact name>`: finds the newest run of this branch in this repository with that artifact; artifacts from forks and other branches are never used.
+29 -25
View File
@@ -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
-1
View File
@@ -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 <<EOF
ignore-project: False
-1
View File
@@ -19,7 +19,6 @@ cabal --version
echo "rts extra-libraries: $(ghc-pkg field rts extra-libraries --simple-output)"
echo "ghc-bignum extra-libraries: $(ghc-pkg field ghc-bignum extra-libraries --simple-output)"
find "$(ghc --print-libdir)" -name 'lib*ffi*.a' -o -name 'libgmp*.a' -o -name 'libHSrts*_thr.a'
du -sh ~/.cabal/store dist-newstyle || true
cat > cabal.project.local <<EOF
ignore-project: False
+1 -1
View File
@@ -56,4 +56,4 @@ Write `report.md` in the current directory as you go, so a session that is cut s
4. `## Findings`: app bugs, each with steps, what you saw, what you expected, screenshots.
5. `## Harness notes`: anything about `ui` or the runner that got in the way.
End the session by making sure `report.md` is complete and no row says `todo`; your final message is a three-line summary: passed, failed and skipped counts, and the most important finding.
End the session by making sure `report.md` is complete and no row says `todo`; your final message is a three-line summary: passed, failed and skipped counts, and the most important finding, then one more line: `CLAUDE-TEST: PASS` if no case failed, otherwise `CLAUDE-TEST: FAIL` and the IDs of the cases that failed.
+2
View File
@@ -1,6 +1,8 @@
# What claude-test.sh runs: one Claude session per layout (reachable, top), each with its cases, and each session's bounds.
# A case runs in both layouts only when the layouts render it differently; the rest run in one.
# entries-<layout> (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
+23 -5
View File
@@ -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 <output dir>/<layout>: prompt.md, report.md, shots/, transcript.jsonl (stream-json).
# own directory, which ends up in <output dir>/<layout>: 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 <output dir>
# 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
+16 -4
View File
@@ -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 <output dir>/sessions; a session's files reach <output dir> 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"
+3 -3
View File
@@ -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"
+31
View File
@@ -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
+1 -1
View File
@@ -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";
+1
View File
@@ -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:
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+11 -6
View File
@@ -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 =
+3 -3
View File
@@ -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
+3 -1
View File
@@ -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
@@ -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;
|]
@@ -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=?)
@@ -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,
+2 -4
View File
@@ -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}
+7
View File
@@ -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"
+1 -31
View File
@@ -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"
-27
View File
@@ -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
+6
View File
@@ -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 = "[]"}
+2 -1
View File
@@ -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