Merge branch 'stable'

This commit is contained in:
Evgeny Poberezkin
2026-05-30 16:01:27 +01:00
14 changed files with 115 additions and 13 deletions
+4
View File
@@ -5143,6 +5143,10 @@ public enum SimplexLinkType: String, Decodable, Hashable {
public struct SimplexNameInfo: Decodable, Equatable, Hashable {
public var nameType: SimplexNameType
public var nameDomain: SimplexNameDomain
}
public struct SimplexNameDomain: Decodable, Equatable, Hashable {
public var nameTLD: SimplexTLD
public var domain: String
public var subDomain: [String]
@@ -4764,6 +4764,11 @@ enum class SimplexLinkType(val linkType: String) {
@Serializable
data class SimplexNameInfo(
val nameType: SimplexNameType,
val nameDomain: SimplexNameDomain
)
@Serializable
data class SimplexNameDomain(
val nameTLD: SimplexTLD,
val domain: String,
val subDomain: List<String>
@@ -217,6 +217,9 @@ class SelectionManager {
val hi = maxOf(r.startIndex, r.endIndex)
return (lo..hi).mapNotNull { idx ->
val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null
// Only real messages are copyable. Event/info items (e.g. "connected", calls, e2ee info)
// have no msgContent and are never highlighted as selected, so they must never be copied.
if (ci.content.msgContent == null) return@mapNotNull null
if (ci.meta.itemDeleted != null && (!revealedItems.contains(ci.id) || ci.isDeletedContent)) return@mapNotNull null
val sel = selectedRange(range, idx) ?: return@mapNotNull null
selectedItemCopiedText(ci, sel, linkMode)
+12 -3
View File
@@ -165,6 +165,7 @@ This file is generated automatically.
- [SecurityCode](#securitycode)
- [SimplePreference](#simplepreference)
- [SimplexLinkType](#simplexlinktype)
- [SimplexNameDomain](#simplexnamedomain)
- [SimplexNameInfo](#simplexnameinfo)
- [SimplexNameType](#simplexnametype)
- [SimplexTLD](#simplextld)
@@ -3445,15 +3446,23 @@ A_QUEUE:
- "relay"
---
## SimplexNameDomain
**Record type**:
- nameTLD: [SimplexTLD](#simplextld)
- domain: string
- subDomain: [string]
---
## SimplexNameInfo
**Record type**:
- nameType: [SimplexNameType](#simplexnametype)
- nameTLD: [SimplexTLD](#simplextld)
- domain: string
- subDomain: [string]
- nameDomain: [SimplexNameDomain](#simplexnamedomain)
---
+2
View File
@@ -345,6 +345,7 @@ chatTypesDocsData =
(sti @SecurityCode, STRecord, "", [], "", ""),
(sti @SimplePreference, STRecord, "", [], "", ""),
(sti @SimplexLinkType, STEnum, "XL", [], "", ""),
(sti @SimplexNameDomain, STRecord, "", [], "", ""),
(sti @SimplexNameInfo, STRecord, "", [], "", ""),
(sti @SimplexNameType, STEnum, "NT", [], "", ""),
(sti @SimplexTLD, STEnum, "TLD", [], "", ""),
@@ -561,6 +562,7 @@ deriving instance Generic RelayStatus
deriving instance Generic ReportReason
deriving instance Generic SecurityCode
deriving instance Generic SimplexLinkType
deriving instance Generic SimplexNameDomain
deriving instance Generic SimplexNameInfo
deriving instance Generic SimplexNameType
deriving instance Generic SimplexTLD
+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: 04960864c4ec958c21c20e2a7d618524138b2e72
tag: 61ee188ee0839c34de16bc17934f04ebc7fd4873
source-repository-package
type: git
@@ -3850,13 +3850,17 @@ export enum SimplexLinkType {
Relay = "relay",
}
export interface SimplexNameInfo {
nameType: SimplexNameType
export interface SimplexNameDomain {
nameTLD: SimplexTLD
domain: string
subDomain: string[]
}
export interface SimplexNameInfo {
nameType: SimplexNameType
nameDomain: SimplexNameDomain
}
export enum SimplexNameType {
PublicGroup = "publicGroup",
Contact = "contact",
@@ -2689,12 +2689,15 @@ class SimplePreference(TypedDict):
SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"]
class SimplexNameInfo(TypedDict):
nameType: "SimplexNameType"
class SimplexNameDomain(TypedDict):
nameTLD: "SimplexTLD"
domain: str
subDomain: list[str]
class SimplexNameInfo(TypedDict):
nameType: "SimplexNameType"
nameDomain: "SimplexNameDomain"
SimplexNameType = Literal["publicGroup", "contact"]
SimplexTLD = Literal["simplex", "testing", "web"]
@@ -0,0 +1,50 @@
# Desktop: text selection copies non-message event items
Branch: `nd/fix-copy-non-msg-items` · code commit `a536452ca` · PR [#6993](https://github.com/simplex-chat/simplex-chat/pull/6993).
## 1. Problem statement
The Desktop "select text in messages" feature (PR [#6725](https://github.com/simplex-chat/simplex-chat/pull/6725)) lets the user drag a selection across several message bubbles and copy it. When the selection spans a chat event/info item — a "connected" event, a member "joined"/"left" event, a call event, an e2ee-info line, a feature-change line — the copied text includes that item's text, even though the item is never shown highlighted as part of the selection.
Expected: only real message text is copied. Observed: event/info text such as "connected" is appended into the clipboard between the selected messages.
### Privacy note
Event/info item text is produced from localized string resources (`generalGetString`, `RcvConnEvent.text`, etc.) — it is rendered in the language the user has chosen for the app, whereas real message text is not. A user who selects and copies a long span of messages carelessly, then pastes it into another chat or app, can therefore leak their chosen interface language through the event lines mixed into the paste. For a privacy-focused messenger this is a metadata leak, not only a cosmetic bug.
## 2. Root cause
`SelectionManager.getSelectedCopiedText` (`TextSelection.kt`) builds the copied string by iterating every merged-item index between the selection bounds and emitting each item's text:
```kotlin
return (lo..hi).mapNotNull { idx ->
val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null
if (ci.meta.itemDeleted != null && ...) return@mapNotNull null
val sel = selectedRange(range, idx) ?: return@mapNotNull null
selectedItemCopiedText(ci, sel, linkMode)
}
```
For an *interior* item in a multi-item selection, `selectedRange` returns `0 until Int.MAX_VALUE` — the whole item is treated as selected — so its text is emitted unconditionally. The only items previously skipped were deleted ones.
Anchor/focus character tracking (`setupItemSelection`) is wired up only for real message views (`FramedItemView`, `EmojiItemView`); event/info items never register offsets and never compute a highlight range. So an event item caught between two selected messages is invisible to the highlight but fully visible to `getSelectedCopiedText`. The copy logic and the on-screen selection disagreed.
The distinguishing property: a real message has `ci.content.msgContent != null`; every event/info `CIContent` variant returns `msgContent == null`.
## 3. Solution summary
`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt` — one guard, +3 lines.
In `getSelectedCopiedText`, skip any item whose `content.msgContent` is null, alongside the existing deleted-item filter:
```kotlin
if (ci.content.msgContent == null) return@mapNotNull null
```
Only real messages now contribute copied text — which is exactly the set of items that are selectable and highlighted, so the clipboard matches the visible selection. `content.msgContent` is the existing model property used elsewhere to tell a real message apart from an event/info item.
## 4. Alternatives considered (and rejected)
- **Special-case only "connected" events.** Matches the literal report but leaves the identical bug for every other event/info item (joined/left, calls, e2ee info, feature changes) — same class, same language leak.
- **Make event items non-selectable / consume the drag.** Larger change to the selection gesture; event items are already non-anchorable, and the bug is purely in the copy aggregation, not in the gesture.
- **Filter at the call site (`onCopySelection`).** Duplicates the message/non-message distinction outside the one function that owns copied-text assembly; `getSelectedCopiedText` is the correct single source.
@@ -0,0 +1,13 @@
# Fix `/start remote host` parser when iface name contains a space
## Problem
Picking a non-default interface (e.g. Windows `Ethernet 2`) on the "Link a mobile" screen and refreshing the QR code returns `error chat: error chat commandError Failed reading: empty`. The desktop UI sends `/start remote host new addr=… iface="Ethernet 2" port=…`; the chat backend rejects it as an unparseable command. Without a workaround the user can't pin a specific local interface for the mobile-link controller.
## Cause
`rcCtrlAddressP` parses the iface value with `jsonP <|> text1P` (`src/Simplex/Chat/Library/Commands.hs:5549`). `jsonP` calls `A.takeByteString`, consuming *all* remaining input, then runs `eitherDecodeStrict'`. When `port=…` follows `iface=…` the strict decode fails because the JSON value `"Ethernet 2"` has trailing junk after it, so attoparsec backtracks to `text1P` (`takeTill (== ' ')`). `text1P` stops at the first space — inside the JSON quotes — leaving `2" port=12345` which nothing downstream can consume, `A.endOfInput` fails, the whole `A.choice` exhausts and surfaces attoparsec's `empty` message. With an iface name that has no space (`"lo"`) the bug is invisible: text1P swallows the full quoted token and the rest parses, but the interface name is stored with literal quotes so the iface preference silently never matches a real adapter anyway.
## Fix
Replace `jsonP` with a bounded `quotedP` that consumes only the bytes between `"…"` and leaves trailing fields for the next parser. `text1P` is kept as the unquoted fallback. Two-line change in `Commands.hs` plus a pure regression test in `tests/RemoteTests.hs` that asserts `parseChatCommand` of `/start remote host new addr=192.168.1.5 iface="Ethernet 2" port=12345` produces `RCCtrlAddress _ "Ethernet 2"` with port `12345`.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."04960864c4ec958c21c20e2a7d618524138b2e72" = "0bxirnczwxjdm8jdvqalf0jvllvpmvbxwvjcz6aq9z7k7rx8ijai";
"https://github.com/simplex-chat/simplexmq.git"."61ee188ee0839c34de16bc17934f04ebc7fd4873" = "0ap5khdfwzi9gzc96y916hngmbl3c4ivkbf33anmv2r8n15bkkp0";
"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";
+2 -1
View File
@@ -5550,7 +5550,8 @@ chatCommandP =
addressAA = AddressSettings False <$> (Just . AutoAccept <$> (" incognito=" *> onOffP <|> pure False)) <*> autoReply
businessAA = " business" *> (AddressSettings True (Just $ AutoAccept False) <$> autoReply)
autoReply = optional (A.space *> msgContentP)
rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P))
rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (quotedP <|> text1P))
quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"')
text1P = safeDecodeUtf8 <$> A.takeTill (== ' ')
char_ = optional . A.char
+2 -2
View File
@@ -10,7 +10,7 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat.Markdown
import Simplex.Messaging.Agent.Protocol (SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..))
import Simplex.Messaging.Agent.Protocol (SimplexNameDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util ((<$$>))
import System.Console.ANSI.Types
@@ -381,7 +381,7 @@ command' :: Text -> Text -> FormattedText
command' = FormattedText . Just . Command
sname :: SimplexNameType -> SimplexTLD -> Text -> [Text] -> Text -> Markdown
sname nt ns dom sub txt = markdown (SimplexName $ SimplexNameInfo nt ns dom sub) (pfx <> txt)
sname nt ns dom sub txt = markdown (SimplexName $ SimplexNameInfo nt (SimplexNameDomain ns dom sub)) (pfx <> txt)
where
pfx = case nt of NTPublicGroup -> "#"; NTContact -> "@"
+9 -1
View File
@@ -16,7 +16,8 @@ import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.List (find, isPrefixOf)
import qualified Data.Map.Strict as M
import Simplex.Chat.Controller (ChatConfig (..), versionNumber)
import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), versionNumber)
import Simplex.Chat.Library.Commands (parseChatCommand)
import qualified Simplex.Chat.Controller as Controller
import Simplex.Chat.Mobile.File
import Simplex.Chat.Remote (remoteFilesFolder)
@@ -24,6 +25,7 @@ import Simplex.Chat.Remote.Types
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util
import Simplex.RemoteControl.Types (RCCtrlAddress (..))
import System.FilePath ((</>))
import Test.Hspec hiding (it)
import UnliftIO
@@ -32,6 +34,12 @@ import UnliftIO.Directory
remoteTests :: SpecWith TestParams
remoteTests = describe "Remote" $ do
describe "/start remote host parser" $ do
it "parses iface name with a space followed by port=" $ \_ ->
parseChatCommand "/start remote host new addr=192.168.1.5 iface=\"Ethernet 2\" port=12345"
`shouldSatisfy` \case
Right (StartRemoteHost Nothing (Just (RCCtrlAddress _ "Ethernet 2")) (Just 12345)) -> True
_ -> False
xdescribe "No compression" $ aroundWith (. ((False, False),)) runRemoteTests
xdescribe "Mobile offers compression" $ aroundWith (. ((True, False),)) runRemoteTests
xdescribe "Desktop offers compression" $ aroundWith (. ((False, True),)) runRemoteTests