mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Merge pull request #29 from sudosylabs/feat/win32-screenshots
fix(desktop): use native Windows file and folder dialogs
This commit is contained in:
9
artifacts/microsoft-store/captions.md
Normal file
9
artifacts/microsoft-store/captions.md
Normal 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.
|
||||||
BIN
artifacts/microsoft-store/final/01-review-transfer.png
Normal file
BIN
artifacts/microsoft-store/final/01-review-transfer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
artifacts/microsoft-store/final/02-share-with-qr.png
Normal file
BIN
artifacts/microsoft-store/final/02-share-with-qr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
artifacts/microsoft-store/final/03-transfer-details.png
Normal file
BIN
artifacts/microsoft-store/final/03-transfer-details.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
artifacts/microsoft-store/final/04-receive-invitation.png
Normal file
BIN
artifacts/microsoft-store/final/04-receive-invitation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
artifacts/microsoft-store/final/05-privacy-and-security.png
Normal file
BIN
artifacts/microsoft-store/final/05-privacy-and-security.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -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)
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user