diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index 8119be5..e442ccb 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -392,6 +392,19 @@ tasks.named("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 }, diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockGate.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockGate.kt new file mode 100644 index 0000000..b2286eb --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockGate.kt @@ -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 = _blocked.asStateFlow() + + private val _processes = MutableStateFlow>(emptyList()) + val processes: StateFlow> = _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) +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockModels.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockModels.kt new file mode 100644 index 0000000..4f460de --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockModels.kt @@ -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?, +) diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockWindow.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockWindow.kt new file mode 100644 index 0000000..7237421 --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockWindow.kt @@ -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, + 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, + 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)) + } + } + } + } +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockingProcessResolver.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockingProcessResolver.kt new file mode 100644 index 0000000..360a10a --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockingProcessResolver.kt @@ -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): List { + 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): List { + val pids = linkedSetOf() + 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): List { + val pids = linkedSetOf() + 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), + ) + } +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChrome.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChrome.kt index 64634d7..fb52b45 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChrome.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChrome.kt @@ -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() diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 262a22a..5c28951 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -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) { + 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) { } 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) { 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) { 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) { .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. diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/ProcessExecutableResolver.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/ProcessExecutableResolver.kt new file mode 100644 index 0000000..c4663e4 --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/ProcessExecutableResolver.kt @@ -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 { + 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) + } +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsRestartManager.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsRestartManager.kt new file mode 100644 index 0000000..cb1f47a --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsRestartManager.kt @@ -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): List { + 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?, + serviceCount: Int, + serviceNames: Array?, + processCount: Int, + processIds: IntArray?, + ): Int + fun RmGetList( + sessionHandle: Int, + procInfoNeeded: IntByReference, + procInfoCount: IntByReference, + affectedApps: Array?, + rebootReasons: IntByReference?, + ): Int + + companion object { + val INSTANCE: RestartManager = Native.load("Rstrtmgr", RestartManager::class.java, W32APIOptions.DEFAULT_OPTIONS) + } + } +} diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 9b86a9a..83cb88b 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -33,6 +33,12 @@ Выделить всё Свернуть Масштабировать + База данных заблокирована + FromChat не может получить доступ к базе данных — её использует другой процесс. + Завершить процесс + Ожидание освобождения базы данных… + Неизвестный процесс + PID %1$d Добро пожаловать! Войти Войдите в свой аккаунт diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 9f726f7..82cb09c 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -38,6 +38,12 @@ Select All Minimize Zoom + Database locked + FromChat cannot access its database because another process is using it. + End process + Waiting for the database to become available… + Unknown process + PID %1$d Welcome! diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/SqliteConcurrency.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/SqliteConcurrency.kt new file mode 100644 index 0000000..989e5cd --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/SqliteConcurrency.kt @@ -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 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) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt index 8150d97..cfabc4d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt @@ -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 withDatabaseRecover(block: () -> T): T { - return try { - block() - } catch (e: Exception) { - if (!isStaleSqliteConnection(e)) throw e - closeAndReset() - block() + internal fun 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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 2068e8f..a671798 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt index 8bcc7fc..a305f5f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt @@ -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), - ) - } } } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt index 31bf25f..2ae5984 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt @@ -1,6 +1,8 @@ package ru.fromchat.api.local.db internal actual fun withMessageDatabaseLock(block: () -> T): T = - synchronized(MessageDatabaseLock) { block() } + withSqliteBusyRetry { + synchronized(MessageDatabaseLock) { block() } + } private object MessageDatabaseLock diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt index 31f0c33..2f58800 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt @@ -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) } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabasePaths.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabasePaths.kt new file mode 100644 index 0000000..b25b84b --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabasePaths.kt @@ -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 { + 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) } +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SynchronizedRetryingSqlDriver.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SynchronizedRetryingSqlDriver.kt new file mode 100644 index 0000000..d472c41 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SynchronizedRetryingSqlDriver.kt @@ -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 = synchronized(accessLock) { + withSqliteBusyRetry { delegate.execute(identifier, sql, parameters, binders) } + } + + override fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult = synchronized(accessLock) { + withSqliteBusyRetry { delegate.executeQuery(identifier, sql, mapper, parameters, binders) } + } + + override fun newTransaction(): QueryResult = 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) + } +} diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt index f786e78..1b10174 100644 --- a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt +++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt @@ -5,17 +5,22 @@ import java.io.File /** * Resolves the FromChat desktop data/cache root on the JVM. * - * - Portable (`-Dfromchat.portable=true`): `/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`): `/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() }