mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
fix(desktop): use native Windows file dialogs
This commit is contained in:
@@ -32,8 +32,8 @@ actual fun rememberShareFilePicker(
|
||||
return remember(onFilesPicked, onError, scope) {
|
||||
object : ShareFilePicker {
|
||||
override fun pickFiles() {
|
||||
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
|
||||
scope.launch {
|
||||
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
|
||||
JvmFilePickerBackend.XdgPortal -> scope.launch {
|
||||
try {
|
||||
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
|
||||
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||
@@ -43,8 +43,20 @@ actual fun rememberShareFilePicker(
|
||||
onError(error.message ?: error.toString())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openPicker(onError) {
|
||||
JvmFilePickerBackend.WindowsNative -> {
|
||||
val owner = activeFrame()
|
||||
scope.launch {
|
||||
try {
|
||||
val selected = withContext(Dispatchers.IO) { pickWindowsFiles(owner) }
|
||||
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
onError(error.message ?: error.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
|
||||
val selected = pickShareFiles()
|
||||
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||
}
|
||||
@@ -52,8 +64,8 @@ actual fun rememberShareFilePicker(
|
||||
}
|
||||
|
||||
override fun pickFolder() {
|
||||
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
|
||||
scope.launch {
|
||||
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
|
||||
JvmFilePickerBackend.XdgPortal -> scope.launch {
|
||||
try {
|
||||
val selected = withContext(Dispatchers.IO) {
|
||||
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
|
||||
@@ -65,8 +77,22 @@ actual fun rememberShareFilePicker(
|
||||
onError(error.message ?: error.toString())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openPicker(onError) {
|
||||
JvmFilePickerBackend.WindowsNative -> {
|
||||
val owner = activeFrame()
|
||||
scope.launch {
|
||||
try {
|
||||
val selected = withContext(Dispatchers.IO) {
|
||||
pickWindowsFolder("Select folder to share", owner)
|
||||
} ?: return@launch
|
||||
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
onError(error.message ?: error.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
|
||||
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
|
||||
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
|
||||
}
|
||||
@@ -85,8 +111,8 @@ actual fun rememberReceiveFolderPicker(
|
||||
return remember(onFolderPicked, onError, scope) {
|
||||
object : ReceiveFolderPicker {
|
||||
override fun pickFolder() {
|
||||
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
|
||||
scope.launch {
|
||||
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
|
||||
JvmFilePickerBackend.XdgPortal -> scope.launch {
|
||||
try {
|
||||
val selected = withContext(Dispatchers.IO) {
|
||||
pickDirectoryWithPortal("Select receive folder")
|
||||
@@ -98,8 +124,22 @@ actual fun rememberReceiveFolderPicker(
|
||||
onError(error.message ?: error.toString())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openPicker(onError) {
|
||||
JvmFilePickerBackend.WindowsNative -> {
|
||||
val owner = activeFrame()
|
||||
scope.launch {
|
||||
try {
|
||||
val selected = withContext(Dispatchers.IO) {
|
||||
pickWindowsFolder("Select receive folder", owner)
|
||||
} ?: return@launch
|
||||
onFolderPicked(selected.toReceiveFolder())
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
onError(error.message ?: error.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
|
||||
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
|
||||
onFolderPicked(selected.toReceiveFolder())
|
||||
}
|
||||
@@ -111,11 +151,16 @@ actual fun rememberReceiveFolderPicker(
|
||||
|
||||
internal enum class JvmFilePickerBackend {
|
||||
XdgPortal,
|
||||
WindowsNative,
|
||||
AwtSwing,
|
||||
}
|
||||
|
||||
internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend =
|
||||
if (osName.orEmpty().startsWith("Linux", ignoreCase = true)) JvmFilePickerBackend.XdgPortal else JvmFilePickerBackend.AwtSwing
|
||||
when {
|
||||
osName.orEmpty().startsWith("Linux", ignoreCase = true) -> JvmFilePickerBackend.XdgPortal
|
||||
osName.orEmpty().startsWith("Windows", ignoreCase = true) -> JvmFilePickerBackend.WindowsNative
|
||||
else -> JvmFilePickerBackend.AwtSwing
|
||||
}
|
||||
|
||||
private fun openPicker(
|
||||
onError: (String) -> Unit,
|
||||
@@ -167,7 +212,7 @@ private suspend fun pickDirectoryWithPortal(title: String): File? =
|
||||
dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()),
|
||||
)?.file
|
||||
|
||||
private fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
|
||||
internal fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
|
||||
PickedShareFile(
|
||||
value = absolutePath,
|
||||
displayName = name.ifBlank { absolutePath },
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.WString
|
||||
import com.sun.jna.platform.win32.COM.COMUtils
|
||||
import com.sun.jna.platform.win32.COM.Unknown
|
||||
import com.sun.jna.platform.win32.Guid.GUID
|
||||
import com.sun.jna.platform.win32.Ole32
|
||||
import com.sun.jna.platform.win32.WinDef.HWND
|
||||
import com.sun.jna.platform.win32.WinNT.HRESULT
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import com.sun.jna.ptr.PointerByReference
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
|
||||
internal enum class WindowsFilePickerMode {
|
||||
Files,
|
||||
Folder,
|
||||
}
|
||||
|
||||
internal const val FOS_NOCHANGEDIR = 0x00000008
|
||||
internal const val FOS_PICKFOLDERS = 0x00000020
|
||||
internal const val FOS_FORCEFILESYSTEM = 0x00000040
|
||||
internal const val FOS_ALLOWMULTISELECT = 0x00000200
|
||||
internal const val FOS_PATHMUSTEXIST = 0x00000800
|
||||
internal const val FOS_FILEMUSTEXIST = 0x00001000
|
||||
|
||||
internal fun windowsFilePickerOptions(mode: WindowsFilePickerMode): Int =
|
||||
FOS_NOCHANGEDIR or FOS_FORCEFILESYSTEM or FOS_PATHMUSTEXIST or when (mode) {
|
||||
WindowsFilePickerMode.Files -> FOS_ALLOWMULTISELECT or FOS_FILEMUSTEXIST
|
||||
WindowsFilePickerMode.Folder -> FOS_PICKFOLDERS
|
||||
}
|
||||
|
||||
internal fun pickWindowsFiles(owner: Frame?): List<PickedShareFile> =
|
||||
showWindowsFilePicker(
|
||||
title = "Select files to share",
|
||||
mode = WindowsFilePickerMode.Files,
|
||||
owner = owner,
|
||||
).map { it.toPickedShareFile(isDirectory = false) }
|
||||
|
||||
internal fun pickWindowsFolder(title: String, owner: Frame?): File? =
|
||||
showWindowsFilePicker(title, WindowsFilePickerMode.Folder, owner).singleOrNull()
|
||||
|
||||
private fun showWindowsFilePicker(
|
||||
title: String,
|
||||
mode: WindowsFilePickerMode,
|
||||
owner: Frame?,
|
||||
): List<File> {
|
||||
val ole32 = Ole32.INSTANCE
|
||||
val initialization = ole32.CoInitializeEx(Pointer.NULL, Ole32.COINIT_APARTMENTTHREADED)
|
||||
COMUtils.checkRC(initialization)
|
||||
try {
|
||||
val dialogReference = PointerByReference()
|
||||
COMUtils.checkRC(
|
||||
ole32.CoCreateInstance(
|
||||
GUID(CLSID_FILE_OPEN_DIALOG),
|
||||
Pointer.NULL,
|
||||
CLSCTX_INPROC_SERVER,
|
||||
GUID(IID_FILE_OPEN_DIALOG),
|
||||
dialogReference,
|
||||
),
|
||||
)
|
||||
val dialog = FileOpenDialog(dialogReference.value)
|
||||
try {
|
||||
val existingOptions = IntByReference()
|
||||
COMUtils.checkRC(dialog.getOptions(existingOptions))
|
||||
COMUtils.checkRC(dialog.setOptions(existingOptions.value or windowsFilePickerOptions(mode)))
|
||||
COMUtils.checkRC(dialog.setTitle(WString(title)))
|
||||
|
||||
val ownerHandle = owner
|
||||
?.takeIf { it.isDisplayable }
|
||||
?.let { HWND(Native.getWindowPointer(it)) }
|
||||
val showResult = dialog.show(ownerHandle)
|
||||
if (showResult.toInt() == HRESULT_CANCELLED) return emptyList()
|
||||
COMUtils.checkRC(showResult)
|
||||
return dialog.results(ole32)
|
||||
} finally {
|
||||
dialog.Release()
|
||||
}
|
||||
} finally {
|
||||
ole32.CoUninitialize()
|
||||
}
|
||||
}
|
||||
|
||||
private class FileOpenDialog(pointer: Pointer) : Unknown(pointer) {
|
||||
fun show(owner: HWND?): HRESULT = invokeHResult(3, owner)
|
||||
|
||||
fun setOptions(options: Int): HRESULT = invokeHResult(9, options)
|
||||
|
||||
fun getOptions(options: IntByReference): HRESULT = invokeHResult(10, options)
|
||||
|
||||
fun setTitle(title: WString): HRESULT = invokeHResult(17, title)
|
||||
|
||||
private fun getResults(results: PointerByReference): HRESULT = invokeHResult(27, results)
|
||||
|
||||
fun results(ole32: Ole32): List<File> {
|
||||
val resultsReference = PointerByReference()
|
||||
COMUtils.checkRC(getResults(resultsReference))
|
||||
val results = ShellItemArray(resultsReference.value)
|
||||
try {
|
||||
val count = IntByReference()
|
||||
COMUtils.checkRC(results.getCount(count))
|
||||
return List(count.value) { index -> results.fileAt(index, ole32) }
|
||||
} finally {
|
||||
results.Release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
|
||||
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
|
||||
}
|
||||
|
||||
private class ShellItemArray(pointer: Pointer) : Unknown(pointer) {
|
||||
fun getCount(count: IntByReference): HRESULT = invokeHResult(7, count)
|
||||
|
||||
private fun getItemAt(index: Int, item: PointerByReference): HRESULT = invokeHResult(8, index, item)
|
||||
|
||||
fun fileAt(index: Int, ole32: Ole32): File {
|
||||
val itemReference = PointerByReference()
|
||||
COMUtils.checkRC(getItemAt(index, itemReference))
|
||||
val item = ShellItem(itemReference.value)
|
||||
try {
|
||||
return item.file(ole32)
|
||||
} finally {
|
||||
item.Release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
|
||||
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
|
||||
}
|
||||
|
||||
private class ShellItem(pointer: Pointer) : Unknown(pointer) {
|
||||
private fun getDisplayName(name: PointerByReference): HRESULT =
|
||||
invokeHResult(5, SIGDN_FILESYSPATH, name)
|
||||
|
||||
fun file(ole32: Ole32): File {
|
||||
val nameReference = PointerByReference()
|
||||
COMUtils.checkRC(getDisplayName(nameReference))
|
||||
val name = nameReference.value
|
||||
try {
|
||||
return File(name.getWideString(0))
|
||||
} finally {
|
||||
ole32.CoTaskMemFree(name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
|
||||
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
|
||||
}
|
||||
|
||||
private const val CLSCTX_INPROC_SERVER = 0x1
|
||||
private const val HRESULT_CANCELLED = 0x800704C7.toInt()
|
||||
private const val SIGDN_FILESYSPATH = 0x80058000.toInt()
|
||||
private const val CLSID_FILE_OPEN_DIALOG = "{DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7}"
|
||||
private const val IID_FILE_OPEN_DIALOG = "{D57C7288-D4AD-4768-BE02-9D969532D960}"
|
||||
@@ -2,6 +2,8 @@ package com.vnidrop.app.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FilePickerJvmTest {
|
||||
@Test
|
||||
@@ -10,9 +12,34 @@ class FilePickerJvmTest {
|
||||
assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsUsesTheModernNativePicker() {
|
||||
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("Windows 11"))
|
||||
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("windows 10"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherDesktopPlatformsKeepTheirExistingPickers() {
|
||||
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Windows 11"))
|
||||
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Mac OS X"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsFilePickerAllowsMultipleFilesystemFiles() {
|
||||
val options = windowsFilePickerOptions(WindowsFilePickerMode.Files)
|
||||
|
||||
assertTrue(options and FOS_FORCEFILESYSTEM != 0)
|
||||
assertTrue(options and FOS_ALLOWMULTISELECT != 0)
|
||||
assertTrue(options and FOS_FILEMUSTEXIST != 0)
|
||||
assertFalse(options and FOS_PICKFOLDERS != 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowsFolderPickerSelectsOneFilesystemFolder() {
|
||||
val options = windowsFilePickerOptions(WindowsFilePickerMode.Folder)
|
||||
|
||||
assertTrue(options and FOS_FORCEFILESYSTEM != 0)
|
||||
assertTrue(options and FOS_PICKFOLDERS != 0)
|
||||
assertTrue(options and FOS_PATHMUSTEXIST != 0)
|
||||
assertFalse(options and FOS_ALLOWMULTISELECT != 0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user