mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-28 09:59:44 +00:00
Merge branch 'master' into master-android
This commit is contained in:
@@ -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)
|
||||
|
||||
+1
-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,
|
||||
|
||||
+6
-9
@@ -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() =
|
||||
|
||||
+3
-19
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-13
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user