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
|
||||
* 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 ->
|
||||
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() },
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
) {
|
||||
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
-2
@@ -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,8 +34,9 @@ object MessageDatabaseProvider {
|
||||
}
|
||||
|
||||
/** Runs [block] once; on a stale connection after cache wipe, resets and retries. */
|
||||
internal fun <T> withDatabaseRecover(block: () -> T): T {
|
||||
return try {
|
||||
internal fun <T> withDatabaseRecover(block: () -> T): T =
|
||||
withSqliteBusyRetry {
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
if (!isStaleSqliteConnection(e)) throw e
|
||||
|
||||
@@ -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 =
|
||||
withSqliteBusyRetry {
|
||||
synchronized(MessageDatabaseLock) { block() }
|
||||
}
|
||||
|
||||
private object MessageDatabaseLock
|
||||
|
||||
+12
-3
@@ -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) }
|
||||
}
|
||||
+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.
|
||||
*
|
||||
* - 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() }
|
||||
|
||||
Reference in New Issue
Block a user