feat(desktop): polish Linux native experience

This commit is contained in:
2026-07-22 11:23:10 +02:00
parent 627c205853
commit cf7734fb42
13 changed files with 346 additions and 115 deletions

View File

@@ -2,6 +2,12 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings
import io.github.vinceglb.filekit.dialogs.FileKitMode
import io.github.vinceglb.filekit.dialogs.openDirectoryPicker
import io.github.vinceglb.filekit.dialogs.openFilePicker
import java.awt.EventQueue
import java.awt.FileDialog
import java.awt.Frame
@@ -12,34 +18,59 @@ import java.io.File
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import javax.swing.filechooser.FileSystemView
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
actual fun rememberShareFilePicker(
onFilesPicked: (List<PickedShareFile>) -> Unit,
onError: (String) -> Unit,
): ShareFilePicker = remember(onFilesPicked, onError) {
object : ShareFilePicker {
override fun pickFiles() {
openPicker(onError) {
val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected)
): ShareFilePicker {
val scope = rememberCoroutineScope()
return remember(onFilesPicked, onError, scope) {
object : ShareFilePicker {
override fun pickFiles() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
try {
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
if (selected.isNotEmpty()) onFilesPicked(selected)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected)
}
}
}
}
override fun pickFolder() {
openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(
listOf(
PickedShareFile(
value = selected.absolutePath,
displayName = selected.name.ifBlank { selected.absolutePath },
sizeBytes = null,
thumbnailBytes = selected.systemIconPng(),
isDirectory = true,
),
),
)
override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
try {
val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
} ?: return@launch
onFilesPicked(listOf(selected))
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
}
}
}
}
}
@@ -49,23 +80,43 @@ actual fun rememberShareFilePicker(
actual fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker = remember(onFolderPicked, onError) {
object : ReceiveFolderPicker {
override fun pickFolder() {
openPicker(onError) {
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
onFolderPicked(
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = selected.absolutePath,
displayName = selected.name.ifBlank { selected.absolutePath },
),
)
): ReceiveFolderPicker {
val scope = rememberCoroutineScope()
return remember(onFolderPicked, onError, scope) {
object : ReceiveFolderPicker {
override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
try {
val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select receive folder")
} ?: return@launch
onFolderPicked(selected.toReceiveFolder())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
onFolderPicked(selected.toReceiveFolder())
}
}
}
}
}
}
internal enum class JvmFilePickerBackend {
XdgPortal,
AwtSwing,
}
internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend =
if (osName.orEmpty().startsWith("Linux", ignoreCase = true)) JvmFilePickerBackend.XdgPortal else JvmFilePickerBackend.AwtSwing
private fun openPicker(
onError: (String) -> Unit,
block: () -> Unit,
@@ -99,20 +150,39 @@ private fun pickShareFiles(): List<PickedShareFile> {
val names = dialog.files?.map { it.name }.orEmpty().ifEmpty {
dialog.file?.let { listOf(it) }.orEmpty()
}
names.map { name ->
val selected = File(directory, name)
PickedShareFile(
selected.absolutePath,
selected.name,
selected.length().takeIf { it >= 0L }?.toULong(),
selected.systemIconPng(),
)
}
names.map { name -> File(directory, name).toPickedShareFile(isDirectory = false) }
} finally {
dialog.dispose()
}
}
private suspend fun pickShareFilesWithPortal(): List<PickedShareFile> =
FileKit.openFilePicker(
mode = FileKitMode.Multiple(),
dialogSettings = FileKitDialogSettings(title = "Select files to share", parentWindow = activeFrame()),
).orEmpty().map { it.file.toPickedShareFile(isDirectory = false) }
private suspend fun pickDirectoryWithPortal(title: String): File? =
FileKit.openDirectoryPicker(
dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()),
)?.file
private fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
PickedShareFile(
value = absolutePath,
displayName = name.ifBlank { absolutePath },
sizeBytes = if (isDirectory) null else length().takeIf { it >= 0L }?.toULong(),
thumbnailBytes = systemIconPng(),
isDirectory = isDirectory,
)
private fun File.toReceiveFolder(): ReceiveFolder =
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = absolutePath,
displayName = name.ifBlank { absolutePath },
)
private fun File.systemIconPng(): ByteArray? = runCatching {
val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128)
val image = BufferedImage(icon.iconWidth, icon.iconHeight, BufferedImage.TYPE_INT_ARGB)