improvement(Desktop): Persist window size & placement between restarts
This commit is contained in:
parent
57134bd577
commit
a7d3c8cece
|
@ -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"),
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue