Implement database lock handling, fix right pane invisible in settings

This commit is contained in:
2026-09-12 23:58:18 +03:00
Unverified
parent 8033d53aa2
commit f9eb9cc438
20 changed files with 891 additions and 40 deletions
+14
View File
@@ -392,6 +392,19 @@ tasks.named<Copy>("processResources") {
* (`bundleProxyForCurrentProcess is nil`). :run is therefore launched from a
* FromChat.app wrapper so Notification Center sees `ru.fromchat.desktop`.
*/
private fun Project.fromChatGradleDevDataDir(): String {
val variant = if (
gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
) {
"release"
} else {
"beta"
}
return rootProject.layout.buildDirectory.get().asFile
.resolve("dev-data/$variant")
.absolutePath
}
fun JavaExec.configureFromChatDesktopJvm() {
val javafxJars = classpath.files.filter { file ->
javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
@@ -416,6 +429,7 @@ fun JavaExec.configureFromChatDesktopJvm() {
}
addAll(
listOf(
"-Dfromchat.dev.data.dir=${project.fromChatGradleDevDataDir()}",
"-Dapple.awt.enableTemplateImages=true",
"--module-path",
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
@@ -0,0 +1,56 @@
package ru.fromchat.desktop
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import ru.fromchat.Logger
import ru.fromchat.api.local.db.store.MessageDatabasePaths
import kotlin.time.Duration.Companion.milliseconds
object DatabaseLockGate {
private val _blocked = MutableStateFlow(false)
val blocked: StateFlow<Boolean> = _blocked.asStateFlow()
private val _processes = MutableStateFlow<List<LockingProcessInfo>>(emptyList())
val processes: StateFlow<List<LockingProcessInfo>> = _processes.asStateFlow()
private var monitorJob: Job? = null
fun startMonitoring(scope: CoroutineScope, onUnlocked: () -> Unit) {
monitorJob?.cancel()
monitorJob = scope.launch {
var everBlocked = false
var bootstrapDelivered = false
while (isActive) {
val locked = MessageDatabasePaths.isDatabaseLocked()
if (locked) {
everBlocked = true
_blocked.value = true
_processes.value = DatabaseLockingProcessResolver.findLockingProcesses(
MessageDatabasePaths.lockTargetFiles(),
)
} else {
if (_blocked.value) {
Logger.i("DatabaseLockGate", "database unlocked; resuming startup")
}
_blocked.value = false
_processes.value = emptyList()
if (!bootstrapDelivered) {
bootstrapDelivered = true
onUnlocked()
}
if (!everBlocked) return@launch
if (!_blocked.value) return@launch
}
delay(750.milliseconds)
}
}
}
fun killProcess(pid: Long): Boolean = DatabaseLockingProcessResolver.killProcess(pid)
}
@@ -0,0 +1,11 @@
package ru.fromchat.desktop
import androidx.compose.ui.graphics.ImageBitmap
data class LockingProcessInfo(
val pid: Long,
val name: String,
val description: String?,
val executablePath: String?,
val icon: ImageBitmap?,
)
@@ -0,0 +1,210 @@
package ru.fromchat.desktop
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.rememberWindowState
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.desktop_database_locked_kill
import ru.fromchat.desktop_database_locked_pid
import ru.fromchat.desktop_database_locked_message
import ru.fromchat.desktop_database_locked_title
import ru.fromchat.desktop_database_locked_unknown_process
import ru.fromchat.desktop_database_locked_waiting
import ru.fromchat.desktop_quit
import ru.fromchat.ui.FromChatTheme
@Composable
fun DatabaseLockWindow(
processes: List<LockingProcessInfo>,
onKillProcess: (Long) -> Unit,
onQuit: () -> Unit,
) {
val title = stringResource(Res.string.desktop_database_locked_title)
val windowState = rememberWindowState(width = 520.dp, height = 420.dp)
Window(
onCloseRequest = onQuit,
title = title,
state = windowState,
resizable = false,
alwaysOnTop = true,
) {
FromChatTheme(darkTheme = desktopAppDarkTheme()) {
Surface(color = MaterialTheme.colorScheme.background) {
DatabaseLockContent(
processes = processes,
onKillProcess = onKillProcess,
onQuit = onQuit,
)
}
}
}
}
@Composable
private fun DatabaseLockContent(
processes: List<LockingProcessInfo>,
onKillProcess: (Long) -> Unit,
onQuit: () -> Unit,
) {
val unknownProcess = stringResource(Res.string.desktop_database_locked_unknown_process)
Column(
modifier = Modifier
.widthIn(min = 480.dp, max = 520.dp)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp),
)
Text(
text = stringResource(Res.string.desktop_database_locked_title),
style = MaterialTheme.typography.headlineSmall,
)
}
Text(
text = stringResource(Res.string.desktop_database_locked_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (processes.isEmpty()) {
LockingProcessRow(
process = LockingProcessInfo(
pid = -1L,
name = unknownProcess,
description = null,
executablePath = null,
icon = null,
),
killEnabled = false,
onKill = {},
)
} else {
processes.forEach { process ->
LockingProcessRow(
process = process,
killEnabled = process.pid > 0L,
onKill = { onKillProcess(process.pid) },
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Text(
text = stringResource(Res.string.desktop_database_locked_waiting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(onClick = onQuit) {
Text(stringResource(Res.string.desktop_quit))
}
}
}
}
@Composable
private fun LockingProcessRow(
process: LockingProcessInfo,
killEnabled: Boolean,
onKill: () -> Unit,
) {
Surface(
tonalElevation = 1.dp,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (process.icon != null) {
Image(
bitmap = process.icon,
contentDescription = null,
modifier = Modifier.size(40.dp),
)
} else {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = process.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
process.description?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (process.pid > 0L) {
Text(
text = stringResource(Res.string.desktop_database_locked_pid, process.pid.toInt()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (killEnabled) {
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onKill) {
Text(stringResource(Res.string.desktop_database_locked_kill))
}
}
}
}
}
@@ -0,0 +1,79 @@
package ru.fromchat.desktop
import java.io.File
import java.lang.ProcessHandle
import java.util.concurrent.TimeUnit
object DatabaseLockingProcessResolver {
fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> {
val discovered = buildList {
if (isWindowsOs()) {
addAll(WindowsRestartManager.findLockingProcesses(files))
}
if (isMacOs()) {
addAll(findWithLsof(files))
}
if (isLinuxOs()) {
addAll(findWithFuser(files))
}
addAll(ProcessExecutableResolver.findFromChatProcesses())
}
return discovered
.distinctBy { it.pid }
.sortedBy { it.name.lowercase() }
}
fun killProcess(pid: Long): Boolean = ProcessExecutableResolver.killProcess(pid)
private fun findWithLsof(files: List<File>): List<LockingProcessInfo> {
val pids = linkedSetOf<Long>()
files.forEach { file ->
val process = runCatching {
ProcessBuilder("lsof", "-t", file.absolutePath)
.redirectErrorStream(true)
.start()
}.getOrNull() ?: return@forEach
process.waitFor(3, TimeUnit.SECONDS)
process.inputStream.bufferedReader().readLines()
.mapNotNull { it.trim().toLongOrNull() }
.forEach { pids += it }
}
return pids.mapNotNull { pidToLockingProcess(it) }
}
private fun findWithFuser(files: List<File>): List<LockingProcessInfo> {
val pids = linkedSetOf<Long>()
files.forEach { file ->
val process = runCatching {
ProcessBuilder("fuser", file.absolutePath)
.redirectErrorStream(true)
.start()
}.getOrNull() ?: return@forEach
process.waitFor(3, TimeUnit.SECONDS)
val output = process.inputStream.bufferedReader().readText()
output.split(Regex("\\s+"))
.mapNotNull { token -> token.trim().toLongOrNull() }
.forEach { pids += it }
}
return pids.mapNotNull { pidToLockingProcess(it) }
}
private fun pidToLockingProcess(pid: Long): LockingProcessInfo? {
if (pid == ProcessHandle.current().pid()) return null
val handle = ProcessHandle.of(pid)
if (handle.isEmpty) return null
val info = handle.get().info()
val command = info.command().orElse("")
val executable = ProcessExecutableResolver.executablePathForPid(pid)
val name = executable?.let { File(it).name }
?: command.substringBefore(' ').takeIf { it.isNotBlank() }
?: "PID $pid"
return LockingProcessInfo(
pid = pid,
name = name,
description = command.ifBlank { null },
executablePath = executable,
icon = ProcessExecutableResolver.iconForExecutable(executable),
)
}
}
@@ -10,6 +10,9 @@ internal fun isMacOs(): Boolean =
internal fun isWindowsOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("win")
internal fun isLinuxOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
internal fun isWindowsArm64(): Boolean {
if (!isWindowsOs()) return false
val arch = System.getProperty("os.arch").orEmpty().lowercase()
@@ -56,6 +56,7 @@ import ru.fromchat.action_copy
import ru.fromchat.action_select
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.isSqliteBusy
import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
@@ -260,6 +261,7 @@ private object DesktopProtocolRegistration {
}
fun main(args: Array<String>) {
configureDesktopAppDataName()
// 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
@@ -291,9 +293,6 @@ fun main(args: Array<String>) {
} else {
System.setProperty("skiko.renderApi", "OPENGL")
}
if (AppBuildInfo.isDebug && System.getProperty("fromchat.app.data.name").isNullOrBlank()) {
System.setProperty("fromchat.app.data.name", "FromChatBeta")
}
setWindowsDesktopAppUserModelId(
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
)
@@ -313,9 +312,36 @@ fun main(args: Array<String>) {
application {
UtilsLibrary.init()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Logger.e("Desktop", "uncaught on ${thread.name}", throwable)
if (isSqliteBusy(throwable)) {
Logger.w(
"Desktop",
"SQLite busy on ${thread.name}; database will retry on the next access",
throwable,
)
} else {
Logger.e("Desktop", "uncaught on ${thread.name}", throwable)
}
}
var bootstrapStarted by remember { mutableStateOf(false) }
val databaseBlocked by DatabaseLockGate.blocked.collectAsState()
val lockingProcesses by DatabaseLockGate.processes.collectAsState()
LaunchedEffect(Unit) {
DatabaseLockGate.startMonitoring(this) {
if (!bootstrapStarted) {
bootstrapStarted = true
DesktopApplicationBootstrap.launchOnApplicationStart()
}
}
}
if (databaseBlocked) {
DatabaseLockWindow(
processes = lockingProcesses,
onKillProcess = { DatabaseLockGate.killProcess(it) },
onQuit = { exitApplication() },
)
}
DesktopApplicationBootstrap.launchOnApplicationStart()
val windowState = rememberWindowState(
size = remember { DesktopWindowPrefs.loadSize() },
@@ -538,7 +564,7 @@ fun main(args: Array<String>) {
onCloseRequest = { requestCloseToBackgroundOrQuit() },
title = appName,
state = windowState,
visible = contentReady && (windowVisible || !traySupported),
visible = contentReady && (windowVisible || !traySupported) && !databaseBlocked,
icon = windowIcon,
undecorated = windows,
onPreviewKeyEvent = { event ->
@@ -688,7 +714,9 @@ fun main(args: Array<String>) {
.fillMaxSize()
.background(windowChrome),
) {
App(onContentReady = { contentReady = true })
if (!databaseBlocked) {
App(onContentReady = { contentReady = true })
}
if (windows) {
MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) {
WindowsDesktopTitleBar(
@@ -822,8 +850,13 @@ private fun performAwtEditAction(actionName: String) {
action.actionPerformed(ActionEvent(focusOwner, ActionEvent.ACTION_PERFORMED, actionName))
}
private fun isLinuxOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
/** Beta and release builds must not share the same on-disk data directory. */
private fun configureDesktopAppDataName() {
if (!System.getProperty("fromchat.app.data.name").isNullOrBlank()) return
if (AppBuildInfo.isDebug) {
System.setProperty("fromchat.app.data.name", "FromChatBeta")
}
}
/**
* JFrame uses UIManager "control" as its default background before Compose draws.
@@ -0,0 +1,100 @@
package ru.fromchat.desktop
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.lang.ProcessHandle
import javax.imageio.ImageIO
import javax.swing.ImageIcon
import javax.swing.filechooser.FileSystemView
import org.jetbrains.skia.Image as SkiaImage
internal object ProcessExecutableResolver {
fun executablePathForPid(pid: Long): String? {
if (pid <= 0L) return null
return runCatching {
ProcessHandle.of(pid).flatMap { handle ->
handle.info().command().map { command ->
extractExecutablePath(command)
}
}.orElse(null)
}.getOrNull()
}
fun commandLineForPid(pid: Long): String? {
if (pid <= 0L) return null
return runCatching {
ProcessHandle.of(pid).flatMap { it.info().command() }.orElse(null)
}.getOrNull()
}
fun iconForExecutable(path: String?): ImageBitmap? {
if (path.isNullOrBlank()) return null
val file = File(path)
if (!file.exists()) return null
val awtIcon = runCatching {
val icon = FileSystemView.getFileSystemView().getSystemIcon(file)
when (icon) {
is ImageIcon -> icon.image as? BufferedImage
else -> null
}
}.getOrNull() ?: return null
return bufferedImageToImageBitmap(awtIcon)
}
private fun bufferedImageToImageBitmap(image: BufferedImage): ImageBitmap {
val bytes = ByteArrayOutputStream().use { out ->
ImageIO.write(image, "png", out)
out.toByteArray()
}
return SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
}
fun findFromChatProcesses(): List<LockingProcessInfo> {
val currentPid = ProcessHandle.current().pid()
return ProcessHandle.allProcesses().toList().mapNotNull { handle ->
val pid = handle.pid()
if (pid == currentPid) return@mapNotNull null
val command = handle.info().command().orElse("")
if (!looksLikeFromChatProcess(command)) return@mapNotNull null
val executable = extractExecutablePath(command)
LockingProcessInfo(
pid = pid,
name = handle.info().command().map { File(it).name }.orElse("FromChat"),
description = command,
executablePath = executable,
icon = iconForExecutable(executable),
)
}
}
private fun looksLikeFromChatProcess(command: String): Boolean {
if (command.isBlank()) return false
val lower = command.lowercase()
return lower.contains("ru.fromchat.desktop.mainkt") ||
lower.contains("fromchat.desktop.main") ||
(lower.contains("fromchat") && (lower.contains("java") || lower.contains("javaw")))
}
private fun extractExecutablePath(command: String): String? {
val trimmed = command.trim()
if (trimmed.isEmpty()) return null
if (trimmed.first() == '"') {
val end = trimmed.indexOf('"', startIndex = 1)
if (end > 1) return trimmed.substring(1, end)
}
return trimmed.substringBefore(' ').takeIf { it.isNotBlank() }
}
fun killProcess(pid: Long): Boolean {
if (pid <= 0L) return false
val handle = ProcessHandle.of(pid)
if (handle.isEmpty) return false
return runCatching {
handle.get().destroyForcibly()
true
}.getOrDefault(false)
}
}
@@ -0,0 +1,133 @@
package ru.fromchat.desktop
import com.sun.jna.Native
import com.sun.jna.Structure
import com.sun.jna.WString
import com.sun.jna.platform.win32.WinBase
import com.sun.jna.platform.win32.WinDef.DWORD
import com.sun.jna.platform.win32.WinNT
import com.sun.jna.ptr.IntByReference
import com.sun.jna.win32.StdCallLibrary
import com.sun.jna.win32.W32APIOptions
import java.io.File
internal object WindowsRestartManager {
private const val ERROR_MORE_DATA = 234
private const val CCH_RM_MAX_APP_NAME = 255
private const val CCH_RM_MAX_SVC_NAME = 63
fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> {
if (!isWindowsOs()) return emptyList()
val paths = files.map { it.absolutePath }.filter { it.isNotBlank() }
if (paths.isEmpty()) return emptyList()
val session = IntByReference()
val sessionKey = CharArray(33)
val start = RestartManager.INSTANCE.RmStartSession(session, 0, sessionKey)
if (start != WinNT.ERROR_SUCCESS) return emptyList()
val sessionHandle = session.value
try {
val register = RestartManager.INSTANCE.RmRegisterResources(
sessionHandle,
paths.size,
paths.toTypedArray(),
0,
null,
0,
null,
)
if (register != WinNT.ERROR_SUCCESS) return emptyList()
val needed = IntByReference()
val count = IntByReference()
var result = RestartManager.INSTANCE.RmGetList(
sessionHandle,
needed,
count,
null,
null,
)
if (result != ERROR_MORE_DATA && result != WinNT.ERROR_SUCCESS) return emptyList()
val processCount = needed.value.coerceAtLeast(count.value).coerceAtLeast(1)
val processes = Array(processCount) { RmProcessInfo() }
count.value = processCount
result = RestartManager.INSTANCE.RmGetList(
sessionHandle,
needed,
count,
processes,
null,
)
if (result != WinNT.ERROR_SUCCESS) return emptyList()
return processes
.take(count.value.coerceAtMost(processes.size))
.mapNotNull { info ->
val pid = info.dwProcessId.toLong()
if (pid <= 0L) return@mapNotNull null
val appName = info.appName().trim()
val executable = ProcessExecutableResolver.executablePathForPid(pid)
LockingProcessInfo(
pid = pid,
name = appName.ifBlank { executable?.let { File(it).name } ?: "PID $pid" },
description = executable ?: ProcessExecutableResolver.commandLineForPid(pid),
executablePath = executable,
icon = ProcessExecutableResolver.iconForExecutable(executable),
)
}
.distinctBy { it.pid }
} finally {
RestartManager.INSTANCE.RmEndSession(sessionHandle)
}
}
@Structure.FieldOrder(
"dwProcessId",
"processStartTime",
"strAppName",
"strServiceShortName",
"applicationType",
"appStatus",
"tsSessionId",
"restartable",
)
private class RmProcessInfo : Structure(), Structure.ByReference {
@JvmField var dwProcessId = 0
@JvmField var processStartTime = WinBase.FILETIME()
@JvmField var strAppName = CharArray(CCH_RM_MAX_APP_NAME + 1)
@JvmField var strServiceShortName = CharArray(CCH_RM_MAX_SVC_NAME + 1)
@JvmField var applicationType = DWORD(0)
@JvmField var appStatus = DWORD(0)
@JvmField var tsSessionId = DWORD(0)
@JvmField var restartable = 0
fun appName(): String = Native.toString(strAppName)
}
private interface RestartManager : StdCallLibrary {
fun RmStartSession(sessionHandle: IntByReference, sessionFlags: Int, sessionKey: CharArray): Int
fun RmEndSession(sessionHandle: Int): Int
fun RmRegisterResources(
sessionHandle: Int,
fileCount: Int,
fileNames: Array<String>?,
serviceCount: Int,
serviceNames: Array<WString>?,
processCount: Int,
processIds: IntArray?,
): Int
fun RmGetList(
sessionHandle: Int,
procInfoNeeded: IntByReference,
procInfoCount: IntByReference,
affectedApps: Array<RmProcessInfo>?,
rebootReasons: IntByReference?,
): Int
companion object {
val INSTANCE: RestartManager = Native.load("Rstrtmgr", RestartManager::class.java, W32APIOptions.DEFAULT_OPTIONS)
}
}
}
@@ -33,6 +33,12 @@
<string name="desktop_select_all">Выделить всё</string>
<string name="desktop_minimize">Свернуть</string>
<string name="desktop_zoom">Масштабировать</string>
<string name="desktop_database_locked_title">База данных заблокирована</string>
<string name="desktop_database_locked_message">FromChat не может получить доступ к базе данных — её использует другой процесс.</string>
<string name="desktop_database_locked_kill">Завершить процесс</string>
<string name="desktop_database_locked_waiting">Ожидание освобождения базы данных…</string>
<string name="desktop_database_locked_unknown_process">Неизвестный процесс</string>
<string name="desktop_database_locked_pid">PID %1$d</string>
<string name="welcome">Добро пожаловать!</string>
<string name="login">Войти</string>
<string name="login_d">Войдите в свой аккаунт</string>
@@ -38,6 +38,12 @@
<string name="desktop_select_all">Select All</string>
<string name="desktop_minimize">Minimize</string>
<string name="desktop_zoom">Zoom</string>
<string name="desktop_database_locked_title">Database locked</string>
<string name="desktop_database_locked_message">FromChat cannot access its database because another process is using it.</string>
<string name="desktop_database_locked_kill">End process</string>
<string name="desktop_database_locked_waiting">Waiting for the database to become available…</string>
<string name="desktop_database_locked_unknown_process">Unknown process</string>
<string name="desktop_database_locked_pid">PID %1$d</string>
<!-- Authentication -->
<string name="welcome">Welcome!</string>
@@ -0,0 +1,38 @@
package ru.fromchat.api.local.db
import ru.fromchat.Logger
fun isSqliteBusy(throwable: Throwable): Boolean {
var current: Throwable? = throwable
while (current != null) {
val message = current.message.orEmpty()
if (message.contains("SQLITE_BUSY", ignoreCase = true)) return true
if (message.contains("database is locked", ignoreCase = true)) return true
if (message.contains("database file is locked", ignoreCase = true)) return true
current = current.cause
}
return false
}
internal fun <T> withSqliteBusyRetry(
maxAttempts: Int = 8,
initialDelayMs: Long = 25L,
block: () -> T,
): T {
var attempt = 0
var delayMs = initialDelayMs
while (true) {
try {
return block()
} catch (e: Throwable) {
attempt++
if (!isSqliteBusy(e) || attempt >= maxAttempts) throw e
Logger.w(
"MessageDatabase",
"SQLite busy (attempt $attempt/$maxAttempts), retrying in ${delayMs}ms",
)
Thread.sleep(delayMs)
delayMs = minOf(delayMs * 2, 500L)
}
}
}
@@ -4,6 +4,7 @@ import app.cash.sqldelight.db.SqlDriver
import ru.fromchat.api.local.db.ensureMessageDatabaseSchema
import ru.fromchat.api.local.db.rebindUnboundMigrationInstance
import ru.fromchat.api.local.db.withMessageDatabaseLock
import ru.fromchat.api.local.db.withSqliteBusyRetry
import ru.fromchat.db.MessageDatabase
/**
@@ -33,15 +34,16 @@ object MessageDatabaseProvider {
}
/** Runs [block] once; on a stale connection after cache wipe, resets and retries. */
internal fun <T> withDatabaseRecover(block: () -> T): T {
return try {
block()
} catch (e: Exception) {
if (!isStaleSqliteConnection(e)) throw e
closeAndReset()
block()
internal fun <T> withDatabaseRecover(block: () -> T): T =
withSqliteBusyRetry {
try {
block()
} catch (e: Exception) {
if (!isStaleSqliteConnection(e)) throw e
closeAndReset()
block()
}
}
}
internal fun isStaleSqliteConnection(throwable: Throwable): Boolean {
var current: Throwable? = throwable
@@ -815,7 +815,15 @@ fun MessageItem(
}
Box(modifier = Modifier.fillMaxWidth()) {
Column {
Column(
verticalArrangement = if (
replyRef != null && primaryIsImageContent
) {
Arrangement.spacedBy(6.dp)
} else {
Arrangement.Top
},
) {
replyRef?.let { replyToMsg ->
MessageReplyQuote(
replyTo = replyToMsg,
@@ -827,6 +835,8 @@ fun MessageItem(
isContextMenuOpen = isContextMenuOpen,
onReplyClick = onReplyClick,
onReplyPressedChange = { replyPressed = it },
inImageBubble = primaryIsImageContent,
hasAttachmentBelow = primaryIsImageContent,
)
}
@@ -1256,6 +1266,8 @@ private fun MessageReplyQuote(
isContextMenuOpen: Boolean,
onReplyClick: ((Int) -> Unit)?,
onReplyPressedChange: (Boolean) -> Unit,
inImageBubble: Boolean = false,
hasAttachmentBelow: Boolean = false,
) {
val replyName = messageDisplayUsername(replyTo, currentUserId)
val replyTapEnabled = onReplyClick != null && replyTo.id > 0
@@ -1300,7 +1312,12 @@ private fun MessageReplyQuote(
Box(
Modifier
.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
.padding(
top = if (inImageBubble) 6.dp else 0.dp,
bottom = if (hasAttachmentBelow) 0.dp else 4.dp,
start = 6.dp,
end = 6.dp,
)
.graphicsLayer(
scaleX = replyScale,
scaleY = replyScale,
@@ -91,7 +91,18 @@ fun DesktopSettingsDetailNavHost(
onOpenChatFromProfile: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val detailRoute =
navController.currentBackStackEntryAsState().value?.destination?.route
val showPanel =
detailRoute != null && detailRoute != DesktopDetailNav.SETTINGS_ROOT
Box(modifier.fillMaxSize()) {
if (showPanel) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
)
}
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
@@ -116,17 +127,6 @@ fun DesktopSettingsDetailNavHost(
)
}
}
val detailRoute =
navController.currentBackStackEntryAsState().value?.destination?.route
val showPanel =
detailRoute != null && detailRoute != DesktopDetailNav.SETTINGS_ROOT
if (showPanel) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
)
}
}
}
@@ -1,6 +1,8 @@
package ru.fromchat.api.local.db
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T =
synchronized(MessageDatabaseLock) { block() }
withSqliteBusyRetry {
synchronized(MessageDatabaseLock) { block() }
}
private object MessageDatabaseLock
@@ -11,9 +11,18 @@ actual fun provideMessageDatabaseDriver(): SqlDriver {
dir.mkdirs()
val dbFile = File(dir, "message_database.db")
val needsCreate = !dbFile.exists()
val driver = JdbcSqliteDriver("jdbc:sqlite:${dbFile.absolutePath}")
val jdbc = JdbcSqliteDriver(
"jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=30000&journal_mode=WAL",
)
if (needsCreate) {
MessageDatabase.Schema.create(driver)
MessageDatabase.Schema.create(jdbc)
}
return driver
configureJvmSqliteDriver(jdbc)
return SynchronizedRetryingSqlDriver(jdbc)
}
private fun configureJvmSqliteDriver(driver: SqlDriver) {
driver.execute(null, "PRAGMA busy_timeout = 30000;", 0)
driver.execute(null, "PRAGMA journal_mode = WAL;", 0)
driver.execute(null, "PRAGMA synchronous = NORMAL;", 0)
}
@@ -0,0 +1,58 @@
package ru.fromchat.api.local.db.store
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import ru.fromchat.api.local.db.isSqliteBusy
import java.io.File
import java.io.RandomAccessFile
import java.nio.channels.OverlappingFileLockException
object MessageDatabasePaths {
fun databaseFile(): File {
val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat")
return File(dir, "message_database.db")
}
fun lockTargetFiles(): List<File> {
val db = databaseFile()
return listOf(
db,
File("${db.path}-wal"),
File("${db.path}-shm"),
File("${db.path}-journal"),
).filter { it.exists() }
}
fun isDatabaseLocked(): Boolean {
val targets = lockTargetFiles()
if (targets.isEmpty()) return false
if (targets.any { isFileLockedExclusively(it) }) return true
return isSqliteWriteLocked(databaseFile())
}
private fun isFileLockedExclusively(file: File): Boolean =
runCatching {
RandomAccessFile(file, "rw").use { raf ->
val channel = raf.channel
val lock = try {
channel.tryLock()
} catch (_: OverlappingFileLockException) {
return true
}
lock?.release()
lock == null
}
}.getOrDefault(true)
private fun isSqliteWriteLocked(dbFile: File): Boolean =
runCatching {
val probe = JdbcSqliteDriver("jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=1")
try {
probe.execute(null, "BEGIN IMMEDIATE;", 0)
probe.execute(null, "ROLLBACK;", 0)
false
} finally {
probe.close()
}
}.getOrElse { isSqliteBusy(it) }
}
@@ -0,0 +1,60 @@
package ru.fromchat.api.local.db.store
import app.cash.sqldelight.Query
import app.cash.sqldelight.Transacter
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlCursor
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlPreparedStatement
import ru.fromchat.api.local.db.withSqliteBusyRetry
/**
* Serializes JDBC SQLite access and retries transient [SQLITE_BUSY] errors.
* Desktop can hit the DB from multiple threads while the provider lock is not held per query.
*/
internal class SynchronizedRetryingSqlDriver(
private val delegate: SqlDriver,
) : SqlDriver {
private val accessLock = Any()
override fun close() = synchronized(accessLock) { delegate.close() }
override fun execute(
identifier: Int?,
sql: String,
parameters: Int,
binders: (SqlPreparedStatement.() -> Unit)?,
): QueryResult<Long> = synchronized(accessLock) {
withSqliteBusyRetry { delegate.execute(identifier, sql, parameters, binders) }
}
override fun <R> executeQuery(
identifier: Int?,
sql: String,
mapper: (SqlCursor) -> QueryResult<R>,
parameters: Int,
binders: (SqlPreparedStatement.() -> Unit)?,
): QueryResult<R> = synchronized(accessLock) {
withSqliteBusyRetry { delegate.executeQuery(identifier, sql, mapper, parameters, binders) }
}
override fun newTransaction(): QueryResult<Transacter.Transaction> = synchronized(accessLock) {
withSqliteBusyRetry { delegate.newTransaction() }
}
override fun currentTransaction(): Transacter.Transaction? = synchronized(accessLock) {
delegate.currentTransaction()
}
override fun addListener(vararg queryKeys: String, listener: Query.Listener) {
delegate.addListener(*queryKeys, listener = listener)
}
override fun removeListener(vararg queryKeys: String, listener: Query.Listener) {
delegate.removeListener(*queryKeys, listener = listener)
}
override fun notifyListeners(vararg queryKeys: String) {
delegate.notifyListeners(*queryKeys)
}
}
@@ -5,17 +5,22 @@ import java.io.File
/**
* Resolves the FromChat desktop data/cache root on the JVM.
*
* - Portable (`-Dfromchat.portable=true`): `<exeDir>/fromchat-data`
* - Windows installed: `%LOCALAPPDATA%\FromChat` (or `FromChat Beta` when `-Dfromchat.app.data.name` is set)
* - macOS: `~/Library/Application Support/FromChat`
* - Linux: `${XDG_DATA_HOME:-~/.local/share}/FromChat`
* - Custom data root (`-Dfromchat.dev.data.dir=…`): use only when set explicitly (e.g. IDE run config)
* - Portable (`-Dfromchat.portable=true`): `<exeDir>/fromchat-data` or `fromchat-data-beta`
* - Windows: `%LOCALAPPDATA%\FromChat` or `%LOCALAPPDATA%\FromChat Beta`
* - macOS: `~/Library/Application Support/FromChat` or `…/FromChat Beta`
* - Linux: `~/.local/share/FromChat` or `…/FromChat Beta`
*
* Variant is selected via `-Dfromchat.app.data.name` (`FromChatBeta` → `FromChat Beta`; default `FromChat`).
*
* Migrates once from the legacy `~/.fromchat/cache` directory when present.
*/
internal object AppDataRoot {
private const val LEGACY_RELATIVE = ".fromchat/cache"
private const val PORTABLE_DIR_NAME = "fromchat-data"
private const val PORTABLE_BETA_DIR_NAME = "fromchat-data-beta"
private const val APP_DIR_NAME = "FromChat"
private const val APP_BETA_DIR_NAME = "FromChat Beta"
@Volatile
private var cached: File? = null
@@ -32,8 +37,9 @@ internal object AppDataRoot {
}
private fun resolveUncached(): File {
devDataRoot()?.let { return it }
if (isPortable()) {
return File(executableDirectory(), PORTABLE_DIR_NAME)
return File(executableDirectory(), portableDirName())
}
val os = System.getProperty("os.name").orEmpty().lowercase()
return when {
@@ -43,6 +49,11 @@ internal object AppDataRoot {
}
}
private fun devDataRoot(): File? {
val explicit = System.getProperty("fromchat.dev.data.dir")?.trim()?.takeIf { it.isNotEmpty() }
return explicit?.let { File(it).absoluteFile }
}
private fun isPortable(): Boolean =
System.getProperty("fromchat.portable")
?.equals("true", ignoreCase = true) == true
@@ -66,11 +77,14 @@ internal object AppDataRoot {
private fun installedAppDirName(): String {
val raw = System.getProperty("fromchat.app.data.name")?.trim()?.takeIf { it.isNotEmpty() }
return when (raw) {
"FromChatBeta" -> "FromChat Beta"
"FromChatBeta", APP_BETA_DIR_NAME -> APP_BETA_DIR_NAME
else -> raw ?: APP_DIR_NAME
}
}
private fun portableDirName(): String =
if (installedAppDirName() == APP_BETA_DIR_NAME) PORTABLE_BETA_DIR_NAME else PORTABLE_DIR_NAME
private fun windowsInstalledRoot(): File {
val dirName = installedAppDirName()
val localAppData = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() }