From c16566355597cb5c72e8fb396a58ae31dcba182a Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 18 May 2026 08:15:20 +0000 Subject: [PATCH] desktop: prevent duplicate launches (#6979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../commonMain/resources/MR/base/strings.xml | 2 + .../kotlin/chat/simplex/common/DesktopApp.kt | 16 ++- .../kotlin/chat/simplex/common/DesktopTray.kt | 6 - .../chat/simplex/common/SingleInstance.kt | 133 ++++++++++++++++++ .../chat/simplex/app/SingleInstanceTest.kt | 56 ++++++++ .../kotlin/chat/simplex/desktop/Main.kt | 2 + plans/2026-05-13-desktop-single-instance.md | 30 ++++ 7 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt create mode 100644 plans/2026-05-13-desktop-single-instance.md 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.