Merge branch 'master' into ep/new-design

This commit is contained in:
Evgeny Poberezkin
2026-05-18 09:48:54 +01:00
7 changed files with 237 additions and 8 deletions
@@ -27,6 +27,8 @@
<string name="non_content_uri_alert_title">Invalid file path</string>
<string name="non_content_uri_alert_text">You shared an invalid file path. Report the issue to the app developers.</string>
<string name="app_was_crashed">View crashed</string>
<string name="another_instance_title">App is already running</string>
<string name="another_instance_not_responding">Another app instance may be running or did not exit properly. Start anyway?</string>
<!-- Server info - ChatModel.kt -->
<string name="server_connected">connected</string>
@@ -23,6 +23,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import java.awt.Frame
import java.awt.event.WindowEvent
import java.awt.event.WindowFocusListener
import java.io.File
@@ -241,10 +242,10 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Bool
val pref = ChatController.appPrefs.closeBehavior
when (pref.get()) {
CloseBehavior.Quit -> exitApplication()
CloseBehavior.MinimizeToTray -> if (trayIsAvailable) {
CloseBehavior.MinimizeToTray -> if (trayIsAvailable && singleInstanceLock) {
simplexWindowState.windowVisible.value = false
} else exitApplication()
CloseBehavior.Ask -> if (trayIsAvailable) {
CloseBehavior.Ask -> if (trayIsAvailable && singleInstanceLock) {
requestCloseBehavior()
} else {
// Tray unavailable — Minimize is not a real option; remember Quit and exit.
@@ -254,6 +255,17 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Bool
}
}
fun showWindow() {
simplexWindowState.windowVisible.value = true
simplexWindowState.window?.apply {
// Clear ICONIFIED so a minimized window un-minimizes; preserves MAXIMIZED_BOTH
// when set. toFront() alone does not un-minimize on any AWT platform.
extendedState = extendedState and Frame.ICONIFIED.inv()
toFront()
requestFocus()
}
}
class SimplexWindowState {
lateinit var windowState: WindowState
val backstack = mutableStateListOf<() -> Unit>()
@@ -47,12 +47,6 @@ val trayIsAvailable: Boolean by lazy {
}
}
fun showWindow() {
simplexWindowState.windowVisible.value = true
simplexWindowState.window?.toFront()
simplexWindowState.window?.requestFocus()
}
@Composable
fun ApplicationScope.SimplexTray() {
if (!trayIsAvailable) return
@@ -0,0 +1,133 @@
package chat.simplex.common
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import chat.simplex.common.platform.dataDir
import java.io.IOException
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.channels.OverlappingFileLockException
import java.nio.file.*
import java.nio.file.StandardOpenOption.CREATE
import java.nio.file.StandardOpenOption.READ
import java.nio.file.StandardOpenOption.WRITE
import javax.swing.SwingUtilities
import kotlin.concurrent.thread
private var lockHandle: FileLock? = null
private var watcher: WatchService? = null
private val lockPath get() = dataDir.resolve("simplex.started").toPath()
private val showPath get() = dataDir.resolve("simplex.show").toPath()
var singleInstanceLock = false
private set
private sealed interface LockResult {
class Acquired(val lock: FileLock) : LockResult
object Taken : LockResult
object Failed : LockResult
}
fun acquireSingleInstance(): Boolean {
dataDir.mkdirs()
when (val result = tryAcquireLock()) {
is LockResult.Acquired -> {
lockHandle = result.lock
singleInstanceLock = true
deleteShowFile()
startShowFileWatcher()
return true
}
LockResult.Failed -> {
return true
}
LockResult.Taken -> {
// Ensure the signal file exists (createShowFile is a no-op if it does)
// and wait up to 1s for the primary's watcher to consume it. If still
// there after the wait, the primary is hung — let the user decide.
createShowFile()
val deadline = System.currentTimeMillis() + 1000
while (Files.exists(showPath) && System.currentTimeMillis() < deadline) {
try { Thread.sleep(50) } catch (_: InterruptedException) { break }
}
if (!Files.exists(showPath)) return false
val start = showSingleInstanceAlert()
if (start) deleteShowFile()
return start
}
}
}
private fun tryAcquireLock(): LockResult {
val channel = try {
FileChannel.open(lockPath, READ, WRITE, CREATE)
} catch (e: IOException) {
Log.w(TAG, "single-instance: cannot open lock file: ${e.message}")
return LockResult.Failed
}
return try {
val lock = channel.tryLock(0L, 1L, false)
if (lock != null) {
LockResult.Acquired(lock)
} else {
channel.close()
LockResult.Taken
}
} catch (_: OverlappingFileLockException) {
Log.w(TAG, "single-instance: overlapping lock in same JVM")
LockResult.Failed
} catch (e: IOException) {
Log.w(TAG, "single-instance: tryLock failed: ${e.message}")
channel.close(); LockResult.Failed
}
}
private fun deleteShowFile() {
try { Files.deleteIfExists(showPath) } catch (e: IOException) {
Log.w(TAG, "single-instance: cannot delete show file: ${e.message}")
}
}
private fun createShowFile() {
try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) {
// Another duplicate already signalled; primary will pick it up.
} catch (e: IOException) {
Log.w(TAG, "single-instance: cannot create show file: ${e.message}")
}
}
private fun showSingleInstanceAlert(): Boolean {
val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title)
val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding)
val result = javax.swing.JOptionPane.showConfirmDialog(
null, message, title,
javax.swing.JOptionPane.YES_NO_OPTION,
javax.swing.JOptionPane.WARNING_MESSAGE
)
return result == javax.swing.JOptionPane.YES_OPTION
}
private fun startShowFileWatcher() {
if (watcher != null) return
val ws = try {
dataDir.toPath().fileSystem.newWatchService()
} catch (e: IOException) {
Log.w(TAG, "single-instance: WatchService failed: ${e.message}")
return
}
dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE)
watcher = ws
thread(name = "simplex-single-instance", isDaemon = true) {
while (true) {
val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread }
for (event in key.pollEvents()) {
if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") {
deleteShowFile()
SwingUtilities.invokeLater { showWindow() }
}
}
if (!key.reset()) return@thread
}
}
}
@@ -0,0 +1,56 @@
package chat.simplex.app
import java.nio.channels.FileChannel
import java.nio.channels.OverlappingFileLockException
import java.nio.file.Files
import java.nio.file.StandardOpenOption.CREATE
import java.nio.file.StandardOpenOption.READ
import java.nio.file.StandardOpenOption.WRITE
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
class SingleInstanceTest {
@Test
fun overlappingLockOnSameRegionThrowsWithinOneJvm() = withTempDir { dir ->
val lockPath = dir.resolve("simplex.started")
val first = FileChannel.open(lockPath, READ, WRITE, CREATE)
val firstLock = first.tryLock(0L, 1L, false)
assertNotNull(firstLock, "first acquirer must get the lock")
val second = FileChannel.open(lockPath, READ, WRITE, CREATE)
assertFailsWith<OverlappingFileLockException> {
second.tryLock(0L, 1L, false)
}
second.close()
firstLock.release()
first.close()
}
@Test
fun releasedLockCanBeReacquired() = withTempDir { dir ->
val lockPath = dir.resolve("simplex.started")
val first = FileChannel.open(lockPath, READ, WRITE, CREATE)
val firstLock = first.tryLock(0L, 1L, false)
assertNotNull(firstLock)
firstLock.release()
first.close()
val second = FileChannel.open(lockPath, READ, WRITE, CREATE)
val secondLock = second.tryLock(0L, 1L, false)
assertNotNull(secondLock, "after release, a fresh acquirer must succeed")
secondLock.release()
second.close()
}
private fun withTempDir(block: (java.nio.file.Path) -> Unit) {
val tmp = Files.createTempDirectory("simplex-singleinstance-test")
try {
block(tmp)
} finally {
Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach {
try { Files.delete(it) } catch (_: java.io.IOException) {}
}
}
}
}
@@ -8,6 +8,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.*
import chat.simplex.common.acquireSingleInstance
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.size
import chat.simplex.common.platform.*
@@ -19,6 +20,7 @@ import kotlinx.coroutines.*
import java.io.File
fun main() {
if (!acquireSingleInstance()) return
// Disable hardware acceleration
//System.setProperty("skiko.renderApi", "SOFTWARE")
initHaskell()
@@ -0,0 +1,30 @@
# Desktop single instance - restore on duplicate launch
## Problem
After tray support (#6970), the desktop app can minimize to tray. The process stays alive holding the database. When the user clicks the app launcher again (forgetting about the tray), a second process starts and either crashes on the SQLite lock or runs in a degraded state.
## Design
Two files in `dataDir`: `simplex.started` (lock file) and `simplex.show` (signal file).
### Startup
1. Try `FileChannel.tryLock(0, 1, false)` on `simplex.started`.
2. **Lock acquired**: delete stale `simplex.show` if present (leftover from crash), start a daemon `WatchService` on `dataDir` for `ENTRY_CREATE`, start the app normally.
3. **Lock taken** (another process holds it): create `simplex.show`, exit. The running instance detects it and shows its window.
4. **Lock fails** (IOException, filesystem doesn't support locks, etc.): start normally but disable minimize-to-tray. Close quits the app. No worse than before tray support existed.
### Signal handling
While the lock is held, the daemon watcher runs for the JVM lifetime. When `simplex.show` appears it deletes the file and posts `showWindow()` to the EDT. `showWindow()` sets `windowVisible = true`, clears `ICONIFIED`, and brings the window to front — restores from tray, from taskbar-minimize, or just raises if visible-but-behind.
Minimize-to-tray is only available when `singleInstanceLock` is held. If the lock couldn't be acquired (case 4), close always quits - preventing the scenario where two tray'd instances fight over the database.
### Crash recovery
The OS releases the file lock when the process dies. `simplex.show` may be left behind but is harmless - the next startup (step 2) deletes it.
## Scope
Linux, Windows, macOS. Per-data-directory - separate installs with different `dataDir` run independently.