Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2026-07-06 17:01:46 +01:00
11 changed files with 223 additions and 59 deletions
+1 -1
View File
@@ -192,7 +192,7 @@ extension ThemeModeOverride {
background: colors.background != tc.background ? colors.background : nil,
surface: colors.surface != tc.surface ? colors.surface : nil,
title: colors.title != tc.title ? colors.title : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primaryVariant2 : nil,
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
@@ -131,7 +131,12 @@ struct ComposeState {
}
var memberMentions: [String: Int64] {
self.mentions.compactMapValues { $0.memberRef?.groupMemberId }
var result: [String: Int64] = [:]
for ft in parsedMessage {
if result.count >= MAX_NUMBER_OF_MENTIONS { break }
if case let .mention(name) = ft.format, let id = mentions[name]?.memberRef?.groupMemberId { result[name] = id }
}
return result
}
var editing: Bool {
@@ -47,7 +47,7 @@ struct GroupMentionsView: View {
LazyVStack(spacing: 0) {
ForEach(Array(filtered.enumerated()), id: \.element.wrapped.groupMemberId) { index, member in
let mentioned = mentionMemberId == member.wrapped.memberId
let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
let disabled = composeState.memberMentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
ZStack(alignment: .bottom) {
memberRowView(member.wrapped, mentioned)
.contentShape(Rectangle())
@@ -124,14 +124,13 @@ struct GroupMentionsView: View {
}
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
removeUnusedMentions(parsedMsg)
if let (ft, r) = selectedMarkdown(parsedMsg, range) {
switch ft.format {
case let .mention(name):
isVisible = true
mentionName = name
mentionRange = r
mentionMemberId = composeState.mentions[name]?.memberId
mentionMemberId = composeState.memberMentions[name] != nil ? composeState.mentions[name]?.memberId : nil
if !m.membersLoaded {
Task {
await m.loadGroupMembers(groupInfo)
@@ -169,15 +168,6 @@ struct GroupMentionsView: View {
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
}
private func removeUnusedMentions(_ parsedMsg: [FormattedText]) {
let usedMentions: Set<String> = Set(parsedMsg.compactMap { ft in
if case let .mention(name) = ft.format { name } else { nil }
})
if usedMentions.count < composeState.mentions.count {
composeState = composeState.copy(mentions: composeState.mentions.filter({ usedMentions.contains($0.key) }))
}
}
private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? {
if pos < 0 || pos >= s.count { return nil }
let r = NSRange(location: pos, length: 1)
@@ -568,7 +568,7 @@ data class ThemeModeOverride (
background = if (colors.background?.colorFromReadableHex() != c.background) colors.background else null,
surface = if (colors.surface?.colorFromReadableHex() != c.surface) colors.surface else null,
title = if (colors.title?.colorFromReadableHex() != ac.title) colors.title else null,
primaryVariant2 = if (colors.primaryVariant2?.colorFromReadableHex() != ac.primaryVariant2) colors.primary else null,
primaryVariant2 = if (colors.primaryVariant2?.colorFromReadableHex() != ac.primaryVariant2) colors.primaryVariant2 else null,
sentMessage = if (colors.sentMessage?.colorFromReadableHex() != ac.sentMessage) colors.sentMessage else null,
sentQuote = if (colors.sentQuote?.colorFromReadableHex() != ac.sentQuote) colors.sentQuote else null,
receivedMessage = if (colors.receivedMessage?.colorFromReadableHex() != ac.receivedMessage) colors.receivedMessage else null,
@@ -133,15 +133,12 @@ data class ComposeState(
)
val memberMentions: Map<String, Long>
get() = this.mentions.mapNotNull {
val memberRef = it.value.memberRef
if (memberRef != null) {
it.key to memberRef.groupMemberId
} else {
null
}
}.toMap()
get() = parsedMessage
.mapNotNull { (it.format as? Format.Mention)?.memberName }
.distinct()
.mapNotNull { name -> mentions[name]?.memberRef?.groupMemberId?.let { name to it } }
.take(MAX_NUMBER_OF_MENTIONS)
.toMap()
val editing: Boolean
get() =
@@ -102,7 +102,6 @@ fun GroupMentions(
}
fun messageChanged(msg: ComposeMessage, parsedMsg: List<FormattedText>) {
removeUnusedMentions(composeState, parsedMsg)
val selected = selectedMarkdown(parsedMsg, msg.selection)
if (selected != null) {
@@ -113,7 +112,7 @@ fun GroupMentions(
isVisible.value = true
mentionName.value = ft.format.memberName
mentionRange.value = r
mentionMemberId.value = composeState.value.mentions[mentionName.value]?.memberId
mentionMemberId.value = if (mentionName.value in composeState.value.memberMentions) composeState.value.mentions[mentionName.value]?.memberId else null
if (!chatModel.membersLoaded.value) {
scope.launch {
setGroupMembers(rhId, chatInfo.groupInfo, chatModel)
@@ -211,7 +210,7 @@ fun GroupMentions(
},
contentAlignment = Alignment.BottomStart
) {
val showMaxReachedBox = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && isVisible.value && composeState.value.mentions[mentionName.value] == null
val showMaxReachedBox = composeState.value.memberMentions.size >= MAX_NUMBER_OF_MENTIONS && isVisible.value && mentionName.value !in composeState.value.memberMentions
LazyColumnWithScrollBarNoAppBar(
Modifier
.heightIn(max = MAX_PICKER_HEIGHT)
@@ -229,7 +228,7 @@ fun GroupMentions(
Divider()
}
val mentioned = mentionMemberId.value == member.memberId
val disabled = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && !mentioned
val disabled = composeState.value.memberMentions.size >= MAX_NUMBER_OF_MENTIONS && !mentioned
Row(
Modifier
.fillMaxWidth()
@@ -309,18 +308,3 @@ private fun selectedMarkdown(
parsedMsg[i] to TextRange(pos, pos + parsedMsg[i].text.length)
}
}
private fun removeUnusedMentions(composeState: MutableState<ComposeState>, parsedMsg: List<FormattedText>) {
val usedMentions = parsedMsg.mapNotNull { ft ->
when (ft.format) {
is Format.Mention -> ft.format.memberName
else -> null
}
}.toSet()
if (usedMentions.size < composeState.value.mentions.size) {
composeState.value = composeState.value.copy(
mentions = composeState.value.mentions.filterKeys { it in usedMentions }
)
}
}
@@ -29,6 +29,7 @@ import java.net.Proxy
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import kotlin.math.min
import kotlin.system.exitProcess
data class SemVer(
val major: Int,
@@ -315,6 +316,10 @@ private suspend fun downloadAsset(asset: GitHubAsset) {
call.execute().use { response ->
response.body?.use { body ->
body.byteStream().use { stream ->
// On Windows the install path exits the app before it can delete the downloaded file, so a
// previous update's installer can be left in the temp dir; remove it here at the next
// download instead of letting it accumulate. Other platforms delete the file after install.
if (desktopPlatform.isWindows()) File(tmpDir, asset.name).delete()
createTmpFileAndDelete { file ->
// It's important to close output stream (with use{}), otherwise, Windows cannot rename the file
file.outputStream().use { output ->
@@ -433,19 +438,12 @@ private suspend fun installAppUpdate(file: File) = withContext(Dispatchers.IO) {
}
}
desktopPlatform.isWindows() -> {
val process = Runtime.getRuntime().exec("msiexec /i ${file.absolutePath}"/* /qb */).onExit().join()
val startedInstallation = process.exitValue() == 0
if (!startedInstallation) {
Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}")
// Failed to start installation. show directory with the file for manual installation
desktopOpenDir(file.parentFile)
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title),
text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc)
)
file.delete()
}
// Launch the installer, then exit so our files are no longer locked. While the app runs it
// holds SimpleX.exe/JRE/DLLs open, which forces the MSI to defer replacement to a reboot and
// corrupts the upgrade (the app then fails to launch). Array form passes the path as a single
// argument so a space in the temp path does not break the command.
Runtime.getRuntime().exec(arrayOf("msiexec", "/i", file.absolutePath))
exitProcess(0)
}
desktopPlatform.isMac() -> {
// Default mount point if no other DMGs were mounted before
@@ -0,0 +1,107 @@
# Fix "error store invalidMention" and preserve mention bindings across in-place edits
## Symptom
Rare "Error sending message: error store invalidMention" when sending a group
message that contains an @mention.
## Root cause
When the user picks a member from the picker, the client inserts `@Name` into
the message text and records `mentions[Name] = memberId` in the compose
state. The original `removeUnusedMentions` only pruned that map when the
parsed `@name` count was **strictly less** than the map size:
```kotlin
if (usedMentions.size < composeState.value.mentions.size) { ... }
```
If the user **edits the inserted token in place** (e.g. fixes a typo, deletes
one character) without re-picking from the picker, the parser sees one
mention with the new name while the map still contains the old key. Both
have size 1, so the guard does not fire and the stale entry is sent. The
core's `getCIMentions` (`Internal.hs:267-271`) requires every key in the
client-supplied mentions map to also appear as a parsed `@name` token in the
message text, and throws `SEInvalidMention` otherwise.
A naive fix (prune whenever any key is missing from the parsed names) stops
the crash but also drops the binding the instant a letter is removed — so
typing the letter back leaves an unresolved `@name` rather than a real
mention.
## Approach
Stop mutating the compose-state mentions map while editing. Treat the map
as a sticky cache of `name → memberId` bindings recorded by the picker, and
filter against the currently-parsed text only at the points where a "current
set of mentions" is actually needed: sending, picker UX, name
disambiguation. This both eliminates the `SEInvalidMention` failure and
lets a round-trip edit (`@Usernama``@Usernam``@Usernama`) re-resolve
the original member.
## Changes
### 1. Drop `removeUnusedMentions`
- `apps/multiplatform/.../GroupMentions.kt`: delete the function and its call
in `messageChanged`.
- `apps/ios/.../GroupMentions.swift`: same.
### 2. Filter at send time via `memberMentions`
`ComposeState.memberMentions` intersects the cached map with the names
currently parsed in `parsedMessage`. All sends already route through this
getter, so this is the single chokepoint that determines what reaches the
core.
### 3. Picker "max reached" uses parsed-mention count
`GroupMentions.kt:213,231` and the Swift equivalent at `GroupMentions.swift:50`
switch from `composeState.mentions.size` to a count of `Format.Mention`
entries in `parsedMessage`. This keeps the limit aligned with what the
server will see, regardless of how many stale entries the cache holds.
### 4. `mentionMemberName` disambiguation uses parsed names
`ComposeView.kt`'s `mentionMemberName` walks the set of parsed mention names
instead of `mentions.containsKey`. Same for Swift. A stale cache entry no
longer pushes a fresh pick to an awkward `_1` suffix, and a picker tap on a
broken `@alic` cleanly replaces it with `@alice`.
### 5. Editing-an-existing-item path
`ComposeState(editingItem, ...)` seeds `mentions = editingItem.mentions`.
That stays as-is — it is already a sticky cache and the new `memberMentions`
getter handles filtering.
## Manual reproduction (original bug)
1. In a group, type `@` and pick a member. Text becomes `@Name ` and the
mentions map gets `{Name → memberId}`.
2. Place the cursor inside the inserted name and add or delete one character
(`@Nam`, `@Names`, etc.) — do not use the picker.
3. Tap send. Before the fix: send fails with `error store invalidMention`.
After the fix: message is sent.
## Edge cases to test
- Pick `Usernama`, delete last `a`, retype it → send: mention resolves to
the original member (restoration behaviour).
- Pick `Usernama`, delete the whole token → send: no mention. Cache still
holds a stale `Usernama` entry but `memberMentions` is empty.
- Pick `alice` (member A), delete the `@alice` text, then pick a different
`alice` (member B) → inserted as `@alice` (the cleaner, visible name);
cache key rebinds A→B as an explicit user action.
- Two members with same display name, pick A, edit `@alice``@alic`
`@alice` by typing only → A's binding is restored (typing never mutates
the cache; only picker taps do).
- Pick 3 members, delete one of the tokens — picker should not show
"max reached"; only 2 mentions are now in the text.
- Edit an existing sent message: original mentions render, surviving edits
re-bind, removed ones disappear from `memberMentions` on send.
## Out of scope
- No core changes. `getCIMentions` (`Internal.hs:267-271`) stays as-is;
this is purely client-side cache lifecycle.
- No change to the @-picker trigger heuristic in `messageChanged`.
@@ -0,0 +1,83 @@
# Fix: Windows desktop fails to launch after in-app MSI update
Issue: https://github.com/simplex-chat/simplex-chat/issues/7105
## Symptom
After updating SimpleX Desktop on Windows via the in-app updater (MSI), the MSI reports
success but `SimpleX.exe` never launches afterwards — no window, no process in Task Manager,
and it persists across a reboot. No application crash is logged. Reported on Windows 11
(6.5.4 → 7.0.0), and the reporter saw it on a prior update too.
Event Viewer shows the cause: RestartManager 10010 (`SimpleX.exe … cannot be restarted -
Application SID does not match Conductor SID`) followed by MsiInstaller 1038
(`requires a system restart … Reason for Restart: 1` = files in use).
## Root cause
The Windows branch of `installAppUpdate` (`AppUpdater.kt`) ran the MSI **while the app was
still running** and waited for it (`Runtime.getRuntime().exec("msiexec /i …").onExit().join()`).
The desktop MSI is a per-machine major upgrade (`desktop/build.gradle.kts`:
`perUserInstall = false`, fixed `upgradeUuid`), so it must replace `SimpleX.exe`, the bundled
JRE and the app DLLs in `C:\Program Files\SimpleX`. The running JVM holds those files open, so
the installer cannot replace them: Restart Manager can't close the app across the
user/elevated security boundary (the "SID does not match" event), the locked files are
deferred to `PendingFileRenameOperations`, and a reboot is requested. At reboot the deferred
uninstall-then-install operations leave the install directory inconsistent, so the executable
never starts.
macOS and Linux are unaffected because those kernels allow a running executable's file to be
replaced by inode; Windows locks open files, so the app must release its locks before the MSI
replaces them.
## Fix
Launch the installer and **exit the app immediately** instead of waiting, so the files are
unlocked before the MSI reaches its file-replacement phase (which happens only after UAC
elevation and the installer's costing phase — well after the JVM is gone). With no running
instance there are no locked files, no reboot deferral and no Restart Manager conflict.
Two small changes, both confined to the Windows path:
1. `installAppUpdate` Windows branch: replace the `msiexec … .onExit().join()` + result
handling with `Runtime.getRuntime().exec(arrayOf("msiexec", "/i", file.absolutePath))`
followed by `exitProcess(0)`. The array form also fixes a latent bug where a space in the
path broke the single-string `exec`.
2. `downloadAsset`: before downloading, `if (desktopPlatform.isWindows()) File(tmpDir,
asset.name).delete()`. Because the app now exits before it can delete the installer, a
leftover MSI is removed at the next download instead of accumulating in the temp dir.
The user reopens the updated app from the Start Menu (the MSI recreates the shortcut) — the
same hand-off the updater already uses for the `.deb` path and all failure branches.
## Alternatives considered
- **Helper script that waits for exit, installs, then relaunches** — adds a generated batch
with PID-polling, relaunch and self-delete; its only benefit over exiting is auto-relaunch,
which is not worth the complexity and platform-specific fragility.
- **Register the app with Restart Manager** so the elevated MSI can close/restart it —
jpackage apps don't register RM, the SID mismatch shows RM can't manage the process across
the elevation boundary anyway, and it is far more code.
- **Keep the app running with `REBOOT=ReallySuppress` / repair flags** — does not address the
lock; in-use files still defer to a reboot.
## Scope / out of scope
- In scope: the Windows branch of `installAppUpdate` and a Windows-guarded cleanup in
`downloadAsset`. macOS, Linux AppImage and `.deb` paths are unchanged.
- Trade-off accepted: the app no longer shows its own "installed successfully" dialog (it has
exited); msiexec shows its own progress UI and the relaunched app is the success signal.
- Out of scope (separate follow-ups): the macOS install branch ignores `renameTo` results;
download failures are only logged with no user-facing error; no signature/SHA verification
of the downloaded artifact.
## Test plan (Windows VM)
1. Install a per-machine MSI built at a version below the latest release.
2. Settings → Check for updates → download + Install the newer MSI.
3. Before the fix: RestartManager 10010 + MsiInstaller 1038 (Reason 1) appear and the app
fails to launch after reboot (reproduces #7105).
4. After the fix: the app exits, the MSI installs with no reboot request, and the new version
launches normally from the Start Menu; `C:\Program Files\SimpleX` is a complete install
with no queued `PendingFileRenameOperations`.
5. Re-run with a user profile path containing a space to confirm the array-form `exec` fix.
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 7.0.0.7
version: 7.0.0.8
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
+2 -2
View File
@@ -4262,10 +4262,10 @@ processChatCommand cxt nm = \case
case plan of
CPContactAddress (CAPKnown ct') -> do
ct'' <- refreshContact ct'
pure (con l' cReq, plan {contactAddressPlan = CAPKnown ct''})
pure (con l' cReq, CPContactAddress (CAPKnown ct''))
CPContactAddress (CAPContactViaAddress ct') -> do
ct'' <- refreshContact ct'
pure (con l' cReq, plan {contactAddressPlan = CAPContactViaAddress ct''})
pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct''))
_ -> pure (con l' cReq, plan)
where
knownLinkPlans :: CM (Maybe (ACreatedConnLink, ConnectionPlan))