From cfd3ae35a8a3843c567f92230b4f6d8c6eb5d1ab Mon Sep 17 00:00:00 2001 From: BunnyPraneeth5 Date: Mon, 31 Aug 2026 22:38:49 +0530 Subject: [PATCH] Implement Windows native handlers for file/folder pickers Adds native Win32/COM implementations for pickFilesForUpload and selectHostFolder on com.erebrus.drop/network, which previously threw MissingPluginException on Windows (no channel was registered at all). - pickFilesForUpload: IFileOpenDialog multi-select, returns real file paths, no cache copy needed. - selectHostFolder: IFileOpenDialog folder-picker mode, returns bare path + display name + platform: Windows. - Other methods on this channel remain unimplemented (unchanged behavior). Manually tested on Windows: multi-file upload, folder selection, cancel handling, non-ASCII names, restart persistence. --- windows/runner/CMakeLists.txt | 4 + windows/runner/erebrus_drop_channel.cpp | 313 ++++++++++++++++++++++++ windows/runner/erebrus_drop_channel.h | 61 +++++ windows/runner/flutter_window.cpp | 3 + 4 files changed, 381 insertions(+) create mode 100644 windows/runner/erebrus_drop_channel.cpp create mode 100644 windows/runner/erebrus_drop_channel.h diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 394917c..1c39c8b 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -7,6 +7,7 @@ project(runner LANGUAGES CXX) # # Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} WIN32 + "erebrus_drop_channel.cpp" "flutter_window.cpp" "main.cpp" "utils.cpp" @@ -34,6 +35,9 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +# COM (CoInitializeEx/CoCreateInstance/CoTaskMemFree) and the shell item and +# file dialog interfaces used by the "com.erebrus.drop/network" file picker. +target_link_libraries(${BINARY_NAME} PRIVATE "ole32.lib" "shell32.lib") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") # Run the Flutter tool portions of the build. This must not be removed. diff --git a/windows/runner/erebrus_drop_channel.cpp b/windows/runner/erebrus_drop_channel.cpp new file mode 100644 index 0000000..5c472ba --- /dev/null +++ b/windows/runner/erebrus_drop_channel.cpp @@ -0,0 +1,313 @@ +#include "erebrus_drop_channel.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "utils.h" + +namespace { + +constexpr char kChannelName[] = "com.erebrus.drop/network"; +constexpr char kPickFilesMethod[] = "pickFilesForUpload"; +constexpr char kSelectHostFolderMethod[] = "selectHostFolder"; + +using FlutterMethodResult = flutter::MethodResult; + +// Minimal owning pointer for the COM interfaces used below, so every early +// return releases the dialog and its results. +template +class ComPtr { + public: + ComPtr() = default; + ~ComPtr() { Reset(); } + ComPtr(const ComPtr&) = delete; + ComPtr& operator=(const ComPtr&) = delete; + + T** Receive() { return &pointer_; } + T* operator->() const { return pointer_; } + T* Get() const { return pointer_; } + + void Reset() { + if (pointer_) { + pointer_->Release(); + pointer_ = nullptr; + } + } + + private: + T* pointer_ = nullptr; +}; + +// Balances CoInitializeEx for the duration of a dialog. The runner already +// initializes COM on the platform thread (see main.cpp), so this usually just +// bumps the reference count. +class ScopedComInitializer { + public: + ScopedComInitializer() { + const HRESULT result = ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + // RPC_E_CHANGED_MODE means someone else picked a different apartment; COM + // is usable but this instance must not uninitialize it. + owns_initialization_ = SUCCEEDED(result); + } + + ~ScopedComInitializer() { + if (owns_initialization_) { + ::CoUninitialize(); + } + } + + ScopedComInitializer(const ScopedComInitializer&) = delete; + ScopedComInitializer& operator=(const ScopedComInitializer&) = delete; + + private: + bool owns_initialization_ = false; +}; + +std::wstring FileNameFromPath(const std::wstring& path) { + const size_t separator = path.find_last_of(L"\\/"); + if (separator == std::wstring::npos) { + return path; + } + return path.substr(separator + 1); +} + +// Returns the file size in bytes, or 0 when the path cannot be queried or is a +// directory (the dialog is configured for files only, but be defensive). +int64_t FileSizeInBytes(const std::wstring& path) { + WIN32_FILE_ATTRIBUTE_DATA attributes = {}; + if (!::GetFileAttributesExW(path.c_str(), GetFileExInfoStandard, + &attributes)) { + return 0; + } + if (attributes.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + return 0; + } + ULARGE_INTEGER size = {}; + size.HighPart = attributes.nFileSizeHigh; + size.LowPart = attributes.nFileSizeLow; + return static_cast(size.QuadPart); +} + +void FailWithHresult(FlutterMethodResult* result, const std::string& code, + const std::string& message, HRESULT hr) { + result->Error(code, message, + flutter::EncodableValue(static_cast(hr))); +} + +void PickFilesForUpload(HWND owner_window, FlutterMethodResult* result) { + ScopedComInitializer com_initializer; + + ComPtr dialog; + HRESULT hr = ::CoCreateInstance(CLSID_FileOpenDialog, nullptr, + CLSCTX_INPROC_SERVER, IID_IFileOpenDialog, + reinterpret_cast(dialog.Receive())); + if (FAILED(hr) || dialog.Get() == nullptr) { + FailWithHresult(result, "PICK_FILE_UNAVAILABLE", + "Could not open the Windows file picker.", hr); + return; + } + + FILEOPENDIALOGOPTIONS options = 0; + hr = dialog->GetOptions(&options); + if (FAILED(hr)) { + FailWithHresult(result, "PICK_FILE_UNAVAILABLE", + "Could not configure the Windows file picker.", hr); + return; + } + // Multi-select matches Android (EXTRA_ALLOW_MULTIPLE) and iOS + // (allowsMultipleSelection); FORCEFILESYSTEM keeps the results to real paths + // that Dart can hand to File(). + hr = dialog->SetOptions(options | FOS_ALLOWMULTISELECT | FOS_FILEMUSTEXIST | + FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST); + if (FAILED(hr)) { + FailWithHresult(result, "PICK_FILE_UNAVAILABLE", + "Could not configure the Windows file picker.", hr); + return; + } + dialog->SetTitle(L"Select files to upload"); + + hr = dialog->Show(owner_window); + if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + // Cancelling is not an error: Dart reads an empty list as "nothing picked". + result->Success(flutter::EncodableValue(flutter::EncodableList())); + return; + } + if (FAILED(hr)) { + FailWithHresult(result, "PICK_FILE_FAILED", + "The Windows file picker could not be shown.", hr); + return; + } + + ComPtr items; + hr = dialog->GetResults(items.Receive()); + if (FAILED(hr) || items.Get() == nullptr) { + FailWithHresult(result, "PICK_FILE_FAILED", + "Could not read the selected files.", hr); + return; + } + + DWORD count = 0; + hr = items->GetCount(&count); + if (FAILED(hr)) { + FailWithHresult(result, "PICK_FILE_FAILED", + "Could not read the selected files.", hr); + return; + } + + flutter::EncodableList picked; + for (DWORD index = 0; index < count; index++) { + ComPtr item; + if (FAILED(items->GetItemAt(index, item.Receive())) || + item.Get() == nullptr) { + continue; + } + PWSTR display_path = nullptr; + if (FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &display_path)) || + display_path == nullptr) { + continue; + } + const std::wstring path(display_path); + ::CoTaskMemFree(display_path); + if (path.empty()) { + continue; + } + picked.push_back(flutter::EncodableValue(flutter::EncodableMap{ + {flutter::EncodableValue("path"), + flutter::EncodableValue(Utf8FromUtf16(path.c_str()))}, + {flutter::EncodableValue("name"), + flutter::EncodableValue(Utf8FromUtf16(FileNameFromPath(path).c_str()))}, + {flutter::EncodableValue("sizeBytes"), + flutter::EncodableValue(FileSizeInBytes(path))}, + })); + } + + result->Success(flutter::EncodableValue(std::move(picked))); +} + +void SelectHostFolder(HWND owner_window, FlutterMethodResult* result) { + ScopedComInitializer com_initializer; + + ComPtr dialog; + HRESULT hr = ::CoCreateInstance(CLSID_FileOpenDialog, nullptr, + CLSCTX_INPROC_SERVER, IID_IFileOpenDialog, + reinterpret_cast(dialog.Receive())); + if (FAILED(hr) || dialog.Get() == nullptr) { + FailWithHresult(result, "PICK_FOLDER_FAILED", + "Could not open the Windows folder picker.", hr); + return; + } + + FILEOPENDIALOGOPTIONS options = 0; + hr = dialog->GetOptions(&options); + if (FAILED(hr)) { + FailWithHresult(result, "PICK_FOLDER_FAILED", + "Could not configure the Windows folder picker.", hr); + return; + } + // FOS_PICKFOLDERS turns the open dialog into a folder browser; a single + // folder only, so FOS_ALLOWMULTISELECT is deliberately not set. + hr = dialog->SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | + FOS_PATHMUSTEXIST); + if (FAILED(hr)) { + FailWithHresult(result, "PICK_FOLDER_FAILED", + "Could not configure the Windows folder picker.", hr); + return; + } + dialog->SetTitle(L"Select Drop folder"); + dialog->SetOkButtonLabel(L"Select Folder"); + + hr = dialog->Show(owner_window); + if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + // Cancelling is not an error: Dart reads a null result as "no folder". + result->Success(); + return; + } + if (FAILED(hr)) { + FailWithHresult(result, "PICK_FOLDER_FAILED", + "The Windows folder picker could not be shown.", hr); + return; + } + + ComPtr item; + hr = dialog->GetResult(item.Receive()); + if (FAILED(hr) || item.Get() == nullptr) { + FailWithHresult(result, "PICK_FOLDER_FAILED", + "Could not read the selected folder.", hr); + return; + } + + PWSTR display_path = nullptr; + hr = item->GetDisplayName(SIGDN_FILESYSPATH, &display_path); + if (FAILED(hr) || display_path == nullptr) { + FailWithHresult(result, "PICK_FOLDER_FAILED", + "The selected folder has no filesystem path.", hr); + return; + } + const std::wstring path(display_path); + ::CoTaskMemFree(display_path); + if (path.empty()) { + // Treated as a cancellation rather than an error: Dart would reject an + // empty uri anyway, and there is nothing the user could act on. + result->Success(); + return; + } + + // A drive root such as "D:\" has no trailing component to name it after; + // macOS applies the same "Selected folder" fallback for an empty + // lastPathComponent. + std::wstring name = FileNameFromPath(path); + if (name.empty()) { + name = L"Selected folder"; + } + + // "uri" is a bare Windows path, not a file:// URI: DesktopHostFolder feeds it + // straight to Directory(), which avoids any percent-encoding round trip. + result->Success(flutter::EncodableValue(flutter::EncodableMap{ + {flutter::EncodableValue("name"), + flutter::EncodableValue(Utf8FromUtf16(name.c_str()))}, + {flutter::EncodableValue("uri"), + flutter::EncodableValue(Utf8FromUtf16(path.c_str()))}, + {flutter::EncodableValue("platform"), flutter::EncodableValue("Windows")}, + })); +} + +} // namespace + +void RegisterErebrusDropChannel(flutter::FlutterEngine* engine, + std::function owner_window_provider) { + if (engine == nullptr) { + return; + } + // Owned for the process lifetime: the channel must outlive this call so the + // handler stays registered. + static std::unique_ptr> + channel; + channel = std::make_unique>( + engine->messenger(), kChannelName, + &flutter::StandardMethodCodec::GetInstance()); + channel->SetMethodCallHandler( + [owner_window_provider = std::move(owner_window_provider)]( + const flutter::MethodCall& call, + std::unique_ptr result) { + // Both dialogs are modal and run on the platform thread, so they + // cannot be re-entered while one is open. + const HWND owner = + owner_window_provider ? owner_window_provider() : nullptr; + if (call.method_name() == kPickFilesMethod) { + PickFilesForUpload(owner, result.get()); + return; + } + if (call.method_name() == kSelectHostFolderMethod) { + SelectHostFolder(owner, result.get()); + return; + } + result->NotImplemented(); + }); +} diff --git a/windows/runner/erebrus_drop_channel.h b/windows/runner/erebrus_drop_channel.h new file mode 100644 index 0000000..e5394cc --- /dev/null +++ b/windows/runner/erebrus_drop_channel.h @@ -0,0 +1,61 @@ +#ifndef RUNNER_EREBRUS_DROP_CHANNEL_H_ +#define RUNNER_EREBRUS_DROP_CHANNEL_H_ + +#include +#include + +#include + +// Registers the Windows side of the "com.erebrus.drop/network" method channel. +// +// Channel contract (must stay in sync with the Android, iOS and macOS +// implementations, and with NativeFilePickerService in +// lib/features/join/native_file_picker_service.dart): +// +// method: "pickFilesForUpload" +// args: none +// returns: a list of maps, one per selected file, each shaped as +// { +// "path": String, // absolute filesystem path +// "name": String, // file name including extension +// "sizeBytes": int, // 64-bit file size, 0 if unknown +// } +// The user cancelling the dialog is not an error: an empty list is +// returned, which Dart treats as "nothing picked". +// errors: FlutterError with code "PICK_FILE_UNAVAILABLE" when the shell +// dialog cannot be created, or "PICK_FILE_FAILED" when reading the +// selection fails. Details carry the failing HRESULT. +// +// method: "selectHostFolder" +// args: none +// returns: a map describing the folder to host files from: +// { +// "name": String, // folder name, "Selected folder" for a +// // drive root such as "D:\" +// "uri": String, // bare absolute path, e.g. "C:\Drop"; +// // DesktopHostFolder resolves this with +// // Directory(), so it is deliberately +// // not a file:// URI +// "platform": String, // "Windows", alongside "macOS", +// // "iOS Files" and "Android SAF" +// } +// No "bookmark" key: that is macOS security-scoped bookmark data, +// and Dart only calls restoreHostFolderAccess on macOS. +// Cancelling returns null, which Dart reads as "nothing selected". +// errors: FlutterError with code "PICK_FOLDER_FAILED". Note that +// "PICK_FOLDER_UNAVAILABLE" is reserved: Dart synthesizes it when +// no native handler is registered at all. +// +// Any other method on this channel is answered with notImplemented, matching +// the behaviour before this handler existed. In particular +// restoreHostFolderAccess and releaseHostFolderAccess are macOS-only, and +// syncShareIntakeHostFolder / clearShareIntakeHostFolder are ignored by Dart +// when unimplemented. +// +// |owner_window_provider| is queried lazily each time a dialog is opened so +// that the picker is modal to the current Flutter window (it may return nullptr +// once the window is gone, in which case the dialog is shown unowned). +void RegisterErebrusDropChannel(flutter::FlutterEngine* engine, + std::function owner_window_provider); + +#endif // RUNNER_EREBRUS_DROP_CHANNEL_H_ diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 955ee30..6e0e301 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -2,6 +2,7 @@ #include +#include "erebrus_drop_channel.h" #include "flutter/generated_plugin_registrant.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) @@ -25,6 +26,8 @@ bool FlutterWindow::OnCreate() { return false; } RegisterPlugins(flutter_controller_->engine()); + RegisterErebrusDropChannel(flutter_controller_->engine(), + [this]() { return GetHandle(); }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() {