diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index dc672adda1..919f83d108 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -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] diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 22b64ec92e..f3abc6801e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index d85488cefc..db31c05dce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -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) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 0347f25e6d..ac7da8e3e5 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -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) --- diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 0f9e198cc1..c759a7453c 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -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 diff --git a/cabal.project b/cabal.project index a917c35c97..27f82d85bd 100644 --- a/cabal.project +++ b/cabal.project @@ -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 diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 052255eb1e..851b4a4497 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -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", diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 2897591b33..fdde144307 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -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"] diff --git a/plans/2026-05-20-fix-copy-non-msg-items.md b/plans/2026-05-20-fix-copy-non-msg-items.md new file mode 100644 index 0000000000..4f0c554357 --- /dev/null +++ b/plans/2026-05-20-fix-copy-non-msg-items.md @@ -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. diff --git a/plans/2026-05-29-fix-space-in-interface.md b/plans/2026-05-29-fix-space-in-interface.md new file mode 100644 index 0000000000..e3d8ba7cc4 --- /dev/null +++ b/plans/2026-05-29-fix-space-in-interface.md @@ -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`. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 8bdc81fee3..5ce5bca19a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -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"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c0b2322a3c..4342a8821e 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -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 diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 1db400c62a..efa010ceb1 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -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 -> "@" diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 51f4324bf0..e96d531805 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -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