diff --git a/app/desktop/icons/app_window_icon.icns b/app/desktop/icons/app_window_icon.icns index 1cc6557..9ff3260 100644 Binary files a/app/desktop/icons/app_window_icon.icns and b/app/desktop/icons/app_window_icon.icns differ diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 8727bd6..1fcc592 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -246,9 +246,11 @@ private object DesktopProtocolRegistration { } fun main(args: Array) { - // 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 + // ~7–8 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) { 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) { } 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) { if (mac) { FromChatMenuBar( + isLoggedIn = isLoggedIn, onNewChat = { showMainWindow() DesktopMenuCommands.emit(DesktopMenuCommand.NewChat) @@ -520,6 +531,7 @@ fun main(args: Array) { @Composable private fun FrameWindowScope.FromChatMenuBar( + isLoggedIn: Boolean, onNewChat: () -> Unit, onSearchConversations: () -> Unit, onSelectChats: () -> Unit, @@ -545,19 +557,21 @@ private fun FrameWindowScope.FromChatMenuBar( MenuBar { Menu(file, mnemonic = 'F') { - Item( - newChat, - shortcut = KeyShortcut(Key.N, meta = true), - onClick = onNewChat, - ) + if (isLoggedIn) { + Item( + newChat, + shortcut = KeyShortcut(Key.N, meta = true), + onClick = onNewChat, + ) - Item( - searchConversations, - shortcut = KeyShortcut(Key.F, meta = true), - onClick = onSearchConversations, - ) + Item( + searchConversations, + shortcut = KeyShortcut(Key.F, meta = true), + onClick = onSearchConversations, + ) - Separator() + Separator() + } Item( quit, diff --git a/app/desktop/src/main/resources/app_window_icon.png b/app/desktop/src/main/resources/app_window_icon.png index 80c667a..c0e179f 100644 Binary files a/app/desktop/src/main/resources/app_window_icon.png and b/app/desktop/src/main/resources/app_window_icon.png differ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 09a12b8..c1aa4fc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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(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) { - settingsDetailNavController.navigateReplacingMainDetail( - route = SettingsRoutes.About, - ) + 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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt index eef5568..9b3876a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt @@ -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,45 +111,60 @@ fun WelcomeScreen( } } - Column( - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() - .padding(horizontal = SettingsStepHorizontalPadding) - .padding(bottom = 16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AsyncImage( - model = Res.getUri("drawable/logo_square.svg"), - contentDescription = null, - modifier = Modifier - .size(112.dp) - .clip(MaterialTheme.shapes.extraLarge), - contentScale = ContentScale.Crop, - ) + 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() + .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 = stringResource(Res.string.auth_welcome_title), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) + Text( + text = stringResource(Res.string.auth_welcome_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) - Text( - text = stringResource(Res.string.auth_welcome_tagline), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + Text( + text = stringResource(Res.string.auth_welcome_tagline), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(40.dp)) - ActionButton(onClick = onGetStarted) { - Text(stringResource(Res.string.auth_get_started)) + ActionButton(onClick = onGetStarted) { + Text(stringResource(Res.string.auth_get_started)) + } + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt index 7024620..8f0049e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt @@ -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,108 +443,130 @@ fun AuthScreen( } val yandexStep = yandexParams - ExpressiveStepFlowScaffold( - flowState = flowState, - pages = listOf( - usernameStepPage( - username = username, - onUsernameChange = { username = it }, - onContinue = { - flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal) + 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() }, - onSnackbar = ::snackbar, - ), - passwordStepPage( - username = username, - password = password, - onPasswordChange = { password = it }, - onLoginSuccess = wrappedAuthSuccess, - onNeedsRegister = { required, params, captchaReq, captcha -> - Logger.i( - SmartCaptchaLog.TAG, - "onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " + - "clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}", - ) - yandexRequired = required - yandexParams = params - captchaRequired = captchaReq - captchaParams = captcha - registrationProof = null - 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 -> { + ) { + ExpressiveStepFlowScaffold( + flowState = flowState, + pages = listOf( + usernameStepPage( + username = username, + onUsernameChange = { username = it }, + onContinue = { + flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal) + }, + onSnackbar = ::snackbar, + ), + passwordStepPage( + username = username, + password = password, + onPasswordChange = { password = it }, + onLoginSuccess = wrappedAuthSuccess, + onNeedsRegister = { required, params, captchaReq, captcha -> Logger.i( SmartCaptchaLog.TAG, - "confirm → Profile captchaRequired=$captchaRequired " + - "hasToken=${!captchaToken.isNullOrBlank()}", + "onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " + + "clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}", ) - flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal) - } - } - }, - onSnackbar = ::snackbar, - ), - if (yandexStep != null) { - yandexIdStepPage( - yandex = yandexStep, - onProof = { proof -> - registrationProof = proof - 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) + yandexRequired = required + yandexParams = params + captchaRequired = captchaReq + captchaParams = captcha + registrationProof = null + 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( + 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) - } - } + }, + 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, - onDisplayNameChange = { displayName = it }, - bio = bio, - onBioChange = { bio = it }, - password = password, - registrationProof = registrationProof, - captchaToken = captchaToken, - onRegisterSuccess = wrappedAuthSuccess, - onUsernameTaken = resetToUsername, - onSnackbar = ::snackbar, - ), - ), - snackbarHostState = snackbarHostState, - onBackAtFirstPage = wrappedBackToWelcome, - ) + profileStepPage( + username = username, + displayName = displayName, + onDisplayNameChange = { displayName = it }, + bio = bio, + onBioChange = { bio = it }, + password = password, + registrationProof = registrationProof, + captchaToken = captchaToken, + onRegisterSuccess = wrappedAuthSuccess, + onUsernameTaken = resetToUsername, + onSnackbar = ::snackbar, + ), + ), + snackbarHostState = snackbarHostState, + onBackAtFirstPage = wrappedBackToWelcome, + ) + } + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt index b00ba5a..3a6614a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt @@ -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, ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt index 8f1c37c..74903c9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt @@ -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), ) {} } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt index 8430d81..492710f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt @@ -60,7 +60,7 @@ fun AboutScreen() { val uriHandler = LocalUriHandler.current Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + containerColor = MaterialTheme.colorScheme.background, topBar = { SettingsDetailTopBar( title = { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt index 9ac4501..ced6611 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt @@ -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)) }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt index 83e49f7..d2c1f4b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt @@ -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( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt index c334ce0..eda909e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt @@ -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 list–detail shell. + val paneColor = MaterialTheme.colorScheme.background if (settingsDetailUseCollapsingTopBar()) { MediumTopAppBar( title = title, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt index 31053a1..765d0c8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt @@ -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)) }, diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.jvm.kt index 7cc7df8..9a637c6 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.jvm.kt @@ -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(null) } + val engineRef = remember { AtomicReference(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) - } -} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.jvm.kt index 3d67fae..738a50b 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.jvm.kt @@ -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(null) } val engineRef = remember { AtomicReference(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) - } -} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/jvm/JavaFxWebViewLifecycle.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/jvm/JavaFxWebViewLifecycle.kt new file mode 100644 index 0000000..11e3159 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/jvm/JavaFxWebViewLifecycle.kt @@ -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, + engineRef: AtomicReference, +) { + 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}") + } + } +}