diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 7304625945..c7b48b4e5b 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -27,6 +27,8 @@
Invalid file path
You shared an invalid file path. Report the issue to the app developers.
View crashed
+ App is already running
+ Another app instance may be running or did not exit properly. Start anyway?
connected
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt
index 2ae4aed8e2..ba8901793f 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt
@@ -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 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 Unit>()
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt
index 3f35c10c9c..9f75e481f4 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt
@@ -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
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt
new file mode 100644
index 0000000000..19cb7aea91
--- /dev/null
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt
@@ -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
+ }
+ }
+}
diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt
new file mode 100644
index 0000000000..1b495c1774
--- /dev/null
+++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt
@@ -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 {
+ 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) {}
+ }
+ }
+ }
+}
diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt
index 0e8a452e08..338660b746 100644
--- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt
+++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt
@@ -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()
diff --git a/plans/2026-05-13-desktop-single-instance.md b/plans/2026-05-13-desktop-single-instance.md
new file mode 100644
index 0000000000..87c5f7c9bb
--- /dev/null
+++ b/plans/2026-05-13-desktop-single-instance.md
@@ -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.