diff --git a/README.md b/README.md index 2a7989e..e12d6bc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ spawns the BEAM release; in development an existing BEAM can launch the host wit | Windows | WebView2 | Done — [docs/status/windows.md](docs/status/windows.md) | | Linux | WebKitGTK | Usable — [docs/status/linux.md](docs/status/linux.md) | +HTML `` support is required on all three hosts. The shared E2E +checks the fixture's DOM contract; native picker selection remains a manual check. +It is separate from the `dialog.choose_file` RPC. See [docs/protocol.md](docs/protocol.md). + ## Quick start (development) ```elixir diff --git a/docs/porting.md b/docs/porting.md index 60f4937..250163b 100644 --- a/docs/porting.md +++ b/docs/porting.md @@ -44,6 +44,22 @@ Do **not** copy macOS UI code into other platforms — share only the protocol. 10. **Packaged BEAM spawn** + **CI artifact** on tag draft releases 11. **Test RPC** behind `--edw-test-rpc`; run shared E2E +## HTML file chooser + +`` is required on every platform. It is separate from +`dialog.choose_file`, which is an explicit Elixir RPC. Follow the semantics in +[protocol.md](protocol.md) and use the platform hook below. + +| Platform | Hook | Required integration | +|----------|------|----------------------| +| macOS | `WKUIDelegate.webView(_:runOpenPanelWith:initiatedByFrame:completionHandler:)` | Map `WKOpenPanelParameters` to the native panel. Pass selected URLs to the completion handler, or `nil` on cancel. | +| Windows | WebView2's built-in file picker | Keep the WebView2 UI thread and message loop active. WebView2 has no native file-chooser event for this input; do not replace it with `dialog.choose_file` or CDP file injection. | +| Linux | WebKitGTK `run-file-chooser` default handler | Keep WebKitGTK's asynchronous default handler enabled, or provide an equivalent handler that completes the request with selected paths or cancellation. | + +The shared E2E checks the fixture's DOM contract only. It cannot drive a native +picker or inject a `FileList`; selection, cancellation, multiple files, and +directory selection need manual checks until a supported platform test hook exists. + ## Toolchain expectations ### Windows (`native/windows/`) @@ -95,6 +111,7 @@ Before flipping a status row to `done`, the corresponding E2E (or an added E2E) | Menu / tray / icon / notification | `menu create and notification` | | Session reset | `session reset wipes tray and windows` | | Permissions + JS eval | `permission policy and simulate` | +| HTML file input DOM contract | `HTML file input fixture exposes chooser semantics` | | Locale / OS string | `system locale and os_description` | Platform-specific asserts (e.g. `caps["platform"] == "macos"`) must be generalized when the second host lands — use `:os.type()` / host `initialize.platform`. diff --git a/docs/protocol.md b/docs/protocol.md index cca0b76..bd6589d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -129,6 +129,31 @@ section disagree, **fix the host** and keep this section as the contract. - `webview.rebuild` replaces the engine view inside the same window and returns a **new** `webview_id` (old id becomes invalid). +### HTML file inputs + +`` is a required webview feature on macOS, Windows, and Linux. +It is distinct from `dialog.choose_file`: the former is started by web content and +populates the browser's `FileList`; the latter is an explicit Elixir RPC that +returns a path. + +Every host MUST provide the web engine's native file chooser path and preserve +these semantics: + +- Without `multiple`, the chooser returns at most one file. +- With `multiple`, it returns every selected file. +- With `webkitdirectory` (and the engine's directory mode), the user selects a + directory and the webview receives its files recursively, not one directory + path. +- Cancel completes with no new selection. It does not clear the current + selection or report a new `change` event. +- `accept` filters should be passed to the native chooser when the engine + exposes them. Applications still validate selected files. + +The platform hooks for this behavior are listed in [porting.md](porting.md). +The shared E2E may inspect the fixture's DOM, but it cannot select files or +populate a `FileList` through JavaScript. Native picker selection and cancellation +remain manual checks until a supported platform test hook exists. + ### Menus and tray - The macOS host installs a default `Edit` submenu on the main menu (Undo, Redo, @@ -275,8 +300,9 @@ Events: `event.menu.click` (`menu_id`, `onclick`), `event.tray.click`. | `dialog.choose_directory` | `title?`, `default_path?` | `{path}` or `null` | | `dialog.prompt` | `title`, `message`, `default_value?` | `{value}` or `null` | -macOS: `NSOpenPanel` / `NSAlert`. Linux/Windows: may return error `-32004` until ported. -AppKit dialogs run on the host main thread and block the RPC until dismissed. +macOS: `NSOpenPanel` / `NSAlert`; Windows: `IFileOpenDialog` / Win32 prompt. +Linux returns error `-32004` until ported. These RPCs do not implement HTML file +inputs. AppKit dialogs run on the host main thread and block the RPC until dismissed. ### Notification / media / system diff --git a/docs/status/linux.md b/docs/status/linux.md index ada799e..887323f 100644 --- a/docs/status/linux.md +++ b/docs/status/linux.md @@ -32,6 +32,7 @@ Host: GTK 4 + WebKitGTK 6 (`native/linux/`). Binary delivery via GitHub Releases | Permission policy hybrid | done | | | Microphone in webview | done | E2E via test RPC + fixture | | Camera in webview | done | E2E via test RPC + fixture | +| HTML `` | partial | WebKitGTK's asynchronous `run-file-chooser` default handler; native selection and cancellation checks pending | | Test RPC channel | done | `--edw-test-rpc` | | Release artifact download | todo | | | CI build | done | ubuntu-latest + xvfb | diff --git a/docs/status/macos.md b/docs/status/macos.md index 5593a77..864a5d1 100644 --- a/docs/status/macos.md +++ b/docs/status/macos.md @@ -32,7 +32,8 @@ manual-only with justification). | Permission policy hybrid | done | | | Microphone in webview | done | E2E via test RPC + fixture | | Camera in webview | done | E2E via test RPC + fixture | -| Dialog choose file/dir | done | `NSOpenPanel` (manual; blocks RPC) | +| Native dialogs (`dialog.choose_file/dir`) | done | `NSOpenPanel` (manual; blocks RPC) | +| HTML `` | partial | `WKUIDelegate` open-panel hook; native selection and cancellation 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/docs/status/windows.md b/docs/status/windows.md index 7556a18..c8783bc 100644 --- a/docs/status/windows.md +++ b/docs/status/windows.md @@ -32,7 +32,8 @@ Release asset: `DesktopWebView-windows-x64.exe` (GitHub Releases; not Hex `priv/ | Permission policy hybrid | done | E2E simulate + policy | | Microphone in webview | done | Permission RPC + WebView2 kinds | | Camera in webview | done | Permission RPC + WebView2 kinds | -| Native dialogs | done | IFileOpenDialog + Win32 prompt | +| Native dialogs (`dialog.choose_file/dir`) | done | IFileOpenDialog + Win32 prompt | +| HTML `` | partial | WebView2's built-in picker; native selection and cancellation checks pending | | Host-driven BEAM restart | done | `restart_beam` ini + process wait | | Test RPC channel | done | E2E | | Release artifact download | todo | Elixir fetch/cache still pending | diff --git a/native/linux/README.md b/native/linux/README.md index aa7a93d..174a1b9 100644 --- a/native/linux/README.md +++ b/native/linux/README.md @@ -46,6 +46,18 @@ Release artifact name: `DesktopWebView-linux-x86_64` (see [packaging.md](../../d Headless CI needs a display (`xvfb-run` or a pre-set `DISPLAY`). +## HTML file inputs + +WebKitGTK's asynchronous `run-file-chooser` default handler serves +``. It handles single files, multiple files, directories, +and cancellation without using the `dialog.choose_file` RPC. + +The shared E2E checks the fixture DOM, but it cannot drive a native picker. +Manually verify selection and cancellation with +`test/fixtures/file_input.html`. The chooser uses the GTK desktop portal when +the desktop session provides one, so the host needs an interactive display for +manual checks. + ## Structure | Path | Role | diff --git a/native/macos/README.md b/native/macos/README.md index 77f6bf6..4cea905 100644 --- a/native/macos/README.md +++ b/native/macos/README.md @@ -21,6 +21,16 @@ Output is copied to `priv/native/macos/DesktopWebView` (universal when both arch # prints: listening ``` +## HTML file inputs + +`` uses the `WKUIDelegate` open-panel callback in +`WebWindow.swift`. The callback maps single, multiple, directory, and cancel +actions to WebKit's `FileList`. + +The shared E2E checks the fixture DOM, but it cannot drive the macOS picker. +Manually verify selection and cancellation with +`test/fixtures/file_input.html`, including after `webview.rebuild`. + ## Structure | File | Role | diff --git a/native/macos/Sources/DesktopWebView/WebWindow.swift b/native/macos/Sources/DesktopWebView/WebWindow.swift index dabbb9e..f2b433b 100644 --- a/native/macos/Sources/DesktopWebView/WebWindow.swift +++ b/native/macos/Sources/DesktopWebView/WebWindow.swift @@ -106,6 +106,21 @@ final class WebWindowController: NSObject, NSWindowDelegate, WKUIDelegate, WKNav return nil } + func webView( + _ webView: WKWebView, + runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = parameters.allowsDirectories + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.beginSheetModal(for: window) { response in + completionHandler(response == .OK ? panel.urls : nil) + } + } + func webView( _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, diff --git a/native/windows/README.md b/native/windows/README.md index 645ea32..940bfa0 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -33,6 +33,25 @@ $env:DESKTOP_WEBVIEW_BINARY = "$PWD\priv\native\windows\DesktopWebView.exe" mix test.e2e ``` +## HTML file inputs + +Normal HTML file inputs use WebView2's built-in Windows file picker: + +- `` selects one file by default. +- `` can select multiple files. +- If the user cancels, the input receives no new files and the page receives no new selection. +- No host C++ change, JSON-RPC call, CDP interception, or custom picker is needed. The page reads selected files through the normal HTML `input.files` API. + +The picker needs an interactive Windows desktop and a working Microsoft Edge +WebView2 Evergreen Runtime. It cannot show from a service, a headless run, or a +non-interactive session. Runtime or Windows security restrictions can also +prevent native UI from appearing, so test with a current runtime in a normal +desktop session. + +This browser feature is separate from the [`dialog.choose_file` JSON-RPC +method](../../docs/protocol.md#dialog). That method is an explicit host dialog +request for code that needs a path returned through the Elixir transport. + ## Structure | Path | Role | diff --git a/test/e2e/e2e_test.exs b/test/e2e/e2e_test.exs index faccb74..d1427c9 100644 --- a/test/e2e/e2e_test.exs +++ b/test/e2e/e2e_test.exs @@ -71,8 +71,7 @@ defmodule DesktopWebview.E2ETest do "height" => 480 }) - html = File.read!(Path.expand("test/fixtures/media.html")) - url = "data:text/html;charset=utf-8," <> URI.encode(html) + url = fixture_url("media.html") assert {:ok, true} = Transport.call("webview.load_url", %{"webview_id" => vid, "url" => url}) # Give the engine a moment @@ -99,6 +98,65 @@ defmodule DesktopWebview.E2ETest do assert {:ok, true} = Transport.call("window.destroy", %{"window_id" => wid}) end + test "HTML file input fixture exposes chooser semantics" do + assert {:ok, %{"window_id" => wid, "webview_id" => vid}} = + Transport.call("window.open", %{ + "title" => "File input", + "width" => 640, + "height" => 480 + }) + + assert {:ok, true} = + Transport.call("webview.load_url", %{ + "webview_id" => vid, + "url" => fixture_url("file_input.html") + }) + + assert :ok = wait_for_file_input(vid) + + # The shared test RPC can inspect DOM state, but cannot drive a native picker. + assert {:ok, result} = + Transport.call( + "test.webview.eval", + %{ + "webview_id" => vid, + "script" => """ + JSON.stringify({ + single: { + type: document.querySelector("#single-file").type, + multiple: document.querySelector("#single-file").multiple + }, + multiple: { + type: document.querySelector("#multiple-files").type, + multiple: document.querySelector("#multiple-files").multiple + }, + directory: { + type: document.querySelector("#directory-files").type, + multiple: document.querySelector("#directory-files").multiple, + webkitdirectory: document.querySelector("#directory-files").hasAttribute("webkitdirectory"), + directory: document.querySelector("#directory-files").hasAttribute("directory") + } + }) + """ + }, + 15_000 + ) + + assert {:ok, + %{ + "single" => %{"type" => "file", "multiple" => false}, + "multiple" => %{"type" => "file", "multiple" => true}, + "directory" => %{ + "type" => "file", + "multiple" => true, + "webkitdirectory" => true, + "directory" => true + } + }} = Jason.decode(result) + + assert {:ok, true} = Transport.call("window.destroy", %{"window_id" => wid}) + end + test "window iconize raise hide show does not crash host" do assert {:ok, %{"window_id" => wid}} = Transport.call("window.open", %{ @@ -243,8 +301,7 @@ defmodule DesktopWebview.E2ETest do assert {:ok, %{"window_id" => _wid, "webview_id" => vid}} = Transport.call("window.open", %{"title" => "Media", "width" => 400, "height" => 300}) - html = File.read!(Path.expand("test/fixtures/media.html")) - url = "data:text/html;charset=utf-8," <> URI.encode(html) + url = fixture_url("media.html") assert {:ok, true} = Transport.call("webview.load_url", %{"webview_id" => vid, "url" => url}) @@ -335,4 +392,33 @@ defmodule DesktopWebview.E2ETest do Transport.call("test.menu.list", %{}) end end + + defp fixture_url(filename) do + html = File.read!(Path.expand("test/fixtures/#{filename}")) + "data:text/html;base64," <> Base.encode64(html) + end + + defp wait_for_file_input(webview_id, attempts \\ 30) + + defp wait_for_file_input(_webview_id, 0), do: :error + + defp wait_for_file_input(webview_id, attempts) do + ready? = + case Transport.call("test.webview.eval", %{ + "webview_id" => webview_id, + "script" => + "document.readyState === \"complete\" && " <> + "document.querySelector(\"#single-file\") !== null" + }) do + {:ok, true} -> true + _ -> false + end + + if ready? do + :ok + else + Process.sleep(100) + wait_for_file_input(webview_id, attempts - 1) + end + end end diff --git a/test/fixtures/file_input.html b/test/fixtures/file_input.html new file mode 100644 index 0000000..12aea94 --- /dev/null +++ b/test/fixtures/file_input.html @@ -0,0 +1,42 @@ + + + + + EDW File Input Fixture + + +

HTML file input fixture

+

ready

+

Use this page to check single-file, multiple-file, and directory selection.

+ +
+ +
+ +
+ +
+ + + +