From 6cf6644c09c751033e40e668c3655b1f75e00d00 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 21:49:49 +0200 Subject: [PATCH] fix(desktop): use native Windows file dialogs --- shared/build.gradle.kts | 1 + .../com/vnidrop/app/core/FilePicker.jvm.kt | 73 ++++++-- .../com/vnidrop/app/core/WindowsFilePicker.kt | 157 ++++++++++++++++++ .../com/vnidrop/app/core/FilePickerJvmTest.kt | 29 +++- 4 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/core/WindowsFilePicker.kt diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 86c3b3a..ea17415 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -131,6 +131,7 @@ kotlin { } jvmMain.dependencies { implementation(libs.filekit.dialogs) + implementation(libs.jna.platform) } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt index 1da8921..a5bddc2 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -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 }, diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/WindowsFilePicker.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/WindowsFilePicker.kt new file mode 100644 index 0000000..e105af4 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/WindowsFilePicker.kt @@ -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 = + 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 { + 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 { + 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}" diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FilePickerJvmTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FilePickerJvmTest.kt index a282ecc..e0d549b 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FilePickerJvmTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FilePickerJvmTest.kt @@ -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) + } }