diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift index 1f98b23a1d..254d1707da 100644 --- a/apps/ios/Shared/Theme/Theme.swift +++ b/apps/ios/Shared/Theme/Theme.swift @@ -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, diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 9c40b2b395..aaf8a30120 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift index cdbed7fe30..958bbbabb2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -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 = 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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index b8dc9ff6d8..20e0280acd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -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, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c37aa0a77f..865c690c3c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -133,15 +133,12 @@ data class ComposeState( ) val memberMentions: Map - 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() = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt index aa737a02d3..2a5f9df8c1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -102,7 +102,6 @@ fun GroupMentions( } fun messageChanged(msg: ComposeMessage, parsedMsg: List) { - 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, parsedMsg: List) { - 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 } - ) - } -} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index c4c34a1db9..783c438f66 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -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 diff --git a/plans/2026-05-26-fix-invalid-mention-on-edit.md b/plans/2026-05-26-fix-invalid-mention-on-edit.md new file mode 100644 index 0000000000..2fe9bad5a5 --- /dev/null +++ b/plans/2026-05-26-fix-invalid-mention-on-edit.md @@ -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`. diff --git a/plans/2026-06-23-fix-windows-msi-update-running-app.md b/plans/2026-06-23-fix-windows-msi-update-running-app.md new file mode 100644 index 0000000000..d276dbfe6f --- /dev/null +++ b/plans/2026-06-23-fix-windows-msi-update-running-app.md @@ -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. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 14bc3e4075..6403dd19b2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -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 diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 837317176f..18ca626a46 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -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))