desktop: add tray support (#6970)

* plans: design and implementation plan for desktop tray icon

Adds a system tray icon to the SimpleX desktop app with first-close
dialog and an Appearance toggle. Covered: minimize-to-tray on Linux,
Windows, and macOS via ComposeNativeTray; tri-state CloseBehavior
preference; unread-dot icon swap; show/quit menu.

The design plan covers the user-facing behavior, library choice
rationale (AWT/Compose Tray is broken on stock GNOME per JDK-8322750;
ComposeNativeTray uses platform-native APIs and is maintained), and
out-of-scope items. The implementation plan splits the work into 8
incremental commits, each leaving the build green.

* desktop: add CloseBehavior preference

* plans: switch tray to built-in Compose Tray with GNOME probe

* desktop: branch close handler on CloseBehavior preference

* desktop: first-close dialog for tray choice

* desktop: tray icon assets and menu strings

* desktop: system tray icon with show/quit menu

* desktop: unread indicator on tray icon

* desktop: Appearance toggle for minimize-to-tray

* desktop: tray feature fixes from audit

- Scope closedByError per application iteration; set before dispatchEvent.
- Short-circuit Ask to Quit when tray is unavailable.
- Sum users.unreadCount (pre-aggregated) instead of iterating chats.
- Replace dialog's captured lambdas with a top-level flag and
  ApplicationScope extension; wrap in SimpleXTheme.
- Probe SystemTray with real add/remove off the EDT.
- Drop duplicate ic_simplex_tray.svg; nudge unread dot to cy=34 to
  stop the r=6 circle clipping at the viewBox bottom.
- Use mkSafeEnumPreference, PreferenceToggle, SectionTextFooter.

* plans: sync desktop tray plans with implementation

* desktop: render close-behavior popup via AlertManager

Replaces the bespoke DialogWindow with AlertManager.shared.showAlertDialogButtonsColumn —
same in-app surface as e.g. the link-previews opt-in alert. Drops isAskingCloseBehavior,
the CloseBehaviorDialog Composable, the resetAskCloseBehavior helper, and the per-iteration
reset call: the alert's lifecycle is now bounded by AlertManager's single slot, so a crash
mid-dialog gets cleanly overwritten by the crash report.

* desktop: reset closeBehavior with 'Reset all hints'

Generalises AppPreferences.hintPreferences to a heterogeneous List<HintPref>
so non-Boolean prefs can participate. Adds closeBehavior to the list, so
'Reset all hints' brings the first-close dialog back.

* desktop: skip muted profiles in tray unread sum

Active profile still counts so the user can see their own unread; non-active
muted profiles contribute zero.

* desktop: dark-theme tray icons

Adds ic_simplex_tray_light and ic_simplex_tray_dot_light — copies of the
existing tray SVGs with the navy back-X swapped to white. Picks the variant
via isInDarkTheme() so the icon stays visible against dark tray backgrounds.
This commit is contained in:
sh
2026-05-13 08:58:42 +01:00
committed by GitHub
parent de573a2299
commit 334a50dda5
11 changed files with 980 additions and 21 deletions
@@ -91,6 +91,13 @@ enum class SimplexLinkMode {
}
}
enum class CloseBehavior {
Ask, Quit, MinimizeToTray;
companion object { val default = Ask }
}
class HintPref(val reset: () -> Unit, val isUnchanged: () -> Boolean)
// Spec: spec/state.md#AppPreferences
class AppPreferences {
// deprecated, remove in 2024
@@ -99,6 +106,7 @@ class AppPreferences {
SHARED_PREFS_NOTIFICATIONS_MODE,
if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default
) { NotificationsMode.values().firstOrNull { it.name == this } }
val closeBehavior: SharedPreference<CloseBehavior> = mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default)
val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name)
val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true)
val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
@@ -257,17 +265,23 @@ class AppPreferences {
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true)
val chatBottomBar = mkBoolPreference(SHARED_PREFS_CHAT_BOTTOM_BAR, true)
val hintPreferences: List<Pair<SharedPreference<Boolean>, Boolean>> = listOf(
laNoticeShown to false,
oneHandUICardShown to false,
addressCreationCardShown to false,
liveMessageAlertShown to false,
showHiddenProfilesNotice to true,
showMuteProfileAlert to true,
showReportsInSupportChatAlert to true,
showDeleteConversationNotice to true,
showDeleteContactNotice to true,
privacyLinkPreviewsShowAlert to true,
val hintPreferences: List<HintPref> = listOf(
hintPref(laNoticeShown, false),
hintPref(oneHandUICardShown, false),
hintPref(addressCreationCardShown, false),
hintPref(liveMessageAlertShown, false),
hintPref(showHiddenProfilesNotice, true),
hintPref(showMuteProfileAlert, true),
hintPref(showReportsInSupportChatAlert, true),
hintPref(showDeleteConversationNotice, true),
hintPref(showDeleteContactNotice, true),
hintPref(privacyLinkPreviewsShowAlert, true),
hintPref(closeBehavior, CloseBehavior.default),
)
private fun <T> hintPref(pref: SharedPreference<T>, default: T) = HintPref(
reset = { pref.set(default) },
isUnchanged = { pref.state.value == default },
)
private fun mkIntPreference(prefName: String, default: Int) =
@@ -479,6 +493,7 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR = "DesktopCloseBehavior"
private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
@@ -295,14 +295,10 @@ fun ChatLockItem(
}
private fun resetHintPreferences() {
for ((pref, def) in appPreferences.hintPreferences) {
pref.set(def)
}
appPreferences.hintPreferences.forEach { it.reset() }
}
fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { (pref, def) ->
pref.state.value == def
}
fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { it.isUnchanged() }
@Composable
fun AppVersionItem(showVersion: () -> Unit) {
@@ -3079,4 +3079,16 @@
<string name="link_previews_alert_desc_socks">Link preview will be requested via SOCKS proxy. DNS lookup may still happen locally via your DNS resolver.</string>
<string name="link_previews_alert_enable">Enable</string>
<string name="link_previews_alert_disable">Disable</string>
<!-- Desktop tray / minimize-to-tray -->
<string name="close_behavior_dialog_title">Minimize to tray?</string>
<string name="close_behavior_dialog_text">If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings.</string>
<string name="close_behavior_dialog_close">Close the app</string>
<string name="close_behavior_dialog_minimize">Minimize to tray</string>
<string name="tray_show">Show SimpleX</string>
<string name="tray_quit">Quit SimpleX</string>
<string name="tray_tooltip">SimpleX</string>
<string name="tray_tooltip_unread">SimpleX — %d unread</string>
<string name="appearance_minimize_to_tray">Minimize to tray when closing window</string>
<string name="appearance_minimize_to_tray_desc">Keep SimpleX running in the background to receive messages.</string>
</resources>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="120"
height="120"
viewBox="121 0 40 40"
fill="none"
version="1.1"
id="svg3"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
fill="#030749"
id="path1"
style="stroke-width:0.866122" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
fill="url(#paint0_linear_40_164)"
id="path2"
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
<!-- Unread dot in bottom-right; cy ≤ 34 to keep it inside the 40×40 viewBox bottom edge -->
<circle cx="155" cy="34" r="6" fill="#e53935" />
<defs
id="defs3">
<linearGradient
x1="135.948"
y1="-0.81632602"
x2="132.09599"
y2="36.985699"
gradientUnits="userSpaceOnUse"
id="paint0_linear_40_164"
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
<stop
stop-color="#01f1ff"
id="stop2" />
<stop
offset="1"
stop-color="#0197ff"
id="stop3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="120"
height="120"
viewBox="121 0 40 40"
fill="none"
version="1.1"
id="svg3"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
fill="#ffffff"
id="path1"
style="stroke-width:0.866122" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
fill="url(#paint0_linear_40_164)"
id="path2"
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
<!-- Unread dot in bottom-right; cy ≤ 34 to keep it inside the 40×40 viewBox bottom edge -->
<circle cx="155" cy="34" r="6" fill="#e53935" />
<defs
id="defs3">
<linearGradient
x1="135.948"
y1="-0.81632602"
x2="132.09599"
y2="36.985699"
gradientUnits="userSpaceOnUse"
id="paint0_linear_40_164"
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
<stop
stop-color="#01f1ff"
id="stop2" />
<stop
offset="1"
stop-color="#0197ff"
id="stop3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="120"
height="120"
viewBox="121 0 40 40"
fill="none"
version="1.1"
id="svg3"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
fill="#ffffff"
id="path1"
style="stroke-width:0.866122" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
fill="url(#paint0_linear_40_164)"
id="path2"
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
<defs
id="defs3">
<linearGradient
x1="135.948"
y1="-0.81632602"
x2="132.09599"
y2="36.985699"
gradientUnits="userSpaceOnUse"
id="paint0_linear_40_164"
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
<stop
stop-color="#01f1ff"
id="stop2" />
<stop
offset="1"
stop-color="#0197ff"
id="stop3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -31,8 +31,11 @@ import kotlin.system.exitProcess
val simplexWindowState = SimplexWindowState()
fun showApp() {
val closedByError = mutableStateOf(true)
while (closedByError.value) {
// Probe SystemTray off the EDT — the lazy's first read would otherwise block the
// EDT during composition; JDK-8322750's GNOME detection forks a subprocess.
trayIsAvailable
while (true) {
val closedByError = mutableStateOf(false)
application(exitProcessOnExit = false) {
CompositionLocalProvider(
LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window ->
@@ -43,8 +46,9 @@ fun showApp() {
shareText = true
)
Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString())
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
// Must precede dispatchEvent — handleCloseRequest reads this flag.
closedByError.value = true
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
includeMoreFailedComposables()
// If the left side of screen has open modal, it's probably caused the crash
if (ModalManager.start.hasModalsOpen()) {
@@ -73,9 +77,11 @@ fun showApp() {
}
}
) {
SimplexTray()
AppWindow(closedByError)
}
}
if (!closedByError.value) break
}
exitProcess(0)
}
@@ -115,7 +121,7 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
simplexWindowState.windowState = windowState
// Reload all strings in all @Composable's after language change at runtime
if (remember { ChatController.appPrefs.appLanguage.state }.value != "") {
Window(state = windowState, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { closedByError.value = false; exitApplication() }, onKeyEvent = {
Window(state = windowState, visible = simplexWindowState.windowVisible.value, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { handleCloseRequest(closedByError) }, onKeyEvent = {
if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) {
simplexWindowState.backstack.lastOrNull()?.invoke() != null
} else {
@@ -224,6 +230,30 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
}
}
// Not invoked for macOS Cmd+Q — that goes through AWT's default QuitHandler and
// exits the process directly. Intentional: Cmd+Q is canonical "always quit" on macOS.
private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Boolean>) {
// Crash dispatch — bypass user-facing policy and exit; outer loop will restart.
if (closedByError.value) {
exitApplication()
return
}
val pref = ChatController.appPrefs.closeBehavior
when (pref.get()) {
CloseBehavior.Quit -> exitApplication()
CloseBehavior.MinimizeToTray -> if (trayIsAvailable) {
simplexWindowState.windowVisible.value = false
} else exitApplication()
CloseBehavior.Ask -> if (trayIsAvailable) {
requestCloseBehavior()
} else {
// Tray unavailable — Minimize is not a real option; remember Quit and exit.
pref.set(CloseBehavior.Quit)
exitApplication()
}
}
}
class SimplexWindowState {
lateinit var windowState: WindowState
val backstack = mutableStateListOf<() -> Unit>()
@@ -232,6 +262,7 @@ class SimplexWindowState {
val saveDialog = DialogState<File?>()
val toasts = mutableStateListOf<Pair<String, Long>>()
var windowFocused = mutableStateOf(true)
val windowVisible = mutableStateOf(true)
var window: ComposeWindow? = null
}
@@ -0,0 +1,127 @@
package chat.simplex.common
import SectionItemView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.CloseBehavior
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import chat.simplex.common.ui.theme.isInDarkTheme
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import java.awt.AWTException
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.image.BufferedImage
// Probed once at startup. False on stock GNOME ≥ JDK 21.0.3 per JDK-8322750, and
// also when SystemTray.add() fails despite isSupported() returning true (an older
// JDK pattern Compose-MP does not catch). When false: the Appearance toggle is
// hidden, the first-close dialog is skipped (Ask migrates silently to Quit), and
// the close handler treats MinimizeToTray as Quit.
val trayIsAvailable: Boolean by lazy {
if (!SystemTray.isSupported()) return@lazy false
try {
val tray = SystemTray.getSystemTray()
val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
tray.add(probe)
tray.remove(probe)
true
} catch (e: AWTException) {
Log.w(TAG, "SystemTray probe failed: ${e.stackTraceToString()}")
false
} catch (e: SecurityException) {
Log.w(TAG, "SystemTray probe denied: ${e.stackTraceToString()}")
false
}
}
fun showWindow() {
simplexWindowState.windowVisible.value = true
simplexWindowState.window?.toFront()
simplexWindowState.window?.requestFocus()
}
@Composable
fun ApplicationScope.SimplexTray() {
if (!trayIsAvailable) return
if (remember { appPrefs.closeBehavior.state }.value != CloseBehavior.MinimizeToTray) return
// Sum of per-profile unread (UserInfo.unreadCount, the same field UserPicker renders
// per row). Skip muted profiles unless they're the active one.
val unread by remember {
derivedStateOf {
ChatModel.users.sumOf {
if (!it.user.showNtfs && !it.user.activeUser) 0 else it.unreadCount
}
}
}
val iconRes = if (unread > 0) {
if (isInDarkTheme()) MR.images.ic_simplex_tray_dot_light else MR.images.ic_simplex_tray_dot
} else {
if (isInDarkTheme()) MR.images.ic_simplex_tray_light else MR.images.ic_simplex
}
val tooltip =
if (unread > 0) stringResource(MR.strings.tray_tooltip_unread, unread)
else stringResource(MR.strings.tray_tooltip)
Tray(
icon = painterResource(iconRes),
tooltip = tooltip,
onAction = ::showWindow,
menu = {
Item(stringResource(MR.strings.tray_show), onClick = ::showWindow)
Separator()
Item(stringResource(MR.strings.tray_quit), onClick = { exitApplication() })
}
)
}
// Renders in the main app window via AlertManager (same surface as e.g. the link
// previews confirmation). Lambdas close over the calling ApplicationScope; if the
// app crashes while the dialog is open, the crash handler's alert replaces it, so
// stale closures never get clicked.
fun ApplicationScope.requestCloseBehavior() {
val pref = appPrefs.closeBehavior
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.close_behavior_dialog_title),
text = AnnotatedString(generalGetString(MR.strings.close_behavior_dialog_text)),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
pref.set(CloseBehavior.Quit)
exitApplication()
}) {
Text(
stringResource(MR.strings.close_behavior_dialog_close),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = Color.Red
)
}
SectionItemView({
AlertManager.shared.hideAlert()
pref.set(CloseBehavior.MinimizeToTray)
simplexWindowState.windowVisible.value = false
}) {
Text(
stringResource(MR.strings.close_behavior_dialog_minimize),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.primary
)
}
}
}
)
}
@@ -3,6 +3,7 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -18,7 +19,9 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.CloseBehavior
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.trayIsAvailable
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
@@ -65,6 +68,11 @@ fun AppearanceScope.AppearanceLayout(
SectionDividerSpaced()
ThemesSection(systemDarkTheme)
if (trayIsAvailable) {
SectionDividerSpaced()
MinimizeToTraySection()
}
SectionDividerSpaced()
AppToolbarsSection()
@@ -84,6 +92,21 @@ fun AppearanceScope.AppearanceLayout(
}
}
@Composable
private fun MinimizeToTraySection() {
val pref = remember { appPrefs.closeBehavior.state }
val on = pref.value == CloseBehavior.MinimizeToTray
SectionView {
PreferenceToggle(
stringResource(MR.strings.appearance_minimize_to_tray),
checked = on,
) { checked ->
appPrefs.closeBehavior.set(if (checked) CloseBehavior.MinimizeToTray else CloseBehavior.Quit)
}
}
SectionTextFooter(stringResource(MR.strings.appearance_minimize_to_tray_desc))
}
@Composable
fun DensityScaleSection() {
val localDensityScale = remember { mutableStateOf(appPrefs.densityScale.get()) }
@@ -0,0 +1,422 @@
# Desktop tray icon — implementation plan
Companion to the design at `plans/2026-05-09-desktop-tray.md`. Read that first.
## What
Seven small commits that build the feature incrementally. After each commit the build is green and the app still runs; only the last commit makes the feature visible to the user end-to-end.
## Why
We split the work this way so each commit is reviewable on its own and revertable without unwinding others. The order keeps the build green throughout (no commit introduces a reference to something the next commit will define).
## How
### Pre-flight
- Pull the branch `sh/tray` (current branch). It is at `stable`.
- Confirm dev environment can build desktop: `cd apps/multiplatform && ./gradlew :common:desktopMainClasses` — should succeed before any change.
- Read `plans/2026-05-09-desktop-tray.md` end to end. The implementation steps below assume that design is settled.
---
### Task 1 — `CloseBehavior` enum + preference
**Files**
- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`
**What to add.** The enum lives next to other small enums in this file (search for `enum class LAMode` for placement convention). The preference goes in `class AppPreferences` next to `notificationsMode`.
Match the existing pattern (use `values().firstOrNull { it.name == this }`, not `entries`, to stay consistent with `LAMode` and others in this file):
```kotlin
enum class CloseBehavior {
Ask, Quit, MinimizeToTray;
companion object { val default = Ask }
}
// In AppPreferences:
val closeBehavior: SharedPreference<CloseBehavior> =
mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default)
```
Add the constant at the bottom of `AppPreferences` next to other `SHARED_PREFS_*` constants:
```kotlin
private const val SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR = "DesktopCloseBehavior"
```
**Verify.** Build: `./gradlew :common:desktopMainClasses` — succeeds. No behavior change yet.
**Commit.** `desktop: add CloseBehavior preference`
---
### Task 2 — Window-visibility state + branching close handler (no dialog, no tray yet)
**(Note: a `Task 2 — Add ComposeNativeTray dependency` is removed. We now use Compose Multiplatform's built-in `androidx.compose.ui.window.Tray`, already on the classpath via the `org.jetbrains.compose` plugin. No new dep.)**
**Files**
- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`
**What to change.**
1. Add `windowVisible` to `SimplexWindowState` (the class at line 227):
```kotlin
class SimplexWindowState {
// ...existing fields...
val windowVisible = mutableStateOf(true)
}
```
2. In `AppWindow`, pass it to `Window`:
```kotlin
Window(
state = windowState,
visible = simplexWindowState.windowVisible.value,
icon = painterResource(MR.images.ic_simplex),
onCloseRequest = { handleCloseRequest(closedByError) },
// ...rest unchanged...
)
```
3. Add the handler at file scope (or near `showApp`). Temporarily make `Ask` fall through to `Quit` — the dialog comes in Task 3:
```kotlin
private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Boolean>) {
if (closedByError.value) { closedByError.value = false; exitApplication(); return }
when (appPrefs.closeBehavior.get()) {
CloseBehavior.Quit, CloseBehavior.Ask -> {
closedByError.value = false
exitApplication()
}
CloseBehavior.MinimizeToTray -> {
simplexWindowState.windowVisible.value = false
}
}
}
```
The `MinimizeToTray` branch will get a tray-availability guard in Task 5 (defensive: a user could have set the pref on a different machine where tray works).
(Imports: `chat.simplex.common.model.CloseBehavior`, `chat.simplex.common.model.ChatController.appPrefs`.)
**Verify.** Build + run desktop:
```
./gradlew :desktop:run
```
Click X — app exits exactly as today. No dialog, no tray. (Internal preference is `Ask`, branch falls through to Quit.)
**Commit.** `desktop: branch close handler on CloseBehavior preference`
---
### Task 3 — First-close dialog
**Files**
- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` *(new)*
- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`
- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`
**Strings.** Add to `strings.xml`:
```xml
<string name="close_behavior_dialog_title">Minimize to tray?</string>
<string name="close_behavior_dialog_text">If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings.</string>
<string name="close_behavior_dialog_close">Close the app</string>
<string name="close_behavior_dialog_minimize">Minimize to tray</string>
```
**`DesktopTray.kt` — dialog only.** A `mutableStateOf<Pair<onClose, onMinimize>?>` global, and a Composable that, when set, renders a non-dismissible `Dialog` with the two buttons. Skeleton:
```kotlin
package chat.simplex.common
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
private val pendingCloseChoice = mutableStateOf<CloseChoice?>(null)
private data class CloseChoice(val onClose: () -> Unit, val onMinimize: () -> Unit)
fun requestCloseBehavior(onClose: () -> Unit, onMinimize: () -> Unit) {
pendingCloseChoice.value = CloseChoice(onClose, onMinimize)
}
@Composable
fun CloseBehaviorDialog() {
val choice = pendingCloseChoice.value ?: return
Dialog(
onCloseRequest = { /* swallow — non-dismissible */ },
state = rememberDialogState(width = 420.dp, height = 220.dp),
title = stringResource(MR.strings.close_behavior_dialog_title),
resizable = false,
) {
Column(Modifier.padding(24.dp)) {
Text(stringResource(MR.strings.close_behavior_dialog_text))
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { pendingCloseChoice.value = null; choice.onClose() },
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error),
) { Text(stringResource(MR.strings.close_behavior_dialog_close)) }
// Hide the Minimize button when tray isn't supported (stock GNOME).
// The dialog still asks once so the user gets a definitive Quit answer
// and doesn't see the dialog again. trayIsAvailable is defined in Task 5;
// until then, the button is always shown.
Button(
onClick = { pendingCloseChoice.value = null; choice.onMinimize() },
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primary),
) { Text(stringResource(MR.strings.close_behavior_dialog_minimize)) }
}
}
}
}
```
**Wire it up in `DesktopApp.kt`.** Inside `application(exitProcessOnExit = false) { … }`, render `CloseBehaviorDialog()` alongside `AppWindow`. Update `handleCloseRequest`'s `Ask` branch:
```kotlin
CloseBehavior.Ask -> requestCloseBehavior(
onClose = {
appPrefs.closeBehavior.set(CloseBehavior.Quit)
closedByError.value = false
exitApplication()
},
onMinimize = {
appPrefs.closeBehavior.set(CloseBehavior.MinimizeToTray)
simplexWindowState.windowVisible.value = false
}
)
```
**Verify.** Run, click X — dialog appears with the exact text and button colors. Click "Close the app" → exits. Reopen, click X — exits without dialog (preference is `Quit`).
To reset the preference for re-testing, delete the SimpleX Chat desktop preferences file:
- Linux: `~/.config/simplex/SimpleXChatDesktop.properties`
- macOS: `~/Library/Preferences/SimpleXChatDesktop.properties`
- Windows: `%AppData%\SimpleX\SimpleXChatDesktop.properties`
Click "Minimize to tray" → window hides; the app process keeps running but is invisible (no tray icon yet — that's Task 6). Kill the JVM with Ctrl-C in the terminal to recover.
**Commit.** `desktop: first-close dialog for tray choice`
---
### Task 4 — Tray icon resources
**Files**
- `apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg`
- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`
**Icons.** Reuse the existing `MR.images.ic_simplex` for the no-unread case and add a single new asset for the unread case:
- `ic_simplex_tray_dot` — copy of `ic_simplex.svg` with a small red filled circle added in the bottom-right (~6px radius in the 40×40 viewBox).
Drop the SVG into `MR/images/`. Moko picks it up; refer to as `MR.images.ic_simplex_tray_dot`. Run a build to check generation: `./gradlew :common:generateMRcommonMain`.
**Strings.** Tray menu items + tooltip strings:
```xml
<string name="tray_show">Show SimpleX</string>
<string name="tray_quit">Quit SimpleX</string>
<string name="tray_tooltip">SimpleX</string>
<string name="tray_tooltip_unread">SimpleX — %d unread</string>
```
**Verify.** Build succeeds; the generated `MR.images.ic_simplex_tray_dot` and `MR.strings.tray_*` symbols compile when referenced from a temporary scratch file (delete after).
**Commit.** `desktop: tray icon assets and menu strings`
---
### Task 5 — Tray composable (no unread indicator yet)
**Files**
- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt`
- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`
**Add to `DesktopTray.kt`.** Tray-availability probe, functions to show window and quit, the Tray composable itself.
```kotlin
import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.MenuBar
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import java.awt.SystemTray
// Probed once at startup. Performs a real add/remove of a transparent TrayIcon
// because SystemTray.isSupported() can return true while add() throws (JDK-8322750).
val trayIsAvailable: Boolean by lazy {
if (!SystemTray.isSupported()) return@lazy false
try {
val tray = SystemTray.getSystemTray()
val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
tray.add(probe); tray.remove(probe); true
} catch (e: AWTException) { false } catch (e: SecurityException) { false }
}
fun showWindow() {
simplexWindowState.windowVisible.value = true
simplexWindowState.window?.toFront()
simplexWindowState.window?.requestFocus()
}
@Composable
fun ApplicationScope.SimplexTray(closedByError: MutableState<Boolean>) {
if (!trayIsAvailable) return
if (appPrefs.closeBehavior.state.value != CloseBehavior.MinimizeToTray) return
Tray(
icon = painterResource(MR.images.ic_simplex_tray),
tooltip = stringResource(MR.strings.tray_tooltip),
onAction = ::showWindow,
menu = {
Item(stringResource(MR.strings.tray_show), onClick = ::showWindow)
Separator()
Item(stringResource(MR.strings.tray_quit), onClick = {
closedByError.value = false
exitApplication()
})
}
)
}
```
(Note: this uses Compose Multiplatform's built-in `androidx.compose.ui.window.Tray`. The API is `icon: Painter`, `onAction` (not `primaryAction`), menu DSL uses `Separator()` (not `Divider()`).)
**Update `DesktopApp.kt`'s close handler** to add the defensive tray-availability check from Task 2's TODO:
```kotlin
CloseBehavior.MinimizeToTray -> {
if (trayIsAvailable) {
simplexWindowState.windowVisible.value = false
} else {
closedByError.value = false
exitApplication()
}
}
```
**Wire into `DesktopApp.kt`.** Inside `application(exitProcessOnExit = false) { … }`:
```kotlin
SimplexTray(closedByError)
CloseBehaviorDialog()
AppWindow(closedByError)
```
The order doesn't affect rendering — the tray and dialog are top-level surfaces.
**Verify.** Run; in the dialog pick "Minimize to tray". Window hides; tray icon appears. Left-click tray — window restores. Right-click tray — menu has "Show SimpleX" and "Quit SimpleX". Both work. Quit, restart — preference persists; clicking X hides directly without dialog. Tray icon appears at app startup (because the preference is now `MinimizeToTray`).
**Commit.** `desktop: system tray icon with show/quit menu`
---
### Task 6 — Unread indicator + tooltip count
**Files**
- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt`
**Change `SimplexTray`.** Replace the static icon and tooltip with reactive ones:
```kotlin
// UserInfo.unreadCount is incremented only when ntfsEnabled(item) — see SimpleXAPI.kt:2781-2783.
val unread by remember {
derivedStateOf { ChatModel.users.sumOf { it.unreadCount } }
}
val iconRes = if (unread > 0) MR.images.ic_simplex_tray_dot else MR.images.ic_simplex
val tooltip =
if (unread > 0) stringResource(MR.strings.tray_tooltip_unread, unread)
else stringResource(MR.strings.tray_tooltip)
Tray(
icon = painterResource(iconRes),
tooltip = tooltip,
// onAction + menu unchanged
)
```
**Verify.**
1. With "Minimize to tray" enabled, hide the window.
2. Trigger a notification (have another account/contact send you a message; or open a direct chat with notifications enabled and post from another device).
3. Tray icon switches to the red-dot variant; tooltip shows "SimpleX — 1 unread" (or higher).
4. Click tray, view the message in the relevant chat. Icon reverts to the plain variant; tooltip becomes "SimpleX".
**Commit.** `desktop: unread indicator on tray icon`
---
### Task 7 — Appearance settings toggle
**Files**
- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt`
- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`
**Strings.**
```xml
<string name="appearance_minimize_to_tray">Minimize to tray when closing window</string>
<string name="appearance_minimize_to_tray_desc">Keep SimpleX running in the background to receive messages.</string>
```
**UI row.** In `AppearanceLayout` (the Composable around line ~38), add a new section row using the existing `SectionItemView` / `SettingsActionItemWithContent` / similar patterns visible in this file. The entire row is gated on `trayIsAvailable` — if the OS has no tray host, the toggle is omitted. Read the surrounding rows for the exact convention; the snippet below is illustrative:
```kotlin
if (trayIsAvailable) {
val pref = remember { appPrefs.closeBehavior.state }
val on = pref.value == CloseBehavior.MinimizeToTray
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(MR.strings.appearance_minimize_to_tray))
Text(
stringResource(MR.strings.appearance_minimize_to_tray_desc),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
)
}
Switch(
checked = on,
onCheckedChange = { checked ->
appPrefs.closeBehavior.set(if (checked) CloseBehavior.MinimizeToTray else CloseBehavior.Quit)
}
)
}
}
}
```
Place the row in the existing `AppearanceLayout` Composable, after the theme/dark-mode rows and before the language selector — that grouping is for general window-and-display preferences and the new toggle fits there. Match the styling of nearby rows. If a clearer section emerges during implementation, add a new `SectionView` with a "Window" header instead.
**Verify.** Open Appearance settings; toggle the row off — tray icon disappears; click X exits with no dialog. Toggle back on — tray icon reappears (Compose recomposes the gated `Tray` composable). Window-close behavior still depends on the toggle.
**Commit.** `desktop: Appearance toggle for minimize-to-tray`
---
### Final manual test pass
Run the full test plan from the spec on each platform you can reach (Linux KDE, Windows 11, macOS):
1. Fresh install (clear `~/.config/simplex/` or per-OS data dir). Click X → dialog with the right text and button colors. Esc / outside-tap do nothing.
2. Pick Close → exits. Reopen → click X → exits with no dialog.
3. Reset, pick Minimize to tray → window hides, tray icon shows.
4. Receive a message → red-dot variant + tooltip count.
5. Click tray → window restores and focuses (acceptable if focus is best-effort per spec).
6. Right-click tray → Show / Quit both work.
7. Appearance toggle off → tray vanishes, X exits without dialog.
8. Appearance toggle on → tray reappears.
If anything fails, file follow-ups; the spec's "out of scope" list catches the expected omissions (autostart, number-on-icon, etc.).
+197
View File
@@ -0,0 +1,197 @@
# Desktop tray icon — minimize to tray on close
## What
Add a system tray icon (Windows notification area, Linux StatusNotifierItem, macOS menu bar) to the SimpleX desktop app, with a "minimize to tray" close behavior gated on first-time user choice.
Three pieces:
1. **First-close dialog** — the first time the user clicks the window's close (X) button, a modal asks whether to close the app or minimize it to the tray. The choice is remembered.
2. **Tray icon** — when the user has chosen "minimize to tray", the app installs a tray icon with a small right-click menu (Show / Quit) and an unread indicator. Clicking the icon restores the window.
3. **Appearance setting** — a "Minimize to tray when closing window" toggle in Appearance settings lets the user change their mind later.
Scope: Linux + Windows + macOS. No autostart. No number-on-icon unread badge. No profile switcher in the tray menu.
## Why
Today, closing the SimpleX desktop window quits the process and the user stops receiving messages until they reopen the app. There is no way to keep the app running quietly in the background, which is the standard expectation for a chat client.
We want this to be opt-in rather than a behavior change for existing users — hence the dialog on first close. Users who prefer the current quit-on-close behavior get exactly that with one click and never see the dialog again. Users who want background message delivery get it with one click and can manage it from settings.
We are using Compose Multiplatform's built-in `androidx.compose.ui.window.Tray` rather than a third-party library. It works cleanly on Windows, macOS, and Linux desktops with a system tray host (KDE Plasma, XFCE, Cinnamon, MATE, GNOME with the AppIndicator extension). The trade-off is that on stock GNOME the JDK deliberately returns `false` from `SystemTray.isSupported()` (per JDK-8322750), so we **probe at startup and disable the feature entirely** when the OS reports no tray support — the dialog hides the "Minimize to tray" option and the Appearance toggle is hidden too. Users with a working tray get the feature; users without never see broken/invisible UI.
All tray-specific code lives in `desktopMain` only. The Android target compiles none of it — there are no expect/actual surfaces calling into tray functionality from `commonMain`.
Users upgrading from a prior version will see the dialog on their first window-close after the update — that is intentional. The dialog is the chosen mechanism for getting consent before keeping a process running in the background, and an existing user has no way to give that consent in advance.
## How
### Close behavior — preference and flow
Add an enum preference:
```kotlin
enum class CloseBehavior { Ask, Quit, MinimizeToTray }
// in AppPreferences:
val closeBehavior: SharedPreference<CloseBehavior> =
mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default)
```
`Ask` is the default for fresh installs and for users upgrading from a version that did not have this preference.
Replace the inline close handler in `DesktopApp.kt` (currently `onCloseRequest = { closedByError.value = false; exitApplication() }`) with a function that branches on the preference:
- **Crash recovery first.** If `closedByError.value == true`, exit immediately with no dialog, no minimize. The crash handler at `DesktopApp.kt:46-47` dispatches `WINDOW_CLOSING` and depends on the application loop ending so it can re-enter. Honouring `closedByError` is what keeps that path working.
- `Quit` → exit immediately, as today.
- `MinimizeToTray` → set `simplexWindowState.windowVisible.value = false` and return.
- `Ask` → show the first-close dialog. The dialog's button writes the preference and then performs the corresponding action.
The same handler is invoked for the X button, Alt+F4 on Windows, and the macOS red traffic-light close — Compose routes all three through `onCloseRequest`. **macOS Cmd+Q is not routed through `onCloseRequest`**: it goes through the application menu's Quit and calls `exitApplication()` directly. We accept that as "always quit" — Cmd+Q is an explicit user intent to quit the application and should not be intercepted by the dialog. Programmatic `WindowEvent.WINDOW_CLOSING` (e.g. from the crash handler) reaches `onCloseRequest` and is handled by the `closedByError` branch above.
The dialog is non-dismissible (no Esc, no outside-tap) so the user must choose. Wording verbatim:
> **Minimize to tray?**
>
> If you choose Close, messages won't be received.
> You can change it later in Appearance settings.
>
> [ Close the app ] [ Minimize to tray ]
The "Close the app" button uses `MaterialTheme.colors.error` (red); "Minimize to tray" uses `MaterialTheme.colors.primary` (blue). The dialog is implemented bespoke (not via the existing `AlertManager`), because `AlertManager` does not support the non-dismissible + custom-button-color combination needed here.
The Compose application loop already runs with `exitProcessOnExit = false`, so hiding the window does not exit the process. No restructuring of `showApp()` is needed.
### Tray icon
No new dependency. We use `androidx.compose.ui.window.Tray` (built into Compose Multiplatform, already on the classpath). It wraps `java.awt.SystemTray` under the hood — works wherever AWT's tray works, returns silently when it doesn't.
**Tray availability probe.** `java.awt.SystemTray.isSupported()` alone is not reliable — there is a JDK pattern where it returns `true` but `SystemTray.add()` then throws `AWTException` (and Compose-MP does not catch it). We expose a `desktopMain` value that runs a real add/remove of a transparent `TrayIcon` inside a `try/catch` and caches the result:
```kotlin
val trayIsAvailable: Boolean by lazy {
if (!SystemTray.isSupported()) return@lazy false
try {
val tray = SystemTray.getSystemTray()
val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
tray.add(probe)
tray.remove(probe)
true
} catch (e: AWTException) { false }
catch (e: SecurityException) { false }
}
```
The probe is force-evaluated at the top of `showApp()` (off the EDT) so the JDK-8322750 GNOME detection subprocess does not block composition. When `false`: the Appearance toggle is hidden, the first-close dialog is skipped (`Ask` migrates silently to `Quit`), and the close handler treats `MinimizeToTray` as `Quit` (in case the preference was carried over from a tray-capable machine).
The tray composable lives next to `AppWindow` inside `application(exitProcessOnExit = false) { … }` in `showApp()`. It is gated by the preference AND by tray availability:
```kotlin
if (trayIsAvailable && appPrefs.closeBehavior.state.value == CloseBehavior.MinimizeToTray) {
// UserInfo.unreadCount is the pre-aggregated, ntfs-filtered counter — see SimpleXAPI.kt:2781-2783.
val unread by remember { derivedStateOf {
ChatModel.users.sumOf { it.unreadCount }
} }
val iconRes = if (unread > 0) MR.images.ic_simplex_tray_dot else MR.images.ic_simplex
val tooltip = if (unread > 0)
stringResource(MR.strings.tray_tooltip_unread, unread)
else
stringResource(MR.strings.tray_tooltip)
Tray(
icon = painterResource(iconRes),
tooltip = tooltip,
onAction = ::showWindow,
menu = {
Item(stringResource(MR.strings.tray_show), onClick = ::showWindow)
Separator()
Item(stringResource(MR.strings.tray_quit), onClick = { exitApplication() })
}
)
}
```
Note: Compose's `Tray` takes `icon: Painter` (not `iconContent`), `onAction` (not `primaryAction`), and the menu DSL uses `Separator()` (not `Divider()`). These are the right names for the built-in API.
`showWindow()` sets `windowVisible.value = true` and calls `window?.toFront()` + `window?.requestFocus()`. Quitting from the tray menu just calls `exitApplication()``closedByError` is already `false` in the non-crash path, so the outer loop in `showApp()` terminates cleanly.
**Unread indicator.** Icon swap based on `hasUnread`: reuse `ic_simplex` when zero, `ic_simplex_tray_dot` (same icon with a red dot overlay in the bottom-right) otherwise. Compose passes the `Painter` into AWT via `Painter.toAwtImage(density, layoutDirection, size)` — a single bitmap per state. One new image resource is enough:
- `MR.images.ic_simplex_tray_dot` — base icon with the red-dot overlay.
**Icon size.** Compose `Tray` rasterises the `Painter` once at a per-platform target size: Linux 22×22, Windows 16×16, macOS 22×22 (with retina 2×). It's a single bitmap, so we source the painter at a comfortable size (e.g. via a `painterResource(MR.images.ic_simplex)` from the 40×40 SVG already shipped) and let the conversion handle the scale. We accept the slight scaling cost on 16×16 Windows panels rather than ship multiple size variants.
**Tooltip.** Plain "SimpleX" when unread is zero; "SimpleX — N unread" otherwise.
**Window restore is best-effort.** Compose Multiplatform issue [#4231](https://github.com/JetBrains/compose-multiplatform/issues/4231) documents that `toFront()` does not always pull the restored window above other windows on Linux/Windows — the OS may flash the taskbar entry instead. Acceptable for v1; if it bites users we can add the `isAlwaysOnTop = true; toFront(); isAlwaysOnTop = false` workaround in a follow-up.
**No collision with the existing notification path.** `NtfManager.desktop.kt:178-188` contains an `java.awt.SystemTray` hack inside a private helper that turns out to be unreachable — the live notification path is `displayNotificationViaLib` (TwoSlices). The hack will not fire and cannot conflict with our tray icon. Cleaning up that dead code is out of scope here.
**Toggling at runtime.** The `Tray { … }` composable is gated on `closeBehavior.state.value == MinimizeToTray`; Compose's recomposition lifecycle handles install/uninstall when the user flips the setting. No `LaunchedEffect` is needed.
**Android isolation.** All tray code (the `Tray` composable, the close-behavior dialog, `showWindow`, the `trayIsAvailable` probe) lives in `desktopMain` only. The Android target compiles none of it — there are no expect/actual surfaces from `commonMain` calling into tray functionality. The only shared piece is the `CloseBehavior` enum + `closeBehavior` preference in `SimpleXAPI.kt`, which is plain data and never references tray APIs.
### Appearance settings row
In `Appearance.desktop.kt`, add one row to the existing settings section — **only when `trayIsAvailable`**:
> ☑ **Minimize to tray when closing window**
> *Keep SimpleX running in the background to receive messages.*
The toggle maps to the preference:
- `MinimizeToTray` → on.
- `Quit` or `Ask` → off.
Flipping on writes `MinimizeToTray`. Flipping off writes `Quit`. Touching the toggle resolves the `Ask` state to a definitive value — so a fresh-install user who opens Appearance settings, flips the row off, and then closes the window will *not* see the dialog (their preference is now `Quit`). This matches the user's apparent intent (they made a choice in settings) and avoids the surprise of a dialog appearing for a setting they thought they had already configured.
When `trayIsAvailable` is `false` (stock GNOME without AppIndicator extension), the entire row is omitted from Appearance settings, the first-close dialog is skipped (`Ask` migrates silently to `Quit`), and the close handler treats `MinimizeToTray` as `Quit` (in case the user previously enabled it on a different machine).
The wording "Minimize to tray" is used uniformly across all platforms, including macOS where the more native term would be "menu bar". A consistent in-app term is more important here than per-platform purity.
### Files changed
| File | Change |
|---|---|
| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | Add `CloseBehavior` enum, `closeBehavior` preference, `SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR` constant. *(already in this branch as commit 1)* |
| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Replace inline `onCloseRequest`; add `windowVisible` to `SimplexWindowState`; wire `Window(visible = …)`; host the `Tray` composable conditionally on `trayIsAvailable && closeBehavior == MinimizeToTray`. |
| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` *(new)* | `trayIsAvailable` probe, `requestCloseBehavior` + `CloseBehaviorDialog`, `SimplexTray` composable, `showWindow` helper. |
| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt` | Add the toggle row (gated on `trayIsAvailable`). |
| `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` | Add 8 new strings (dialog title/body/buttons, settings row, tray menu). |
| `apps/multiplatform/common/src/commonMain/resources/MR/images/` | Add `ic_simplex_tray` + `ic_simplex_tray_dot`. |
No `build.gradle.kts` change — Compose's `Tray` is already on the classpath via the existing `org.jetbrains.compose` plugin.
### New strings
```xml
<string name="close_behavior_dialog_title">Minimize to tray?</string>
<string name="close_behavior_dialog_text">If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings.</string>
<string name="close_behavior_dialog_close">Close the app</string>
<string name="close_behavior_dialog_minimize">Minimize to tray</string>
<string name="appearance_minimize_to_tray">Minimize to tray when closing window</string>
<string name="appearance_minimize_to_tray_desc">Keep SimpleX running in the background to receive messages.</string>
<string name="tray_show">Show SimpleX</string>
<string name="tray_quit">Quit SimpleX</string>
```
### Out of scope
The following are deliberately not in this PR:
- **Run on system startup / autostart entries.** Per-platform integration (Windows registry Run key, Linux `~/.config/autostart/*.desktop`, macOS LaunchAgents) is its own design.
- **Number-on-icon unread badges.** Cross-platform text rendering on tray icons is fragile across DPIs and macOS menu bar tinting.
- **Per-profile switcher / mute / mark-all-read** in the tray menu. Keep the menu to Show / Quit for now.
- **macOS template (auto-tinting) icon.** Compose `Tray` doesn't expose `NSImage.setTemplate:`; the tray icon will be a colored bitmap on macOS. Acceptable initial cost.
- **GNOME workaround documentation.** Users on stock GNOME won't see the option at all (probe returns false). We don't bundle or recommend the AppIndicator extension from the app itself; if we want to surface that guidance, it goes in the website/help docs, not in this PR.
### Test plan
Verified manually on at least one Linux (KDE Plasma), Windows 11, and macOS host:
1. Fresh install. Click X on the window. Dialog appears with the exact text and button colors. Dialog cannot be dismissed by Esc or outside-click.
2. Click "Close the app". App exits. Reopen, click X — app exits with no dialog (preference is now `Quit`).
3. Reset preference (or fresh install). Click X, click "Minimize to tray". Window hides. Tray icon appears.
4. Send a message to yourself / receive one. Tray icon switches to the red-dot variant; tooltip updates with unread count.
5. Click tray icon (left-click). Window restores and gains focus. Unread is cleared on viewing the chat.
6. Right-click tray icon. Menu shows "Show SimpleX" and "Quit SimpleX". Both work.
7. Open Appearance settings, flip "Minimize to tray when closing window" off. Tray icon disappears. Click X — app exits with no dialog.
8. Flip the toggle back on. Tray icon appears immediately (the composable is gated on the preference, so installation/removal follows the toggle).