Merge pull request #29 from sudosylabs/feat/win32-screenshots

fix(desktop): use native Windows file and folder dialogs
This commit is contained in:
Hammed Abass
2026-07-22 22:06:20 +02:00
committed by GitHub
10 changed files with 254 additions and 15 deletions

View File

@@ -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 },

View File

@@ -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}"

View File

@@ -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)
}
}