From d183f48fca2164c44eafd95b465ec3cc7f81b216 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:12:36 +0400 Subject: [PATCH 1/5] core: fix armv7a build (incomplete record update) (#7205) --- src/Simplex/Chat/Library/Commands.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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)) From 63eaf260a24f10eabace8d9c0b5682e7dd7b641c Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:45:45 +0000 Subject: [PATCH 2/5] ui: fix SEInvalidMention when @mention is edited in place (#7018) * android, desktop, ios: drop stale mention map entry when @name is edited in place The compose-state mentions map was only pruned when fewer @name tokens were parsed from the text than were in the map. Editing an inserted @Name token in place (without re-picking from the picker) keeps the count at 1 while the parsed name no longer matches the stored key, so the stale entry was sent and the core rejected it with SEInvalidMention. Pruning now triggers whenever any map key is absent from the parsed mention names; the modified token is then sent as an unresolved formatted @ token. * android, desktop, ios: filter mentions at send time so in-place edits round-trip Treat the compose-state mentions map as a sticky cache of name -> memberId bindings recorded by the picker. The previous removeUnusedMentions pruned it on every text change, which dropped the binding the instant a letter was removed from an inserted @name token. Now the map is no longer mutated by text edits: memberMentions, the picker max-reached check, and the mentionMemberName disambiguator all filter against the names currently parsed from the message, so deleting and retyping the original characters re-resolves the original member, and the SEInvalidMention error from an in-place edit no longer occurs. * android, desktop, ios: cap memberMentions at MAX so stale cache entries cannot overflow the server limit With the sticky cache, manually typing an @name that happens to match a stale cache entry could push memberMentions above maxSndMentions and have the core reject the send with SEInvalidMention. The getter now walks parsedMessage in text order and stops at MAX_NUMBER_OF_MENTIONS; later @-tokens past the cap become visual-only formatting and the message still goes through. Also hoists the iOS activeMentions computation out of the per-row ForEach so it runs once per picker open rather than once per row. * android, desktop, ios: simplify mention restoration to minimal surgical diff Reverts the unnecessary mentionMemberName changes (the original mentions.containsKey behaviour preserves bindings better than the parsed-only variant), switches the picker checks to memberMentions.size (no helper variable needed since the getter already caps at MAX), and collapses the Kotlin memberMentions getter into a flat chain. * android, desktop, ios: gate picker mention-id and max-reached banner by memberMentions With the sticky cache, the picker's "currently bound member" highlight and the showMaxReachedBox banner-suppression clause read mentions[name] directly, so a stale cache entry made one row clickable (as a no-op) at the MAX limit and could suppress the banner when the user is actually adding a new mention. Both now gate the lookup by membership in the capped memberMentions, restoring the pre-fix UX where at MAX all rows are disabled and the banner shows when adding past the limit. --- .../Chat/ComposeMessage/ComposeView.swift | 7 +- .../Views/Chat/Group/GroupMentions.swift | 14 +-- .../simplex/common/views/chat/ComposeView.kt | 15 +-- .../common/views/chat/group/GroupMentions.kt | 22 +--- .../2026-05-26-fix-invalid-mention-on-edit.md | 107 ++++++++++++++++++ 5 files changed, 124 insertions(+), 41 deletions(-) create mode 100644 plans/2026-05-26-fix-invalid-mention-on-edit.md 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/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/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`. From 3b752ff749667b339dd1e622d375176d2261dcc7 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:54:55 +0000 Subject: [PATCH 3/5] fix: keep customized "Additional accent 2" color on theme import (#7134) removeSameColors stored colors.primary in the primaryVariant2 slot instead of colors.primaryVariant2, so importing a theme with a customized primaryVariant2 ("Additional accent 2") replaced it with the primary accent color. Fixed on both Android/desktop and iOS. --- apps/ios/Shared/Theme/Theme.swift | 2 +- .../src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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, From e79b6ead11a98cfcd5599d3836b2d5417154cf92 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:56:08 +0000 Subject: [PATCH 4/5] desktop: fix Windows in-app updater corrupting install (#7105) (#7136) * desktop: fix Windows in-app updater corrupting install by exiting before the MSI runs The Windows install path ran msiexec while the app was still running and waited for it. The running JVM holds SimpleX.exe, the JRE and DLLs open, so the per-machine MSI upgrade cannot replace them, defers to a reboot, and the install ends up broken - the app fails to launch (#7105). Launch the installer and exit instead, so the files are unlocked; the user reopens from the Start Menu. Also pass the path via the array form of exec (fixes spaces in the path) and remove a leftover installer before the next download, since the exiting app can no longer delete it itself. * docs: plan justifying the Windows in-app MSI updater fix --- .../common/views/helpers/AppUpdater.kt | 24 +++--- ...6-23-fix-windows-msi-update-running-app.md | 83 +++++++++++++++++++ 2 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 plans/2026-06-23-fix-windows-msi-update-running-app.md 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-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. From 1427933ee4cc2b31cbab9e2c5e4676db6eced54f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 6 Jul 2026 17:01:34 +0100 Subject: [PATCH 5/5] core: 7.0.0.8 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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