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

@@ -0,0 +1,9 @@
# Microsoft Store screenshot order and captions
1. **Review transfer** — Choose a file, name the transfer, and decide whether every receiver needs your approval.
2. **Share with QR** — Invite another device with a QR code or a portable `.vnd` invitation file.
3. **Transfer details** — See availability, size, access policy, receiver activity, and sharing options in one place.
4. **Receive invitation** — Open a private VniDrop invitation to receive files directly on your PC.
5. **Privacy and security** — Direct, account-free, end-to-end encrypted transfer with no cloud copy left behind.
All final screenshots are 1920 × 1080 PNG files. The QR shown in screenshot 2 is a deliberately non-functional demo pattern; it does not contain a live transfer capability.

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -131,6 +131,7 @@ kotlin {
} }
jvmMain.dependencies { jvmMain.dependencies {
implementation(libs.filekit.dialogs) implementation(libs.filekit.dialogs)
implementation(libs.jna.platform)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)

View File

@@ -32,8 +32,8 @@ actual fun rememberShareFilePicker(
return remember(onFilesPicked, onError, scope) { return remember(onFilesPicked, onError, scope) {
object : ShareFilePicker { object : ShareFilePicker {
override fun pickFiles() { override fun pickFiles() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { when (jvmFilePickerBackend(System.getProperty("os.name"))) {
scope.launch { JvmFilePickerBackend.XdgPortal -> scope.launch {
try { try {
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() } val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
if (selected.isNotEmpty()) onFilesPicked(selected) if (selected.isNotEmpty()) onFilesPicked(selected)
@@ -43,8 +43,20 @@ actual fun rememberShareFilePicker(
onError(error.message ?: error.toString()) onError(error.message ?: error.toString())
} }
} }
} else { JvmFilePickerBackend.WindowsNative -> {
openPicker(onError) { 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() val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected) if (selected.isNotEmpty()) onFilesPicked(selected)
} }
@@ -52,8 +64,8 @@ actual fun rememberShareFilePicker(
} }
override fun pickFolder() { override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { when (jvmFilePickerBackend(System.getProperty("os.name"))) {
scope.launch { JvmFilePickerBackend.XdgPortal -> scope.launch {
try { try {
val selected = withContext(Dispatchers.IO) { val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true) pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
@@ -65,8 +77,22 @@ actual fun rememberShareFilePicker(
onError(error.message ?: error.toString()) onError(error.message ?: error.toString())
} }
} }
} else { JvmFilePickerBackend.WindowsNative -> {
openPicker(onError) { 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 val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true))) onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
} }
@@ -85,8 +111,8 @@ actual fun rememberReceiveFolderPicker(
return remember(onFolderPicked, onError, scope) { return remember(onFolderPicked, onError, scope) {
object : ReceiveFolderPicker { object : ReceiveFolderPicker {
override fun pickFolder() { override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { when (jvmFilePickerBackend(System.getProperty("os.name"))) {
scope.launch { JvmFilePickerBackend.XdgPortal -> scope.launch {
try { try {
val selected = withContext(Dispatchers.IO) { val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select receive folder") pickDirectoryWithPortal("Select receive folder")
@@ -98,8 +124,22 @@ actual fun rememberReceiveFolderPicker(
onError(error.message ?: error.toString()) onError(error.message ?: error.toString())
} }
} }
} else { JvmFilePickerBackend.WindowsNative -> {
openPicker(onError) { 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 val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
onFolderPicked(selected.toReceiveFolder()) onFolderPicked(selected.toReceiveFolder())
} }
@@ -111,11 +151,16 @@ actual fun rememberReceiveFolderPicker(
internal enum class JvmFilePickerBackend { internal enum class JvmFilePickerBackend {
XdgPortal, XdgPortal,
WindowsNative,
AwtSwing, AwtSwing,
} }
internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend = 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( private fun openPicker(
onError: (String) -> Unit, onError: (String) -> Unit,
@@ -167,7 +212,7 @@ private suspend fun pickDirectoryWithPortal(title: String): File? =
dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()), dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()),
)?.file )?.file
private fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile = internal fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
PickedShareFile( PickedShareFile(
value = absolutePath, value = absolutePath,
displayName = name.ifBlank { 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FilePickerJvmTest { class FilePickerJvmTest {
@Test @Test
@@ -10,9 +12,34 @@ class FilePickerJvmTest {
assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux")) assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux"))
} }
@Test
fun windowsUsesTheModernNativePicker() {
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("Windows 11"))
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("windows 10"))
}
@Test @Test
fun otherDesktopPlatformsKeepTheirExistingPickers() { fun otherDesktopPlatformsKeepTheirExistingPickers() {
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Windows 11"))
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Mac OS X")) 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)
}
} }