improvement(Desktop): Persist window size & placement between restarts

This commit is contained in:
Artem Chepurnoy 2024-10-04 11:24:28 +03:00
parent 57134bd577
commit a7d3c8cece
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
3 changed files with 202 additions and 2 deletions

View File

@ -7,6 +7,7 @@ enum class Files(
FINGERPRINT("fingerprint"), FINGERPRINT("fingerprint"),
DEVICE_ID("device_id"), DEVICE_ID("device_id"),
UI_STATE("ui_state"), UI_STATE("ui_state"),
WINDOW_STATE("window_state"),
SESSION_METADATA("session_metadata"), SESSION_METADATA("session_metadata"),
SETTINGS("settings"), SETTINGS("settings"),
BREACHES("breaches"), BREACHES("breaches"),

View File

@ -17,7 +17,6 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.isTraySupported import androidx.compose.ui.window.isTraySupported
import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import com.artemchep.keyguard.common.AppWorker import com.artemchep.keyguard.common.AppWorker
import com.artemchep.keyguard.common.io.attempt import com.artemchep.keyguard.common.io.attempt
import com.artemchep.keyguard.common.io.bind 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.usecase.ShowMessage
import com.artemchep.keyguard.common.worker.Wrker import com.artemchep.keyguard.common.worker.Wrker
import com.artemchep.keyguard.core.session.diFingerprintRepositoryModule 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.navigateToBrowser
import com.artemchep.keyguard.desktop.util.navigateToEmail import com.artemchep.keyguard.desktop.util.navigateToEmail
import com.artemchep.keyguard.desktop.util.navigateToFile import com.artemchep.keyguard.desktop.util.navigateToFile
@ -108,6 +108,9 @@ fun main() {
bindSingleton { bindSingleton {
kamelConfig kamelConfig
} }
bindSingleton {
WindowStateManager(this)
}
} }
val processLifecycleProvider = LePlatformLifecycleProvider( val processLifecycleProvider = LePlatformLifecycleProvider(
@ -208,6 +211,7 @@ fun main() {
val getCloseToTray: GetCloseToTray = appDi.direct.instance() val getCloseToTray: GetCloseToTray = appDi.direct.instance()
val windowStateManager by appDi.di.instance<WindowStateManager>()
application(exitProcessOnExit = true) { application(exitProcessOnExit = true) {
withDI(appDi) { withDI(appDi) {
val isWindowOpenState = remember { val isWindowOpenState = remember {
@ -258,6 +262,7 @@ fun main() {
if (isWindowOpenState.value) { if (isWindowOpenState.value) {
KeyguardWindow( KeyguardWindow(
processLifecycleProvider = processLifecycleProvider, processLifecycleProvider = processLifecycleProvider,
windowStateManager = windowStateManager,
onCloseRequest = { onCloseRequest = {
val shouldCloseToTray = getCloseToTrayState.value val shouldCloseToTray = getCloseToTrayState.value
if (shouldCloseToTray) { if (shouldCloseToTray) {
@ -275,9 +280,10 @@ fun main() {
@Composable @Composable
private fun ApplicationScope.KeyguardWindow( private fun ApplicationScope.KeyguardWindow(
processLifecycleProvider: LePlatformLifecycleProvider, processLifecycleProvider: LePlatformLifecycleProvider,
windowStateManager: WindowStateManager,
onCloseRequest: () -> Unit, onCloseRequest: () -> Unit,
) { ) {
val windowState = rememberWindowState() val windowState = windowStateManager.rememberWindowState()
Window( Window(
onCloseRequest = onCloseRequest, onCloseRequest = onCloseRequest,
icon = painterResource(Res.drawable.ic_keyguard), icon = painterResource(Res.drawable.ic_keyguard),

View File

@ -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<JsonObject>(text)
val map = obj.toMap()
AnyMap(map)
}.getOrNull()
},
)
}
private var windowStateLatest: SaveableWindowState? = null
constructor(directDI: DirectDI) : this(
store = directDI.instance<Files, KeyValueStore>(
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<String, Any?>): 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<String, Any?> {
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()
}
}
}