From a7d3c8cece961f8d195acebbc1aa5640afe27540 Mon Sep 17 00:00:00 2001 From: Artem Chepurnoy Date: Fri, 4 Oct 2024 11:24:28 +0300 Subject: [PATCH] improvement(Desktop): Persist window size & placement between restarts --- .../keyguard/common/service/Files.kt | 1 + .../kotlin/com/artemchep/keyguard/Main.kt | 10 +- .../keyguard/desktop/WindowStateManager.kt | 193 ++++++++++++++++++ 3 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/desktop/WindowStateManager.kt diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/Files.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/Files.kt index 1f89c634..0cc633f4 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/Files.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/Files.kt @@ -7,6 +7,7 @@ enum class Files( FINGERPRINT("fingerprint"), DEVICE_ID("device_id"), UI_STATE("ui_state"), + WINDOW_STATE("window_state"), SESSION_METADATA("session_metadata"), SETTINGS("settings"), BREACHES("breaches"), diff --git a/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/Main.kt b/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/Main.kt index 06f20f98..8e005cfe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/Main.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.isTraySupported import androidx.compose.ui.window.rememberTrayState -import androidx.compose.ui.window.rememberWindowState import com.artemchep.keyguard.common.AppWorker import com.artemchep.keyguard.common.io.attempt import com.artemchep.keyguard.common.io.bind @@ -35,6 +34,7 @@ import com.artemchep.keyguard.common.usecase.PutVaultSession import com.artemchep.keyguard.common.usecase.ShowMessage import com.artemchep.keyguard.common.worker.Wrker import com.artemchep.keyguard.core.session.diFingerprintRepositoryModule +import com.artemchep.keyguard.desktop.WindowStateManager import com.artemchep.keyguard.desktop.util.navigateToBrowser import com.artemchep.keyguard.desktop.util.navigateToEmail import com.artemchep.keyguard.desktop.util.navigateToFile @@ -108,6 +108,9 @@ fun main() { bindSingleton { kamelConfig } + bindSingleton { + WindowStateManager(this) + } } val processLifecycleProvider = LePlatformLifecycleProvider( @@ -208,6 +211,7 @@ fun main() { val getCloseToTray: GetCloseToTray = appDi.direct.instance() + val windowStateManager by appDi.di.instance() application(exitProcessOnExit = true) { withDI(appDi) { val isWindowOpenState = remember { @@ -258,6 +262,7 @@ fun main() { if (isWindowOpenState.value) { KeyguardWindow( processLifecycleProvider = processLifecycleProvider, + windowStateManager = windowStateManager, onCloseRequest = { val shouldCloseToTray = getCloseToTrayState.value if (shouldCloseToTray) { @@ -275,9 +280,10 @@ fun main() { @Composable private fun ApplicationScope.KeyguardWindow( processLifecycleProvider: LePlatformLifecycleProvider, + windowStateManager: WindowStateManager, onCloseRequest: () -> Unit, ) { - val windowState = rememberWindowState() + val windowState = windowStateManager.rememberWindowState() Window( onCloseRequest = onCloseRequest, icon = painterResource(Res.drawable.ic_keyguard), diff --git a/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/desktop/WindowStateManager.kt b/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/desktop/WindowStateManager.kt new file mode 100644 index 00000000..49950a69 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/desktop/WindowStateManager.kt @@ -0,0 +1,193 @@ +package com.artemchep.keyguard.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.rememberWindowState +import com.artemchep.keyguard.common.io.attempt +import com.artemchep.keyguard.common.io.bind +import com.artemchep.keyguard.common.model.AnyMap +import com.artemchep.keyguard.common.service.Files +import com.artemchep.keyguard.common.service.keyvalue.KeyValueStore +import com.artemchep.keyguard.common.service.keyvalue.getObject +import com.artemchep.keyguard.common.service.state.impl.toJson +import com.artemchep.keyguard.common.service.state.impl.toMap +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.kodein.di.DirectDI +import org.kodein.di.instance + +class WindowStateManager( + private val store: KeyValueStore, + private val json: Json, +) { + companion object { + private const val KEY_WINDOW_STATE = "main" + + /** + * The debounce period between saving the + * window state. + */ + private const val SAVE_DEBOUNCE_MS = 100L + } + + private val windowStatePref by lazy { + store.getObject( + key = KEY_WINDOW_STATE, + defaultValue = null, + serialize = { entity -> + val obj = entity?.value.toJson() + obj.let(json::encodeToString) + }, + deserialize = { text -> + kotlin.runCatching { + val obj = json.decodeFromString(text) + val map = obj.toMap() + AnyMap(map) + }.getOrNull() + }, + ) + } + + private var windowStateLatest: SaveableWindowState? = null + + constructor(directDI: DirectDI) : this( + store = directDI.instance( + arg = Files.WINDOW_STATE, + ), + json = directDI.instance(), + ) + + private data class SaveableWindowState( + val placement: WindowPlacement, + val size: DpSize, + ) { + companion object { + private const val KEY_ARG_WIDTH = "width" + private const val KEY_ARG_HEIGHT = "height" + + private const val KEY_ARG_PLACEMENT = "placement" + private const val KEY_ARG_PLACEMENT_FLOATING = "floating" + private const val KEY_ARG_PLACEMENT_MAXIMIZED = "maximized" + private const val KEY_ARG_PLACEMENT_FULLSCREEN = "fullscreen" + + private val defaultSize get() = DpSize(800.dp, 600.dp) + + private val defaultPlacement get() = WindowPlacement.Floating + + fun of(state: Map): SaveableWindowState { + val placement = when (state[KEY_ARG_PLACEMENT]) { + KEY_ARG_PLACEMENT_FLOATING -> WindowPlacement.Floating + KEY_ARG_PLACEMENT_MAXIMIZED -> WindowPlacement.Maximized + KEY_ARG_PLACEMENT_FULLSCREEN -> WindowPlacement.Fullscreen + else -> defaultPlacement + } + val size = kotlin.run { + val width = state[KEY_ARG_WIDTH] as? Number + ?: return@run null + val height = state[KEY_ARG_HEIGHT] as? Number + ?: return@run null + DpSize( + width = width.toDouble().dp, + height = height.toDouble().dp, + ) + } ?: defaultSize + return SaveableWindowState( + placement = placement, + size = size, + ) + } + + fun of(state: WindowState): SaveableWindowState { + return SaveableWindowState( + placement = state.placement, + size = state.size, + ) + } + } + + fun toMap(): Map { + val placement = when (placement) { + WindowPlacement.Floating -> KEY_ARG_PLACEMENT_FLOATING + WindowPlacement.Maximized -> KEY_ARG_PLACEMENT_MAXIMIZED + WindowPlacement.Fullscreen -> KEY_ARG_PLACEMENT_FULLSCREEN + } + val width = size.width.value.toDouble() + val height = size.height.value.toDouble() + return mapOf( + KEY_ARG_PLACEMENT to placement, + KEY_ARG_WIDTH to width, + KEY_ARG_HEIGHT to height, + ) + } + } + + private suspend fun get(): SaveableWindowState { + val state = kotlin.runCatching { + windowStatePref + .first() + ?.value + }.getOrNull().orEmpty() + return SaveableWindowState.of(state) + } + + @Composable + fun rememberWindowState(): WindowState { + // Load the previous window configuration from the + // disk. This is bad, but I do not see any other + // solution on how to do it without blocking the + // user. + val restoredState = remember { + windowStateLatest + ?: runBlocking { get() } + } + val state = rememberWindowState( + placement = restoredState.placement, + size = restoredState.size, + ) + + LaunchSaveEffect(state) + return state + } + + @OptIn(FlowPreview::class) + @Composable + private fun LaunchSaveEffect(windowState: WindowState) { + val stateFlow = remember(windowState) { + val stateFlow = snapshotFlow { + SaveableWindowState.of(windowState) + } + stateFlow + } + + LaunchedEffect(stateFlow) { + stateFlow + .onEach { state -> + windowStateLatest = state + } + .debounce(SAVE_DEBOUNCE_MS) + .onEach { state -> + val model = state.toMap() + val anyMap = AnyMap( + value = model, + ) + windowStatePref.setAndCommit(anyMap) + .attempt() + .bind() + } + .collect() + } + } +}