From 3de946b7d4d323c7ff1a6cca8bf899004a53827f Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:19:24 +0000 Subject: [PATCH 01/19] ci: upload app builds for manual tests (#7880) --- .github/workflows/android-emu.yml | 4 ++-- .github/workflows/desktop-linux.yml | 8 ++++---- .github/workflows/ios-sim.yml | 6 ++---- scripts/ci/README.md | 4 +++- scripts/ci/android/README.md | 2 +- scripts/ci/desktop-linux/README.md | 2 +- scripts/ci/ios-sim/README.md | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/android-emu.yml b/.github/workflows/android-emu.yml index 3bc26a5a50..f8512aeda9 100644 --- a/.github/workflows/android-emu.yml +++ b/.github/workflows/android-emu.yml @@ -250,13 +250,13 @@ jobs: unzip -l "$RUNNER_TEMP/apk/$APK" | grep -E ' lib/' - name: Upload app - if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} uses: actions/upload-artifact@v7 with: name: android-app path: ${{ runner.temp }}/apk/${{ env.APK }} compression-level: 0 - retention-days: 1 + retention-days: 14 if-no-files-found: error - name: Install emulator and system image diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index 82725791f5..ce5a1c7922 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -253,19 +253,19 @@ jobs: du -sh "$app" LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so" - # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + # a tarball keeps the app's file modes, which an artifact drops - name: Pack app - if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: tar -czf "$RUNNER_TEMP/desktop-app.tar.gz" -C apps/multiplatform/release/main/app simplex - name: Upload app - if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} uses: actions/upload-artifact@v7 with: name: desktop-app path: ${{ runner.temp }}/desktop-app.tar.gz compression-level: 0 - retention-days: 1 + retention-days: 14 if-no-files-found: error - name: Run desktop tests diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml index 08502674d9..b6477018e9 100644 --- a/.github/workflows/ios-sim.yml +++ b/.github/workflows/ios-sim.yml @@ -249,19 +249,17 @@ jobs: DEVELOPMENT_TEAM= \ PROVISIONING_PROFILE_SPECIFIER= - # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + # a tarball keeps the app's file modes, which an artifact drops - name: Pack app - if: needs.plan.outputs.run-claude == 'true' run: tar -czf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator" SimpleX.app - name: Upload app - if: needs.plan.outputs.run-claude == 'true' uses: actions/upload-artifact@v7 with: name: ios-sim-app path: ${{ runner.temp }}/SimpleX.app.tar.gz compression-level: 0 - retention-days: 1 + retention-days: 14 if-no-files-found: error - name: Install AXe diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 5b5e6d4351..a8cb147b01 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -150,7 +150,7 @@ Pushes start none of the app workflows: a run is started by hand, on any branch, | Input | iOS | Desktop | Android | |---|---|---|---| -| no input | scripted test | scripted test | scripted test | +| no input | scripted test, the app uploaded | scripted test, the app uploaded | scripted test, the app uploaded | | `claude` | scripted test, then Claude | scripted test, then Claude | scripted test, then Claude | | `backend` | also the test backend | also the test backend | also the test backend | | `release` | — | also the latest release's onboarding, to tell a branch's failure from the runner's | also the latest release's onboarding, to tell a branch's failure from the runner's | @@ -166,6 +166,8 @@ gh workflow run android-emu.yml --ref -f claude=true A newer run of a workflow on the same branch cancels the older one. +Each run uploads the app it built, for manual tests, as an artifact kept 14 days: `ios-sim-app` (SimpleX.app.tar.gz, for the iOS simulator on an Apple-silicon Mac), `desktop-app` (desktop-app.tar.gz, for Linux x86-64) and `android-app` (the debug APK, arm64-v8a only). The run's page lists it under Artifacts; `gh run download -n ` downloads it. + ## How long a run takes | Run | iOS | Desktop | Android | diff --git a/scripts/ci/android/README.md b/scripts/ci/android/README.md index 106a147a22..ceac2a1b08 100644 --- a/scripts/ci/android/README.md +++ b/scripts/ci/android/README.md @@ -19,7 +19,7 @@ Every run builds the core library, and the backend when it's asked for, from the | `backend` (opt-in) | desktop-linux's `build-backend.sh` | about 30 min, skipped when reused | | `release` (opt-in) | the latest release's arm64 APK, its signer checked against SimpleX's key, onboarding only | about 4 min | -Results are artifacts: `android-smoke-test` (numbered screenshots, the accessibility tree and the text of each, `steps.log`, `checks.txt`, logcat as `app.log`, `emulator.log`), `android-release-smoke-test`, `android-claude-test`, and the backend's logs; `android-app`, the APK for the Claude job, is kept a day. The job summaries list the checks and the Claude usage. +Results are artifacts: `android-smoke-test` (numbered screenshots, the accessibility tree and the text of each, `steps.log`, `checks.txt`, logcat as `app.log`, `emulator.log`), `android-release-smoke-test`, `android-claude-test`, and the backend's logs; `android-app`, the APK for manual tests and the Claude job, is kept 14 days. The job summaries list the checks and the Claude usage. ## The emulator diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index 918e0d68ce..90143005e8 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -19,7 +19,7 @@ Every run builds the core library, and the backend when it's asked for, from the | `backend` (opt-in) | `build-backend.sh`: `simplex-chat`, `simplex-directory-service`, and `smp-server` from the simplexmq commit `cabal.project` pins | about 30 min, skipped when reused | | `release` (opt-in) | the latest release's .deb for this Ubuntu, onboarding only, to tell a branch's failure from the runner's | 1.5 min | -Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-claude-test`, and the backend's logs; `desktop-app`, the distributable for the Claude job, is kept a day. The job summaries list the checks, the desktopTest results and the Claude usage. +Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-claude-test`, and the backend's logs; `desktop-app`, the distributable for manual tests and the Claude job, is kept 14 days. The job summaries list the checks, the desktopTest results and the Claude usage. ## Scripts diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index c79acfaa59..6bc7d673e4 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -22,7 +22,7 @@ The macOS jobs run on `macos-26`, the image with Xcode 26 and the iPhone 17 simu ## The Claude session -It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept a day) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. +It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept 14 days) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. Each session's final message ends with `CLAUDE-TEST: PASS`, or `CLAUDE-TEST: FAIL` and the failed cases; the step fails unless every session passes. From ec8884f465b6b758352045e3ffe25bf8c6fe6d56 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:28:09 +0000 Subject: [PATCH 02/19] core: ignore relay join requests in groups without relays (#7875) --- src/Simplex/Chat/Library/Subscriber.hs | 28 ++++++++++++++----------- tests/ChatTests/Groups.hs | 29 +++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index aa4a0205ad..d36bdc194e 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1688,22 +1688,26 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag User {userChatRelay} = user -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Maybe SignedMsg -> Profile -> MemberId -> MemberKey -> Maybe MemberId -> CM () - memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey@(MemberKey joiningKey) viaRelay = do + memberJoinRequestViaRelay invId chatVRange signedMsg_ p@Profile {displayName} joiningMemberId joiningMemberKey@(MemberKey joiningKey) viaRelay = do (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId case gLinkInfo_ of Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do g@(GIK gInfo _) <- withStore $ \db -> getGroupInfoKeys db cxt user groupId - existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db cxt user gInfo joiningMemberId) - let signed = memberSigned gInfo joiningMemberId joiningKey signedMsg_ - case existing_ of - Just rosterMem - -- a privileged memberId's key is owner-authoritative (the roster); the joiner must prove - -- possession of that exact key, otherwise this is an attempt to impersonate it - | isRosterRole (memberRole' rosterMem) -> - if verifyKey gInfo rosterMem signed - then acceptJoin g signed (Just rosterMem) (memberRole' rosterMem) - else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)" - _ -> acceptJoin g signed Nothing gLinkMemRole + if + | not (useRelays' gInfo) -> + messageWarning $ "memberJoinRequestViaRelay (group " <> groupName' gInfo <> "): ignored relay join request from " <> displayName <> " (group does not use relays)" + | otherwise -> do + existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db cxt user gInfo joiningMemberId) + let signed = memberSigned gInfo joiningMemberId joiningKey signedMsg_ + case existing_ of + Just rosterMem + -- a privileged memberId's key is owner-authoritative (the roster); the joiner must prove + -- possession of that exact key, otherwise this is an attempt to impersonate it + | isRosterRole (memberRole' rosterMem) -> + if verifyKey gInfo rosterMem signed + then acceptJoin g signed (Just rosterMem) (memberRole' rosterMem) + else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)" + _ -> acceptJoin g signed Nothing gLinkMemRole Nothing -> messageError "memberJoinRequestViaRelay: no group link info for relay link" where diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 01a504f13c..136e417ef2 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -39,7 +39,7 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), chatMsgToBody, mcSimple, msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMember, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), chatMsgToBody, mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -135,6 +135,7 @@ chatGroupTests = do it "demotion does not remove group link" testGroupLinkDemotedAdmin it "host profile received" testGroupLinkHostProfileReceived it "existing contact merged" testGroupLinkExistingContactMerged + it "ignore relay join request in group without relays" testGroupLinkIgnoreRelayJoinRequest describe "group links - member screening" $ do it "reject member - blocked name" testGLinkRejectBlockedName it "accept member - only host approval" testGLinkApproveMember @@ -3629,6 +3630,32 @@ testGroupLinkExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" +testGroupLinkIgnoreRelayJoinRequest :: HasCallStack => TestParams -> IO () +testGroupLinkIgnoreRelayJoinRequest ps = + withNewTestChatOpts ps (testOpts {coreOptions = testCoreOpts {logLevel = CLLWarning}}) "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + (gShortLink, _gLink) <- getGroupLinks alice "team" GRMember True + sendMemberJoinRequest bob gShortLink + alice <## "warning: memberJoinRequestViaRelay (group team): ignored relay join request from bob (group does not use relays)" + alice ##> "/ms team" + alice <## "alice (Alice): owner, you, created group" + +sendMemberJoinRequest :: TestCC -> String -> IO () +sendMemberJoinRequest cc groupLink = + withCCUser cc $ \user -> do + gLink :: ShortLinkContact <- either fail pure $ strDecode (B.pack groupLink) + (memberKey, _) <- atomically . C.generateKeyPair =<< C.newRandom + let joinGroup = do + (_, _, cReq) <- getShortLinkConnReq NRMInteractive user gLink + (connId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + dm <- encodeConnInfo $ XMember bobProfile (MemberId "member") (MemberKey memberKey) Nothing + withAgent $ \a -> joinConnection a NRMInteractive (aUserId user) connId True cReq dm PQSupportOff SMSubscribe + runExceptT joinGroup `runReaderT` chatController cc >>= either (fail . show) (\_ -> pure ()) + testGLinkRejectBlockedName :: HasCallStack => TestParams -> IO () testGLinkRejectBlockedName = testChatCfg2 cfg aliceProfile bobProfile $ From f7f42e70e5519fbe9e583723091100bf7386638a Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:12:01 +0000 Subject: [PATCH 03/19] ci: reduced build on pushes to master and stable in forks (#7882) * ci: reduced build on pushes to master and stable in forks * ci: test retry message only when retrying * ci: pull request changes against the current base * ci: deploy the website only from simplex-chat/simplex-chat --- .github/workflows/build.yml | 12 +++++------- .github/workflows/packages.yml | 8 +++----- .github/workflows/web.yml | 1 + scripts/ci/README.md | 4 +++- scripts/ci/run-tests.sh | 6 +++--- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cb6913671..1f28d05d74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,7 +54,7 @@ env: release: ${{ startsWith(github.ref, 'refs/tags/v') }} branch: ${{ github.base_ref || github.ref_name }} cache_o0: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') }} - release_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') && github.ref_name || '' }} + release_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') && github.repository == 'simplex-chat/simplex-chat' && github.ref_name || '' }} ZSTD_CLEVEL: 9 # This workflow uses custom actions (prepare-build and prepare-release) defined in: @@ -124,7 +124,7 @@ jobs: publish: ${{ steps.publish.outputs.nodejs_version || steps.publish.outputs.python_version }} packages: ${{ steps.changes.outputs.packages }} scopes: ${{ steps.changes.outputs.scopes }} - reduced: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == true }} + reduced: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == true || (github.event_name == 'push' && github.ref_type == 'branch' && github.repository != 'simplex-chat/simplex-chat') }} steps: - name: Reject a manual run on a tag if: github.event_name == 'workflow_dispatch' && github.ref_type == 'tag' @@ -141,14 +141,12 @@ jobs: id: changes shell: bash env: - BASE: ${{ github.event.pull_request.base.sha }} BASE_BRANCH: ${{ inputs.base }} GH_TOKEN: ${{ github.token }} run: | case "$GITHUB_EVENT_NAME" in pull_request) - git fetch -q --depth=1 origin "$BASE" - python3 scripts/ci/packages_release.py changes "$BASE" + python3 scripts/ci/packages_release.py changes HEAD^1 ;; workflow_dispatch) base=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_BRANCH...$GITHUB_REF_NAME" --jq .merge_base_commit.sha) @@ -157,7 +155,7 @@ jobs: ;; *) echo "haskell=true" - if [ "$GITHUB_REF_TYPE" = branch ]; then + if [ -n "$release_branch" ]; then echo "kotlin=true" echo "ios=true" python3 scripts/ci/packages_release.py scopes @@ -807,7 +805,7 @@ jobs: build-linux-push: name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] - if: github.event_name == 'push' + if: needs.maybe-release.outputs.reduced != 'true' && github.event_name == 'push' runs-on: ${{ matrix.runner }} permissions: contents: write diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index d40ef7d625..12fc5f5862 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -31,14 +31,12 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v6 + with: + fetch-depth: 2 - name: Build plan id: plan - env: - BASE: ${{ github.event.pull_request.base.sha }} - run: | - git fetch -q --depth=1 origin "$BASE" - python3 scripts/ci/packages_release.py build-plan --base "$BASE" >> "$GITHUB_OUTPUT" + run: python3 scripts/ci/packages_release.py build-plan --base HEAD^1 >> "$GITHUB_OUTPUT" build: name: "build ${{ matrix.platform }}" diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 5fbe8293bc..7628eb1f9a 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -33,6 +33,7 @@ jobs: ./website/web.sh - name: Deploy + if: github.repository == 'simplex-chat/simplex-chat' uses: simplex-chat/actions-gh-pages@v3 with: publish_dir: ./website/_site diff --git a/scripts/ci/README.md b/scripts/ci/README.md index a8cb147b01..e3096b71fe 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -4,7 +4,9 @@ Three workflows build the apps from a branch's own code and test them, only when ## Reduced build -GitHub runs the reduced build of `build.yml` on a dispatch (workflow_dispatch) and on each push to a draft pull request. Once the pull request is marked ready for review, GitHub runs the full matrix. +GitHub runs the reduced build of `build.yml` on a dispatch (workflow_dispatch), on each push to a draft pull request and on a push to master or stable in a fork. Once the pull request is marked ready for review, GitHub runs the full matrix, as on a push to master or stable in simplex-chat/simplex-chat. + +On a push to master or stable in a fork, the release steps and tests are skipped; build-linux saves the -O0 build caches, and on master the Docker image cache, that the fork's dispatches restore. On a draft pull request, the tests are selected by a fenced block with the info string `ci-tests` in the description, one hspec match pattern per line; the block is read at each push. `/` runs the whole suite; without a block, the reduced build runs no tests. diff --git a/scripts/ci/run-tests.sh b/scripts/ci/run-tests.sh index 327ff667cf..ef7c92112c 100755 --- a/scripts/ci/run-tests.sh +++ b/scripts/ci/run-tests.sh @@ -26,13 +26,13 @@ attempts=${ATTEMPTS:-1} while [ "$i" -le "$attempts" ]; do if "$@" "${args[@]}"; then break - else + elif [ "$i" -lt "$attempts" ]; then echo "Attempt $i failed, retrying..." - i=$((i + 1)) sleep 1 fi + i=$((i + 1)) done if [ "$i" -gt "$attempts" ]; then - echo "All $attempts attempts failed." + echo "Attempt $attempts failed." exit 1 fi From b72379c640f708452ca6a9143621c2d192038314 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:19:21 +0000 Subject: [PATCH 04/19] desktop: ask before replacing a file, do not suggest hidden file names (#7877) * desktop: ask before replacing a file, do not suggest hidden file names * desktop: test the file name suggested by the Save dialog --- .../moko-resources/base/strings.xml | 1 + .../simplex/common/platform/Files.desktop.kt | 7 +++++-- .../views/helpers/DefaultDialog.desktop.kt | 17 +++++++++++++++- .../simplex/app/SaveDialogFileNameTest.kt | 20 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SaveDialogFileNameTest.kt diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index a6c036b925..a86a84745a 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -697,6 +697,7 @@ File will be received when your contact completes uploading it. File will be received when your contact is online, please wait or check later! File saved + File already exists. Replace it? File not found Error saving file Formatted view diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index b779954efc..9720a78435 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -75,9 +75,10 @@ actual class FileChooserLauncher actual constructor() { ) res = simplexWindowState.openDialog.awaitResult(params) } else { - res = simplexWindowState.saveDialog.awaitResult(DialogParams(filename = input)) + val filename = saveDialogFileName(input) + res = simplexWindowState.saveDialog.awaitResult(DialogParams(filename = filename)) if (res != null && res.isDirectory) { - res = File(res, input) + res = File(res, filename) } } onResult(res?.toURI()) @@ -115,5 +116,7 @@ private fun fileFilterDescription(input: String): String = when(input) { else -> "" } +internal fun saveDialogFileName(name: String): String = name.trimStart('.').ifEmpty { name } + actual fun URI.inputStream(): InputStream? = toFile().inputStream() actual fun URI.outputStream(): OutputStream = toFile().outputStream() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index 4f26f1d5fe..7b5d0ae371 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -17,6 +17,7 @@ import java.awt.FileDialog import java.awt.event.ActionListener import java.io.File import javax.swing.JFileChooser +import javax.swing.JOptionPane import javax.swing.filechooser.FileFilter import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.plaf.basic.BasicFileChooserUI @@ -75,7 +76,21 @@ fun FrameWindowScope.FileDialogChooserMultiple( val scope = rememberCoroutineScope() DisposableEffect(Unit) { val job = scope.launch(Dispatchers.Main) { - val fileChooser = JFileChooser() + val fileChooser = object: JFileChooser() { + override fun approveSelection() { + if (!isLoad && selectedFile?.isFile == true) { + val answer = JOptionPane.showConfirmDialog( + this, + generalGetString(MR.strings.file_already_exists_replace_question), + dialogTitle, + JOptionPane.YES_NO_OPTION, + JOptionPane.WARNING_MESSAGE + ) + if (answer != JOptionPane.YES_OPTION) return + } + super.approveSelection() + } + } fileChooser.dialogTitle = title fileChooser.isMultiSelectionEnabled = allowMultiple && isLoad fileChooser.isAcceptAllFileFilterUsed = fileFilter == null diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SaveDialogFileNameTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SaveDialogFileNameTest.kt new file mode 100644 index 0000000000..21b3d2e341 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SaveDialogFileNameTest.kt @@ -0,0 +1,20 @@ +package chat.simplex.app + +import chat.simplex.common.platform.saveDialogFileName +import kotlin.test.Test +import kotlin.test.assertEquals + +class SaveDialogFileNameTest { + @Test + fun testLeadingDotsRemoved() { + assertEquals("bashrc", saveDialogFileName(".bashrc")) + assertEquals("hidden.txt", saveDialogFileName("..hidden.txt")) + } + + @Test + fun testOtherNamesUnchanged() { + assertEquals("report.pdf", saveDialogFileName("report.pdf")) + assertEquals("archive.tar.gz", saveDialogFileName("archive.tar.gz")) + assertEquals("...", saveDialogFileName("...")) + } +} From 91675ec5ab8d90574f92a1d5ab1a3aa5d74e85ed Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:46:01 +0000 Subject: [PATCH 05/19] docs: Swift functions inside types, extensions only where required (#7901) --- apps/ios/CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/ios/CLAUDE.md b/apps/ios/CLAUDE.md index 99ee350f47..c2722350fa 100644 --- a/apps/ios/CLAUDE.md +++ b/apps/ios/CLAUDE.md @@ -1 +1,12 @@ @README.md + +## Swift code + +- Functions and computed properties are declared in the body of the type they belong to, never at file scope or in an extension of a type declared in this project. +- A member of a SimpleXChat type is declared in that type when everything it uses is available in SimpleXChat. A member that depends on the app is a static member of the app type that uses it; when several app types use it, of the type that names its concept. +- An extension is written only where Swift has no other form: + - a SwiftUI environment value, which SwiftUI requires as a property of EnvironmentValues; + - a conformance, required by an API, of a type declared in another module. +- A view modifier is a ViewModifier type applied with `.modifier(_:)`. +- A custom alignment is a static constant of a non-generic type that uses it. +- A new member is declared in its type even when the file already extends that type. Existing extensions and file-scope functions are moved only in a task that concerns them. From 6bc1c692b5cd50ea1bf2a5bc2ab1dd739ae44152 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:09:16 +0000 Subject: [PATCH 06/19] docs: the Swift rule covers extension members, not file-scope functions (#7904) --- apps/ios/CLAUDE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/ios/CLAUDE.md b/apps/ios/CLAUDE.md index c2722350fa..a75cbe027a 100644 --- a/apps/ios/CLAUDE.md +++ b/apps/ios/CLAUDE.md @@ -2,11 +2,12 @@ ## Swift code -- Functions and computed properties are declared in the body of the type they belong to, never at file scope or in an extension of a type declared in this project. -- A member of a SimpleXChat type is declared in that type when everything it uses is available in SimpleXChat. A member that depends on the app is a static member of the app type that uses it; when several app types use it, of the type that names its concept. - An extension is written only where Swift has no other form: - a SwiftUI environment value, which SwiftUI requires as a property of EnvironmentValues; - a conformance, required by an API, of a type declared in another module. +- Members of a type declared in this project are declared in the body of that type, not in an extension. +- A member of a SimpleXChat type is declared in that type; a value it needs from the app is passed as a parameter. - A view modifier is a ViewModifier type applied with `.modifier(_:)`. - A custom alignment is a static constant of a non-generic type that uses it. -- A new member is declared in its type even when the file already extends that type. Existing extensions and file-scope functions are moved only in a task that concerns them. +- Functions and constants at file scope are kept at file scope. +- A new member is declared in its type even when the file already extends that type. Existing extensions are moved only in a task that concerns them. From d5827478b50c8c1554e192aa463644911de130c4 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:48:45 +0000 Subject: [PATCH 07/19] ci: reduced build skips the core for app test scripts and workflows (#7907) --- .github/workflows/build.yml | 6 ++++-- scripts/ci/README.md | 1 - scripts/ci/packages_release.py | 8 +++++--- scripts/ci/test_packages_release.py | 11 ++++++++++- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f28d05d74..0614b5439a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,10 +25,12 @@ on: - "simplex-chat.cabal" - "cabal.project" - "Dockerfile*" - - "scripts/ci/**" + - "scripts/ci/*.sh" - "scripts/ci/ios-stub-libs.sh" - "scripts/desktop/**" - - ".github/**" + - ".github/workflows/build.yml" + - ".github/actions/**" + - ".github/changelog_conf.json" - "packages/simplex-chat-nodejs/**" - "packages/simplex-chat-client/types/typescript/**" - "packages/simplex-chat-python/**" diff --git a/scripts/ci/README.md b/scripts/ci/README.md index e3096b71fe..61370815ae 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -112,7 +112,6 @@ The reduced build runs one Linux job, build-linux (ubuntu-22.04 x86_64), for eve - Globs: - scripts/ci/packages_release.py - scripts/ci/test_packages_release.py -- These paths are also in the core globs (scripts/ci/**). - CI, in build-linux: `scope-tests.sh ci-scripts` (step "Test changed scopes"). - Local: `python3 scripts/ci/test_packages_release.py`; Python 3.11 or newer is required for tomllib (packages_release.py). diff --git a/scripts/ci/packages_release.py b/scripts/ci/packages_release.py index 393fc85c5d..7b8d5db551 100644 --- a/scripts/ci/packages_release.py +++ b/scripts/ci/packages_release.py @@ -63,12 +63,14 @@ BUILD_CHANGES = { "simplex-chat.cabal", "cabal.project", "Dockerfile*", - "scripts/ci/**", + "scripts/ci/*.sh", ":!scripts/ci/ios-stub-libs.sh", "scripts/desktop/**", - ".github/**", + ".github/workflows/build.yml", + ".github/actions/**", + ".github/changelog_conf.json", ], - "kotlin": ["apps/multiplatform/**", "Dockerfile*", ".github/**"], + "kotlin": ["apps/multiplatform/**", "Dockerfile*", ".github/workflows/build.yml", ".github/actions/**"], "ios": ["apps/ios/**", "scripts/ci/ios-stub-libs.sh"], "packages": [ "packages/simplex-chat-nodejs/**", diff --git a/scripts/ci/test_packages_release.py b/scripts/ci/test_packages_release.py index fc1bde780e..72e4295a20 100644 --- a/scripts/ci/test_packages_release.py +++ b/scripts/ci/test_packages_release.py @@ -485,12 +485,21 @@ class ChangesTest(GitRepoTest): def test_other_ci_script_builds_the_core(self): self.assertEqual(self.builds("scripts/ci/prune-old-builds.sh"), {"haskell"}) + def test_app_test_scripts_build_nothing(self): + self.assertEqual(self.builds("scripts/ci/ios-sim/smoke-test.sh", "scripts/ci/desktop-linux/smoke-test.sh", "scripts/ci/android/ui", "scripts/ci/snapshots-compare.py", "scripts/ci/README.md"), set()) + def test_build_image_change_builds_the_core_and_kotlin(self): self.assertEqual(self.builds("Dockerfile.build"), {"haskell", "kotlin"}) def test_workflow_change_builds_the_core_and_kotlin(self): self.assertEqual(self.builds(".github/workflows/build.yml"), {"haskell", "kotlin"}) + def test_build_action_change_builds_the_core_and_kotlin(self): + self.assertEqual(self.builds(".github/actions/prepare-build/action.yml"), {"haskell", "kotlin"}) + + def test_other_workflow_change_builds_nothing(self): + self.assertEqual(self.builds(".github/workflows/ios-sim.yml", ".github/workflows/desktop-linux.yml", ".github/ISSUE_TEMPLATE/bug.md"), set()) + def test_unrelated_change_builds_nothing(self): self.assertEqual(self.builds("docs/guide.md", "packages/pkg/src.txt"), set()) @@ -502,7 +511,7 @@ class ChangesTest(GitRepoTest): self.assertEqual(self.values["scopes"], "badge-web") def test_release_script_change_tests_the_ci_scripts(self): - self.assertEqual(self.builds("scripts/ci/packages_release.py"), {"haskell"}) + self.assertEqual(self.builds("scripts/ci/packages_release.py"), set()) self.assertEqual(self.values["scopes"], "ci-scripts") From 0b4c798e50c34d6c6ea182324f5e16e787cea3bb Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:49:16 +0000 Subject: [PATCH 08/19] ci: render iOS previews in the simulator, compare with references (#7905) * ci: render iOS previews in the simulator, compare with references * docs: assets license covers app screenshots and test snapshot images * ci: references of the iOS preview snapshots --- .../References/ASSETS_LICENSE.md | 18 +++ ...hatInfoImage-dark-accessibility-medium.png | Bin 0 -> 3282 bytes ...InfoImage-dark-extra-extra-extra-large.png | Bin 0 -> 3281 bytes .../References/ChatInfoImage-dark-large.png | Bin 0 -> 3298 bytes ...atInfoImage-light-accessibility-medium.png | Bin 0 -> 2538 bytes ...nfoImage-light-extra-extra-extra-large.png | Bin 0 -> 2550 bytes .../References/ChatInfoImage-light-large.png | Bin 0 -> 2500 bytes .../ios/SimpleX Snapshots/SnapshotTests.swift | 104 ++++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 116 ++++++++++++++ .../xcschemes/SimpleX (iOS).xcscheme | 10 ++ assets/ASSETS_LICENSE.md | 4 +- scripts/ci/ios-sim/README.md | 18 ++- scripts/ci/ios-sim/smoke-test.sh | 7 + scripts/ci/ios-sim/snapshots-remote.py | 146 +++++++++++++++++ scripts/ci/ios-sim/snapshots.sh | 39 +++++ scripts/ci/snapshots-compare.py | 150 ++++++++++++++++++ 16 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-accessibility-medium.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-extra-extra-extra-large.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-accessibility-medium.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-extra-extra-extra-large.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png create mode 100644 apps/ios/SimpleX Snapshots/SnapshotTests.swift create mode 100755 scripts/ci/ios-sim/snapshots-remote.py create mode 100755 scripts/ci/ios-sim/snapshots.sh create mode 100755 scripts/ci/snapshots-compare.py diff --git a/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md b/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md new file mode 100644 index 0000000000..3ce290fc5c --- /dev/null +++ b/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md @@ -0,0 +1,18 @@ +# Application Graphic Assets License + +Copyright (C) 2026 SimpleX Chat Ltd. All rights reserved. + +The graphic assets in this folder, subfolders and in other folders of this repository - including illustrations, images, icons, visual designs, and screenshots of the applications, such as the snapshot images of the tests - are proprietary and are not licensed under the AGPLv3 that covers the application source code. + +## Permitted use + +- Unmodified application distribution. You may use these assets as part of the SimpleX Chat application, provided the application is not modified in any way. The snapshot images of the tests are not needed to build the application and are not part of it. +- Publications with permission. You may use screenshots containing these assets in publications with prior written permission from SimpleX Chat Ltd. + +## Not permitted + +All other use, including modification, redistribution, or incorporation into other works, is not permitted without prior written permission from SimpleX Chat Ltd. + +## Contact + +To request permission, contact chat@simplex.chat. diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-accessibility-medium.png new file mode 100644 index 0000000000000000000000000000000000000000..e742c5dea63990283be55cd6bf660c1b5b276ae9 GIT binary patch literal 3282 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 literal 0 HcmV?d00001 diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000000000000000000000000000000000..b856951159b8e242f523b6a00ba2e7dbfe64f93b GIT binary patch literal 3281 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 bEv8*H)b5|9Ax94WJV4BhiAMMFZt?#CSr4G3 literal 0 HcmV?d00001 diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png new file mode 100644 index 0000000000000000000000000000000000000000..9d4f92bbacd167ab2c54af5e5a7d2af21cd3ab96 GIT binary patch literal 3298 zcmYLL2|U!>7oQoU#+E^LHMSYXUi42Q)6CcFJBzZ_OvWfiMxBtSX&st5`xDJi3)qeDYOk&%&` zo12UkFDEA_5~-oC?my?xpV!d%lTN2s$xo7@`ef)+==j9MGb>LWowuExls>nnh6WPQ z0s{ksgPU7he|dPTw{^|Uxo0;wH`_WmAP|VkvNCF1+~)dv^8KPyY{?+Dd1<(i5W(L5 zlBy~T@_tB2wB_YT;uhMkZU+PeP%GMJW@nFu3t?*p226AMM{}5qZoy$39G+fY6QiTy zmJ%@$ydkBQWr9w7TU*d04R!VT)zw5Qbx@Uk2MOjC2XjFW4-Z8|M0R#}8IzML+ZO!N zIf6d&l;MR14;vdB42B#NUd6%ulbhnnX=ydh&72{=j~y>8ilQ=2al}{b931C3W9@)- z^)Wf*`uc#Npo`MS{513m_C@LXV^aG=!^5$iCL#D{EJPXuS%hM-*rK8$IPvJr#Hs>6 zuAjqcczRm0#%@uYqY_x91Wrjvu(Pv^cDrDdTFW@T|8r$!<>AAV=>^}_n12y02}dAW zU%ZG8i2n1%yGE@uGFloM-JP9#YHGRSfJ5O|q0Y|EKq`I}xS`o8mcvt1PUhx=#o!Gn zQ(YYnV`qPDgfsq_EvnVE8k5NZp?cv?u*^vOZ+3laD?2+|E%!Z^9btqVh@ab#$RC;N z?2A9q%FAs)hKj))FPHN4MVPdUgsSQ8ZhA%r9YJNL zZ;}nbM_*rGm-&ruVpVbR@ulX;wY~2!bWMN1NtDUF0<0-{1}h<^ggb(z|4M25btRuh zGp1Qzzka>2u<*tnKR-Wu+Y8zq?a1XvP_!S3h3dC$vx!r!ZEQLsSXyp9HvR!1?1ieF ztZYL|3%{`rPZ1E|5S{@K1WQmVh%GZ0WOyX;`-gJt`6LjR|Lv_yVSau!3eR!By(%p= zsJyr%1LpNP2kE?}sHn(>RlFVaZWZC+;Lw7v%5rjb)tU8map_<%94htx5=29+t*xJ{ zU63s5?lu(WIH{M;YD;KxFgKU=!S8h39YY?1%)0K1>$G2l{YTcP!~kRd^Udq#d0h+uPe`SIE~~T%M%JRF;?X zHNES;_V1*Ni_7y*>L=-6rr&2}iTrWf>Fwe2GK~~^YuX_W2l$R@Ij(?*OV>R#ki69^ z>NGMqD4C`xy-{uONHiqAtyqyQGadA5kjhQzx?@B6psS~spt(OVkZfG1@?~?=PvFsc zNM4<^qZOn?AnCnfPuW><-I~$c0&zM!>x8dV=cow&V%XNh2zY5}>Dl30NieUspHPz`;q9@KdL>w6wg;>)<@Z`S!@;O#y57jNN;FS4>DqkWK93 zSuim$G`ypkT|p(oNaEnT#Ngn=o(yGF2m;q=-*Vr7b-eM^#d{-Z>?cn2_wa!lm1{hE5C)-uXWLc6@w1 zJ6kM@R#4z{^XB|mz4xjJEa%ke)A!K%vs2vWANjzQl0FAJUiBo5z!c6xW$!CJDJ)dr zL`fFsp-sH#aRpVj#e=;)(ootUQ3nJ4DPwAD8{a%K{~BTcEvCGzYzqdAHa~3pd33;o zmK+aju(W2Jj_d2~HC7IB%&LP?$iNX4C#XFBf`a==kap&Btv*?neK+H*Gj&}!3^hOc z0PtNo+;h0%4{MpnQqV_LWRt%B{=|fYmf-#8fbLpeVP$2K9JT`~G7xVnXh&<2$w;im zWM%GNQtY#Z;z5wEuC4}b#eE!6LL4K@lZqd-^6*%g>WFo5cCPbgm@J|_3d4>R#7rmZ zyhJkyqu;q*JU!)0c^e$>(kVt+Ixf1s{r#UK`G6eXf;_#f8G0y`)MdbW+0!QwB_p#o z-srbA|KXWU{Y$E{nwpxteB7sx4<4`?Usww+ux4Rl0fWI6i6uLd;*eb3FEd?fCfx!` zyrsG4&Yhc@nwmu~kJij>eCweIy5;!p4yg0C_a@160K(u0ONP&laaip5#g{Z7NHi=Qq@T0rrR*LX2W6q*#*PFXmgnyRYb@<{c!T%kuA@UsVn zcc$j6afx|(j600T*PV_hozbNB(DjFlTIYEe6Q&vBDm>YB-|p4jw2 zsE7{KN;o_s0&*y_R46&Lv9Xb!{ceAA&g=1Q{Io$8p(|*B!1OEzbf)OhdGC#%HLj(U zh^lJF+34EkMjw{z6}A%HZjE5I@@LPyj(%+KB=`gH1u!KA+&)HzhN20CD3;i`K6fi| z8|uK&_R=tqVO*0Q?OA<&#_elb%ss{9gLjg!GP+Kn{@D35&* z&k*RtDs_=46pD&Fp@oUdNDP4>)b0A!uO)C3rL7${@zQ_Qwao^zjP5kTe#Uy-w6e9$ z4x*=?18iSKRRJi}OFGRn_q;*-u&a~3qNXP8>dywxSu`5WjkqM8>6)`kww27dN+!rc#8O zr>%De{@SzF5cl`FIayiH?c1lq%yfEkwZrVq%);rlftzmby*z6&M~j1nM(P#-MBk?4 z>IRfIYP!3-NljzIp0}hRK$MMm_4V}~y(lz7F-ly`GC}-DXD96EbyruUSy_C1d~K~- zZTxHSwO7&MFRNX-7_ajM?dESP5@_%X`v4?Aefos-#tH(#$G0IN`04$dLN*1{lA{zs z2(iILpZofQFf-Riy(zMsOoCBo+KU_3QQmj&)|Qma*JoraUBM?XgBu6>P@(GHU*%pp znqoJ_JDY2 z8hju5xnHmSoB7WD|G9JK%$zglp6|w7H`C`tpb-E7a2gumECB%W z669D`DEP$lP04})@we310ZN91*T6xftBs+XsVN`_a#jEi;RRqPDqsg7XaN3S4gf|F zq5pA9h|Dh?&?fmV!1PPU4#bmY01CMJ7s7HN|F@U}{Z||EG6(iA2e2p0;KGKVL16PS zu=NK3_ERSU0WvaC;7j4VICBDsCr+F+aZqG_p%?%#nSY)E#m`03Ph;Uo#_;q+f(re=A@B6T3eergD26kb)Y1SbKmG4rrJ)%ime?v*;<= z;NoMBXme4vr4m^)gnrLa5%;{yMHp{gdk{{nwQ6#_a6KmEk{RFWiav1;-@T|LVqFkh z?L=tVaxj8m0i@U}@MBEVxvhc11^rK)hruD&Ub8z|$LG}BN!{a0`2dg#m;KmWjV=@E z1AW-#s7GG!k>^SrZV~z$n3ueQUPD~F%(b#s$NIy*!5uNaGn+J%5JGnybEM+RsrlZo zz7=01=8dTXhy#Ra*-imx_c@~PvCX6(7A`6uYAqzlfdz^$cu%;iSXQl<+wS7Z6$isu z(Bk^JTT!HJ;=Xc@RCEAFSoDEuO_PV#TY9(-o@!C1rW2h$Za zl9p*6s@Xxdm7wRoQh12vTzWbDuv=&AKAZhkek&CkeMhxg?O>>_%#L7mNR8PwKG`Xg zd9rkRzqQO@QYm`M^3gk+T*vM zSKDyd-nP<4A|fr?m~n4bQ9SC3e5$q)V59~kkJ0luGRjTDQ7?&bN%O&jq^F0<)B>|@ z(5;v1j2*}Vz0>7NiVJbI+pSXRr$ey{V>&xYU)2({|9gFuas2Scy-yGZ8!3k0y9jx! zCMdwK)_Zq#k6gAqxtEr^cyEfgI+XQV#-}Pl+`v#475_!e%=@67=qF9ZhzG2CQq;=u z?Kt{)$(Y8(pBp~q168VWg)U$sopm-^)S8^H9+7p?}`RcAcOWxki z=mPgsefOhFD6v-=ZlC;G{ST3=sRhC$cY{WEhfkkdDd2pQ(QlhixI^}7n~lhsh3G$C zDRpPQ=n&9>f5dj}8dXr=hn9^YE{%=$_DG8L9H@lM2rsTiU1iFeaOlr;?=_C1rlN|T zduP<}z%Y9g16che8EBxEE)w$HwLT|Ir<< z@>}-GFxNGjH0nr9wZJ*Cha;Vp4+Quk-wr6LlKP$2mRyd&igbKD^lbnSmWi^HGO?NE zVMV!!e13S`QmOAzJ#Dv1H@<2<%M}s&LM=^VX*t45e9Cj~;;^S0W$u>HiKb)EMuGvZ`TIxJqn{E^Gr$`{2iyXmHF=P#Szq3=RSvS63 zeXi0S#ojPG)RZr$p#+bl#&E8}XM;jQTAG?TbsDJ8`^YW&P<3 zdM8sskvfD{6~C(aHGM*WrYxp~{jS(#P1Y&+*cv67Qe7{S%5(YDaj;QAfEj|-UL%3X zaVy;?XnUFE`W=I|)7#5S^OOiH`_f_oit}pTM--_N&mrP+NfHcCJhOZ(<;L(SPF?Xe z1|!+@O|eh~PyDQXq|TVK7#6d9rbb3f31LP5?8u}bZ-U>z@#yI7fD!1sQ@rp9Vap!} z$M}$0qoy?Qe|8)_Z@c-A1pz?lBTpy9tu#aQ zT`*VIY<8LI7Q=8$#Q5RUTe+fNP<6ba5z=V;f9__S9g?R#KbvOCDQ&GaX(SiOpAZ() z70k*Wl(zs|Oz=Xg^d*w#e=1g>pc+%9;Fnu!o*OvDIxQ`LjH$|wwvF#7loK1iKL_Eh zL_9lsUa#oMDNFeLtY>V0lv~N76@PWc13^U(Kr@_(g-LThEm2fbLad^8HYGV%y}8UB z`iODd_cSH!8R~w^6s)DEO+V|!k6LQI^L4FBbcam*G>e+pgLZf%Poi&HS&tIQTnSM* zl@*)7O1bQMc4TC_!1_0TB`{T;iZuCVaC)>xo7a7U?i&dI%L^Bfda1E8@!MlcH=eiDnyfcC&v#8$%ciq2$(Hmj%Mw`9 zp@>;#9ZjlR1dUmU<&8)_i~=u)FR!rA7Lq5mN526xG}b=TV;$BjL&UxX=rSDCUF_R$ z9Q!ijp8Ey)^>^UWEn29%dvZU`6uPw0bKARr<%L$)1*{_Vs38Lqm2c zvL?a|-<5`ICk$Dle1G59{l0Ua_xyh6oZorRdCzm6_c_t#rUtxRC%6Cr;59NtTL1tg z0pwboEMTiAJT4Cc#LL1!2dMmjSOh!aw``4wCMLickaGf1h#eriMZ*rXPx;4tmc?{(#k~aS)*7 z@b`V{zT<;2uEHtjC}RFsaE~YRYyMOk2r+|p2m7ijsLfA`jrk&kZpI&GJL?5vODwjz zIC0}GV*UM6`Y-mr?Pupq zk}l2#^KII`&7LxAQ?&*|AKBP=8BIW@6|~E>;{4KjZ%CDFJcojzVhXW%7zw#CKvb$bFu)6e_ z*vqy$F|Ir>wp8h!#L7>9iOddR#I;-A;$1CLI&2b~J-l%J_cz=5TttNT2}F`+w4Ybn zDB~=+`;VI3)yAy7&c%H~U&GbADNmhO3s^eF3S=5GH-482R?_XVDKFRj=ybYFEy}is zr?10?VtT0Myg7qxht%y37GIed`m4?`-}f_Y*x)sCy1u32<){z;#Oi!s-<&#|W90lK z(Ja@1hWPGKJGrrXU!FvriX0P26_;jrC({`#=_)4t@QQnDI+q>GxE)?S2{|Bds`3(t zlC7+9w7Hc?lyRd?WwXiNy;p*DjSPc6xlDSfMEN;8SDV$0yYhq6YA{QB>6w6(r9&{i z9UijH{GfsPqV_0XtY(dS{!4{NZ}V^}jp(}l&@4dk6(TZ0tc&fmZnMApt#me&xfeT| zfcM(dN{1`!ySuO3+78R63A;T#J^T9s4cv=?{{Gmiou5Bj@g@Gs{j34p8BYr>zlIbQ z6>&NLILQ_KVQ{do$J@`(u18Twn!#Y?1r{zfsPJ>*xN#rFjpz6F_P%^+kHEXQxKveD zWq7x>wAkbEBj@{gC=D5-i!^8eijG~M4my89!;?(5eX7L$C@wDU-u?T}G)FLsKEA%b zfq^TXinsTfOjWD=WK*OB$-CyG^rvu{I60$r!XVkpOZRIucf5dOn#fsV9w$2a{xt() zW#N}ou)5H*H?#I6x}M31yp~jHDxX}2Gcs9625>K&z)fRXf}W4TMYJJjbB(1r!g`kf zFvY*s1t+*#0;ye12pLn|vV16MQMB5J5%%=A|2j+lj6ttAhA1vF6Vm>6f2@+eTeTUk zZ@>(GDeOwc(y=!TsTjFboz_gm!P;Q#$<_(KS-1LkxjCOD!U?e=LGk@4@K`KLHgL=O z&$1^dC_(Er8jY5D(t=a}n~eyUz{jC(vz3(v_6MQ_r$@bt-_OJJOc0WJ9$?g+c4Zfn zLxowS-Oc#~E?H_qgC)A_tqXO_7$;aY->C&XUoMbkWMIT8%BvN6^rgF<26NnKuIgLu zgd2+%tI;sDcDv^);$fUI$|TDVMPDMld0%)ZdhhYsU_*e-?67SR0ZCCId@%;3wQmG z%81F8ks1X2Mpb1cb^$A!Sh4VtQn>%gJGuCw%M}UI>dJ7xlkN8VQJC|z77(6LMxV}} zEUSy$MDjM3TGJ;+pQrUG$1RcDblCYEuAYZzm_K{I;=~?-#U)V&{zjH$b+dMiq2u`S z*^PnN$M|rhG8Mag5InqM46qiHZw@B-N=zC258C zqKsk#p4f8Foc8huC7?$wQ#?eE+j0BJ>By4N$uTN1 zKNqJJo*bY#>VjY~k{6e>s;>SlfF=+5rJWin$Ag#G(C9VPA*WA$w+Fj)OkW)J{W~Ax zPW7O*X+c1cj^eqj&~iPktJ#V0I%G=&+0MD?ORShGk&F>N(|j&oG6rRwR^F+l*$Nk` z3;Z!2q1GYA+#r1)4#02=5R0c1Loy9({6=DLHvhpOqfLF49XrtW7VIS2S@lhHI zA@WTO*-P;)e}2i9*DY{bUX6U~G49$rG literal 0 HcmV?d00001 diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png new file mode 100644 index 0000000000000000000000000000000000000000..7a62931eb3514c14e12a79bb1f9a332772aeff63 GIT binary patch literal 2500 zcmYLJ2|QH$7r!%1%nXffYy+sKk_8tX_#_N55L*v1lNdyiu5YZ{E5nQV#i zGV(}5Pf=lbQt`@AJRa|QpWgrfyPtEv=id80=l;&`d_U)|z3l~Fgg62K0A5QAGb{i= zazXz*Hx#^M(65z2fP`Z&m;g=BC04Y0r`K%3g~}qNNxqke?D;jFbpPP`4I$o zsD*1d03Zbq4FVLEh=MOA0?h1iARZQRxTHZ-`hijaz^S8~NW2Xqr=^)OE(-Fw06{vQmbDG;vt8;stlfNLi@e5?5 zbim&COmSq-Tbx^-Yra9{?3lBd>aFl3QxD?Hhp@p|YPu zg##RWx$etP-0Znb^HJe#CbgjB;W#+Z-uC-#Oi}NKI zy5qeDVh<1?}<*N-?w8 zkfYrnN)Me3{uaI0^PphLx6ExCY%}!_!OaNvwtc3WH`}|&!odgMjlFxfF8EGdx6;Ro zcVPThS-B$qLO*6EZzOwk+G{X{smE3fJJ-1>+CblOFq)|!ckdk#pclMxS98<7)igOb zN28bZ5gMoJtO-=x_OCbaQJQ|fY3!P^9qF9%0u8e3PlhfK!l!|x{U&B1%62gOtk-X1 z<@-&#xilZ}SlSdEtj-uOp-Z;m+NwB{eTphI7tPYaGOCD$yKUf&#I zwW=A`+_L3fm2(dhY3h4~j$Yr{XxmAV{`{pR<8zjh*>-#IOH`6cw~UQBlF@S)b*7_ryW60%>>g4XgLDZkHj+MpL{{+#ci(+V@Z zqj~rArCkNJWw`C_M%+NL^MM9977h;jVBar;^Puu@9;}swT>p5I$Nc+2!pgcQ&(P32 z67RYJ_93^AQTr>K(m9G0UkH}jN|nBHep&%wshju0yY&Bu!=0)D@QQ!838N|YP ztYJc2oB>kf2r(w6=gE^tUcw!J@9ljqC@Y$!fjOJ5_xPIoK5{wHU@tHa<8r1>`?e=u zlF-o5FmvI{a^T@I@r-wS<8y@eyC0a^uv{!KxPbPwR*moD=Gilad&XM2b(Q?`^?FEd z{B+kSmQW=#C_S8FRcd0ty|eTBB_h|c7;N^NbH4S>&wRZU{e$&MY9Vq(hOjNTApIk| zuyUw@uN8YO1O31_KdJ!fx09(IZz^YjixlRg*M}FjG6s=#vXwLQ$;u7nWE0_C-R(hT zk%08KsU#km2BuiC(PAKYG>*m0hOM&x!;G+-_F$zqS8hg0nO zV3dBL+mCvb(=7P;2XC@Ll0H7<_*gP5MGfUq;E`5B3YxM=6G^_qYVxiN%`%+CTZ!wm zkXn}e;fAG_&A zJ*;islqm`2;)|t52B>h`^=Xb*;Lc_0eK_tD@wN-BZRKc@`Q!GbD)svs2U(STUiYdd z`%kdNTQ%>KM>oKJj?DIE^UFBrs$p?xhPn#6;g9}(PXX`S4&2{`{9MJl40IVzF^1a3 z{25jafr<5#DlbAuv~-^$9*gvL=FIX7Naqc&=)E{yoXV+vC`Mhfo86ss&cM3oj0}j=rhDFFuDbH6bIXB?*dYp7OL_d)TJ)xjRgw-o!X#a!Deb-dYK9HvcUFSi zfLLA=qU{{-}+IMWBLhHU&;n|sNlpT%g$3wLLNRgs`=OrL(HRt(tNYXn<*kZ z#k#c*CEXZC6F028ICSNj^;8&v^U+G@VFYUL*sNHN(p_F-t6Akd=?SFj_Z%BafXs#!|m [SnapshotScene] { + [ + SnapshotScene(name: "ChatInfoImage", view: AnyView(ChatInfoImage_Previews.previews)) + ] +} + +private func snapshotTheme(_ dark: Bool) -> ThemeManager.ActiveTheme { + let base: DefaultTheme = dark ? .DARK : .LIGHT + return ThemeManager.ActiveTheme( + name: base.themeName, + base: base, + colors: (dark ? DarkColorPalette : LightColorPalette).clone(), + appColors: (dark ? DarkColorPaletteApp : LightColorPaletteApp).clone(), + wallpaper: AppWallpaper(background: nil, tint: nil, type: .empty) + ) +} + +@MainActor +private func renderScene(_ scene: SnapshotScene, _ theme: AppTheme, dark: Bool, in windowScene: UIWindowScene) throws -> UIImage { + let screen = windowScene.screen + let width = screen.bounds.width + let root = scene.view + .environmentObject(ChatModel.shared) + .environmentObject(theme) + .frame(width: width, height: scene.height) + let host = UIHostingController(rootView: AnyView(root)) + if #available(iOS 16.4, *) { + host.safeAreaRegions = [] + } + let window = UIWindow(windowScene: windowScene) + window.overrideUserInterfaceStyle = dark ? .dark : .light + window.rootViewController = host + window.frame = CGRect(x: 0, y: 0, width: width, height: scene.height ?? screen.bounds.height) + window.isHidden = false + settle() + if scene.height == nil { + for _ in 0..<2 { + let fit = host.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + window.frame = CGRect(x: 0, y: 0, width: width, height: ceil(fit.height)) + settle() + } + } + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.preferredRange = .standard + format.opaque = true + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in + _ = window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + window.isHidden = true + window.rootViewController = nil + return image +} + +private func settle() { + RunLoop.main.run(until: Date().addingTimeInterval(0.6)) +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a3c29d98df..35557ebfbc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -268,6 +268,7 @@ E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */; }; E5A0B0012F960000AAAA0001 /* YourNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A0B0022F960000AAAA0001 /* YourNetwork.swift */; }; E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */; }; + E5AEC0C22F9C000100270665 /* SnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0C12F9C000100270665 /* SnapshotTests.swift */; }; E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; @@ -285,6 +286,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + E5AEC0C92F9C000100270665 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CA059C9279559F40002BEB4; + remoteInfo = "SimpleX (iOS)"; + }; 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; @@ -662,6 +670,8 @@ E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = ""; }; E5A0B0022F960000AAAA0001 /* YourNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YourNetwork.swift; sourceTree = ""; }; E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; + E5AEC0C12F9C000100270665 /* SnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTests.swift; sourceTree = ""; }; + E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SimpleX Snapshots.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = ""; }; E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = ""; }; @@ -752,6 +762,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C62F9C000100270665 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD422818589900503DA2 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -939,6 +956,7 @@ 5CDCAD462818589900503DA2 /* SimpleX NSE */, CEE723A82C3BD3D70009AE93 /* SimpleX SE */, 5CA059DA279559F40002BEB4 /* Tests iOS */, + E5AEC0C42F9C000100270665 /* SimpleX Snapshots */, 5CE2BA692845308900EC33A6 /* SimpleXChat */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, @@ -968,6 +986,7 @@ children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, + E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */, 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */, 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */, CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */, @@ -984,6 +1003,14 @@ path = "Tests iOS"; sourceTree = ""; }; + E5AEC0C42F9C000100270665 /* SimpleX Snapshots */ = { + isa = PBXGroup; + children = ( + E5AEC0C12F9C000100270665 /* SnapshotTests.swift */, + ); + path = "SimpleX Snapshots"; + sourceTree = ""; + }; 5CA8D01B2AD9B076001FD661 /* RemoteAccess */ = { isa = PBXGroup; children = ( @@ -1364,6 +1391,24 @@ productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + E5AEC0C82F9C000100270665 /* SimpleX Snapshots */ = { + isa = PBXNativeTarget; + buildConfigurationList = E5AEC0CB2F9C000100270665 /* Build configuration list for PBXNativeTarget "SimpleX Snapshots" */; + buildPhases = ( + E5AEC0C52F9C000100270665 /* Sources */, + E5AEC0C62F9C000100270665 /* Frameworks */, + E5AEC0C72F9C000100270665 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E5AEC0CA2F9C000100270665 /* PBXTargetDependency */, + ); + name = "SimpleX Snapshots"; + productName = "SimpleX Snapshots"; + productReference = E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 5CDCAD442818589900503DA2 /* SimpleX NSE */ = { isa = PBXNativeTarget; buildConfigurationList = 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */; @@ -1451,6 +1496,10 @@ CEE723A62C3BD3D70009AE93 = { CreatedOnToolsVersion = 15.4; }; + E5AEC0C82F9C000100270665 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 5CA059C9279559F40002BEB4; + }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -1493,6 +1542,7 @@ targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, + E5AEC0C82F9C000100270665 /* SimpleX Snapshots */, 5CDCAD442818589900503DA2 /* SimpleX NSE */, CEE723A62C3BD3D70009AE93 /* SimpleX SE */, 5CE2BA672845308900EC33A6 /* SimpleXChat */, @@ -1521,6 +1571,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C72F9C000100270665 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD432818589900503DA2 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1793,6 +1850,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C52F9C000100270665 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E5AEC0C22F9C000100270665 /* SnapshotTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD412818589900503DA2 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1846,6 +1911,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + E5AEC0CA2F9C000100270665 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; + targetProxy = E5AEC0C92F9C000100270665 /* PBXContainerItemProxy */; + }; 5CA059D9279559F40002BEB4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; @@ -2285,6 +2355,43 @@ }; name = Release; }; + E5AEC0CC2F9C000100270665 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.snapshots"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SimpleX.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SimpleX"; + }; + name = Debug; + }; + E5AEC0CD2F9C000100270665 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.snapshots"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SimpleX.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SimpleX"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 5CDCAD4E2818589900503DA2 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2564,6 +2671,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E5AEC0CB2F9C000100270665 /* Build configuration list for PBXNativeTarget "SimpleX Snapshots" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E5AEC0CC2F9C000100270665 /* Debug */, + E5AEC0CD2F9C000100270665 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme index 6a1d4192e6..6815864bf4 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:SimpleX.xcodeproj"> + + + + --log` and `gh run download -n `. +Results: the job logs, the Claude job's summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log, the snapshots in `snapshots/`), `ios-sim-backend-logs` and `ios-sim-claude-test` (the Claude job's smoke test in `smoke-test/`, and per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. ## How long it takes, what it costs - `plan` (ubuntu-24.04, 10 s) reads what the run asks for, names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change. - `core` (macOS, 30–45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`). - `backend` (macOS, 20–45 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`. -- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min, the backend start about 10 min when asked. +- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min with the snapshots a few minutes more, the backend start about 10 min when asked. - `claude` (macOS, only when asked, after `app` passed): a new simulator with the app job's build and the smoke test, about 7 min, then the sessions (the smoke plan's took 2.6 min). A run with reused builds and no extras takes about 25 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`: builds are reused only as the artifacts above, and a core or backend rebuild compiles its dependencies too. @@ -38,6 +38,20 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; `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. +## Snapshots: `snapshots.sh` + +At the end of the app job's smoke test, `smoke-test.sh` runs `snapshots.sh /snapshots` when the app it tested lies in the derived data of a build (`…/Build/Products/Debug-iphonesimulator`); the Claude job, which installs the app from a tarball, skips it. + +`snapshots.sh` builds the unit-test target `SimpleX Snapshots` (`apps/ios/SimpleX Snapshots/SnapshotTests.swift`, hosted by the app) with `xcodebuild build-for-testing`, then runs it in the smoke test's simulator three times, after `xcrun simctl ui content_size` `large`, `extra-extra-extra-large` and `accessibility-medium`, and sets `large` back. The test hosts each preview listed in `previewScenes()` (the `previews` of a `PreviewProvider`) in a `UIHostingController` in a window of the screen's width, in the light and dark themes without a wallpaper, and draws it with `drawHierarchy` at scale 1, one pixel per point. + +The test runs in UTC (`NSTimeZone.default`) and en_US (`-testLanguage en -testRegion US`), so the renders do not depend on the runner's time zone or locale; a preview that shows the current date or time differs between runs. + +The images go to `snapshots/` of the artifact `ios-sim-smoke-test` as 8-bit sRGB PNG files without alpha (`preferredRange = .standard`, `opaque`), named `--.png`, with `index-.json` listing each file's scene, theme, size, pixel width and height, scale and the content size category the app saw. + +When `apps/ios/SimpleX Snapshots/References` exists, `snapshots.sh` then runs `scripts/ci/snapshots-compare.py `. It compares each render listed in `index-*.json` with the reference of the same name: a render passes when its file is identical to the reference, or when no channel of any pixel differs from it by more than 8 of 255. A missing or extra render, a changed size or a pixel beyond the tolerance fails: the script writes `compare.json` (each file's result and the failed files) next to the renders and, per failed file, `snapshots-diff/` with the reference, the render and the changed pixels in red side by side, lists the failed files in the job summary and exits with 1, which fails the smoke test step. + +`snapshots-remote.py record|check [branch]` renders the snapshots of a pushed branch (the current one by default) on GitHub's runner: it starts `ios-sim.yml` on the branch, waits for the run and downloads `snapshots/` and `snapshots-diff/` of its `ios-sim-smoke-test` to a temporary folder. `record` replaces the PNG files in `apps/ios/SimpleX Snapshots/References` with the run's renders and prints the folder's `git status`, for review and commit; `check` prints the files that failed the run's comparison and the folder of their diff images, and exits with 1 when any failed. It takes the token from `GITHUB_TOKEN` or `GH_TOKEN`, otherwise from `gh auth token`; the token must be allowed to start workflows. + ## The test backend `backend.sh start ` (with the `backend` input) starts, on the runner, against the preset SMP and XFTP servers: diff --git a/scripts/ci/ios-sim/smoke-test.sh b/scripts/ci/ios-sim/smoke-test.sh index e9e245a1ab..9df68585cb 100755 --- a/scripts/ci/ios-sim/smoke-test.sh +++ b/scripts/ci/ios-sim/smoke-test.sh @@ -2,6 +2,8 @@ # 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. +# For an app in the derived data of a build, it then renders the previews of the SimpleX Snapshots tests to the snapshots +# folder of the output dir (snapshots.sh). # Usage: scripts/ci/ios-sim/smoke-test.sh # 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. @@ -122,3 +124,8 @@ expect_chat_list reachable 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" + +products=$(dirname "$app") +if [[ "$products" == */Build/Products/Debug-iphonesimulator ]]; then + "$(dirname "$0")/snapshots.sh" "${products%/Build/Products/Debug-iphonesimulator}" "$UI_OUT/snapshots" +fi diff --git a/scripts/ci/ios-sim/snapshots-remote.py b/scripts/ci/ios-sim/snapshots-remote.py new file mode 100755 index 0000000000..a3ae83db29 --- /dev/null +++ b/scripts/ci/ios-sim/snapshots-remote.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +WORKFLOW = "ios-sim.yml" +ARTIFACT = "ios-sim-smoke-test" +REFERENCES = "apps/ios/SimpleX Snapshots/References" + + +def git(*args): + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout.strip() + + +def github_token(): + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + return token + try: + return subprocess.run(["gh", "auth", "token"], check=True, capture_output=True, text=True).stdout.strip() + except (OSError, subprocess.CalledProcessError): + sys.exit("set GITHUB_TOKEN, or log in with gh") + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args): + return None + + +class GitHub: + def __init__(self): + self.repo = git("remote", "get-url", "origin").split("github.com", 1)[1].lstrip(":/").removesuffix(".git") + self.token = github_token() + + def request(self, url, body=None): + return urllib.request.Request( + url if url.startswith("https://") else f"https://api.github.com/repos/{self.repo}{url}", + data=None if body is None else json.dumps(body).encode(), + headers={"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json"}, + ) + + def get(self, path): + with urllib.request.urlopen(self.request(path)) as r: + return json.loads(r.read()) + + def post(self, path, body): + urllib.request.urlopen(self.request(path, body)).close() + + def download(self, url): + try: + urllib.request.build_opener(NoRedirect).open(self.request(url)).close() + sys.exit(f"no redirect from {url}") + except urllib.error.HTTPError as e: + if e.code not in (301, 302, 303, 307, 308): + raise + location = e.headers["Location"] + with urllib.request.urlopen(location) as r: + return r.read() + + +def run_workflow(github, branch): + head = git("rev-parse", "HEAD") + quoted = urllib.parse.quote(branch, safe="") + remote_head = github.get(f"/branches/{quoted}")["commit"]["sha"] + if remote_head != head: + sys.exit(f"{branch} is at {remote_head[:9]} on GitHub and at {head[:9]} here: push it first") + dispatched = datetime.now(timezone.utc) - timedelta(minutes=1) + github.post(f"/actions/workflows/{WORKFLOW}/dispatches", {"ref": branch}) + run = None + while run is None: + time.sleep(15) + runs = github.get(f"/actions/workflows/{WORKFLOW}/runs?branch={quoted}&event=workflow_dispatch&per_page=10")["workflow_runs"] + run = next((r for r in runs if r["head_sha"] == head and datetime.fromisoformat(r["created_at"].replace("Z", "+00:00")) >= dispatched), None) + print(f"run {run['html_url']}", flush=True) + while run["status"] != "completed": + time.sleep(60) + run = github.get(f"/actions/runs/{run['id']}") + print(f"run {run['id']}: {run['conclusion']}") + return run + + +def download_snapshots(github, run): + artifacts = github.get(f"/actions/runs/{run['id']}/artifacts?name={ARTIFACT}")["artifacts"] + if not artifacts: + sys.exit(f"run {run['id']} has no artifact {ARTIFACT}") + target = Path(tempfile.mkdtemp(prefix="ios-snapshots-")) + with zipfile.ZipFile(io.BytesIO(github.download(artifacts[0]["archive_download_url"]))) as z: + for name in z.namelist(): + if name.startswith(("snapshots/", "snapshots-diff/")): + z.extract(name, target) + return target + + +def record(downloaded): + renders = downloaded / "snapshots" + files = sorted({e["file"] for index in renders.glob("index-*.json") for e in json.loads(index.read_text())}) + if not files: + sys.exit(f"no snapshots in {renders}") + references = Path(git("rev-parse", "--show-toplevel")) / REFERENCES + references.mkdir(parents=True, exist_ok=True) + for old in references.glob("*.png"): + if old.name not in files: + old.unlink() + for name in files: + shutil.copyfile(renders / name, references / name) + size = sum((references / name).stat().st_size for name in files) + print(f"{len(files)} references, {size / 1e6:.1f} MB, in {references}") + print(git("status", "--short", "--", str(references))) + + +def check(downloaded): + result = downloaded / "snapshots" / "compare.json" + if not result.exists(): + sys.exit(f"the run compared no snapshots; its renders are in {downloaded / 'snapshots'}") + compared = json.loads(result.read_text()) + for name in compared["failed"]: + print(f"{name}: {compared['results'][name]}") + print(f"{len(compared['results'])} snapshots, {len(compared['failed'])} differ; diff images in {downloaded / 'snapshots-diff'}") + sys.exit(1 if compared["failed"] else 0) + + +def main(): + if len(sys.argv) not in (2, 3) or sys.argv[1] not in ("record", "check"): + sys.exit("usage: snapshots-remote.py record|check [branch]") + branch = sys.argv[2] if len(sys.argv) == 3 else git("rev-parse", "--abbrev-ref", "HEAD") + github = GitHub() + downloaded = download_snapshots(github, run_workflow(github, branch)) + if sys.argv[1] == "record": + record(downloaded) + else: + check(downloaded) + + +main() diff --git a/scripts/ci/ios-sim/snapshots.sh b/scripts/ci/ios-sim/snapshots.sh new file mode 100755 index 0000000000..13c1b1a267 --- /dev/null +++ b/scripts/ci/ios-sim/snapshots.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -euo pipefail + +derived=$1 +out=$2 +mkdir -p "$out" + +xcode=( + -project apps/ios/SimpleX.xcodeproj + -scheme "SimpleX (iOS)" + -configuration Debug + -destination "id=$SIM_UDID" + -derivedDataPath "$derived" + -onlyUsePackageVersionsFromResolvedFile + -skipPackagePluginValidation + -skipMacroValidation + -only-testing:"SimpleX Snapshots" + ARCHS=arm64 + CODE_SIGN_STYLE=Manual + CODE_SIGN_IDENTITY=- + DEVELOPMENT_TEAM= + PROVISIONING_PROFILE_SPECIFIER= +) + +xcodebuild build-for-testing -quiet "${xcode[@]}" + +for size in large extra-extra-extra-large accessibility-medium; do + xcrun simctl ui "$SIM_UDID" content_size "$size" + TEST_RUNNER_SNAPSHOT_DIR="$out" TEST_RUNNER_SNAPSHOT_SIZE="$size" xcodebuild test-without-building -testLanguage en -testRegion US "${xcode[@]}" +done +xcrun simctl ui "$SIM_UDID" content_size large + +ls -l "$out" + +references="apps/ios/SimpleX Snapshots/References" +if [ -d "$references" ]; then + python3 "$(dirname "$0")/../snapshots-compare.py" "$references" "$out" "$(dirname "$out")/snapshots-diff" +fi diff --git a/scripts/ci/snapshots-compare.py b/scripts/ci/snapshots-compare.py new file mode 100755 index 0000000000..f6d99cab14 --- /dev/null +++ b/scripts/ci/snapshots-compare.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 + +import json +import os +import struct +import sys +import zlib +from pathlib import Path + +CHANNEL_TOLERANCE = 8 + + +def unfilter(kind, line, prev, bpp): + if kind == 1: + for i in range(bpp, len(line)): + line[i] = (line[i] + line[i - bpp]) & 0xFF + elif kind == 2: + for i in range(len(line)): + line[i] = (line[i] + prev[i]) & 0xFF + elif kind == 3: + for i in range(len(line)): + left = line[i - bpp] if i >= bpp else 0 + line[i] = (line[i] + ((left + prev[i]) >> 1)) & 0xFF + elif kind == 4: + for i in range(len(line)): + a = line[i - bpp] if i >= bpp else 0 + b = prev[i] + c = prev[i - bpp] if i >= bpp else 0 + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 0xFF + + +def read_rgb(path): + data = path.read_bytes() + pos, idat = 8, [] + while pos < len(data): + length, kind = struct.unpack(">I4s", data[pos:pos + 8]) + body = data[pos + 8:pos + 8 + length] + pos += 12 + length + if kind == b"IHDR": + width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", body) + elif kind == b"IDAT": + idat.append(body) + if depth not in (8, 16) or color not in (2, 6) or interlace: + raise ValueError(f"{path.name}: bit depth {depth}, colour type {color}, interlace {interlace}") + channels = 3 if color == 2 else 4 + bpp = channels * depth // 8 + stride = width * bpp + raw = zlib.decompress(b"".join(idat)) + rows, prev = [], bytearray(stride) + for y in range(height): + start = y * (stride + 1) + line = bytearray(raw[start + 1:start + 1 + stride]) + unfilter(raw[start], line, prev, bpp) + prev = line + high = line[0::2] if depth == 16 else line + rgb = bytearray(width * 3) + for c in range(3): + rgb[c::3] = high[c::channels] + rows.append(bytes(rgb)) + return width, height, rows + + +def write_rgb(path, width, rows): + def chunk(kind, body): + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body)) + raw = b"".join(b"\0" + row for row in rows) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, len(rows), 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 6)) + + chunk(b"IEND", b"") + ) + + +def changed_pixels(ref_rows, out_rows, width): + changed, max_delta = {}, 0 + for y, (a, b) in enumerate(zip(ref_rows, out_rows)): + if a == b: + continue + for x in range(width): + i = x * 3 + delta = max(abs(a[i] - b[i]), abs(a[i + 1] - b[i + 1]), abs(a[i + 2] - b[i + 2])) + max_delta = max(max_delta, delta) + if delta > CHANNEL_TOLERANCE: + changed.setdefault(y, []).append(x) + return changed, max_delta + + +def diff_image(path, ref, out, changed): + (rw, rh, ref_rows), (ow, oh, out_rows) = ref, out + height = max(rh, oh) + grey = bytes([128]) * 3 + rows = [] + for y in range(height): + a = ref_rows[y] if y < rh else grey * rw + b = out_rows[y] if y < oh else grey * ow + row = a + grey * 8 + b + if (rw, rh) == (ow, oh): + mask = bytearray(255 - (255 - v) // 4 for v in a) + for x in changed.get(y, []): + mask[x * 3:x * 3 + 3] = b"\xff\0\0" + row += grey * 8 + mask + rows.append(bytes(row)) + write_rgb(path, len(rows[0]) // 3, rows) + + +def compare(ref_path, out_path, diff_dir): + if ref_path.read_bytes() == out_path.read_bytes(): + return {"status": "equal"} + ref, out = read_rgb(ref_path), read_rgb(out_path) + if ref[:2] != out[:2]: + diff_image(diff_dir / out_path.name, ref, out, {}) + return {"status": "size", "reference": list(ref[:2]), "render": list(out[:2])} + changed, max_delta = changed_pixels(ref[2], out[2], ref[0]) + if not changed: + return {"status": "within tolerance", "maxDelta": max_delta} + diff_image(diff_dir / out_path.name, ref, out, changed) + return {"status": "changed", "changedPixels": sum(len(xs) for xs in changed.values()), "maxDelta": max_delta} + + +def main(): + if len(sys.argv) != 4: + sys.exit("usage: snapshots-compare.py ") + references, renders, diff_dir = map(Path, sys.argv[1:]) + diff_dir.mkdir(parents=True, exist_ok=True) + rendered = {e["file"] for index in sorted(renders.glob("index-*.json")) for e in json.loads(index.read_text())} + expected = {p.name for p in references.glob("*.png")} + results = {} + for name in sorted(expected | rendered): + if name not in rendered: + results[name] = {"status": "missing"} + elif name not in expected: + results[name] = {"status": "extra"} + else: + results[name] = compare(references / name, renders / name, diff_dir) + failed = {n: r for n, r in results.items() if r["status"] not in ("equal", "within tolerance")} + (renders / "compare.json").write_text(json.dumps({"results": results, "failed": sorted(failed)}, indent=2, sort_keys=True)) + lines = [f"{len(results)} snapshots, {len(failed)} differ from the references in {references}"] + lines += [f"- {n}: {', '.join(f'{k} {v}' for k, v in sorted(r.items()))}" for n, r in failed.items()] + print("\n".join(lines)) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a") as f: + f.write("\n".join(["## Snapshots", ""] + lines) + "\n") + sys.exit(1 if failed else 0) + + +main() From e4d05097e27a6c5fcd71a9e76136e057b643b9f1 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:27:31 +0000 Subject: [PATCH 09/19] ios, android, desktop: channel owners can require signed messages and make subscribers moderators (#7805) * core: test that history posted before channel signing reaches later joiners as signature missing, and channel moderator delete for all Confirms the case the owner's new alert describes: a subscriber who joins after the owner turned on "Sign messages" sees the earlier posts as "signature missing" and later ones as signed, while earlier subscribers keep them unmarked. Also covers a non-owner channel moderator deleting a contributor's signed post for everyone. Run: 001-channel-signing-moderators, slice 1 * ios: keep "Chat with admins" off when saving group preferences toGroupPreferences left out the support preference, so the saved profile had none and core filled in its default, on: every channel preference save from iOS silently turned on chats with admins, which have no end-to-end encryption in channels. Android and desktop already pass it. Run: 001-channel-signing-moderators, slice 2 * ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing The channel preference was hidden while signing was recipient-only. Owners now see the switch; turning it on asks for confirmation, because subscribers who join later receive the earlier posts unsigned through history and see them as "signature missing". The "Signature missing" explanation now says the message may predate signing or have been forwarded by a relay without its signature, instead of only implying tampering. Run: 001-channel-signing-moderators, slice 3 * ui: channel owners can make subscribers moderators, and moderators see the subscriber list Core already lets a channel owner make a subscriber a moderator; the role picker offered only subscriber and contributor. It now offers moderator too (not admin, by decision). Moderators see the full member list the owner sees, so they can reach the subscribers they may block (subscribers can't post, so there was no other way to reach them), and member info is headed Moderator or Contributor instead of Subscriber for those roles. Run: 001-channel-signing-moderators, slice 4 * ci: long-press and right-click for app tests, safer ui arguments, desktop Kotlin 4 GB, desktop session limits from claude-scope Message menus open only on long-press (iOS) or right-click (desktop), so Claude sessions couldn't reach Moderate, Info or Delete. Ported from f/directory-search unchanged, with its hardening: the guards refuse unquoted glob, brace and tilde expansion, and desktop ui checks numbers and sends one key per call so a key argument can't run an xdotool command. The desktop app job's Kotlin compiler gets 4 GB, as f/directory-search needed after merging master. Desktop sessions take max-turns and max-budget-usd from claude-scope like iOS (60 and 3 when unset, as before), and the Claude job may run 120 minutes. Run: 001-channel-signing-moderators, slice 5 * ci: Claude test plans for channel message signing and channel moderators This branch's iOS device test plan and desktop prompt replace master's smoke tests: the owner creates a channel on the preset relays, turns on Sign messages through its alert, and two more profiles in the same app join by the copied link; the owner makes one a moderator, who then sees the subscriber list, moderates a contributor's message and blocks the contributor. Signatures are checked as text in message Info. One layout, no test backend. Run: 001-channel-signing-moderators, slice 6 * fixup! ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing * update plans * fixup! ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing * fixup! ci: Claude test plans for channel message signing and channel moderators * fixup! ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing * fixup! ci: Claude test plans for channel message signing and channel moderators * fixup! ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing * fixup! ci: Claude test plans for channel message signing and channel moderators --------- Co-authored-by: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> --- .github/workflows/desktop-linux.yml | 7 +- .../Views/Chat/Group/ChannelMembersView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 4 +- .../Chat/Group/GroupMemberInfoView.swift | 2 + .../Chat/Group/GroupPreferencesView.swift | 17 ++- apps/ios/SimpleXChat/ChatTypes.swift | 8 +- .../chat/simplex/common/model/ChatModel.kt | 5 +- .../views/chat/group/ChannelMembersView.kt | 4 +- .../views/chat/group/GroupChatInfoView.kt | 4 +- .../views/chat/group/GroupMemberInfoView.kt | 2 + .../views/chat/group/GroupPreferences.kt | 19 ++- .../moko-resources/base/strings.xml | 7 +- scripts/ci/desktop-linux/README.md | 6 +- scripts/ci/desktop-linux/claude-guard.py | 10 +- scripts/ci/desktop-linux/claude-scope | 3 + scripts/ci/desktop-linux/claude-test.md | 75 +++++++---- scripts/ci/desktop-linux/claude-test.sh | 11 +- scripts/ci/desktop-linux/ui | 32 ++++- scripts/ci/ios-sim/README.md | 2 +- scripts/ci/ios-sim/claude-guard.py | 10 +- scripts/ci/ios-sim/claude-scope | 6 +- scripts/ci/ios-sim/device-test-plan.md | 38 +++++- scripts/ci/ios-sim/ui | 10 +- tests/ChatTests/Groups.hs | 124 ++++++++++++++++-- 24 files changed, 326 insertions(+), 82 deletions(-) diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index ce5a1c7922..814f04b8f9 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -242,12 +242,13 @@ jobs: distribution: corretto java-version: ${{ env.JAVA_VER }} + # the Kotlin compiler daemon otherwise gets gradle.properties' 2 GB, which compiling :common runs out of - 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 + ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.daemon.jvmargs=-Xmx4g :desktop:createDistributable app=release/main/app/simplex ls -la "$app/bin" "$app/lib/app/resources" du -sh "$app" @@ -271,7 +272,7 @@ jobs: - 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 + run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.daemon.jvmargs=-Xmx4g :common:desktopTest - name: Report desktop tests if: ${{ !cancelled() && steps.build.outcome == 'success' }} @@ -329,7 +330,7 @@ jobs: needs: [plan, app] if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 120 environment: name: app-sim-claude deployment: false diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index 6b7f5b65fc..e4d788b47c 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -26,7 +26,7 @@ struct ChannelMembersView: View { .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - if groupInfo.isOwner { + if groupInfo.canModerate { List { Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) { searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 1be8070259..d2644e600f 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -125,7 +125,7 @@ struct GroupChatInfoView: View { Label("Share via chat", systemImage: "arrowshape.turn.up.forward") } } - if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) { + if groupInfo.canModerate || members.contains(where: { $0.wrapped.memberRole >= .owner }) { channelMembersButton() } if groupInfo.membership.memberRole >= .moderator { @@ -734,7 +734,7 @@ struct GroupChatInfoView: View { } private func channelMembersButton() -> some View { - let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners & contributors" + let label: LocalizedStringKey = groupInfo.canModerate ? "Subscribers" : "Owners & contributors" return NavigationLink { ChannelMembersView(chat: chat, groupInfo: groupInfo) .navigationTitle(label) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index ebbdc6b6a3..d529bc4e76 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -38,6 +38,8 @@ struct GroupMemberInfoView: View { switch groupMember.wrapped.memberRole { case .relay: "Relay" case .owner: "Owner" + case .moderator: "Moderator" + case .member: "Contributor" default: "Subscriber" } } else { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index da15ab98a5..eed907a643 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -50,8 +50,21 @@ struct GroupPreferencesView: View { featureSection(.history, $preferences.history.enable) featureSection(.support, $preferences.support.enable, disabled: true) } else { - // hidden until message signing is user-facing (recipient-only stage) -// featureSection(.signMessages, $preferences.signMessages.enable) + featureSection(.signMessages, $preferences.signMessages.enable) + .onChange(of: preferences.signMessages.enable) { enable in + if enable == .on && currentPreferences.signMessages.enable != .on { + showAlert( + NSLocalizedString("Sign messages?", comment: "alert title"), + message: NSLocalizedString("Messages sent before will show \"signature missing\" to subscribers who join later.", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .default) { _ in }, + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel) { _ in + preferences.signMessages.enable = .off + } + ]} + ) + } + } featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.reactions, $preferences.reactions.enable) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index bc59534a86..28507af64c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1818,6 +1818,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group simplexLinks: fullPreferences.simplexLinks, reports: fullPreferences.reports, history: fullPreferences.history, + support: fullPreferences.support, signMessages: fullPreferences.signMessages, commands: fullPreferences.commands ) @@ -3197,7 +3198,7 @@ public enum MsgVerified: Decodable, Equatable, Hashable { switch self { case .sigMissing: return ( NSLocalizedString("Signature missing", comment: "alert title"), - NSLocalizedString("The channel required this message to be signed, but the signature is missing.", comment: "alert message") + NSLocalizedString("The message was sent before the channel started signing messages, or it is fake.", comment: "alert message") ) default: return nil } @@ -3453,10 +3454,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { if groupInfo.useRelays && !groupInfo.isOwner { return nil } let userRole = groupInfo.membership.memberRole if groupInfo.useRelays { - // TODO [relays]: for now owners can only set observer/member in channels. - // Restore the full Owner-excluded picker when moderator/admin promotion is supported: - // return GroupMemberRole.supportedRoles.filter { $0 <= userRole && $0 != .owner } - return [.observer, .member] + return [.observer, .member, .moderator] } else { return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 2dbec6edde..77b084020e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -3042,10 +3042,7 @@ data class GroupMember ( else if (groupInfo.useRelays && !groupInfo.isOwner) null else groupInfo.membership.memberRole.let { userRole -> if (groupInfo.useRelays) - // TODO [relays]: for now owners can only set observer/member in channels. - // Restore the full Owner-excluded picker when moderator/admin promotion is supported: - // GroupMemberRole.selectableRoles.filter { it <= userRole && it != GroupMemberRole.Owner } - listOf(GroupMemberRole.Observer, GroupMemberRole.Member) + listOf(GroupMemberRole.Observer, GroupMemberRole.Member, GroupMemberRole.Moderator) else GroupMemberRole.selectableRoles.filter { it <= userRole } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index ea841acc86..3ad2906759 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -44,7 +44,7 @@ fun ModalData.ChannelMembersView( val s = searchText.value.text.trim().lowercase() val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } - val title = if (groupInfo.isOwner) { + val title = if (groupInfo.canModerate) { generalGetString(MR.strings.channel_members_title_subscribers) } else { generalGetString(MR.strings.channel_members_section_owners) @@ -57,7 +57,7 @@ fun ModalData.ChannelMembersView( item { AppBarTitle(title) } - if (groupInfo.isOwner) { + if (groupInfo.canModerate) { val showSearch = members.size > 8 item { SectionView(title = subscriberCountStr(subscriberCount)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 973b684d38..8cbbeb18c7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -651,7 +651,7 @@ fun ModalData.GroupChatInfoLayout( ModalManager.closeAllModalsEverywhere() } } - if (groupInfo.isOwner || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { + if (groupInfo.canModerate || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { anyTopSectionRowShow = true ChannelMembersButton(chat.remoteHostId, groupInfo, showMemberInfo) } @@ -1295,7 +1295,7 @@ private fun ShareViaChatButton(onClick: () -> Unit) { @Composable private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { - val title = if (groupInfo.isOwner) { + val title = if (groupInfo.canModerate) { stringResource(MR.strings.channel_members_title_subscribers) } else { stringResource(MR.strings.channel_members_section_owners) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index ac1c6915b4..dc06d15ea6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -594,6 +594,8 @@ fun GroupMemberInfoLayout( when (member.memberRole) { GroupMemberRole.Relay -> stringResource(MR.strings.member_info_section_title_relay) GroupMemberRole.Owner -> stringResource(MR.strings.member_info_section_title_owner) + GroupMemberRole.Moderator -> stringResource(MR.strings.member_info_section_title_moderator) + GroupMemberRole.Member -> stringResource(MR.strings.member_info_section_title_contributor) else -> stringResource(MR.strings.member_info_section_title_subscriber) } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index bd3af10c37..c52bb5b0f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -178,10 +178,14 @@ private fun GroupPreferencesLayout( } } } - @Composable fun SignMessagesPreference() { + @Composable fun SignMessagesPreference(onEnable: ((() -> Unit) -> Unit)? = null) { val enableSignMessages = remember(preferences) { mutableStateOf(preferences.signMessages.enable) } FeatureSection(GroupFeature.SignMessages, enableSignMessages, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> applyPrefs(preferences.copy(signMessages = GroupPreference(enable = enable))) + if (enable == GroupFeatureEnabled.ON && currentPreferences.signMessages.enable != GroupFeatureEnabled.ON) onEnable?.invoke { + enableSignMessages.value = GroupFeatureEnabled.OFF + applyPrefs(preferences.copy(signMessages = GroupPreference(enable = GroupFeatureEnabled.OFF))) + } } } ColumnWithScrollBar { @@ -216,9 +220,16 @@ private fun GroupPreferencesLayout( SectionDividerSpaced() SupportPreference(disabled = true) } else { - // hidden until message signing is user-facing (recipient-only stage) -// SignMessagesPreference() -// SectionDividerSpaced() + SignMessagesPreference(onEnable = { revert -> + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.sign_messages_question), + text = generalGetString(MR.strings.sign_messages_alert_desc), + confirmText = generalGetString(MR.strings.sign_messages_enable), + onDismiss = revert, + onDismissRequest = revert, + ) + }) + SectionDividerSpaced() TimedMessagesPreference() SectionDividerSpaced() FullDeletePreference() diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index a86a84745a..9f59c7dc3d 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -788,10 +788,13 @@ Do not require signing messages. Message signing is required. Message signing is not required. + Sign messages? + Messages sent before will show "signature missing" to subscribers who join later. + Enable Show signature Show encryption Signature missing - The channel required this message to be signed, but the signature is missing. + The message was sent before the channel started signing messages, or it is fake. Live message! Send a live message - it will update for the recipient(s) as you type it Send @@ -3093,6 +3096,8 @@ Relay Owner + Moderator + Contributor Subscriber Channel Relay link diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index 90143005e8..6356ad1312 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -23,10 +23,10 @@ Queueing for a runner adds from under a minute to 20 minutes. Results are artifa ## 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. +- `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, right-click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers. - `smoke-test.sh [start|onboarding|setup|backend]`: the scripted cases; the default is onboarding and both layouts. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts. - `backend.sh start ` / `stop `: the test backend, below. -- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend), `claude-guard.py`, `claude-usage.py`: the Claude job. +- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend, and optionally the session's turns and budget), `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 @@ -41,7 +41,7 @@ It writes the directory's address to `/directory-link` and the SMP server's ## The Claude job -It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 60`, `--max-budget-usd 3` and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`). Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with the turns and budget `claude-scope` sets (`--max-turns 60` and `--max-budget-usd 3` unless it sets `max-turns:` or `max-budget-usd:`) and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`); the job's time limit is 120 minutes. 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 diff --git a/scripts/ci/desktop-linux/claude-guard.py b/scripts/ci/desktop-linux/claude-guard.py index 7b004584b2..fb0099c448 100755 --- a/scripts/ci/desktop-linux/claude-guard.py +++ b/scripts/ci/desktop-linux/claude-guard.py @@ -11,12 +11,18 @@ import os import re import sys -# nothing that chains, pipes, redirects, substitutes, expands or escapes +# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") +# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into +# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes +UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]") +QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'") 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) +allowed = call.get("tool_name") != "Bash" or ( + UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", 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") diff --git a/scripts/ci/desktop-linux/claude-scope b/scripts/ci/desktop-linux/claude-scope index ca4c2da0f9..548ea0c988 100644 --- a/scripts/ci/desktop-linux/claude-scope +++ b/scripts/ci/desktop-linux/claude-scope @@ -1,3 +1,6 @@ # What the Claude job needs for the session claude-test.md describes. # backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too. +# max-turns and max-budget-usd (optional) bound the session; claude-test.sh uses 60 and 3 when they're absent. backend: no +max-turns: 300 +max-budget-usd: 5 diff --git a/scripts/ci/desktop-linux/claude-test.md b/scripts/ci/desktop-linux/claude-test.md index 94982805f1..b2c3e99798 100644 --- a/scripts/ci/desktop-linux/claude-test.md +++ b/scripts/ci/desktop-linux/claude-test.md @@ -1,52 +1,73 @@ -# Test: onboarding and the chat list in the SimpleX Chat desktop app +# Test: channel message signing and channel moderators 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 [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 ` saves `screens/NN-.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. +Your working directory is where your output goes. `ui shot ` saves `screens/NN-.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 and can't read icons; 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. +- After onboarding, the chat list is in the left pane (x 1 to 389) and chats open in the right pane. An empty chat list shows only its toolbar, with the profile picture at about 27,730 and a pencil (New chat) at about 363,730: the search field "Search or paste SimpleX link" appears once there is a chat. The toolbar is at the bottom of the left pane. +- The channel uses the app's preset chat relays, on the internet. Creating it and joining it wait for them: wait up to 120 s there (`ui wait "" 120`). + +## How to do things in the app + +- **Send a message:** click the message field at the bottom of the right pane (its placeholder is "Broadcast" for the channel's owner, "Message" for others), `ui type ""`, then `ui key Return`. Check the message then shows in the chat. +- **Channel info:** click the channel name at the top of the right pane. Press Escape to close it and other panes (preferences, member info, a message's info) and get back to the chat. +- **Message menu:** `ui right-click ` on the message's text (centre from `ui find`) opens its menu ("Info", "Moderate", …). +- **Signatures are read as text, never from the seal icons:** a message's "Info" shows "Signed" (your own message), "Signed & verified" (someone else's, checked) or "Signature missing". +- **Switch profile:** click the profile picture (about 27,730); the menu lists your other profiles by name: click the one you want. The chat list then shows that profile's chats; click "ci signing" in it. +- **Add a profile:** profile picture → "Your chat profiles" → "Add profile". On "Create profile", click the field about 40 px below the text "Enter your name:", type the name, and click the "Create profile" button (the lower of the two "Create profile" texts; the upper one is the title). The new profile becomes the active one; press Escape until the chat list shows. +- **Join by the copied link** (from a profile without chats): the pencil (about 363,730) → "Paste link" → "Tap to paste link". An alert names the channel with "Open channel": click it. The channel opens with "Tap Join channel" and a "Join channel" bar at the bottom: click the bar, and wait up to 120 s until the bottom of the chat shows "you are subscriber". ## The test -**A. Onboarding.** Each screen waits for the one before it; wait for a text before clicking it. +The parts build on each other: run them in this order. cisub must join only after cimod has become a moderator (B2), so that cimod learns about cisub. -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. +**S. Setup.** -**B. A group, and the chat list with the toolbar at the bottom.** +1. (S1) Onboarding. Each screen waits for the one before it; wait for a text before clicking it. The first screen has "Create your profile": click it. On "Create profile", click the field "Enter profile name", type `citester`, and click "Create profile". The next screen offers a database passphrase: click "Use random passphrase", then "OK" in the alert that follows. Click "Continue", then "Accept" on the screen after it. Wait up to 60 s for the chat list: the right pane shows "Talk to someone". +2. (S2) Click the pencil, then "Create public channel". Click the field about 40 px below the text "Channel name", type `ci signing`, and click the "Create public channel" button (the lower of the two texts with that name). Wait up to 120 s for "Share link": the channel opens and its "Channel link" pane shows over it. If "N/M relays active" stays below M for 120 s with N at least 1, click "Continue", and "Continue" in the alert. On "Channel link", click "Continue". Check: the chat "ci signing" is open. +3. (S3) Send `before signing`. Check: it shows in the chat. -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. +**A. "Sign messages" (as citester).** -**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. +1. (A1) Channel info → "Channel preferences". Check: the first row is "Sign messages", and its footer says "Do not require signing messages." (the switch is off). +2. (A2) Click the switch of "Sign messages", at the right end of its row. Check: the alert "Sign messages?" says "Messages sent before will show "signature missing" to subscribers who join later." and has "Enable" and "Cancel". Click "Cancel". Check: the footer says "Do not require signing messages." again. +3. (A3) Click the switch again, then "Enable". Check: the footer says "Require signing messages.". Click "Save and notify channel subscribers", then press Escape until the chat shows. Check: the chat shows "Sign messages: on". +4. (A4) Send `after signing`, right-click it → "Info". Check: the info says "Signed". +5. (A5) Channel info → "Channel preferences". Check: the footer of "Chat with admins" says "Prohibit chats with admins." (saving in A3 did not turn it on). Press Escape until the chat shows. -**C. The chat list, toolbar at the top.** Check, noting the evidence: +**J. A subscriber joins.** -- "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. +1. (J1) Channel info → "Channel link" → "Share link". Check: the toast "Copied to clipboard". Press Escape until the chat list shows. +2. (J2) Add the profile `cimod`, and as cimod join by the copied link. Check: "you are subscriber". +3. (A6) As cimod in "ci signing": click the message "before signing". Check: the alert "Signature missing" says "The message was sent before the channel started signing messages, or it is fake." Close it, right-click "before signing" → "Info". Check: "Signature missing". Then right-click "after signing" → "Info". Check: "Signed & verified". -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: +**B. A moderator.** + +1. (B1) Switch to citester → "ci signing" → channel info → "Subscribers" → "cimod". Click the "Change role" row. Check: the list offers exactly "subscriber", "contributor" and "moderator" (no admin, no owner). +2. (B2) Click "moderator", then "Change" in the alert "Change role?". Check: cimod's role shows moderator, and the section above it is titled "Moderator". Press Escape until the chat shows. Check: the chat shows "you changed role of cimod to moderator". +3. (B3) Switch to cimod → "ci signing". Check: the chat shows "changed your role to moderator". Send `moderator post`. Check: it shows in the chat. +4. (J3) Add the profile `cisub`, and as cisub join by the same link (it is still on the clipboard; if not, copy it again as citester as in J1). Check: "you are subscriber". +5. (J4) Switch to citester → "ci signing" → channel info → "Subscribers" → "cisub" → "Change role" → "contributor" → "Change". Check: cisub's role shows contributor, in the section titled "Contributor". +6. (J5) Switch to cisub → "ci signing". Send `contributor post`. Check: it shows in the chat. +7. (B4) Switch to cimod → "ci signing" → channel info. Check: it shows "Subscribers"; click it: the list includes "cisub". +8. (B5) Click "cisub". Check: its info offers "Block for all" (don't click it yet). Press Escape until the chat shows. +9. (B6) Right-click "contributor post" → "Moderate" → "Delete" in the alert "Delete member message?". Check: the message shows "moderated by cimod" in its place. +10. (B7) Channel info → "Subscribers" → "cisub" → "Block for all" → "Block for all" in the alert "Block subscriber for all?". Check: cisub's info now offers "Unblock for all". Press Escape until the chat shows. Check: the chat shows "you blocked cisub". + +You have at most 300 turns, and the whole test fits in about 150 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 click 363 730 -- click-text "Create public channel" 10 -- wait "Channel name" -- shot new-channel` +- `ui do click-text "Broadcast" -- type "after signing" -- key Return -- wait "after signing" -- shot after-signing` +- `ui do click 27 730 -- click-text "Your chat profiles" 10 -- click-text "Add profile" 10 -- wait "Enter your name" -- shot add-profile` -`ui do` stops at the first command that fails, so a failed chain tells you where it stopped. +`ui do` stops at the first command that fails, so a failed chain tells you where it stopped. Take a shot at each check, named after its step (`ui shot A2`). ## 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. +Write `report.md` in your working directory as you go, so a session that is cut short still leaves its results: one row per step (S1 to S3, A1 to A5, J1, J2, A6, B1 to B3, J3 to J5, B4 to B7), each check you ran as PASS or FAIL with its evidence (the text OCR read, screenshot names), 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 steps that failed. ## The ui helper diff --git a/scripts/ci/desktop-linux/claude-test.sh b/scripts/ci/desktop-linux/claude-test.sh index 6f70e6400f..730a374c72 100755 --- a/scripts/ci/desktop-linux/claude-test.sh +++ b/scripts/ci/desktop-linux/claude-test.sh @@ -23,6 +23,13 @@ settings=$(mktemp) export CLAUDE_GUARD_LOG=$transcript.guard.log : > "$CLAUDE_GUARD_LOG" +scope() { + sed -n "s/^$1: *//p" "$here/claude-scope" +} + +max_turns=$(scope max-turns) +max_budget=$(scope max-budget-usd) + { cat "$prompt" echo @@ -47,8 +54,8 @@ CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \ --permission-mode dontAsk \ --permission-prompts none \ --strict-mcp-config \ - --max-turns 60 \ - --max-budget-usd 3 \ + --max-turns "${max_turns:-60}" \ + --max-budget-usd "${max_budget:-3}" \ --autocompact 200k \ --no-session-persistence \ --settings "$settings" \ diff --git a/scripts/ci/desktop-linux/ui b/scripts/ci/desktop-linux/ui index 83c6f74bc0..1ef9c21940 100755 --- a/scripts/ci/desktop-linux/ui +++ b/scripts/ci/desktop-linux/ui @@ -14,6 +14,7 @@ # ui ink print the height in pixels of the text's ink, to compare text sizes # ui wait [secs] wait up to secs (30) until the text is on screen # ui click click at x,y +# ui right-click right-click at x,y, e.g. to open a list row's menu # ui click-text [secs] wait up to secs (20) for the text and click its centre # ui scroll [clicks] # turn the mouse wheel over x,y, 3 clicks unless given @@ -155,8 +156,17 @@ shot() { log "shot $(basename "$f").png" } +number() { + if ! [[ "$1" =~ ^[0-9]+$ ]]; then + echo "error: \"$1\" is not a number" + exit 1 + fi +} + click() { - xdotool mousemove --sync "$1" "$2" click 1 + number "$1" + number "$2" + xdotool mousemove --sync "$1" "$2" click "${3:-1}" sleep 1 } @@ -171,6 +181,9 @@ scroll() { return 1 ;; esac + number "$1" + number "$2" + number "$4" xdotool mousemove --sync "$1" "$2" click --repeat "$4" --delay 100 "$button" sleep 1 } @@ -223,6 +236,7 @@ case "${1:-}" in ;; wait) secs=${3:-30} + number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "wait \"$2\": not on screen after $secs s" exit 1 @@ -234,8 +248,13 @@ case "${1:-}" in click "$2" "$3" log "click $2,$3" ;; + right-click) + click "$2" "$3" 3 + log "right-click $2,$3" + ;; click-text) secs=${3:-20} + number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "click-text \"$2\": not on screen after $secs s" exit 1 @@ -255,7 +274,16 @@ case "${1:-}" in ;; key) shift - xdotool key --delay 100 -- "$@" + for k in "$@"; do + if ! [[ "$k" =~ ^[A-Za-z0-9_]+(\+[A-Za-z0-9_]+)*$ ]]; then + echo "error: \"$k\" is not a key name" + exit 1 + fi + done + # one key per call: xdotool key runs any later argument that names one of its commands, such as exec + for k in "$@"; do + xdotool key --delay 100 -- "$k" + done log "key $*" ;; do) diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index 56c0a80f4a..37ef8a78e8 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -36,7 +36,7 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; ## 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. +`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, long-press, 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. ## Snapshots: `snapshots.sh` diff --git a/scripts/ci/ios-sim/claude-guard.py b/scripts/ci/ios-sim/claude-guard.py index ab131ae997..c335b9836a 100755 --- a/scripts/ci/ios-sim/claude-guard.py +++ b/scripts/ci/ios-sim/claude-guard.py @@ -11,12 +11,18 @@ import os import re import sys -# nothing that chains, pipes, redirects, substitutes, expands or escapes +# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") +# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into +# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes +UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]") +QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'") 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) +allowed = call.get("tool_name") != "Bash" or ( + UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", 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") diff --git a/scripts/ci/ios-sim/claude-scope b/scripts/ci/ios-sim/claude-scope index 7d917dc20b..4571871501 100644 --- a/scripts/ci/ios-sim/claude-scope +++ b/scripts/ci/ios-sim/claude-scope @@ -5,6 +5,6 @@ backend: no model: claude-haiku-5-5 layouts: reachable -cases-reachable: S1-S3 -max-turns: 60 -max-budget-usd: 3 +cases-reachable: S2, S3, A1, A2, A3, A4, A5, J1, J2, A6, B1, B2, B3, J3, J4, J5, B4, B5, B6, B7 +max-turns: 350 +max-budget-usd: 5 diff --git a/scripts/ci/ios-sim/device-test-plan.md b/scripts/ci/ios-sim/device-test-plan.md index cf82b7daf0..6bfe81d9ad 100644 --- a/scripts/ci/ios-sim/device-test-plan.md +++ b/scripts/ci/ios-sim/device-test-plan.md @@ -1,9 +1,37 @@ -# Device test plan: smoke test +# Device test plan: channel message signing and channel moderators -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`. +The app as the scripted smoke test leaves it: the profile "citester", without conversations. The cases run in the order of the tables and build on each other: one channel, "ci signing", owned by citester, and two more profiles in this app, "cimod" and "cisub", that join it by its link. Do not reorder them: cisub must join only after cimod has become a moderator (B2), so that cimod learns about cisub. + +## Setup + +- The channel uses the app's preset chat relays. Creating it and joining it wait for the relays: use `ui wait "" 120` there, and take a shot if a wait fails. +- **Sending a message:** in the chat, tap the message field at the bottom (find it in `ui tree`: the text view next to the send button; for the channel's owner its placeholder is "Broadcast"), `ui type ""`, then tap the send button at the right end of the field. Check the message then shows in the chat. +- **Channel info:** tap the channel name at the top of the chat (`ui find "ci signing"`, the element with the smallest y; tap it with `ui tap-xy`). Go back with the navigation back buttons; close a sheet by swiping it down. +- **Message menu:** `ui long-press ` on the message's text (centre from `ui find`) opens its menu ("Info", "Moderate", …). "Info" opens the message's details; close them by swiping down. +- **Signatures are read as text, never from the seal icons:** a message's "Info" shows "Signed" (your own message), "Signed & verified" (someone else's, checked) or "Signature missing". +- **Switching profile:** open the picker with your profile image (`Image "Account"`), then tap the other profile's name in the row at the top of the picker (`ui find `, then `ui tap-xy`; the picker's elements are in the tree even while it is closed, so open it first). The chat list then shows that profile's chats; open "ci signing" from it. +- **Adding a profile:** picker → "Your chat profiles" → "Add profile" → tap the field "Enter your name…" (`ui tap-value "Enter your name…"`), type the name, check the field's value in the tree (the keyboard may capitalize it: then fix it), tap "Create profile". The new profile becomes the active one; close "Your chat profiles" by swiping it down. +- **Joining by the copied link** (from a profile without conversations): "Compose" → "Scan / Paste link" → "Tap to paste link". If iOS asks to allow pasting, tap "Allow Paste". An alert names the channel with "Open channel": tap it; the channel opens with "Tap Join channel" and a "Join channel" bar at the bottom: tap the bar, and wait up to 120 s until the chat shows "you are subscriber". | ID | Do | Expect | |----|----|--------| -| S1 | Look at the screen: `ui tree`, then `ui shot S1-` | 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 | +| S2 | "Compose" → "Create public channel" → `ui tap-value "Enter channel name…"`, type `ci signing` (check the value) → tap the "Create public channel" button (the list row, not the title; use `ui tap-xy` if the label is ambiguous) → wait up to 120 s for "Channel link"; if "%d/%d relays active" stays below the total for 120 s with at least 1 active, tap "Continue" and "Continue" in the alert → on "Channel link" tap "Continue" (top right) | The channel "ci signing" opens as its chat | +| S3 | Send `before signing` | "before signing" shows in the chat as sent | +| A1 | Channel info → "Channel preferences" | The first row is the switch "Sign messages", off; its footer says "Do not require signing messages." | +| A2 | Turn "Sign messages" on | Alert "Sign messages?" with the message "Messages sent before will show "signature missing" to subscribers who join later." and the buttons "Enable" and "Cancel". Tap "Cancel": the switch is off again | +| A3 | Turn "Sign messages" on again, tap "Enable", then "Save and notify subscribers"; go back to the chat | After "Enable" the switch stays on (footer "Require signing messages."); after saving and going back, the chat shows the event "Sign messages: on" | +| A4 | Send `after signing`, then long-press it → "Info" | The message's info says "Signed" | +| A5 | Channel info → "Channel preferences" | "Chat with admins" is still off (its footer says "Prohibit chats with admins."); saving in A3 did not turn it on | +| J1 | Channel info → "Channel link" → "Share link" → in the share sheet tap "Copy" (`ui find Copy`; if it is not in the tree, `ui tap-xy` at its position in the screenshot) → close the sheet and go back to the chat list | The link is copied (the sheet closes or shows it was copied) | +| J2 | Add the profile `cimod`; as cimod, join by the copied link | The chat "ci signing" shows "you are subscriber" | +| A6 | As cimod in "ci signing": tap the message "before signing"; then long-press it → "Info"; then long-press "after signing" → "Info" | Tapping "before signing" shows the alert "Signature missing" with "The message was sent before the channel started signing messages, or it is fake."; its info says "Signature missing"; the info of "after signing" says "Signed & verified" | +| B1 | Switch to citester → "ci signing" → channel info → "Subscribers" → "cimod" → tap the "Change role" row | The role menu lists exactly "subscriber", "contributor" and "moderator" (no admin, no owner) | +| B2 | Choose "moderator" → "Change role?" → "Change"; go back to the chat | cimod's info shows the role moderator under the section header "Moderator" (any case); the chat shows "you changed role of cimod to moderator" | +| B3 | Switch to cimod → "ci signing"; send `moderator post` | The chat shows "changed your role to moderator"; "moderator post" shows as sent | +| J3 | Add the profile `cisub`; as cisub, join by the same link (it is still on the clipboard; if not, copy it again as citester as in J1) | cisub's chat "ci signing" shows "you are subscriber" | +| J4 | Switch to citester → "ci signing" → channel info → "Subscribers" → "cisub" → "Change role" → "contributor" → "Change" | cisub's role shows contributor, under the section header "Contributor" | +| J5 | Switch to cisub → "ci signing"; send `contributor post` | "contributor post" shows as sent | +| B4 | Switch to cimod → "ci signing" → channel info | The info shows "Subscribers"; its list includes "cisub" | +| B5 | Open "cisub" in that list | cisub's info offers "Block for all" (do not tap it yet) | +| B6 | Back in the chat, long-press "contributor post" → "Moderate" → "Delete member message?" → "Delete" | The message shows "moderated by cimod" in its place | +| B7 | Channel info → "Subscribers" → "cisub" → "Block for all" → "Block subscriber for all?" → "Block for all" | cisub's info now offers "Unblock for all"; the chat shows "you blocked cisub" | diff --git a/scripts/ci/ios-sim/ui b/scripts/ci/ios-sim/ui index 6738dac5c2..a705155388 100755 --- a/scripts/ci/ios-sim/ui +++ b/scripts/ci/ios-sim/ui @@ -6,6 +6,7 @@ # ui tap