Merge branch 'stable' into stable-android

This commit is contained in:
Evgeny Poberezkin
2026-06-21 23:50:38 +01:00
25 changed files with 345 additions and 54 deletions
+5 -4
View File
@@ -637,7 +637,8 @@ jobs:
toolchain:p
cmake:p
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
# rm -rf dist-newstyle/src/{direct-sq,simplexmq}* is here because of the bug in cabal's dependency which prevents second build from finishing
# (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows)
- name: Build CLI
id: windows_cli_build
shell: msys2 {0}
@@ -652,10 +653,10 @@ jobs:
echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local
echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local
rm -rf dist-newstyle/src/direct-sq*
rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
sed -i "s/, unix /--, unix /" simplex-chat.cabal
cabal build -j --enable-tests
rm -rf dist-newstyle/src/direct-sq*
rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
path=$(cabal list-bin simplex-chat | tail -n 1)
echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
@@ -679,7 +680,7 @@ jobs:
scripts/desktop/build-lib-windows.sh
cd apps/multiplatform
./gradlew -Psimplex.assets.dir=../../assets packageMsi
rm -rf dist-newstyle/src/direct-sq*
rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
@@ -191,9 +191,14 @@ private func handleTextTaps(
}
}
}
if let index, let (uri, browser) = attributedStringLink(s, for: index) {
if let index, let (uri, browser, simplex) = attributedStringLink(s, for: index) {
if browser {
openBrowserAlert(uri: uri)
} else if simplex, let url = URL(string: uri) {
// SimpleX links target this same app (simplex: scheme / simplex.chat universal link),
// so UIApplication.shared.open is dropped by iOS while the app is in the foreground.
// Route to the in-app connect flow instead (same sink onOpenURL feeds).
ChatModel.shared.appOpenUrl = url
} else if let url = URL(string: uri) {
UIApplication.shared.open(url)
} else {
@@ -203,9 +208,10 @@ private func handleTextTaps(
})
}
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? {
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool, Bool)? {
var linkURL: String?
var browser: Bool = false
var simplex: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if index >= range.location && index < range.location + range.length {
if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo {
@@ -213,6 +219,7 @@ private func handleTextTaps(
} else if let url = attrs[linkAttrKey] as? String {
linkURL = url
browser = attrs[webLinkAttrKey] != nil
simplex = attrs[simplexLinkAttrKey] != nil
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
if showSecrets.wrappedValue.contains(i) {
showSecrets.wrappedValue.remove(i)
@@ -225,7 +232,7 @@ private func handleTextTaps(
stop.pointee = true
}
}
return if let linkURL { (linkURL, browser) } else { nil }
return if let linkURL { (linkURL, browser, simplex) } else { nil }
}
}
@@ -250,6 +257,8 @@ private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
private let simplexLinkAttrKey = NSAttributedString.Key("chat.simplex.app.simplexLink")
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command")
@@ -392,6 +401,7 @@ func messageText(
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = simplexUri
attrs[simplexLinkAttrKey] = true
handleTaps = true
}
if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) {
@@ -163,10 +163,13 @@ struct ContextProfilePickerView: View {
} label: {
HStack {
ProfileImage(imageStr: user.image, size: 38)
Text(user.chatViewName)
.fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
NameWithBadge(
Text(user.chatViewName)
.fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
.foregroundColor(theme.colors.onBackground),
user.profile.localBadge
)
.lineLimit(1)
Spacer()
@@ -26,7 +26,9 @@ struct ChatHelp: View {
Button("connect to SimpleX Chat developers.") {
dismissSettingsSheet()
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
// simplexTeamURL targets this same app; route to the in-app connect flow
// (UIApplication.shared.open is dropped for self-owned URLs in the foreground)
ChatModel.shared.appOpenUrl = simplexTeamURL
}
}
.padding(.top, 2)
@@ -390,7 +390,9 @@ struct SettingsView: View {
Button("Send questions and ideas") {
dismiss()
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
// simplexTeamURL targets this same app; route to the in-app connect flow
// (UIApplication.shared.open is dropped for self-owned URLs in the foreground)
ChatModel.shared.appOpenUrl = simplexTeamURL
}
}
}
@@ -146,9 +146,10 @@ fun ComposeContextProfilePickerView(
) {
ProfileImage(size = USER_ROW_AVATAR_SIZE, image = user.image)
TextIconSpaced(false)
Text(
NameWithBadge(
user.chatViewName,
modifier = Modifier.align(Alignment.CenterVertically),
user.profile.localBadge,
Modifier.align(Alignment.CenterVertically),
fontWeight = if (selectedUser.value.userId == user.userId && !incognitoDefault) FontWeight.Medium else FontWeight.Normal
)
@@ -321,7 +321,11 @@ private suspend fun downloadAsset(asset: GitHubAsset) {
stream.copyTo(output)
}
val newFile = File(file.parentFile, asset.name)
file.renameTo(newFile)
// Moving instead of renameTo: a bare rename can silently fail (returns false, ignored),
// and the enclosing createTmpFileAndDelete then deletes the only copy in its finally block,
// leaving the user with an empty download dir. Files.move performs the same in-place rename
// when possible, falls back to copy when it can't, and throws (handled below) on real failure.
Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.app_check_for_updates_download_completed_title),
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 376d6a261a1074717aed65ad97bb6f2a9532011b
tag: 92598c2ddb06cfc2c19797a2c900cffcb8af4d5c
source-repository-package
type: git
@@ -0,0 +1,48 @@
# Fix: in-app updater deletes the downloaded file before the user can open/install it
## Symptom
Desktop in-app updater (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt`): after a successful download the "Download completed" dialog appears, but clicking **"Open file location"** opens an **empty** `/tmp/simplex` — the downloaded artifact is gone. Reported against a Linux AppImage build; verified from a terminal that the file was genuinely absent on disk (not a file-manager display glitch).
## Root cause
`downloadAsset` writes the download to a temporary UUID-named file created by `createTmpFileAndDelete`, whose contract is to delete that temp file in a `finally` block. To keep the bytes, the code renames the temp file to the asset name so the survivor sits at a *different* path than the one the `finally` deletes:
```kotlin
createTmpFileAndDelete { file -> // file = /tmp/simplex/<UUID>
file.outputStream().use { output -> stream.copyTo(output) }
val newFile = File(file.parentFile, asset.name)
file.renameTo(newFile) // return value IGNORED
... show "Download completed" dialog ...
} // finally { tmpFile.delete() }
```
`File.renameTo` returns a boolean and **its result was ignored**. When the rename succeeds (the common case) the survivor is `newFile` and the `finally` deletes the now-absent UUID path (a no-op) — everything works. But if the rename returns `false`, the bytes stay at the UUID path, `newFile` is never created, and the `finally { tmpFile.delete() }` deletes the only copy. The dialog is still shown (the rename result was never checked), so the user sees "Download completed" over an empty directory.
The download path is **shared by every platform and asset type** (`.AppImage`, `.deb`, Windows `.msi`, macOS `.dmg`), so this affected all of them — most acutely `.deb`, where the in-app "Install" button is hidden and "Open file location" is the only way forward.
## Fix
Replace the unchecked `renameTo` with `Files.move(..., REPLACE_EXISTING)`:
```kotlin
val newFile = File(file.parentFile, asset.name)
Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
```
Behaviour:
- **Same in-place rename in the normal case.** Verified that a same-directory `Files.move` preserves the inode — it executes the same `rename(2)` syscall as `renameTo`, with no copy. No behaviour or performance change on the happy path.
- **Recovers when an in-place rename is not possible** (e.g. cross-filesystem): falls back to copy-then-delete, so `newFile` still ends up present.
- **Surfaces genuine failures** by throwing, which the existing outer `catch (e: Exception)` in `downloadAsset` already handles (logs the error) — instead of silently deleting the download and showing a misleading "Download completed" dialog.
One line changed (plus a clarifying comment). `Files` / `StandardCopyOption` are already imported.
## Compatibility impact
Before this change the updater's download step worked only on *some* Linux systems — it failed on those where `File.renameTo` returns `false`. In particular it did **not** work on **Whonix**, where the "Download completed" dialog appeared over an empty folder and the update could not proceed. `Files.move` succeeds on those systems too (in-place rename when possible, copy+delete otherwise, throwing only on genuine failure), so this fix expands the set of platforms on which the in-app updater works — Whonix included — without changing behaviour where `renameTo` already succeeded.
## Scope / out of scope
- This change is limited to the shared download step; it fixes the reported symptom on every OS at once.
- The per-OS *install* paths are untouched. A separate, related fragility remains in the macOS install branch (`File("/Applications/SimpleX.app").renameTo(...)` return value ignored at the app-replace/restore steps); it is a different symptom (botched/missing install, not a lost download) and is left for a follow-up, consistent with the out-of-scope list in `plans/2026-05-16-desktop-updater-fixes.md`.
@@ -0,0 +1,65 @@
# iOS: open SimpleX links in chat messages via in-app connect flow
## Problem
On iOS, tapping a **SimpleX connection/invitation link inside message text** does nothing — it never reaches the connection flow. Reproduced on iPhone 17 (v6.5.2 and v6.5.5). On the same screens, tapping a web link (opens browser), a `mailto:`/`tel:` link, and the connection-link **card** all work. Notably it was **device-specific**: dead on an iPhone 17 but working on an iPhone 12 running the **same iOS version**, with only **one** SimpleX app installed.
## Root cause
Inline links are dispatched in `MsgContentView.handleTextTaps` (`apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift`):
- web links (`webLinkAttrKey`) → `openBrowserAlert``UIApplication.shared.open` (Safari)
- everything else → `UIApplication.shared.open(url)`
SimpleX links fell into the second branch. Two facts make this the bug:
1. **The URI is always the `simplex:` custom scheme.** The core markdown parser normalizes every connection link to the `simplex:` scheme via `simplexConnReqUri` / `simplexShortLink` (`src/Simplex/Chat/Markdown.hs:344,353`), regardless of whether the message contained `https://simplex.chat/…` or `simplex:/…` (see `tests/MarkdownTests.hs`). So the tap always calls `UIApplication.shared.open("simplex:/contact#…")`.
2. **`simplex:` is registered to this app, and the app is in the foreground.** `UIApplication.shared.open` is an OS app-launch API: it asks iOS (LaunchServices) to resolve the scheme to its registered app and activate it. Here the registered app is SimpleX itself, already foregrounded. **Re-entering the same foreground app through `open()` is not a supported operation**`open()` exists to hand a URL to a *different* app or the system. When the resolved target is the calling foreground app, the outcome is undefined: on some devices iOS still delivers the URL to `onOpenURL`, on others it is a silent no-op (`open` returns `false`, no error, no UI).
That undefined outcome is decided by device-local OS state (scheme resolution / launch services), which is why identical code + identical OS + identical single app behaved differently on the iPhone 12 (delivered → connected) and the iPhone 17 (no-op → dead). It is **not** an OS-version rule and **not** a multiple-handler conflict — both were ruled out (same OS; single install).
This also explains the full symptom matrix — only the path that re-enters the same app via `open()` is affected:
| Tapped | Dispatch | Target | Result |
|---|---|---|---|
| Web link | `openBrowserAlert``open()` | Safari (other app) | works |
| `mailto:` / `tel:` | `open()` | Mail / Phone (other apps) | works |
| Invite card | `planAndConnect` in-process | this app, no `open()` | works |
| Inline SimpleX link | `open("simplex:…")` | this app (self), foreground | undefined → dead |
The underlying cause is using the **wrong mechanism**: an OS hand-off API to perform an **in-app** action. Every other connect path handles the connection in-process and never leaves the app:
- the card: `planAndConnect` directly (`FramedItemView.swift`)
- the share extension: `ShareSheet.openExternalLink` sets `ChatModel.appOpenUrl`
- multiplatform: `openVerifiedSimplexUri``connectIfOpenedViaUri``planAndConnect`
Inline links were the lone exception delegating to the OS, making them hostage to undefined self-open behavior.
## Fix
Restore the three-way dispatch the multiplatform clients use (`WEB_URL` / `OTHER_URL` / `SIMPLEX_URL`):
- web → `openBrowserAlert` (unchanged)
- `mailto:` / `tel:``UIApplication.shared.open` (unchanged — these target other apps)
- **SimpleX → `ChatModel.appOpenUrl`** — the same sink `onOpenURL` feeds, leading to `connectViaUrl``planAndConnect`, entirely **in-process** with no OS round-trip
SimpleX links are identified by a dedicated attribute key (`simplexLinkAttrKey`) set on the `.simplexLink` format, mirroring the multiplatform `SIMPLEX_URL` annotation tag, rather than sniffing the URL string — so all link types (contact, invitation, group, channel, relay) are covered.
This is correct regardless of the exact device-local trigger, because it removes the dependency on iOS re-delivering a self-owned URL. The invite card already proves the in-process path works on the affected device.
Also fixes the same issue for the **"Send questions and ideas"** (Settings) and **"connect to SimpleX Chat developers"** (chat help) buttons, which opened `simplexTeamURL` (a `simplex:` link) the same broken way.
## Scope
- `apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift` — three-way tap dispatch + `simplexLinkAttrKey`
- `apps/ios/Shared/Views/UserSettings/SettingsView.swift`, `apps/ios/Shared/Views/ChatList/ChatHelp.swift` — route `simplexTeamURL` in-process
No behavior change for web / `mailto:` / `tel:` links.
## Verification
- Tap an inline SimpleX invitation/contact link in a received message → the connection sheet opens (on iPhone 17, where it was previously dead).
- The two developer-contact buttons open the connect flow.
- Web links still open the browser; `mailto:`/`tel:` still open Mail/Phone.
- Optional, to confirm the device-local nature: open a `simplex:/contact#…` link from another app (e.g. Notes) on the affected device — if that is also dead there but works on a second device, it confirms the difference is device-local scheme resolution rather than app code.
+3 -2
View File
@@ -38,8 +38,9 @@ scripts/desktop/prepare-openssl-windows.sh
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
rm -rf $BUILD_DIR 2>/dev/null || true
# Existence of this directory produces build error: cabal's bug
rm -rf dist-newstyle/src/direct-sq* 2>/dev/null || true
# Existence of these directories produces build error: cabal's bug
# (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows)
rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* 2>/dev/null || true
rm cabal.project.local 2>/dev/null || true
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."376d6a261a1074717aed65ad97bb6f2a9532011b" = "1j83kzjcgjr7ngbamby96r90yal80c6kv79l9shy05mppmp73f4y";
"https://github.com/simplex-chat/simplexmq.git"."92598c2ddb06cfc2c19797a2c900cffcb8af4d5c" = "0x98w50vcb2qwdxgkgfvygn1vzclb12zg1k6kcs9rrw54rhmvfmp";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.5.5.0
version: 6.5.6.1
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
+4 -4
View File
@@ -3675,7 +3675,7 @@ processChatCommand cxt nm = \case
profileToSend <-
presentUserBadge user incognitoProfile $ case gInfo_ of
Just gInfo_' ->
let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_'
let allowSimplexLinks = maybe True groupUserAllowSimplexLinks gInfo_'
in userProfileInGroup' user allowSimplexLinks incognitoProfile
Nothing -> userProfileDirect user incognitoProfile Nothing True
chatEvent <- case gInfo_ of
@@ -3988,10 +3988,10 @@ processChatCommand cxt nm = \case
conn <- createRelayConnection db cxt user (groupMemberId' relayMember) connId ConnPrepared chatV subMode
pure (relayMember, conn, groupRelay)
let GroupMember {memberRole = userRole, memberId = userMemberId} = membership
allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
allowSimplexLinks = groupUserAllowSimplexLinks gInfo
GroupMember {memberId = relayMemberId} = relayMember
relayInv = GroupRelayInvitation {
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
let relayInv = GroupRelayInvitation {
fromMember = MemberIdRole userMemberId userRole,
fromMemberProfile = membershipProfile,
relayMemberId,
+5 -4
View File
@@ -367,7 +367,7 @@ prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole
prohibitedSimplexLinks :: GroupInfo -> GroupMember -> MsgContent -> Maybe MarkdownList -> Bool
prohibitedSimplexLinks gInfo m mc ft =
not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo)
&& (isChatLink mc || maybe False (any ftIsSimplexLink) ft)
&& (isChatLink mc || maybe False (any ftIsSimplexLink) ft || hasObfuscatedSimplexLink (msgContentText mc))
where
isChatLink = \case
MCChat {} -> True
@@ -1177,7 +1177,7 @@ introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn
sendGroupMemberMessages user gInfo conn introEvts'
userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile
userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks
userProfileInGroup user = userProfileInGroup' user . groupUserAllowSimplexLinks
{-# INLINE userProfileInGroup #-}
userProfileInGroup' :: User -> Bool -> Maybe Profile -> Profile
@@ -1195,7 +1195,7 @@ memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, a
memberKey = MemberKey <$> memberPubKey
}
where
allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g
allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g && groupFeatureMemberAllowed SGFDirectMessages m g
redactedMemberProfile :: Bool -> Profile -> Profile
redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType, badge} =
@@ -1203,6 +1203,7 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes
where
removeSimplexLink s
| allowSimplexLinks = Just s
| hasObfuscatedSimplexLink s = Nothing
| otherwise = maybe (Just s) (\fts -> if any ftIsSimplexLink fts then Nothing else Just s) $ parseMaybeMarkdownList s
sendHistory :: User -> GroupInfo -> GroupMember -> CM ()
@@ -2129,7 +2130,7 @@ sendGroupMessages user gInfo scope asGroup members events = do
_ -> False
sendProfileUpdate = do
let members' = filter (`supportsVersion` memberProfileUpdateVersion) members
allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
allowSimplexLinks = groupUserAllowSimplexLinks gInfo
-- shouldSendProfileUpdate excludes incognito membership, so the badge is presented
profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p
void $ sendGroupMessage' user gInfo members' $ XInfo profileUpdate
+32 -23
View File
@@ -731,8 +731,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
ct <- getContactViaMember db cxt user m
liftIO $ setNewContactMemberConnRequest db user m cReq
liftIO $ (ct,) <$> getGroupLinkId db user gInfo
sendGrpInvitation ct m groupLinkId
toView $ CEvtSentGroupInvitation user gInfo ct m
if memberRole' membership >= GRAdmin
then do
sendGrpInvitation ct m groupLinkId
toView $ CEvtSentGroupInvitation user gInfo ct m
else messageError "processGroupMessage: group link host no longer has admin role"
where
sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> CM ()
sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do
@@ -813,7 +816,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
XGrpMemInfo memId _memProfile
| sameMemberId memId m -> do
let GroupMember {memberId = membershipMemId} = membership
allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
allowSimplexLinks = groupUserAllowSimplexLinks gInfo
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
-- TODO update member profile
-- [async agent commands] no continuation needed, but command should be asynchronous for stability
@@ -1535,9 +1538,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do
-- TODO [short links] deduplicate request by xContactId?
gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId
if useRelays' gInfo
then messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)"
else do
if
| useRelays' gInfo ->
messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)"
| memberRole' (membership gInfo) < GRAdmin ->
messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored join request because host is no longer admin"
| otherwise -> do
acceptMember_ <- asks $ acceptMember . chatHooks . config
maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case
Right (acceptance, useRole)
@@ -1566,20 +1572,23 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
createRelayRequestGroup db cxt user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited
lift $ void $ getRelayRequestWorker True
xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM ()
xGrpRelayTest invId chatVRange challenge = do
privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn)
case privKey_ of
Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address")
Just privKey -> do
let sig = C.signatureBytes $ C.sign' privKey challenge
msg = XGrpRelayTest challenge (Just sig)
subMode <- chatReadVar subscriptionMode
chatVR <- chatVersionRange
let chatV = chatVR `peerConnChatVersion` chatVRange
(cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV
withStore $ \db -> do
Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode
liftIO $ setCommandConnId db user cmdId testCId
xGrpRelayTest invId chatVRange challenge
| isTrue userChatRelay && isNothing ucGroupId_ =
withAgent (`getConnLinkPrivKey` aConnId conn) >>= \case
Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address")
Just privKey -> do
let sig = C.signatureBytes $ C.sign' privKey challenge
msg = XGrpRelayTest challenge (Just sig)
subMode <- chatReadVar subscriptionMode
chatVR <- chatVersionRange
let chatV = chatVR `peerConnChatVersion` chatVRange
(cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV
withStore $ \db -> do
Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode
liftIO $ setCommandConnId db user cmdId testCId
| otherwise = messageError "relay test sent to non-relay link"
where
User {userChatRelay} = user
-- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays
-- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember)
memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM ()
@@ -2701,7 +2710,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
pure m
where
contentChanged = not (sameProfileContent (redactedMemberProfile allowSimplexLinks (fromLocalProfile p)) (redactedMemberProfile allowSimplexLinks p'))
allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo
allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo && groupFeatureMemberAllowed SGFDirectMessages m gInfo
updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of
Just bc | isMainBusinessMember bc m -> do
g' <- withStore $ \db -> updateGroupProfileFromMember db user g p'
@@ -3090,7 +3099,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
pure toMember
subMode <- chatReadVar subscriptionMode
-- [incognito] send membership incognito profile, create direct connection as incognito
let allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
let allowSimplexLinks = groupUserAllowSimplexLinks gInfo
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile
-- [async agent commands] no continuation needed, but commands should be asynchronous for stability
@@ -3113,7 +3122,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
where
GroupMember {memberId = membershipMemId} = membership
changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent
| senderRole < GRAdmin || senderRole < fromRole =
| senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) =
messageError "x.grp.mem.role with insufficient member permissions" $> Nothing
| otherwise = do
withStore' $ \db -> updateGroupMemberRole db user member memRole
+11
View File
@@ -18,6 +18,7 @@ import Control.Monad
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import qualified Data.Attoparsec.ByteString.Char8 as AB
import Data.Attoparsec.Text (Parser)
import qualified Data.Attoparsec.Text as A
import Data.ByteString.Char8 (ByteString)
@@ -191,6 +192,16 @@ isLink = \case
hasLinks :: MarkdownList -> Bool
hasLinks = any $ \(FormattedText f _) -> maybe False isLink f
hasObfuscatedSimplexLink :: Text -> Bool
hasObfuscatedSimplexLink t =
fromRight False $ AB.parseOnly findLinkP $ encodeUtf8 $ T.filter (not . isSpace) t
where
findLinkP = do
AB.skipWhile (\c -> c /= 's' && c /= 'h') -- links start only with "simplex:" or "https://"
(True <$ (strP :: AB.Parser AConnectionLink))
<|> (AB.anyChar *> findLinkP)
<|> pure False
markdownP :: Parser Markdown
markdownP = mconcat <$> A.many' fragmentP
where
+1 -1
View File
@@ -932,7 +932,7 @@ parseChatMessages msg = case B.head msg of
Right (compressed :: L.NonEmpty Compressed) -> case traverse decompressedSize compressed of
Nothing -> [Left "compressed size not specified"]
Just sizes
| sum sizes > maxDecompressedMsgLength -> [Left "decompressed size exceeds limit"]
| any (maxDecompressedMsgLength <) sizes || maxDecompressedMsgLength < sum sizes -> [Left "decompressed size exceeds limit"]
| otherwise -> concatMap (either (\e -> [Left e]) parseUncompressed' . decompress1) compressed
parseUncompressed' "" = [Left "empty string"]
parseUncompressed' s = parseUncompressed (B.head s) s
+1 -1
View File
@@ -355,7 +355,7 @@ getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cur
map (toContactMember currentTs cxt user) <$>
DB.query
db
(groupMemberQuery <> " WHERE m.group_member_id IN ?")
(groupMemberQuery <> " WHERE m.group_member_id IN ? ORDER BY m.group_member_id ASC")
(Only (In gmIds))
#else
rights <$> mapM (runExceptT . getGroupMemberById db cxt user) gmIds
@@ -1134,6 +1134,19 @@ Query:
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE group_members
SET member_role = 'owner'
WHERE member_category = 'user'
AND group_id IN (
SELECT group_id FROM groups WHERE local_display_name = 'team'
)
Plan:
SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?)
LIST SUBQUERY 1
SCAN groups USING COVERING INDEX sqlite_autoindex_groups_1
Query:
DELETE FROM chat_item_reactions
WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ?
+6
View File
@@ -638,6 +638,12 @@ groupFeatureUserAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupInfo -
groupFeatureUserAllowed feature GroupInfo {membership = GroupMember {memberRole}, fullGroupPreferences} =
groupFeatureMemberAllowed' feature memberRole fullGroupPreferences
-- A connection link in a profile description enables a direct connection, so a description
-- keeps its links only when both SimpleX links and direct messages are allowed.
groupUserAllowSimplexLinks :: GroupInfo -> Bool
groupUserAllowSimplexLinks g =
groupFeatureUserAllowed SGFSimplexLinks g && groupFeatureUserAllowed SGFDirectMessages g
mergeUserChatPrefs :: User -> Contact -> FullPreferences
mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct)
+37
View File
@@ -6,6 +6,7 @@ module ChatTests.ChatRelays where
import ChatClient
import ChatTests.DBUtils
import ChatTests.Groups (memberJoinChannel, memberJoinChannel', prepareChannel, prepareChannel', prepareChannel1Relay, setupRelay)
import ChatTests.Profiles (addTestBadge, issueTestBadge, testBadgeKeys)
import ChatTests.Utils
import Control.Concurrent (threadDelay)
import qualified Data.Aeson as J
@@ -14,8 +15,10 @@ import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import ProtocolTests (testGroupProfile)
import Simplex.Chat.Controller (ChatConfig (..))
import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..))
import Simplex.Chat.Types (GroupProfile (..))
import Simplex.Messaging.Crypto.BBS (bbsKeyGen)
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import Simplex.Messaging.Util (decodeJSON)
import Test.Hspec hiding (it)
@@ -32,6 +35,40 @@ chatRelayTests = do
it "share channel card in direct chat" testShareChannelDirect
it "share channel card in group" testShareChannelGroup
it "share channel card in channel" testShareChannelChannel
describe "channel badges" $ do
it "subscriber and owner see each other's badges forwarded by the relay" testChannelMemberBadges
-- A channel owner and a subscriber each hold a supporter badge; their member profiles only reach
-- each other forwarded by the relay. Both sides should still see the other's active badge.
testChannelMemberBadges :: HasCallStack => TestParams -> IO ()
testChannelMemberBadges ps = do
Right (pk, sk) <- bbsKeyGen
let cfg = testCfg {badgePublicKeys = testBadgeKeys pk}
withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice ->
withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatCfgOpts ps cfg testOpts "cath" cathProfile $ \cath -> do
addTestBadge alice =<< issueTestBadge sk Nothing
addTestBadge cath =<< issueTestBadge sk Nothing
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
-- a channel message lets the relay-forwarded member profiles settle on both sides
alice #> "#team hi"
bob <# "#team> hi"
cath <# "#team> hi [>>]"
threadDelay 1000000
-- owner and subscriber are connected only via the relay, so /i shows the badge then "member not connected" for both
alice ##> "/i #team cath"
alice <## "group ID: 1"
alice <##. "member ID: "
alice <## "supporter badge - active"
alice <## "no expiry"
alice <## "member not connected"
cath ##> "/i #team alice"
cath <## "group ID: 1"
cath <##. "member ID: "
cath <## "supporter badge - active"
cath <## "no expiry"
cath <## "member not connected"
testGetSetChatRelays :: HasCallStack => TestParams -> IO ()
testGetSetChatRelays ps =
+52
View File
@@ -81,6 +81,7 @@ chatGroupTests = do
it "group live message" testGroupLiveMessage
it "update group profile" testUpdateGroupProfile
it "update member role" testUpdateMemberRole
it "check owner role change" testOwnerRoleChange
it "group description is shown as the first message to new members" testGroupDescription
it "moderate message of another group member" testGroupModerate
it "moderate own message (should process as deletion)" testGroupModerateOwn
@@ -108,6 +109,7 @@ chatGroupTests = do
it "invitee incognito" testGroupLinkInviteeIncognito
it "incognito - join/invite" testGroupLinkIncognitoJoinInvite
it "group link member role" testGroupLinkMemberRole
it "demotion does not remove group link" testGroupLinkDemotedAdmin
it "host profile received" testGroupLinkHostProfileReceived
it "existing contact merged" testGroupLinkExistingContactMerged
describe "group links - member screening" $ do
@@ -1608,6 +1610,37 @@ testUpdateMemberRole =
alice ##> "/mr team alice admin"
alice <## "bad chat command: can't change role for self"
testOwnerRoleChange :: HasCallStack => TestParams -> IO ()
testOwnerRoleChange =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
createGroup3 "team" alice bob cath
void $ withCCTransaction cath $ \db ->
DB.execute_
db
[sql|
UPDATE group_members
SET member_role = 'owner'
WHERE member_category = 'user'
AND group_id IN (
SELECT group_id FROM groups WHERE local_display_name = 'team'
)
|]
cath ##> "/mr #team bob owner"
cath <## "#team: you changed the role of bob to owner"
concurrentlyN_
[ alice <## "error: x.grp.mem.role with insufficient member permissions",
bob <## "error: x.grp.mem.role with insufficient member permissions"
]
bob ##> "/ms team"
bob
<### [ "alice (Alice): owner, host, connected",
"bob (Bob): admin, you, connected",
"cath (Catherine): admin, connected"
]
testGroupDescription :: HasCallStack => TestParams -> IO ()
testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do
connectUsers alice bob
@@ -2929,6 +2962,25 @@ testGroupLinkMemberRole =
bob <## "#team: cath changed your role from member to admin"
alice <## "#team: cath changed the role of bob from member to admin"
testGroupLinkDemotedAdmin :: HasCallStack => TestParams -> IO ()
testGroupLinkDemotedAdmin =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob _cath -> do
createGroup2' "team" alice (bob, GRAdmin) True
bob ##> "/create link #team member"
_gLink <- getGroupLink bob "team" GRMember True
alice ##> "/mr #team bob member"
concurrentlyN_
[ alice <## "#team: you changed the role of bob to member",
bob <## "#team: alice changed your role from admin to member"
]
-- demotion does not remove bob's group link (it is preserved, usable again on re-promotion)
bob ##> "/show link #team"
void $ getGroupLink bob "team" GRMember False
testGroupLinkHostIncognito :: HasCallStack => TestParams -> IO ()
testGroupLinkHostIncognito =
testChat2 aliceProfile bobProfile $
+6
View File
@@ -2903,6 +2903,12 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil
bob <## "bad chat command: feature not allowed SimpleX links"
bob ##> ("/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"" <> inv <> "\\ntest\"}}]")
bob <## "bad chat command: feature not allowed SimpleX links"
-- a link split with a space or a newline is still blocked
let (lnk1, lnk2) = splitAt 12 inv
bob ##> ("#team \"" <> lnk1 <> " " <> lnk2 <> "\"")
bob <## "bad chat command: feature not allowed SimpleX links"
bob ##> ("#team \"" <> lnk1 <> "\\n" <> lnk2 <> "\"")
bob <## "bad chat command: feature not allowed SimpleX links"
(alice </)
(cath </)
bob `send` ("@alice \"" <> inv <> "\\ntest\"")
+19
View File
@@ -25,6 +25,7 @@ markdownTests = do
textColor
textWithUri
textWithHyperlink
obfuscatedSimplexLinks
textWithEmail
textWithPhone
textWithMentions
@@ -284,6 +285,24 @@ textWithHyperlink = describe "text with HyperLink without link text" do
"[click here](example.com)" <==> "[click here](example.com)"
"[click here](https://example.com )" <==> "[click here](https://example.com )"
obfuscatedSimplexLinks :: Spec
obfuscatedSimplexLinks = describe "SimpleX links obfuscated with whitespace" do
let addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw"
inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D"
let spaced s = T.replace "://" ":// " s -- insert a space right after the scheme
it "detects links split with spaces or newlines" do
hasObfuscatedSimplexLink addr `shouldBe` True
hasObfuscatedSimplexLink (spaced addr) `shouldBe` True
hasObfuscatedSimplexLink (T.intercalate "\n" $ T.chunksOf 8 addr) `shouldBe` True
hasObfuscatedSimplexLink ("connect with me: " <> spaced addr) `shouldBe` True
hasObfuscatedSimplexLink (T.intercalate " " $ T.chunksOf 8 $ "https://simplex.chat" <> inv) `shouldBe` True
it "detects a split link followed by other text" do
hasObfuscatedSimplexLink (spaced addr <> "\nplease connect") `shouldBe` True
it "ignores text without a SimpleX link" do
hasObfuscatedSimplexLink "" `shouldBe` False
hasObfuscatedSimplexLink "hello there, this is a normal message" `shouldBe` False
hasObfuscatedSimplexLink "see https://example.com/page?ref=123 for details" `shouldBe` False
email :: Text -> Markdown
email = Markdown $ Just Email