mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-31 05:02:54 +00:00
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>
This commit is contained in:
1 parent
0b4c798e50
commit
e4d05097e2
24 files changed
+326
-82
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
+1
-4
@@ -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 }
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)) {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+15
-4
@@ -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()
|
||||
|
||||
@@ -788,10 +788,13 @@
|
||||
<string name="do_not_require_message_signatures">Do not require signing messages.</string>
|
||||
<string name="message_signatures_are_required">Message signing is required.</string>
|
||||
<string name="message_signatures_are_not_required">Message signing is not required.</string>
|
||||
<string name="sign_messages_question">Sign messages?</string>
|
||||
<string name="sign_messages_alert_desc">Messages sent before will show "signature missing" to subscribers who join later.</string>
|
||||
<string name="sign_messages_enable">Enable</string>
|
||||
<string name="show_signature">Show signature</string>
|
||||
<string name="show_encryption">Show encryption</string>
|
||||
<string name="signature_missing_alert_title">Signature missing</string>
|
||||
<string name="signature_missing_alert_desc">The channel required this message to be signed, but the signature is missing.</string>
|
||||
<string name="signature_missing_alert_desc">The message was sent before the channel started signing messages, or it is fake.</string>
|
||||
<string name="live_message">Live message!</string>
|
||||
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
|
||||
<string name="send_verb">Send</string>
|
||||
@@ -3093,6 +3096,8 @@
|
||||
<!-- GroupMemberInfoView.kt channel-related -->
|
||||
<string name="member_info_section_title_relay">Relay</string>
|
||||
<string name="member_info_section_title_owner">Owner</string>
|
||||
<string name="member_info_section_title_moderator">Moderator</string>
|
||||
<string name="member_info_section_title_contributor">Contributor</string>
|
||||
<string name="member_info_section_title_subscriber">Subscriber</string>
|
||||
<string name="info_row_channel">Channel</string>
|
||||
<string name="info_row_relay_link">Relay link</string>
|
||||
|
||||
@@ -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 <app dir> <out dir> [start|onboarding|setup|backend]`: the scripted cases; the default is onboarding and both layouts. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts.
|
||||
- `backend.sh start <tar.gz> <dir>` / `stop <dir>`: the test backend, below.
|
||||
- `claude-test.sh`, `claude-test.md` (the prompt), `claude-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 `<dir>/directory-link` and the SMP server's
|
||||
|
||||
## The Claude job
|
||||
|
||||
It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. 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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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 <command> [args]`. Anything else is refused; don't look for ways around that, and if something blocks the test, report it. The commands are listed at the end.
|
||||
|
||||
Your working directory is where your output goes. `ui shot <name>` saves `screens/NN-<name>.png` and prints the text read from it, one phrase per line after its centre `x,y` in screen pixels. Read the PNG to see the screen (1440×900, the app window at the top left). OCR can misread or split words; when the text and the picture disagree, trust the picture. "Above" means a smaller y.
|
||||
Your working directory is where your output goes. `ui shot <name>` saves `screens/NN-<name>.png` and prints the text read from it, one phrase per line after its centre `x,y` in screen pixels. Read the PNG to see the screen (1440×900, the app window at the top left). OCR can misread or split words 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 "<text>" 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 "<text>"`, 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 <x> <y>` 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
|
||||
@@ -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" \
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
# ui ink <text> print the height in pixels of the text's ink, to compare text sizes
|
||||
# ui wait <text> [secs] wait up to secs (30) until the text is on screen
|
||||
# ui click <x> <y> click at x,y
|
||||
# ui right-click <x> <y> right-click at x,y, e.g. to open a list row's menu
|
||||
# ui click-text <text> [secs] wait up to secs (20) for the text and click its centre
|
||||
# ui scroll <x> <y> <up|down> [clicks]
|
||||
# turn the mouse wheel over x,y, 3 clicks unless given
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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 "<text>" 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 "<text>"`, 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 <x> <y>` 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 <name>`, 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-<layout>` | The chat list of a profile without conversations: the heading "Talk to someone", the cards "Let someone connect to you" and "Connect via link or QR code", and the toolbar with your profile image (`Image "Account"`, 32 x 32) and "Compose": at the bottom of the screen with the one-hand UI, at the top with the top bar |
|
||||
| S2 | Tap "Let someone connect to you", then "Back" | The page "Create your link" with the cards "Invite someone privately" and "Create your public address" and a "Back" button; after "Back", "Talk to someone" again |
|
||||
| S3 | Open the profile picker by tapping the toolbar's profile image, tap "Settings", then close Settings by swiping it down | The picker shows your profile name and "Settings"; Settings opens with "Your settings" and "Network & servers"; after closing, "Talk to someone" again |
|
||||
| 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" |
|
||||
@@ -6,6 +6,7 @@
|
||||
# ui tap <label> [secs] tap the element with this exact accessibility label, waiting up to secs (10) for it
|
||||
# ui tap-value <text> [secs] tap the element with this exact value: text fields have no label, only their placeholder or text
|
||||
# ui tap-xy <x> <y> tap this point (points, as in the tree)
|
||||
# ui long-press <x> <y> touch this point for 1.5 s, e.g. to open a context menu
|
||||
# ui swipe <x1> <y1> <x2> <y2> swipe from one point to the other, e.g. to scroll a list
|
||||
# ui type <text> type into the focused field (US keyboard characters only)
|
||||
# ui clipboard <text> put the text on the clipboard, to paste it with the text field's Paste menu
|
||||
@@ -70,6 +71,7 @@ point() {
|
||||
|
||||
wait_for() {
|
||||
local text=$1 secs=${2:-30}
|
||||
number "$secs"
|
||||
local deadline=$((SECONDS + secs)) t
|
||||
until t=$(tree 2> /dev/null) && grep -qF -- "$text" <<< "$t"; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
@@ -212,6 +214,12 @@ case "${1:-}" in
|
||||
number "$3"
|
||||
axe tap -x "$2" -y "$3" --post-delay 2 --udid "$SIM_UDID"
|
||||
;;
|
||||
long-press)
|
||||
number "$2"
|
||||
number "$3"
|
||||
axe touch -x "$2" -y "$3" --down --up --delay 1.5 --udid "$SIM_UDID"
|
||||
sleep 2
|
||||
;;
|
||||
swipe)
|
||||
for n in "$2" "$3" "$4" "$5"; do number "$n"; done
|
||||
axe swipe --start-x "$2" --start-y "$3" --end-x "$4" --end-y "$5" --duration 0.5 --post-delay 1 --udid "$SIM_UDID"
|
||||
@@ -231,7 +239,7 @@ case "${1:-}" in
|
||||
listings) backend listings ;;
|
||||
requests) requests ;;
|
||||
*)
|
||||
sed -n '3,26s/^# \{0,1\}//p' "$0"
|
||||
sed -n '3,27s/^# \{0,1\}//p' "$0"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+116
-8
@@ -342,6 +342,7 @@ chatGroupTests = do
|
||||
it "concurrent fresh invitations both rejected" testRelayRejectRaceConcurrentInvitations
|
||||
describe "promoted members roster" $ do
|
||||
it "moderator action verifies via owner-signed roster" testChannelModeratorActionViaRoster
|
||||
it "moderator deletes a contributor's message for all" testChannelModeratorDeleteForAll
|
||||
it "subscriber recovers a missed roster member after a version gap" testChannelSubscriberRosterCatchUp
|
||||
it "2 relays: subscriber recovers a missed roster member after a version gap" testChannel2RelaysSubscriberRosterCatchUp
|
||||
it "removed moderator drops from the roster cache" testChannelRemovedModeratorRefreshesRoster
|
||||
@@ -387,6 +388,7 @@ chatGroupTests = do
|
||||
it "should warn on missing signature when signing is required" testChannelSignMessagesRequired
|
||||
it "should preserve signatures in history for catch-up subscribers" testChannelSignedHistory
|
||||
it "should forward unsigned channel history for catch-up subscribers" testChannelUnsignedHistory
|
||||
it "should mark history posted before signing was required as missing a signature" testChannelSignedAfterHistory
|
||||
|
||||
testGroupCheckMessages :: HasCallStack => TestParams -> IO ()
|
||||
testGroupCheckMessages =
|
||||
@@ -10936,6 +10938,62 @@ testChannelModeratorActionViaRoster ps =
|
||||
DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text]
|
||||
map (\(Only r) -> r) roles `shouldBe` [expectedRole]
|
||||
|
||||
testChannelModeratorDeleteForAll :: HasCallStack => TestParams -> IO ()
|
||||
testChannelModeratorDeleteForAll ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
|
||||
withNewTestChat ps "cath" cathProfile $ \cath ->
|
||||
withNewTestChat ps "dan" danProfile $ \dan ->
|
||||
withNewTestChat ps "eve" eveProfile $ \eve -> do
|
||||
createChannel1Relay "team" alice bob cath dan eve
|
||||
|
||||
-- cath promoted observer -> moderator
|
||||
threadDelay 1000000
|
||||
alice ##> "/mr #team cath moderator"
|
||||
alice <## "#team: you changed the role of cath to moderator (signed)"
|
||||
concurrentlyN_
|
||||
[ bob <## "#team: alice changed the role of cath from observer to moderator (signed)",
|
||||
cath <## "#team: alice changed your role from observer to moderator (signed)",
|
||||
dan <### [EndsWith "to moderator (signed)"],
|
||||
eve <### [EndsWith "to moderator (signed)"]
|
||||
]
|
||||
|
||||
-- promote dan (observer default) so it can post
|
||||
threadDelay 1000000
|
||||
promoteChannelMember "team" alice bob dan [cath, eve]
|
||||
dan ##> "/_send #1 sign=on text hello from dan"
|
||||
dan <# "#team hello from dan (signed)"
|
||||
bob <# "#team dan> hello from dan (signed)"
|
||||
concurrentlyN_
|
||||
[ alice <# "#team dan> hello from dan (signed) [>>]",
|
||||
do
|
||||
cath <### [EndsWith "updated to dan"]
|
||||
cath <## "#team: bob introduced dan (Daniel) in the channel"
|
||||
cath <# "#team dan> hello from dan (signed) [>>]",
|
||||
do
|
||||
eve <### [EndsWith "updated to dan"]
|
||||
eve <## "#team: bob introduced dan (Daniel) in the channel"
|
||||
eve <# "#team dan> hello from dan (signed) [>>]"
|
||||
]
|
||||
|
||||
-- cath (moderator) deletes dan's post for everyone; recipients holding it signed accept only a verified delete
|
||||
threadDelay 1000000
|
||||
danItemIdOnCath <- itemIdByText cath "hello from dan"
|
||||
cath ##> ("/_delete member item #1 " <> danItemIdOnCath)
|
||||
cath <## "message marked deleted by you"
|
||||
concurrentlyN_
|
||||
[ bob <# "#team dan> [marked deleted by cath] hello from dan (signed)",
|
||||
alice <# "#team dan> [marked deleted by cath] hello from dan (signed)",
|
||||
do
|
||||
dan <### [EndsWith "updated to cath"]
|
||||
dan <## "#team: bob introduced cath (Catherine) in the channel"
|
||||
dan <# "#team dan> [marked deleted by cath] hello from dan (signed)",
|
||||
do
|
||||
eve <### [EndsWith "updated to cath"]
|
||||
eve <## "#team: bob introduced cath (Catherine) in the channel"
|
||||
eve <# "#team dan> [marked deleted by cath] hello from dan (signed)"
|
||||
]
|
||||
|
||||
testChannelSubscriberRosterCatchUp :: HasCallStack => TestParams -> IO ()
|
||||
testChannelSubscriberRosterCatchUp ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
@@ -13877,6 +13935,56 @@ testChannelUnsignedHistory ps =
|
||||
dan #$> ("/_get chat #1 count=100 search=from cath member", chat, [(0, "from cath member")])
|
||||
dan #$> ("/_get chat #1 count=100 search=history two", chat, [])
|
||||
|
||||
testChannelSignedAfterHistory :: HasCallStack => TestParams -> IO ()
|
||||
testChannelSignedAfterHistory ps =
|
||||
testChat4 aliceProfile bobProfile cathProfile danProfile test ps
|
||||
where
|
||||
test alice bob cath dan = withRelay ps $ \relay -> do
|
||||
(shortLink, fullLink) <- prepareChannel1Relay "team" alice relay
|
||||
memberJoinChannel "team" [relay] [alice] shortLink fullLink bob
|
||||
memberJoinChannel "team" [relay] [alice] shortLink fullLink cath
|
||||
|
||||
-- as-group post before signing is required: unsigned
|
||||
alice ##> "/_send #1(as_group=on) text before signing"
|
||||
alice <# "#team before signing"
|
||||
relay <# "#team> before signing"
|
||||
[bob, cath] *<# "#team> before signing [>>]"
|
||||
|
||||
-- require signatures
|
||||
alice ##> "/set signatures #team on"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Sign messages: on"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
relay <## "alice updated group #team: (signed)"
|
||||
relay <## "updated group preferences:"
|
||||
relay <## "Sign messages: on",
|
||||
do
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Sign messages: on",
|
||||
do
|
||||
cath <## "alice updated group #team: (signed)"
|
||||
cath <## "updated group preferences:"
|
||||
cath <## "Sign messages: on"
|
||||
]
|
||||
|
||||
-- as-group post after signing is required: auto-signed
|
||||
alice ##> "/_send #1(as_group=on) text after signing"
|
||||
alice <# "#team after signing (signed)"
|
||||
relay <# "#team> after signing (signed)"
|
||||
[bob, cath] *<# "#team> after signing (signed) [>>]"
|
||||
|
||||
-- a subscriber who received the earlier post before signing was required holds it unmarked
|
||||
bob #$> ("/_get chat #1 count=100 search=before signing", chat, [(0, "before signing")])
|
||||
|
||||
-- catch-up subscriber joins late: the earlier post arrives unsigned while signing is required
|
||||
memberJoinChannel "team" [relay] [alice] shortLink fullLink dan
|
||||
dan <# "#team> before signing (signature missing) [>>]"
|
||||
dan <# "#team> after signing (signed) [>>]"
|
||||
dan #$> ("/_get chat #1 count=100 search=before signing", chat, [(0, "before signing (signature missing)")])
|
||||
dan #$> ("/_get chat #1 count=100 search=after signing", chat, [(0, "after signing (signed)")])
|
||||
|
||||
testChannelAsGroupSpoof :: HasCallStack => TestParams -> IO ()
|
||||
testChannelAsGroupSpoof ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
@@ -14054,14 +14162,14 @@ testChannelModerationDeleteSign ps =
|
||||
dan <# "#team cath> [marked deleted by alice] moderated post (signed)",
|
||||
eve <# "#team cath> [marked deleted by alice] moderated post (signed)"
|
||||
]
|
||||
where
|
||||
itemIdByText :: TestCC -> T.Text -> IO String
|
||||
itemIdByText cc t = do
|
||||
rows <- withCCTransaction cc $ \db ->
|
||||
DB.query db "SELECT chat_item_id FROM chat_items WHERE item_text LIKE '%' || ? || '%' ORDER BY chat_item_id DESC LIMIT 1" (Only t) :: IO [Only Int64]
|
||||
case rows of
|
||||
(Only i : _) -> pure (show i)
|
||||
_ -> fail $ "no item with text " <> T.unpack t
|
||||
|
||||
itemIdByText :: TestCC -> T.Text -> IO String
|
||||
itemIdByText cc t = do
|
||||
rows <- withCCTransaction cc $ \db ->
|
||||
DB.query db "SELECT chat_item_id FROM chat_items WHERE item_text LIKE '%' || ? || '%' ORDER BY chat_item_id DESC LIMIT 1" (Only t) :: IO [Only Int64]
|
||||
case rows of
|
||||
(Only i : _) -> pure (show i)
|
||||
_ -> fail $ "no item with text " <> T.unpack t
|
||||
|
||||
testGroupLinkContentFilter :: HasCallStack => TestParams -> IO ()
|
||||
testGroupLinkContentFilter =
|
||||
|
||||
Reference in new issue
Block a user