mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-27 22:34:50 +00:00
Merge branch 'master' into ep/new-design
This commit is contained in:
@@ -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
|
||||
|
||||
+133
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user