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.