mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix UI issues, fix big memory leak
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
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
|
||||||
|
// ~7–8 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,19 +557,21 @@ private fun FrameWindowScope.FromChatMenuBar(
|
|||||||
|
|
||||||
MenuBar {
|
MenuBar {
|
||||||
Menu(file, mnemonic = 'F') {
|
Menu(file, mnemonic = 'F') {
|
||||||
Item(
|
if (isLoggedIn) {
|
||||||
newChat,
|
Item(
|
||||||
shortcut = KeyShortcut(Key.N, meta = true),
|
newChat,
|
||||||
onClick = onNewChat,
|
shortcut = KeyShortcut(Key.N, meta = true),
|
||||||
)
|
onClick = onNewChat,
|
||||||
|
)
|
||||||
|
|
||||||
Item(
|
Item(
|
||||||
searchConversations,
|
searchConversations,
|
||||||
shortcut = KeyShortcut(Key.F, meta = true),
|
shortcut = KeyShortcut(Key.F, meta = true),
|
||||||
onClick = onSearchConversations,
|
onClick = onSearchConversations,
|
||||||
)
|
)
|
||||||
|
|
||||||
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) {
|
||||||
settingsDetailNavController.navigateReplacingMainDetail(
|
if (ApiClient.token.isNullOrBlank()) {
|
||||||
route = SettingsRoutes.About,
|
// 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 {
|
} 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,45 +111,60 @@ fun WelcomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
val constrainWide = maxWidth > AUTH_CONTENT_MAX_WIDTH
|
||||||
.fillMaxSize()
|
Box(
|
||||||
.navigationBarsPadding()
|
modifier =
|
||||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
if (constrainWide) {
|
||||||
.padding(bottom = 16.dp),
|
Modifier
|
||||||
verticalArrangement = Arrangement.Center,
|
.align(Alignment.Center)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.widthIn(max = AUTH_CONTENT_MAX_WIDTH)
|
||||||
) {
|
.heightIn(max = AUTH_CONTENT_MAX_HEIGHT)
|
||||||
AsyncImage(
|
} else {
|
||||||
model = Res.getUri("drawable/logo_square.svg"),
|
Modifier.fillMaxSize()
|
||||||
contentDescription = null,
|
},
|
||||||
modifier = Modifier
|
) {
|
||||||
.size(112.dp)
|
Column(
|
||||||
.clip(MaterialTheme.shapes.extraLarge),
|
modifier = Modifier
|
||||||
contentScale = ContentScale.Crop,
|
.fillMaxSize()
|
||||||
)
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = Res.getUri("drawable/logo_square.png"),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(112.dp)
|
||||||
|
.clip(MaterialTheme.shapes.extraLarge),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.auth_welcome_title),
|
text = stringResource(Res.string.auth_welcome_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.auth_welcome_tagline),
|
text = stringResource(Res.string.auth_welcome_tagline),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(40.dp))
|
Spacer(Modifier.height(40.dp))
|
||||||
|
|
||||||
ActionButton(onClick = onGetStarted) {
|
ActionButton(onClick = onGetStarted) {
|
||||||
Text(stringResource(Res.string.auth_get_started))
|
Text(stringResource(Res.string.auth_get_started))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,108 +443,130 @@ fun AuthScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val yandexStep = yandexParams
|
val yandexStep = yandexParams
|
||||||
ExpressiveStepFlowScaffold(
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
flowState = flowState,
|
val constrainWide = maxWidth > AUTH_CONTENT_MAX_WIDTH
|
||||||
pages = listOf(
|
Box(
|
||||||
usernameStepPage(
|
modifier =
|
||||||
username = username,
|
if (constrainWide) {
|
||||||
onUsernameChange = { username = it },
|
Modifier
|
||||||
onContinue = {
|
.align(Alignment.Center)
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal)
|
.widthIn(max = AUTH_CONTENT_MAX_WIDTH)
|
||||||
|
.heightIn(max = AUTH_CONTENT_MAX_HEIGHT)
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxSize()
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
) {
|
||||||
),
|
ExpressiveStepFlowScaffold(
|
||||||
passwordStepPage(
|
flowState = flowState,
|
||||||
username = username,
|
pages = listOf(
|
||||||
password = password,
|
usernameStepPage(
|
||||||
onPasswordChange = { password = it },
|
username = username,
|
||||||
onLoginSuccess = wrappedAuthSuccess,
|
onUsernameChange = { username = it },
|
||||||
onNeedsRegister = { required, params, captchaReq, captcha ->
|
onContinue = {
|
||||||
Logger.i(
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal)
|
||||||
SmartCaptchaLog.TAG,
|
},
|
||||||
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
|
onSnackbar = ::snackbar,
|
||||||
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
|
),
|
||||||
)
|
passwordStepPage(
|
||||||
yandexRequired = required
|
username = username,
|
||||||
yandexParams = params
|
password = password,
|
||||||
captchaRequired = captchaReq
|
onPasswordChange = { password = it },
|
||||||
captchaParams = captcha
|
onLoginSuccess = wrappedAuthSuccess,
|
||||||
registrationProof = null
|
onNeedsRegister = { required, params, captchaReq, captcha ->
|
||||||
captchaToken = null
|
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
|
||||||
},
|
|
||||||
onSnackbar = ::snackbar,
|
|
||||||
),
|
|
||||||
confirmPasswordStepPage(
|
|
||||||
confirmPassword = confirmPassword,
|
|
||||||
onConfirmPasswordChange = { confirmPassword = it },
|
|
||||||
password = password,
|
|
||||||
onContinue = {
|
|
||||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
|
||||||
when {
|
|
||||||
yandexRequired && yandexParams != null -> {
|
|
||||||
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
|
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
|
||||||
}
|
|
||||||
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
|
||||||
openCaptchaRoute(captchaKey)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
Logger.i(
|
Logger.i(
|
||||||
SmartCaptchaLog.TAG,
|
SmartCaptchaLog.TAG,
|
||||||
"confirm → Profile captchaRequired=$captchaRequired " +
|
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
|
||||||
"hasToken=${!captchaToken.isNullOrBlank()}",
|
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
|
||||||
)
|
)
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
yandexRequired = required
|
||||||
}
|
yandexParams = params
|
||||||
}
|
captchaRequired = captchaReq
|
||||||
},
|
captchaParams = captcha
|
||||||
onSnackbar = ::snackbar,
|
registrationProof = null
|
||||||
),
|
captchaToken = null
|
||||||
if (yandexStep != null) {
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||||
yandexIdStepPage(
|
},
|
||||||
yandex = yandexStep,
|
onSnackbar = ::snackbar,
|
||||||
onProof = { proof ->
|
),
|
||||||
registrationProof = proof
|
confirmPasswordStepPage(
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
confirmPassword = confirmPassword,
|
||||||
},
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
onSnackbar = ::snackbar,
|
password = password,
|
||||||
)
|
onContinue = {
|
||||||
} else {
|
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||||
confirmPasswordStepPage(
|
when {
|
||||||
confirmPassword = confirmPassword,
|
yandexRequired && yandexParams != null -> {
|
||||||
onConfirmPasswordChange = { confirmPassword = it },
|
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
|
||||||
password = password,
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||||
onContinue = {
|
}
|
||||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
captchaRequired &&
|
||||||
when {
|
captchaToken.isNullOrBlank() &&
|
||||||
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
captchaKey.isNotEmpty() -> {
|
||||||
openCaptchaRoute(captchaKey)
|
openCaptchaRoute(captchaKey)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"confirm → Profile captchaRequired=$captchaRequired " +
|
||||||
|
"hasToken=${!captchaToken.isNullOrBlank()}",
|
||||||
|
)
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
},
|
||||||
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile")
|
onSnackbar = ::snackbar,
|
||||||
|
),
|
||||||
|
if (yandexStep != null) {
|
||||||
|
yandexIdStepPage(
|
||||||
|
yandex = yandexStep,
|
||||||
|
onProof = { proof ->
|
||||||
|
registrationProof = proof
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
}
|
},
|
||||||
}
|
onSnackbar = ::snackbar,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
confirmPasswordStepPage(
|
||||||
|
confirmPassword = confirmPassword,
|
||||||
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
|
password = password,
|
||||||
|
onContinue = {
|
||||||
|
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||||
|
when {
|
||||||
|
captchaRequired &&
|
||||||
|
captchaToken.isNullOrBlank() &&
|
||||||
|
captchaKey.isNotEmpty() -> {
|
||||||
|
openCaptchaRoute(captchaKey)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"yandex-placeholder confirm → Profile",
|
||||||
|
)
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSnackbar = ::snackbar,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
profileStepPage(
|
||||||
)
|
username = username,
|
||||||
},
|
displayName = displayName,
|
||||||
profileStepPage(
|
onDisplayNameChange = { displayName = it },
|
||||||
username = username,
|
bio = bio,
|
||||||
displayName = displayName,
|
onBioChange = { bio = it },
|
||||||
onDisplayNameChange = { displayName = it },
|
password = password,
|
||||||
bio = bio,
|
registrationProof = registrationProof,
|
||||||
onBioChange = { bio = it },
|
captchaToken = captchaToken,
|
||||||
password = password,
|
onRegisterSuccess = wrappedAuthSuccess,
|
||||||
registrationProof = registrationProof,
|
onUsernameTaken = resetToUsername,
|
||||||
captchaToken = captchaToken,
|
onSnackbar = ::snackbar,
|
||||||
onRegisterSuccess = wrappedAuthSuccess,
|
),
|
||||||
onUsernameTaken = resetToUsername,
|
),
|
||||||
onSnackbar = ::snackbar,
|
snackbarHostState = snackbarHostState,
|
||||||
),
|
onBackAtFirstPage = wrappedBackToWelcome,
|
||||||
),
|
)
|
||||||
snackbarHostState = snackbarHostState,
|
}
|
||||||
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)) },
|
||||||
|
|||||||
+1
-1
@@ -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(
|
||||||
|
|||||||
+4
-4
@@ -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 list–detail shell.
|
||||||
val paneColor = MaterialTheme.colorScheme.surfaceContainerLowest
|
val paneColor = MaterialTheme.colorScheme.background
|
||||||
if (settingsDetailUseCollapsingTopBar()) {
|
if (settingsDetailUseCollapsingTopBar()) {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = title,
|
title = title,
|
||||||
|
|||||||
+2
-2
@@ -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)) },
|
||||||
|
|||||||
+9
-25
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user