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()