Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="file">` 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
Expand Down
17 changes: 17 additions & 0 deletions docs/porting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

`<input type="file">` 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/`)
Expand Down Expand Up @@ -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`.
Expand Down
30 changes: 28 additions & 2 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

`<input type="file">` 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,
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/status/linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type=file>` | 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 |
3 changes: 2 additions & 1 deletion docs/status/macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type=file>` | 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` |
Expand Down
3 changes: 2 additions & 1 deletion docs/status/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type=file>` | 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 |
Expand Down
12 changes: 12 additions & 0 deletions native/linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<input type="file">`. 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 |
Expand Down
10 changes: 10 additions & 0 deletions native/macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ Output is copied to `priv/native/macos/DesktopWebView` (universal when both arch
# prints: listening <port>
```

## HTML file inputs

`<input type="file">` 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 |
Expand Down
15 changes: 15 additions & 0 deletions native/macos/Sources/DesktopWebView/WebWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions native/windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

- `<input type="file">` selects one file by default.
- `<input type="file" multiple>` 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 |
Expand Down
94 changes: 90 additions & 4 deletions test/e2e/e2e_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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", %{
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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
42 changes: 42 additions & 0 deletions test/fixtures/file_input.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>EDW File Input Fixture</title>
</head>
<body>
<h1>HTML file input fixture</h1>
<p id="status">ready</p>
<p>Use this page to check single-file, multiple-file, and directory selection.</p>

<form>
<label>
Single file
<input id="single-file" type="file" accept=".txt,text/plain" />
</label>
<br />
<label>
Multiple files
<input id="multiple-files" type="file" multiple />
</label>
<br />
<label>
Directory
<input id="directory-files" type="file" multiple webkitdirectory directory />
</label>
</form>

<script>
const status = document.getElementById("status");

for (const input of document.querySelectorAll('input[type="file"]')) {
input.addEventListener("change", () => {
status.textContent = `${input.id}:changed`;
});
input.addEventListener("cancel", () => {
status.textContent = `${input.id}:cancelled`;
});
}
</script>
</body>
</html>
Loading