Fix UI issues, fix big memory leak

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-29 16:22:09 +03:00
Unverified
parent ba13a31700
commit 7916e16820
16 changed files with 325 additions and 210 deletions
Binary file not shown.
@@ -246,9 +246,11 @@ private object DesktopProtocolRegistration {
}
fun main(args: Array<String>) {
// Separate AWT windows for Popup/Dialog so menus can draw past the app window edge
// (closer to Android Popup behavior). Must be set before any Compose UI runs.
System.setProperty("compose.layers.type", "WINDOW")
// Do NOT set compose.layers.type=WINDOW.
// On macOS Metal that mode creates a JWindow + MetalRedrawer per Popup/Dialog; native
// IOAccelerator surfaces are retained after dismiss and grew to multi-GB (Activity Monitor
// ~78 GB) while the Java heap stayed small. Default COMPONENT layers avoid that leak;
// context menus already use PopupProperties(clippingEnabled = false).
if (isMacOs()) {
// Must be set before AWT Toolkit init (do not call getString / Compose here).
// Matches Res.string.app_name / app_name_beta.
@@ -302,9 +304,15 @@ fun main(args: Array<String>) {
val quit = stringResource(Res.string.desktop_quit)
val connectionStatus by ConnectionStateStore.status.collectAsState()
var wsLinked by remember { mutableStateOf(WebSocketManager.isConnected) }
// ApiClient.token is not a Compose state; poll so menu items update on login/logout.
// Only write when the value changes — avoids MenuBar/Tray recomposition churn every tick.
var isLoggedIn by remember { mutableStateOf(!ApiClient.token.isNullOrBlank()) }
LaunchedEffect(Unit) {
while (true) {
wsLinked = WebSocketManager.isConnected
val linked = WebSocketManager.isConnected
if (wsLinked != linked) wsLinked = linked
val loggedIn = !ApiClient.token.isNullOrBlank()
if (isLoggedIn != loggedIn) isLoggedIn = loggedIn
delay(400.milliseconds)
}
}
@@ -422,11 +430,13 @@ fun main(args: Array<String>) {
}
when {
!event.isShiftPressed && event.key == Key.N -> {
if (!isLoggedIn) return@Window false
showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
true
}
!event.isShiftPressed && event.key == Key.F -> {
if (!isLoggedIn) return@Window false
showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
true
@@ -477,6 +487,7 @@ fun main(args: Array<String>) {
if (mac) {
FromChatMenuBar(
isLoggedIn = isLoggedIn,
onNewChat = {
showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
@@ -520,6 +531,7 @@ fun main(args: Array<String>) {
@Composable
private fun FrameWindowScope.FromChatMenuBar(
isLoggedIn: Boolean,
onNewChat: () -> Unit,
onSearchConversations: () -> Unit,
onSelectChats: () -> Unit,
@@ -545,6 +557,7 @@ private fun FrameWindowScope.FromChatMenuBar(
MenuBar {
Menu(file, mnemonic = 'F') {
if (isLoggedIn) {
Item(
newChat,
shortcut = KeyShortcut(Key.N, meta = true),
@@ -558,6 +571,7 @@ private fun FrameWindowScope.FromChatMenuBar(
)
Separator()
}
Item(
quit,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 102 KiB

@@ -358,6 +358,7 @@ fun App(
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
val isDesktopListDetail = widthSizeClass != WindowWidthSizeClass.COMPACT
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
var pendingPreAuthSettingsDetailRoute by remember { mutableStateOf<String?>(null) }
// Handle startup/deep-link navigation targets (profile links)
LaunchedEffect(
@@ -505,9 +506,19 @@ fun App(
if (command != DesktopMenuCommand.OpenAbout) return@collect
pendingMainTab = MAIN_PAGE_SETTINGS
if (isDesktopListDetail) {
if (ApiClient.token.isNullOrBlank()) {
// Pre-auth: the settings detail NavHost isn't mounted until the root
// shell is visible, so we defer settings navigation until `currentRoute`
// becomes `chat`.
pendingPreAuthSettingsDetailRoute = SettingsRoutes.About
navController.navigate("chat") {
launchSingleTop = true
}
} else {
settingsDetailNavController.navigateReplacingMainDetail(
route = SettingsRoutes.About,
)
}
} else {
navController.navigateReplacingMainDetail(
route = SettingsRoutes.About,
@@ -516,6 +527,14 @@ fun App(
}
}
LaunchedEffect(currentRoute, pendingPreAuthSettingsDetailRoute) {
val route = pendingPreAuthSettingsDetailRoute ?: return@LaunchedEffect
if (!isDesktopListDetail) return@LaunchedEffect
if (currentRoute != "chat") return@LaunchedEffect
settingsDetailNavController.navigateReplacingMainDetail(route = route)
pendingPreAuthSettingsDetailRoute = null
}
ScreenSurface {
Box(Modifier.fillMaxSize()) {
@Composable
@@ -2,7 +2,9 @@ package ru.fromchat.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -11,6 +13,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
@@ -48,6 +52,9 @@ import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
private val AUTH_CONTENT_MAX_WIDTH = 600.dp
private val AUTH_CONTENT_MAX_HEIGHT = 800.dp
@Composable
fun WelcomeScreen(
onGetStarted: () -> Unit,
@@ -60,7 +67,7 @@ fun WelcomeScreen(
}
Scaffold(
contentWindowInsets = WindowInsets.safeDrawing,
contentWindowInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars),
) { innerPadding ->
val navController = LocalNavController.current
var menuExpanded by remember { mutableStateOf(false) }
@@ -104,6 +111,19 @@ fun WelcomeScreen(
}
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val constrainWide = maxWidth > AUTH_CONTENT_MAX_WIDTH
Box(
modifier =
if (constrainWide) {
Modifier
.align(Alignment.Center)
.widthIn(max = AUTH_CONTENT_MAX_WIDTH)
.heightIn(max = AUTH_CONTENT_MAX_HEIGHT)
} else {
Modifier.fillMaxSize()
},
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -114,12 +134,12 @@ fun WelcomeScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = Res.getUri("drawable/logo_square.svg"),
model = Res.getUri("drawable/logo_square.png"),
contentDescription = null,
modifier = Modifier
.size(112.dp)
.clip(MaterialTheme.shapes.extraLarge),
contentScale = ContentScale.Crop,
contentScale = ContentScale.Fit,
)
Spacer(Modifier.height(24.dp))
@@ -148,3 +168,5 @@ fun WelcomeScreen(
}
}
}
}
}
@@ -1,6 +1,11 @@
package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.SnackbarHostState
@@ -13,7 +18,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
@@ -78,6 +85,9 @@ internal sealed interface RegisterResult {
private const val AUTH_SERVER_PROBE_TIMEOUT_MS = 5_000L
private val AUTH_CONTENT_MAX_WIDTH = 600.dp
private val AUTH_CONTENT_MAX_HEIGHT = 800.dp
internal suspend fun probeCurrentServer() = runCatching {
withTimeout(AUTH_SERVER_PROBE_TIMEOUT_MS.milliseconds) {
probeServer(Settings.readServerConfig()) is ServerProbeResult.Supported
@@ -433,6 +443,19 @@ fun AuthScreen(
}
val yandexStep = yandexParams
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val constrainWide = maxWidth > AUTH_CONTENT_MAX_WIDTH
Box(
modifier =
if (constrainWide) {
Modifier
.align(Alignment.Center)
.widthIn(max = AUTH_CONTENT_MAX_WIDTH)
.heightIn(max = AUTH_CONTENT_MAX_HEIGHT)
} else {
Modifier.fillMaxSize()
},
) {
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
@@ -476,7 +499,9 @@ fun AuthScreen(
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
}
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
captchaRequired &&
captchaToken.isNullOrBlank() &&
captchaKey.isNotEmpty() -> {
openCaptchaRoute(captchaKey)
}
else -> {
@@ -508,11 +533,16 @@ fun AuthScreen(
onContinue = {
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
when {
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
captchaRequired &&
captchaToken.isNullOrBlank() &&
captchaKey.isNotEmpty() -> {
openCaptchaRoute(captchaKey)
}
else -> {
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile")
Logger.i(
SmartCaptchaLog.TAG,
"yandex-placeholder confirm → Profile",
)
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
}
@@ -538,3 +568,5 @@ fun AuthScreen(
onBackAtFirstPage = wrappedBackToWelcome,
)
}
}
}
@@ -23,7 +23,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
@@ -96,6 +96,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.back
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import kotlin.math.abs
@@ -550,6 +551,7 @@ fun ExpressiveStepFlowScaffold(
fun HazeTopBar(hazeState: HazeState) {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
windowInsets = WindowInsets.extraStatusBars,
title = {},
navigationIcon = {
IconButton(onClick = navigateBack) {
@@ -583,7 +585,7 @@ fun ExpressiveStepFlowScaffold(
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.extraStatusBars)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -12,6 +12,7 @@ import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
@@ -100,8 +101,11 @@ fun DesktopSettingsDetailNavHost(
Box(modifier.fillMaxSize()) {
if (showPanel) {
// Match Profile / ConversationListDetailShell (`background`), not the
// default AppPanel `surfaceContainerLowest` (visible mismatch in two-pane).
AppPanel(
Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
shape = RoundedCornerShape(24.dp),
) {}
}
@@ -60,7 +60,7 @@ fun AboutScreen() {
val uriHandler = LocalUriHandler.current
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SettingsDetailTopBar(
title = {
@@ -67,7 +67,7 @@ fun AppearanceScreen(onBack: () -> Unit) {
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_category_appearance)) },
@@ -59,7 +59,7 @@ fun NotificationsScreen(onBack: () -> Unit) {
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
containerColor = MaterialTheme.colorScheme.background,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
SettingsDetailTopBar(
@@ -81,10 +81,10 @@ fun SettingsDetailTopBar(
}
}
}
// Must match settings-detail Scaffold.containerColor and AppPanel
// (`surfaceContainerLowest`). Scaffold defaults to `background`, which is a
// different token (e.g. light Neutral98 vs Neutral100) — that was the stripe.
val paneColor = MaterialTheme.colorScheme.surfaceContainerLowest
// Match Profile / shell (`background`), not AppPanel's default
// `surfaceContainerLowest` — that token reads as a separate pane fill in
// two-pane against the listdetail shell.
val paneColor = MaterialTheme.colorScheme.background
if (settingsDetailUseCollapsingTopBar()) {
MediumTopAppBar(
title = title,
@@ -75,8 +75,8 @@ fun AccountScreen(
}
Scaffold(
// Match AppPanel / SettingsDetailTopBar — default `background` is a darker stripe under the bar.
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
// Match Profile / shell — `surfaceContainerLowest` mismatches the pane behind in two-pane.
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_account_title)) },
@@ -16,9 +16,9 @@ import javafx.scene.web.WebEngine
import javafx.scene.web.WebView
import netscape.javascript.JSObject
import ru.fromchat.Logger
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import ru.fromchat.ui.jvm.ensureJavaFxStarted
import ru.fromchat.ui.jvm.releaseJavaFxWebView
import java.util.concurrent.atomic.AtomicReference
import javax.swing.SwingUtilities
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
@@ -58,6 +58,8 @@ actual fun SmartCaptchaWebView(
deliverError = { message -> onErrorState.value(message) },
)
}
val panelRef = remember { AtomicReference<JFXPanel?>(null) }
val engineRef = remember { AtomicReference<WebEngine?>(null) }
DisposableEffect(instanceId) {
Logger.i(
@@ -66,15 +68,17 @@ actual fun SmartCaptchaWebView(
"sitekey=${SmartCaptchaLog.redactKey(sitekey)} lang=$lang " +
"url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
)
ensureJavaFxStarted()
ensureJavaFxStarted(SmartCaptchaLog.TAG)
onDispose {
Logger.i(SmartCaptchaLog.TAG, "JavaFX WebView compose dispose id=$instanceId")
releaseJavaFxWebView(panelRef, engineRef)
}
}
SwingPanel(
factory = {
JFXPanel().also { panel ->
panelRef.set(panel)
panel.background = java.awt.Color(
surfaceContainer.red,
surfaceContainer.green,
@@ -86,6 +90,7 @@ actual fun SmartCaptchaWebView(
val webView = WebView()
val engine = webView.engine
engine.isJavaScriptEnabled = true
engineRef.set(engine)
engine.loadWorker.stateProperty().addListener { _, _, newState ->
when (newState) {
Worker.State.SUCCEEDED -> {
@@ -169,24 +174,3 @@ private fun injectNativeClient(engine: WebEngine, bridge: SmartCaptchaJsBridge)
Logger.w(SmartCaptchaLog.TAG, "inject NativeClient failed: ${it.message}", it)
}
}
private val javaFxStarted = AtomicBoolean(false)
private fun ensureJavaFxStarted() {
if (javaFxStarted.get()) return
synchronized(javaFxStarted) {
if (javaFxStarted.get()) return
runCatching {
Platform.setImplicitExit(false)
val latch = CountDownLatch(1)
Platform.startup { latch.countDown() }
if (!latch.await(5, TimeUnit.SECONDS)) {
Logger.w(SmartCaptchaLog.TAG, "JavaFX Platform.startup timed out")
}
}.onFailure { error ->
// Toolkit may already be running via JFXPanel.
Logger.d(SmartCaptchaLog.TAG, "JavaFX startup: ${error.message}")
}
javaFxStarted.set(true)
}
}
@@ -26,9 +26,8 @@ import netscape.javascript.JSObject
import ru.fromchat.Logger
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import ru.fromchat.ui.jvm.ensureJavaFxStarted
import ru.fromchat.ui.jvm.releaseJavaFxWebView
import java.util.concurrent.atomic.AtomicReference
import javax.swing.SwingUtilities
@@ -73,6 +72,7 @@ private fun LiveKitJavaFxCall(
) {
var micOn by remember(session.roomName) { mutableStateOf(true) }
var camOn by remember(session.roomName) { mutableStateOf(true) }
val panelRef = remember { AtomicReference<JFXPanel?>(null) }
val engineRef = remember { AtomicReference<WebEngine?>(null) }
val sessionState = rememberUpdatedState(session)
val surface = MaterialTheme.colorScheme.surfaceContainerLowest
@@ -89,7 +89,7 @@ private fun LiveKitJavaFxCall(
}
DisposableEffect(session.roomName) {
ensureJavaFxStarted()
ensureJavaFxStarted(TAG)
onDispose {
val engine = engineRef.get()
if (engine != null) {
@@ -97,7 +97,7 @@ private fun LiveKitJavaFxCall(
runCatching { engine.executeScript(LiveKitCallWebPage.disconnectScript()) }
}
}
engineRef.set(null)
releaseJavaFxWebView(panelRef, engineRef)
}
}
@@ -105,6 +105,7 @@ private fun LiveKitJavaFxCall(
SwingPanel(
factory = {
JFXPanel().also { panel ->
panelRef.set(panel)
panel.background = java.awt.Color(
surface.red,
surface.green,
@@ -224,23 +225,3 @@ private fun injectNativeBridge(engine: WebEngine, bridge: LiveKitCallJsBridge) {
Logger.w(TAG, "inject FromChatNative failed: ${it.message}", it)
}
}
private val javaFxStarted = AtomicBoolean(false)
private fun ensureJavaFxStarted() {
if (javaFxStarted.get()) return
synchronized(javaFxStarted) {
if (javaFxStarted.get()) return
runCatching {
Platform.setImplicitExit(false)
val latch = CountDownLatch(1)
Platform.startup { latch.countDown() }
if (!latch.await(5, TimeUnit.SECONDS)) {
Logger.w(TAG, "JavaFX Platform.startup timed out")
}
}.onFailure { error ->
Logger.d(TAG, "JavaFX startup: ${error.message}")
}
javaFxStarted.set(true)
}
}
@@ -0,0 +1,57 @@
package ru.fromchat.ui.jvm
import javafx.application.Platform
import javafx.embed.swing.JFXPanel
import javafx.scene.web.WebEngine
import ru.fromchat.Logger
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
private val javaFxStarted = AtomicBoolean(false)
/** Starts the JavaFX toolkit once (safe if already running via [JFXPanel]). */
fun ensureJavaFxStarted(logTag: String) {
if (javaFxStarted.get()) return
synchronized(javaFxStarted) {
if (javaFxStarted.get()) return
runCatching {
Platform.setImplicitExit(false)
val latch = CountDownLatch(1)
Platform.startup { latch.countDown() }
if (!latch.await(5, TimeUnit.SECONDS)) {
Logger.w(logTag, "JavaFX Platform.startup timed out")
}
}.onFailure { error ->
// Toolkit may already be running via JFXPanel.
Logger.d(logTag, "JavaFX startup: ${error.message}")
}
javaFxStarted.set(true)
}
}
/**
* Tears down an OpenJFX [WebView] hosted in a Compose [JFXPanel].
*
* Without this, [WebEngine]'s pulse timer and WebKit textures stay alive after the Swing panel
* leaves composition observed as multi-GB macOS IOAccelerator (graphics) growth while the
* Java heap stays small.
*/
fun releaseJavaFxWebView(
panelRef: AtomicReference<JFXPanel?>,
engineRef: AtomicReference<WebEngine?>,
) {
val panel = panelRef.getAndSet(null)
val engine = engineRef.getAndSet(null)
if (panel == null && engine == null) return
Platform.runLater {
runCatching {
engine?.loadWorker?.cancel()
engine?.load(null)
panel?.scene = null
}.onFailure { error ->
Logger.w("JavaFxWebView", "release failed: ${error.message}")
}
}
}