diff --git a/docs/WebView.md b/docs/WebView.md
index 170707f..649dfad 100644
--- a/docs/WebView.md
+++ b/docs/WebView.md
@@ -8,7 +8,7 @@ Unlike other widgets, WebView does **not** extend `AbstractWidget`. It creates a
---
-### How It Works
+## How It Works
```
┌──────────────┐ JSON/stdio ┌──────────────────┐
@@ -20,17 +20,17 @@ Unlike other widgets, WebView does **not** extend `AbstractWidget`. It creates a
┌───────┼───────┐
│ │ │
WebKitGTK WKWebView WebView2
- (Linux) (macOS) (Windows)
+ (Linux) (macOS) (Windows)
```
-- **Commands** (JS → PHP): JavaScript calls PHP functions via `invoke()`, PHP returns values asynchronously.
-- **Events** (PHP → JS): PHP pushes data to the frontend via `emit()`, JavaScript listens with `onPhpEvent()`.
+- **Commands** (JS → PHP): JavaScript calls PHP functions via `invoke()`. PHP returns values asynchronously.
+- **Events** (PHP → JS): PHP pushes data to the frontend via `emit()`. JavaScript listens with `onPhpEvent()`.
The helper binary is **auto-downloaded** on first use — no manual build steps required.
---
-### Requirements
+## Requirements
- PHP 8.1+ with `ext-ffi`
- **Linux**: WebKitGTK (`sudo apt install libwebkit2gtk-4.1-dev`)
@@ -39,83 +39,88 @@ The helper binary is **auto-downloaded** on first use — no manual build steps
---
-### Constructor
+## Constructor
```php
new WebView(array $options = [])
```
-| Option | Type | Default | Description |
-|----------|----------|--------------|----------------------------------------|
-| `title` | `string` | `'WebView'` | Window title |
-| `width` | `int` | `800` | Window width in pixels |
-| `height` | `int` | `600` | Window height in pixels |
-| `url` | `string` | `null` | URL to navigate to on startup |
-| `html` | `string` | `null` | Raw HTML content to display on startup |
-| `debug` | `bool` | `false` | Enable DevTools / inspector |
+| Option | Type | Default | Description |
+|----------|----------|--------------|-----------------------------------------|
+| `title` | `string` | `'WebView'` | Window title |
+| `width` | `int` | `800` | Window width in pixels |
+| `height` | `int` | `600` | Window height in pixels |
+| `url` | `string` | `null` | URL to navigate to on startup |
+| `html` | `string` | `null` | Raw HTML content to display on startup |
+| `debug` | `bool` | `false` | Enable DevTools / browser inspector |
---
-### Methods
+## Methods
-#### Content
+### Content
-| Method | Description |
-|-------------------------------|--------------------------------------|
-| `navigate(string $url)` | Navigate to a URL |
-| `setHtml(string $html)` | Set the page to raw HTML content |
+| Method | Description |
+|----------------------------------|----------------------------------------|
+| `navigate(string $url)` | Navigate to a URL |
+| `setHtml(string $html)` | Replace the page with raw HTML content |
+| `serveFromDisk(string $path)` | Serve a built frontend with no HTTP server — see [Serving a Frontend](#serving-a-frontend) |
+| `serveVite(string $buildDir, string $devUrl, float $timeout)` | Auto-detect dev vs production — see [Vite Integration](#vite-integration) |
+| `serveDirectory(string $path, int $port)` | Serve a directory via PHP's built-in HTTP server |
-#### Window
+### Window
-| Method | Description |
-|--------------------------------------------------|----------------------------------------------------------------|
-| `setTitle(string $title)` | Change the window title |
-| `setSize(int $w, int $h, int $hint = 0)` | Resize the window. Hint: `0` = none, `1` = min, `2` = max, `3` = fixed |
+| Method | Description |
+|---------------------------------------------|-------------------------------------------------------------------------|
+| `setTitle(string $title)` | Change the window title |
+| `setSize(int $w, int $h, int $hint = 0)` | Resize the window. Hint: `0`=none, `1`=min, `2`=max, `3`=fixed |
-#### JavaScript
+### JavaScript
-| Method | Description |
-|----------------------------|----------------------------------------------------|
-| `evalJs(string $js)` | Execute JavaScript in the webview |
-| `initJs(string $js)` | Inject JS that runs automatically before each page load |
+| Method | Description |
+|-----------------------------|-------------------------------------------------------|
+| `evalJs(string $js)` | Execute JavaScript in the webview |
+| `initJs(string $js)` | Inject JS that runs automatically before each page load |
-#### Commands (JS → PHP)
+### Commands (JS → PHP)
-| Method | Description |
-|--------------------------------------------------------|------------------------------------------------------|
-| `bind(string $name, callable $callback)` | Register a PHP function callable from JavaScript |
-| `unbind(string $name)` | Remove a binding |
-| `returnValue(string $id, int $status, string $result)` | Return a value to JS. Status `0` = success, non-zero = error |
+| Method | Description |
+|---------------------------------------------------------|-----------------------------------------------------------|
+| `bind(string $name, callable $callback)` | Register a PHP function callable from JavaScript |
+| `unbind(string $name)` | Remove a binding |
+| `returnValue(string $id, int $status, string $result)` | Return a value to JS. Status `0` = resolve, non-zero = reject |
-#### Events (PHP → JS)
+### Events (PHP → JS)
-| Method | Description |
-|--------------------------------------------|------------------------------------------|
-| `emit(string $event, mixed $payload = null)` | Send an event to the JavaScript frontend |
+| Method | Description |
+|----------------------------------------------|------------------------------------------|
+| `emit(string $event, mixed $payload = null)` | Push an event to the JavaScript frontend |
-#### Lifecycle
+### Lifecycle
-| Method | Description |
-|-----------------------------------|------------------------------------------|
-| `onReady(callable $callback)` | Called when the webview is ready |
-| `onClose(callable $callback)` | Called when the webview is closed |
-| `onError(callable $callback)` | Called on error (fallback: `error_log`) |
-| `destroy()` | Close the webview and terminate helper |
-| `isReady(): bool` | Check if the webview is ready |
-| `isClosed(): bool` | Check if the webview has been closed |
-| `getId(): string` | Get the unique instance ID |
+| Method | Description |
+|----------------------------------------|-----------------------------------------------------------------------|
+| `onReady(callable $callback)` | Called when the webview window is ready |
+| `onClose(callable $callback)` | Called when the webview window is closed by the user |
+| `onError(callable $callback)` | Called on IPC-level errors (fallback: `error_log`) |
+| `onServeDirReady(callable $callback)` | Called when `serveFromDisk()` or `serveVite()` (prod mode) is loaded — receives the effective URL |
+| `destroy()` | Close the window and terminate the helper process |
+| `isReady(): bool` | Whether the webview is ready |
+| `isClosed(): bool` | Whether the webview has been closed |
+| `getId(): string` | Unique instance ID |
+| `getServerPort(): ?int` | Port used by `serveDirectory()`, or `null` |
---
-### JavaScript Bridge API
+## JavaScript Bridge API
-These functions are automatically available inside the webview:
+These functions are automatically injected into every page:
```javascript
-// Call a PHP-bound function (returns a Promise)
+// Call a PHP-bound function — returns a Promise
invoke('functionName', arg1, arg2, ...)
-// Listen for events from PHP
+// Listen for events emitted by PHP
onPhpEvent('eventName', function(payload) {
console.log(payload);
})
@@ -123,9 +128,142 @@ onPhpEvent('eventName', function(payload) {
---
-### Examples
+## Serving a Frontend
-#### Minimal WebView
+For production builds (e.g., a Vite app), load the frontend directly from disk — no HTTP server, no open ports, no firewall prompts.
+
+### `serveFromDisk(string $path)`
+
+Serves a built frontend using a platform-native mechanism:
+
+| Platform | Mechanism | URL |
+|----------|----------------------------------------|-----------------------------------------|
+| Linux | `phpgui://` custom URI scheme (WebKitGTK) | `phpgui://app/index.html` |
+| Windows | Virtual hostname (WebView2) | `https://phpgui.localhost/index.html` |
+| macOS | File URL with directory read access | `file:///path/to/dist/index.html` |
+
+Linux and Windows have SPA-friendly origins — absolute asset paths (`/assets/index.js`) work correctly. **On macOS**, WKWebView loads via `file://`, which requires relative asset paths. Set `base: './'` in your `vite.config.js`:
+
+```js
+// vite.config.js
+export default {
+ base: './', // required for macOS file:// serving
+}
+```
+
+Use `onServeDirReady()` to be notified of the effective URL after the content loads:
+
+```php
+$wv->onServeDirReady(function (string $url): void {
+ echo "Serving from: {$url}\n";
+ // Linux: "phpgui://app/index.html"
+ // macOS: "file:///Users/you/app/dist/index.html"
+ // Windows: "https://phpgui.localhost/index.html"
+});
+
+$wv->serveFromDisk(__DIR__ . '/dist');
+```
+
+**Requires** a directory containing `index.html`. Throws `RuntimeException` if the path is invalid or `index.html` is missing.
+
+---
+
+## Vite Integration
+
+### `serveVite(string $buildDir, string $devUrl = 'http://localhost:5173', float $timeout = 0.3)`
+
+Smart frontend serving that automatically switches between dev and production:
+
+- **Dev**: If the Vite dev server is reachable at `$devUrl`, navigate to it. Hot Module Replacement (HMR) works.
+- **Prod**: If the dev server is not reachable, call `serveFromDisk($buildDir)`. No HTTP server, no ports.
+
+```php
+// One line — works in dev and production
+$wv->serveVite(__DIR__ . '/../frontend/dist');
+```
+
+With a custom dev URL:
+
+```php
+$wv->serveVite(
+ buildDir: __DIR__ . '/../frontend/dist',
+ devUrl: 'http://localhost:5174', // custom Vite port
+);
+```
+
+The dev server detection uses a lightweight TCP probe with a configurable timeout. A timeout of `0.3` seconds (default) adds no perceptible startup delay.
+
+**Recommended Vite config** for cross-platform compatibility:
+
+```js
+// vite.config.js
+export default {
+ base: './', // required for macOS production builds (file:// serving)
+ build: {
+ outDir: 'dist',
+ },
+}
+```
+
+---
+
+## Transparent Fetch Proxy
+
+### `enableFetchProxy()`
+
+Intercepts all `window.fetch()` calls to absolute `http://` and `https://` URLs and routes them through PHP, **bypassing CORS entirely**.
+
+Without this, cross-origin API requests fail on all platforms:
+
+| Platform | Origin seen by external APIs | fetch() to API |
+|----------|------------------------------|----------------|
+| Linux | `phpgui://app` | Blocked |
+| macOS | `null` (file://) | Blocked |
+| Windows | `https://phpgui.localhost` | Blocked |
+
+After calling `enableFetchProxy()`, the same `fetch()` calls succeed transparently — PHP makes the HTTP request server-side and returns the result to JS as a proper `Response` object.
+
+```php
+$wv = new WebView(['title' => 'My App', ...]);
+$wv->enableFetchProxy(); // ← one line
+$wv->serveFromDisk(__DIR__ . '/dist');
+```
+
+```js
+// Frontend code — unchanged, works on all platforms
+const res = await fetch('https://api.example.com/data');
+const data = await res.json();
+```
+
+**How it works:**
+
+```
+JS: fetch('https://api.example.com/data')
+ └─ interceptor: absolute http(s) URL detected
+ └─ invoke('__phpFetch', { url, method, headers, body })
+ └─ PHP: cURL (or stream_context fallback) makes the real request
+ └─ returns { status, headers, body (base64) }
+ └─ JS: new Response(decodedBody, { status, headers })
+```
+
+**What is proxied:**
+- Any `fetch()` call to an absolute `http://` or `https://` URL
+
+**What passes through natively:**
+- Relative URLs (`/api/data`, `./config.json`)
+- Same-origin asset URLs (`phpgui://`, `file://`, `https://phpgui.localhost`)
+
+**Notes:**
+- Uses cURL when available, falls back to PHP stream context
+- Response body is base64-encoded over the IPC bridge for binary safety — binary responses (images, PDFs) work correctly
+- `enableFetchProxy()` can safely be called multiple times
+- Call it before `serveFromDisk()` / `serveVite()` so the JS interceptor is injected on the first page load
+
+---
+
+## Examples
+
+### Minimal WebView
```php
'Hello WebView',
- 'width' => 800,
+ 'title' => 'Hello WebView',
+ 'width' => 800,
'height' => 600,
]);
$wv->setHtml('
Hello from PHP!
');
-
$wv->onClose(fn() => $app->quit());
+
$app->addWebView($wv);
$app->run();
```
-#### Load a URL
+### Load a URL
```php
$wv = new WebView(['title' => 'Browser']);
@@ -158,57 +296,104 @@ $wv->navigate('https://example.com');
$app->addWebView($wv);
```
-#### Commands: Calling PHP from JavaScript
+### Commands: Calling PHP from JavaScript
-Bind PHP functions that JavaScript can call. Each bound function receives a request ID and JSON-encoded arguments.
+Bind PHP functions that JavaScript can call. Each callback receives a request ID and the JSON-encoded argument list.
-**PHP (backend):**
+**PHP:**
```php
-$wv->bind('greet', function (string $reqId, string $args) use ($wv) {
+$wv->bind('greet', function (string $reqId, string $args) use ($wv): void {
$data = json_decode($args, true);
$name = $data[0] ?? 'World';
-
$wv->returnValue($reqId, 0, json_encode("Hello, {$name}!"));
});
```
-**JavaScript (frontend):**
+**JavaScript:**
```javascript
const message = await invoke('greet', 'Alice');
console.log(message); // "Hello, Alice!"
```
-> Always call `returnValue()` inside your bind callback. Status `0` resolves the JS Promise, non-zero rejects it.
+> Always call `returnValue()` inside your bind callback. Skipping it leaves the JS Promise pending indefinitely.
-#### Events: Pushing Data from PHP to JavaScript
+### Events: Pushing Data from PHP to JavaScript
-**PHP (backend):**
+**PHP:**
```php
$wv->emit('userUpdated', ['name' => 'Alice', 'role' => 'admin']);
```
-**JavaScript (frontend):**
+**JavaScript:**
```javascript
onPhpEvent('userUpdated', function (user) {
document.getElementById('name').textContent = user.name;
});
```
-#### Inject Startup JavaScript
+### Vite App: Dev + Production in One File
-Use `initJs()` to inject JS that runs before every page load — useful for configuration or polyfills:
+A complete setup that hot-reloads in development and serves from disk in production:
+**PHP (`app.php`):**
```php
-$wv->initJs('
- window.AppConfig = { version: "1.0.0", debug: true };
-');
+ 'My App', 'width' => 1024, 'height' => 768]);
+
+// Transparent fetch() proxy — required for cross-origin API calls
+$wv->enableFetchProxy();
+
+// Dev: navigate to Vite dev server (HMR)
+// Prod: serve dist/ via phpgui:// custom URI scheme (no HTTP server)
+$wv->serveVite(__DIR__ . '/frontend/dist');
+
+$wv->onServeDirReady(function (string $url): void {
+ echo "Serving from: {$url}\n";
+});
+
+// JS → PHP: proxy an API call
+$wv->bind('getUser', function (string $id, string $args) use ($wv): void {
+ $userId = json_decode($args, true)[0] ?? 1;
+ // fetch() from JS would be blocked by CORS — PHP has no such restriction
+ $data = file_get_contents("https://jsonplaceholder.typicode.com/users/{$userId}");
+ $wv->returnValue($id, 0, $data);
+});
+
+$wv->onClose(fn() => $app->quit());
+$app->addWebView($wv);
+$app->run();
```
-#### Full Example: Todo App
+**JavaScript (frontend):**
+```javascript
+// Works identically in dev and production — no code changes needed
+const res = await fetch('https://jsonplaceholder.typicode.com/todos/1');
+const todo = await res.json();
-This example shows all the key patterns — commands, events, lifecycle hooks, and HTML rendering:
+const user = await invoke('getUser', 1);
+console.log(user);
+```
+
+**`vite.config.js`:**
+```js
+export default {
+ base: './', // required for macOS production builds
+ build: {
+ outDir: 'dist',
+ },
+}
+```
+
+### Todo App
+
+A complete example showing commands, events, lifecycle hooks, and HTML rendering:
-**PHP:**
```php
'Todo App',
- 'width' => 900,
- 'height' => 700,
- 'debug' => true,
-]);
-
+$app = new Application();
+$wv = new WebView(['title' => 'Todo App', 'width' => 900, 'height' => 700, 'debug' => true]);
$todos = [];
-// JS → PHP: Get all todos
-$wv->bind('getTodos', function (string $reqId, string $args) use ($wv, &$todos) {
- $wv->returnValue($reqId, 0, json_encode($todos));
+$wv->bind('getTodos', function (string $id, string $args) use ($wv, &$todos): void {
+ $wv->returnValue($id, 0, json_encode($todos));
});
-// JS → PHP: Add a todo
-$wv->bind('addTodo', function (string $reqId, string $args) use ($wv, &$todos) {
- $data = json_decode($args, true);
- $text = trim($data[0] ?? '');
+$wv->bind('addTodo', function (string $id, string $args) use ($wv, &$todos): void {
+ $text = trim(json_decode($args, true)[0] ?? '');
if ($text !== '') {
$todos[] = ['id' => uniqid(), 'text' => $text, 'completed' => false];
- $wv->emit('todosUpdated', $todos); // PHP → JS
+ $wv->emit('todosUpdated', $todos);
}
- $wv->returnValue($reqId, 0, json_encode(true));
+ $wv->returnValue($id, 0, json_encode(true));
});
-// JS → PHP: Toggle completion
-$wv->bind('toggleTodo', function (string $reqId, string $args) use ($wv, &$todos) {
- $id = json_decode($args, true)[0] ?? '';
- foreach ($todos as &$todo) {
- if ($todo['id'] === $id) {
- $todo['completed'] = !$todo['completed'];
- break;
- }
+$wv->bind('toggleTodo', function (string $id, string $args) use ($wv, &$todos): void {
+ $todoId = json_decode($args, true)[0] ?? '';
+ foreach ($todos as &$t) {
+ if ($t['id'] === $todoId) { $t['completed'] = !$t['completed']; break; }
}
$wv->emit('todosUpdated', $todos);
- $wv->returnValue($reqId, 0, json_encode(true));
+ $wv->returnValue($id, 0, json_encode(true));
});
$wv->setHtml('
Todos
-
-
+
+
');
@@ -312,70 +484,70 @@ $app->run();
---
-### Size Hints
+## Size Hints
-The `setSize()` method accepts a hint parameter:
+The `setSize()` hint controls the resize behaviour:
-| Value | Constant | Behavior |
-|-------|----------|-------------------------------------------------|
-| `0` | NONE | Default — user can resize freely |
-| `1` | MIN | Sets minimum size |
-| `2` | MAX | Sets maximum size |
-| `3` | FIXED | Fixed size — user cannot resize |
+| Value | Behaviour |
+|-------|------------------------------|
+| `0` | Default — freely resizable |
+| `1` | Minimum size |
+| `2` | Maximum size |
+| `3` | Fixed — not resizable |
```php
-$wv->setSize(1024, 768, 3); // Fixed size, not resizable
+$wv->setSize(1024, 768, 3); // Fixed, not resizable
```
---
-### Error Handling
+## Debug Mode
+
+```php
+$wv = new WebView(['debug' => true]);
+```
+
+- **Linux / macOS**: Right-click → Inspect Element
+- **Windows**: F12 or right-click → Inspect
+
+---
+
+## Error Handling
Exceptions thrown inside `bind()` callbacks are automatically caught and returned to JavaScript as rejected Promises:
```php
-$wv->bind('riskyOperation', function (string $reqId, string $args) use ($wv) {
- // If this throws, JS gets a rejected Promise with the error message
+$wv->bind('riskyOp', function (string $id, string $args) use ($wv): void {
$data = json_decode($args, true);
if (empty($data[0])) {
throw new \InvalidArgumentException('Missing required argument');
+ // JS: await invoke('riskyOp') rejects with "Missing required argument"
}
- $wv->returnValue($reqId, 0, json_encode('Success'));
+ $wv->returnValue($id, 0, json_encode('ok'));
});
```
-Register a global error handler for IPC-level errors:
+Register a global handler for IPC-level errors:
```php
-$wv->onError(function (string $message) {
+$wv->onError(function (string $message): void {
error_log("WebView error: {$message}");
});
```
---
-### Debug Mode
+## Platform Notes
-Pass `'debug' => true` to enable the browser's built-in developer tools:
-
-```php
-$wv = new WebView(['debug' => true]);
-```
-
-- **Linux/macOS**: Right-click → Inspect Element
-- **Windows**: F12 or right-click → Inspect
+| Platform | Engine | Notes |
+|----------|-----------|--------------------------------------------------------------------|
+| Linux | WebKitGTK | Requires `libwebkit2gtk-4.1-dev`. Needs a display (X11/Wayland). |
+| macOS | WKWebView | Built-in, no extra dependencies. Set `base: './'` in Vite config for `serveFromDisk()`. |
+| Windows | WebView2 | Pre-installed on Windows 10/11. |
---
-### Platform Notes
-
-| Platform | Engine | Notes |
-|----------|-------------|---------------------------------------------------------|
-| Linux | WebKitGTK | Requires `libwebkit2gtk-4.1-dev`. Needs display (X11 or Wayland). |
-| macOS | WKWebView | Built-in, no extra dependencies. |
-| Windows | WebView2 | Pre-installed on Windows 10/11. Falls back to Edge. |
-
-### Helper Binary
+## Helper Binary
The WebView widget relies on a small native helper binary that hosts the browser engine. It is automatically downloaded from GitHub Releases on first use.
@@ -396,15 +568,15 @@ sudo apt install cmake libgtk-3-dev libwebkit2gtk-4.1-dev
brew install cmake
```
-**Windows:** Requires CMake and Visual Studio Build Tools.
+**Windows:** CMake + Visual Studio Build Tools.
---
-### Notes
+## Notes
- WebView does **not** extend `AbstractWidget` — it is a separate native window, not a Tcl/Tk widget.
-- Multiple WebView instances can run simultaneously, each in their own process.
-- The event loop in `Application::run()` polls all registered WebViews automatically.
-- Call `$app->addWebView($wv)` to register a WebView with the event loop.
-- Closing a WebView window triggers the `onClose` callback and auto-removes it from the event loop.
-- The helper binary is platform-specific and named `webview_helper_{os}_{arch}` (e.g., `webview_helper_linux_x86_64`).
+- Multiple WebView instances can run simultaneously, each with their own helper process.
+- Register a WebView with the event loop via `$app->addWebView($wv)`.
+- Closing the window triggers `onClose` and auto-removes the WebView from the event loop.
+- The helper binary is named `webview_helper_{os}_{arch}` (e.g., `webview_helper_linux_x86_64`).
+- `enableFetchProxy()` only intercepts absolute `http://`/`https://` URLs — relative paths and same-origin assets go through the native fetch unmodified.
diff --git a/src/Widget/WebView.php b/src/Widget/WebView.php
index 722bf26..383925a 100644
--- a/src/Widget/WebView.php
+++ b/src/Widget/WebView.php
@@ -20,6 +20,10 @@ class WebView
private string $id;
private bool $debug;
+ /** @var resource|null PHP built-in server process for serveDirectory() */
+ private $serverProcess = null;
+ private ?int $serverPort = null;
+
/** @var array JS→PHP command handlers: name => callback(string $id, string $args) */
private array $commandHandlers = [];
@@ -32,6 +36,9 @@ class WebView
/** @var callable|null */
private $onReadyCallback = null;
+ /** @var callable|null Called when serveFromDisk() is ready: fn(string $url) */
+ private $onServeDirReadyCallback = null;
+
/**
* @param array{
* title?: string,
@@ -79,6 +86,238 @@ public function setHtml(string $html): void
$this->process->sendCommand(['cmd' => 'set_html', 'html' => $html]);
}
+ /**
+ * Serve a directory via PHP's built-in web server and navigate to it.
+ *
+ * Ideal for loading production frontend builds (e.g., Vite's dist/ folder).
+ *
+ * @param string $path Path to the directory containing index.html
+ * @param int $port Port to use (0 = auto-pick a free port)
+ */
+ public function serveDirectory(string $path, int $port = 0): void
+ {
+ $path = realpath($path);
+ if (!$path || !is_dir($path)) {
+ throw new \RuntimeException("Directory not found: {$path}");
+ }
+ if (!file_exists($path . '/index.html')) {
+ throw new \RuntimeException("No index.html found in: {$path}");
+ }
+
+ // Auto-pick a free port
+ if ($port === 0) {
+ $sock = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
+ if (!$sock) {
+ throw new \RuntimeException("Could not find a free port: {$errstr}");
+ }
+ $addr = stream_socket_get_name($sock, false);
+ $port = (int) substr($addr, strrpos($addr, ':') + 1);
+ fclose($sock);
+ }
+
+ // Start PHP built-in server
+ $cmd = sprintf(
+ '%s -S 127.0.0.1:%d -t %s',
+ PHP_BINARY,
+ $port,
+ escapeshellarg($path)
+ );
+
+ $descriptors = [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ $this->serverProcess = proc_open($cmd, $descriptors, $pipes);
+ if (!is_resource($this->serverProcess)) {
+ throw new \RuntimeException("Failed to start local server on port {$port}");
+ }
+ $this->serverPort = $port;
+
+ // Close stdin, we don't need it
+ fclose($pipes[0]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+
+ // Wait for server to be ready (up to 3 seconds)
+ $deadline = microtime(true) + 3.0;
+ while (microtime(true) < $deadline) {
+ $fp = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1);
+ if ($fp) {
+ fclose($fp);
+ break;
+ }
+ usleep(50000); // 50ms
+ }
+
+ $this->navigate("http://127.0.0.1:{$port}");
+ }
+
+ /**
+ * Serve a directory directly via the native webview engine (no HTTP server).
+ *
+ * Uses platform-specific mechanisms to serve files without a local server:
+ * - Linux: phpgui:// custom URI scheme (WebKitGTK)
+ * - Windows: https://phpgui.localhost/ virtual host (WebView2)
+ * - macOS: file:// direct access (WKWebView) — requires Vite `base: './'`
+ *
+ * Includes SPA fallback on Linux (unknown paths serve index.html).
+ * For hash-based routing on macOS (e.g. createHashRouter in React Router).
+ *
+ * This is the recommended method for production builds — no ports,
+ * no firewall prompts, no extra processes.
+ *
+ * Listen for the effective URL via onServeDirReady().
+ *
+ * @param string $path Absolute path to directory containing index.html
+ */
+ public function serveFromDisk(string $path): void
+ {
+ $path = realpath($path);
+ if (!$path || !is_dir($path)) {
+ throw new \RuntimeException("Directory not found: {$path}");
+ }
+ if (!file_exists($path . '/index.html')) {
+ throw new \RuntimeException("No index.html found in: {$path}");
+ }
+
+ $this->process->sendCommand(['cmd' => 'serve_dir', 'path' => $path]);
+ }
+
+ /**
+ * Serve a Vite frontend: dev server in development, disk in production.
+ *
+ * - Dev: if the Vite dev server is reachable at $devUrl, navigate to it
+ * (supports HMR / hot reload).
+ * - Prod: if dev server is not reachable, serve $buildDir via
+ * serveFromDisk() — no HTTP server, no ports, no firewall prompts.
+ *
+ * Typical usage:
+ * $webview->serveVite(__DIR__ . '/../frontend/dist');
+ *
+ * For macOS compatibility, add `base: './'` to your vite.config.js.
+ *
+ * @param string $buildDir Absolute path to Vite build output (dist/ folder)
+ * @param string $devUrl Vite dev server URL (default: http://localhost:5173)
+ * @param float $timeout Seconds to wait when probing the dev server
+ */
+ public function serveVite(
+ string $buildDir,
+ string $devUrl = 'http://localhost:5173',
+ float $timeout = 0.3,
+ ): void {
+ $parts = parse_url($devUrl);
+ $host = $parts['host'] ?? 'localhost';
+ $port = $parts['port'] ?? (($parts['scheme'] ?? 'http') === 'https' ? 443 : 80);
+
+ $fp = @fsockopen($host, (int)$port, $errno, $errstr, $timeout);
+ if ($fp !== false) {
+ fclose($fp);
+ $this->navigate($devUrl);
+ return;
+ }
+
+ $this->serveFromDisk($buildDir);
+ }
+
+ /**
+ * Enable transparent fetch() proxying through PHP.
+ *
+ * Intercepts all window.fetch() calls to absolute http(s) URLs and routes
+ * them through PHP — bypassing CORS entirely, since PHP makes the request
+ * server-side. Same-origin requests (relative URLs, phpgui://, file://)
+ * are passed through to the native fetch unchanged.
+ *
+ * Solves the CORS problem on all platforms:
+ * - macOS: file:// origin is "null" — blocked by most APIs
+ * - Linux: phpgui:// origin unknown — not in any server's allowlist
+ * - Windows: https://phpgui.localhost — same issue
+ *
+ * Call this once after construction. Works with any frontend framework.
+ * The frontend code needs zero changes — fetch() behaves normally.
+ *
+ * Uses cURL if available, falls back to stream_context (file_get_contents).
+ * Response body is base64-encoded over the IPC bridge for binary safety.
+ *
+ * Example:
+ * $webview = new WebView([...]);
+ * $webview->enableFetchProxy(); // ← one line, fetch() just works
+ * $webview->serveFromDisk(__DIR__ . '/dist');
+ */
+ public function enableFetchProxy(): void
+ {
+ // PHP side: handle __phpFetch commands from JS
+ $this->bind('__phpFetch', function (string $id, string $args): void {
+ $params = json_decode($args, true);
+ $req = $params[0] ?? [];
+
+ $url = $req['url'] ?? '';
+ $method = strtoupper($req['method'] ?? 'GET');
+ $headers = $req['headers'] ?? [];
+ $body = $req['body'] ?? null;
+
+ if (function_exists('curl_init')) {
+ [$status, $resHeaders, $resBody] = $this->curlRequest($url, $method, $headers, $body);
+ } else {
+ [$status, $resHeaders, $resBody] = $this->streamRequest($url, $method, $headers, $body);
+ }
+
+ $this->returnValue($id, 0, json_encode([
+ 'status' => $status,
+ 'headers' => $resHeaders,
+ 'body' => base64_encode($resBody),
+ 'ok' => $status >= 200 && $status < 300,
+ ], JSON_UNESCAPED_SLASHES));
+ });
+
+ // JS side: override window.fetch for http(s) URLs only
+ $this->initJs(<<<'JS'
+(function () {
+ var _nativeFetch = window.fetch.bind(window);
+ window.fetch = function (input, init) {
+ var url = typeof input === 'string' ? input : input.url;
+ // Only intercept absolute http(s) — let same-origin assets through
+ if (!/^https?:\/\//i.test(url)) {
+ return _nativeFetch(input, init);
+ }
+ init = init || {};
+ var hdrs = {};
+ if (init.headers) {
+ if (typeof init.headers.forEach === 'function') {
+ init.headers.forEach(function (v, k) { hdrs[k] = v; });
+ } else {
+ hdrs = Object.assign({}, init.headers);
+ }
+ }
+ return window.invoke('__phpFetch', {
+ url: url,
+ method: (init.method || 'GET').toUpperCase(),
+ headers: hdrs,
+ body: (init.body != null ? String(init.body) : null),
+ }).then(function (r) {
+ // Decode base64 body back to binary
+ var raw = atob(r.body);
+ var bytes = new Uint8Array(raw.length);
+ for (var i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
+ return new Response(bytes.buffer, {
+ status: r.status,
+ headers: new Headers(r.headers),
+ });
+ });
+ };
+})();
+JS);
+ }
+
+ /**
+ * Get the port of the local server started by serveDirectory().
+ */
+ public function getServerPort(): ?int
+ {
+ return $this->serverPort;
+ }
+
/* ── Window ──────────────────────────────────────────────────────────── */
/**
@@ -186,6 +425,7 @@ public function emit(string $event, mixed $payload = null): void
*/
public function destroy(): void
{
+ $this->stopServer();
$this->process->close();
}
@@ -230,6 +470,21 @@ public function onReady(callable $callback): void
$this->onReadyCallback = $callback;
}
+ /**
+ * Register a callback for when serveFromDisk() / serveVite() has loaded.
+ *
+ * Receives the effective URL the webview navigated to:
+ * - Linux: "phpgui://app/index.html"
+ * - Windows: "https://phpgui.localhost/index.html"
+ * - macOS: "file:///path/to/dist/index.html"
+ *
+ * @param callable(string $url): void $callback
+ */
+ public function onServeDirReady(callable $callback): void
+ {
+ $this->onServeDirReadyCallback = $callback;
+ }
+
/* ── Event processing (called by Application::run() each tick) ───────── */
/**
@@ -269,6 +524,13 @@ public function processEvents(): void
}
break;
+ case 'serve_dir_ready':
+ if ($this->onServeDirReadyCallback) {
+ $url = $event['url'] ?? '';
+ ($this->onServeDirReadyCallback)($url);
+ }
+ break;
+
case 'pong':
// Health check response — could track last pong time
break;
@@ -302,8 +564,111 @@ private function handleCommand(array $event): void
public function __destruct()
{
+ $this->stopServer();
if (!$this->isClosed()) {
$this->destroy();
}
}
+
+ /**
+ * @return array{int, array, string} [status, headers, body]
+ */
+ private function curlRequest(string $url, string $method, array $headers, ?string $body): array
+ {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 10,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_HEADER => true,
+ ]);
+
+ if ($headers) {
+ $lines = [];
+ foreach ($headers as $k => $v) {
+ $lines[] = "{$k}: {$v}";
+ }
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $lines);
+ }
+
+ if ($body !== null) {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ }
+
+ $raw = (string)curl_exec($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+ curl_close($ch);
+
+ $resHeaders = [];
+ foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
+ if (str_contains($line, ':')) {
+ [$k, $v] = explode(':', $line, 2);
+ $resHeaders[trim($k)] = trim($v);
+ }
+ }
+
+ return [$status, $resHeaders, substr($raw, $headerSize)];
+ }
+
+ /**
+ * @return array{int, array, string} [status, headers, body]
+ */
+ private function streamRequest(string $url, string $method, array $headers, ?string $body): array
+ {
+ $opts = ['method' => $method, 'ignore_errors' => true, 'timeout' => 30];
+
+ if ($headers) {
+ $lines = [];
+ foreach ($headers as $k => $v) {
+ $lines[] = "{$k}: {$v}";
+ }
+ $opts['header'] = implode("\r\n", $lines);
+ }
+
+ if ($body !== null) {
+ $opts['content'] = $body;
+ }
+
+ $resBody = (string)@file_get_contents($url, false, stream_context_create(['http' => $opts]));
+ $status = 200;
+ $resHeaders = [];
+
+ // $http_response_header is set by file_get_contents in the local scope
+ $responseHeaders = $http_response_header ?? [];
+ if ($responseHeaders) {
+ if (preg_match('/HTTP\/[\d.]+\s+(\d+)/', $responseHeaders[0], $m)) {
+ $status = (int)$m[1];
+ }
+ foreach (array_slice($responseHeaders, 1) as $h) {
+ if (str_contains($h, ':')) {
+ [$k, $v] = explode(':', $h, 2);
+ $resHeaders[trim($k)] = trim($v);
+ }
+ }
+ }
+
+ return [$status, $resHeaders, $resBody];
+ }
+
+ private function stopServer(): void
+ {
+ if ($this->serverProcess && is_resource($this->serverProcess)) {
+ $status = proc_get_status($this->serverProcess);
+ if ($status['running']) {
+ // Kill the server process tree
+ $pid = $status['pid'];
+ if (PHP_OS_FAMILY === 'Windows') {
+ exec("taskkill /F /T /PID {$pid} 2>NUL");
+ } else {
+ exec("kill {$pid} 2>/dev/null");
+ }
+ }
+ proc_close($this->serverProcess);
+ $this->serverProcess = null;
+ $this->serverPort = null;
+ }
+ }
}
diff --git a/src/lib/webview_helper/CMakeLists.txt b/src/lib/webview_helper/CMakeLists.txt
index c329181..f1b2374 100644
--- a/src/lib/webview_helper/CMakeLists.txt
+++ b/src/lib/webview_helper/CMakeLists.txt
@@ -19,9 +19,32 @@ FetchContent_MakeAvailable(webview)
add_executable(webview_helper webview_helper.cc cJSON.c)
target_link_libraries(webview_helper PRIVATE webview::core)
-# Platform-specific threading
+# Platform-specific threading and custom URI scheme support
if(UNIX AND NOT APPLE)
target_link_libraries(webview_helper PRIVATE pthread)
+
+ # WebKitGTK headers needed for phpgui:// custom URI scheme (serve_dir)
+ find_package(PkgConfig REQUIRED)
+ pkg_check_modules(WEBKIT2GTK webkit2gtk-4.1)
+ if(NOT WEBKIT2GTK_FOUND)
+ pkg_check_modules(WEBKIT2GTK webkit2gtk-4.0)
+ endif()
+ if(WEBKIT2GTK_FOUND)
+ target_include_directories(webview_helper PRIVATE ${WEBKIT2GTK_INCLUDE_DIRS})
+ endif()
+
+elseif(APPLE)
+ # The serve_dir macOS implementation calls [WKWebView loadFileURL:allowingReadAccessToURL:]
+ # which requires Objective-C++ message syntax. Tell Clang to compile the .cc
+ # file as Objective-C++ without renaming it.
+ set_source_files_properties(webview_helper.cc PROPERTIES
+ COMPILE_OPTIONS "-x;objective-c++"
+ )
+ target_link_libraries(webview_helper PRIVATE
+ "-framework WebKit"
+ "-framework Foundation"
+ "-framework AppKit"
+ )
endif()
# Determine platform and arch for output naming
diff --git a/src/lib/webview_helper/webview_helper.cc b/src/lib/webview_helper/webview_helper.cc
index 357cd59..b7d9717 100644
--- a/src/lib/webview_helper/webview_helper.cc
+++ b/src/lib/webview_helper/webview_helper.cc
@@ -32,6 +32,17 @@ extern "C" {
#include
#endif
+/* Platform-specific includes for custom URI scheme (serve_dir command) */
+#if defined(__linux__)
+#include
+#include
+#elif defined(__APPLE__)
+#include
+#import
+#import
+#import
+#endif
+
/* ── Globals ─────────────────────────────────────────────────────────────── */
static webview_t w = NULL;
@@ -75,6 +86,39 @@ static pthread_mutex_t stdout_mutex = PTHREAD_MUTEX_INITIALIZER;
#define STDOUT_UNLOCK() pthread_mutex_unlock(&stdout_mutex)
#endif
+/* ── File serving (serve_dir) ──────────────────────────────────────────── */
+
+static char g_serve_dir[4096] = {0};
+static int g_scheme_registered = 0; /* guard: register phpgui:// scheme only once */
+
+static const char *get_mime_type(const char *path) {
+ const char *dot = strrchr(path, '.');
+ if (!dot) return "application/octet-stream";
+ if (strcmp(dot, ".html") == 0 || strcmp(dot, ".htm") == 0) return "text/html";
+ if (strcmp(dot, ".js") == 0 || strcmp(dot, ".mjs") == 0) return "application/javascript";
+ if (strcmp(dot, ".css") == 0) return "text/css";
+ if (strcmp(dot, ".json") == 0) return "application/json";
+ if (strcmp(dot, ".png") == 0) return "image/png";
+ if (strcmp(dot, ".jpg") == 0 || strcmp(dot, ".jpeg") == 0) return "image/jpeg";
+ if (strcmp(dot, ".gif") == 0) return "image/gif";
+ if (strcmp(dot, ".svg") == 0) return "image/svg+xml";
+ if (strcmp(dot, ".ico") == 0) return "image/x-icon";
+ if (strcmp(dot, ".webp") == 0) return "image/webp";
+ if (strcmp(dot, ".woff") == 0) return "font/woff";
+ if (strcmp(dot, ".woff2") == 0) return "font/woff2";
+ if (strcmp(dot, ".ttf") == 0) return "font/ttf";
+ if (strcmp(dot, ".otf") == 0) return "font/otf";
+ if (strcmp(dot, ".wasm") == 0) return "application/wasm";
+ if (strcmp(dot, ".mp3") == 0) return "audio/mpeg";
+ if (strcmp(dot, ".mp4") == 0) return "video/mp4";
+ if (strcmp(dot, ".webm") == 0) return "video/webm";
+ if (strcmp(dot, ".ogg") == 0) return "audio/ogg";
+ if (strcmp(dot, ".txt") == 0) return "text/plain";
+ if (strcmp(dot, ".xml") == 0) return "application/xml";
+ if (strcmp(dot, ".pdf") == 0) return "application/pdf";
+ return "application/octet-stream";
+}
+
/* ── JSON output helpers ─────────────────────────────────────────────────── */
static void write_json(cJSON *root) {
@@ -157,6 +201,253 @@ static void on_bound_call(const char *id, const char *req, void *arg) {
cJSON_Delete(root);
}
+/* ── Platform-specific file serving for serve_dir ───────────────────────── */
+
+#if defined(__linux__)
+
+static void on_phpgui_uri_request(WebKitURISchemeRequest *request,
+ gpointer user_data) {
+ (void)user_data;
+ const char *path = webkit_uri_scheme_request_get_path(request);
+
+ /* Default to index.html */
+ if (!path || path[0] == '\0' || strcmp(path, "/") == 0) {
+ path = "/index.html";
+ }
+
+ /* Build absolute file path.
+ * g_serve_dir always ends with '/' (ensured by serve_dir handler).
+ * WebKit always provides path starting with '/'. Skip its leading slash
+ * to avoid double-slash in the concatenated path. */
+ char filepath[4096 + 256];
+ const char *rel = (path[0] == '/') ? path + 1 : path;
+ snprintf(filepath, sizeof(filepath), "%s%s", g_serve_dir, rel);
+
+ /* Resolve symlinks for security check */
+ char *resolved = realpath(filepath, NULL);
+ if (!resolved) {
+ /* SPA fallback: serve index.html for any unknown path (e.g. React Router) */
+ char fallback[4096 + 32];
+ snprintf(fallback, sizeof(fallback), "%sindex.html", g_serve_dir);
+ resolved = realpath(fallback, NULL);
+ if (!resolved) {
+ GError *err = g_error_new_literal(
+ g_io_error_quark(), G_IO_ERROR_NOT_FOUND, "File not found");
+ webkit_uri_scheme_request_finish_error(request, err);
+ g_error_free(err);
+ return;
+ }
+ /* Use index.html for MIME type detection */
+ snprintf(filepath, sizeof(filepath), "%sindex.html", g_serve_dir);
+ }
+
+ /* Security: resolved path must stay inside the serve directory.
+ * g_serve_dir ends with '/', so a prefix match is sufficient — a sibling
+ * directory like /srv/app-evil/ cannot match /srv/app/ at dir_len chars. */
+ size_t dir_len = strlen(g_serve_dir);
+ if (strncmp(resolved, g_serve_dir, dir_len) != 0) {
+ free(resolved);
+ GError *err = g_error_new_literal(
+ g_io_error_quark(), G_IO_ERROR_PERMISSION_DENIED, "Access denied");
+ webkit_uri_scheme_request_finish_error(request, err);
+ g_error_free(err);
+ return;
+ }
+
+ /* Read file via GIO */
+ GFile *gfile = g_file_new_for_path(resolved);
+ free(resolved);
+
+ GError *err = NULL;
+ GFileInputStream *stream = g_file_read(gfile, NULL, &err);
+ g_object_unref(gfile);
+
+ if (err) {
+ webkit_uri_scheme_request_finish_error(request, err);
+ g_error_free(err);
+ return;
+ }
+
+ const char *mime = get_mime_type(filepath);
+ webkit_uri_scheme_request_finish(request, G_INPUT_STREAM(stream), -1, mime);
+ g_object_unref(stream);
+}
+
+static void setup_phpgui_scheme(webview_t wv) {
+ /* Idempotent: WebKitGTK throws a warning if the same scheme is registered
+ * twice on the same WebKitWebContext. Guard against repeated calls (e.g.
+ * if the user calls serveFromDisk() more than once). */
+ if (g_scheme_registered) return;
+
+ void *native = webview_get_native_handle(
+ wv, WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER);
+ WebKitWebView *wk = (WebKitWebView *)native;
+ WebKitWebContext *ctx = webkit_web_view_get_context(wk);
+ webkit_web_context_register_uri_scheme(
+ ctx, "phpgui", on_phpgui_uri_request, NULL, NULL);
+ g_scheme_registered = 1;
+}
+
+#endif /* __linux__ */
+
+#if defined(_WIN32)
+
+/* Returns 1 on success, 0 on failure (emits error event to PHP). */
+static int setup_virtual_host(webview_t wv) {
+ void *native = webview_get_native_handle(
+ wv, WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER);
+ ICoreWebView2Controller *controller =
+ static_cast(native);
+
+ ICoreWebView2 *core = nullptr;
+ if (FAILED(controller->get_CoreWebView2(&core)) || !core) {
+ write_error("serve_dir: failed to obtain ICoreWebView2 interface");
+ return 0;
+ }
+
+ ICoreWebView2_3 *core3 = nullptr;
+ if (FAILED(core->QueryInterface(__uuidof(ICoreWebView2_3),
+ reinterpret_cast(&core3))) ||
+ !core3) {
+ core->Release();
+ /* ICoreWebView2_3 was added in WebView2 SDK 0.9.538 / Runtime 86.0.
+ * Consumer PCs with a very old Edge install may hit this path. */
+ write_error("serve_dir: ICoreWebView2_3 unavailable — "
+ "update the WebView2 Runtime at microsoft.com/edge");
+ return 0;
+ }
+
+ /* Convert UTF-8 path to wide string */
+ wchar_t wpath[MAX_PATH];
+ MultiByteToWideChar(CP_UTF8, 0, g_serve_dir, -1, wpath, MAX_PATH);
+
+ HRESULT hr = core3->SetVirtualHostNameToFolderMapping(
+ L"phpgui.localhost", wpath,
+ COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW);
+
+ core3->Release();
+ core->Release();
+
+ if (FAILED(hr)) {
+ write_error("serve_dir: SetVirtualHostNameToFolderMapping failed");
+ return 0;
+ }
+ return 1;
+}
+
+#endif /* _WIN32 */
+
+#if defined(__APPLE__)
+
+/*
+ * Load a file:// URL using the WKWebView-native API that grants the webview
+ * read access to the entire serve directory, not just the single file.
+ *
+ * webview_navigate() with a file:// URL only grants access to the specific
+ * file being loaded — sibling assets (JS, CSS, images) fail to load.
+ * loadFileURL:allowingReadAccessToURL: grants directory-wide read access,
+ * which is required for any SPA with multiple asset files.
+ */
+static void macos_load_file_url(webview_t wv, const char *dir_path) {
+ void *native = webview_get_native_handle(
+ wv, WEBVIEW_NATIVE_HANDLE_KIND_UI_WIDGET);
+ WKWebView *wkview = (__bridge WKWebView *)native;
+
+ NSString *dir = [NSString stringWithUTF8String:dir_path];
+ NSString *index = [dir stringByAppendingPathComponent:@"index.html"];
+
+ NSURL *fileURL = [NSURL fileURLWithPath:index isDirectory:NO];
+ NSURL *dirURL = [NSURL fileURLWithPath:dir isDirectory:YES];
+
+ [wkview loadFileURL:fileURL allowingReadAccessToURL:dirURL];
+}
+
+/* ── JavaScript dialog delegate ───────────────────────────────────────────
+ *
+ * WKWebView silently drops all JavaScript dialogs (alert, confirm, prompt)
+ * unless a WKUIDelegate is installed. This delegate shows native NSAlert
+ * panels so the behaviour matches WebKitGTK on Linux.
+ *
+ * All three delegate methods are called on the main thread by WebKit, so
+ * calling [NSAlert runModal] (which blocks the main thread) is safe and
+ * gives the correct blocking behaviour that JS dialogs require.
+ */
+@interface PhpGuiUIDelegate : NSObject
+@end
+
+@implementation PhpGuiUIDelegate
+
+/* alert() */
+- (void)webView:(WKWebView *)webView
+ runJavaScriptAlertPanelWithMessage:(NSString *)message
+ initiatedByFrame:(WKFrameInfo *)frame
+ completionHandler:(void (^)(void))completionHandler
+{
+ NSAlert *alert = [[NSAlert alloc] init];
+ alert.messageText = message;
+ alert.alertStyle = NSAlertStyleInformational;
+ [alert addButtonWithTitle:@"OK"];
+ [alert runModal];
+ completionHandler();
+}
+
+/* confirm() — returns true if user clicks OK */
+- (void)webView:(WKWebView *)webView
+ runJavaScriptConfirmPanelWithMessage:(NSString *)message
+ initiatedByFrame:(WKFrameInfo *)frame
+ completionHandler:(void (^)(BOOL result))completionHandler
+{
+ NSAlert *alert = [[NSAlert alloc] init];
+ alert.messageText = message;
+ [alert addButtonWithTitle:@"OK"];
+ [alert addButtonWithTitle:@"Cancel"];
+ completionHandler([alert runModal] == NSAlertFirstButtonReturn);
+}
+
+/* prompt() — returns the input string, or nil if user cancels */
+- (void)webView:(WKWebView *)webView
+ runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt
+ defaultText:(nullable NSString *)defaultText
+ initiatedByFrame:(WKFrameInfo *)frame
+ completionHandler:(void (^)(NSString * _Nullable result))completionHandler
+{
+ NSAlert *alert = [[NSAlert alloc] init];
+ alert.messageText = prompt;
+
+ NSTextField *input = [[NSTextField alloc]
+ initWithFrame:NSMakeRect(0, 0, 220, 24)];
+ input.stringValue = defaultText ?: @"";
+ alert.accessoryView = input;
+
+ [alert addButtonWithTitle:@"OK"];
+ [alert addButtonWithTitle:@"Cancel"];
+ [alert layout];
+ [alert.window makeFirstResponder:input];
+
+ NSModalResponse response = [alert runModal];
+ completionHandler(response == NSAlertFirstButtonReturn
+ ? input.stringValue
+ : nil);
+}
+
+@end
+
+/* Strong reference keeps the delegate alive for the webview's lifetime
+ * (UIDelegate on WKWebView is a weak property). */
+static PhpGuiUIDelegate *g_ui_delegate = nil;
+
+static void setup_ui_delegate(webview_t wv) {
+ void *native = webview_get_native_handle(
+ wv, WEBVIEW_NATIVE_HANDLE_KIND_UI_WIDGET);
+ WKWebView *wkview = (__bridge WKWebView *)native;
+ if (!g_ui_delegate) {
+ g_ui_delegate = [[PhpGuiUIDelegate alloc] init];
+ }
+ wkview.UIDelegate = g_ui_delegate;
+}
+
+#endif /* __APPLE__ */
+
/* ── Command dispatch (runs on main thread via webview_dispatch) ─────────── */
typedef struct {
@@ -259,6 +550,81 @@ static void dispatch_command(webview_t wv, void *arg) {
cJSON_Delete(event_json);
}
+ } else if (strcmp(cmd, "serve_dir") == 0) {
+ const char *path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path"));
+ if (path) {
+ /* Resolve and store the directory path */
+#ifdef _WIN32
+ char resolved[MAX_PATH];
+ if (_fullpath(resolved, path, MAX_PATH)) {
+ strncpy(g_serve_dir, resolved, sizeof(g_serve_dir) - 1);
+ } else {
+ strncpy(g_serve_dir, path, sizeof(g_serve_dir) - 1);
+ }
+#else
+ char *resolved = realpath(path, NULL);
+ if (resolved) {
+ strncpy(g_serve_dir, resolved, sizeof(g_serve_dir) - 1);
+ free(resolved);
+ } else {
+ strncpy(g_serve_dir, path, sizeof(g_serve_dir) - 1);
+ }
+#endif
+ g_serve_dir[sizeof(g_serve_dir) - 1] = '\0';
+
+ /* Normalize: ensure g_serve_dir always ends with '/'.
+ * This makes the path-traversal check in on_phpgui_uri_request
+ * a simple prefix comparison — a sibling dir like /srv/app-evil/
+ * cannot share the prefix /srv/app/ at dir_len chars. */
+ size_t gdlen = strlen(g_serve_dir);
+ if (gdlen > 0 && g_serve_dir[gdlen - 1] != '/' &&
+ gdlen < sizeof(g_serve_dir) - 1) {
+ g_serve_dir[gdlen] = '/';
+ g_serve_dir[gdlen + 1] = '\0';
+ }
+
+ /* Platform-specific setup + navigate.
+ * Each branch is responsible for its own navigation call so that
+ * macOS can use loadFileURL:allowingReadAccessToURL: instead of
+ * webview_navigate(), and Windows can skip navigation on failure. */
+ const char *url = NULL;
+ char url_buf[4096 + 64];
+
+#if defined(__linux__)
+ setup_phpgui_scheme(wv);
+ url = "phpgui://app/index.html";
+ webview_navigate(wv, url);
+
+#elif defined(_WIN32)
+ if (setup_virtual_host(wv)) {
+ url = "https://phpgui.localhost/index.html";
+ webview_navigate(wv, url);
+ }
+ /* On failure, setup_virtual_host() already emitted an error event.
+ * url stays NULL so serve_dir_ready is not emitted. */
+
+#elif defined(__APPLE__)
+ /* loadFileURL:allowingReadAccessToURL: grants read access to the
+ * entire directory, enabling JS/CSS/image assets to load correctly.
+ * webview_navigate("file://...") only grants access to the single
+ * file, causing asset 404s in any multi-file SPA. */
+ macos_load_file_url(wv, g_serve_dir);
+ snprintf(url_buf, sizeof(url_buf),
+ "file://%sindex.html", g_serve_dir);
+ url = url_buf;
+#endif
+
+ if (url) {
+ /* Notify PHP of the effective URL (informational) */
+ cJSON *evt = cJSON_CreateObject();
+ cJSON_AddNumberToObject(evt, "version", 1);
+ cJSON_AddStringToObject(evt, "event", "serve_dir_ready");
+ cJSON_AddStringToObject(evt, "url", url);
+ write_json(evt);
+ cJSON_Delete(evt);
+ }
+ }
+
} else if (strcmp(cmd, "ping") == 0) {
write_event("pong");
@@ -393,6 +759,12 @@ int main(int argc, char *argv[]) {
return 1;
}
+#if defined(__APPLE__)
+ /* Install UI delegate so alert(), confirm(), and prompt() show native
+ * NSAlert panels. WKWebView silences JS dialogs without a delegate. */
+ setup_ui_delegate(w);
+#endif
+
webview_set_title(w, cfg.title);
webview_set_size(w, cfg.width, cfg.height, WEBVIEW_HINT_NONE);
diff --git a/tests/webview/FrontendServingTest.php b/tests/webview/FrontendServingTest.php
new file mode 100644
index 0000000..c6cd60c
--- /dev/null
+++ b/tests/webview/FrontendServingTest.php
@@ -0,0 +1,486 @@
+ 'darwin',
+ 'Windows' => 'windows',
+ default => 'linux',
+ };
+ $arch = php_uname('m');
+ if ($os === 'darwin' && $arch === 'aarch64') $arch = 'arm64';
+ $ext = $os === 'windows' ? '.exe' : '';
+ $path = $libDir . "webview_helper_{$os}_{$arch}{$ext}";
+ return file_exists($path) ? $path : null;
+}
+
+$binary = findBinary();
+if ($binary === null) {
+ echo "[SKIP] WebView helper binary not built.\n";
+ echo " Build: cd src/lib/webview_helper && bash build.sh\n";
+ exit(0);
+}
+
+/* ── Raw-IPC helpers (reused from HelperBinaryTest) ─────────────────────── */
+
+function launchBinary(string $binary, array $args = []): array
+{
+ $cmd = escapeshellarg($binary);
+ foreach ($args as $arg) {
+ $cmd .= ' ' . escapeshellarg($arg);
+ }
+ $proc = proc_open($cmd, [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ], $pipes);
+ if (!is_resource($proc)) {
+ throw new RuntimeException("Failed to launch binary");
+ }
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+ return ['proc' => $proc, 'in' => $pipes[0], 'out' => $pipes[1], 'err' => $pipes[2]];
+}
+
+function readNextEvent(array &$h, float $timeout = 5.0): ?array
+{
+ $buf = '';
+ $start = microtime(true);
+ while (microtime(true) - $start < $timeout) {
+ $chunk = @fread($h['out'], 8192);
+ if ($chunk !== false && $chunk !== '') {
+ $buf .= $chunk;
+ if (($pos = strpos($buf, "\n")) !== false) {
+ $line = substr($buf, 0, $pos);
+ $event = json_decode(trim($line), true);
+ if (is_array($event)) return $event;
+ }
+ }
+ usleep(10000);
+ }
+ return null;
+}
+
+function sendCmd(array &$h, array $cmd): void
+{
+ $cmd['version'] = 1;
+ fwrite($h['in'], json_encode($cmd, JSON_UNESCAPED_SLASHES) . "\n");
+ fflush($h['in']);
+}
+
+function destroyBinary(array &$h): void
+{
+ if (is_resource($h['in'] ?? null)) @fclose($h['in']);
+ if (is_resource($h['out'] ?? null)) @fclose($h['out']);
+ if (is_resource($h['err'] ?? null)) @fclose($h['err']);
+ if (is_resource($h['proc'] ?? null)) {
+ $st = proc_get_status($h['proc']);
+ if ($st['running']) proc_terminate($h['proc']);
+ proc_close($h['proc']);
+ }
+}
+
+/* ── WebView helpers ─────────────────────────────────────────────────────── */
+
+function waitReady(WebView $wv, float $timeout = 5.0): bool
+{
+ $start = microtime(true);
+ while (microtime(true) - $start < $timeout) {
+ $wv->processEvents();
+ if ($wv->isReady()) return true;
+ usleep(20000);
+ }
+ return false;
+}
+
+/**
+ * Poll processEvents() until onServeDirReady fires or timeout.
+ * Returns the URL string or null on timeout.
+ */
+function waitServeDirReady(WebView $wv, float $timeout = 8.0): ?string
+{
+ $receivedUrl = null;
+ $wv->onServeDirReady(function (string $url) use (&$receivedUrl): void {
+ $receivedUrl = $url;
+ });
+
+ $start = microtime(true);
+ while (microtime(true) - $start < $timeout && $receivedUrl === null) {
+ $wv->processEvents();
+ usleep(20000);
+ }
+ return $receivedUrl;
+}
+
+/** Find a free TCP port on loopback. */
+function freePort(): int
+{
+ $sock = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
+ if (!$sock) throw new RuntimeException("No free port: {$errstr}");
+ $name = stream_socket_get_name($sock, false);
+ $port = (int)substr($name, strrpos($name, ':') + 1);
+ fclose($sock);
+ return $port;
+}
+
+/** Call a private method on an object via Reflection. */
+function callPrivate(object $obj, string $method, array $args = []): mixed
+{
+ $ref = new ReflectionMethod($obj, $method);
+ $ref->setAccessible(true);
+ return $ref->invokeArgs($obj, $args);
+}
+
+/** Read a private property via Reflection. */
+function readPrivate(object $obj, string $prop): mixed
+{
+ $ref = new ReflectionProperty($obj, $prop);
+ $ref->setAccessible(true);
+ return $ref->getValue($obj);
+}
+
+/* ── Test fixtures ───────────────────────────────────────────────────────── */
+
+// A real directory with an index.html we can use for serve_dir tests.
+$fixtureDir = realpath(__DIR__ . '/../../webview_test_app');
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ SUITE 1: Binary-level serve_dir → serve_dir_ready IPC round-trip
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+TestRunner::suite('FrontendServingTest — Binary IPC: serve_dir');
+
+$h = launchBinary($binary, ['--title', 'ServeDir Test', '--width', '400', '--height', '300']);
+
+$ev = readNextEvent($h, 5.0);
+TestRunner::assertEqual('ready', $ev['event'] ?? '', 'Binary emits ready event');
+
+// Send serve_dir command
+sendCmd($h, ['cmd' => 'serve_dir', 'path' => $fixtureDir]);
+
+// Expect serve_dir_ready
+$ev = readNextEvent($h, 8.0);
+TestRunner::assertEqual('serve_dir_ready', $ev['event'] ?? '', 'serve_dir_ready event received');
+TestRunner::assert(isset($ev['url']) && $ev['url'] !== '', 'serve_dir_ready includes non-empty url');
+
+// Verify platform URL scheme
+$expectedScheme = match (PHP_OS_FAMILY) {
+ 'Darwin' => 'file://',
+ 'Windows' => 'https://phpgui.localhost',
+ default => 'phpgui://',
+};
+TestRunner::assert(
+ str_starts_with($ev['url'], $expectedScheme),
+ "serve_dir_ready URL uses platform scheme ({$expectedScheme}): {$ev['url']}"
+);
+
+// Binary stays alive after serve_dir
+sendCmd($h, ['cmd' => 'ping']);
+$ev = readNextEvent($h, 3.0);
+TestRunner::assertEqual('pong', $ev['event'] ?? '', 'Binary still alive after serve_dir');
+
+sendCmd($h, ['cmd' => 'destroy']);
+readNextEvent($h, 3.0);
+destroyBinary($h);
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ SUITE 2: serveFromDisk() — input validation
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+TestRunner::suite('FrontendServingTest — serveFromDisk() validation');
+
+$wv = new WebView(['title' => 'Validation Test', 'width' => 400, 'height' => 300]);
+waitReady($wv, 5.0);
+
+// Non-existent path
+TestRunner::assertThrows(
+ fn() => $wv->serveFromDisk('/path/that/does/not/exist/at/all'),
+ RuntimeException::class,
+ 'serveFromDisk() throws on non-existent directory'
+);
+
+// Directory with no index.html
+$noIndex = sys_get_temp_dir() . '/phpgui_test_noindex_' . uniqid();
+mkdir($noIndex, 0777, true);
+TestRunner::assertThrows(
+ fn() => $wv->serveFromDisk($noIndex),
+ RuntimeException::class,
+ 'serveFromDisk() throws when directory has no index.html'
+);
+rmdir($noIndex);
+
+// Valid directory
+TestRunner::assertNoThrow(
+ fn() => $wv->serveFromDisk($fixtureDir),
+ 'serveFromDisk() does not throw for valid directory with index.html'
+);
+
+$wv->destroy();
+usleep(300000);
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ SUITE 3: onServeDirReady() callback
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+TestRunner::suite('FrontendServingTest — onServeDirReady() callback');
+
+$wv = new WebView(['title' => 'ServeDirReady Test', 'width' => 400, 'height' => 300]);
+waitReady($wv, 5.0);
+
+// Register callback, then call serveFromDisk
+$wv->serveFromDisk($fixtureDir);
+$url = waitServeDirReady($wv, 8.0);
+
+TestRunner::assert($url !== null, 'onServeDirReady() callback fires after serveFromDisk()');
+TestRunner::assert(is_string($url) && $url !== '', 'Callback receives non-empty URL string');
+
+// Verify scheme again via the PHP layer
+$expectedScheme = match (PHP_OS_FAMILY) {
+ 'Darwin' => 'file://',
+ 'Windows' => 'https://phpgui.localhost',
+ default => 'phpgui://',
+};
+TestRunner::assert(
+ str_starts_with($url, $expectedScheme),
+ "PHP callback URL uses platform scheme ({$expectedScheme}): {$url}"
+);
+
+$wv->destroy();
+usleep(300000);
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ SUITE 4: serveVite() — dev / prod mode detection
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+TestRunner::suite('FrontendServingTest — serveVite() mode detection');
+
+// ── Dev mode: a TCP listener is running on the target port ────────────────
+// Hold the port open (bind it but do not accept connections — serveVite only
+// probes with fsockopen, which succeeds as soon as the port is bound).
+$devPort = freePort();
+$devSock = stream_socket_server("tcp://127.0.0.1:{$devPort}", $errno, $errstr);
+TestRunner::assert((bool)$devSock, "Dev-mode fixture server bound on port {$devPort}");
+
+$wvDev = new WebView(['title' => 'Vite Dev Test', 'width' => 400, 'height' => 300]);
+waitReady($wvDev, 5.0);
+
+$devServeDirFired = false;
+$wvDev->onServeDirReady(function () use (&$devServeDirFired): void {
+ $devServeDirFired = true;
+});
+
+// serveVite() should detect the listening port and call navigate(), NOT serveFromDisk()
+TestRunner::assertNoThrow(
+ fn() => $wvDev->serveVite($fixtureDir, "http://127.0.0.1:{$devPort}", 1.0),
+ 'serveVite() does not throw in dev mode'
+);
+
+// Drain events briefly — serve_dir_ready should NOT fire (navigate was used, not serveFromDisk)
+$deadline = microtime(true) + 1.0;
+while (microtime(true) < $deadline) {
+ $wvDev->processEvents();
+ usleep(20000);
+}
+TestRunner::assert(!$devServeDirFired, 'serveVite() dev mode: onServeDirReady does NOT fire (navigate used)');
+
+if ($devSock) fclose($devSock);
+$wvDev->destroy();
+usleep(300000);
+
+// ── Prod mode: nothing is listening on the port ───────────────────────────
+$unusedPort = freePort();
+// Don't bind it — port is free and nothing is listening
+
+$wvProd = new WebView(['title' => 'Vite Prod Test', 'width' => 400, 'height' => 300]);
+waitReady($wvProd, 5.0);
+
+$wvProd->serveFromDisk($fixtureDir); // prime with a serve so we can check the fallback
+$wvProd->destroy();
+usleep(300000);
+
+// Fresh instance for clean prod-mode test
+$wvProd = new WebView(['title' => 'Vite Prod Test 2', 'width' => 400, 'height' => 300]);
+waitReady($wvProd, 5.0);
+
+TestRunner::assertNoThrow(
+ fn() => $wvProd->serveVite($fixtureDir, "http://127.0.0.1:{$unusedPort}", 0.3),
+ 'serveVite() does not throw in prod mode (no server running)'
+);
+
+$prodUrl = waitServeDirReady($wvProd, 8.0);
+TestRunner::assert($prodUrl !== null, 'serveVite() prod mode: onServeDirReady fires (serveFromDisk called)');
+TestRunner::assert(
+ str_starts_with($prodUrl, $expectedScheme),
+ "serveVite() prod mode URL uses platform scheme: {$prodUrl}"
+);
+
+$wvProd->destroy();
+usleep(300000);
+
+// ── Prod mode with invalid build dir throws ───────────────────────────────
+$wvErr = new WebView(['title' => 'Vite Error Test', 'width' => 400, 'height' => 300]);
+waitReady($wvErr, 5.0);
+
+TestRunner::assertThrows(
+ fn() => $wvErr->serveVite('/no/such/dist', "http://127.0.0.1:{$unusedPort}", 0.1),
+ RuntimeException::class,
+ 'serveVite() throws in prod mode when build dir does not exist'
+);
+
+$wvErr->destroy();
+usleep(300000);
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ SUITE 5: enableFetchProxy() — registration and PHP HTTP client
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+TestRunner::suite('FrontendServingTest — enableFetchProxy()');
+
+$wvProxy = new WebView(['title' => 'Proxy Test', 'width' => 400, 'height' => 300]);
+waitReady($wvProxy, 5.0);
+
+// enableFetchProxy() must not throw
+TestRunner::assertNoThrow(
+ fn() => $wvProxy->enableFetchProxy(),
+ 'enableFetchProxy() does not throw'
+);
+
+// __phpFetch handler must be registered in commandHandlers
+$handlers = readPrivate($wvProxy, 'commandHandlers');
+TestRunner::assert(
+ isset($handlers['__phpFetch']) && is_callable($handlers['__phpFetch']),
+ '__phpFetch handler is registered in commandHandlers'
+);
+
+// ── PHP HTTP client: curlRequest ─────────────────────────────────────────
+// Start a minimal local HTTP server via PHP built-in server.
+$servePort = freePort();
+$serveDir = sys_get_temp_dir() . '/phpgui_proxy_test_' . uniqid();
+mkdir($serveDir, 0777, true);
+file_put_contents($serveDir . '/index.php', ' $_SERVER["REQUEST_METHOD"], "ok" => true]);
+');
+
+$serverProc = proc_open(
+ PHP_BINARY . ' -S 127.0.0.1:' . $servePort . ' -t ' . escapeshellarg($serveDir),
+ [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
+ $serverPipes
+);
+fclose($serverPipes[0]);
+fclose($serverPipes[1]);
+fclose($serverPipes[2]);
+
+// Wait for server to start
+$deadline = microtime(true) + 3.0;
+while (microtime(true) < $deadline) {
+ $fp = @fsockopen('127.0.0.1', $servePort, $errno, $errstr, 0.1);
+ if ($fp) { fclose($fp); break; }
+ usleep(50000);
+}
+
+// Test curlRequest (if curl available)
+if (function_exists('curl_init')) {
+ [$status, $headers, $body] = callPrivate($wvProxy, 'curlRequest',
+ ["http://127.0.0.1:{$servePort}/", 'GET', [], null]);
+
+ TestRunner::assertEqual(200, $status, 'curlRequest: status 200 from local server');
+ TestRunner::assert(!empty($body), 'curlRequest: non-empty response body');
+
+ $json = json_decode($body, true);
+ TestRunner::assertEqual(true, $json['ok'] ?? false, 'curlRequest: response body parses to expected JSON');
+ TestRunner::assertEqual('GET', $json['method'] ?? '', 'curlRequest: server sees correct HTTP method');
+
+ // Verify header parsing
+ TestRunner::assert(
+ isset($headers['X-Test-Header']) && $headers['X-Test-Header'] === 'phpgui',
+ 'curlRequest: custom response header is parsed correctly'
+ );
+
+ // POST with body
+ [$statusPost] = callPrivate($wvProxy, 'curlRequest',
+ ["http://127.0.0.1:{$servePort}/", 'POST', ['Content-Type' => 'application/json'], '{"test":1}']);
+ TestRunner::assertEqual(200, $statusPost, 'curlRequest: POST request returns 200');
+} else {
+ echo " [SKIP] curlRequest tests (ext-curl not available)\n";
+}
+
+// Test streamRequest (always available)
+[$statusStream, $headersStream, $bodyStream] = callPrivate($wvProxy, 'streamRequest',
+ ["http://127.0.0.1:{$servePort}/", 'GET', [], null]);
+
+TestRunner::assertEqual(200, $statusStream, 'streamRequest: status 200 from local server');
+TestRunner::assert(!empty($bodyStream), 'streamRequest: non-empty response body');
+
+$jsonStream = json_decode($bodyStream, true);
+TestRunner::assertEqual(true, $jsonStream['ok'] ?? false, 'streamRequest: response body parses to expected JSON');
+
+// Cleanup server
+proc_terminate($serverProc);
+proc_close($serverProc);
+array_map('unlink', glob($serveDir . '/*'));
+rmdir($serveDir);
+
+// ── base64 encoding roundtrip ─────────────────────────────────────────────
+$sampleBody = "binary\x00data\xFF\xFEwith nulls";
+$encoded = base64_encode($sampleBody);
+$decoded = base64_decode($encoded);
+TestRunner::assertEqual($sampleBody, $decoded, 'base64 roundtrip preserves binary response body');
+
+// ── Calling enableFetchProxy() twice is safe ──────────────────────────────
+TestRunner::assertNoThrow(
+ fn() => $wvProxy->enableFetchProxy(),
+ 'enableFetchProxy() can be called multiple times without error'
+);
+
+$wvProxy->destroy();
+usleep(300000);
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ SUITE 6: serveDirectory() smoke-test (existing HTTP server path, no regression)
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+TestRunner::suite('FrontendServingTest — serveDirectory() regression');
+
+$wvHttp = new WebView(['title' => 'HTTP Server Test', 'width' => 400, 'height' => 300]);
+waitReady($wvHttp, 5.0);
+
+TestRunner::assertNoThrow(
+ fn() => $wvHttp->serveDirectory($fixtureDir),
+ 'serveDirectory() starts local PHP server without error'
+);
+TestRunner::assert($wvHttp->getServerPort() !== null, 'serveDirectory() assigns a server port');
+TestRunner::assert($wvHttp->getServerPort() > 0, 'serveDirectory() port is a valid port number');
+
+$wvHttp->destroy();
+usleep(500000);
+
+/* ── Summary ──────────────────────────────────────────────────────────────── */
+
+TestRunner::summary();