Merge branch 'master' into badge-in-app-checkout

This commit is contained in:
spaced4ndy committed 2026-10-16 22:07:28 +04:00
commit a356512645
53 files changed
+6660 -7

No files matched your search

+500
View File
@@ -0,0 +1,500 @@
name: desktop-linux
# A push runs the jobs only when its head commit's message asks for them: see scripts/ci/desktop-linux/README.md.
on:
push:
branches:
- "**"
# a squash merge's message lists the merged commits' subjects, with their tags
- "!master"
- "!stable"
workflow_dispatch:
inputs:
claude:
description: Also run the Claude job, which spends the Claude subscription's usage limits
type: boolean
default: false
backend:
description: Also start the test backend and test the released app
type: boolean
default: false
permissions:
contents: read
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]') }}
cancel-in-progress: true
env:
GHC_VER: 9.6.3
CABAL_VER: 3.10.2.0
JAVA_VER: 17
CORE_PKG: desktop-core-linux-x86_64.tar.gz
BACKEND_PKG: desktop-linux-backend.tar.gz
jobs:
plan:
name: choose jobs, find earlier builds
if: >-
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]')
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
run-claude: ${{ steps.jobs.outputs.claude }}
run-backend: ${{ steps.jobs.outputs.backend }}
backend: ${{ steps.names.outputs.backend }}
backend-run: ${{ steps.backend.outputs.run-id }}
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:desktop]') }}
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
if: steps.jobs.outputs.backend == 'true'
uses: actions/checkout@v5
# 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"
- name: Find backend
id: backend
if: steps.jobs.outputs.backend == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.backend }}"
backend:
name: test backend for the app
needs: plan
if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == ''
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
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
sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev
- 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
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend/${{ env.BACKEND_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
core:
name: core library for Linux
needs: plan
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Show runner
run: |
lsb_release -ds
nproc
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
with:
name: desktop-core-linux-x86_64
path: ~/core/${{ env.CORE_PKG }}
compression-level: 0
retention-days: 3
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
if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
submodules: recursive
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
echo "ANDROID_HOME=$ANDROID_HOME"
ls "$ANDROID_HOME/platforms" "$ANDROID_HOME/build-tools" || true
- name: Install tools
run: |
sudo apt-get update
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
with:
name: desktop-core-linux-x86_64
path: ~/core
- name: Install core library
id: core
run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }}
- name: Download backend
if: needs.plan.outputs.run-backend == 'true'
uses: actions/download-artifact@v7
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.run-backend == 'true'
timeout-minutes: 30
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
# the app and its tests are still built when the backend fails
- name: Setup Java
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
uses: actions/setup-java@v5
with:
distribution: corretto
java-version: ${{ env.JAVA_VER }}
cache: gradle
- name: Build app
id: build
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
working-directory: apps/multiplatform
run: |
./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable
app=release/main/app/simplex
ls -la "$app/bin" "$app/lib/app/resources"
du -sh "$app"
LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so"
- name: Run desktop tests
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
working-directory: apps/multiplatform
run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :common:desktopTest
- name: Report desktop tests
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: scripts/ci/desktop-linux/test-summary.py apps/multiplatform/common/build/test-results/desktopTest
- name: Run smoke test
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' }}
env:
BACKEND: ${{ steps.backend.outcome }}
run: |
if [ "$BACKEND" = success ]; then
export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server")
cases=backend
fi
scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/smoke-test" ${cases:-}
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: desktop-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
- name: Upload desktop test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: desktop-test-report
path: |
apps/multiplatform/common/build/reports/tests/desktopTest
apps/multiplatform/common/build/test-results/desktopTest
retention-days: 3
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v6
with:
name: desktop-backend-logs
path: ${{ runner.temp }}/backend/*.log
retention-days: 3
if-no-files-found: warn
# Claude Code repeats the smoke test with the ui helper. Each run spends the Claude subscription's usage
# limits, so it runs only when asked for. The app-sim-claude environment's branch rule decides which branches
# may use the token.
claude:
name: Claude tests the desktop app
needs: [plan, core, backend]
if: ${{ !cancelled() && !failure() && needs.plan.outputs.run-claude == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
environment:
name: app-sim-claude
deployment: false
concurrency:
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
CLAUDE_MODEL: claude-haiku-5-5
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
submodules: recursive
persist-credentials: false
- name: Install tools
run: |
sudo apt-get update
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
with:
name: desktop-core-linux-x86_64
path: ~/core
- name: Install core library
run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }}
- name: Download backend
if: needs.plan.outputs.run-backend == 'true'
uses: actions/download-artifact@v7
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
if: needs.plan.outputs.run-backend == 'true'
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: |
npm install --prefix "$RUNNER_TEMP/claude-code" "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"
echo "$RUNNER_TEMP/claude-code/node_modules/.bin" >> "$GITHUB_PATH"
printf '#!/bin/sh\nexec %s/scripts/ci/desktop-linux/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui
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
# 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.
- name: Run Claude
id: claude
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1
run: scripts/ci/desktop-linux/claude-test.sh "$RUNNER_TEMP/claude-test" "$RUNNER_TEMP/claude-transcript.jsonl"
- name: Report Claude usage
if: ${{ !cancelled() && steps.claude.outcome != 'skipped' }}
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' }}
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload Claude test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: desktop-claude-test
path: |
${{ runner.temp }}/claude-test
${{ runner.temp }}/claude-transcript.jsonl
${{ runner.temp }}/claude-transcript.jsonl.stderr
${{ runner.temp }}/claude-transcript.jsonl.guard.log
${{ runner.temp }}/claude-usage.json
retention-days: 7
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && needs.plan.outputs.run-backend == 'true' }}
uses: actions/upload-artifact@v6
with:
name: desktop-claude-backend-logs
path: ${{ runner.temp }}/backend/*.log
retention-days: 3
if-no-files-found: warn
# the latest release's onboarding, to tell a branch's own failure from the runner's
release:
name: released app under Xvfb
needs: plan
if: needs.plan.outputs.run-backend == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
filter: tree:0
- name: Download released app
run: scripts/ci/desktop-linux/release-app.sh "$RUNNER_TEMP/release"
- name: Install UI tools
run: |
sudo apt-get update
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr
- name: Run smoke test
run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/release/app/opt/simplex" "$RUNNER_TEMP/smoke-test" onboarding
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: desktop-release-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
+348
View File
@@ -0,0 +1,348 @@
name: ios-sim
on:
push:
branches:
- '**'
# a squash merge's message lists the merged commits' subjects, with their tags
- '!master'
- '!stable'
workflow_dispatch:
inputs:
claude:
description: Also run the Claude session
type: boolean
default: false
backend:
description: Start the test backend
type: boolean
default: false
permissions:
contents: read
actions: read
concurrency:
# a push that asks for no run must not cancel one that does
group: ios-sim-${{ github.ref }}-${{ (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]')) && 'requested' || github.run_id }}
cancel-in-progress: true
env:
GHC_VER: 9.6.3
CABAL_VER: 3.10.2.0
CORE_PKG: pkg-ios-aarch64-swift-json.zip
BACKEND_PKG: ios-sim-backend.zip
HOMEBREW_NO_AUTO_UPDATE: 1
HOMEBREW_NO_INSTALL_CLEANUP: 1
# newer images add Command Line Tools whose macOS SDK the default Xcode's linker cannot read
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
jobs:
plan:
name: find earlier builds
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
timeout-minutes: 10
outputs:
core: ${{ steps.names.outputs.core }}
backend: ${{ steps.names.outputs.backend }}
core-run: ${{ steps.core.outputs.run-id }}
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: Checkout code
uses: actions/checkout@v4
# 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
run: |
echo "core=ios-sim-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-core.sh') }}" >> "$GITHUB_OUTPUT"
echo "backend=ios-sim-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
- name: Find core library
id: core
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.core }}"
- name: Find backend
id: backend
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.backend }}"
core:
name: core library for the iOS simulator
needs: plan
if: needs.plan.outputs.core-run == ''
runs-on: macos-latest
timeout-minutes: 240
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Show runner
run: |
sw_vers
sysctl -n hw.ncpu hw.memsize
df -h /
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
- 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
- name: Build core library
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
with:
name: ${{ needs.plan.outputs.core }}
path: ~/Downloads/${{ env.CORE_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
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
timeout-minutes: 240
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Show runner
run: |
sw_vers
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
- 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
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/Downloads/${{ env.BACKEND_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
app:
name: app in the iOS simulator
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
env:
CLAUDE_RUN: ${{ needs.plan.outputs.claude }}
START_BACKEND: ${{ needs.plan.outputs.start-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
- name: Show runner
run: |
sw_vers
df -h /
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
xcrun simctl list runtimes
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Download core library
uses: actions/download-artifact@v4
with:
name: ${{ needs.plan.outputs.core }}
path: ~/Downloads
run-id: ${{ needs.plan.outputs.core-run || github.run_id }}
github-token: ${{ github.token }}
- name: Prepare libraries
run: |
scripts/ci/ios-sim/prepare-libs.sh
scripts/ios/update-pbxproj.sh
- name: Download backend
if: env.START_BACKEND == 'true'
uses: actions/download-artifact@v4
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
if: env.START_BACKEND == 'true'
timeout-minutes: 30
run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
- name: Build app
run: |
xcodebuild build -quiet \
-project apps/ios/SimpleX.xcodeproj \
-scheme "SimpleX (iOS)" \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/DerivedData" \
-onlyUsePackageVersionsFromResolvedFile \
-skipPackagePluginValidation \
-skipMacroValidation \
ARCHS=arm64 \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY=- \
DEVELOPMENT_TEAM= \
PROVISIONING_PROFILE_SPECIFIER=
- 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
with:
name: ios-sim-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && env.START_BACKEND == 'true' }}
uses: actions/upload-artifact@v4
with:
name: ios-sim-backend-logs
path: |
${{ runner.temp }}/backend/*.log
${{ runner.temp }}/backend/seed.md
retention-days: 3
if-no-files-found: warn
- name: Upload Claude test results
if: ${{ always() && env.CLAUDE_RUN == 'true' }}
uses: actions/upload-artifact@v4
with:
name: ios-sim-claude-test
path: ${{ runner.temp }}/claude-test
retention-days: 14
if-no-files-found: warn
@@ -0,0 +1,7 @@
package chat.simplex.common.views.chat
import androidx.compose.runtime.*
// Android keyboards have their own emoji panel
@Composable
actual fun ComposeEmojiButton(pickerShown: MutableState<Boolean>, enabled: Boolean, onEmoji: (String) -> Unit) {}
@@ -0,0 +1,14 @@
package chat.simplex.common.views.chat
import androidx.compose.runtime.*
import androidx.compose.ui.text.TextRange
import kotlin.math.min
@Composable
expect fun ComposeEmojiButton(pickerShown: MutableState<Boolean>, enabled: Boolean, onEmoji: (String) -> Unit)
fun ComposeMessage.insertingEmoji(emoji: String): ComposeMessage {
val start = min(selection.min, text.length)
val end = min(selection.max, text.length)
return ComposeMessage(text.replaceRange(start, end, emoji), TextRange(start + emoji.length))
}
@@ -454,6 +454,7 @@ fun ComposeView(
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
val emojiPickerShown = rememberSaveable { mutableStateOf(false) }
AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } }
suspend fun fetchAndUpdateLinkPreview(url: String) {
@@ -1278,6 +1279,17 @@ fun ComposeView(
val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason((ownerRelayState?.noActiveRelays ?: subscriberRelayState?.noActiveRelays) == true))
val sendMsgEnabled = rememberUpdatedState(userCantSendReason.value == null)
val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv)
val emojiEnabled = sendMsgEnabled.value && composeState.value.preview !is ComposePreview.VoicePreview
LaunchedEffect(emojiEnabled) {
if (!emojiEnabled) emojiPickerShown.value = false
}
KeyChangeEffect(emojiPickerShown.value) {
if (!emojiPickerShown.value && emojiEnabled) focusRequester?.requestFocus()
}
fun onEmoji(emoji: String) {
if (!composeState.value.inProgress) onMessageChange(composeState.value.message.insertingEmoji(emoji))
}
@Composable
fun CommandsButton() {
@@ -1332,7 +1344,7 @@ fun ComposeView(
}
@Composable
fun AttachmentAndCommandsButtons() {
fun ComposeActionButtons() {
val cInfo = chat.chatInfo
Row(
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
@@ -1346,6 +1358,7 @@ fun ComposeView(
if (showAttachment) {
AttachmentButton()
}
ComposeEmojiButton(emojiPickerShown, emojiEnabled, ::onEmoji)
}
}
@@ -1784,7 +1797,7 @@ fun ComposeView(
ContextSendMessageToConnect(generalGetString(MR.strings.compose_send_direct_message_to_connect))
Divider()
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
AttachmentAndCommandsButtons()
ComposeActionButtons()
SendMsgView_(
disableSendButton = disableSendButton,
sendToConnect = { withApi { sendMemberContactInvitation() } }
@@ -1839,7 +1852,7 @@ fun ComposeView(
)
} else {
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
AttachmentAndCommandsButtons()
ComposeActionButtons()
val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi ->
if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast)
else null
@@ -588,6 +588,17 @@
<!-- ComposeView.kt, helpers -->
<string name="attach">Attach</string>
<string name="icon_descr_emoji">Emoji</string>
<string name="emoji_category_smileys">Smileys &amp; emotion</string>
<string name="emoji_category_people">People &amp; body</string>
<string name="emoji_category_animals">Animals &amp; nature</string>
<string name="emoji_category_food">Food &amp; drink</string>
<string name="emoji_category_travel">Travel &amp; places</string>
<string name="emoji_category_activities">Activities</string>
<string name="emoji_category_objects">Objects</string>
<string name="emoji_category_symbols">Symbols</string>
<string name="emoji_category_flags">Flags</string>
<string name="no_emoji_found">No emoji found</string>
<string name="icon_descr_context">Context icon</string>
<string name="icon_descr_cancel_image_preview">Cancel image preview</string>
<string name="icon_descr_cancel_file_preview">Cancel file preview</string>
@@ -0,0 +1,42 @@
package chat.simplex.app
import androidx.compose.ui.text.TextRange
import chat.simplex.common.views.chat.ComposeMessage
import chat.simplex.common.views.chat.insertingEmoji
import kotlin.test.Test
import kotlin.test.assertEquals
// use this command for testing:
// ./gradlew desktopTest
class ComposeEmojiTest {
@Test
fun testInsertIntoEmptyMessage() {
assertEquals(ComposeMessage("😀", TextRange(2)), ComposeMessage().insertingEmoji("😀"))
}
@Test
fun testInsertAtCursor() {
assertEquals(ComposeMessage("hi😀 there", TextRange(4)), ComposeMessage("hi there", TextRange(2)).insertingEmoji("😀"))
assertEquals(ComposeMessage("😀hi", TextRange(2)), ComposeMessage("hi", TextRange(0)).insertingEmoji("😀"))
assertEquals(ComposeMessage("hi👍", TextRange(4)), ComposeMessage("hi").insertingEmoji("👍"))
}
@Test
fun testReplaceSelection() {
assertEquals(ComposeMessage("say ❤️!", TextRange(6)), ComposeMessage("say love!", TextRange(4, 8)).insertingEmoji("❤️"))
// selection made right to left has start > end
assertEquals(ComposeMessage("say ❤️!", TextRange(6)), ComposeMessage("say love!", TextRange(8, 4)).insertingEmoji("❤️"))
}
@Test
fun testInsertAfterEmoji() {
val flag = "🏴󠁧󠁢󠁷󠁬󠁳󠁿"
val withFlag = ComposeMessage(flag)
assertEquals(ComposeMessage(flag + "👩‍💻", TextRange(flag.length + "👩‍💻".length)), withFlag.insertingEmoji("👩‍💻"))
}
@Test
fun testSelectionBeyondTextIsClamped() {
assertEquals(ComposeMessage("ab😀", TextRange(4)), ComposeMessage("ab", TextRange(5)).insertingEmoji("😀"))
}
}
@@ -0,0 +1,248 @@
package chat.simplex.common.views.chat
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.common.ui.theme.EmojiFont
import chat.simplex.common.views.helpers.KeyChangeEffect
import chat.simplex.common.views.helpers.SearchTextField
import chat.simplex.res.*
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.launch
private const val EMOJI_DATA_RESOURCE = "/emoji/emoji.txt"
private const val EMOJI_GROUP_PREFIX = "@"
private val EMOJI_POPUP_WIDTH = 360.dp
private val EMOJI_POPUP_HEIGHT = 420.dp
private val EMOJI_CELL_SIZE = 38.dp
private val EMOJI_SIZE = 26.dp
private val EMOJI_TAB_SIZE = 32.dp
private val EMOJI_TAB_ICON_SIZE = 20.dp
// the buttons left of the message field overlap by 8 dp, this keeps the emoji button a little apart
private val EMOJI_BUTTON_START_PADDING = 4.dp
enum class EmojiCategory(val unicodeGroup: String, val icon: String, val title: StringResource) {
Smileys("Smileys & Emotion", "😀", MR.strings.emoji_category_smileys),
People("People & Body", "👋", MR.strings.emoji_category_people),
Animals("Animals & Nature", "🐻", MR.strings.emoji_category_animals),
Food("Food & Drink", "🍔", MR.strings.emoji_category_food),
Travel("Travel & Places", "🚗", MR.strings.emoji_category_travel),
Activities("Activities", "⚽", MR.strings.emoji_category_activities),
Objects("Objects", "💡", MR.strings.emoji_category_objects),
Symbols("Symbols", "🔣", MR.strings.emoji_category_symbols),
Flags("Flags", "🏁", MR.strings.emoji_category_flags),
}
data class EmojiData(val emoji: String, val name: String)
data class EmojiSection(val category: EmojiCategory, val emojis: List<EmojiData>)
fun parseEmojiData(lines: Sequence<String>): List<EmojiSection> {
val sections = mutableListOf<EmojiSection>()
var emojis = mutableListOf<EmojiData>()
for (line in lines) {
if (line.isEmpty()) continue
if (line.startsWith(EMOJI_GROUP_PREFIX)) {
val group = line.removePrefix(EMOJI_GROUP_PREFIX)
val category = EmojiCategory.entries.firstOrNull { it.unicodeGroup == group }
?: throw IllegalArgumentException("unknown emoji group: $group")
emojis = mutableListOf()
sections.add(EmojiSection(category, emojis))
} else {
val (emoji, name) = line.split("\t", limit = 2)
emojis.add(EmojiData(emoji, name))
}
}
return sections
}
val emojiSections: List<EmojiSection> by lazy {
val stream = EmojiCategory::class.java.getResourceAsStream(EMOJI_DATA_RESOURCE)
?: throw IllegalStateException("missing resource $EMOJI_DATA_RESOURCE")
stream.bufferedReader().useLines(::parseEmojiData)
}
fun searchEmoji(sections: List<EmojiSection>, query: String): List<EmojiData> {
val q = query.trim()
return sections.flatMap { s -> s.emojis.filter { it.name.contains(q, ignoreCase = true) } }
}
@Composable
actual fun ComposeEmojiButton(pickerShown: MutableState<Boolean>, enabled: Boolean, onEmoji: (String) -> Unit) {
// the popup opens above the button, so in a short window it must not be taller than the space there
var spaceAbove by remember { mutableStateOf(EMOJI_POPUP_HEIGHT) }
val density = LocalDensity.current
Box(Modifier.padding(start = EMOJI_BUTTON_START_PADDING).onGloballyPositioned { spaceAbove = with(density) { it.positionInWindow().y.toDp() } }) {
EmojiIconButton(painterResource(MR.images.ic_add_reaction), stringResource(MR.strings.icon_descr_emoji), enabled) {
pickerShown.value = !pickerShown.value
}
if (pickerShown.value) {
Popup(
popupPositionProvider = AboveAnchorPositionProvider,
onDismissRequest = { pickerShown.value = false },
properties = PopupProperties(focusable = true),
// closing on KeyUp, as the window does, so that the same key release does not also close the top modal
onKeyEvent = {
if (it.key == Key.Escape) {
if (it.type == KeyEventType.KeyUp) pickerShown.value = false
true
} else false
}
) {
EmojiPicker(height = minOf(EMOJI_POPUP_HEIGHT, spaceAbove), onEmoji = onEmoji)
}
}
}
}
private object AboveAnchorPositionProvider : PopupPositionProvider {
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
val x = if (layoutDirection == LayoutDirection.Ltr) anchorBounds.left else anchorBounds.right - popupContentSize.width
return IntOffset(
x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)),
(anchorBounds.top - popupContentSize.height).coerceAtLeast(0)
)
}
}
@Composable
private fun EmojiPicker(height: Dp, onEmoji: (String) -> Unit) {
val sections = remember { emojiSections }
val query = remember { mutableStateOf("") }
val found = remember { derivedStateOf { if (query.value.isBlank()) null else searchEmoji(sections, query.value) } }
val gridState = rememberLazyGridState()
KeyChangeEffect(found.value) { gridState.scrollToItem(0) }
val headerIndexes = remember(sections) {
var index = 0
sections.map { s -> index.also { index += s.emojis.size + 1 } }
}
Surface(
Modifier.size(EMOJI_POPUP_WIDTH, height),
shape = RoundedCornerShape(12.dp),
elevation = 8.dp,
border = BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.2f))
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF).size(22.dp),
tint = MaterialTheme.colors.secondary
)
SearchTextField(Modifier.weight(1f), alwaysVisible = false) { query.value = it }
}
if (found.value == null) {
EmojiCategoryTabs(sections, headerIndexes, gridState)
}
Divider()
LazyVerticalGrid(
columns = GridCells.Adaptive(EMOJI_CELL_SIZE),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(6.dp)
) {
val foundEmojis = found.value
if (foundEmojis == null) {
sections.forEach { s ->
sectionHeader(s.category.title)
items(s.emojis) { EmojiCell(it.emoji, onEmoji) }
}
} else if (foundEmojis.isEmpty()) {
sectionHeader(MR.strings.no_emoji_found)
} else {
items(foundEmojis) { EmojiCell(it.emoji, onEmoji) }
}
}
}
}
}
@Composable
private fun EmojiCategoryTabs(sections: List<EmojiSection>, headerIndexes: List<Int>, gridState: LazyGridState) {
val scope = rememberCoroutineScope()
val selected = remember {
derivedStateOf { headerIndexes.indexOfLast { it <= gridState.firstVisibleItemIndex } }
}
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) {
sections.forEachIndexed { i, s ->
EmojiTab(selected = selected.value == i, onClick = { scope.launch { gridState.scrollToItem(headerIndexes[i]) } }) {
Text(s.category.icon, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_TAB_ICON_SIZE.toSp() })
}
}
}
}
@Composable
private fun EmojiTab(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
Box(
Modifier
.size(EMOJI_TAB_SIZE)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) MaterialTheme.colors.secondary.copy(alpha = 0.15f) else Color.Transparent)
.focusProperties { canFocus = false }
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
content()
}
}
private fun LazyGridScope.sectionHeader(title: StringResource) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
stringResource(title),
Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary
)
}
}
@Composable
private fun EmojiCell(emoji: String, onEmoji: (String) -> Unit) {
Box(
Modifier
.size(EMOJI_CELL_SIZE)
.clip(RoundedCornerShape(8.dp))
// not focusable, so that the search field keeps focus and typing after a click goes to search
.focusProperties { canFocus = false }
.clickable { onEmoji(emoji) },
contentAlignment = Alignment.Center
) {
Text(emoji, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_SIZE.toSp() }, maxLines = 1)
}
}
@Composable
private fun EmojiIconButton(icon: Painter, contentDescription: String, enabled: Boolean, onClick: () -> Unit) {
IconButton(onClick, enabled = enabled) {
Icon(
icon,
contentDescription = contentDescription,
tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
package chat.simplex.app
import chat.simplex.common.views.chat.*
import kotlin.test.*
// use this command for testing:
// ./gradlew desktopTest
class EmojiPickerDataTest {
private val skinToneModifiers = 0x1F3FB..0x1F3FF
@Test
fun testBundledDataHasAllCategoriesInOrder() {
assertEquals(EmojiCategory.entries.toList(), emojiSections.map { it.category })
emojiSections.forEach { assertTrue(it.emojis.isNotEmpty(), "empty category ${it.category}") }
assertEquals(1870, emojiSections.sumOf { it.emojis.size })
}
@Test
fun testBundledDataEntries() {
val all = emojiSections.flatMap { it.emojis }
assertEquals(all.size, all.map { it.emoji }.toSet().size, "duplicate emoji")
all.forEach {
assertTrue(it.emoji.isNotEmpty() && it.name.isNotBlank(), "bad entry $it")
assertFalse(it.emoji.codePoints().anyMatch { cp -> cp in skinToneModifiers }, "skin tone variant ${it.name}")
}
assertEquals(EmojiData("😀", "grinning face"), emojiSections.first().emojis.first())
assertEquals(EmojiData("🏴󠁧󠁢󠁷󠁬󠁳󠁿", "flag: Wales"), emojiSections.last().emojis.last())
}
@Test
fun testParseEmojiData() {
val sections = parseEmojiData(sequenceOf("@Smileys & Emotion", "😀\tgrinning face", "", "@Flags", "🏁\tchequered flag", "#️⃣\tkeycap: #"))
assertEquals(
listOf(
EmojiSection(EmojiCategory.Smileys, listOf(EmojiData("😀", "grinning face"))),
EmojiSection(EmojiCategory.Flags, listOf(EmojiData("🏁", "chequered flag"), EmojiData("#️⃣", "keycap: #"))),
),
sections
)
}
@Test
fun testParseUnknownGroupFails() {
assertFailsWith<IllegalArgumentException> { parseEmojiData(sequenceOf("@Component", "🏻\tlight skin tone")) }
}
@Test
fun testSearchEmoji() {
assertEquals(listOf("❤️"), searchEmoji(emojiSections, "red heart").map { it.emoji })
assertEquals(searchEmoji(emojiSections, "red heart"), searchEmoji(emojiSections, " RED Heart "))
val flags = searchEmoji(emojiSections, "flag: ")
assertTrue(flags.size > 250, "found ${flags.size} flags")
assertTrue(searchEmoji(emojiSections, "no such emoji name").isEmpty())
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ This document provides a structured mapping between product-level concepts, thei
| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) |
| PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` |
| PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) |
| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) |
| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt`, `ComposeEmojiPicker.kt` | `Controller.hs` (`APISendMessages`) |
| PC5 | Message Reactions | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/ChatItemView.kt` (ChatItemReactions composable) | `Controller.hs` (`APIChatItemReaction`) |
| PC6 | Message Editing | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `ChatItemInfoView.kt` | `Controller.hs` (`APIUpdateChatItem`) |
| PC7 | Message Deletion | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/MarkedDeletedItemView.kt`, `DeletedItemView.kt` | `Controller.hs` (`APIDeleteChatItem`) |
+27
View File
@@ -14,6 +14,7 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des
6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented)
7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented)
8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration)
9. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-09-desktop-emoji-picker-without-skin-tones-and-localized-search)
---
@@ -322,3 +323,29 @@ A stale registration or an unregistered Flatpak sends the user back to a link th
1. Remove the registration in an uninstaller where one exists (MSI), and on AppImage when the entry's file is gone.
2. Removing the command-line exposure needs the page to encrypt the code to a key the app passes in the URL fragment.
---
## GAP-09: Desktop: Emoji Picker Without Skin Tones and Localized Search
**Severity:** Low
**Category:** Feature / Platform
**Platform:** Desktop only
### Description
The desktop compose emoji picker lists emoji without skin tone variants, and its search matches English Unicode emoji names only.
### Affected Locations
- `common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt`
- `scripts/desktop/generate-emoji-data.py` (`../../scripts/desktop/` relative to `apps/multiplatform/`) -- drops skin tone variants
### Impact
Desktop users cannot pick a skin tone in the picker, and search in other languages finds nothing.
### Recommendation
1. Keep the skin tone variants in the generated data, grouped by base emoji, and add a skin tone choice to the picker.
2. Add localized names (CLDR annotations) to the generated data for search.
+2
View File
@@ -95,6 +95,7 @@ Bottom input area for composing messages:
|---|---|
| Text field | `PlatformTextField` with markdown support, `@mention` autocomplete, file paste support |
| Attachment button | Opens `ModalBottomSheetLayout` with options: camera, gallery (image/video), file |
| Emoji button | Desktop only (Android keyboards have their own emoji panel). Opens a popup with search and categories; a picked emoji is inserted at the cursor and the picker stays open. With text selected the emoji goes after it, as the text field drops its selection when the picker takes focus. Picked emoji are not remembered, for privacy. Esc, a click outside or the button closes it |
| Send button | Sends message; changes to checkmark for reports; animated size/alpha |
| Voice record button | Shown when text is empty and voice allowed; hold to record, release to preview |
| Live message button | Start/update live typing message (if `liveMessageAlertShown`) |
@@ -132,4 +133,5 @@ When `timedMessageAllowed` is true, compose bar includes a timer icon for settin
| `ChatView.kt` | `views/chat/ChatView.kt` |
| `ComposeView.kt` | `views/chat/ComposeView.kt` |
| `SendMsgView.kt` | `views/chat/SendMsgView.kt` |
| `ComposeEmojiPicker.kt` | `views/chat/ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) |
| Chat item views | `views/chat/item/*.kt` |
+29 -3
View File
@@ -14,6 +14,7 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView
6. [Attachment Handling](#6-attachment-handling)
7. [Draft Persistence](#7-draft-persistence)
8. [Source Files](#8-source-files)
9. [Emoji Picker](#9-emoji-picker)
---
@@ -41,9 +42,10 @@ ComposeView
| |-- ComposeImageView [media thumbnails]
| |-- ComposeVoiceView [voice recording waveform]
| +-- ComposeFileView [file name display]
|-- AttachmentAndCommandsButtons
|-- ComposeActionButtons
| |-- CommandsButton [bot commands "//"]
| +-- AttachmentButton [paperclip icon]
| |-- AttachmentButton [paperclip icon]
| +-- ComposeEmojiButton [desktop emoji picker popup]
+-- SendMsgView
|-- PlatformTextField [multiline text input]
|-- DeleteTextButton [clear text, shown on long text]
@@ -348,7 +350,7 @@ When `privacyLinkPreviews` is enabled and the message contains a URL:
## 7. Draft Persistence
**Location:** [`ComposeView.kt#L1230`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1230) (`KeyChangeEffect(chatModel.chatId.value)`)
**Location:** [`ComposeView.kt#L1394`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1394) (`KeyChangeEffect(chatModel.chatId.value)`)
Controlled by the `privacySaveLastDraft` preference.
@@ -397,3 +399,27 @@ When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the d
| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions |
| `ComposeContextProfilePickerView.kt` | Profile picker in compose context |
| `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) |
| `ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) | Desktop emoji button and picker, emoji insertion |
---
<a id="EmojiPicker"></a>
## 9. Emoji Picker
**Source:** [`ComposeEmojiPicker.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt), [`ComposeEmojiPicker.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt)
Desktop only; on Android the [`ComposeEmojiButton()`](../../common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt) actual is empty, as keyboards have their own emoji panel.
An emoji button in [`ComposeActionButtons()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1346-L1363) toggles `emojiPickerShown`, owned by `ComposeView`. A picked emoji goes through [`onEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1290-L1292): [`insertingEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt#L10-L14) replaces the selection (clamped to the text) and moves the cursor after the emoji, then `onMessageChange` applies markdown parsing, link sanitizing and the large-emoji font. Emoji are ignored while a message is in progress. The picker stays open after picking. The text field collapses its selection to the end when the popup takes focus, so the emoji goes after selected text.
[`emojiEnabled`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1282-L1288) is false when sending is not allowed or a voice message is previewed; the picker closes then. When the picker closes, the text field gets focus through `focusRequester`.
| Function | Behavior |
|---|---|
| [`ComposeEmojiButton()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L89-L115) | Button 4 dp after the attachment button (`EMOJI_BUTTON_START_PADDING`, against the row's -8 dp overlap). Focusable `Popup` above the button; closes on Esc (on key release, so the window does not also close its top modal), outside click or the button; its height is capped to the space above the button |
| [`EmojiPicker()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L127-L178) | Search field, category tabs, `LazyVerticalGrid` with one section per `EmojiCategory`; tabs and cells are not focusable, so typing after a click goes to search. Picked emoji are not stored, so nothing about what the user typed is kept in preferences |
Data: `desktopMain/resources/emoji/emoji.txt` is generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0 (the version covered by the bundled NotoColorEmoji): fully-qualified emoji without skin tone variants, `@<group>` lines followed by `<emoji>\t<name>` lines. [`parseEmojiData()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L59-L76) maps groups to `EmojiCategory` and fails on an unknown group; [`emojiSections`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L78-L82) loads it lazily. [`searchEmoji()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L84-L87) matches the English name case-insensitively.
[GAP] No skin tone selection, and search uses English emoji names only.
+3
View File
@@ -111,6 +111,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features |
| `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages |
| `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle |
| `views/chat/ComposeEmojiPicker.kt` | PC4 | Low | Emoji button expect declaration, emoji insertion |
| `views/chat/ComposeVoiceView.kt` | PC9 | Medium | Voice message recording UI |
| `views/chat/ComposeFileView.kt` | PC10 | Low | File attachment preview in compose area |
| `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area |
@@ -365,6 +366,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/`
| `views/call/CallAudioDeviceManager.kt` | PC17 | Medium | Android audio routing (speaker, earpiece, bluetooth) |
| `views/chat/ComposeView.android.kt` | PC4, PC10 | Low | Android compose view extensions |
| `views/chat/SendMsgView.android.kt` | PC4 | Low | Android send button extensions |
| `views/chat/ComposeEmojiPicker.android.kt` | PC4 | Low | Empty emoji button actual (no picker on Android) |
| `views/chat/item/ChatItemView.android.kt` | PC2, PC3 | Low | Android chat item extensions |
| `views/chat/item/CIImageView.android.kt` | PC10 | Low | Android image rendering extensions |
| `views/chat/item/CIVideoView.android.kt` | PC10 | Low | Android video rendering extensions |
@@ -443,6 +445,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/`
| `views/call/CallView.desktop.kt` | PC17 | Medium | Desktop WebView-based WebRTC call |
| `views/chat/ComposeView.desktop.kt` | PC4, PC10 | Low | Desktop compose view (drag-and-drop, paste) |
| `views/chat/SendMsgView.desktop.kt` | PC4 | Low | Desktop send shortcut (Enter key handling) |
| `views/chat/ComposeEmojiPicker.desktop.kt` | PC4 | Low | Desktop emoji picker popup (data from `resources/emoji/emoji.txt`, search) |
| `views/chat/item/ChatItemView.desktop.kt` | PC2, PC3 | Low | Desktop chat item extensions |
| `views/chat/item/CIImageView.desktop.kt` | PC10 | Low | Desktop image rendering |
| `views/chat/item/CIVideoView.desktop.kt` | PC10 | Low | Desktop video rendering |
+51
View File
@@ -0,0 +1,51 @@
# Desktop: emoji picker in the compose field
## Problem
The desktop compose field has no way to choose an emoji: Linux has no system emoji picker, and on Windows and macOS the system pickers are shortcuts most users do not know. Android keyboards have their own emoji panel, so Android is not changed.
## Solution
On desktop, an emoji button next to the attachment button opens a popup emoji picker: search field, category tabs, grid of emoji. A picked emoji is inserted at the cursor and the cursor moves after it; the picker stays open, so several emoji can be picked in a row. Esc, a click outside or the button closes it, and focus returns to the text field. The text field collapses its selection to the end when the popup takes focus, so with text selected the emoji goes after it.
No protocol or core changes: picked emoji are ordinary message text.
## Technical design
### Common (`views/chat/ComposeEmojiPicker.kt`)
- `expect fun ComposeEmojiButton(pickerShown, enabled, onEmoji)`. It sits in `ComposeActionButtons` (renamed from `AttachmentAndCommandsButtons`) after the attachment button. The Android actual is empty.
- `fun ComposeMessage.insertingEmoji(emoji: String): ComposeMessage` replaces `selection` (clamped to the text) with the emoji and puts the cursor after it. `ComposeView.onEmoji` passes the result to `onMessageChange`, so markdown parsing, link sanitizing and the large-emoji font apply as for typed text. Ignored while a message is being sent.
`ComposeView` owns `emojiPickerShown` (`rememberSaveable`). It is closed when sending becomes disabled or a voice message is recorded (`emojiEnabled`). When it closes, focus returns to the text field through the existing `focusRequester`.
### Desktop (`ComposeEmojiPicker.desktop.kt`)
- Button: same look as the attachment button. The row keeps its `spacedBy(-8.dp)` overlap; the emoji button adds `EMOJI_BUTTON_START_PADDING` (4 dp), so its centre is 44 dp from the attachment button.
- Data: `desktopMain/resources/emoji/emoji.txt`, generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0, the version covered by the bundled NotoColorEmoji 2.038 (all 1870 sequences checked against the font cmap). Fully-qualified emoji only, without the Component group and without skin tone variants. Format: `@<group>` lines, then `<emoji>\t<name>` lines. Parsed once, lazily (`emojiSections`).
- `EmojiCategory` maps Unicode group names to a tab emoji and a localized title.
- Popup: focusable `Popup` above the button (`AboveAnchorPositionProvider`), 360×420 dp, or shorter when the window has less space above the button. Search field (auto-focused), category tabs, then a `LazyVerticalGrid` with section headers; tabs and cells are not focusable, so typing after a click still goes to search. Esc (handled on key release, as the window does, so it does not also close the top modal), a click outside, or the button closes it.
- Search: case-insensitive substring of the English Unicode name (`searchEmoji`).
- No recently used list: storing picked emoji in preferences would keep a trace of what the user wrote outside the encrypted database.
- Rendering uses `EmojiFont` (Noto on Windows/Linux, system font on macOS), as emoji-only messages do.
## Affected docs
- `product/views/chat.md` — Compose Bar table: emoji button.
- `spec/client/compose.md` — emoji picker functions.
- `spec/impact.md` — new source files.
- `product/concepts.md` — PC4 source list.
## Not included
- Android emoji picker: the keyboard's emoji panel covers it. An in-app panel was built and tested in simulation, then dropped; it would also need its own recents disabled for privacy.
- Skin tone selection.
- Emoji shortcode autocomplete (`:smile:`).
- Localized emoji names for search.
- Custom (image) emoji.
## Verification
- Unit tests: `insertingEmoji` (empty, cursor, selection, surrogate pairs); desktop data loads, every group maps to a category, no skin tone modifiers, search.
- The real desktop app run under Xvfb with screenshots and a screen recording: button in a group compose bar and its spacing, insert at cursor, several picks, search, no results, category tabs, close by Esc / outside click / button with focus returned, emoji-only message in large font, light and dark themes, editing, live messages, chat switching, small window, large font, right-to-left layout.
- Android: `compileDebugKotlinAndroid` passes; the compose bar is unchanged from before.
+61
View File
@@ -0,0 +1,61 @@
# Desktop app CI on Linux
`.github/workflows/desktop-linux.yml` builds the desktop app from the branch on a GitHub runner (Ubuntu 24.04), runs its JVM tests (`:common:desktopTest`), and drives the app on a virtual display: onboarding to the chat list, then the chat list with the toolbar at the bottom (the default) and at the top. On request it also runs Claude Code driving the same path, a test backend, and the latest release as a baseline.
## Running it
On any branch, in either of two ways:
- **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.
## 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 |
| `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.
## 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.
- `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
`backend.sh` starts, on the runner, with CLI peers:
- a chat relay (`--relay --headless`) and a directory service on the preset servers;
- a group `ci_group` and a channel `ci_channel` with letter avatars as large as the apps make them (`avatar`), registered and approved by the admin `ci_admin`, and found by a new user who sends the directory "ci seed";
- an SMP server on 127.0.0.1:5223 whose `[NAMES]` section asks `names-resolver.py`, and a contact `ci_contact` whose address it resolves as `seeded.testing`, checked with a connect plan from another client.
It writes the directory's address to `<dir>/directory-link` and the SMP server's to `<dir>/names-server`. The app reaches the names server only with private routing set to unknown servers (`ui start … NetworkSMPProxyMode=Unknown`), since the preset proxies can't reach 127.0.0.1, and with the test server's receiving role off: a directory can't answer requests whose reply queue is on it.
## 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`.
## 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).
+341
View File
@@ -0,0 +1,341 @@
#!/bin/bash
# Test backend on the app runner: a chat relay and a directory service on the preset servers, with one group
# and one channel registered and approved by a CLI peer, and another CLI peer that must find both by sending the
# directory the search text. Both have profile images as large as the apps make them.
# For SimpleX names, an SMP server on 127.0.0.1 resolves .testing names through a local resolver, and a CLI peer
# with an address on it claims the name seeded.testing.
# Usage:
# scripts/ci/desktop-linux/backend.sh start <backend .tar.gz> <work dir> writes the directory's short address to <work dir>/directory-link
# and the names server's address to <work dir>/names-server
# scripts/ci/desktop-linux/backend.sh stop <work dir> stops the servers, prints their logs
# The relay, the directory, the SMP server and the resolver keep running between start and stop.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
directory_name="SimpleX Directory"
admin=ci_admin
search_text="ci seed"
# the longest profile image data URI the apps make and the core accepts (maxProfileImageSize)
max_image_size=12500
name_label=seeded
resolver_port=8090
# Runs one chat command in a CLI client that stays online for <secs> after it; the output is in $work/out.
chat() {
local db=$1 secs=$2 cmd=$3
shift 3
local status=0
"$bin/simplex-chat" -d "$work/$db" -e "$cmd" -t "$secs" --execute-log all "$@" < /dev/null > "$work/out" 2>&1 || status=$?
cat "$work/out" >> "$work/$db-cli.log"
# commands with images are thousands of characters long
echo "::group::$db: ${cmd:0:200}"
cat "$work/out"
echo "::endgroup::"
return "$status"
}
admin() {
chat "$admin" "$1" "$2" --user-display-name "$admin"
}
expect_out() {
if ! grep -qE "$1" "$work/out"; then
echo "error: \"$1\" not in the output"
return 1
fi
}
# Each run keeps the client online for a few seconds, which also lets the handshakes it waits for progress.
until_shown() {
local client=$1 pattern=$2 secs=$3 cmd=$4
local deadline=$((SECONDS + secs))
until "$client" 5 "$cmd" && grep -qE "$pattern" "$work/out"; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "error: \"$pattern\" not shown by \"$cmd\" after $secs s"
return 1
fi
done
}
admin_until() {
until_shown admin "$@"
}
db_query() {
sqlite3 "$work/$1_chat.db" "$2"
}
# Prints a profile image data URI made as the apps make one: JPEG at quality 85, scaled down by the square root
# of its excess, at most halved per step, until it is at most max_image_size characters. The picture fills the
# whole square, a gradient between two shades of one colour with a large white letter; noise keeps JPEG from
# compressing it much, so the image ends up near the limit.
avatar() {
local top=$1 bottom=$2 letter=$3 source=$work/avatar-source.png file=$work/avatar.jpg size=512 uri font
font=$(fc-match -f '%{file}' 'DejaVu Sans:bold')
convert -size 512x512 gradient:"$top-$bottom" -seed 1 -attenuate 0.5 +noise Gaussian \
-font "$font" -pointsize 320 -fill white -gravity center -annotate +0+0 "$letter" "$source"
while :; do
convert "$source" -resize "${size}x${size}" -quality 85 "$file"
uri="data:image/jpg;base64,$(base64 -w 0 "$file")"
[ "${#uri}" -gt "$max_image_size" ] || break
size=$(awk -v s="$size" -v n="${#uri}" -v m="$max_image_size" 'BEGIN { r = sqrt(n / m); if (r > 2) r = 2; print int(s / r) }')
done
echo "avatar \"$letter\" over $top to $bottom: ${size}x${size} px, ${#uri} characters, mean colour $(convert "$file" -resize '1x1!' -format '%[pixel:p{0,0}]' info:)" >&2
echo "$uri"
}
start_relay() {
nohup "$bin/simplex-chat" -d "$work/relay" --relay --headless --user-display-name ci_relay < /dev/null > "$work/relay.log" 2>&1 &
echo $! > "$work/relay.pid"
local deadline=$((SECONDS + 120))
until grep -qE '^https://[^ ]+/r#' "$work/relay.log"; do
if ! kill -0 "$(cat "$work/relay.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then
echo "error: the relay did not create its address"
cat "$work/relay.log"
return 1
fi
sleep 1
done
relay_link=$(grep -m 1 -oE '^https://[^ ]+/r#[^ ]+' "$work/relay.log")
echo "relay address: $relay_link"
}
# The address is created here, like the directory tests do, with auto-accept on before the service subscribes to it.
create_directory() {
chat directory 1 "/ad pq_ratchet=on" --create-bot-display-name "$directory_name"
directory_link=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out")
case "$directory_link" in
https://*/a#*) ;;
*)
echo "error: no short directory address in the output"
return 1
;;
esac
chat directory 1 "/auto_accept on"
expect_out "^auto_accept on"
echo "$directory_link" > "$work/directory-link"
echo "directory address: $directory_link"
}
start_directory() {
# -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
}
stop_directory() {
local pid deadline
pid=$(cat "$work/directory.pid")
kill -INT "$pid" 2> /dev/null || true
deadline=$((SECONDS + 30))
while kill -0 "$pid" 2> /dev/null; do
if [ "$SECONDS" -ge "$deadline" ]; then
kill -KILL "$pid" 2> /dev/null || true
fi
sleep 1
done
}
# A super user is known by contact ID, which exists only after the admin connects, so the service starts
# with nobody and is restarted with the admin.
connect_admin() {
admin 10 "/c $directory_link"
expect_out "connection request sent!"
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
start_directory "$id:$admin"
echo "directory restarted with super user $id:$admin"
}
# The directory sends each registration to super users as a message with the exact /approve command.
approve() {
local name=$1 reply=$2
admin_until "/approve [0-9]+:$name [0-9]+" 300 "/tail @'$directory_name' 20"
local cmd
cmd=$(grep -oE "/approve [0-9]+:$name [0-9]+" "$work/out" | tail -n 1)
admin 5 "@'$directory_name' $cmd"
admin_until "$reply" 120 "/tail @'$directory_name' 20"
}
# The CLI's group commands can't set an image, so groups are created with the API commands, with the profiles
# that "/g" and "/public group" make plus the image.
group_json() {
local name=$1 descr=$2 image=$3 prefs=$4
echo "{\"displayName\":\"$name\",\"fullName\":\"\",\"shortDescr\":\"$descr\",\"image\":\"$image\",\"groupPreferences\":{\"directMessages\":{\"enable\":\"on\"},\"history\":{\"enable\":\"on\"}$prefs}}"
}
seed_group() {
local user_id
user_id=$(db_query "$admin" "SELECT user_id FROM users WHERE local_display_name = '$admin'")
group_image=$(avatar "rgb(235,80,70)" "rgb(150,25,30)" G)
admin 1 "/_group $user_id $(group_json ci_group "CI seeded group" "$group_image" "")"
expect_out "group #ci_group \(CI seeded group\) is created"
admin 5 "/a ci_group '$directory_name' admin"
expect_out "invitation to join the group #ci_group sent to"
approve ci_group "Group approved!"
}
seed_channel() {
admin 1 "/relays name=ci_relay $relay_link"
expect_out "^ok$"
local user_id relay_id
user_id=$(db_query "$admin" "SELECT user_id FROM users WHERE local_display_name = '$admin'")
relay_id=$(db_query "$admin" "SELECT chat_relay_id FROM chat_relays WHERE display_name = 'ci_relay' AND deleted = 0")
channel_image=$(avatar "rgb(80,200,90)" "rgb(25,120,45)" C)
admin 10 "/_public group $user_id $relay_id $(group_json ci_channel "CI seeded channel" "$channel_image" ",\"support\":{\"enable\":\"off\"}")"
expect_out "group #ci_channel \(CI seeded channel\) is created"
local group_id
group_id=$(db_query "$admin" "SELECT group_id FROM groups WHERE local_display_name = 'ci_channel'")
admin_until "relay id [0-9]+: active" 180 "/_get relays #$group_id"
admin 5 "/share chat #ci_channel @'$directory_name'"
expect_out "link to join channel #ci_channel"
approve ci_channel "Channel approved!"
}
# An SMP server on 127.0.0.1 whose [NAMES] section asks the local resolver, which answers from names.json.
start_names_server() {
local cfg=$work/smp/etc logs=$work/smp/var ini deadline
echo '{}' > "$work/names.json"
nohup python3 "$here/names-resolver.py" "$resolver_port" "$work/names.json" < /dev/null > "$work/resolver.log" 2>&1 &
echo $! > "$work/resolver.pid"
SMP_SERVER_CFG_PATH=$cfg SMP_SERVER_LOG_PATH=$logs "$bin/smp-server" init -y --ip 127.0.0.1 --no-password --disable-web
ini=$cfg/smp-server.ini
# only 5223: 443 needs root
sed -i -e 's/^port = 5223,443$/port = 5223/' \
-e '/^\[NAMES\]/,/^\[/{s/^enable: off$/enable: on/;s|^# resolver_endpoint: http://127.0.0.1:8000$|resolver_endpoint: http://127.0.0.1:'"$resolver_port"'|}' "$ini"
if ! grep -qx 'port = 5223' "$ini" ||
! sed -n '/^\[NAMES\]/,/^\[/p' "$ini" | grep -qx 'enable: on' ||
! sed -n '/^\[NAMES\]/,/^\[/p' "$ini" | grep -qx "resolver_endpoint: http://127.0.0.1:$resolver_port"; then
echo "error: $ini has a different port or [NAMES] than expected:"
grep -E '^port|^\[NAMES\]|enable|resolver' "$ini"
return 1
fi
SMP_SERVER_CFG_PATH=$cfg SMP_SERVER_LOG_PATH=$logs nohup "$bin/smp-server" start < /dev/null > "$work/smp-server.log" 2>&1 &
echo $! > "$work/smp-server.pid"
deadline=$((SECONDS + 60))
until (exec 3<> /dev/tcp/127.0.0.1/5223) 2> /dev/null; do
if ! kill -0 "$(cat "$work/smp-server.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then
echo "error: the SMP server is not listening on 5223"
cat "$work/smp-server.log"
return 1
fi
sleep 1
done
names_server="smp://$(cat "$cfg/fingerprint")@127.0.0.1"
echo "$names_server" > "$work/names-server"
echo "names server: $names_server"
}
# A CLI client started with -s uses only that server and, once its user exists, gives it every role, names included.
names_client() {
local db=$1
shift
chat "$db" "$@" --user-display-name "$db" -s "$names_server"
}
# ci_contact's address is on the names server; the resolver maps seeded.testing to it, and ci_contact claims the name.
seed_name() {
local image link
image=$(avatar "rgb(70,130,240)" "rgb(30,50,170)" N)
names_client ci_contact 1 "/_profile 1 {\"displayName\":\"ci_contact\",\"fullName\":\"\",\"shortDescr\":\"CI seeded contact\",\"image\":\"$image\"}"
names_client ci_contact 3 "/ad"
link=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out")
case "$link" in
https://*/a#*) ;;
*)
echo "error: no short address for ci_contact in the output"
return 1
;;
esac
jq -n --arg n "$name_label" --arg l "$link" '{($n): {simplexContact: [$l]}}' > "$work/names.json"
names_client ci_contact 5 "/_set domain 1 $name_label.testing"
# what the app does on the Search key, from a client that knows nothing but the names server; it finds the
# address only if ci_contact's claim is in the address data
names_client name_probe 1 "/user"
names_client name_probe 5 "/_connect plan 1 @$name_label.testing"
expect_out "contact address: ok to connect"
echo "@$name_label.testing resolves to ci_contact's address"
}
searcher() {
chat searcher "$1" "$2" --user-display-name ci_searcher
}
# A new user connects to the directory and sends the search text; it replies with the count, then one message per
# group or channel found, with its image.
search_by_message() {
searcher 10 "/c $directory_link"
expect_out "connection request sent!"
until_shown searcher "Welcome to $directory_name" 180 "/tail @'$directory_name' 10"
searcher 5 "@'$directory_name' $search_text"
until_shown searcher "Found 2 group\(s\)" 120 "/tail @'$directory_name' 10"
until_shown searcher "ci_group" 60 "/tail @'$directory_name' 10"
until_shown searcher "ci_channel" 60 "/tail @'$directory_name' 10"
echo "search for \"$search_text\" by message found ci_group and ci_channel"
}
dump_directory() {
echo "::group::directory registrations"
db_query directory "SELECT group_id, group_reg_status FROM sx_directory_group_regs" || true
echo "::endgroup::"
}
start() {
bin=$work/bin
mkdir -p "$bin"
tar -C "$bin" -xzf "$1"
"$bin/simplex-chat" --version
"$bin/simplex-directory-service" --version
start_relay
create_directory
start_directory "0:nobody"
trap dump_directory EXIT
connect_admin
seed_group
seed_channel
search_by_message
start_names_server
seed_name
}
stop() {
local p
for p in directory relay smp-server resolver; do
if [ -f "$work/$p.pid" ]; then
kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true
fi
done
sleep 3
for p in directory relay smp-server resolver; do
echo "::group::$p log"
cat "$work/$p.log" 2> /dev/null || true
echo "::endgroup::"
done
}
case "${1:-}" in
start)
work=$3
mkdir -p "$work"
start "$2"
;;
stop)
work=$2
stop
;;
*)
echo "usage: $0 start <backend .tar.gz> <work dir> | stop <work dir>"
exit 1
;;
esac
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Builds the CLI, the directory service and the SMP server that the app job runs as its test backend.
# Usage: scripts/ci/desktop-linux/build-backend.sh <output .tar.gz>
# They need the server-capable configuration (no client_library), as build.yml's Linux job builds the CLI.
# The SMP server, which resolves the test's SimpleX names, comes from a clone of the simplexmq commit that
# cabal.project pins: cabal builds only the libraries of a source-repository-package.
set -euo pipefail
mkdir -p "$(dirname "$1")"
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
jobs: \$ncpus
package direct-sqlcipher
flags: +openssl
EOF
cabal build exe:simplex-chat exe:simplex-directory-service
simplexmq_tag=$(sed -n '/simplex-chat\/simplexmq.git/{n;s/^ *tag: *//p}' cabal.project)
smq=$(mktemp -d)
git -C "$smq" init -q
git -C "$smq" fetch -q --depth 1 https://github.com/simplex-chat/simplexmq.git "$simplexmq_tag"
git -C "$smq" checkout -q FETCH_HEAD
git -C "$smq" submodule update -q --init --recursive
cp cabal.project.local "$smq"
(cd "$smq" && cabal build exe:smp-server)
pkg=$(mktemp -d)
for exe in simplex-chat simplex-directory-service; do
cp "$(cabal list-bin "exe:$exe")" "$pkg"
done
# in a fresh project list-bin first prints git's output from syncing the source-repository-packages
cp "$(cd "$smq" && cabal list-bin exe:smp-server | tail -n 1)" "$pkg"
ldd "$pkg"/*
tar -C "$pkg" -czf "$out" .
ls -la "$out"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Builds the desktop core library for Linux with scripts/desktop/build-lib-linux.sh, as build.yml does, and packs its libs directory.
# Usage: scripts/ci/desktop-linux/build-core.sh <output .tar.gz>
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
jobs: \$ncpus
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"
readelf -d "$libs_dir/libsimplex.so" | grep -E 'SONAME|RUNPATH|NEEDED'
echo "Selected symbols defined in libsimplex.so:"
nm -D --defined-only "$libs_dir/libsimplex.so" | awk '$NF ~ /^(chat_migrate_init_key|chat_send_cmd_retry|chat_recv_msg_wait)$/ {print " " $NF}'
du -sh "$libs_dir"
tar -C "$libs_dir" -czf "$out" .
ls -la "$out"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
# PreToolUse hook for Claude on the runner: lets through only Bash commands that run the ui helper, with plain
# arguments, so the session has no general shell. Claude Code runs read-only commands such as cat and echo without
# asking in every permission mode, so permission rules alone can't exclude them.
# Exits 2 for anything else; Claude sees the message as the reason. Each decision is appended to CLAUDE_GUARD_LOG
# when it is set, as evidence that the hook ran.
import json
import os
import re
import sys
# nothing that chains, pipes, redirects, substitutes, expands or escapes
UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?")
call = json.load(sys.stdin)
command = (call.get("tool_input") or {}).get("command", "").strip()
allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command)
if os.environ.get("CLAUDE_GUARD_LOG"):
with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log:
log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n")
if not allowed:
print(f"Only the ui helper runs here, as one command: ui <command> [args]. Refused: {command[:200]}", file=sys.stderr)
sys.exit(2)
+52
View File
@@ -0,0 +1,52 @@
# Test: onboarding and the chat list in the SimpleX Chat desktop app
You are testing the SimpleX Chat desktop app, which runs on a virtual display on this machine. Drive it only with the `ui` helper, through Bash, one command per call: `ui <command> [args]`. Anything else is refused; don't look for ways around that, and if something blocks the test, report it. The commands are listed at the end.
Your working directory is where your output goes. `ui shot <name>` saves `screens/NN-<name>.png` and prints the text read from it, one phrase per line after its centre `x,y` in screen pixels. Read the PNG to see the screen (1440×900, the app window at the top left). OCR can misread or split words; when the text and the picture disagree, trust the picture. "Above" means a smaller y.
## What is set up
- The app is already running, on its first screen, with a new, empty database: never run `ui start` or `ui stop`. Begin with `ui shot start` to see it.
- After onboarding, the chat list is in the left pane (x 1 to 389), and the right pane shows the empty chat list's cards. An empty chat list shows only its toolbar, with the profile picture at about 27,730 and a pencil at about 363,730: the search field "Search or paste SimpleX link" appears once there is a chat. In the default "Reachable app toolbars" layout, the toolbar and the search field are at the bottom of the left pane.
## The test
**A. Onboarding.** Each screen waits for the one before it; wait for a text before clicking it.
1. The first screen has "Create your profile": click it.
2. On "Create profile", click the field "Enter profile name", type `citester`, and click "Create profile".
3. The next screen offers a database passphrase: click "Use random passphrase", then "OK" in the alert that follows.
4. Click "Continue", then "Accept" on the screen after it.
5. Wait up to 60 s for the chat list: the right pane shows "Talk to someone". Take a shot.
**B. A group, and the chat list with the toolbar at the bottom.**
1. Click the pencil (about 363,730) to open New chat, then "Create group". Click the name field, about 40 px below the text "Enter group name", type `seeded notes`, and click "Create group". If "Skip inviting members" shows, click it.
2. The group opens in the right pane, and stays there. Press Escape until the left pane shows the chat list again, then take a shot.
3. Check, noting the evidence (screenshot names, positions):
- "seeded notes" is listed in the left pane;
- the search field "Search or paste SimpleX link" is at the bottom of the left pane (y over 600), and "Add list" is just above it;
- nothing is cut off, overlapping or garbled.
**Between the layouts:** click the profile picture at the left end of the bottom toolbar (about 27,730) to open the menu, open "Settings", then "Appearance", and turn off the switch of "Reachable app toolbars", at the right end of its row (about x 335). Press Escape until the chat list shows again; its search field should now be at the top.
**C. The chat list, toolbar at the top.** Check, noting the evidence:
- "seeded notes" is still listed;
- the search field is at the top of the left pane (y under 200), and "Add list" is just below it;
- nothing is cut off, overlapping or garbled. The pencil is now a floating button at the bottom right of the left pane, drawn over whatever the list shows there: note what it covers, but that alone is not a failure.
You have at most 60 turns, and the whole test fits in about 25 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example:
- `ui do click-text "Create your profile" -- wait "Enter profile name" -- click-text "Enter profile name" -- type citester -- shot profile`
- `ui do click-text "Create profile" -- wait "Use random passphrase" 60 -- shot passphrase`
- `ui do click 363 730 -- click-text "Create group" 10 -- wait "Enter group name" -- shot new-group`
- `ui do click 27 730 -- click-text Settings 10 -- click-text Appearance 10 -- wait "Reachable app toolbars" -- shot appearance`
`ui do` stops at the first command that fails, so a failed chain tells you where it stopped.
## Report
Write `report.md` in your working directory: for each of A, B and C, each check you ran as PASS or FAIL with its evidence, and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the checks that failed.
## The ui helper
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
# Runs Claude Code to repeat the smoke test with the ui helper, as claude-test.md describes, and passes when
# its final message ends with "CLAUDE-TEST: PASS".
# Usage: scripts/ci/desktop-linux/claude-test.sh <output dir> <transcript .jsonl>
# Expects the app started (smoke-test.sh ... start) with UI_OUT under <output dir>, claude and ui on PATH,
# CLAUDE_MODEL and CLAUDE_CODE_OAUTH_TOKEN. Claude works in <output dir>: the screenshots, its report.md and the
# prompt it was given end up there. The transcript, Claude Code's stderr (<transcript>.stderr) and the guard hook's
# log (<transcript>.guard.log) are kept outside it, where Claude can't write. If any of them holds the token, all are
# deleted and the run fails.
set -euo pipefail
: "${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set}"
here=$(cd "$(dirname "$0")" && pwd)
out=$(cd "$1" && pwd)
transcript=$2
stderr=$transcript.stderr
settings=$(mktemp)
export CLAUDE_GUARD_LOG=$transcript.guard.log
: > "$CLAUDE_GUARD_LOG"
{
cat "$here/claude-test.md"
echo
echo '```'
"$here/ui" || true
echo '```'
} > "$out/prompt.md"
cat > "$settings" << EOF
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "$here/claude-guard.py"}]}]}}
EOF
claude --version
cd "$out"
status=0
claude -p "$(cat prompt.md)" \
--model "$CLAUDE_MODEL" \
--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 \
--no-session-persistence \
--settings "$settings" \
< /dev/null > "$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=$?
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"
exit 1
fi
echo "::group::claude stderr"
cat "$stderr"
echo "::endgroup::"
echo "::group::report.md"
cat report.md 2> /dev/null || echo "no report.md"
echo "::endgroup::"
echo "::group::guard hook decisions"
cat "$CLAUDE_GUARD_LOG"
echo "::endgroup::"
result=$(jq -r 'select(.type == "result") | .result // empty' "$transcript" 2> /dev/null | tail -n 20 || true)
echo "final message, last lines:"
echo "$result"
if [ "$status" -ne 0 ]; then
echo "error: claude exited with $status"
exit 1
fi
last=$(grep -v '^[[:space:]]*$' <<< "$result" | tail -n 1 || true)
grep -qxE '[*`]*CLAUDE-TEST: PASS[*`]*' <<< "$last"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# Reports what a Claude Code run spent, from the result event at the end of its stream-json transcript: prints it,
# adds it to the job summary, and writes it as JSON. Without a result event (a timeout or a crash) it says so
# rather than reporting zero.
# Usage: scripts/ci/desktop-linux/claude-usage.py <transcript .jsonl> <output .json>
import json
import os
import sys
FIELDS = ["subtype", "is_error", "num_turns", "duration_ms", "total_cost_usd", "usage", "modelUsage"]
def last_result(path):
result = None
try:
with open(path) as f:
for line in f:
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(event, dict) and event.get("type") == "result":
result = event
except FileNotFoundError:
pass
return result
def summary(result):
lines = ["### Claude run usage", ""]
if result is None:
return lines + ["No result event in the transcript (timeout or crash): usage unknown, not zero."]
usage = result.get("usage") or {}
lines += [
f"- result: {result.get('subtype')}{' (error)' if result.get('is_error') else ''}",
f"- turns: {result.get('num_turns')}",
f"- duration: {(result.get('duration_ms') or 0) / 1000:.0f} s",
f"- tokens: input {usage.get('input_tokens')}, output {usage.get('output_tokens')}, "
f"cache read {usage.get('cache_read_input_tokens')}, cache write {usage.get('cache_creation_input_tokens')}",
f"- total_cost_usd: {result.get('total_cost_usd')}, estimated at API prices "
"(computed by Claude Code; on a subscription it is not a bill)",
]
for model, model_usage in (result.get("modelUsage") or {}).items():
lines.append(f"- {model}: {json.dumps(model_usage)}")
return lines
def main():
transcript, out = sys.argv[1], sys.argv[2]
result = last_result(transcript)
with open(out, "w") as f:
json.dump({k: result.get(k) for k in FIELDS} if result else {"result": None}, f, indent=2)
text = "\n".join(summary(result))
print(text)
if os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
f.write(text + "\n")
main()
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Finds the newest run of this branch in this repository that uploaded an unexpired artifact with this name,
# and writes its id as run-id to GITHUB_OUTPUT. Needs GH_TOKEN with actions: read.
# Usage: scripts/ci/desktop-linux/earlier-run.sh <artifact name>
# Artifacts from forks and other branches are not used: their workflows could upload anything under this name.
set -euo pipefail
name=$1
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 == $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"
else
echo "$name: not built yet"
fi
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Unpacks the core package from build-core.sh where scripts/desktop/build-lib-linux.sh leaves the libraries,
# and links them as the desktop app's resources, as that script does.
# Usage: scripts/ci/desktop-linux/install-core.sh <core .tar.gz>
set -euo pipefail
arch=$(uname -m)
case "$arch" in
aarch64) compose_arch=arm64 ;;
*) compose_arch=x64 ;;
esac
libs_dir=apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-$arch
mkdir -p "$libs_dir"
tar -C "$libs_dir" -xzf "$1"
ls -la "$libs_dir"
mkdir -p apps/multiplatform/build/links
ln -sfT "../../common/src/commonMain/cpp/desktop/libs/linux-$arch/" "apps/multiplatform/build/links/linux-$compose_arch"
ls -la apps/multiplatform/build/links
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# A SimpleX name resolver for the test SMP server's [NAMES] section, as simplexmq's test resolver answers:
# GET /v2/resolve/<query> with a NameResponse for every name, registered or not, and GET /health.
# Names are read from the registry file on each request: {"<label>": {"simplexContact": [...], "simplexChannel": [...]}},
# all under .testing. A v22 router asks by label hash, "[<keccak-256 hex>].testing", so names are matched by hash.
# Usage: names-resolver.py <port> <registry .json>
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote
MASK = (1 << 64) - 1
ROTATIONS = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
def round_constants():
r, constants = 1, []
for _ in range(24):
c = 0
for j in range(7):
if r & 1:
c |= 1 << ((1 << j) - 1)
r = ((r << 1) ^ 0x71) & 0xFF if r & 0x80 else r << 1
constants.append(c)
return constants
ROUND_CONSTANTS = round_constants()
def rotate(x, n):
return ((x << n) | (x >> (64 - n))) & MASK if n else x
def keccak_f(a):
for rc in ROUND_CONSTANTS:
c = [a[x][0] ^ a[x][1] ^ a[x][2] ^ a[x][3] ^ a[x][4] for x in range(5)]
d = [c[x - 1] ^ rotate(c[(x + 1) % 5], 1) for x in range(5)]
a = [[a[x][y] ^ d[x] for y in range(5)] for x in range(5)]
b = [[0] * 5 for _ in range(5)]
for x in range(5):
for y in range(5):
b[y][(2 * x + 3 * y) % 5] = rotate(a[x][y], ROTATIONS[x][y])
a = [[b[x][y] ^ (~b[(x + 1) % 5][y] & b[(x + 2) % 5][y]) for y in range(5)] for x in range(5)]
a[0][0] ^= rc
return a
# Keccak-256 as Ethereum uses it (padding 0x01, not SHA3-256's 0x06), which labelHash in SimplexName.hs is
def keccak256(data):
rate = 136
padded = bytearray(data) + b"\x01" + bytes(-(len(data) + 1) % rate)
padded[-1] |= 0x80
a = [[0] * 5 for _ in range(5)]
for block in range(0, len(padded), rate):
for i in range(rate // 8):
a[i % 5][i // 5] ^= int.from_bytes(padded[block + 8 * i:block + 8 * i + 8], "little")
a = keccak_f(a)
return b"".join(a[i % 5][i // 5].to_bytes(8, "little") for i in range(4))
def query_key(label):
return "[" + keccak256(label.lower().encode()).hex() + "].testing"
def record(label, links):
return {
"name": label + ".testing",
"nickname": "",
"website": "",
"location": "",
"simplexContact": links.get("simplexContact", []),
"simplexChannel": links.get("simplexChannel", []),
"eth": None,
"btc": None,
"xmr": None,
"dot": None,
"owner": "",
"resolver": "",
}
def name_response(query, registry_file):
with open(registry_file) as f:
registry = json.load(f)
for label, links in registry.items():
if query in (query_key(label), label.lower() + ".testing"):
return {"registration": {"type": "registered", "nameRecord": record(label, links)}}
return {"registration": {"type": "available", "pricing": {"registrationPrices": {}, "basePrice": 1000, "minLabelLength": 1}}}
class Resolver(BaseHTTPRequestHandler):
def do_GET(self):
prefix = "/v2/resolve/"
if self.path == "/health":
self.reply(200, {})
elif self.path.startswith(prefix):
query = unquote(self.path[len(prefix):])
response = name_response(query, self.server.registry_file)
print(f"resolve {query}: {response['registration']['type']}", flush=True)
self.reply(200, response)
else:
self.reply(404, {})
def reply(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def main():
server = ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Resolver)
server.registry_file = sys.argv[2]
print(f"names resolver on 127.0.0.1:{sys.argv[1]}, registry {sys.argv[2]}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Downloads the released desktop app for this runner's Ubuntu at the branch's latest release tag, and unpacks it.
# Usage: scripts/ci/desktop-linux/release-app.sh <dir>
# The app is then in <dir>/app/opt/simplex, laid out as release/main/app/simplex of a branch build.
set -euo pipefail
dir=$1
tag=$(git describe --tags --abbrev=0 --match 'v*' --exclude '*-armv7a' --exclude '*-fdroid')
. /etc/os-release
asset=simplex-desktop-ubuntu-${VERSION_ID/./_}-$(uname -m).deb
echo "release $tag, asset $asset"
mkdir -p "$dir"
curl -fsSL -o "$dir/$asset" "https://github.com/simplex-chat/simplex-chat/releases/download/$tag/$asset"
dpkg-deb -x "$dir/$asset" "$dir/app"
rm "$dir/$asset"
ls -la "$dir/app/opt/simplex/bin"
du -sh "$dir/app/opt/simplex"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# Prints how many pixels of an image stand out from its background (its most common colour), and their mean colour.
# With a colour, fails unless enough pixels stand out and that channel of their mean exceeds the other two by a margin.
# Usage: screen-colour.py <image.ppm> [red|green|blue]
import re
import sys
from collections import Counter
# A pixel stands out when one of its channels differs from the background's by more than this.
DIFFERENCE = 48
# A 42 px profile image has about 1200 pixels; fewer than this standing out is no image.
MIN_PIXELS = 300
DOMINANCE = 50
CHANNELS = {"red": 0, "green": 1, "blue": 2}
def read_ppm(path):
with open(path, "rb") as f:
data = f.read()
header = re.match(rb"P6\s+(\d+)\s+(\d+)\s+(\d+)\s", data)
if int(header[3]) != 255:
sys.exit(f"{path}: expected 8-bit samples, got maximum {int(header[3])}")
samples = data[header.end():]
return [tuple(samples[i:i + 3]) for i in range(0, len(samples) - 2, 3)]
def main():
pixels = read_ppm(sys.argv[1])
colour = sys.argv[2] if len(sys.argv) > 2 else None
if colour is not None and colour not in CHANNELS:
sys.exit(f"unknown colour: {colour}")
background = Counter(pixels).most_common(1)[0][0]
standing_out = [p for p in pixels if max(abs(a - b) for a, b in zip(p, background)) > DIFFERENCE]
n = len(standing_out)
mean = [round(sum(p[c] for p in standing_out) / n) if n else 0 for c in range(3)]
print(f"{n} px, mean {mean[0]},{mean[1]},{mean[2]}")
if colour is not None:
c = CHANNELS[colour]
if n < MIN_PIXELS or any(mean[c] - mean[o] < DOMINANCE for o in range(3) if o != c):
sys.exit(1)
main()
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# Prints the height in pixels of the ink in a box of a screenshot: from the first to the last row with a pixel darker than
# halfway between the box's lightest and darkest. OCR's boxes are padded, so they hardly tell text sizes apart.
#
# screen-ink.py <gray.pgm> <left> <top> <right> <bottom>
#
# <gray.pgm> is the screenshot in 8-bit grayscale; the box is in its pixels, edges included.
import re
import sys
def main():
path, left, top, right, bottom = sys.argv[1], *map(int, sys.argv[2:6])
with open(path, "rb") as f:
data = f.read()
header = re.match(rb"P5\s+(\d+)\s+(\d+)\s+(\d+)\s", data)
width, height, pixels = int(header[1]), int(header[2]), data[header.end():]
columns = range(max(left, 0), min(right, width - 1) + 1)
rows = [[pixels[y * width + x] for x in columns] for y in range(max(top, 0), min(bottom, height - 1) + 1)]
threshold = (max(map(max, rows)) + min(map(min, rows))) / 2
ink = [i for i, row in enumerate(rows) if min(row) < threshold]
print(ink[-1] - ink[0] + 1 if ink else 0)
main()
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# Reads the words tesseract found on a screenshot and prints the phrases on screen, or where one text is.
#
# screen-text.py <scale> <ocr dir> lines one phrase per line: centre x,y and its words
# screen-text.py <scale> <ocr dir> find <text> "x y left top right bottom": centre and box of the text, from the phrase
# that is exactly the text, else the first one containing it; fails if none does
#
# <ocr dir> has tesseract's TSV of the dark-text and the light-text pass (dark.tsv, light.tsv), in pixels of the
# screenshot enlarged by <scale>, and gray.pgm, the screenshot in 8-bit grayscale. Printed coordinates are
# screen pixels. Text is compared ignoring case, spaces and punctuation.
import csv
import os
import re
import statistics
import sys
# Luminance (0-255) below which a background is dark enough for light text: blue buttons are about 115, disabled grey
# ones about 137, light ones (window, fields, cards) over 230.
LIGHT_BACKGROUND = 190
# In the app's font a capital I looks like a lowercase l, and OCR reads it as one, so the two compare equal.
def key(text):
return re.sub(r"[^0-9a-z]", "", text.lower()).replace("l", "i")
def read_words(path, scale):
words = []
with open(path, newline="") as f:
for row in csv.DictReader(f, delimiter="\t", quoting=csv.QUOTE_NONE):
text = (row["text"] or "").strip()
if row["level"] != "5" or not key(text):
continue
left, top, width, height = (int(row[k]) / scale for k in ("left", "top", "width", "height"))
words.append({"text": text, "key": key(text), "l": left, "t": top, "r": left + width, "b": top + height})
return words
def read_gray(path):
with open(path, "rb") as f:
data = f.read()
header = re.match(rb"P5\s+(\d+)\s+(\d+)\s+(\d+)\s", data)
if int(header[3]) != 255:
sys.exit(f"{path}: expected 8-bit samples, got maximum {int(header[3])}")
return int(header[1]), int(header[2]), data[header.end():]
# The median of the rows 2 to 4 pixels above and below the word: its background, free of its own letters.
def background(gray, w):
width, height, luminance = gray
left, right = max(int(w["l"]), 0), min(int(w["r"]), width - 1)
rows = [int(w["t"]) - d for d in (2, 3, 4)] + [int(w["b"]) + d for d in (2, 3, 4)]
return statistics.median(luminance[y * width + x] for y in rows if 0 <= y < height for x in range(left, right + 1))
def box(words):
return min(w["l"] for w in words), min(w["t"] for w in words), max(w["r"] for w in words), max(w["b"] for w in words)
def centre(words):
left, top, right, bottom = box(words)
return (left + right) / 2, (top + bottom) / 2
# Each pass also reads noise where its polarity doesn't apply (the gaps between dark letters look like light text),
# so a word counts only from the pass that matches the background under it.
def screen_words(ocr_dir, scale):
gray = read_gray(os.path.join(ocr_dir, "gray.pgm"))
def on_light_background(w):
return background(gray, w) >= LIGHT_BACKGROUND
dark = read_words(os.path.join(ocr_dir, "dark.tsv"), scale)
light = read_words(os.path.join(ocr_dir, "light.tsv"), scale)
return [w for w in dark if on_light_background(w)] + [w for w in light if not on_light_background(w)]
# A word continues a phrase when it is on the same line as the phrase's last word and at most 1.5 of their height after it;
# wider gaps separate UI elements.
def follows(a, b):
h = min(a["b"] - a["t"], b["b"] - b["t"])
return min(a["b"], b["b"]) - max(a["t"], b["t"]) >= 0.5 * h and -0.5 * h <= b["l"] - a["r"] <= 1.5 * h
def phrases(words):
result = []
for w in sorted(words, key=lambda w: w["l"]):
phrase = next((p for p in result if follows(p[-1], w)), None)
if phrase:
phrase.append(w)
else:
result.append([w])
return sorted(result, key=lambda p: (min(w["t"] for w in p), p[0]["l"]))
# OCR can split or join words ("#ci_group" read as "#ci_ group"), so words are compared by their joined characters.
# It can also glue an icon to the first word, or punctuation or the text cursor to the last, so the text may start
# inside the first word and end inside the last when at least 4 of its characters are in that word.
def span_matches(words, target):
keys = [w["key"] for w in words]
joined = "".join(keys)
start = joined.find(target)
while start >= 0:
end = start + len(target)
first_ok = start == 0 or len(keys[0]) - start >= 4
last_ok = end == len(joined) or len(keys[-1]) - (len(joined) - end) >= 4
if start < len(keys[0]) and end > len(joined) - len(keys[-1]) and first_ok and last_ok:
return True
start = joined.find(target, start + 1)
return False
def find(all_phrases, text):
target = key(text)
if not target:
return None
for phrase in all_phrases:
if "".join(w["key"] for w in phrase) == target:
return phrase
for phrase in all_phrases:
for i in range(len(phrase)):
for j in range(i + 1, len(phrase) + 1):
if span_matches(phrase[i:j], target):
return phrase[i:j]
return None
def main():
scale, ocr_dir, command, args = float(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4:]
on_screen = phrases(screen_words(ocr_dir, scale))
if command == "lines":
for phrase in on_screen:
x, y = centre(phrase)
print(f"{round(x)},{round(y)} {' '.join(w['text'] for w in phrase)}")
elif command == "find":
words = find(on_screen, args[0])
if not words:
sys.exit(1)
print(" ".join(str(round(v)) for v in centre(words) + box(words)))
else:
sys.exit(f"unknown command: {command}")
main()
+374
View File
@@ -0,0 +1,374 @@
#!/bin/bash
# Starts the desktop app on a virtual display and runs the scripted UI cases with the ui helper: onboarding to the
# chat list, a local group, then the chat list with the toolbar at the bottom (the default) and at the top.
# Usage: scripts/ci/desktop-linux/smoke-test.sh <app dir> <output dir> [start|onboarding|layouts|setup|backend]
# With "start" it stops at the first screen, for an agent to go on from; with "onboarding", at the chat list.
# "setup" creates the group, makes the test SMP server (backend.sh) the one that resolves SimpleX names, and stops;
# "backend" does the same, connects to the name that backend.sh registered, and goes on to both layouts.
# Both need NAMES_SERVER, the test SMP server's address.
# Each step saves a numbered screenshot and the text read from it to the output dir, prints the text to the log,
# and adds a line to steps.log there. What the screens show is checked without stopping: every check is listed in
# checks.txt and the job summary, and the test fails at the end if any failed. The app keeps running for later
# steps of the job, which get UI_OUT.
set -euo pipefail
app_dir=$1
export UI_OUT=$2
cases=${3:-layouts}
case "$cases" in
start | onboarding | layouts | setup | backend) ;;
*)
echo "error: unknown case \"$cases\": start, onboarding, layouts (the default), setup or backend"
exit 1
;;
esac
# backend.sh registered seeded.testing for ci_contact
name_label=seeded
local_chat="seeded notes"
ui=$(dirname "$0")/ui
checks=$UI_OUT/checks.txt
# switches are at the right of the left pane, where Settings open
switch_x=335
mkdir -p "$UI_OUT"
: > "$checks"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "UI_OUT=$UI_OUT" >> "$GITHUB_ENV"
fi
report_checks() {
[ -s "$checks" ] || return 0
echo "::group::checks"
cat "$checks"
echo "::endgroup::"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
echo "### Smoke test checks"
echo
sed 's/^/- /' "$checks"
} >> "$GITHUB_STEP_SUMMARY"
fi
}
finish() {
local status=$?
if [ "$status" -ne 0 ]; then
"$ui" shot final || true
fi
echo "::group::app log, last 300 lines"
tail -n 300 "$UI_OUT/app.log" || true
echo "::endgroup::"
report_checks
exit "$status"
}
trap finish EXIT
fail() {
echo "error: $*"
exit 1
}
check() {
local what=$1
shift
if "$@"; then
echo "PASS $what" | tee -a "$checks"
else
echo "FAIL $what" | tee -a "$checks"
echo "::error::$what"
fi
}
# text compared as ui compares it: letters and digits, lowercase, a lowercase l read as i
normalise() {
tr -cd '[:alnum:]' <<< "$1" | tr '[:upper:]' '[:lower:]' | tr l i
}
# Prints "x y" of the text in the chat list pane, left of x 390: a phrase equal to it, else, unless the second argument
# is "exact", the first containing it. The chats, results and Settings are there; the right pane shows the open chat,
# which can have the same names.
left_pane_position() {
local target line x y found=""
target=$(normalise "$1")
while IFS= read -r line; do
x=${line%%,*}
y=${line#*,}
y=${y%% *}
[ "$x" -lt 390 ] || continue
case "$(normalise "${line#* }")" in
"$target")
echo "$x $y"
return 0
;;
*"$target"*)
found=${found:-"$x $y"}
;;
esac
done < <("$ui" text)
[ -n "$found" ] && [ "${2:-}" != exact ] || return 1
echo "$found"
}
on_screen() {
left_pane_position "$1" > /dev/null
}
not_on_screen() {
! left_pane_position "$1" > /dev/null
}
y_of() {
local pos
pos=$(left_pane_position "$1") || return 1
cut -d ' ' -f 2 <<< "$pos"
}
above() {
local a b
a=$(y_of "$1") || return 1
b=$(y_of "$2") || return 1
[ "$a" -lt "$b" ]
}
# the value is on the same line as its setting's title
in_line_with() {
local value title
value=$(y_of "$1") || return 1
title=$(y_of "$2") || return 1
[ $((value - title)) -lt 10 ] && [ $((title - value)) -lt 10 ]
}
# Scrolls the left pane down until the text shows above its bottom toolbar.
scroll_to() {
local y
for _ in 1 2 3 4 5 6 7 8; do
if y=$(y_of "$1") && [ "$y" -lt 640 ]; then
return 0
fi
"$ui" scroll 195 400 down
done
return 1
}
# Escape closes one screen at a time, and clears the search field
back_to_chat_list() {
for _ in 1 2 3 4 5 6; do
on_chat_list && return 0
"$ui" key Escape
done
on_chat_list
}
# New chat has the same search field
on_chat_list() {
not_on_screen "Settings" && not_on_screen "Chat preferences" && not_on_screen "Network" && not_on_screen "New chat" && on_screen "Search or paste SimpleX link"
}
# Opens Settings from the user menu, behind the profile picture at the left of the bottom toolbar.
open_settings() {
"$ui" click 27 730
"$ui" click-text "Settings" 10
}
# Turns "Reachable app toolbars" off in Settings > Appearance, which moves the toolbar and the search field to the top.
use_top_toolbar() {
local y
"$ui" key Escape
open_settings
"$ui" click-text "Appearance" 10
"$ui" wait "Reachable app toolbars" 10
"$ui" shot appearance
y=$(y_of "Reachable app toolbars")
"$ui" click "$switch_x" "$y"
"$ui" shot appearance-toolbar-switched
back_to_chat_list || true
"$ui" shot top-chat-list
y=$(y_of "Search or paste SimpleX link") || fail "no search field after closing Settings"
[ "$y" -lt 200 ] || fail "the search field is still at the bottom (y $y) after switching Reachable app toolbars"
}
# The search field and "Add list" next to it: at the bottom of the chat list, "Add list" above the field; at the top, below it.
check_layout() {
local layout=$1 field
field=$(y_of "Search or paste SimpleX link") || field=""
"$ui" shot "$layout-layout"
check "$layout: \"$local_chat\" listed" on_screen "$local_chat"
if [ "$layout" = bottom ]; then
check "bottom: the search field is at the bottom" [ "${field:-0}" -gt 600 ]
check "bottom: \"Add list\" above the search field" above "Add list" "Search or paste SimpleX link"
else
check "top: the search field is at the top" [ "${field:-900}" -lt 200 ]
check "top: \"Add list\" below the search field" above "Search or paste SimpleX link" "Add list"
fi
}
# A group of the user's own: without chats the chat list shows no search field.
create_local_chat() {
local y
# the pencil at the right of the bottom toolbar opens New chat
"$ui" click 363 730
"$ui" click-text "Create group" 10
"$ui" wait "Enter group name" 10
y=$(y_of "Enter group name")
"$ui" click 195 $((y + 40))
"$ui" type "$local_chat"
"$ui" shot new-group
"$ui" click-text "Create group" 10
"$ui" click-text "Skip inviting members" 10 || true
"$ui" shot group-created
"$ui" key Escape
back_to_chat_list || fail "no chat list after creating the group"
# the group stays open in the right pane: Escape doesn't close a chat on desktop
on_screen "$local_chat" || fail "\"$local_chat\" is not in the chat list"
"$ui" shot local-chat
}
# Clicks a server role's value until its dropdown shows. The menu is open when an item reads exactly "yes", which no
# role's value does ("default (yes)", "default (no)", "no"); it can take longer to show than OCR takes to read the screen.
open_role_menu() {
for _ in 1 2 3; do
"$ui" click-text "$1" 10 || return 1
for _ in 1 2 3; do
left_pane_position yes exact > /dev/null && return 0
sleep 1
done
done
return 1
}
# Settings > Network & servers: the test SMP server added with the names role, and names off for the preset
# operators, so that only the test server resolves names.
use_names_server() {
local x y operator
open_settings
# OCR reads "Network & servers" as two phrases
"$ui" click-text "Network" 10
"$ui" click-text "Your servers" 10
"$ui" click-text "Add server" 10
"$ui" click-text "Enter server manually" 10
"$ui" wait "Your server address" 10
y=$(y_of "Your server address")
"$ui" click 195 $((y + 50))
"$ui" type "$NAMES_SERVER"
y=$(y_of "Use for new connections")
"$ui" click "$switch_x" "$y"
"$ui" shot new-server
"$ui" key Escape
# the New server screen shows the address with "127.0.0.1" too, until it closes
"$ui" wait "Add server" 10
"$ui" wait "Test servers" 10
"$ui" click-text "127.0.0.1" 10
"$ui" wait "SMP server" 10
scroll_to "To resolve names" || fail "no \"To resolve names\" in the test server's settings"
# A dropdown opens from its value; a custom server's roles default to yes, yes and no, so the first "default (yes)"
# is the receiving one. A directory service can't reply to queues on this server, so it only resolves names.
open_role_menu "default (yes)" || fail "no menu for the test server's \"To receive\""
# OCR misreads the short "no", the item after "yes"
read -r x y < <(left_pane_position yes exact) || fail "the \"To receive\" menu closed"
"$ui" click "$x" $((y + 48))
open_role_menu "default (no)" || fail "no menu for the test server's \"To resolve names\""
read -r x y < <(left_pane_position yes exact) || fail "the \"To resolve names\" menu closed"
"$ui" click "$x" "$y"
"$ui" shot names-server-roles
# the first "default (yes)" and the first "yes" are on the lines after "To receive", so it is no
in_line_with "default (yes)" "For private routing" || fail "the test server's \"To receive\" is still its default"
in_line_with "yes" "To resolve names" || fail "the test server's \"To resolve names\" is not yes, or \"To receive\" is"
"$ui" key Escape
"$ui" wait "Add server" 10
"$ui" key Escape
for operator in "SimpleX Chat" Flux; do
"$ui" click-text "$operator" 10
# the operator's own toggle: the title "<operator> servers" can wrap
"$ui" wait "Use servers" 10
scroll_to "To resolve names" || fail "no \"To resolve names\" in the $operator settings"
y=$(y_of "To resolve names")
"$ui" click "$switch_x" "$y"
"$ui" shot "names-off-$(tr -cd 'a-zA-Z' <<< "$operator")"
"$ui" key Escape
"$ui" wait "Your servers" 10
done
scroll_to "Save servers" || fail "no \"Save servers\" in Network & servers"
"$ui" click-text "Save servers" 10
sleep 2
"$ui" shot servers-saved
back_to_chat_list || fail "no chat list after saving the servers"
}
# A bare name in the search field is offered with the .testing domain; the test SMP server resolves it to ci_contact's
# address, and the app shows that profile before connecting.
connect_by_name() {
"$ui" click-text "Search or paste SimpleX link"
"$ui" type "$name_label"
"$ui" wait "Connect to $name_label.testing" 15
"$ui" shot name-typed
"$ui" click-text "Connect to $name_label.testing"
check "$name_label.testing resolves to ci_contact" "$ui" wait ci_contact 60
check "the app offers to open a new chat with ci_contact" "$ui" wait "Open new chat" 10
"$ui" shot name-resolved
"$ui" click-text Cancel 10 || "$ui" key Escape
back_to_chat_list || fail "no chat list after the name's alert"
}
# private routing to unknown servers only: the preset servers that would proxy name lookups can't reach the test SMP
# server on 127.0.0.1; developer tools at the debug level put the app's debug log in app.log
"$ui" start "$app_dir" NetworkSMPProxyMode=Unknown DeveloperTools=true LogLevel=DEBUG
# onboarding to the chat list
"$ui" wait "Create your profile" 180
"$ui" shot first-screen
if [ "$cases" = start ]; then
echo "smoke test: the app shows its first screen"
exit 0
fi
"$ui" click-text "Create your profile"
"$ui" wait "Create profile" 30
"$ui" click-text "Enter profile name"
"$ui" type citester
"$ui" shot profile-name
"$ui" click-text "Create profile"
# a new desktop database gets a random passphrase, which onboarding offers to replace
"$ui" wait "Use random passphrase" 60
"$ui" shot database-passphrase
"$ui" click-text "Use random passphrase"
"$ui" wait "OK" 10
"$ui" shot random-passphrase-alert
"$ui" click-text "OK"
"$ui" wait "Continue" 60
"$ui" shot your-network
"$ui" click-text "Continue"
"$ui" wait "Accept" 30
"$ui" shot network-commitments
"$ui" click-text "Accept"
"$ui" wait "Talk to someone" 60
sleep 3
"$ui" shot chat-list
if [ "$cases" = onboarding ]; then
echo "smoke test passed: onboarding reached the chat list"
exit 0
fi
create_local_chat
if [ "$cases" = setup ] || [ "$cases" = backend ]; then
: "${NAMES_SERVER:?NAMES_SERVER must be the test SMP server address}"
use_names_server
if [ "$cases" = setup ]; then
echo "smoke test setup done: \"$local_chat\" created, $NAMES_SERVER resolves names"
exit 0
fi
connect_by_name
fi
check_layout bottom
use_top_toolbar
check_layout top
if grep -q '^FAIL' "$checks"; then
fail "the smoke test failed checks: $(grep -c '^FAIL' "$checks") of $(grep -cE '^(PASS|FAIL)' "$checks")"
fi
echo "smoke test passed: onboarding reached the chat list, which shows \"$local_chat\" with the toolbar at the bottom and at the top"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
# Prints the results in the JUnit XML reports of a Gradle test task, and adds them to the job summary on GitHub.
# Usage: scripts/ci/desktop-linux/test-summary.py <test-results dir>
import glob
import os
import sys
import xml.etree.ElementTree as ET
results_dir = sys.argv[1]
suites = [ET.parse(path).getroot() for path in sorted(glob.glob(os.path.join(results_dir, "*.xml")))]
if not suites:
sys.exit(f"no test results in {results_dir}")
rows = ["| Suite | Tests | Failed | Skipped | Seconds |", "|---|---|---|---|---|"]
failures = []
totals = [0, 0, 0]
for suite in suites:
tests, failed, skipped = (int(suite.get(k, 0)) for k in ("tests", "failures", "skipped"))
failed += int(suite.get("errors", 0))
totals = [totals[0] + tests, totals[1] + failed, totals[2] + skipped]
rows.append(f"| {suite.get('name')} | {tests} | {failed} | {skipped} | {suite.get('time')} |")
for case in suite.iter("testcase"):
for problem in list(case.findall("failure")) + list(case.findall("error")):
failures.append(f"{case.get('classname')}.{case.get('name')}: {problem.get('message')}")
summary = [f"### desktopTest: {totals[0]} tests, {totals[1]} failed, {totals[2]} skipped", ""] + rows
if failures:
summary += ["", "Failures:", ""] + [f"- {f}" for f in failures]
print("\n".join(summary))
if os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
f.write("\n".join(summary) + "\n")
+283
View File
@@ -0,0 +1,283 @@
#!/bin/bash
# Drives the desktop app on a virtual X display, for the scripted cases and for an agent on the runner.
# Compose for Desktop has no accessibility tree on Linux, so text is found on screenshots by OCR (tesseract);
# coordinates are screen pixels, as in the screenshots. Screenshots, the text read from them and
# a log of every command (steps.log) go to UI_OUT; the app's data stays in UI_STATE between starts.
#
# ui start <app dir> [<setting>=<value>...]
# start Xvfb, openbox and <app dir>/bin/simplex, and wait for its window; the app gets
# the settings given when UI_STATE has none yet
# ui shot <name> save UI_OUT/NN-<name>.png and NN-<name>.txt (OCR's input and output in UI_OUT/ocr), and print the text on screen
# ui text print the text on screen, one phrase per line after its centre x,y
# ui find <text> print "x y left top right bottom", the text's centre and box; fail if it is not on screen
# ui ink <text> print the height in pixels of the text's ink, to compare text sizes
# ui wait <text> [secs] wait up to secs (30) until the text is on screen
# ui click <x> <y> click at x,y
# ui click-text <text> [secs] wait up to secs (20) for the text and click its centre
# ui scroll <x> <y> <up|down> [clicks]
# turn the mouse wheel over x,y, 3 clicks unless given
# ui avatar <text> [colour] print how many pixels of the profile image left of a list row's name (the text) stand out
# from the background, and their mean colour, saving the area as UI_OUT/NN-avatar-<text>.png;
# with red, green or blue, fail unless that colour dominates
# ui type <text> type into the focused field
# ui key <key>... press keys named as in xdotool (Return, Escape, ctrl+a)
# ui do <command> [args] -- <command> [args] ...
# run the commands in order, stopping at the first that fails
# ui stop stop the app, openbox and Xvfb
#
# Text is compared ignoring case, spaces and punctuation, with a capital I and a lowercase l alike; a phrase that is
# exactly the text wins over one that contains it.
set -euo pipefail
# an agent on the runner runs this, and nothing it starts needs the agent's credentials
unset CLAUDE_CODE_OAUTH_TOKEN
: "${UI_OUT:?UI_OUT must be the output directory}"
export DISPLAY=${UI_DISPLAY:-:99}
state=${UI_STATE:-${RUNNER_TEMP:-/tmp}/ui}
here=$(cd "$(dirname "$0")" && pwd)
mkdir -p "$UI_OUT" "$state/ocr"
log() {
echo "$(date -u +%H:%M:%S) $*" | tee -a "$UI_OUT/steps.log"
}
running() {
[ -f "$state/$1.pid" ] && kill -0 "$(cat "$state/$1.pid")" 2> /dev/null
}
stop_process() {
local name=$1 secs=$2 pid
if ! running "$name"; then
rm -f "$state/$name.pid"
return
fi
pid=$(cat "$state/$name.pid")
kill "$pid" 2> /dev/null || true
for _ in $(seq "$secs"); do
kill -0 "$pid" 2> /dev/null || break
sleep 1
done
kill -9 "$pid" 2> /dev/null || true
rm -f "$state/$name.pid"
}
start() {
local app_dir settings=$state/config/simplex/settings.properties
app_dir=$(cd "$1" && pwd)
shift
if running app; then
echo "error: the app is already running"
return 1
fi
stop_process openbox 2
stop_process xvfb 2
setsid Xvfb "$DISPLAY" -screen 0 1440x900x24 -nolisten tcp < /dev/null > "$UI_OUT/xvfb.log" 2>&1 &
echo $! > "$state/xvfb.pid"
for _ in $(seq 20); do
xdotool getdisplaygeometry > /dev/null 2>&1 && break
sleep 0.5
done
xdotool getdisplaygeometry
setsid openbox --sm-disable < /dev/null > "$UI_OUT/openbox.log" 2>&1 &
echo $! > "$state/openbox.pid"
mkdir -p "$state/data" "$state/config" "$state/tmp"
if [ $# -gt 0 ] && [ ! -f "$settings" ]; then
mkdir -p "$(dirname "$settings")"
printf '%s\n' "$@" > "$settings"
fi
XDG_DATA_HOME="$state/data" \
XDG_CONFIG_HOME="$state/config" \
JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=$state/tmp -Dskiko.renderApi=SOFTWARE" \
LD_LIBRARY_PATH="$app_dir/lib/app/resources" \
setsid "$app_dir/bin/simplex" < /dev/null >> "$UI_OUT/app.log" 2>&1 &
echo $! > "$state/app.pid"
if ! timeout 120 xdotool search --sync --onlyvisible --name '^SimpleX$' > "$state/window"; then
echo "error: no app window after 120 s"
return 1
fi
xdotool getwindowgeometry "$(head -n 1 "$state/window")"
}
# ocr <png> lines | ocr <png> find <text>
# Dark text is cut out by a local threshold, light text (labels on coloured buttons) by the difference from a morphological open:
# a local threshold of the negative rings the buttons, and tesseract reads nothing inside a ring.
ocr() {
local png=$1 o=$state/ocr
shift
convert "$png" -colorspace Gray -depth 8 "$o/gray.pgm"
convert "$o/gray.pgm" -resize 200% -lat 60x60-5% "$o/dark.png"
convert "$o/gray.pgm" \( +clone -morphology Erode Rectangle:9x1 -morphology Erode Rectangle:1x9 -morphology Dilate Rectangle:9x1 -morphology Dilate Rectangle:1x9 \) -compose Difference -composite -negate -resize 200% "$o/light.png"
tesseract "$o/dark.png" "$o/dark" --psm 11 tsv > /dev/null 2>&1
tesseract "$o/light.png" "$o/light" --psm 11 tsv > /dev/null 2>&1
python3 "$here/screen-text.py" 2 "$o" "$@"
}
screen_ocr() {
import -window root "$state/ocr/screen.png"
ocr "$state/ocr/screen.png" "$@"
}
# prints "x y left top right bottom" of the text once it is on screen
wait_for() {
local text=$1 deadline=$((SECONDS + $2))
until screen_ocr find "$text"; do
[ "$SECONDS" -lt "$deadline" ] || return 1
sleep 1
done
}
# prints UI_OUT/NN-<name>, numbered after the files already saved
next_file() {
local n
n=$(find "$UI_OUT" -maxdepth 1 -name '[0-9][0-9]-*.png' | wc -l)
printf '%s/%02d-%s' "$UI_OUT" $((n + 1)) "$1"
}
shot() {
local f
f=$(next_file "$1")
import -window root "$f.png"
ocr "$f.png" lines > "$f.txt"
mkdir -p "$UI_OUT/ocr"
for pass in dark light; do
cp "$state/ocr/$pass.png" "$UI_OUT/ocr/$(basename "$f")-$pass.png"
cp "$state/ocr/$pass.tsv" "$UI_OUT/ocr/$(basename "$f")-$pass.tsv"
done
echo "::group::text on screen: $(basename "$f")"
cat "$f.txt"
echo "::endgroup::"
log "shot $(basename "$f").png"
}
click() {
xdotool mousemove --sync "$1" "$2" click 1
sleep 1
}
# X buttons 4 and 5 are the wheel turned up and down
scroll() {
local button
case "$3" in
up) button=4 ;;
down) button=5 ;;
*)
echo "error: scroll up or down, not \"$3\""
return 1
;;
esac
xdotool mousemove --sync "$1" "$2" click --repeat "$4" --delay 100 "$button"
sleep 1
}
# A list row's profile image is 42 px square, 8 px left of the row's name, and starts a little below the name's top:
# the area allows for OCR's box being a few pixels off, and leaves out the rows above and below.
avatar() {
local text=$1 colour=${2:-} pos x y l t r b f stats
if ! pos=$(screen_ocr find "$text"); then
log "avatar \"$text\": not on screen"
return 1
fi
read -r x y l t r b <<< "$pos"
f=$(next_file "avatar-$(tr -cd 'a-zA-Z0-9_' <<< "$text")")
convert "$state/ocr/screen.png" -crop "52x64+$((l > 56 ? l - 56 : 0))+$((t > 6 ? t - 6 : 0))" +repage "$f.png"
convert "$f.png" -depth 8 "$state/ocr/avatar.ppm"
if ! stats=$(python3 "$here/screen-colour.py" "$state/ocr/avatar.ppm" ${colour:+"$colour"}); then
log "avatar \"$text\": $stats, not $colour"
return 1
fi
log "avatar \"$text\": $stats${colour:+, $colour}"
}
case "${1:-}" in
start)
shift
log "start $*"
start "$@"
;;
shot) shot "$2" ;;
text) screen_ocr lines ;;
find)
if ! pos=$(screen_ocr find "$2"); then
log "find \"$2\": not on screen" >&2
exit 1
fi
log "find \"$2\": $pos" > /dev/null
echo "$pos"
;;
ink)
if ! pos=$(screen_ocr find "$2"); then
log "ink \"$2\": not on screen" >&2
exit 1
fi
read -r _ _ l t r b <<< "$pos"
# 2 px around OCR's box, less than the gap between lines
h=$(python3 "$here/screen-ink.py" "$state/ocr/gray.pgm" "$l" $((t - 2)) "$r" $((b + 2)))
log "ink \"$2\": $h px" > /dev/null
echo "$h"
;;
wait)
secs=${3:-30}
if ! pos=$(wait_for "$2" "$secs"); then
log "wait \"$2\": not on screen after $secs s"
exit 1
fi
read -r x y _ <<< "$pos"
log "wait \"$2\": at $x,$y"
;;
click)
click "$2" "$3"
log "click $2,$3"
;;
click-text)
secs=${3:-20}
if ! pos=$(wait_for "$2" "$secs"); then
log "click-text \"$2\": not on screen after $secs s"
exit 1
fi
read -r x y _ <<< "$pos"
click "$x" "$y"
log "click-text \"$2\" at $x,$y"
;;
scroll)
scroll "$2" "$3" "$4" "${5:-3}"
log "scroll $4 at $2,$3, ${5:-3} clicks"
;;
avatar) avatar "$2" "${3:-}" ;;
type)
xdotool type --delay 50 -- "$2"
log "type \"$2\""
;;
key)
shift
xdotool key --delay 100 -- "$@"
log "key $*"
;;
do)
shift
command=()
for arg in "$@" --; do
if [ "$arg" != -- ]; then
command+=("$arg")
elif [ ${#command[@]} -gt 0 ]; then
"$0" "${command[@]}"
command=()
fi
done
;;
stop)
stop_process app 10
stop_process openbox 2
stop_process xvfb 2
log "stop"
;;
*)
sed -n '3,/^$/s/^# \{0,1\}//p' "$0"
exit 1
;;
esac
+57
View File
@@ -0,0 +1,57 @@
# iOS app in the simulator on GitHub's runners
`.github/workflows/ios-sim.yml` builds the core library and the iOS app from the branch's code on a macOS runner, starts the app in an iPhone 17 simulator, and drives it through onboarding to the chat list with a scripted smoke test. On request it also runs a Claude session that follows a device test plan, and a test backend (relay, directory, names server, contacts) for tests that need servers and peers.
## Running it
The workflow runs only when asked, on any branch:
- **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>`.
## 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.
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.
## 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.
- `claude-scope`: 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`.
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.
## Driving the app: `ui`
`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, swipe, type, paste, keys, wait for text, find, screenshots with trees, switch the layout (one-hand UI or top bar), relaunch, text size, and the backend's directory (`ui` with no arguments lists them). It needs `SIM_UDID` and `UI_OUT`, which the smoke test exports to the job. Lessons kept in its comments and in the smoke test: text fields have no label (tap them by value), the keyboard capitalizes and autocorrects (compare typed text case-insensitively), the closed user picker is always in the tree, "Cancel" clears the search field and its "Close" image doesn't, the keyboard's keys are below the screen.
## The test backend
`backend.sh start <backend zip> <work dir>` (with `[ci-backend]` or the `backend` input) starts, on the runner, against the preset SMP and XFTP servers:
- a chat relay for channels, and a SimpleX Directory whose address it writes to `<work dir>/directory-link`;
- a CLI peer `ci_admin` that creates, registers and gets approved groups and channels with the profile JSON the app sends: `ci_group` and `ci_channel` ("ci seed"), `ci_cards`, six entries with app-size avatars made by `avatar.swift` ("ci avatar"), a long name, markdown in a description, and `cinews`, `cinews_digest`, `cinews_weekly`;
- 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).
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.
- `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.
+87
View File
@@ -0,0 +1,87 @@
// Makes a group avatar the way the app makes profile images (resizeImageToStrSize in apps/ios/SimpleXChat/ImageUtils.swift):
// a JPEG at quality 0.85 drawn at scale 1, shrunk by sqrt(size / 12500), at most halved per step,
// until its data URI is at most 12500 characters. The picture is synthetic and photo-like: a gradient,
// discs and noise, with more noise for a higher detail.
// Usage: avatar <seed> <detail 0...1> <output file> [side in pixels, default 1024]
import CoreGraphics
import Foundation
import ImageIO
let maxDataSize = 12500
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
struct Random {
var state: UInt64
mutating func next() -> Double {
state = state &* 6364136223846793005 &+ 1442695040888963407
return Double(state >> 11) / Double(UInt64(1) << 53)
}
mutating func color(alpha: Double = 1) -> CGColor {
let components: [CGFloat] = [next(), next(), next(), alpha]
return CGColor(colorSpace: colorSpace, components: components)!
}
}
func context(_ width: Int, _ height: Int) -> CGContext {
CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
}
func picture(side: Int, detail: Double, random: inout Random) -> CGImage {
let ctx = context(side, side)
let gradient = CGGradient(colorsSpace: colorSpace, colors: [random.color(), random.color()] as CFArray, locations: [0, 1])!
ctx.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: 0, y: side), options: [])
for _ in 0..<24 {
let r = Double(side) * (0.05 + 0.25 * random.next())
let alpha = 0.4 + 0.5 * random.next()
ctx.setFillColor(random.color(alpha: alpha))
ctx.fillEllipse(in: CGRect(x: Double(side) * random.next() - r, y: Double(side) * random.next() - r, width: 2 * r, height: 2 * r))
}
let pixels = ctx.data!.bindMemory(to: UInt8.self, capacity: ctx.bytesPerRow * side)
let amplitude = 96 * detail
for y in 0..<side {
for x in 0..<side {
let i = y * ctx.bytesPerRow + x * 4
for c in 0..<3 {
let v = Double(pixels[i + c]) + amplitude * (random.next() - 0.5)
pixels[i + c] = UInt8(max(0, min(255, v)))
}
}
}
return ctx.makeImage()!
}
func jpegDataURI(_ image: CGImage) -> String {
let data = NSMutableData()
let destination = CGImageDestinationCreateWithData(data as CFMutableData, "public.jpeg" as CFString, 1, nil)!
CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.85] as CFDictionary)
CGImageDestinationFinalize(destination)
return "data:image/jpg;base64," + (data as Data).base64EncodedString()
}
func reduced(_ image: CGImage, ratio: Double) -> CGImage {
let width = Int(floor(Double(image.width) / ratio))
let height = Int(floor(Double(image.height) / ratio))
let ctx = context(width, height)
ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return ctx.makeImage()!
}
let args = CommandLine.arguments
guard args.count == 4 || args.count == 5, let seed = UInt64(args[1]), let detail = Double(args[2]), let side = Int(args.count == 5 ? args[4] : "1024") else {
print("usage: avatar <seed> <detail 0...1> <output file> [side in pixels, default 1024]")
exit(1)
}
var generator = Random(state: seed)
var image = picture(side: side, detail: detail, random: &generator)
var uri = jpegDataURI(image)
var steps = 0
while uri.count > maxDataSize {
image = reduced(image, ratio: min((Double(uri.count) / Double(maxDataSize)).squareRoot(), 2))
uri = jpegDataURI(image)
steps += 1
}
try uri.write(toFile: args[3], atomically: true, encoding: .utf8)
print("\(args[3]): \(image.width)x\(image.height) px after \(steps) steps, \(uri.count) characters")
+477
View File
@@ -0,0 +1,477 @@
#!/bin/bash
# Test backend on the app runner: a chat relay and a directory service on the preset servers, with groups and
# channels registered and approved by a CLI peer, and a search that must find them; and for SimpleX names,
# a local SMP server with a name resolver, names claimed by channels and by contacts that accept connections.
# Usage:
# scripts/ci/ios-sim/backend.sh start <backend zip> <work dir> writes the directory's short address to <work dir>/directory-link,
# the names server's address to <work dir>/names-server
# and what was seeded to <work dir>/seed.md
# scripts/ci/ios-sim/backend.sh stop <work dir> stops everything it started, prints the logs
# scripts/ci/ios-sim/backend.sh directory-stop <work dir> stops the directory only
# scripts/ci/ios-sim/backend.sh directory-start <work dir> starts it again
# scripts/ci/ios-sim/backend.sh listings <work dir> prints the directory's listed groups and channels
# Everything it starts keeps running between start and stop.
set -euo pipefail
directory_name="SimpleX Directory"
admin=ci_admin
search_text="ci seed"
avatar_search_text="ci avatar"
long_name=ci_long_name_entry_for_layout_truncation_checks
names_resolver_port=18080
# Runs one chat command in a CLI client that stays online for <secs> after it; the output is in $work/out.
chat() {
local db=$1 secs=$2 cmd=$3
shift 3
local status=0
"$bin/simplex-chat" -d "$work/$db" -e "$cmd" -t "$secs" --execute-log all "$@" < /dev/null > "$work/out" 2>&1 || status=$?
cat "$work/out" >> "$work/$db-cli.log"
echo "::group::$db: $(cut -c1-200 <<< "$cmd")"
cat "$work/out"
echo "::endgroup::"
return "$status"
}
admin() {
chat "$admin" "$1" "$2" --user-display-name "$admin"
}
# A command that resolves a SimpleX name: given -s, the client uses that server alone, with every role, names included.
# -s also saves it among the user's servers, and later runs would put queues on it that the directory can't reach
# (it routes to unknown servers through a preset proxy), so it is then kept for names only.
named() {
local db=$1 user=$2 secs=$3 cmd=$4
local status=0
chat "$db" "$secs" "$cmd" --user-display-name "$user" -s "$names_server" || status=$?
db_query "$db" "UPDATE protocol_servers SET role_storage = 0, role_proxy = 0, role_names = 1 WHERE host = 'localhost'"
return "$status"
}
expect_out() {
if ! grep -qE "$1" "$work/out"; then
echo "error: \"$1\" not in the output"
return 1
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
local deadline=$((SECONDS + secs))
until admin 5 "$cmd" && grep -qE "$pattern" "$work/out"; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "error: \"$pattern\" not shown by \"$cmd\" after $secs s"
return 1
fi
done
}
db_query() {
sqlite3 "$work/$1_chat.db" "$2"
}
listings_query="SELECT gp.display_name AS name, gp.short_descr AS description, g.summary_current_members_count AS members, length(gp.image) AS image_characters
FROM sx_directory_group_regs r
JOIN groups g ON g.group_id = r.group_id
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
WHERE r.group_reg_status = 'active'
ORDER BY g.summary_current_members_count DESC, r.group_id"
start_relay() {
nohup "$bin/simplex-chat" -d "$work/relay" --relay --headless --user-display-name ci_relay < /dev/null > "$work/relay.log" 2>&1 &
echo $! > "$work/relay.pid"
local deadline=$((SECONDS + 120))
until grep -qE '^https://[^ ]+/r#' "$work/relay.log"; do
if ! kill -0 "$(cat "$work/relay.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then
echo "error: the relay did not create its address"
cat "$work/relay.log"
return 1
fi
sleep 1
done
relay_link=$(grep -m 1 -oE '^https://[^ ]+/r#[^ ]+' "$work/relay.log")
echo "relay address: $relay_link"
}
# The address is created here, like the directory tests do, with auto-accept on before the service subscribes to it.
create_directory() {
chat directory 1 "/ad pq_ratchet=on" --create-bot-display-name "$directory_name"
directory_link=$(new_address)
chat directory 1 "/auto_accept on"
expect_out "^auto_accept on"
echo "$directory_link" > "$work/directory-link"
echo "directory address: $directory_link"
}
start_directory() {
# -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"
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
}
stop_directory() {
local pid deadline
pid=$(cat "$work/directory.pid")
kill -INT "$pid" 2> /dev/null || true
deadline=$((SECONDS + 30))
while kill -0 "$pid" 2> /dev/null; do
if [ "$SECONDS" -ge "$deadline" ]; then
kill -KILL "$pid" 2> /dev/null || true
fi
sleep 1
done
}
# A super user is known by contact ID, which exists only after the admin connects, so the service starts
# with nobody and is restarted with the admin.
connect_admin() {
admin 10 "/c $directory_link"
expect_out "connection request sent!"
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
start_directory "$id:$admin"
echo "directory restarted with super user $id:$admin"
}
# The directory sends each registration to super users as a message with the exact /approve command,
# and tells the owner "The group ID N (name) is approved".
approve() {
local name=$1
admin_until "/approve [0-9]+:$name [0-9]+" 300 "/tail @'$directory_name' 20"
local cmd
cmd=$(grep -oE "/approve [0-9]+:$name [0-9]+" "$work/out" | tail -n 1)
admin 5 "@'$directory_name' $cmd"
admin_until "\($name\) is approved" 120 "/tail @'$directory_name' 20"
}
# The profile the app sends to create a group or a channel (AddGroupView, AddChannelView), with an optional image file.
group_profile() {
local name=$1 descr=$2 prefs=$3 image_file=${4:-}
if [ -n "$image_file" ]; then
jq -cn --arg n "$name" --arg d "$descr" --argjson p "$prefs" --rawfile i "$image_file" '{displayName: $n, fullName: "", shortDescr: $d, image: $i, groupPreferences: $p}'
else
jq -cn --arg n "$name" --arg d "$descr" --argjson p "$prefs" '{displayName: $n, fullName: "", shortDescr: $d, groupPreferences: $p}'
fi
}
seed_group() {
local name=$1 descr=$2 image_file=${3:-}
admin 1 "/_group 1 $(group_profile "$name" "$descr" '{"history":{"enable":"on"}}' "$image_file")"
expect_out "group #$name .*is created"
admin 5 "/a $name '$directory_name' admin"
expect_out "invitation to join the group #$name sent to"
approve "$name"
}
setup_relay() {
admin 1 "/relays name=ci_relay $relay_link"
expect_out "^ok$"
relay_id=$(db_query "$admin" "SELECT chat_relay_id FROM chat_relays WHERE display_name = 'ci_relay' AND deleted = 0")
}
create_channel() {
local name=$1 descr=$2 image_file=${3:-}
admin 10 "/_public group 1 $relay_id $(group_profile "$name" "$descr" '{"history":{"enable":"on"},"support":{"enable":"off"}}' "$image_file")"
expect_out "group #$name .*is created"
local group_id
group_id=$(db_query "$admin" "SELECT group_id FROM groups WHERE local_display_name = '$name'")
admin_until "relay id [0-9]+: active" 180 "/_get relays #$group_id"
}
list_channel() {
local name=$1
admin 5 "/share chat #$name @'$directory_name'"
expect_out "link to join channel #$name"
approve "$name"
}
seed_channel() {
create_channel "$@"
list_channel "$1"
}
channel_link() {
admin 1 "/show link #$1" >&2
grep -m 1 -oE 'https://[^ ]+/c#[^ ]+' "$work/out"
}
# The short address that /ad printed.
new_address() {
local address
address=$(awk '/Your new chat address is created!/ {getline; getline; print; exit}' "$work/out")
case "$address" in
https://*/a#*) echo "$address" ;;
*)
echo "error: no short address in the output" >&2
return 1
;;
esac
}
# Avatars at the size the app makes them (avatar.swift), with different amounts of detail;
# and small ones for ci_group, ci_channel and ci_cards, so that several fit in one size-limited search response.
make_avatars() {
xcrun swiftc -O -o "$work/avatar" "$(dirname "$0")/avatar.swift"
local i
for i in 1 2 3 4 5 6; do
"$work/avatar" "$i" "0.$((i + 2))" "$work/avatar-$i.txt"
done
for i in 7 8 9; do
"$work/avatar" "$i" 0 "$work/avatar-$i.txt" 64
done
}
seed_avatars() {
local i
for i in 1 2 3; do
seed_group "ci_avatar_g$i" "CI avatar group $i" "$work/avatar-$i.txt"
seed_channel "ci_avatar_c$i" "CI avatar channel $i" "$work/avatar-$((i + 3)).txt"
done
}
# A local SMP server whose [NAMES] section points at names-resolver.py, which answers from names.json.
start_names() {
echo "{}" > "$work/names.json"
nohup python3 "$(dirname "$0")/names-resolver.py" "$names_resolver_port" "$work/names.json" < /dev/null > "$work/names-resolver.log" 2>&1 &
echo $! > "$work/names-resolver.pid"
export SMP_SERVER_CFG_PATH=$work/smp-server/etc SMP_SERVER_LOG_PATH=$work/smp-server/log
"$bin/smp-server" init -y --disable-store-log --no-password --fqdn localhost --disable-web > "$work/out" 2>&1 || {
cat "$work/out"
return 1
}
cat "$work/out"
names_server=$(grep -m 1 -oE 'smp://[^ ]+@localhost' "$work/out")
sed -i '' \
-e 's/^enable: off$/enable: on/' \
-e "s|^# resolver_endpoint: http://127.0.0.1:8000\$|resolver_endpoint: http://127.0.0.1:$names_resolver_port|" \
-e 's/^port = 5223,443$/port = 5223/' \
"$SMP_SERVER_CFG_PATH/smp-server.ini"
grep -E '^(enable: on|resolver_endpoint|port = )' "$SMP_SERVER_CFG_PATH/smp-server.ini"
nohup "$bin/smp-server" start < /dev/null > "$work/smp-server.log" 2>&1 &
echo $! > "$work/smp-server.pid"
local deadline=$((SECONDS + 60))
until nc -z 127.0.0.1 5223 2> /dev/null; do
if ! kill -0 "$(cat "$work/smp-server.pid")" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then
echo "error: the names server did not start"
cat "$work/smp-server.log" "$work/names-resolver.log"
return 1
fi
sleep 1
done
echo "$names_server" > "$work/names-server"
echo "names server: $names_server"
}
register_name() {
local name=$1 contact=$2 channel=$3
jq --arg n "$name" --arg ct "$contact" --arg ch "$channel" \
'.[$n] = {simplexContact: ([$ct] | map(select(. != ""))), simplexChannel: ([$ch] | map(select(. != "")))}' \
"$work/names.json" > "$work/names.json.new"
mv "$work/names.json.new" "$work/names.json"
}
# A contact that claims a name and accepts every connection; it keeps running, so the app can connect to it.
start_contact() {
local db=$1 display_name=$2 name=$3
chat "$db" 1 "/ad" --user-display-name "$display_name"
local address
address=$(new_address)
chat "$db" 1 "/auto_accept on" --user-display-name "$display_name"
expect_out "^auto_accept on"
register_name "$name" "$address" ""
named "$db" "$display_name" 5 "/_set domain 1 $name"
expect_no_error
# --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"
}
# A name is claimed before its channel is listed: a profile change after approval hides it until approved again.
seed_names() {
local address link
create_channel cinews "CI news channel"
link=$(channel_link cinews)
register_name cinews.testing "" "$link"
named "$admin" "$admin" 5 "/public group access #cinews domain=cinews.testing"
expect_out "updated public group access: domain=cinews.testing"
list_channel cinews
seed_group cinews_digest "CI news digest"
seed_group cinews_weekly "CI news weekly"
create_channel ciboth "CI both channel"
admin 1 "/ad"
address=$(new_address)
link=$(channel_link ciboth)
register_name ciboth.testing "$address" "$link"
named "$admin" "$admin" 5 "/_set domain 1 ciboth.testing"
expect_no_error
named "$admin" "$admin" 5 "/public group access #ciboth domain=ciboth.testing"
expect_out "updated public group access: domain=ciboth.testing"
start_contact ci_alice cialice cialice.testing
start_contact ci_robert Robert cibobby.testing
}
# What the app will resolve, from a client that knows none of the names' owners.
probe_names() {
local target pattern
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%%|*}
named probe ci_probe 5 "/_connect plan 1 $target"
expect_out "$pattern"
done
named probe ci_probe 5 "/_connect plan 1 #cinone.testing" || true
echo "names resolve: cinews, cialice, cibobby, ciboth; cinone is not registered"
}
# The directory's reply to a search starts with "Found N group(s)"; earlier messages name the same groups.
in_search_reply() {
local reply
reply=$(sed -n '/Found [0-9]* group(s)/,$p' "$work/out")
grep -qF "$1" <<< "$reply"
}
# A search sent as a message, which any client can send.
probe_search() {
admin 5 "@'$directory_name' $search_text"
local deadline=$((SECONDS + 120))
until admin 5 "/tail @'$directory_name' 10" && in_search_reply "ci_group (CI seed group)" && in_search_reply "ci_channel (CI seed channel)"; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "error: the directory's reply to \"$search_text\" does not list ci_group and ci_channel"
return 1
fi
done
echo "search for \"$search_text\" found ci_group and ci_channel"
}
write_seed() {
{
echo "# Test directory"
echo
echo "Directory address: $directory_link"
echo
echo "## Listed groups and channels"
echo
echo "| Search with | Entries | Seeded for |"
echo "|---|---|---|"
echo "| \`$search_text\` | ci_group (group), ci_channel (channel), each with a small avatar (64 px) | a group and a channel to find |"
echo "| \`ci cards\` | ci_cards (channel, with a small avatar) | a channel to join, so that the profile has a conversation |"
echo "| \`$avatar_search_text\` | ci_avatar_g1…3 (groups), ci_avatar_c1…3 (channels), each with an avatar the size the app makes | images at the size limit |"
echo "| \`ci long\` | $long_name (group): a long name and a two-line description | truncation |"
echo "| \`ci markdown\` | ci_markdown (group): markdown in the description | markdown shown literally |"
echo "| \`cinews\` | cinews (channel, with the name cinews.testing), cinews_digest and cinews_weekly (groups) | a search that matches a SimpleX name and listed entries |"
echo
sqlite3 -markdown "$work/directory_chat.db" "$listings_query"
echo
echo "## SimpleX names"
echo
echo "Names server, to add in the app for resolving names: \`$names_server\`"
echo
echo "| Name | Resolves to |"
echo "|---|---|"
echo "| cinews.testing | the channel cinews, listed in the directory (\"CI news channel\") |"
echo "| ciboth.testing | a contact address (ci_admin) and the channel ciboth, not listed |"
echo "| cialice.testing | the contact cialice, online and accepting connections |"
echo "| cibobby.testing | the contact Robert (display name without the name), online and accepting connections |"
echo "| any other, e.g. cinone.testing | not registered |"
echo
} > "$work/seed.md"
}
dump_directory() {
echo "::group::directory registrations"
db_query directory "SELECT group_id, group_reg_status FROM sx_directory_group_regs" || true
echo "::endgroup::"
}
start() {
bin=$work/bin
mkdir -p "$bin"
unzip -o -q "$1" -d "$bin"
"$bin/simplex-chat" --version
"$bin/simplex-directory-service" --version
"$bin/smp-server" --version
make_avatars
start_names
start_relay
create_directory
start_directory "0:nobody"
trap dump_directory EXIT
connect_admin
seed_group ci_group "CI seed group" "$work/avatar-7.txt"
setup_relay
seed_channel ci_channel "CI seed channel" "$work/avatar-8.txt"
seed_channel ci_cards "CI cards channel" "$work/avatar-9.txt"
seed_avatars
seed_group "$long_name" "CI long entry: a description long enough to take two lines in the directory results, to check how the row truncates"
seed_group ci_markdown "CI markdown *bold* _italic_ [x](https://example.com)"
seed_names
probe_search
probe_names
write_seed
}
stop() {
local p processes="directory relay ci_alice ci_robert smp-server names-resolver"
for p in $processes; do
if [ -f "$work/$p.pid" ]; then
kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true
fi
done
sleep 3
for p in $processes; do
echo "::group::$p log"
cat "$work/$p.log" 2> /dev/null || true
echo "::endgroup::"
done
}
case "${1:-}" in
start)
work=$3
mkdir -p "$work"
start "$2"
;;
stop)
work=$2
stop
;;
directory-stop)
work=$2
stop_directory
;;
directory-start)
work=$2
bin=$work/bin
start_directory "$(cat "$work/super-user")"
;;
listings)
work=$2
sqlite3 -markdown "$work/directory_chat.db" "$listings_query"
;;
*)
echo "usage: $0 start <backend zip> <work dir> | stop <work dir> | directory-stop <work dir> | directory-start <work dir> | listings <work dir>"
exit 1
;;
esac
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Builds the CLI, the directory service and the SMP server (for SimpleX names) that the app job runs as its test backend.
# Usage: scripts/ci/ios-sim/build-backend.sh <output zip>
# They need the server-capable configuration (no client_library), as build.yml's macOS job builds the CLI.
set -euo pipefail
mkdir -p "$(dirname "$1")"
out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
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
package simplexmq
extra-include-dirs: $openssl_dir/include
extra-lib-dirs: $openssl_dir/lib
package direct-sqlcipher
flags: +openssl
extra-include-dirs: $openssl_dir/include
extra-lib-dirs: $openssl_dir/lib
EOF
targets="exe:simplex-chat exe:simplex-directory-service"
cabal build -j $targets
# simplexmq's sdist leaves out apps/common/Web/static/.well-known, which smp-server embeds, so it is built from a clone
simplexmq=$(mktemp -d)/simplexmq
git clone --quiet --filter=blob:none https://github.com/simplex-chat/simplexmq.git "$simplexmq"
git -C "$simplexmq" checkout --quiet "$(awk '/simplexmq.git/ {getline; print $2; exit}' cabal.project)"
git -C "$simplexmq" submodule update --init --quiet
git -C "$simplexmq" log -1 --format='simplexmq %H %s'
git -C "$simplexmq" submodule status
cp cabal.project.local "$simplexmq/"
(cd "$simplexmq" && cabal build -j exe:smp-server)
pkg=$(mktemp -d)
for target in $targets; do
cp "$(cabal list-bin "$target")" "$pkg"
done
# in the clone, list-bin first prints git's output from syncing the source packages
cp "$(cd "$simplexmq" && cabal list-bin exe:smp-server | tail -n 1)" "$pkg"
otool -L "$pkg"/*
rm -f "$out"
(cd "$pkg" && zip -9 "$out" *)
ls -la "$out"
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# Builds the iOS core library package with cabal, as flake.nix builds aarch64-darwin-ios:lib:simplex-chat.
# Usage: scripts/ci/ios-sim/build-core.sh <output zip>
# The zip has the same files as the Hydra package that scripts/ios/prepare.sh expects.
set -euo pipefail
mkdir -p "$(dirname "$1")"
out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
ghc_ver=$(ghc --numeric-version)
version=$(sed -n 's/^version: *//p' simplex-chat.cabal)
openssl_dir=$(brew --prefix openssl@3)
ffi_dir=$(brew --prefix libffi)
gmp_dir=$(brew --prefix gmp)
ghc --version
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
package simplex-chat
flags: +swift +client_library
static: True
shared: False
ghc-options: -threaded -DIOS
package simplexmq
flags: +swift +client_library +commoncrypto
extra-include-dirs: $openssl_dir/include
extra-lib-dirs: $openssl_dir/lib
package direct-sqlcipher
flags: +commoncrypto
package entropy
flags: +DoNotGetEntropy
EOF
cabal build -j lib:simplex-chat
build_dir=dist-newstyle/build/aarch64-osx/ghc-$ghc_ver/simplex-chat-$version/build
ls -la "$build_dir"/libHSsimplex-chat-*
ghc_lib=$(ls "$build_dir"/libHSsimplex-chat-*-ghc"$ghc_ver".a)
echo "Selected symbols defined in $ghc_lib:"
{ nm -gU "$ghc_lib" 2>/dev/null || true; } \
| awk '$NF ~ /^_(ffi_call|__gmpz_init|hs_init_with_rtsopts|chat_migrate_init_key|EVP_CIPHER_CTX_new|sqlite3_key)$/ {print " " $NF}' \
| sort -u
pkg=$(mktemp -d)
cp "$build_dir"/libHSsimplex-chat-*.a "$ffi_dir"/lib/libffi.a "$gmp_dir"/lib/libgmp.a "$gmp_dir"/lib/libgmpxx.a "$pkg"
chmod u+w "$pkg"/*.a
for f in "$pkg"/libHSsimplex-chat-*.a; do
strip -x "$f"
done
ls -la "$pkg"
rm -f "$out"
(cd "$pkg" && zip -9 "$out" *.a)
ls -la "$out"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
# PreToolUse hook for Claude on the runner: lets through only Bash commands that run the ui helper, with plain
# arguments, so the session has no general shell. Claude Code runs read-only commands such as cat, echo and env without
# asking in every permission mode, and Bash inherits CLAUDE_CODE_OAUTH_TOKEN, so permission rules alone are not enough.
# Exits 2 for anything else; Claude sees the message as the reason. Each decision is appended to CLAUDE_GUARD_LOG
# when it is set, as evidence that the hook ran.
import json
import os
import re
import sys
# nothing that chains, pipes, redirects, substitutes, expands or escapes
UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?")
call = json.load(sys.stdin)
command = (call.get("tool_input") or {}).get("command", "").strip()
allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command)
if os.environ.get("CLAUDE_GUARD_LOG"):
with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log:
log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n")
if not allowed:
print(f"Only the ui helper runs here, as one command: ui <command> [args]. Refused: {command[:200]}", file=sys.stderr)
sys.exit(2)
+59
View File
@@ -0,0 +1,59 @@
# Device test session: SimpleX Chat iOS
You are testing the SimpleX Chat iOS app in an iPhone 17 simulator (iOS 26.5, screen 402 x 874 points) on a GitHub Actions runner. You run the cases of the device test plan (at the end of this prompt) that the section "This session" names, in the layout it names, and write a report. You cannot change the app or the runner; anything wrong you find is a finding for the report.
## Tools
- **Bash runs only the `ui` helper**, one command per call: `ui tap "Create profile"`, `ui find Settings`. Any other program, a pipe, a redirection, `;` or `&&` is denied. Its commands are listed under "The ui commands" below.
- **Read, Write and Edit work only in the current directory.** Screenshots go to `shots/` (look at the `-view.jpg` files, they are smaller), and you write `report.md` here. This whole prompt, with the plan, is also `prompt.md` here: read it again when you are unsure, and after the conversation has been compacted.
## What you need to know about the app and the helper
- **Layouts.** With the one-hand UI (reachable toolbar, the default) the chat list is drawn from the bottom up: the toolbar (your profile image and the "Compose" pencil) is at the bottom, the search field just above it (y about 754), the list tags ("Add list" when there are no lists) above the field, and chats stack upwards from there. With the top bar the toolbar and the search field are at the top. A profile without conversations sees onboarding pages ("Talk to someone") in place of the list, without the search field. Stay in the layout the session names; the app starts in it, and `ui relaunch` keeps it.
- **Positions.** "Above" means a smaller y. Judge order and position from the tree's coordinates, and look at a screenshot when you judge layout (truncation, overlap, images).
- **The search field** has no label; tap it with `ui tap-value "Search or paste SimpleX link"` when empty, or by its text. To clear it, tap the field so it is focused, then "Cancel" next to it ("Cancel" exists only while the field is focused; it also hides the keyboard); tapping the "Close" image inside the field did not clear it in an earlier run. Type text as one quoted argument (`ui type "ci seed"`). The keyboard capitalizes the first letter and autocorrects words (an earlier run saw "ci" become "Ci" and a typed name become another word when a button was tapped), so check the field's value in the tree after typing, and before typing check that the field shows its placeholder. The keyboard's Search key is `ui key return`; the keyboard's keys are below the screen and not listed in the tree, and the screenshots do not show the keyboard.
- **Pasting**: `ui clipboard "<link>"`, tap the empty field once to focus it and once more to open its menu, then tap its "Paste" item (two elements carry that label: use `ui tap-xy` on the one `ui find Paste` shows in the menu).
- **Disabled elements** end with "disabled" in the tree.
- **Ambiguous labels.** When `ui tap` says several elements match a label (seen for "Paste", "Channels", "Account"), tap the one you mean with `ui tap-xy` at its centre, taken from `ui find`.
- **Hidden elements.** The tree always contains the closed user picker: the profile name, "Create SimpleX address", "Chat preferences", "Your chat profiles", "Use from desktop", "Settings", at coordinates on the screen even while it is hidden. Do not tap them unless you opened the picker by tapping your profile image in the toolbar (`Image "Account"`, 32 x 32; tap its centre with `ui tap-xy`).
- **Settings → Reset all hints:** open the picker, "Settings", scroll to the bottom, the app version row ("v…"), "Developer", "Reset all hints". Go back with the navigation back buttons and close Settings by swiping it down.
- **New chat** is the "Compose" button (the pencil) in the toolbar. With the top bar the toolbar is hidden while the search field is focused.
- **Joining** (the app's normal flow, from `NewChatView.swift`; an earlier session took its first step for a failure). Tapping a group or channel the profile does not know yet, from a link or a list, shows an alert with its name (and subscriber count for a channel) and "Cancel" / "Open channel" or "Open group": that is the start of joining, not a sign that the entry is known. "Open …" opens the chat, not yet joined, with "Tap Join channel" in it and a "Join channel" / "Join group" bar at the bottom; tapping that bar joins (a channel then shows "you are subscriber"; a group may stay "connecting"). A contact works the same way: "Open new chat", then the chat's connect button. An entry already opened, joined or connecting shows the same kind of alert, plus your role ("You are a subscriber", "You are a member") once joined. So each case that joins uses the entry named for it under "This session", which no earlier case has touched. Opened and joined chats appear in the chat list, and the profile is no longer "without conversations".
- **A second profile:** open the picker, "Your chat profiles", add a profile; a new profile has no conversations. Switch back to the first profile when done.
- **Text size:** `ui text-size accessibility-large`, and back to `ui text-size medium` afterwards. Only one phone size is available.
- **If the app gets stuck**, `ui relaunch` restarts it (search text is cleared, everything else stays).
- Waiting: prefer `ui wait <text> <secs>` over repeated trees; `ui wait` returns at once when the text is already shown.
## The test backend
When "This session" gives the test directory's address, the runner also runs a test backend: a chat relay, a SimpleX Directory with the groups and channels listed under "Test directory" below, a server that resolves SimpleX names, and contacts that accept connections. `ui directory stop|start` stops and starts the directory (after `ui directory start`, wait about 15 s before using it), and `ui listings` prints what it lists.
**SimpleX names**: the names server's address is under "This session"; the names it knows are under "Test directory" → "SimpleX names" (all end in `.testing`; a bare word of 5 or more letters is completed with `.testing`, so `cinews` is `cinews.testing`). Set the app up for it once, before the first case that needs names (if an earlier session in this run did it, check it instead: the server is listed under "Your servers"):
1. Open the picker (your profile image), "Settings", "Network & servers".
2. Under "Preset servers", open each operator ("SimpleX Chat", "Flux"), turn "To resolve names" off, and go back. Otherwise the app may ask the real servers, which do not know the test names.
3. "Your servers" → "Add server" → "Enter server manually". Put the names server's address in the address field by pasting it (typing it would let the keyboard capitalize it), check it in the tree, and go back.
4. In "Your servers", open the new server and set "To receive" to "no", "For private routing" to "no" and "To resolve names" to "yes" (each is a picker), then go back.
5. On "Network & servers", tap "Save servers" (and "Save" if asked). Close Settings.
## How to run the cases
- Run each case of the session's list, in the plan's order unless a case needs another first.
- The plan's tables (ID, Do, Expect) are at the end of `prompt.md` in the current directory: read the row before running a case and do exactly what it says. An earlier session did not find them and filed other actions under some IDs.
- For each case, do what the plan says, decide pass or fail against the plan's "Expect" column, and take at least one `ui shot` at the moment that shows the result, named after the case and layout without a number, for example `ui shot S1-reachable` (the helper numbers the files).
- Pass only what you saw: quote the evidence (element labels, values and coordinates from the tree, or what a screenshot shows). If an expectation cannot be checked here, say which part and why, and mark the case passed for the parts you checked only if those parts are the substance of the case; otherwise skipped.
- **Skip** any case that needs something this runner lacks, saying exactly what is missing. Do not skip a case only because it takes several steps.
- Other app bugs you notice are findings: write what you did, what you saw, what you expected, and the screenshot.
- Mind the session's turn limit under "This session": every `ui` command and every file edit is a turn.
## The report
Write `report.md` in the current directory as you go, so a session that is cut short still leaves its results. **Before the first case, create it with the Results table already holding one row for every case of the session's list, Result `todo`**, and fill a row in as soon as its case is done. Before each case, and always after the conversation has been compacted, read `report.md` to see which cases are left (and `prompt.md` for anything else you need). The session is finished only when no row says `todo`. Its sections:
1. `# Device test report: <layout>` and one line on the app state at the start.
2. `## Results`: a table with one row per case of the session's list: `| ID | Result | Evidence | Screenshots |`, Result being pass, fail or skipped (with the reason).
3. Any section the plan asks for.
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.
+8
View File
@@ -0,0 +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.
model: claude-haiku-5-5
layouts: reachable
cases-reachable: S1-S3
max-turns: 60
max-budget-usd: 3
+143
View File
@@ -0,0 +1,143 @@
#!/bin/bash
# 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).
# 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.
set -euo pipefail
token=${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set}
# only the claude processes get the token, nothing else this script starts
unset CLAUDE_CODE_OAUTH_TOKEN
mkdir -p "$1"
out=$(cd "$1" && pwd)
dir=$(cd "$(dirname "$0")" && pwd)
: "${SIM_UDID:?SIM_UDID must be the simulator UDID}"
BACKEND_DIR=${BACKEND_DIR:-}
scope() {
sed -n "s/^$1: *//p" "$dir/claude-scope"
}
model=$(scope model)
layouts=$(scope layouts)
max_turns=$(scope max-turns)
max_budget=$(scope max-budget-usd)
export PATH="$dir:$PATH" BACKEND_DIR APP_LOG=$out/app.log CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
if [ -n "$BACKEND_DIR" ]; then
cp "$BACKEND_DIR/seed.md" "$out/seed.md"
fi
settings=$out/settings.json
cat > "$settings" << EOF
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "$dir/claude-guard.py"}]}]}}
EOF
xcrun simctl spawn "$SIM_UDID" log stream --level debug --style compact --predicate 'process == "SimpleX"' > "$APP_LOG" 2>&1 &
log_pid=$!
trap 'kill "$log_pid" 2> /dev/null || true' EXIT
layout_name() {
case "$1" in
reachable) echo "the one-hand UI (reachable toolbar)" ;;
top) echo "the top bar" ;;
esac
}
write_prompt() {
local layout=$1 previous=$2
cat "$dir/claude-prompt.md"
echo
echo "## The ui commands"
echo
echo '```'
ui || true
echo '```'
echo
echo "## This session"
echo
echo "- Layout: $(layout_name "$layout")."
echo "- Cases: $(scope "cases-$layout")."
local entries
entries=$(scope "entries-$layout")
if [ -n "$entries" ]; then
echo "- Entries to use: $entries."
fi
if [ -n "$BACKEND_DIR" ]; then
echo "- The test directory's address: $(cat "$BACKEND_DIR/directory-link")."
echo "- Names server to add in the app: $(cat "$BACKEND_DIR/names-server")."
else
echo "- No test backend runs in this session."
fi
if [ -z "$previous" ]; then
echo "- At the start: the smoke test onboarded a profile (typed as \"citester\"; the keyboard may have autocorrected the name) and checked the chat list in both layouts; the profile has no conversations."
else
echo "- At the start: an earlier session ran the cases in $(layout_name "$previous") on this app, so the profile may have chats, other profiles may exist and hints may be changed; look at the state first."
fi
echo "- Limits: $max_turns turns and \$$max_budget (estimated at API prices). Keep report.md current as you go."
echo
if [ -n "$BACKEND_DIR" ]; then
cat "$out/seed.md"
echo
fi
cat "$dir/device-test-plan.md"
}
status=0
previous=""
for layout in $layouts; do
session=$out/$layout
mkdir -p "$session/shots"
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
: > "$CLAUDE_GUARD_LOG"
(
cd "$session"
UI_OUT=$session/shots CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \
--model "$model" \
--effort high \
--restricted \
--tools Bash,Read,Write,Edit \
--allowedTools "Bash(ui)" "Bash(ui *)" "Read(/$session/**)" "Edit(/$session/**)" \
--permission-mode dontAsk \
--permission-prompts none \
--strict-mcp-config \
--max-turns "$max_turns" \
--max-budget-usd "$max_budget" \
--autocompact 200k \
--no-session-persistence \
--settings "$settings" \
--output-format stream-json \
--verbose \
< prompt.md > transcript.jsonl 2> stderr.log
) || 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
rm -rf "$session" "$CLAUDE_GUARD_LOG"
echo "::error::the $layout session's files contained the token, so they were deleted before the upload"
session_status=1
else
tail -n 20 "$session/stderr.log"
if [ -f "$session/report.md" ]; then
cat "$session/report.md"
else
echo "no report.md"
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
fi
echo "::endgroup::"
if [ "$session_status" -ne 0 ]; then
status=$session_status
fi
previous=$layout
done
exit "$status"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Reports what each Claude session of claude-test.sh spent, from the final result event of its transcript,
# to the job summary and to <output dir>/claude-usage.json. total_cost_usd is Claude Code's own estimate
# at API prices, not a bill.
# Usage: scripts/ci/ios-sim/claude-usage.sh <output dir>
set -euo pipefail
out=$1
summary=${GITHUB_STEP_SUMMARY:-/dev/stdout}
sessions=0
mkdir -p "$out"
{
echo "## Claude sessions"
echo
echo "| Session | Result | Turns | Duration | Input | Output | Cache read | Cache write | Per model, estimated at API prices | Total, estimated at API prices | Permission denials |"
echo "|---|---|---|---|---|---|---|---|---|---|---|"
} >> "$summary"
for transcript in "$out"/*/transcript.jsonl; do
[ -f "$transcript" ] || continue
session=$(basename "$(dirname "$transcript")")
sessions=$((sessions + 1))
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"
jq -n --arg s "$session" '{session: $s, result: "missing"}' > "$out/$session/usage.json"
continue
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
if [ "$sessions" -eq 0 ]; then
echo "No Claude session ran." >> "$summary"
echo "[]" > "$out/claude-usage.json"
else
jq -s '.' "$out"/*/usage.json > "$out/claude-usage.json"
fi
cat "$out/claude-usage.json"
+9
View File
@@ -0,0 +1,9 @@
# Device test plan: smoke test
The app as the scripted smoke test leaves it: a profile created through onboarding, without conversations. A branch that tests a feature replaces this plan with its own (same table format) and names its cases in `claude-scope`.
| ID | Do | Expect |
|----|----|--------|
| S1 | Look at the screen: `ui tree`, then `ui shot S1-<layout>` | The chat list of a profile without conversations: the heading "Talk to someone", the cards "Let someone connect to you" and "Connect via link or QR code", and the toolbar with your profile image (`Image "Account"`, 32 x 32) and "Compose": at the bottom of the screen with the one-hand UI, at the top with the top bar |
| S2 | Tap "Let someone connect to you", then "Back" | The page "Create your link" with the cards "Invite someone privately" and "Create your public address" and a "Back" button; after "Back", "Talk to someone" again |
| S3 | Open the profile picker by tapping the toolbar's profile image, tap "Settings", then close Settings by swiping it down | The picker shows your profile name and "Settings"; Settings opens with "Your settings" and "Network & servers"; after closing, "Talk to someone" again |
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Finds the newest run of this branch in this repository that uploaded an unexpired artifact with this name,
# and writes its id as run-id to GITHUB_OUTPUT. Needs GH_TOKEN with actions: read.
# Usage: scripts/ci/ios-sim/earlier-run.sh <artifact name>
# Artifacts from forks and other branches are not used: their workflows could upload anything under this name.
set -euo pipefail
name=$1
run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" --jq "
[.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")
if [ -n "$run_id" ]; then
echo "$name: from run $run_id"
echo "run-id=$run_id" >> "$GITHUB_OUTPUT"
else
echo "$name: not built yet"
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Installs a pinned AXe release the way its Homebrew formula (cameroncooke/homebrew-axe) does, rather than the tap's
# latest version, because it runs in the job that later holds the Claude token. Puts axe on the job's PATH.
set -euo pipefail
version=1.8.0
sha256=87eb09cd49dfc74fa8f7f5a46a03dfe2f14518b01d819f6a4094eab0d344ea7f
dir=$RUNNER_TEMP/axe
mkdir -p "$dir/release"
curl --proto '=https' --tlsv1.2 -sSfL -o "$dir/axe.tar.gz" "https://github.com/cameroncooke/AXe/releases/download/v$version/AXe-macOS-homebrew-v$version.tar.gz"
echo "$sha256 $dir/axe.tar.gz" | shasum -a 256 -c
tar -xzf "$dir/axe.tar.gz" -C "$dir/release"
axe=$(find "$dir/release" -type f -name axe -print -quit)
for framework in "$(dirname "$axe")"/Frameworks/*.framework; do
codesign --force --sign - --timestamp=none "$framework"
done
codesign --force --sign - --timestamp=none "$axe"
"$axe" --version
dirname "$axe" >> "$GITHUB_PATH"
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
# The name resolver that the test smp-server's [NAMES] section points at, as simplexmq's Server/Names/HttpResolver.hs
# calls it: GET /v2/resolve/<query> answers a NameResponse for the query "[<keccak-256 of the lowercased label, hex>]<.tld>"
# (or the plain name, which older clients send), GET /health answers {}. Registered names come from a JSON file, read on every request, so names can be added while it runs:
# {"cinews.testing": {"simplexChannel": ["https://..."], "simplexContact": []}, ...}
# Every other name is answered as available.
# Usage: names-resolver.py <port> <names json>
import json
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROUND_CONSTANTS = [
0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008,
]
ROTATIONS = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
MASK = (1 << 64) - 1
def rol(v, n):
return ((v << n) | (v >> (64 - n))) & MASK if n else v
def keccak_f(a):
for rc in ROUND_CONSTANTS:
c = [a[x][0] ^ a[x][1] ^ a[x][2] ^ a[x][3] ^ a[x][4] for x in range(5)]
d = [c[(x - 1) % 5] ^ rol(c[(x + 1) % 5], 1) for x in range(5)]
a = [[a[x][y] ^ d[x] for y in range(5)] for x in range(5)]
b = [[0] * 5 for _ in range(5)]
for x in range(5):
for y in range(5):
b[y][(2 * x + 3 * y) % 5] = rol(a[x][y], ROTATIONS[x][y])
a = [[b[x][y] ^ (~b[(x + 1) % 5][y] & b[(x + 2) % 5][y]) for y in range(5)] for x in range(5)]
a[0][0] ^= rc
return a
# Keccak-256 as Ethereum uses it (padding 0x01, not SHA3's 0x06), which hashlib does not have
def keccak256(data):
rate = 136
padded = bytearray(data) + b"\x01" + bytes(-(len(data) + 1) % rate)
padded[-1] |= 0x80
a = [[0] * 5 for _ in range(5)]
for offset in range(0, len(padded), rate):
for i in range(rate // 8):
a[i % 5][i // 5] ^= int.from_bytes(padded[offset + 8 * i:offset + 8 * i + 8], "little")
a = keccak_f(a)
return b"".join(a[i % 5][i // 5].to_bytes(8, "little") for i in range(4))
def name_query(name):
label, tld = name.lower().split(".", 1)
return "[" + keccak256(label.encode()).hex() + "]." + tld
def name_response(names_file, query):
with open(names_file) as f:
names = json.load(f)
for name, links in names.items():
# hashed from SMP v22 (NQHash), the plain name before (NQDomain)
if query.lower() in (name_query(name), name.lower()):
record = {
"name": name,
"nickname": "",
"website": "",
"location": "",
"simplexContact": links.get("simplexContact", []),
"simplexChannel": links.get("simplexChannel", []),
"eth": None,
"btc": None,
"xmr": None,
"dot": None,
"owner": "",
"resolver": "",
}
return name, {"registration": {"type": "registered", "nameRecord": record}}
return None, {"registration": {"type": "available", "pricing": {"registrationPrices": {}, "basePrice": 1000, "minLabelLength": 1}}}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = urllib.parse.unquote(self.path)
if path == "/health":
self.reply(200, {})
elif path.startswith("/v2/resolve/"):
query = path[len("/v2/resolve/"):]
name, response = name_response(self.server.names_file, query)
print(f"resolve {query}: {name or 'not registered'}", flush=True)
self.reply(200, response)
else:
self.reply(404, {})
def reply(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, format, *args):
pass
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler)
server.names_file = sys.argv[2]
print(f"names resolver on 127.0.0.1:{sys.argv[1]}, names from {sys.argv[2]}", flush=True)
server.serve_forever()
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# Builds mac2ios, runs scripts/ios/prepare.sh on ~/Downloads/pkg-ios-aarch64-swift-json.zip,
# and fails unless every object in apps/ios/Libraries/sim is marked for the iOS simulator.
set -euo pipefail
mac2ios_rev=4dcb77d5ea896d749381806dfab5358851b08951
tools=$(mktemp -d)
for f in mac2ios.c ar.h macho.h; do
curl --proto '=https' --tlsv1.2 -sSfL -o "$tools/$f" "https://raw.githubusercontent.com/zw3rk/mobile-core-tools/$mac2ios_rev/$f"
done
cc -O2 -o "$tools/mac2ios" "$tools/mac2ios.c"
PATH="$tools:$PATH" scripts/ios/prepare.sh
for f in apps/ios/Libraries/sim/*.a; do
echo "$f: $(du -h "$f" | cut -f1), $(lipo -info "$f")"
platforms=$({ otool -l "$f" || true; } | awk '$1 == "platform" {print $2}' | sort | uniq -c)
echo "${platforms:- no LC_BUILD_VERSION found}"
if echo "$platforms" | awk 'NF {print $2}' | grep -vE '^(7|IOSSIMULATOR)$' > /dev/null; then
echo "error: $f has objects for a platform other than the iOS simulator"
exit 1
fi
done
+124
View File
@@ -0,0 +1,124 @@
#!/bin/bash
# Installs the app in a new iPhone simulator and runs the scripted UI cases with the ui helper: onboarding to the
# chat list with the one-hand UI (the default), then the chat list again with the top bar. It leaves the app in the one-hand UI.
# Usage: scripts/ci/ios-sim/smoke-test.sh <path to SimpleX.app> <output dir>
# Each step saves a screenshot and an accessibility tree to the output dir and prints the tree to the log.
# Later steps of the job get SIM_UDID and UI_OUT, with the simulator still running.
set -euo pipefail
app=$1
export UI_OUT=$2
bundle_id=chat.simplex.app
ui=$(dirname "$0")/ui
mkdir -p "$UI_OUT"
axe --version
runtime=com.apple.CoreSimulator.SimRuntime.iOS-$(xcrun --sdk iphonesimulator --show-sdk-version | tr . -)
SIM_UDID=$(xcrun simctl create ci-iphone com.apple.CoreSimulator.SimDeviceType.iPhone-17 "$runtime")
export SIM_UDID
echo "simulator $SIM_UDID, runtime $runtime"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "SIM_UDID=$SIM_UDID" >> "$GITHUB_ENV"
echo "UI_OUT=$UI_OUT" >> "$GITHUB_ENV"
fi
xcrun simctl boot "$SIM_UDID"
xcrun simctl bootstatus "$SIM_UDID" -b
open -a Simulator --args -CurrentDeviceUDID "$SIM_UDID"
xcrun simctl spawn "$SIM_UDID" log stream --level debug --style compact --predicate 'process == "SimpleX"' > "$UI_OUT/app.log" 2>&1 &
log_pid=$!
finish() {
local status=$?
"$ui" shot final || true
kill "$log_pid" || true
echo "::group::core start in the app log"
grep -E 'chatMigrateInit|startChat' "$UI_OUT/app.log" || true
echo "::endgroup::"
echo "::group::app log, last 300 lines"
tail -n 300 "$UI_OUT/app.log"
echo "::endgroup::"
find ~/Library/Logs/DiagnosticReports -name 'SimpleX*' -newer "$app" 2>/dev/null | while read -r report; do
echo "::group::crash report $report"
head -n 200 "$report"
echo "::endgroup::"
done || true
exit "$status"
}
trap finish EXIT
expect_in_tree() {
local tree
tree=$("$ui" tree)
if ! grep -q "$@" <<< "$tree"; then
echo "error: ${*: -1} is not in the accessibility tree"
exit 1
fi
}
# iOS asks once whether the app may send notifications. The tree sometimes shows the app under this alert rather than
# the alert (run 38895987656), so its Allow button is also looked for at its place.
allow_notifications() {
local deadline=$((SECONDS + 30)) element
while [ "$SECONDS" -lt "$deadline" ]; do
if "$ui" tap Allow 1; then
return
fi
element=$("$ui" point 275 518 || true)
if grep -qF '"Allow"' <<< "$element"; then
"$ui" tap-xy 275 518
return
fi
sleep 2
done
echo "no notifications alert to allow"
}
# a profile without conversations sees the onboarding pages in place of the list
expect_chat_list() {
expect_in_tree -F '"Talk to someone"'
expect_in_tree -F 'Button "Let someone connect to you"'
expect_in_tree -F 'Button "Connect via link or QR code"'
expect_in_tree -F 'Button "Compose"'
echo "in the $1 layout the chat list shows the onboarding pages and the toolbar"
}
xcrun simctl install "$SIM_UDID" "$app"
xcrun simctl launch "$SIM_UDID" "$bundle_id"
# onboarding to the chat list
"$ui" wait "Get started" 180
"$ui" shot first-screen
"$ui" tap "Get started"
"$ui" wait "Create profile" 30
"$ui" tap-value "Enter profile name..."
"$ui" type citester
"$ui" shot profile-name
"$ui" tap "Create profile"
"$ui" wait "Continue" 60
"$ui" shot your-network
"$ui" tap "Continue"
"$ui" wait "Accept" 30
"$ui" shot network-commitments
"$ui" tap "Accept"
sleep 5
"$ui" shot after-onboarding
allow_notifications
"$ui" shot notifications-allowed
"$ui" wait "Talk to someone" 60
"$ui" shot chat-list
expect_chat_list reachable
# ui layout checks that the toolbar moved
"$ui" layout top
"$ui" shot chat-list-top
expect_chat_list top
"$ui" layout reachable
echo "smoke test passed: onboarding reaches the chat list, which shows its onboarding pages and toolbar with the one-hand UI and with the top bar"
+237
View File
@@ -0,0 +1,237 @@
#!/bin/bash
# Drives the app in the simulator SIM_UDID with AXe, for the scripted cases and for an agent on the runner.
# Screenshots and accessibility trees go to UI_OUT.
#
# ui tap <label> [secs] tap the element with this exact accessibility label, waiting up to secs (10) for it
# ui tap-value <text> [secs] tap the element with this exact value: text fields have no label, only their placeholder or text
# ui tap-xy <x> <y> tap this point (points, as in the tree)
# ui swipe <x1> <y1> <x2> <y2> swipe from one point to the other, e.g. to scroll a list
# ui type <text> type into the focused field (US keyboard characters only)
# ui clipboard <text> put the text on the clipboard, to paste it with the text field's Paste menu
# ui key <return|delete> [count] press a keyboard key: return submits the field (the Search key), delete erases a character
# ui wait <text> [secs] wait up to secs (30) until a label or value contains the text (case-sensitive)
# ui find <text> print the elements whose label or value contains the text (any case)
# ui shot <name> save UI_OUT/NN-<name>.png, a smaller NN-<name>-view.jpg and NN-<name>.json, and print the tree
# ui tree print the tree, one element per line: type, label, value, top-left corner and size in points,
# and "disabled" for a disabled element
# ui point <x> <y> print the element at this point, also when it belongs to a system alert that the tree misses
# ui layout <reachable|top> restart the app with the one-hand UI (reachable toolbar) or the top bar
# ui relaunch restart the app
# ui text-size <category> set Dynamic Type: medium (the default), large, extra-large, extra-extra-large,
# extra-extra-extra-large, accessibility-medium ... accessibility-extra-extra-extra-large
# ui directory <stop|start> stop or start the test directory (searches fail while it is stopped)
# ui listings the groups and channels listed in the test directory
# ui requests how many directory searches the app sent and how many were answered (needs APP_LOG)
set -euo pipefail
# nothing this helper runs needs the token of the Claude session that calls it
unset CLAUDE_CODE_OAUTH_TOKEN
: "${SIM_UDID:?SIM_UDID must be the simulator UDID}"
bundle_id=chat.simplex.app
app_group=group.chat.simplex.app
# the toolbar's new chat button: shown with and without conversations, unlike the search field
toolbar_button=Compose
# Elements below the screen, such as the keyboard's keys, are counted rather than listed; the screen is the first
# element's frame, or the given height.
compact_tree() {
jq -r --argjson screen "${1:-null}" '($screen // .[0].frame.height // 100000) as $h
| [.. | objects | select(.AXLabel or .AXValue)]
| (.[] | select((.frame.y // 0) < $h)
| "\(.type) \(.AXLabel | tojson) value=\(.AXValue | tojson) at \(.frame.x // 0 | round),\(.frame.y // 0 | round) size \(.frame.width // 0 | round)x\(.frame.height // 0 | round)\(if .enabled == false then " disabled" else "" end)"),
(map(select((.frame.y // 0) >= $h)) | length | select(. > 0) | "(\(.) elements below the screen not listed, such as keyboard keys)")'
}
# describe-ui failed once while a system alert was appearing, and read the tree a few seconds later (run 38895987656)
describe() {
local attempt
for attempt in 1 2 3 4; do
if axe describe-ui --udid "$SIM_UDID" "$@"; then
return
fi
sleep 2
done
axe describe-ui --udid "$SIM_UDID" "$@"
}
tree() {
describe | compact_tree
}
point() {
number "$1"
number "$2"
describe --point "$1,$2" | compact_tree 100000
}
wait_for() {
local text=$1 secs=${2:-30}
local deadline=$((SECONDS + secs)) t
until t=$(tree 2> /dev/null) && grep -qF -- "$text" <<< "$t"; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "error: \"$text\" not shown after $secs s"
return 1
fi
sleep 2
done
}
find_text() {
local t
t=$(tree)
if ! grep -iF -- "$1" <<< "$t"; then
echo "no element contains \"$1\""
return 1
fi
}
shot() {
: "${UI_OUT:?UI_OUT must be the output directory}"
if ! [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]; then
echo "error: a shot name has only letters, digits, - and _"
return 1
fi
mkdir -p "$UI_OUT"
local n f
n=$(find "$UI_OUT" -maxdepth 1 -name '*.png' | wc -l)
f=$(printf '%s/%02d-%s' "$UI_OUT" $((n + 1)) "$1")
xcrun simctl io "$SIM_UDID" screenshot "$f.png" > /dev/null
sips -Z 1000 -s format jpeg -s formatOptions 70 "$f.png" --out "$f-view.jpg" > /dev/null
describe > "$f.json"
echo "::group::accessibility tree: $1"
echo "saved $f.png, and $f-view.jpg to look at"
compact_tree < "$f.json"
echo "::endgroup::"
}
number() {
if ! [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
echo "error: \"$1\" is not a number"
exit 1
fi
}
key() {
local code count=${2:-1} i
case "$1" in
return) code=40 ;;
delete) code=42 ;;
*)
echo "error: key is return or delete"
return 1
;;
esac
number "$count"
for ((i = 0; i < count; i++)); do
axe key "$code" --udid "$SIM_UDID"
done
sleep 1
}
launch() {
xcrun simctl terminate "$SIM_UDID" "$bundle_id" 2> /dev/null || true
"$@"
xcrun simctl launch "$SIM_UDID" "$bundle_id" > /dev/null
wait_for "$toolbar_button" 90
}
toolbar_y() {
tree | grep -F "Button \"$toolbar_button\" " | sed -E 's/.* at [0-9-]+,([0-9-]+) .*/\1/' | head -n 1
}
# The preference is set in the app group's defaults while the app is not running; Settings shows the same switch.
layout() {
local one_hand
case "$1" in
reachable) one_hand=true ;;
top) one_hand=false ;;
*)
echo "error: layout is reachable or top"
return 1
;;
esac
local prefs
prefs=$(xcrun simctl get_app_container "$SIM_UDID" "$bundle_id" "$app_group")/Library/Preferences/$app_group
launch xcrun simctl spawn "$SIM_UDID" defaults write "$prefs" oneHandUI -bool "$one_hand"
local y
y=$(toolbar_y)
if { [ "$one_hand" = true ] && [ "$y" -lt 437 ]; } || { [ "$one_hand" = false ] && [ "$y" -ge 437 ]; }; then
echo "error: the toolbar is at y=$y, not where the $1 layout puts it"
return 1
fi
echo "layout $1: the toolbar is at y=$y"
}
text_size() {
case "$1" in
medium | large | extra-large | extra-extra-large | extra-extra-extra-large | accessibility-medium | accessibility-large | accessibility-extra-large | accessibility-extra-extra-large | accessibility-extra-extra-extra-large) ;;
*)
echo "error: unknown text size \"$1\""
return 1
;;
esac
xcrun simctl ui "$SIM_UDID" content_size "$1"
sleep 2
}
backend() {
: "${BACKEND_DIR:?BACKEND_DIR must be the work dir of the test backend}"
"$(dirname "$0")/backend.sh" "$@" "$BACKEND_DIR"
}
directory() {
case "$1" in
stop) backend directory-stop ;;
start) backend directory-start ;;
*)
echo "error: directory stop or directory start"
return 1
;;
esac
echo "test directory: $1"
}
requests() {
: "${APP_LOG:?APP_LOG must be the app log}"
local sent answered results
sent=$(grep -c 'chatSendCmd apiSendServiceRequest$' "$APP_LOG" || true)
results=$(grep -c 'chatSendCmd apiSendServiceRequest: ' "$APP_LOG" || true)
answered=$(grep -c 'chatSendCmd apiSendServiceRequest: serviceResponse' "$APP_LOG" || true)
echo "directory searches sent: $sent, answered: $answered, failed: $((results - answered)), waiting: $((sent - results))"
}
case "${1:-}" in
tap) axe tap --label "$2" --wait-timeout "${3:-10}" --post-delay 2 --udid "$SIM_UDID" ;;
tap-value) axe tap --value "$2" --wait-timeout "${3:-10}" --post-delay 2 --udid "$SIM_UDID" ;;
tap-xy)
number "$2"
number "$3"
axe tap -x "$2" -y "$3" --post-delay 2 --udid "$SIM_UDID"
;;
swipe)
for n in "$2" "$3" "$4" "$5"; do number "$n"; done
axe swipe --start-x "$2" --start-y "$3" --end-x "$4" --end-y "$5" --duration 0.5 --post-delay 1 --udid "$SIM_UDID"
;;
type) axe type "$2" --udid "$SIM_UDID" ;;
clipboard) printf '%s' "$2" | xcrun simctl pbcopy "$SIM_UDID" ;;
key) key "$2" "${3:-1}" ;;
wait) wait_for "$2" "${3:-30}" ;;
find) find_text "$2" ;;
shot) shot "$2" ;;
tree) tree ;;
point) point "$2" "$3" ;;
layout) layout "$2" ;;
relaunch) launch true ;;
text-size) text_size "$2" ;;
directory) directory "$2" ;;
listings) backend listings ;;
requests) requests ;;
*)
sed -n '3,26s/^# \{0,1\}//p' "$0"
exit 1
;;
esac
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Runner images from 20261013 add Command Line Tools whose default SDK is macOS 27, which Xcode 26.6's linker cannot read
# ("tapi error: malformed file"); GHC's links reach it despite DEVELOPER_DIR, so the default goes back to macOS 26.5.
set -euo pipefail
sdks=/Library/Developer/CommandLineTools/SDKs
if [ -L "$sdks/MacOSX.sdk" ] && [ -d "$sdks/MacOSX26.5.sdk" ]; then
sudo ln -sfn MacOSX26.5.sdk "$sdks/MacOSX.sdk"
fi
ls -l "$sdks"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# Generates the desktop emoji picker data from Unicode emoji-test.txt.
# Emoji version must not exceed the bundled NotoColorEmoji font (2.038 covers Emoji 15.0):
# curl -O https://unicode.org/Public/emoji/15.0/emoji-test.txt
# python3 scripts/desktop/generate-emoji-data.py emoji-test.txt apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt
# Output: "@<group>" lines followed by "<emoji>\t<name>" lines, in CLDR order.
import sys
SKIPPED_GROUPS = {"Component"}
SKIN_TONE_MODIFIERS = {0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF}
def parse(lines):
group = None
for line in lines:
line = line.rstrip("\n")
if line.startswith("# group: "):
group = line[len("# group: "):]
if group not in SKIPPED_GROUPS:
yield "@" + group
elif line and not line.startswith("#") and group not in SKIPPED_GROUPS:
codes, rest = line.split(";", 1)
status, comment = rest.split("#", 1)
if status.strip() != "fully-qualified":
continue
code_points = [int(c, 16) for c in codes.split()]
if SKIN_TONE_MODIFIERS.intersection(code_points):
continue
# comment is "<emoji> E<version> <name>"
name = comment.strip().split(" ", 2)[2]
yield "".join(map(chr, code_points)) + "\t" + name
def main():
src, dst = sys.argv[1], sys.argv[2]
with open(src, encoding="utf-8") as f:
out = list(parse(f))
with open(dst, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(out) + "\n")
if __name__ == "__main__":
main()