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>) { fun main(args: Array<String>) {
// Separate AWT windows for Popup/Dialog so menus can draw past the app window edge // Do NOT set compose.layers.type=WINDOW.
// (closer to Android Popup behavior). Must be set before any Compose UI runs. // On macOS Metal that mode creates a JWindow + MetalRedrawer per Popup/Dialog; native
System.setProperty("compose.layers.type", "WINDOW") // 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()) { if (isMacOs()) {
// Must be set before AWT Toolkit init (do not call getString / Compose here). // Must be set before AWT Toolkit init (do not call getString / Compose here).
// Matches Res.string.app_name / app_name_beta. // 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 quit = stringResource(Res.string.desktop_quit)
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
var wsLinked by remember { mutableStateOf(WebSocketManager.isConnected) } 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) { LaunchedEffect(Unit) {
while (true) { 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) delay(400.milliseconds)
} }
} }
@@ -422,11 +430,13 @@ fun main(args: Array<String>) {
} }
when { when {
!event.isShiftPressed && event.key == Key.N -> { !event.isShiftPressed && event.key == Key.N -> {
if (!isLoggedIn) return@Window false
showMainWindow() showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat) DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
true true
} }
!event.isShiftPressed && event.key == Key.F -> { !event.isShiftPressed && event.key == Key.F -> {
if (!isLoggedIn) return@Window false
showMainWindow() showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations) DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
true true
@@ -477,6 +487,7 @@ fun main(args: Array<String>) {
if (mac) { if (mac) {
FromChatMenuBar( FromChatMenuBar(
isLoggedIn = isLoggedIn,
onNewChat = { onNewChat = {
showMainWindow() showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat) DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
@@ -520,6 +531,7 @@ fun main(args: Array<String>) {
@Composable @Composable
private fun FrameWindowScope.FromChatMenuBar( private fun FrameWindowScope.FromChatMenuBar(
isLoggedIn: Boolean,
onNewChat: () -> Unit, onNewChat: () -> Unit,
onSearchConversations: () -> Unit, onSearchConversations: () -> Unit,
onSelectChats: () -> Unit, onSelectChats: () -> Unit,
@@ -545,6 +557,7 @@ private fun FrameWindowScope.FromChatMenuBar(
MenuBar { MenuBar {
Menu(file, mnemonic = 'F') { Menu(file, mnemonic = 'F') {
if (isLoggedIn) {
Item( Item(
newChat, newChat,
shortcut = KeyShortcut(Key.N, meta = true), shortcut = KeyShortcut(Key.N, meta = true),
@@ -558,6 +571,7 @@ private fun FrameWindowScope.FromChatMenuBar(
) )
Separator() Separator()
}
Item( Item(
quit, 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 widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
val isDesktopListDetail = widthSizeClass != WindowWidthSizeClass.COMPACT val isDesktopListDetail = widthSizeClass != WindowWidthSizeClass.COMPACT
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) } var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
var pendingPreAuthSettingsDetailRoute by remember { mutableStateOf<String?>(null) }
// Handle startup/deep-link navigation targets (profile links) // Handle startup/deep-link navigation targets (profile links)
LaunchedEffect( LaunchedEffect(
@@ -505,9 +506,19 @@ fun App(
if (command != DesktopMenuCommand.OpenAbout) return@collect if (command != DesktopMenuCommand.OpenAbout) return@collect
pendingMainTab = MAIN_PAGE_SETTINGS pendingMainTab = MAIN_PAGE_SETTINGS
if (isDesktopListDetail) { 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( settingsDetailNavController.navigateReplacingMainDetail(
route = SettingsRoutes.About, route = SettingsRoutes.About,
) )
}
} else { } else {
navController.navigateReplacingMainDetail( navController.navigateReplacingMainDetail(
route = SettingsRoutes.About, 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 { ScreenSurface {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@Composable @Composable
@@ -2,7 +2,9 @@ package ru.fromchat.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size 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.Icons
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert 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.SettingsRoutes
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
private val AUTH_CONTENT_MAX_WIDTH = 600.dp
private val AUTH_CONTENT_MAX_HEIGHT = 800.dp
@Composable @Composable
fun WelcomeScreen( fun WelcomeScreen(
onGetStarted: () -> Unit, onGetStarted: () -> Unit,
@@ -60,7 +67,7 @@ fun WelcomeScreen(
} }
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.safeDrawing, contentWindowInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars),
) { innerPadding -> ) { innerPadding ->
val navController = LocalNavController.current val navController = LocalNavController.current
var menuExpanded by remember { mutableStateOf(false) } 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -114,12 +134,12 @@ fun WelcomeScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AsyncImage( AsyncImage(
model = Res.getUri("drawable/logo_square.svg"), model = Res.getUri("drawable/logo_square.png"),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(112.dp) .size(112.dp)
.clip(MaterialTheme.shapes.extraLarge), .clip(MaterialTheme.shapes.extraLarge),
contentScale = ContentScale.Crop, contentScale = ContentScale.Fit,
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -147,4 +167,6 @@ fun WelcomeScreen(
} }
} }
} }
}
}
} }
@@ -1,6 +1,11 @@
package ru.fromchat.ui.auth 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.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -13,7 +18,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException 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 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 { internal suspend fun probeCurrentServer() = runCatching {
withTimeout(AUTH_SERVER_PROBE_TIMEOUT_MS.milliseconds) { withTimeout(AUTH_SERVER_PROBE_TIMEOUT_MS.milliseconds) {
probeServer(Settings.readServerConfig()) is ServerProbeResult.Supported probeServer(Settings.readServerConfig()) is ServerProbeResult.Supported
@@ -433,6 +443,19 @@ fun AuthScreen(
} }
val yandexStep = yandexParams 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( ExpressiveStepFlowScaffold(
flowState = flowState, flowState = flowState,
pages = listOf( pages = listOf(
@@ -476,7 +499,9 @@ fun AuthScreen(
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)") Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal) flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
} }
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> { captchaRequired &&
captchaToken.isNullOrBlank() &&
captchaKey.isNotEmpty() -> {
openCaptchaRoute(captchaKey) openCaptchaRoute(captchaKey)
} }
else -> { else -> {
@@ -508,11 +533,16 @@ fun AuthScreen(
onContinue = { onContinue = {
val captchaKey = captchaParams?.client_key?.trim().orEmpty() val captchaKey = captchaParams?.client_key?.trim().orEmpty()
when { when {
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> { captchaRequired &&
captchaToken.isNullOrBlank() &&
captchaKey.isNotEmpty() -> {
openCaptchaRoute(captchaKey) openCaptchaRoute(captchaKey)
} }
else -> { else -> {
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile") Logger.i(
SmartCaptchaLog.TAG,
"yandex-placeholder confirm → Profile",
)
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal) flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
} }
} }
@@ -537,4 +567,6 @@ fun AuthScreen(
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
onBackAtFirstPage = wrappedBackToWelcome, onBackAtFirstPage = wrappedBackToWelcome,
) )
}
}
} }
@@ -23,7 +23,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
@@ -96,6 +96,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import kotlin.math.abs import kotlin.math.abs
@@ -550,6 +551,7 @@ fun ExpressiveStepFlowScaffold(
fun HazeTopBar(hazeState: HazeState) { fun HazeTopBar(hazeState: HazeState) {
val topBarHazeStyle = HazeMaterials.thin() val topBarHazeStyle = HazeMaterials.thin()
TopAppBar( TopAppBar(
windowInsets = WindowInsets.extraStatusBars,
title = {}, title = {},
navigationIcon = { navigationIcon = {
IconButton(onClick = navigateBack) { IconButton(onClick = navigateBack) {
@@ -583,7 +585,7 @@ fun ExpressiveStepFlowScaffold(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding() .windowInsetsPadding(WindowInsets.extraStatusBars)
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -12,6 +12,7 @@ import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -100,8 +101,11 @@ fun DesktopSettingsDetailNavHost(
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
if (showPanel) { if (showPanel) {
// Match Profile / ConversationListDetailShell (`background`), not the
// default AppPanel `surfaceContainerLowest` (visible mismatch in two-pane).
AppPanel( AppPanel(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
) {} ) {}
} }
@@ -60,7 +60,7 @@ fun AboutScreen() {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
SettingsDetailTopBar( SettingsDetailTopBar(
title = { title = {
@@ -67,7 +67,7 @@ fun AppearanceScreen(onBack: () -> Unit) {
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) } var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
SettingsDetailTopBar( SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_category_appearance)) }, title = { Text(stringResource(Res.string.settings_category_appearance)) },
@@ -59,7 +59,7 @@ fun NotificationsScreen(onBack: () -> Unit) {
val unexpectedErrorText = stringResource(Res.string.error_unexpected) val unexpectedErrorText = stringResource(Res.string.error_unexpected)
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, containerColor = MaterialTheme.colorScheme.background,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
SettingsDetailTopBar( SettingsDetailTopBar(
@@ -81,10 +81,10 @@ fun SettingsDetailTopBar(
} }
} }
} }
// Must match settings-detail Scaffold.containerColor and AppPanel // Match Profile / shell (`background`), not AppPanel's default
// (`surfaceContainerLowest`). Scaffold defaults to `background`, which is a // `surfaceContainerLowest` — that token reads as a separate pane fill in
// different token (e.g. light Neutral98 vs Neutral100) — that was the stripe. // two-pane against the listdetail shell.
val paneColor = MaterialTheme.colorScheme.surfaceContainerLowest val paneColor = MaterialTheme.colorScheme.background
if (settingsDetailUseCollapsingTopBar()) { if (settingsDetailUseCollapsingTopBar()) {
MediumTopAppBar( MediumTopAppBar(
title = title, title = title,
@@ -75,8 +75,8 @@ fun AccountScreen(
} }
Scaffold( Scaffold(
// Match AppPanel / SettingsDetailTopBar — default `background` is a darker stripe under the bar. // Match Profile / shell — `surfaceContainerLowest` mismatches the pane behind in two-pane.
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
SettingsDetailTopBar( SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_account_title)) }, title = { Text(stringResource(Res.string.settings_account_title)) },
@@ -16,9 +16,9 @@ import javafx.scene.web.WebEngine
import javafx.scene.web.WebView import javafx.scene.web.WebView
import netscape.javascript.JSObject import netscape.javascript.JSObject
import ru.fromchat.Logger import ru.fromchat.Logger
import java.util.concurrent.CountDownLatch import ru.fromchat.ui.jvm.ensureJavaFxStarted
import java.util.concurrent.TimeUnit import ru.fromchat.ui.jvm.releaseJavaFxWebView
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview" private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
@@ -58,6 +58,8 @@ actual fun SmartCaptchaWebView(
deliverError = { message -> onErrorState.value(message) }, deliverError = { message -> onErrorState.value(message) },
) )
} }
val panelRef = remember { AtomicReference<JFXPanel?>(null) }
val engineRef = remember { AtomicReference<WebEngine?>(null) }
DisposableEffect(instanceId) { DisposableEffect(instanceId) {
Logger.i( Logger.i(
@@ -66,15 +68,17 @@ actual fun SmartCaptchaWebView(
"sitekey=${SmartCaptchaLog.redactKey(sitekey)} lang=$lang " + "sitekey=${SmartCaptchaLog.redactKey(sitekey)} lang=$lang " +
"url=${SmartCaptchaLog.shortUrl(captchaUrl)}", "url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
) )
ensureJavaFxStarted() ensureJavaFxStarted(SmartCaptchaLog.TAG)
onDispose { onDispose {
Logger.i(SmartCaptchaLog.TAG, "JavaFX WebView compose dispose id=$instanceId") Logger.i(SmartCaptchaLog.TAG, "JavaFX WebView compose dispose id=$instanceId")
releaseJavaFxWebView(panelRef, engineRef)
} }
} }
SwingPanel( SwingPanel(
factory = { factory = {
JFXPanel().also { panel -> JFXPanel().also { panel ->
panelRef.set(panel)
panel.background = java.awt.Color( panel.background = java.awt.Color(
surfaceContainer.red, surfaceContainer.red,
surfaceContainer.green, surfaceContainer.green,
@@ -86,6 +90,7 @@ actual fun SmartCaptchaWebView(
val webView = WebView() val webView = WebView()
val engine = webView.engine val engine = webView.engine
engine.isJavaScriptEnabled = true engine.isJavaScriptEnabled = true
engineRef.set(engine)
engine.loadWorker.stateProperty().addListener { _, _, newState -> engine.loadWorker.stateProperty().addListener { _, _, newState ->
when (newState) { when (newState) {
Worker.State.SUCCEEDED -> { Worker.State.SUCCEEDED -> {
@@ -169,24 +174,3 @@ private fun injectNativeClient(engine: WebEngine, bridge: SmartCaptchaJsBridge)
Logger.w(SmartCaptchaLog.TAG, "inject NativeClient failed: ${it.message}", it) 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.Logger
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession import ru.fromchat.api.calls.LiveKitConnectSession
import java.util.concurrent.CountDownLatch import ru.fromchat.ui.jvm.ensureJavaFxStarted
import java.util.concurrent.TimeUnit import ru.fromchat.ui.jvm.releaseJavaFxWebView
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
@@ -73,6 +72,7 @@ private fun LiveKitJavaFxCall(
) { ) {
var micOn by remember(session.roomName) { mutableStateOf(true) } var micOn by remember(session.roomName) { mutableStateOf(true) }
var camOn 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 engineRef = remember { AtomicReference<WebEngine?>(null) }
val sessionState = rememberUpdatedState(session) val sessionState = rememberUpdatedState(session)
val surface = MaterialTheme.colorScheme.surfaceContainerLowest val surface = MaterialTheme.colorScheme.surfaceContainerLowest
@@ -89,7 +89,7 @@ private fun LiveKitJavaFxCall(
} }
DisposableEffect(session.roomName) { DisposableEffect(session.roomName) {
ensureJavaFxStarted() ensureJavaFxStarted(TAG)
onDispose { onDispose {
val engine = engineRef.get() val engine = engineRef.get()
if (engine != null) { if (engine != null) {
@@ -97,7 +97,7 @@ private fun LiveKitJavaFxCall(
runCatching { engine.executeScript(LiveKitCallWebPage.disconnectScript()) } runCatching { engine.executeScript(LiveKitCallWebPage.disconnectScript()) }
} }
} }
engineRef.set(null) releaseJavaFxWebView(panelRef, engineRef)
} }
} }
@@ -105,6 +105,7 @@ private fun LiveKitJavaFxCall(
SwingPanel( SwingPanel(
factory = { factory = {
JFXPanel().also { panel -> JFXPanel().also { panel ->
panelRef.set(panel)
panel.background = java.awt.Color( panel.background = java.awt.Color(
surface.red, surface.red,
surface.green, surface.green,
@@ -224,23 +225,3 @@ private fun injectNativeBridge(engine: WebEngine, bridge: LiveKitCallJsBridge) {
Logger.w(TAG, "inject FromChatNative failed: ${it.message}", it) 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}")
}
}
}