mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement database lock handling, fix right pane invisible in settings
This commit is contained in:
@@ -392,6 +392,19 @@ tasks.named<Copy>("processResources") {
|
|||||||
* (`bundleProxyForCurrentProcess is nil`). :run is therefore launched from a
|
* (`bundleProxyForCurrentProcess is nil`). :run is therefore launched from a
|
||||||
* FromChat.app wrapper so Notification Center sees `ru.fromchat.desktop`.
|
* 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() {
|
fun JavaExec.configureFromChatDesktopJvm() {
|
||||||
val javafxJars = classpath.files.filter { file ->
|
val javafxJars = classpath.files.filter { file ->
|
||||||
javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
|
javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
|
||||||
@@ -416,6 +429,7 @@ fun JavaExec.configureFromChatDesktopJvm() {
|
|||||||
}
|
}
|
||||||
addAll(
|
addAll(
|
||||||
listOf(
|
listOf(
|
||||||
|
"-Dfromchat.dev.data.dir=${project.fromChatGradleDevDataDir()}",
|
||||||
"-Dapple.awt.enableTemplateImages=true",
|
"-Dapple.awt.enableTemplateImages=true",
|
||||||
"--module-path",
|
"--module-path",
|
||||||
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
|
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 =
|
internal fun isWindowsOs(): Boolean =
|
||||||
System.getProperty("os.name").orEmpty().lowercase().contains("win")
|
System.getProperty("os.name").orEmpty().lowercase().contains("win")
|
||||||
|
|
||||||
|
internal fun isLinuxOs(): Boolean =
|
||||||
|
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
|
||||||
|
|
||||||
internal fun isWindowsArm64(): Boolean {
|
internal fun isWindowsArm64(): Boolean {
|
||||||
if (!isWindowsOs()) return false
|
if (!isWindowsOs()) return false
|
||||||
val arch = System.getProperty("os.arch").orEmpty().lowercase()
|
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.action_select
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
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.ConnectionStateStore
|
||||||
import ru.fromchat.api.local.db.store.ConnectionStatus
|
import ru.fromchat.api.local.db.store.ConnectionStatus
|
||||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||||
@@ -260,6 +261,7 @@ private object DesktopProtocolRegistration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
configureDesktopAppDataName()
|
||||||
// Do NOT set compose.layers.type=WINDOW.
|
// Do NOT set compose.layers.type=WINDOW.
|
||||||
// On macOS Metal that mode creates a JWindow + MetalRedrawer per Popup/Dialog; native
|
// 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
|
// IOAccelerator surfaces are retained after dismiss and grew to multi-GB (Activity Monitor
|
||||||
@@ -291,9 +293,6 @@ fun main(args: Array<String>) {
|
|||||||
} else {
|
} else {
|
||||||
System.setProperty("skiko.renderApi", "OPENGL")
|
System.setProperty("skiko.renderApi", "OPENGL")
|
||||||
}
|
}
|
||||||
if (AppBuildInfo.isDebug && System.getProperty("fromchat.app.data.name").isNullOrBlank()) {
|
|
||||||
System.setProperty("fromchat.app.data.name", "FromChatBeta")
|
|
||||||
}
|
|
||||||
setWindowsDesktopAppUserModelId(
|
setWindowsDesktopAppUserModelId(
|
||||||
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
|
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
|
||||||
)
|
)
|
||||||
@@ -313,9 +312,36 @@ fun main(args: Array<String>) {
|
|||||||
application {
|
application {
|
||||||
UtilsLibrary.init()
|
UtilsLibrary.init()
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, 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)
|
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()
|
DesktopApplicationBootstrap.launchOnApplicationStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (databaseBlocked) {
|
||||||
|
DatabaseLockWindow(
|
||||||
|
processes = lockingProcesses,
|
||||||
|
onKillProcess = { DatabaseLockGate.killProcess(it) },
|
||||||
|
onQuit = { exitApplication() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val windowState = rememberWindowState(
|
val windowState = rememberWindowState(
|
||||||
size = remember { DesktopWindowPrefs.loadSize() },
|
size = remember { DesktopWindowPrefs.loadSize() },
|
||||||
@@ -538,7 +564,7 @@ fun main(args: Array<String>) {
|
|||||||
onCloseRequest = { requestCloseToBackgroundOrQuit() },
|
onCloseRequest = { requestCloseToBackgroundOrQuit() },
|
||||||
title = appName,
|
title = appName,
|
||||||
state = windowState,
|
state = windowState,
|
||||||
visible = contentReady && (windowVisible || !traySupported),
|
visible = contentReady && (windowVisible || !traySupported) && !databaseBlocked,
|
||||||
icon = windowIcon,
|
icon = windowIcon,
|
||||||
undecorated = windows,
|
undecorated = windows,
|
||||||
onPreviewKeyEvent = { event ->
|
onPreviewKeyEvent = { event ->
|
||||||
@@ -688,7 +714,9 @@ fun main(args: Array<String>) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(windowChrome),
|
.background(windowChrome),
|
||||||
) {
|
) {
|
||||||
|
if (!databaseBlocked) {
|
||||||
App(onContentReady = { contentReady = true })
|
App(onContentReady = { contentReady = true })
|
||||||
|
}
|
||||||
if (windows) {
|
if (windows) {
|
||||||
MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) {
|
MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) {
|
||||||
WindowsDesktopTitleBar(
|
WindowsDesktopTitleBar(
|
||||||
@@ -822,8 +850,13 @@ private fun performAwtEditAction(actionName: String) {
|
|||||||
action.actionPerformed(ActionEvent(focusOwner, ActionEvent.ACTION_PERFORMED, actionName))
|
action.actionPerformed(ActionEvent(focusOwner, ActionEvent.ACTION_PERFORMED, actionName))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isLinuxOs(): Boolean =
|
/** Beta and release builds must not share the same on-disk data directory. */
|
||||||
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
|
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.
|
* 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_select_all">Выделить всё</string>
|
||||||
<string name="desktop_minimize">Свернуть</string>
|
<string name="desktop_minimize">Свернуть</string>
|
||||||
<string name="desktop_zoom">Масштабировать</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="welcome">Добро пожаловать!</string>
|
||||||
<string name="login">Войти</string>
|
<string name="login">Войти</string>
|
||||||
<string name="login_d">Войдите в свой аккаунт</string>
|
<string name="login_d">Войдите в свой аккаунт</string>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
<string name="desktop_select_all">Select All</string>
|
<string name="desktop_select_all">Select All</string>
|
||||||
<string name="desktop_minimize">Minimize</string>
|
<string name="desktop_minimize">Minimize</string>
|
||||||
<string name="desktop_zoom">Zoom</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 -->
|
<!-- Authentication -->
|
||||||
<string name="welcome">Welcome!</string>
|
<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
-2
@@ -4,6 +4,7 @@ import app.cash.sqldelight.db.SqlDriver
|
|||||||
import ru.fromchat.api.local.db.ensureMessageDatabaseSchema
|
import ru.fromchat.api.local.db.ensureMessageDatabaseSchema
|
||||||
import ru.fromchat.api.local.db.rebindUnboundMigrationInstance
|
import ru.fromchat.api.local.db.rebindUnboundMigrationInstance
|
||||||
import ru.fromchat.api.local.db.withMessageDatabaseLock
|
import ru.fromchat.api.local.db.withMessageDatabaseLock
|
||||||
|
import ru.fromchat.api.local.db.withSqliteBusyRetry
|
||||||
import ru.fromchat.db.MessageDatabase
|
import ru.fromchat.db.MessageDatabase
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,8 +34,9 @@ object MessageDatabaseProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Runs [block] once; on a stale connection after cache wipe, resets and retries. */
|
/** Runs [block] once; on a stale connection after cache wipe, resets and retries. */
|
||||||
internal fun <T> withDatabaseRecover(block: () -> T): T {
|
internal fun <T> withDatabaseRecover(block: () -> T): T =
|
||||||
return try {
|
withSqliteBusyRetry {
|
||||||
|
try {
|
||||||
block()
|
block()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (!isStaleSqliteConnection(e)) throw e
|
if (!isStaleSqliteConnection(e)) throw e
|
||||||
|
|||||||
@@ -815,7 +815,15 @@ fun MessageItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column {
|
Column(
|
||||||
|
verticalArrangement = if (
|
||||||
|
replyRef != null && primaryIsImageContent
|
||||||
|
) {
|
||||||
|
Arrangement.spacedBy(6.dp)
|
||||||
|
} else {
|
||||||
|
Arrangement.Top
|
||||||
|
},
|
||||||
|
) {
|
||||||
replyRef?.let { replyToMsg ->
|
replyRef?.let { replyToMsg ->
|
||||||
MessageReplyQuote(
|
MessageReplyQuote(
|
||||||
replyTo = replyToMsg,
|
replyTo = replyToMsg,
|
||||||
@@ -827,6 +835,8 @@ fun MessageItem(
|
|||||||
isContextMenuOpen = isContextMenuOpen,
|
isContextMenuOpen = isContextMenuOpen,
|
||||||
onReplyClick = onReplyClick,
|
onReplyClick = onReplyClick,
|
||||||
onReplyPressedChange = { replyPressed = it },
|
onReplyPressedChange = { replyPressed = it },
|
||||||
|
inImageBubble = primaryIsImageContent,
|
||||||
|
hasAttachmentBelow = primaryIsImageContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,6 +1266,8 @@ private fun MessageReplyQuote(
|
|||||||
isContextMenuOpen: Boolean,
|
isContextMenuOpen: Boolean,
|
||||||
onReplyClick: ((Int) -> Unit)?,
|
onReplyClick: ((Int) -> Unit)?,
|
||||||
onReplyPressedChange: (Boolean) -> Unit,
|
onReplyPressedChange: (Boolean) -> Unit,
|
||||||
|
inImageBubble: Boolean = false,
|
||||||
|
hasAttachmentBelow: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val replyName = messageDisplayUsername(replyTo, currentUserId)
|
val replyName = messageDisplayUsername(replyTo, currentUserId)
|
||||||
val replyTapEnabled = onReplyClick != null && replyTo.id > 0
|
val replyTapEnabled = onReplyClick != null && replyTo.id > 0
|
||||||
@@ -1300,7 +1312,12 @@ private fun MessageReplyQuote(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
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(
|
.graphicsLayer(
|
||||||
scaleX = replyScale,
|
scaleX = replyScale,
|
||||||
scaleY = replyScale,
|
scaleY = replyScale,
|
||||||
|
|||||||
@@ -91,7 +91,18 @@ fun DesktopSettingsDetailNavHost(
|
|||||||
onOpenChatFromProfile: (Int) -> Unit,
|
onOpenChatFromProfile: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val detailRoute =
|
||||||
|
navController.currentBackStackEntryAsState().value?.destination?.route
|
||||||
|
val showPanel =
|
||||||
|
detailRoute != null && detailRoute != DesktopDetailNav.SETTINGS_ROOT
|
||||||
Box(modifier.fillMaxSize()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
|
if (showPanel) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
)
|
||||||
|
}
|
||||||
CompositionLocalProvider(LocalNavController provides navController) {
|
CompositionLocalProvider(LocalNavController provides navController) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
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
|
package ru.fromchat.api.local.db
|
||||||
|
|
||||||
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T =
|
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T =
|
||||||
|
withSqliteBusyRetry {
|
||||||
synchronized(MessageDatabaseLock) { block() }
|
synchronized(MessageDatabaseLock) { block() }
|
||||||
|
}
|
||||||
|
|
||||||
private object MessageDatabaseLock
|
private object MessageDatabaseLock
|
||||||
|
|||||||
+12
-3
@@ -11,9 +11,18 @@ actual fun provideMessageDatabaseDriver(): SqlDriver {
|
|||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
val dbFile = File(dir, "message_database.db")
|
val dbFile = File(dir, "message_database.db")
|
||||||
val needsCreate = !dbFile.exists()
|
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) {
|
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) }
|
||||||
|
}
|
||||||
+60
@@ -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.
|
* Resolves the FromChat desktop data/cache root on the JVM.
|
||||||
*
|
*
|
||||||
* - Portable (`-Dfromchat.portable=true`): `<exeDir>/fromchat-data`
|
* - Custom data root (`-Dfromchat.dev.data.dir=…`): use only when set explicitly (e.g. IDE run config)
|
||||||
* - Windows installed: `%LOCALAPPDATA%\FromChat` (or `FromChat Beta` when `-Dfromchat.app.data.name` is set)
|
* - Portable (`-Dfromchat.portable=true`): `<exeDir>/fromchat-data` or `fromchat-data-beta`
|
||||||
* - macOS: `~/Library/Application Support/FromChat`
|
* - Windows: `%LOCALAPPDATA%\FromChat` or `%LOCALAPPDATA%\FromChat Beta`
|
||||||
* - Linux: `${XDG_DATA_HOME:-~/.local/share}/FromChat`
|
* - 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.
|
* Migrates once from the legacy `~/.fromchat/cache` directory when present.
|
||||||
*/
|
*/
|
||||||
internal object AppDataRoot {
|
internal object AppDataRoot {
|
||||||
private const val LEGACY_RELATIVE = ".fromchat/cache"
|
private const val LEGACY_RELATIVE = ".fromchat/cache"
|
||||||
private const val PORTABLE_DIR_NAME = "fromchat-data"
|
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_DIR_NAME = "FromChat"
|
||||||
|
private const val APP_BETA_DIR_NAME = "FromChat Beta"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var cached: File? = null
|
private var cached: File? = null
|
||||||
@@ -32,8 +37,9 @@ internal object AppDataRoot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveUncached(): File {
|
private fun resolveUncached(): File {
|
||||||
|
devDataRoot()?.let { return it }
|
||||||
if (isPortable()) {
|
if (isPortable()) {
|
||||||
return File(executableDirectory(), PORTABLE_DIR_NAME)
|
return File(executableDirectory(), portableDirName())
|
||||||
}
|
}
|
||||||
val os = System.getProperty("os.name").orEmpty().lowercase()
|
val os = System.getProperty("os.name").orEmpty().lowercase()
|
||||||
return when {
|
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 =
|
private fun isPortable(): Boolean =
|
||||||
System.getProperty("fromchat.portable")
|
System.getProperty("fromchat.portable")
|
||||||
?.equals("true", ignoreCase = true) == true
|
?.equals("true", ignoreCase = true) == true
|
||||||
@@ -66,11 +77,14 @@ internal object AppDataRoot {
|
|||||||
private fun installedAppDirName(): String {
|
private fun installedAppDirName(): String {
|
||||||
val raw = System.getProperty("fromchat.app.data.name")?.trim()?.takeIf { it.isNotEmpty() }
|
val raw = System.getProperty("fromchat.app.data.name")?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
return when (raw) {
|
return when (raw) {
|
||||||
"FromChatBeta" -> "FromChat Beta"
|
"FromChatBeta", APP_BETA_DIR_NAME -> APP_BETA_DIR_NAME
|
||||||
else -> raw ?: APP_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 {
|
private fun windowsInstalledRoot(): File {
|
||||||
val dirName = installedAppDirName()
|
val dirName = installedAppDirName()
|
||||||
val localAppData = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() }
|
val localAppData = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() }
|
||||||
|
|||||||
Reference in New Issue
Block a user