Возможность переподключения к remote vault

This commit is contained in:
2026-05-17 12:11:53 +03:00
parent f8d4407eb0
commit 15f13577c8
16 changed files with 259 additions and 56 deletions

View File

@@ -6,6 +6,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
@@ -29,7 +30,13 @@ class ManageTextSecretsUseCase {
val storage = storageInfo as? IStorage ?: return flowOf(emptyList())
return merge(
flowOf(Unit),
storage.accessor.filesUpdates.map { Unit },
storage.accessor.filesUpdates
.filter { page ->
page.data.any { file ->
domainFilePathEquals(file.metaInfo.path, StorageDomainDataFiles.TEXT_SECRETS_FILE)
}
}
.map { Unit },
).map {
mutex.withLock { readAll(storage) }
}.distinctUntilChanged()
@@ -40,11 +47,16 @@ class ManageTextSecretsUseCase {
readAll(storage).firstOrNull { it.id == id }
}
suspend fun create(storageInfo: IStorageInfo, title: String, items: List<TextSecretEntryRecord>): TextSecretRecord =
suspend fun create(
storageInfo: IStorageInfo,
title: String,
items: List<TextSecretEntryRecord>,
id: String = UUID.randomUUID().toString(),
): TextSecretRecord =
mutex.withLock {
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
val next = TextSecretRecord(
id = UUID.randomUUID().toString(),
id = id,
title = title.trim(),
items = items.normalizeItems(),
)
@@ -142,4 +154,7 @@ class ManageTextSecretsUseCase {
)
}
}
private fun domainFilePathEquals(left: String, right: String): Boolean =
left.trimStart('/') == right.trimStart('/')
}

View File

@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
@@ -25,7 +26,13 @@ class ManageTwoFaTokensUseCase {
val storage = storageInfo as? IStorage ?: return flowOf(emptyList())
return merge(
flowOf(Unit),
storage.accessor.filesUpdates.map { Unit },
storage.accessor.filesUpdates
.filter { page ->
page.data.any { file ->
domainFilePathEquals(file.metaInfo.path, StorageDomainDataFiles.TWO_FA_TOKENS_FILE)
}
}
.map { Unit },
).map {
mutex.withLock { readAll(storage) }
}.distinctUntilChanged()
@@ -117,4 +124,7 @@ class ManageTwoFaTokensUseCase {
put("secret", JsonPrimitive(record.secret))
record.notes?.let { put("notes", JsonPrimitive(it)) }
}
private fun domainFilePathEquals(left: String, right: String): Boolean =
left.trimStart('/') == right.trimStart('/')
}

View File

@@ -4,6 +4,6 @@ package com.github.nullptroma.wallenc.usecases
* Единая точка с путями обычных JSON-файлов пользовательских доменных данных.
*/
object StorageDomainDataFiles {
const val TWO_FA_TOKENS_FILE = "/two-fa-tokens.json"
const val TEXT_SECRETS_FILE = "/text-secrets.json"
const val TWO_FA_TOKENS_FILE = "/wallenc-data/two-fa-tokens.json"
const val TEXT_SECRETS_FILE = "/wallenc-data/text-secrets.json"
}

View File

@@ -73,7 +73,7 @@ class StorageSyncEngine(
return
}
val leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
var leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
val lockedAccessors = mutableListOf<IStorageAccessor>()
try {
reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks")
@@ -92,6 +92,12 @@ class StorageSyncEngine(
reportProgress(null, "Storage sync: group \"$groupId\" reading journals")
for ((journalIndex, storage) in storages.withIndex()) {
leaseUntil = renewLocksIfNeeded(
groupId = groupId,
lockedAccessors = lockedAccessors,
currentLeaseUntil = leaseUntil,
reportProgress = reportProgress,
) ?: return
if (syncGeneration.get() != generationSnapshot) {
reportProgress(null, "Storage sync: group \"$groupId\" cancelled by newer run")
return
@@ -115,6 +121,12 @@ class StorageSyncEngine(
reportProgress(null, "Storage sync: group \"$groupId\" processing ${mergedEntries.size} entries")
for ((pathIndex, merged) in mergedEntries.withIndex()) {
leaseUntil = renewLocksIfNeeded(
groupId = groupId,
lockedAccessors = lockedAccessors,
currentLeaseUntil = leaseUntil,
reportProgress = reportProgress,
) ?: return
val path = merged.key
val winnerEntry = merged.value
reportProgress(null, "Storage sync: group \"$groupId\" entry ${pathIndex + 1}/${mergedEntries.size}")
@@ -156,6 +168,30 @@ class StorageSyncEngine(
}
}
private suspend fun renewLocksIfNeeded(
groupId: String,
lockedAccessors: List<IStorageAccessor>,
currentLeaseUntil: Instant,
reportProgress: suspend (fraction: Float?, label: String?) -> Unit,
): Instant? {
val now = Instant.now()
if (currentLeaseUntil.isAfter(now.plusSeconds(SYNC_LOCK_RENEW_MARGIN_SECONDS))) {
return currentLeaseUntil
}
val nextLeaseUntil = now.plusSeconds(SYNC_LOCK_LEASE_SECONDS)
reportProgress(null, "Storage sync: group \"$groupId\" renewing locks")
for (accessor in lockedAccessors) {
val renewed = runCatching {
accessor.tryAcquireSyncLock(holderId, nextLeaseUntil)
}.getOrElse { false }
if (!renewed) {
reportProgress(null, "Storage sync: group \"$groupId\" lock renewal failed")
return null
}
}
return nextLeaseUntil
}
private fun resolveStorages(uuids: Set<UUID>): List<IStorage> {
val byUuid = vaultsManager.allStorages.value.associateBy { it.uuid }
return uuids.mapNotNull { byUuid[it] }
@@ -226,5 +262,6 @@ class StorageSyncEngine(
private companion object {
private const val SYNC_LOCK_LEASE_SECONDS: Long = 30 * 60
private const val SYNC_LOCK_RENEW_MARGIN_SECONDS: Long = 60
}
}

View File

@@ -9,6 +9,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.flow.Flow
@@ -184,7 +185,13 @@ private class FakeStorageAccessor : IStorageAccessor {
return object : ByteArrayOutputStream() {
override fun close() {
dataFiles[path] = toByteArray()
_filesUpdates.tryEmit(DataPage(list = emptyList(), pageLength = 1, pageIndex = 0))
_filesUpdates.tryEmit(
DataPage(
listOf(FakeFile(path)),
pageLength = 1,
pageIndex = 0,
),
)
}
}
}
@@ -223,3 +230,13 @@ private class FakeStorageAccessor : IStorageAccessor {
override suspend fun forceClearSyncLock() = Unit
}
private class FakeFile(path: String) : IFile {
override val metaInfo: IMetaInfo = object : IMetaInfo {
override val size: Long = 0L
override val isDeleted: Boolean = false
override val isHidden: Boolean = false
override val lastModified: Instant = Instant.now()
override val path: String = path
}
}