From b6a20b45984c0e28b622a44658fc71522514d72e Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Fri, 4 Sep 2026 03:05:41 +0200 Subject: [PATCH] macOS: forward Finder drops through WKWebView Co-authored-by: Cursor --- docs/status/macos.md | 2 +- native/macos/README.md | 4 +- .../DesktopWebView/FileDropWebView.swift | 231 ++++++++++++++++++ test/e2e/e2e_test.exs | 6 +- test/fixtures/file_input.html | 8 + 5 files changed, 247 insertions(+), 4 deletions(-) diff --git a/docs/status/macos.md b/docs/status/macos.md index a1c08a1..bdd0bd5 100644 --- a/docs/status/macos.md +++ b/docs/status/macos.md @@ -33,7 +33,7 @@ manual-only with justification). | Microphone in webview | done | E2E via test RPC + fixture | | Camera in webview | done | E2E via test RPC + fixture | | Native dialogs (`dialog.choose_file/dir`) | done | `NSOpenPanel` (manual; blocks RPC) | -| HTML `` and Finder drag-and-drop | partial | `WKUIDelegate` open-panel hook and `FileDropWebView` forwarding; native picker and Finder checks pending | +| HTML `` and Finder drag-and-drop | partial | `WKUIDelegate` open-panel hook and private WebKit drag bridge; native picker and Finder checks pending | | Dialog prompt | done | `NSAlert` + text field (manual) | | EventBridge Env/Window/Menu | done | Elixir unit coverage | | Test RPC channel | done | `--edw-test-rpc` | diff --git a/native/macos/README.md b/native/macos/README.md index 3c37c70..31a5a48 100644 --- a/native/macos/README.md +++ b/native/macos/README.md @@ -26,7 +26,9 @@ Output is copied to `priv/native/macos/DesktopWebView` (universal when both arch `` uses the `WKUIDelegate` open-panel callback in `WebWindow.swift`. The callback maps single, multiple, directory, and cancel actions to WebKit's `FileList`. `FileDropWebView.swift` registers file URLs and -file promises, accepts Finder drops, and forwards the drop methods to WebKit. +file promises and installs a runtime bridge on WebKit's private content view. +The bridge preserves WebKit's original drag methods after accepting Finder +drops. The shared E2E checks the fixture DOM, but it cannot drive the macOS picker or Finder. Manually verify selection, cancellation, multiple files, directory diff --git a/native/macos/Sources/DesktopWebView/FileDropWebView.swift b/native/macos/Sources/DesktopWebView/FileDropWebView.swift index 20b433b..3341eb7 100644 --- a/native/macos/Sources/DesktopWebView/FileDropWebView.swift +++ b/native/macos/Sources/DesktopWebView/FileDropWebView.swift @@ -1,6 +1,173 @@ import AppKit +import ObjectiveC.runtime import WebKit +private final class FileDropBridge: NSObject { + @objc(edw_draggingEntered:) + dynamic func edw_draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + let view = self as AnyObject as! NSView + guard let webView = view.enclosingFileDropWebView else { + return view.edw_originalDraggingEntered(sender) + } + + let acceptsFileDrop = webView.acceptsFileDrop(sender) + let operation = view.edw_originalDraggingEntered(sender) + return webView.fileDropOperation(operation, accepted: acceptsFileDrop) + } + + @objc(edw_draggingUpdated:) + dynamic func edw_draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + let view = self as AnyObject as! NSView + guard let webView = view.enclosingFileDropWebView else { + return view.edw_originalDraggingUpdated(sender) + } + + let acceptsFileDrop = webView.acceptsFileDrop(sender) + let operation = view.edw_originalDraggingUpdated(sender) + return webView.fileDropOperation(operation, accepted: acceptsFileDrop) + } + + @objc(edw_prepareForDragOperation:) + dynamic func edw_prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + let view = self as AnyObject as! NSView + guard let webView = view.enclosingFileDropWebView else { + return view.edw_originalPrepareForDragOperation(sender) + } + + let acceptsFileDrop = webView.acceptsFileDrop(sender) + return view.edw_originalPrepareForDragOperation(sender) || acceptsFileDrop + } + + @objc(edw_performDragOperation:) + dynamic func edw_performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let view = self as AnyObject as! NSView + return view.edw_originalPerformDragOperation(sender) + } + + @objc(edw_draggingExited:) + dynamic func edw_draggingExited(_ sender: NSDraggingInfo?) { + let view = self as AnyObject as! NSView + view.edw_originalDraggingExited(sender) + } + + static func install(on view: NSView) { + let targetClass = type(of: view) + let classId = ObjectIdentifier(targetClass) + + guard !bridgedClasses.contains(classId) else { + return + } + + var installed = false + installed = + install( + on: targetClass, + selector: #selector(NSView.draggingEntered(_:)), + originalSelector: #selector(NSView.edw_originalDraggingEntered(_:)), + replacementSelector: #selector(FileDropBridge.edw_draggingEntered(_:)) + ) || installed + installed = + install( + on: targetClass, + selector: #selector(NSView.draggingUpdated(_:)), + originalSelector: #selector(NSView.edw_originalDraggingUpdated(_:)), + replacementSelector: #selector(FileDropBridge.edw_draggingUpdated(_:)) + ) || installed + installed = + install( + on: targetClass, + selector: #selector(NSView.prepareForDragOperation(_:)), + originalSelector: #selector(NSView.edw_originalPrepareForDragOperation(_:)), + replacementSelector: #selector(FileDropBridge.edw_prepareForDragOperation(_:)) + ) || installed + installed = + install( + on: targetClass, + selector: #selector(NSView.performDragOperation(_:)), + originalSelector: #selector(NSView.edw_originalPerformDragOperation(_:)), + replacementSelector: #selector(FileDropBridge.edw_performDragOperation(_:)) + ) || installed + installed = + install( + on: targetClass, + selector: #selector(NSView.draggingExited(_:)), + originalSelector: #selector(NSView.edw_originalDraggingExited(_:)), + replacementSelector: #selector(FileDropBridge.edw_draggingExited(_:)) + ) || installed + + if installed { + bridgedClasses.insert(classId) + } + } + + private static var bridgedClasses = Set() + + private static func install( + on targetClass: AnyClass, + selector: Selector, + originalSelector: Selector, + replacementSelector: Selector + ) -> Bool { + guard + let method = class_getInstanceMethod(targetClass, selector), + let replacement = class_getInstanceMethod(FileDropBridge.self, replacementSelector) + else { + return false + } + + let originalImplementation = method_getImplementation(method) + let typeEncoding = method_getTypeEncoding(method) + class_addMethod(targetClass, selector, originalImplementation, typeEncoding) + + guard let targetMethod = class_getInstanceMethod(targetClass, selector) else { + return false + } + + class_addMethod(targetClass, originalSelector, originalImplementation, typeEncoding) + method_setImplementation(targetMethod, method_getImplementation(replacement)) + return true + } +} + +private extension NSView { + var enclosingFileDropWebView: FileDropWebView? { + var view: NSView? = self + + while let current = view { + if let webView = current as? FileDropWebView { + return webView + } + + view = current.superview + } + + return nil + } + + @objc(edw_originalDraggingEntered:) + dynamic func edw_originalDraggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + [] + } + + @objc(edw_originalDraggingUpdated:) + dynamic func edw_originalDraggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + [] + } + + @objc(edw_originalPrepareForDragOperation:) + dynamic func edw_originalPrepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + false + } + + @objc(edw_originalPerformDragOperation:) + dynamic func edw_originalPerformDragOperation(_ sender: NSDraggingInfo) -> Bool { + false + } + + @objc(edw_originalDraggingExited:) + dynamic func edw_originalDraggingExited(_ sender: NSDraggingInfo?) {} +} + final class FileDropWebView: WKWebView { private static let fileDragTypes = [ @@ -14,10 +181,74 @@ final class FileDropWebView: WKWebView { override init(frame: NSRect, configuration: WKWebViewConfiguration) { super.init(frame: frame, configuration: configuration) registerForDraggedTypes(Self.fileDragTypes) + installDragBridge() } required init?(coder: NSCoder) { super.init(coder: coder) registerForDraggedTypes(Self.fileDragTypes) + installDragBridge() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + installDragBridge() + DispatchQueue.main.async { [weak self] in + self?.installDragBridge() + } + } + + fileprivate func acceptsFileDrop(_ sender: NSDraggingInfo) -> Bool { + let types = sender.draggingPasteboard.types ?? [] + return types.contains { Self.fileDragTypes.contains($0) } + } + + fileprivate func fileDropOperation( + _ operation: NSDragOperation, + accepted: Bool + ) -> NSDragOperation { + accepted && operation.isEmpty ? .copy : operation + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + let acceptedFileDrop = acceptsFileDrop(sender) + return fileDropOperation(super.draggingEntered(sender), accepted: acceptedFileDrop) + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + let acceptedFileDrop = acceptsFileDrop(sender) + return fileDropOperation(super.draggingUpdated(sender), accepted: acceptedFileDrop) + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + super.prepareForDragOperation(sender) || acceptsFileDrop(sender) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + super.performDragOperation(sender) + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + super.draggingExited(sender) + } + + private func installDragBridge() { + for view in privateDragViews(in: self) { + view.registerForDraggedTypes(Self.fileDragTypes) + FileDropBridge.install(on: view) + } + } + + private func privateDragViews(in view: NSView) -> [NSView] { + view.subviews.flatMap { subview in + let nested = privateDragViews(in: subview) + let name = NSStringFromClass(type(of: subview)) + + if name.contains("WKContentView") || name.contains("WKView") { + return [subview] + nested + } + + return nested + } } } diff --git a/test/e2e/e2e_test.exs b/test/e2e/e2e_test.exs index d1427c9..ec4c618 100644 --- a/test/e2e/e2e_test.exs +++ b/test/e2e/e2e_test.exs @@ -135,7 +135,8 @@ defmodule DesktopWebview.E2ETest do multiple: document.querySelector("#directory-files").multiple, webkitdirectory: document.querySelector("#directory-files").hasAttribute("webkitdirectory"), directory: document.querySelector("#directory-files").hasAttribute("directory") - } + }, + drop_target: document.querySelector("#drop-target").id }) """ }, @@ -151,7 +152,8 @@ defmodule DesktopWebview.E2ETest do "multiple" => true, "webkitdirectory" => true, "directory" => true - } + }, + "drop_target" => "drop-target" }} = Jason.decode(result) assert {:ok, true} = Transport.call("window.destroy", %{"window_id" => wid}) diff --git a/test/fixtures/file_input.html b/test/fixtures/file_input.html index 12aea94..c28fab1 100644 --- a/test/fixtures/file_input.html +++ b/test/fixtures/file_input.html @@ -25,9 +25,11 @@

HTML file input fixture

+

Drop files here to test file-manager drag-and-drop.