Merge branch 'stable' into nd/fix-subscription-status-indicator

This commit is contained in:
Evgeny Poberezkin
2026-06-20 22:36:58 +01:00
12 changed files with 139 additions and 16 deletions
@@ -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) {
@@ -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
}
}
}
@@ -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
+2 -2
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,7 +3988,7 @@ 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
allowSimplexLinks = groupUserAllowSimplexLinks gInfo
GroupMember {memberId = relayMemberId} = relayMember
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership
let relayInv = GroupRelayInvitation {
+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
+3 -3
View File
@@ -813,7 +813,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
@@ -2701,7 +2701,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 +3090,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
+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
+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)
+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