desktop: prevent duplicate launches (#6979)

* desktop: prevent duplicate launches

Acquires a file lock and listens on a loopback ServerSocket in dataDir.
A second launch signals the running instance to restore its window and
exits silently. See plans/2026-05-13-desktop-single-instance.md.

* desktop: un-minimize window in showWindow

toFront() does not un-minimize a JFrame on any AWT platform. Clear the
ICONIFIED bit so a minimized window restores; preserves MAXIMIZED_BOTH.
Also fixes the same case when restoring from the tray icon.

* desktop: move showWindow from DesktopTray to DesktopApp

It has callers outside the tray (single-instance signal) and belongs
next to simplexWindowState, which it operates on.

* simplify

* refactor

* desktop: start show-file watcher when choosing minimize from first-close dialog

The handleCloseRequest path already starts the watcher when minimizing to
tray; the Ask-dialog path did not, so the first-time user who picks
"Minimize to tray" got a hidden window with no signal handling — a
duplicate launch would not restore it.

* desktop: always watch for duplicate-launch signal, drop hung-instance alert

The watcher now runs for the JVM lifetime once the lock is acquired,
not only when minimized to tray. Duplicate launches always restore the
primary's window (un-minimize, un-tray-hide, toFront) instead of being
silently dropped when the primary is not minimized.

Drops the "may be hung, start anyway?" popup and the two strings — that
fallback was needed only because the watcher could miss signals. With
the always-on watcher there is no scenario where the primary fails to
consume simplex.show, so the escape hatch becomes dead code.

* desktop: alert when primary's watcher doesn't consume the show file

Restores the "another instance may be running" alert. Every duplicate
launch waits up to 1s for the primary's watcher to delete the show file
it just created. If the file is consumed within the window, the
duplicate exits silently. If still there after 1s the primary is hung
and the alert fires.

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
sh
2026-05-18 08:15:20 +00:00
committed by GitHub
parent b0901106a9
commit c165663555
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.