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();